Blog
Your queue is a market
Work is not fungible and neither are the people and machines who do it. Treat the queue as a place where supply and demand meet by attribute, and dispatch becomes a query and a claim.
October 21, 2026 · Long Tail team
Most queues are pipes. Work goes in one end, a worker takes the next item from the other, and the only question the queue can answer is “what is next?”
That works when every item is the same and every worker can do every item. It stops working the moment someone says “not that one, I can’t run aluminum on this cell”, or “that order has six parts and we only ship it whole”, or “the rush jobs go first, but only to people certified for them”. Then the pipe grows a side table of capabilities, a routing service that reads it, a lock so two routers don’t grab the same item, and a backlog of edge cases that the routing service gets wrong in new ways each week.
We think the pipe is the wrong picture. A queue full of heterogeneous work is a market. Demand arrives with requirements. Supply arrives with capabilities. The job of the platform is to make both sides searchable by the same attributes, and to make taking work atomic. The policy of who should get what belongs to you.
Four words for routing
In Long Tail every unit of work waiting on the world is an escalation row. Two columns carry almost all the routing information.
role is the wall. A consumer bound to a role cannot see another role’s work, and the database enforces that in SQL, not in a controller. metadata is a JSONB object of facets: soft attributes that a consumer filters, sorts and claims by, served by a GIN index.
That gives you four words that cover most routing decisions:
| Word | Mechanism |
|---|---|
| Capability (hard) | role: the queue you are allowed to draw from |
| Capability (soft) | facets: metadata @> containment, such as { material: 'aluminum' } |
| Preference | orderBy over columns and facets, such as the soonest deadline first |
| Capacity | limit on the claim: how much you can take right now |
A consumer’s view of the market is a query:
const demandQuery = { role: 'cutting', status: 'pending', available: true, facets: { material: 'aluminum' }, orderBy: [{ field: 'metadata.mustCompleteBy', numeric: true, direction: 'asc' }],};Read it aloud and it is the sentence a dispatcher would say: pending cutting work, not held by anyone, needing aluminum, soonest deadline first. Every value is a bound parameter. Field names are validated against a strict pattern before they touch SQL.
Taking is atomic
A query is only half a market. The other half is the trade, and trades must not double-book.
Long Tail has two claim primitives. Both use FOR UPDATE SKIP LOCKED, so any number of consumers can run the same query at the same time without contention. Each consumer gets different rows, and none waits on another’s locks.
claimByFacets locks up to limit matching rows, in rank order, for the caller. With allOrNone: true it commits only when it gets the full limit. Otherwise it rolls back and returns nothing. That is the lock a dispatcher wants when it has already counted what it needs.
claimGroups works one level up. A multi-part order is a set of rows sharing an origin_id, with its size in a facet. A group is eligible only when it is complete, meaning every part has arrived, and it is claimed whole or not at all. Completeness is re-checked inside the lock, so two dispatchers can never split an order between them.
A claim is not a status. It sets assigned_to and assigned_until, and availability is computed from them: a row is held while the window is open. Resolve it, release it, or let the window lapse, and it is back in the market with no cleanup job.
Both sides of the trade are rows
This is where the market picture pays off. Supply can live in the queue too.
Give each machine a lifecycle workflow that parks a row in a cutter-pool role while it is ready, with facets describing what it can do. Now “which cutters are free for aluminum right now?” is the same kind of query as “which jobs need aluminum?” A dispatcher reads both sides and trades:
// 1. Anticipate supplyconst free = await lt.escalations.searchByFacets({ role: 'cutter-pool', available: true, facets: { state: 'ready', material: 'aluminum' },});const capacity = free.data.escalations.length;
// 2. Claim that much demand, complete orders only, by priorityconst orders = await lt.escalations.claimGroups({ query: demandQuery, limit: capacity, sizeFacet: 'orderSize' });
// 3. Lock the machines for the claimed orders, all or noneconst cutters = await lt.escalations.claimByFacets({ query: { role: 'cutter-pool', facets: { state: 'ready', material: 'aluminum' } }, limit: orders.data.groups.length, allOrNone: true,});
// 4. Pair them, do the work, resolve both sidesIf step 3 comes back empty because another dispatcher took a machine in the meantime, nothing is half-assigned. The dispatcher still holds its orders, and it can try again. Because the dispatcher is a durable workflow, it can carry those claims across continueAsNew and converge on the next pass instead of releasing work it already ranked. The durable workflow is the coordinator. There is no distributed transaction to get wrong.
The trade leaves a receipt
When the work is done, the resolve can stamp the outcome onto the same row it settles: a metadata patch merged in the same atomic update, on the winning resolve only. The row now holds what was asked (the facets it was created with), what happened (the outcome facets) and how long it took (created_at to resolved_at, with no stored duration field).
That turns the supply side into a production log. “Every aluminum job that cutter SN-0042 finished with a defect” is a facet query against rows that already exist. There is no second table to keep in step.
People shop the same market
The query language is not only for dispatchers. A person working three queues asks the same questions: what needs doing, in my roles, matching these attributes, oldest first. The list API accepts the same facets, ranges and ordering, and applies the caller’s read scope in the same SQL, so a person can narrow within what they may see and never widen past it. The page total is computed by the same WHERE, so pagination is correct.
Over HTTP the whole query fits in a GET URL. A supervisor builds the view they need, copies the URL, and pins it for the team. A role can declare link variables, so a shared station pins “my facility” once and every device fills in its own value.
Why the policy stays yours
We could have shipped a routing engine with rules for skills, priorities and capacities. We chose primitives instead, because every operation we have seen has a different idea of fairness, urgency and capability, and those ideas change faster than any rule language.
What does not change is the shape underneath: a hard wall, soft attributes, an order and a capacity, with atomic trades and receipts. Make those cheap and correct, and the policy becomes ordinary code in a workflow you can read, test and change on a Tuesday.
The faceted routing guide has the full query reference. The two-sided dispatcher and claim complete groups patterns show the trades in their smallest form.