Aviary
Concepts

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.

A control-plane operator and two tenant workers wired over the transport. Enqueue a run to watch start-run, dispatch and reply travel between a tenant and the operator; or read another tenant's run to see the operator reject it with a cross-tenant error.Transport(Redis)control + readsrun@bluerun@greenControl plane · operatorengine · store · dashboardnamespace: — (drives all)sees every tenant’s runsTenant · blueDURABLE_TENANT=blue · no storehandler@blue · ProxyRunGatewayTenant · greenDURABLE_TENANT=green · no storehandler@green · ProxyRunGateway
Enqueue a run to watch it travel the transport — or read another tenant’s run to see the isolation boundary reject it.

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 with tenant as its namespace.
  • RunRequest / RunReply (correlated by requestId) — 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:

  • listRuns overwrites 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-tenant error if run.namespace doesn'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 @Workflow version) can pick up a run under its namespace with code that no longer matches the run's history, surfacing as a NonDeterminismError or 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 different namespace — 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

On this page