Order, limits & pacing
Choose which jobs run in order, how many run at once, and how often they start.
A lane decides order. A concurrency key or limit decides how many run at once. A rate limit decides how often they start. The gates are independent, so a job passes every one it carries, and a job that carries none is never slowed by them.
One job of the lane at a time, in enqueue order, like an SQS FIFO message group. The lane holds through retries.
o => o.Lane("customer-42")Never two at once per key. Order is not promised: a younger job may take a freed key first.
o => o.ConcurrencyKey("rebuild:acme")The same gate with N slots, per key when the enqueue names one, otherwise for the whole definition.
[Job("rebuild-index", ConcurrencyLimit = 4)]Starts metered on one clock across the namespace’s workers. Early arrivals are booked a turn and come back for it.
[Job("send-invoice", RateLimit = "10/s")]The same five jobs, through each gate.
A1, A2, A3 belong to customer 42 and B1, B2 to customer 73. They arrive in the order A1, B1, A2, A3, B2, and three executors are free. Pick a gate and step through what the ledger records.
o => o.Lane("customer-42")Only lane heads enter the claim scan. Blocked jobs stay outside it, however deep the lane.
Admission happens after the claim and before the handler. A job the gate turns away hands its executor back.
A1 and B1 are claimable. A2, A3 and B2 are Blocked, outside the claim scan, so a deep lane costs the claim nothing. Enqueue order is commit order: two producers cannot interleave a lane.
Execution is at-least-once on every path, so handlers with external effects stay idempotent. A lane head still passes the key and rate gates it carries, and the jobs behind it stay Blocked while they re-arm it. Semantics in full: Lanes · Ordering and exclusion · runnable: 209-concurrency-key
| I need | Gate | Turn it on |
|---|---|---|
| One at a time per customer, in the order they came inItem N+1 starts only after item N finished: succeeded, failed for good, or cancelled. | Lane | await jobs.EnqueueAsync(input, o => o.Lane("customer-42"));Or [Job("sync-orders", Lane = "orders")] on the definition. Start here for "one at a time per entity". |
| Never two at once, and I do not care who goes firstExclusive, unordered work. A failing job releases the key with it. | ConcurrencyKey | await jobs.EnqueueAsync(input, o => o.ConcurrencyKey("rebuild:acme"));The key is also the scope a ConcurrencyLimit applies to. |
| At most N at oncePer key when the enqueue names one, otherwise across the whole definition: four index rebuilds, or four per tenant. | ConcurrencyLimit | [Job("rebuild-index", ConcurrencyLimit = 4)]An operator can change the number on a running system. |
| No more than so many starts a second, across the namespacePacing against a partner API or a shared resource. How often, not how many. | RateLimit | [Job("send-invoice", RateLimit = "10/s")][Job("refund", RateLimit = "10/s", RateName = "stripe")]Definitions in one namespace that name the same RateName share one meter, at the same declared rate. |
32 logical cores, NVMe, zero-work handlers, 8 executors, on the rc.5 benchmark round. One lane holding a thousand jobs ran about 230 to 260 jobs a second on PostgreSQL and SQL Server, each handoff waiting for the previous job's completion to commit. A thousand lanes in round-robin ran at 80 to 99 percent of unlaned throughput. One hot concurrency key with limit 1 managed 16 to 19 jobs a second on the servers, because every loser is claimed, bounced, and re-armed. Rough illustrations of the shape of each gate, not capacity claims. Benchmark round
The release certification kills a worker every five seconds while laned jobs run, then checks from the database's own ledger that no lane ever ran two members at once or out of order, and that every lane drained. The same run checks three rate meters, at 10, 50 and 100 starts a second, never admitted past their published budget. Certification records
Also: how one worker runs a job·which job runs next·many workers, no coordinator