Acta. apache-2.0 · .net 10 · pg / mssql / sqlite

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.

OrderLane

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")
How many at once · 1ConcurrencyKey

Never two at once per key. Order is not promised: a younger job may take a freed key first.

o => o.ConcurrencyKey("rebuild:acme")
How many at once · NConcurrencyLimit

The same gate with N slots, per key when the enqueue names one, otherwise for the whole definition.

[Job("rebuild-index", ConcurrencyLimit = 4)]
How oftenRateLimit

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.

Order. One job of the lane at a time, in enqueue order.o => o.Lane("customer-42")
Acta SQL ledger · two lanes
customer-42 Ordered lane
A1Ready
→
A2Blocked
→
A3Blocked
customer-73 Ordered lane
B1Ready
→
B2Blocked

Only lane heads enter the claim scan. Blocked jobs stay outside it, however deep the lane.

Executors
Executor 1Available
Executor 2Available
Executor 3Available

Admission happens after the claim and before the handler. A job the gate turns away hands its executor back.

Lane · 1 / 5 · EnqueueOnly the head of each lane is Ready.

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

Which gate to use, with the one line that turns it on
I needGateTurn 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.Laneawait 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.ConcurrencyKeyawait 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.
Measured, on one rig

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

Certified, under chaos

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