Cross-Process
Carry the context across queue, durable and ace boundaries with serialize() / deserialize(), bind(), and W3C baggage — the hard part that justifies the library.
This is the page that justifies the library. Reading the current user inside a single request is convenient; carrying that context across a process or queue boundary — so the @adonisjs/queue worker that processes a job knows who enqueued it, and the durable workflow step knows which tenant it belongs to — is the genuinely hard part. @adonis-agora/context solves it with two methods: serialize() and deserialize().
How much you wire depends on the boundary. Across a raw queue (a third-party boundary) you call those two methods yourself — two lines. Across @adonis-agora/durable (a first-party Agora boundary) the context propagates automatically and you wire nothing. Both are covered below.
Why ALS does not cross boundaries
AsyncLocalStorage propagates a store along the async execution tree within a single process. The moment you cross out of that tree — you push a job onto a Redis-backed queue, you dispatch a remote durable task, you hand work to a sub-process — the ALS store is gone. The worker on the other side starts with an empty context. There is no magic that carries it; the store lives in process memory, and that memory does not travel.
So to carry the context across a boundary, you have to explicitly extract a serializable snapshot on one side and re-hydrate it on the other. That is exactly what serialize / deserialize are for.
serialize() — a flat carrier
Context.serialize() takes the active store and produces a ContextCarrier: a plain, JSON-safe object carrying only what is needed to re-hydrate on the other side.
interface ContextCarrier {
traceId: string
tenantId?: string
userRef?: UserRef
}
const carrier = Context.serialize()
// { traceId: '4bf9…4736', tenantId: 't1', userRef: { type: 'user', id: 42 } }Note what is not there: no hydrated user model, no database connection, no loaded relations, no traceparent parse (that field is process-local). Just the refs. This is precisely why the store carries a userRef rather than a full user (see The Store) — a { type, id } pair crosses a boundary trivially; a Lucid model does not.
Context.serialize() returns undefined when called outside any context. Which fields it includes is configurable via the carrier option or a full serialize override — see Customization → cross-process carrier.
deserialize() — re-enter the store
On the other side of the boundary, Context.deserialize(carrier, fn) rebuilds a store from the carrier and runs fn inside it. It is the cross-process cousin of Context.run:
Context.deserialize(carrier, () => {
// inside here, Context.traceId() / tenantId() / userRef() are restored
return doTheWork()
})Everything fn calls — synchronously or across awaits within the callback — sees the re-hydrated context, just as if it were running inside the original request.
The @adonisjs/queue pattern
A queue is a third-party boundary: @adonisjs/queue doesn't know about @adonis-agora/context, so there's no @adonis-agora/context-queue bridge package to install and nothing propagates automatically. Instead you wire the integration yourself — and it really is just two lines: one where you dispatch, one where you consume. The carrier travels as an ordinary field on the job payload.
Here is the whole thing, end to end and copy-pasteable.
On dispatch, snapshot the active context onto the job payload:
import queue from '@adonisjs/queue/services/main'
import { Context } from '@adonis-agora/context'
interface InvoicePayload {
invoiceId: number
email: string
}
export default class InvoiceService {
async requestInvoice(payload: InvoicePayload) {
// Runs inside an HTTP request → there is an active context here.
await queue.dispatch('send-invoice', {
...payload,
__ctx: Context.serialize(), // snapshot who/which tenant/which trace onto the job
})
}
}In the job handler, re-hydrate the carrier before running the real work:
import { Context, type ContextCarrier } from '@adonis-agora/context'
interface InvoicePayload {
invoiceId: number
email: string
}
export default class SendInvoiceJob {
// The worker is a fresh process: no active context until we restore one.
async handle(payload: InvoicePayload & { __ctx?: ContextCarrier }) {
return Context.deserialize(payload.__ctx, () => this.send(payload))
}
// Unchanged business logic — it reads context exactly as it would in a controller.
private async send(payload: InvoicePayload) {
// Context.userRef() → the principal who enqueued the job
// Context.tenantId() → their tenant
// Context.traceId() → ties this worker's logs back to the originating request
// ... resolve the invoice, render it, send the email ...
}
}That's the entire integration: Context.serialize() on the producer, Context.deserialize(payload.__ctx, …) on the consumer. Inside send(payload) the principal, tenant and trace id are those of the enqueuer — your handler code does not change at all, it reads the context exactly as it would inside an HTTP request.
The __ctx key is just a convention for "this is the serialized context" — name it whatever you like, as long as the dispatch and consume sides agree. If a job arrives without a carrier (an external producer, an older job), Context.deserialize still works: see the trace-id safety net below.
ace commands and other entrypoints
A cron-triggered ace command has no HTTP request, so no context is established for it. Wrap the body in Context.run to give it one — useful so the trace id ties together everything the command does (including jobs it dispatches):
import { BaseCommand } from '@adonisjs/core/ace'
import { Context, randomTraceId } from '@adonis-agora/context'
export default class Reconcile extends BaseCommand {
static commandName = 'reconcile'
async run() {
await Context.run(
{ traceId: randomTraceId(), userRef: { type: 'system', id: 'cron' } },
() => this.reconcileAll(),
)
}
}Context.run is the right tool here (not enterWith): you own a clean callback boundary, so the context is established for the duration of the command and torn down cleanly when it finishes.
bind() — escaping ALS's blind spots
ALS follows the async tree, but a few constructs break the chain: callbacks registered now but invoked later outside the originating async context — setTimeout/setInterval, EventEmitter listeners, and some job callbacks. Context.bind(fn) snapshots whatever context is active at bind time and re-enters it every time the wrapped function later runs:
import { Context } from '@adonis-agora/context'
emitter.on('done', Context.bind(() => log(Context.traceId())))
setTimeout(Context.bind(handler), 1000)Arguments, this, and the return value all pass through unchanged. If nothing is active at bind time, bind returns the original fn untouched (so the bound function simply runs with no active store).
@adonis-agora/durable propagates context automatically
The queue pattern above is manual because a raw queue is a third-party boundary. @adonis-agora/durable is different: it is a first-party Agora library, and it propagates the context across the remote-step boundary for you. You write zero serialize/deserialize calls.
When a workflow runs a remote step with ctx.call(...), durable snapshots the active Agora context onto the RemoteTask as it dispatches, ships it across the transport to wherever the handler lives — another Node process, or even a Python worker — and restores it (via the scoped @agora/context:scope slot) before invoking the handler.
A remote step is two decoupled pieces that live in different processes: the typed definition (remoteStep(...), imported wherever the workflow is authored) and the handler (registered separately on the worker-side transport). First the definition:
import { remoteStep } from '@adonis-agora/durable'
import { z } from 'zod'
export const chargeCard = remoteStep({
name: 'payments.charge-card',
group: 'payments',
input: z.object({ orderId: z.string(), amountCents: z.number().int() }),
output: z.object({ chargeId: z.string() }),
})
// in the workflow body: const charge = await ctx.call(chargeCard, { orderId, amountCents })The handler is registered separately, on the worker-side transport — a different process, which is exactly why durable has to restore the context there for you:
import { Context } from '@adonis-agora/context'
transport.handle('payments.charge-card', async (input: { orderId: string; amountCents: number }) => {
// No deserialize() here — durable already restored the originating request's context.
Context.userRef() // the user who started the workflow
Context.tenantId() // their tenant
Context.traceId() // the originating request's trace id
return { chargeId: await charge(input) }
})The handler sees the originating request's userRef, tenantId and traceId even though it runs in a different process. It knows not just which trace it belongs to but who and which tenant it is acting for — with no plumbing on your side.
Durable: zero lines. A raw queue: two lines.
With @adonis-agora/durable you wire nothing — ctx.call carries and restores the context across the boundary on its own. With a raw @adonisjs/queue you wire two lines: Context.serialize() on dispatch and Context.deserialize(...) in the handler. Same snapshot semantics either way (the carrier is dispatch-time history); the only difference is who does the wiring.
How durable carries it: the traceparent hook
Under the hood the trace id rides the W3C traceparent hook that durable already exposes rather than a bespoke channel. If you construct the engine yourself, feed it a function that turns the current trace id into a traceparent header:
import { Context, toTraceparent } from '@adonis-agora/context'
const engine = new WorkflowEngine({
// `store` is durable's own required dependency — whatever state store you
// already pass. Only the `traceparent` hook below concerns this package.
store,
traceparent: () => {
const traceId = Context.traceId()
// No active context (a run started outside a request) — no header to emit.
if (!traceId) return undefined
return toTraceparent(traceId, Context.get()?.traceparent)
},
})toTraceparent wraps the trace id into a 00-<traceId>-<spanId>-01 header (and, given the captured upstream parse, faithfully continues the incoming trace). Alongside it, the RemoteTask carries the tenant and userRef snapshot, which durable re-hydrates by entering the scoped @agora/context:scope slot before running the handler — which is why the handler above can read all three fields without touching deserialize. See the durable docs for the full workflow story.
W3C baggage — the standards-compliant option
Alongside the bespoke ContextCarrier, the context can ride a real W3C baggage header that any baggage-aware peer (OpenTelemetry SDKs, gateways) understands. This is opt-in and independent of the carrier path.
import { Context } from '@adonis-agora/context'
// Build a baggage header from the active context (maps tenantId + userRef):
const header = Context.toBaggage() // 'tenantId=t1,userRef=user%3A42' | undefined
// On the other side, re-hydrate from an inbound baggage header and run inside it.
// Baggage carries no trace-id, so it's seeded from `traceparent` (when supplied
// and valid) else a fresh random id — mirroring the middleware:
Context.fromBaggage(req.headers.baggage, () => doTheWork(), {
traceparent: req.headers.traceparent,
})By default toBaggage maps tenantId and userRef (the ref encoded as a compact type:id token). fromBaggage is tolerant of a malformed or absent header — it just yields a context with those fields unset. Tune the keys, or disable a field entirely, via the baggage config option — see Customization → cross-process carrier.
The low-level codecs
toBaggage / fromBaggage are the ergonomic front doors, but the four pure functions they are built on are exported too, for when you need to read or write a baggage header outside the Context lifecycle — a gateway, a piece of middleware for a non-Agora service, a test:
| Export | Signature | What it does |
|---|---|---|
encodeBaggage | (entries: Baggage) => string | Serialize a flat key → value map into a W3C baggage header value. Values are percent-encoded; returns '' for an empty map. |
decodeBaggage | (header: string | string[] | undefined) => Baggage | Parse a baggage header back into a map. Tolerant — empty members, stray commas, keyless members and ;-properties are skipped rather than thrown; an invalid percent-encoding is kept raw. |
encodeUserRef | (ref: UserRef) => string | Encode a UserRef as the compact type:id token baggage carries. |
decodeUserRef | (token: string) => UserRef | undefined | Parse a type:id token back into a UserRef. A colon-less token is treated as an id with the default type: 'user'; an empty token returns undefined. |
Baggage is the shared type for a decoded map — simply Record<string, string>.
import { encodeBaggage, decodeBaggage, encodeUserRef, decodeUserRef } from '@adonis-agora/context'
const header = encodeBaggage({ tenantId: 't1', userRef: encodeUserRef({ type: 'user', id: 42 }) })
// 'tenantId=t1,userRef=user%3A42'
const map = decodeBaggage(header) // { tenantId: 't1', userRef: 'user:42' }
const ref = decodeUserRef(map.userRef) // { type: 'user', id: '42' }These are the exact primitives Context.toBaggage/fromBaggage use internally; reach for them only when you are handling the header yourself.
The snapshot caveat
There is one behavior you must internalize before building long-running workflows on top of this.
The carrier is a snapshot, not a live view
A carrier captures the user and tenant at the moment you call serialize() — at dispatch time. A workflow that runs for days re-hydrates that snapshot, not the current live value.
If a user's role changes, or they are deactivated, or the tenant is reconfigured after the job was enqueued, the re-hydrated context still reflects the world as it was when the work was dispatched. The carrier is the history, not the present.
This is a deliberate decision, not a bug: a workflow step should generally act with the authority it was dispatched with, not with authority that may have changed underneath it. But if your use case genuinely needs the current value, re-resolve it inside the step from a stable id (e.g. look the user up fresh by userRef.id) rather than trusting the carrier's snapshot.
The trace-id safety net
ContextStore.traceId is a non-optional string (see the invariant), and deserialize protects it. A carrier that crosses the boundary without a trace id — for instance, one produced by a different runtime, or a job from an external system that never set one — would otherwise re-hydrate into a store with an undefined trace id, breaking the correlation that diagnostics and durable depend on.
To prevent that, deserialize (and the underlying carrier→store step) generates a fresh randomTraceId() when the incoming carrier has no trace id, emitting a one-time console.warn so the gap is visible without spamming your logs. The result is that any context you re-enter always has a valid trace id, no matter how messy the producer was.
Putting it together
The cross-process story is small on purpose — three concepts:
serialize()flattens the active store to a JSON-safe carrier (refs only, no models or connections).- You ship the carrier however your boundary already ships data — a queue payload field, the durable traceparent hook, a W3C baggage header.
deserialize(carrier, fn)re-enters the store and runs your work inside it, with a trace-id safety net.
Who does the wiring depends on the boundary: across @adonis-agora/durable it's automatic (steps 1–3 happen for you inside ctx.call); across a raw @adonisjs/queue it's two lines you write (serialize on dispatch, deserialize in the handler).
And one rule to remember: the carrier is a snapshot taken at dispatch time, not a live link to the present — true whether durable wires it or you do.
Next steps
- Customization — choose which fields travel, or override
serialize/deserializeentirely - The Store — why the store carries refs, and the trace-id invariant
- Testing — build a context in tests for code that reads it after an
await - Durable — first-party workflows whose remote steps inherit the context automatically
The Store
The ContextStore shape, why it carries a UserRef instead of the full user, the always-present traceId invariant, and how to add your own typed fields.
Database per Tenant
Resolve the right Lucid connection from the tenant in context — fail-closed, so a request without a tenant never silently reads the default database.