Tenancy
What namespace and partition actually partition, how a store-less tenant borrows the control plane's store over the transport, and the boundary that keeps each tenant to its own runs.
Tenancy is one axis: whose runs are these, and which pool executes them. It is independent of how many processes you run — see Topologies for that. This page is what the tenant axis means once you have reached for it.
Two roles carry it. An operator owns the store and drives runs. A tenant executes work without
a store: its queues are suffixed with the tenant (handler@blue), its runs are stamped with it, and
its RunGateway round-trips reads and control verbs to the operator over the transport instead of
touching a store directly.
This is what makes a developer's laptop a tenant of a shared cluster: point it at the cluster's
transport, set DURABLE_TENANT=your-name, and your runs execute on your machine while the cluster's
operator orchestrates and stores them — without your runs colliding with anyone else's.
DURABLE_TENANT (as used throughout this page) isn't a variable the library reads itself. It's the
app-level convention for feeding DurableModuleOptions.namespace — and, on the worker side,
partition — from the environment. The
topology preset maps one tenant word
onto whichever of the two the role calls for.
What namespace partitions
namespace is a DurableModule.forRoot({ namespace }) option (forwarded to the engine's own
WorkflowEngineDeps.namespace) and it isolates two independent things at once:
- The store. The engine's poll paths — driving pending runs, recovering incomplete ones, resuming due timers, and sweeping timeouts — only act on runs stamped with this instance's namespace. Every run created by a namespaced engine is stamped with it, so a store shared by several pools (a dev cluster and a developer's laptop, say) never has one pool driving another's runs.
- The transport. The engine propagates its namespace to every transport that supports
namespacing; the BullMQ transport folds it into every queue/stream/key name
(
<prefix>-<namespace>-...for tasks, results, decisions, step-events, the worker-heartbeat key, and the control/heartbeat channels).
Both axes share one default-is-bare rule: undefined, "", and "default" all collapse to the
bare name/queue — only a real, non-default namespace suffixes anything. A single-tenant deployment is
byte-identical to the un-namespaced scheme, and unaffected.
Omitting namespace makes the instance an operator: an unset namespace drives, recovers, and
resumes runs of every namespace (the poll filters no-op) and leaves the transport on its bare
prefix. "Seeing everyone" is the absence of a namespace, not a wildcard value.
What partition partitions
partition is the other half of the same word, on the worker side: which queue a worker's consumer
subscribes to. A run's namespace becomes a worker-queue suffix, <handler>@<tenant>, so an operator
dispatching a tenant's run and that tenant's worker subscribing to its partition land on the exact
same queue name — the dispatch side derives the suffix from the run's namespace, the worker side from
DurableModuleOptions.partition.
That symmetry is the whole routing model, and it is why the two options read as synonyms without
being interchangeable: namespace is what an operator polls, partition is what a worker
subscribes. Setting both on one instance is the mistake the
topology preset exists to reject.
The wire between operator and tenant
A tenant never touches the database. Three message kinds cross the boundary, all over the transport:
StartRunMessage(<prefix>-start-run) —{ tenant, workflow, input, runId?, tags?, searchAttributes? }. A tenant publishes this instead of touching a DB; the operator turns it into a durable run stamped withtenantas its namespace.RunRequest/RunReply(correlated byrequestId) — every read and control verb (getRunDetail,listRuns,cancel,retry,continue,retryWithInput, …) proxied to the operator and answered on the correlated reply.TenantEvent— lifecycle events re-published per tenant, so a tenant's dashboard tails its own runs live.
A store-less instance with no transport configured either has nowhere to read from, and its gateway rejects every call. The surface each role exposes — and which verbs go over which path — is Roles & config.
The isolation boundary
A tenant can only ever see and act on its own runs. This is enforced on the operator, not the client — the operator is the boundary:
listRunsoverwrites the query's namespace with the requester's tenant. Whatever namespace the client asks for is discarded, never merely validated — a tenant can't widen its query into another's.- Every runId-bearing verb loads the run first and rejects with a
cross-tenanterror ifrun.namespacedoesn't match the requester — before the mutating method runs.
So the guarantee holds even against a misbehaving client: the operator decides what a tenant is allowed to touch.
That boundary is only as strong as the claim it reads. The tenant on a wire request is asserted by
the caller — on its own, nothing stops a pod from asserting someone else's. Pair this with
layered tenant authentication:
network/prefix segmentation, and a signed token the control plane derives the tenant from rather
than trusting the body.
Read scoping (scopeReads)
Independent of the wire boundary above, an operator can also opt a pre-built store into
namespace-confined reads: DurableModuleOptions.scopeReads: true (with namespace set) confines the
store to a view that only sees its own namespace, using the store's optional scoping capability. It's
off by default — a control plane's own operator screens stay unscoped by design — and is a no-op for
a store that doesn't support scoping.
Child runs inherit the parent's namespace
A child run (ctx.child, ctx.gather_children, or a remote workflow's startChild) is stamped with
the namespace of the run it was spawned from — not the namespace of whichever engine happens to
execute that parent. This matters because an operator (namespace: undefined) legitimately executes
every tenant's parents during recovery; without this rule a recovery-resumed parent's child would
fall back to default and dispatch to the shared worker pool instead of the tenant's own.
Top-level runs and an explicit opts.namespace are unaffected — this only sets the implicit default a
child would otherwise get.
The same principle covers retries: retryWithInput re-stamps the new run with the original run's
namespace, so a fix-and-replay of a tenant's run stays that tenant's instead of falling to default
on an operator. Inherit from the record, never from the executing engine.
Multi-environment patterns
Because namespace partitions both the store and the transport, one Redis/DB pair can safely host several non-interchangeable pools:
- A dev cluster + per-developer local instances. Give the cluster's operator no namespace (or
default) and each developer's local engine a distinct one (e.g.DURABLE_TENANT=blue). Point the local instance at the same transport/DB the cluster uses; its runs, queues, and keys all carry its own namespace suffix, so it can iterate against shared infra without touching anyone else's runs. - Per-team or per-customer isolation — the same shape, one namespace per team/tenant instead of per developer, all driven by one operator.
Pitfalls
- A worker running with no namespace claims default-namespace work. An engine/worker with an
unset (or
"default") namespace is on the bare, un-namespaced queues — if that's not what you intended (e.g. a stray local process), it will pick up runs meant for the shared default pool. - A stale local worker steals a shared pool's runs. A developer running locally against a shared Redis with no namespace set is on the exact same queues as the deployed workers — it collides with, and steals tasks from, them, and vice versa. Set a real, distinct namespace on the local instance; an un-namespaced local worker is not merely "another consumer," it shares the shared pool's queues.
- A stale local worker with an old build, even when namespaced, can misroute a run. A worker
process left running after a step/workflow rename (without bumping the
@Workflowversion) can pick up a run under its namespace with code that no longer matches the run's history, surfacing as aNonDeterminismErroror a hung run — kill stale local processes, don't just rely on the namespace being distinct. listRuns's namespace is enforced server-side, not client-side. A tenant can't widen its own query by passing a differentnamespace— the operator overwrites it with the requester's tenant unconditionally. Don't rely on a client-side filter for isolation; there isn't one to bypass in the first place.
Next steps
- Topologies — the deployment shapes this axis rides on.
- Roles & config — the
topologypreset, theRunGatewaysurface, and layered tenant authentication. - Cross-ecosystem interop — a Python or Adonis tenant on the same operator.
Sleep & signals
Pause a workflow durably — ctx.sleep for time-based waits (minutes to months, no compute) and ctx.waitForSignal for human approvals and webhooks, both surviving restarts.
Authoring
Everything you compose a real workflow out of — child workflows, durable entities, events, queries and updates, webhooks and external tasks, versioning, and scheduling.