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 `static workflow = { concurrency }` / engine.register(..., { 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:
import { BaseWorkflow } from '@adonis-agora/durable'
import type { WorkflowCtx } from '@adonis-agora/durable'
export default class ChatTurnWorkflow extends BaseWorkflow {
static workflow = {
name: 'chat.turn',
version: '1',
concurrency: {
key: (input: unknown) => `tenant:${(input as TurnInput).tenantId}`,
limit: 8,
},
}
async run(ctx: WorkflowCtx, input: TurnInput) {
/* ... */
}
}The same concurrency option is accepted by the low-level engine.register(name, version, fn, { concurrency }) and by engine.registerRemote(...).
Or per start — a concurrency in the start options overrides the workflow's config for that start:
await ChatTurnWorkflow.dispatch(input, {
concurrency: { key: `tenant:${tenantId}`, limit: plan.maxActiveTurns },
})
// or, lower level:
await engine.start('chat.turn', input, runId, {
concurrency: { key: `tenant:${tenantId}`, limit: plan.maxActiveTurns },
})A start that would exceed the quota throws ConcurrencyLimitError (with workflow, key, limit and active) and creates nothing — map it to a 429:
import { ConcurrencyLimitError } from '@adonis-agora/durable'
try {
await ChatTurnWorkflow.dispatch(input)
} catch (error) {
if (error instanceof ConcurrencyLimitError) {
// error.workflow, error.key, error.limit, error.active
return response.tooManyRequests({ message: 'Busy, retry shortly' })
}
throw error
}Options
key— the quota bucket. On the workflow it's a function of the input; returnundefinedto leave that start unlimited. Per start it's a plain string. 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 the workflow it may be a (sync or async) function of the key, for per-plan limits:limit: (key) => plans.maxActive(key). Per start it's a number.limit <= 0rejects every start.countStatuses— which statuses occupy a slot. Default: every non-terminal status (pending,running,suspended,blocked), exported asDEFAULT_CONCURRENCY_STATUSES. 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 before creating the new one. The count goes through the store's countRuns — one COUNT(*) on the Lucid store, also implemented by the in-memory store; a custom store without countRuns falls back to the length of a listRuns 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 existing-run check happens before the quota check. - The count spans every namespace that shares the store, like the key itself. With
scopeReadson, the store's reads are fenced to the engine'snamespace, so the quota only counts that namespace's runs. Put the tenant in the key when the limit is per tenant. - A per-start
concurrencyreplaces the workflow's quota for that start entirely (key, limit andcountStatuses); it is not merged with it. - The quota applies wherever the workflow is started:
engine.start/Wf.dispatch/Wf.start, child workflows (ctx.child/ctx.startChild— a rejected child start is delivered to the parent as a failed child), event triggers (a rejected subscriber is skipped) and schedules. - Persisted schedules take a
concurrencyquota of their own; an over-limit window is skipped and recorded as the schedule'slastError.
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. It is exact whenever starts for a key are not simultaneous. Use singleton for a strict, queueing per-key limit instead of a rejecting one.
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 (+ @adonis-agora/durable/admission-redis) |
Flow control
Durable queues for dispatched steps — cap concurrency and enforce fixed- or sliding-window rate limits with engine.registerQueue and ctx.step(step, input, { queue }). A blocked call re-suspends and the timer poller retries admission. Scale the cap across processes with @adonis-agora/durable/admission-redis.
Singleton workflows
`singleton` serializes runs of a workflow that share a key — a durable, FIFO mutex: at most `limit` run concurrently per key, the rest wait (suspended) and are admitted 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.