Skip to content

proposal: context: add API for performing tasks after Context is Done #19643

@zombiezen

Description

@zombiezen

Summary

A common need when writing server code is performing a task that intentionally goes past the deadline of the request while preserving the request's context values (e.g. trace ID). These tasks may block a request handler from returning (synchronous) or may need to happen in the background, past the return of a request handler (asynchronous). Creating a Context that obtains values from a parent context correctly is difficult in the asynchronous case because:

  1. Context keys cannot be iterated over. This is intentional.
  2. Even if you could iterate over Context keys, there's no indication of which values should be preserved. For example, the trace ID of the original request should be preserved, but the background task should create a new span with its lifetime. However, any value that's tied to the lifetime of the request should not be present in the background task's Context.
  3. As we've found when forming this API, there's quite a few ways to get the concurrency wrong. Ensuring the parent and child have cancelation and wait channels piped through requires careful thought.

I would like to add these functions to the context package (but happy to start with x/net/context):

package context

// IgnoreCancel returns a new Context which takes its values from parent but
// ignores any cancelation or deadline on parent.
//
// IgnoreCancel must only be used synchronously.  To detach a Context for use in
// an asynchronous API, use Detach instead.
func IgnoreCancel(parent Context) Context

// A PreserveFunc takes a value stored in a parent Context and returns the
// corresponding value to be stored in a Context derived from it.
//
// If the PreserveFunc returns a nil interface{}, the detached Context
// will not contain any value for the key.
//
// If the detached value is non-nil, the PreserveFunc may return nil or a "close"
// function to destroy or clean up the value, which will be called exactly once
// when the value is no longer in use.
type PreserveFunc func(interface{}) (detached interface{}, close func())

// RegisterPreserveFunc associates a Context key with a PreserveFunc to be used to
// populate it.  RegisterPreserveFunc must only be called during package initialization.
func RegisterPreserveFunc(key interface{}, f PreserveFunc)

// Detach starts a new goroutine that calls f with a new Context that derives values
// from parent.
//
// The values in the new Context are obtained by calling the PreserveFunc for
// each registered key.  When f returns, the detached values will be "closed"
// using the function returned by the PreserveFunc. Keys without a PreserveFunc
// are not associated with any value in the new Context.  If the PreserveFunc
// for a key returns a nil value, the new Context will not have a value for that
// key.
//
// Detach returns a DetachedTask that can be used to cancel and/or wait for the
// function to return.  The new Context will only be Done if DetachedTask is
// canceled; the parent's cancelation or deadline is ignored.  Most users can
// safely ignore the DetachedTask.
//
// Detach must not be called until all packages have been initialized.
func Detach(parent Context, f func(ctx Context)) DetachedTask

// A DetachedTask describes the status of a detached function call
// started by calling Detach.
type DetachedTask struct {
	// ...
}

// Cancel cancels the Context passed to the function.
// After the first call, subsequent calls to Cancel do nothing.
func (t DetachedTask) Cancel()

// Finished returns a channel that is closed when the function has returned and
// the close functions for its Context have completed.
func (t DetachedTask) Finished() <-chan struct{}

/cc @bcmills @rakyll

Use Cases

Making another RPC to clean up a resource (like rolling back a transaction) after receiving a cancel. This would likely be a synchronous action (an IgnoreCancel usage).

func handler(ctx context.Context) {
  // do something...
  cleanupCtx, cancel := context.WithTimeout(context.IgnoreCancel(ctx), 5 * time.Second)
  Cleanup(cleanupCtx)
  cancel()
}

Wanting to log or update a counter after servicing a request but not wanting to block the response. This would be an asynchronous action (a Detach() usage).

func handler(ctx context.Context) {
  context.Detach(ctx, func(ctx context.Context) {
    if err := SendNotification(ctx); err != nil {
      log.Infof(ctx, "couldn't send background notification: %v", err)
    }
  })

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions