Queues

Named streams of jobs claimed in due-time order, with one queue per job, no per-job priority, and per-queue cluster-wide concurrency limits.


A Queue is a named stream of jobs. Every job belongs to exactly one Queue, declared on its job type and overridable per enqueue. Within a Queue, jobs are claimed strictly in Due Time order, oldest-due first, so a Queue behaves like a time-ordered stream of due work rather than a priority heap. There are no per-job priority numbers. Priority is expressed on the consumer side by a Worker Group's Dispatch Policy, which decides which served Queue to claim from next. Each Queue can carry one optional Concurrency Limit that caps how many of its jobs run at once across the whole deployment, and a Queue can be paused and resumed cluster-wide by an operator.

Assigning a job to a Queue#

A job's Queue is set with the Queue property on its [Job] attribute. When you omit it, the job goes to the Queue named "default".

OrderCharged.cs
[Job("order-charged", Queue = "billing")]
public sealed record OrderCharged(Guid OrderId);

The Queue on the attribute is the default for that job type. You can override it at a specific call site with the optional queue parameter on EnqueueAsync and EnqueueDependencyAsync. Passing null, the default, uses the job type's registered Queue.

ChargeOrder.cs
// Goes to "billing", the job type's registered Queue.
await client.EnqueueAsync(new OrderCharged(orderId), DateTimeOffset.UtcNow);
 
// Same job type, routed to a different Queue for this one call.
await client.EnqueueAsync(new OrderCharged(orderId), DateTimeOffset.UtcNow, queue: "billing-urgent");

The Jobs and Handlers page covers the [Job] attribute in full. The pattern is to declare a sensible default Queue on the type and reach for the enqueue-time override only when a particular call needs a different one.

How a Queue is claimed#

Within a Queue, jobs are claimed in Due Time order. The oldest-due job is taken first, and ties are broken by enqueue order, so two jobs with the same Due Time are claimed in the order they were enqueued. A claim never returns a job whose Due Time is still in the future. Only currently-due jobs are claimable, which means a plain enqueue and a future-scheduled job ride the same ordering. An enqueue for "now" is simply a job whose Due Time is now.

A paused Queue yields nothing on claim until it is resumed. The same is true of a Queue that has hit its Concurrency Limit. In both cases a Worker looking for work passes over that Queue and moves on to the next one it serves.

No per-job priority#

BackWave has no per-job priority number. There is no priority integer on a job and no way to make one job jump ahead of another within the same Queue. Order inside a Queue is Due Time, full stop. This is a deliberate choice, not a missing feature.

Per-job priority numbers cause problems that get worse over time. They make starvation invisible: a steady stream of high-priority jobs can quietly strand lower ones with nothing in the system reporting that it is happening. They also push an ordering cost into every claim query for the life of the deployment, and they create a second prioritization mechanism alongside the one Worker Groups already give you, so the same intent can be expressed two ways. Priority boosting, where a waiting job's priority climbs as it ages, was set aside for a related reason: it makes the simple question "when will this job run?" impossible to answer.

To prioritize work in BackWave, use separate named Queues and let a Worker Group's Dispatch Policy decide how to draw from them. Two policies are available:

  • Strict serves Queues in a fixed priority order. An earlier Queue is always tried before a later one, and a later Queue is reached only when every earlier one has no due work. A continuously busy high-priority Queue can starve the lower ones, which is the explicit, accepted trade-off of this policy.
  • Weighted shares claim opportunity across Queues in proportion to integer weights, for example 6:3:1. Every Queue keeps making progress, and the distribution is deterministic with no random clumping.

Both policies are work-conserving: a Worker never sits idle while any Queue it serves has due work. The Worker Groups & Dispatch page owns the full mechanics of Strict and Weighted, including how weights are set and how a group registers the Queues it serves.

Concurrency Limits#

A Queue can carry one optional Concurrency Limit. It caps how many of that Queue's jobs are leased and executing at the same time across the entire deployment, not per node. The limit is enforced at claim time: a Queue sitting at its limit yields nothing until a slot frees.

PropertyBehavior
ScopePer-Queue, cluster-wide. One shared count across the whole deployment for that Queue.
What it boundsHow many of the Queue's jobs are leased and executing at once.
EnforcedAt claim time. A Queue at its limit is skipped on claim.
DefaultNone. No cap is configured, so the Queue is uncapped.
Slot accountingA slot in use is a currently-leased job. No separate counter is kept.
Slot releaseA slot frees the moment a job reaches a terminal state or its Lease expires, so a crashed Worker never leaks one.

You configure the limit on the job store your application registers, by calling SetConcurrencyLimitAsync with the Queue name and the cap. Passing null clears the cap and returns the Queue to uncapped.

ConfigureQueue.cs
// Cap the "billing" Queue at 5 concurrent jobs across the whole cluster.
await jobStore.SetConcurrencyLimitAsync("billing", 5);
 
// Remove the cap.
await jobStore.SetConcurrencyLimitAsync("billing", null);

Concurrency Limit versus per-node pool capacity#

A Queue's Concurrency Limit and a node's pool capacity are two independent gates, and they do not substitute for each other. The Concurrency Limit is one shared counter across the cluster for a single Queue. A Worker Group's pool capacity is each node's own ceiling, set by PoolSize and defaulting to 20, which bounds how many jobs that group runs concurrently on that node. A node can be full on its own pool while the Queue's Concurrency Limit still has free slots, and the Limit can be saturated while a node's pool is idle. The node side, pool capacity and the Backpressure that pauses polling when a pool is full, is covered on the Worker Groups & Dispatch page.

Pausing a Queue#

A Queue can be paused and resumed across the whole cluster by an operator. While a Queue is paused, no Worker claims work from it, and jobs already running are unaffected and finish normally. Resuming the Queue makes its due jobs claimable again. The The Dashboard covers pausing and resuming from the operations side.

Where to go next#