Module 3 · Lesson 4 · ~25 min read

Context — Cancellation, Deadlines, and Request-Scoped Values

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.

The interface

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.

Where contexts come from

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 cancel

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()

How to consume one

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:

The convention — first parameter, named ctx

// 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.

Cancellation propagates

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.

Deadline vs timeout

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."

Values — for cross-cutting context only

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:

  1. Use unexported key types (often empty structs) so other packages can't accidentally collide.
  2. Don't use it for function arguments. If a function needs a value, take it as a parameter. Context values are for cross-cutting infrastructure (logging, tracing, auth).
  3. Type-assert with comma-ok. Values can be of any type; if they're not what you expect, you want to know without panicking.

How Canton uses context — what to expect

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.

Common mistakes

Don't

Forget to call cancel. govet with the lostcancel checker catches this. Always defer cancel().

Don't

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.

Don't

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.

Don't

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.

Takeaways