Module 3 · Lesson 4 · ~25 min read
Every Go function that does I/O takes a context.Context as its first parameter. It carries cancellation signals, deadlines, and request-scoped values across API boundaries. For Canton work this is non-negotiable — every gRPC call carries one.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
That's it. Four methods. Almost everything you do with context is via the helper functions in the package, not the methods directly.
ctx := context.Background() // the root — main, init, top-level
ctx := context.TODO() // "I haven't decided what context to pass yet"
// Derived contexts:
ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithTimeout(parent, 5 * time.Second)
ctx, cancel := context.WithDeadline(parent, t)
ctx := context.WithValue(parent, key, val)
Always start from a Background at the root of your program (e.g. in main or per request in an HTTP handler — though most handler frameworks already provide one). Derive child contexts as you go down the call stack.
Always call cancel, even on the timeout/deadline variants. The cancel function releases resources held by the context (and any goroutine waiting on its Done channel). Convention: defer cancel() on the next line.
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
func work(ctx context.Context) error {
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return ctx.Err() // Canceled or DeadlineExceeded
case <-time.After(100 * time.Millisecond):
step(i)
}
}
return nil
}
Two patterns:
select on ctx.Done() alongside your main work. If Done fires, exit early with ctx.Err().ctx through. Each call site checks for itself.// Correct:
func SubmitCommand(ctx context.Context, cmd Command) error { ... }
// Wrong (don't store ctx in a struct):
type Client struct {
ctx context.Context
}
Contexts are request-scoped. They flow through call stacks, not through long-lived objects. A struct holding a context turns into a struct holding a stale context — when the original request finishes and that context is canceled, your struct is now broken in subtle ways.
Every context derived from a parent inherits its cancellation. Cancel the parent and every descendant is canceled too. Done channels fire bottom-up.
parent := context.Background()
withTO, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
derived, _ := context.WithCancel(withTO)
// derived.Done() fires when:
// - withTO times out (10s elapse), OR
// - cancel() is called on withTO, OR
// - the cancel from the WithCancel(withTO) line is called
This is the killer feature: a single cancellation at the top of a request tears down the entire dependency tree. No need to thread "should I stop?" booleans through every layer.
WithTimeout(parent, dur) — relative ("from now, give me 5 seconds").WithDeadline(parent, t) — absolute ("be done by 14:00:30").Use deadline when an external SLO sets the absolute time (an HTTP request must complete by request_received_at + 30s). Use timeout for "this operation should not take longer than X."
type requestIDKey struct{} // unexported type — collision-free key
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
WithValue attaches request-scoped data — typically a request ID, a trace span, an authenticated user ID, structured-logger context. Three rules:
Every Ledger API gRPC call takes a ctx as its first argument. Cancel the context = the gRPC call is canceled, the server-side stream tears down, all in-flight work for that request stops.
// Pseudo-code for a Canton client call
func (c *LedgerClient) SubmitAndWait(ctx context.Context, cmd Command) (*Completion, error) {
resp, err := c.grpc.SubmitAndWait(ctx, &grpcSubmit{...})
if err != nil { return nil, fmt.Errorf("submit: %w", err) }
return parseCompletion(resp), nil
}
For streaming RPCs (transaction stream, completion stream), the context controls the entire stream's lifetime. Cancel the context, the stream closes. This is how production indexers shut down cleanly when their host process gets a SIGTERM.
Forget to call cancel. govet with the lostcancel checker catches this. Always defer cancel().
Pass nil as a context. Use context.TODO() as a placeholder if you genuinely don't have one yet. nil contexts will panic the first time you call a method on them.
Store contexts in structs. Pass through call stacks. The exception is goroutine-local helpers that genuinely need to outlive their creator's stack frame — and even then, prefer to recreate the context.
Use Value for arguments. If your function needs a UserID, take a UserID parameter — don't hide it in ctx.Value(userIDKey{}). Context values are for infrastructure, not business data.
ctx context.Context as the first parameter.WithCancel, WithTimeout, WithDeadline, WithValue). Always defer the cancel.select on ctx.Done() in any long-running loop or blocking operation; return ctx.Err() on cancellation.