detached callback context

situation

  • backend services use async-local context to thread database access, logging, operation identity, correlation traces across service boundaries — no parameter drilling.
  • normal request path works: middleware establishes context, downstream services call useContext().
  • managed-process step runtime invokes step callbacks from its own event loop, outside the parent function's await chain.
  • a step body calling readWorkflow(id) fails with "useContext() called outside async local scope" — the callback was spawned in a different execution context.
  • global fallbacks (database without correlation) hide the problem and lose trace identity.
  • threading every dependency through step signatures spreads platform concerns into domain code.
  • failure mode: entering a detached callback without re-entering the boundary loses operation visibility and fails strict context access.

design

  • platform boundary establishes strict async local context; useContext fails if absent.
  • a small wrapper re-enters that context for each detached step callback, adds process and step trace identity.
  • wrapper is also the single place recording durable step status and publishing structured events.
  • recording stays fail-open: observability cannot change the result of a successfully executed step.

decision process

first assumption — "context created at process start will be visible in every step" — fails: the managed runtime invokes a callback from its own event source. three reactions:

optionresultwhy
add global fallbacks for database and logger.rejected.it hides the boundary error and loses operation correlation.
thread every dependency through step functions.rejected.it spreads platform concerns through domain signatures and is easy to omit on a new path.
re-enter the existing context at the detached callback.chosen.the platform-specific fix stays at one boundary; lower services retain strict useContext() access.
  • the wrapper is where durable step identity is known — the right place to stamp traces and attempt records, not every step body.

why

  • the detached callback is the one handoff where context inheritance silently breaks: the runtime invokes it from its own event source, so strict context access throws exactly there and nowhere else.
  • tempting escapes make things worse:
    • global fallback hides the boundary error but strips every query and log of correlation.
    • threading dependencies through step signatures spreads platform concerns into domain code a new path can forget.
  • rebinding at the one wrapper that knows the handoff exists keeps the failure explicit and testable: infrastructure dependencies stop widening every service signature, and a step cannot silently run with a global database or an uncorrelated logger.

transferable rule

  • use execution context to remove infrastructure prop drilling, but enumerate every asynchronous handoff.
  • rebind only at the handoff boundary; keep lower layers unaware of the framework that required it.

verification reasoning

  • normal boundary-to-service call must resolve context.
  • detached callback must resolve the same request/trace fields after rebinding.
  • force recording failure: business step still completes, diagnostic emitted.
  • verifies strict context ownership and the explicit fail-open observability boundary.

good and bad

bad

// in the step runtime's event loop
const stepRuntime = managedStepProvider.createStep("write_data");
step.do("write_data", async () => {
// this callback runs in the runtime's context, not the request's context
const workflow = await readWorkflowRun(workflowRunId);
// ERROR: useContext() called outside async local scope
// because readWorkflowRun -> useContext() -> db access fails on boundary check
return workflow.output;
});
  • callback runs detached, useContext() throws, step fails.
  • only workarounds: global fallbacks (no correlation) or parameter threading (platform concerns in domain code).

good

// a wrapper binds execution context before the step body
const stepRuntime = managedStepProvider.createStep("write_data");
const contextBound = createContextBoundStep(context).do("write_data", async () => {
// the wrapper re-enters the request-local context
// now readWorkflowRun -> useContext() -> db access succeeds with correlation
const workflow = await readWorkflowRun(workflowRunId);
return workflow.output;
});
// the wrapper also records durable step status and publishes structured events
// fail-open: if recording fails, the step still completes, diagnostic emitted
  • the wrapper is the only place that knows it is invoked outside the normal chain: re-enters the context once, stamps traces and attempt records, keeps lower services unaware they run detached.

search pages

go to any page