Roles & config
The topology preset on DurableModule.forRoot, the RunGateway surface every shape shares, and layered tenant authentication for a store-less fleet.
Which shape a process takes is inferred from the options you give it. The
topology preset on DurableModule.forRoot lets you name the role instead, and validates the
two axes that otherwise read as synonyms — namespace (the operator's poll scope) versus partition
(a worker's queue suffix). topology is entirely additive: omit it and inference behaves exactly as
before.
The configs
Omit topology and you get the default: an operator that owns the store and runs every @Workflow
on the inline fast path.
DurableModule.forRoot({
store,
transport: new EventEmitterTransport(), // or BullMQ for cross-process
})Add a connection and the same process keeps the store but moves execution onto a co-located BullMQ
consumer — every @Workflow becomes group-served instead of inline.
A coordinator: it owns the store and the transport, dispatches, recovers, fires timers, prunes retention, and answers thin-pod requests.
DurableModule.forRoot({
topology: { role: 'control-plane' }, // optional `tenant` scopes it to its own namespace
store,
transport, // BullMQ for a cross-process/polyglot fleet
retention: { policies: [{ statuses: ['completed', 'cancelled'], maxAge: '14d' }] },
})The preset does not switch execution off. Bodies run wherever their @Workflow/@Step providers are
registered — declare them here and this process starts consumers for them. Whether that actually
takes work from your workers depends on the partition: without tenant this node is on the bare
queue tokens and never sees a tenant's dispatches, while with tenant it deliberately subscribes
the same <name>@<tenant> tokens it dispatches to. See
dispatch vs execution.
A store-less worker sets a connection (Redis) and no store — that is what makes it
store-less. It consumes its partition's queues, executes the @Workflow/@Step bodies registered in
the module, and publishes results. The NestJS process is the worker: the module starts the
consumer on bootstrap, with no separate CLI.
DurableModule.forRoot({
topology: { role: 'tenant', tenant: 'acme-corp' }, // maps to the partition it serves
connection: process.env.REDIS_URL, // Redis — no `store`
})A store-less api/dashboard pod is the same config in an HTTP app that registers no handlers: it executes nothing and proxies every read and control action to the control plane.
An operator that mounts the store and the dashboard for reads but runs none of the driving loops.
DurableModule.forRoot({
store,
transport,
drive: false, // no poll, recovery, timers, timeout sweep, retention or local step consumption
})drive: false is not "a lighter control plane" — nothing ticks on it. A driving operator must be
running somewhere or pending runs never start, crashed runs never recover, and timers never fire.
What topology validates
Each check is named after the axis it protects, and runs at forRoot/forRootAsync resolution time:
topology | Requires | Forbids | Notes |
|---|---|---|---|
{ role: 'control-plane', tenant? } | store and (transport or transports) | partition | tenant is optional and maps onto namespace. Omit it on a deployed operator (it then drives every tenant); set it to scope a self-contained local stack to its own runs while sharing a broker with a deployed cluster. Setting namespace too is allowed only when it equals tenant; a mismatch throws. |
{ role: 'tenant', tenant } | connection | store, namespace | tenant is required and maps onto partition. Setting partition too is allowed only when it equals tenant; a mismatch throws. |
The preset exists for the case plain inference can't catch: an app that sets both namespace (meant
for the operator) and partition (meant for a worker) on one instance because the two options read
as near-synonyms. topology forces you to pick one role and rejects the other role's axis outright,
with an error message that explains why. What each axis actually partitions is
Tenancy.
The shared surface: RunGateway
Every shape binds exactly one RunGateway, and the role picks the implementation. An operator with a
store reads its store directly (StoreRunGateway). A store-less pod binds a ProxyRunGateway that
round-trips every call over the transport to the control plane, which reads its store and answers.
This is what makes application code portable across shapes. Your controllers and the
dashboard call the same interface either way, so a pod moving from
single-process to store-less changes only its forRoot — no controller code, and the identical
DurableDashboardModule mounts on both.
@Controller('runs')
export class RunsController {
constructor(private readonly gateway: RunGateway) {} // inject by type — the class is its own token
@Get(':id')
async show(@Param('id') id: string) {
return (await this.gateway.getRunDetail(id)) ?? {};
}
}| Verb | Purpose |
|---|---|
topology() | this pod's role, plus its tenant when store-less. Synchronous — no round-trip. |
getRunDetail(runId) | one run plus its step timeline and child ids, or null |
listRuns(query) | a filtered listing (status, workflow, tag, namespace, search attributes), each row optionally carrying what a suspended run is parked on |
runFacets(query) | whole-set counts grouped by (status, origin) — exact even when the listing is paginated |
runValueFacets(axis, query, opts?) | the distinct values of one filter axis (tag, tenant, origin, workflow, search-attribute key or value) with counts — what fills a console's pickers |
waitingFor(runIds) | bulk-resolve what each of an arbitrary set of runs is parked on |
workerHealth() | queue backlog and live worker heartbeats per group; scoped to the requester's own groups over a tenant proxy |
cancel(runId, opts?) | cancel a run, with { compensate: true } to run its saga undo first |
retry(runId) / continue(runId) | re-drive a failed run, or push a dead one one step further |
retryWithInput(runId, input) | fix-and-replay: a fresh run from corrected input, keeping the original's namespace |
redispatchPending(runId) | re-enqueue every remote step still pending — the operator escape hatch for a lost dispatch |
subscribe(runId, onEvent) | live-tail one run's lifecycle events; returns the unsubscribe |
Tune the proxy round-trip with runGatewayTimeoutMs (default 10_000). It is ignored on an
operator, which is bound to the store-backed gateway.
cancel on a store-less pod: same verb, two objects, opposite answers. Injecting
WorkflowEngine there gets you a start-only client — start publishes a start message over the
wire, but cancel, deleteRun, resume, signal, signalWithStart and publishEvent reject with
a named tenantUnsupported(...) error, because they reach for a store the pod does not have. The
RunGateway above has no such problem: its cancel is a wire request the control plane answers
against its store, so it works on every shape.
That is why the table lists cancel as a working verb. It is RunGateway.cancel that works;
WorkflowEngine.cancel is the one that rejects. Inject RunGateway in anything that has to run on
more than one shape, and you never meet the distinction.
Layered tenant authentication
The tenant on a wire request is a claim. Without authentication the
isolation boundary is meaningless — any pod could
ask for any tenant's runs. Durable stacks two layers:
Prefix / network baseline
Each tenant runs on a segmented transport prefix/namespace behind a Redis/network ACL, so a pod can only reach its own prefix. No application code.
Signed token on top
Each tenant pod carries a secret-signed token. The control plane verifies the signature and derives
the tenant from the token, ignoring any tenant in the request body — defense in depth.
The control plane is the trust boundary for these proxied requests: it forces listRuns to the
requester's own tenant (no cross-tenant enumeration), validates run.namespace === tenant on every
runId-bearing verb (anti-IDOR), and rejects unknown verbs and tampered tokens.
Next steps
- Tenancy — what
namespaceandpartitionpartition, and the rules that keep a tenant's runs on the tenant's own pool. - Handshake & capability negotiation — keep a mixed fleet version-safe.
- Cross-ecosystem interop — add Python and Adonis workers.
Topologies
Run durable in one process, split the control plane from the workers, or spread store-less thin workers per tenant — the same engine over the same wire, selected by which options a process is given.
Node worker
Author and run a store-less Node worker with @dudousxd/durable-worker — the JS counterpart to the Python client. Register @Step/@Workflow bodies on a DurableWorkerRuntime and drive them with runRedisWorker, with fixed or adaptive concurrency.