Concurrency quotas
A start-time quota — at most `limit` runs sharing a key in flight, across workflows — that REJECTS an over-limit start with ConcurrencyLimitError before anything is created. Declared per workflow with @Workflow({ concurrency }) or per start with StartOptions.concurrency.
A multi-tenant app wants one tenant unable to occupy every worker: "a company can have at most 8
chat turns executing at once". That is a quota on starting runs, not a mutex — the ninth start
should be refused right away ("busy, try again in a moment", an HTTP 429), not parked behind the
other eight. concurrency is exactly that.
Declaring a quota
On the workflow, deriving the key from the input:
@Workflow({
name: 'chat.turn',
concurrency: {
key: (input: TurnInput) => `tenant:${input.tenantId}`,
limit: 8,
},
})
export class ChatTurnWorkflow { /* … */ }Or per start (overrides the workflow's config for that start):
await workflows.start(ChatTurnWorkflow, input, runId, {
concurrency: { key: `tenant:${tenantId}`, limit: plan.maxActiveTurns },
});A start that would exceed the quota throws ConcurrencyLimitError (key, limit, active,
workflow) and creates nothing — map it to a 429:
try {
await workflows.start(ChatTurnWorkflow, input, runId);
} catch (e) {
if (e instanceof ConcurrencyLimitError) throw new HttpException('Busy, retry shortly', 429);
throw e;
}Options
key— the quota bucket. On@Workflowit's a function of the input; returnundefinedto leave that start unlimited. The key is global, not per workflow: every workflow started withtenant:acmedraws on the same quota, so one limit can cover all the kinds of work a tenant runs.limit— max runs of the key in flight. On@Workflowit may be a (sync or async) function of the key, for per-plan limits:limit: (key) => plans.maxActive(key).limit <= 0rejects every start.countStatuses— which statuses occupy a slot. Default: every non-terminal status (pending,running,suspended,cancelling,blocked). Narrow it to['pending', 'running']when runs parked on a human (suspended on a signal) shouldn't hold a slot.
How it's enforced
The run carries the tag concurrency:<key>, and start counts the runs with that tag in
countStatuses with one COUNT(*) (StateStore.countRuns, implemented by every bundled store; a
custom store without it falls back to runFacets, then to a listing). The store is the only source of
truth, so there is nothing to leak or reconcile when a process crashes: a slot frees the moment its run
leaves the counted statuses.
- An idempotent re-
startof an existingrunIdreturns that run and is never rejected. - The quota applies wherever the workflow is started:
engine.start,ctx.child/startChild(a rejected child start fails the parent'sctx.child), event triggers and schedules. - It is a soft cap under a race: the count and the insert are separate statements, so starts that
are truly simultaneous on the last free slot can each see it free — the same guarantee a hand-rolled
SELECT count(*)gate gives, with an overshoot bounded by the number of simultaneous starts.
Quota, singleton, or flow control?
| You want… | Use |
|---|---|
| Refuse work over a per-key limit, right away | concurrency (this page) |
| Queue work over a per-key limit and run it in order when a slot frees | singleton (limit, maxQueueDepth) |
| Cap concurrent steps hitting a downstream, fleet-wide | flow-control queues (+ RedisAdmissionBackend) |
Singleton workflows
@Workflow({ singleton }) serializes runs that share a key — a durable, FIFO mutex: at most limit run concurrently per key, the rest wait (suspended) and admit in creation order as slots free, with an optional maxQueueDepth back-pressure cap that rejects a start with SingletonQueueFullError instead of letting the same-key backlog grow forever.
Dead-letter queue
Cap crash-recovery with maxRecoveryAttempts so a poison-pill run moves to the terminal dead status instead of crash-looping forever, then route dead runs with engine.onDead or a DLQ workflow — an inline @DeadLetter() method, a per-workflow deadLetterWorkflow reference, or the module-level default — to alert, compensate, or queue for review.