Aviary
Cluster

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:

topologyRequiresForbidsNotes
{ role: 'control-plane', tenant? }store and (transport or transports)partitiontenant 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 }connectionstore, namespacetenant 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)) ?? {};
  }
}
VerbPurpose
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

On this page