BullMQ's native priority option is a strict priority ladder: whenever a
higher-priority job exists, workers take it before any lower-priority job. If a
high-priority stream (for example contract processing) is continuously
replenished, reconciliation, notification, and cleanup work can be starved
indefinitely — their jobs wait forever behind a hot priority stream.
This feature adds a weighted fair scheduler on top of BullMQ that:
- guarantees every priority stream receives service proportional to its configured weight,
- isolates tenants from each other so one tenant's flood cannot block others,
- enforces a maximum wait bound — no job waits longer than
QUEUE_FAIR_MAX_WAIT_MSregardless of how hot a higher-priority stream is, - exposes every scheduling decision in Prometheus metrics.
Strict FIFO across all job families is explicitly out of scope; priority still matters, it is just bounded and fair.
| Component | File | Role |
|---|---|---|
FairScheduler policy |
src/queue/fair-scheduler.ts |
Pure, deterministic ordering policy (no I/O). |
| Scheduling metrics | src/queue/queue-metrics.ts |
Prometheus counters/gauges for decisions. |
| Rebalance loop | src/queue/queue-manager.ts |
Applies the policy to waiting BullMQ jobs on a timer. |
| Config | src/queue/config.ts |
QUEUE_FAIR_MAX_WAIT_MS, QUEUE_FAIR_REBALANCE_INTERVAL_MS. |
Each priority level carries a weight (service share is proportional to weight):
| Level | Weight | Derived from numeric priority |
|---|---|---|
critical |
4 | 1 |
high |
3 | 2 |
normal |
2 | 3 or unset |
low |
1 | >= 4 |
The policy computes a per-level occupancy ratio — waiting(level) / weight(level) —
and orders waiting jobs so the level with the largest backlog relative to its
entitlement is served first. This equalizes occupancy and yields exactly
weight-proportional service under continuous load: critical (weight 4)
against low (weight 1) interleaves roughly 4:1 instead of letting critical
run to the end of the queue.
Fairness is enforced across rebalance passes: each pass re-orders the waiting set, so as a level's backlog shrinks below its fair share its remaining jobs move toward the head on the next pass. The maximum wait bound is the hard liveness guarantee on top of that.
Any job waiting longer than QUEUE_FAIR_MAX_WAIT_MS (default 5 minutes) is
promoted unconditionally to the front of the line — oldest overdue job first.
This is what converts "starves indefinitely" into "waits at most a bounded
amount", and it also rescues jobs under an adversarial trickle (e.g. a single
critical job always present), where occupancy-based fairness alone would keep
preferring the hot stream.
addJob accepts an optional tenantId. Jobs without one share a default
tenant bucket. Within a chosen priority level, the tenant with the fewest
waiting jobs is served first, so a tenant flooding a level cannot block other
tenants of that level; within a (level, tenant) pair, ordering is FIFO by
enqueue time. The tenant id is stored on the job payload (like
correlationId/requestId), so isolation is enforced identically on every
worker.
The policy is stateless: every ordering decision is a pure function of the
waiting set (jobId, priorityLevel, tenantId, enqueue timestamp) plus
configuration. The rebalance pass reconstructs those inputs from durable
BullMQ/Redis metadata — the enqueue timestamp and the tenantId/
priorityLevel fields merged into the payload at enqueue time (the derived
level is always persisted, since the rebalance pass rewrites job.opts.priority
and the immutable payload field is the stable source of truth). A worker that
restarts computes the same ordering as the worker it replaced; there is no
in-memory service-counter state to lose. Jobs enqueued before this feature
shipped fall back to their original numeric priority (mapped via
normalizePriority) on the first pass.
On a timer (QUEUE_FAIR_REBALANCE_INTERVAL_MS, default 5s) per queue:
- Fetch up to 1 000 waiting jobs (
Queue.getWaiting— bounded, so the pass stays cheap under pathological backlog). - Build
PendingJobentries from durable metadata and runorderPendingJobsto get the fair run order. - Assign compact BullMQ priorities
0..n-1in run order viaJob.changePriority, skipping jobs whose priority is already correct. - Record metrics (
ageddecisions for wait-bound promotions,weighted_fairdecisions for everything else).
changePriority races (e.g. a worker picked the job mid-pass) are caught and
logged as warnings; a failed update never aborts the pass or crashes the
worker. The timer is unref()'d and cleared on shutdown.
| Variable | Default | Description |
|---|---|---|
QUEUE_FAIR_MAX_WAIT_MS |
300000 |
Hard wait bound in ms (1..86400000). A waiting job past this is promoted to the front. |
QUEUE_FAIR_REBALANCE_INTERVAL_MS |
5000 |
How often fair priorities are recomputed (1..3600000). |
Level weights are compile-time constants in src/queue/fair-scheduler.ts
(DEFAULT_FAIR_WEIGHTS) — documented defaults that are not intended to be
tuned per deployment.
await queueManager.addJob(JobType.EMAIL_NOTIFICATION, payload, {
priorityLevel: 'high', // optional; derived from `priority` when omitted
tenantId: 'tenant-42', // optional; defaults to 'default'
});The existing numeric priority option is unchanged and still honored; when
priorityLevel is omitted it is derived from priority via
normalizePriority (1 → critical, 2 → high, 3/unset → normal, >= 4 →
low). This keeps the API backward compatible.
All families are prefixed queue_fair_ and registered via
initializeQueueFairMetrics(registry) (same pattern as the webhook DLQ
metrics):
| Metric | Type | Labels | Meaning |
|---|---|---|---|
queue_fair_priority_assigned_total |
Counter | job_type, priority_level |
Enqueues by normalized level. |
queue_fair_decisions_total |
Counter | job_type, decision |
Ordering decisions: aged (wait bound) or weighted_fair. |
queue_fair_aged_boosts_total |
Counter | job_type |
Jobs promoted by the maximum wait bound. |
queue_fair_overdue_waiting |
Gauge | job_type |
Current waiting jobs past the wait bound. |
Alert on queue_fair_aged_boosts_total / queue_fair_overdue_waiting climbing
sustainedly — that signals a stream is repeatedly hitting the wait bound, which
usually means arrival rate exceeds capacity for a whole level.
| Case | Behavior |
|---|---|
| Only one priority | Degrades to FIFO by enqueue time within the level (stable for equal timestamps via job-id tie-break). |
| Priority flood | The flooded level steps back once its occupancy exceeds its fair share; other levels are served proportionally. |
| Priority trickle | Occupancy fairness alone prefers the hot stream while it is scarce; the maximum wait bound guarantees the sparse stream still runs. |
| Tenant flood | Sparse tenants of the same level are served before the flooding tenant's backlog. |
| Worker restart | Identical ordering reconstructed from durable job metadata; no in-memory state. |
| Empty queue | Rebalance is a no-op; gauge reports zero overdue; no errors. |
| Retries | A retried job keeps its original enqueue timestamp; if that makes it overdue it is promoted like any other job. |
changePriority failure |
Caught and logged; the pass continues and other jobs are still re-prioritized. |
- Bounded side effects: at most 1 000 waiting jobs are examined per pass,
the timer is
unref()'d, and every per-job failure is isolated. A misbehaving queue cannot take the process down. - No new trust surface: the scheduler does not read request input beyond
the optional
tenantId/priorityLevelfields, which are opaque strings stored in the payload.tenantIdis a logical grouping key, not an authorization boundary — access control remains the responsibility of the API/auth layer, which is unchanged. - No secrets in logs: rebalance failures log sanitized error messages only
(
error.message), never payloads, tenant values, or stack traces. - Compatibility: the
addJobsignature,POST /api/v1/jobsroute, DLQ, retry, and dedupe semantics are unchanged; new options are additive.