Skip to content

Serve multiple queues fairly instead of by command-line order - #57

Merged
codingjoe merged 1 commit into
mainfrom
codingjoe-fair-multi-queue-scheduling
Sep 14, 2026
Merged

codingjoe merged 1 commit into
mainfrom
codingjoe-fair-multi-queue-scheduling

Conversation

@codingjoe

Copy link
Copy Markdown
Owner

--queues a b was strict precedence, not a set of queues. acquire.lua walked the key list in the order the names were passed and returned on the first non-empty queue, so while a held a single ready task nothing from b ran. A worker serving a latency-sensitive queue next to a bulk one had no correct order, only a choice of which side to starve.

Approach

The scan now starts at a rotating index and wraps:

local queue_index = (start_index + offset) % num_queues + 1

RedisTaskBackend owns the counter and advances it only after a successful acquire, so idle polls keep their backoff and do not disturb the rotation. The pop and the move into the running set stay in the same script call, so atomicity is unchanged.

The result is a hard bound rather than a probabilistic one: a ready task in another queue is served after at most num_queues - 1 acquisitions of the winning queue, regardless of how deep that queue's backlog is.

Two things worth knowing

The counter is seeded randomly. It is per-process state, which alone is not enough: a recycled worker builds a fresh backend, so every incarnation would resume at queue 0 and starve the later queues permanently. This is reachable in practice with --max-tasks below the starved queue's position - reproduced end-to-end at --queues compute io --max-tasks 1, where 20 recycled processes popped compute 20 times and the single io task never ran. random.randrange(len(self.queues)) removes the systematic skew; within a process the rotation stays deterministic.

Only one modulo is needed. The counter is wrapped on the success path ((self._rotation_offset + 1) % len(queue_names)), which keeps it inside the queue range and out of float-precision trouble in Lua, so the value is sent raw and acquire.lua rebases it against the queue count it is given.

Behaviour changes

  • Multiple queues are now served round-robin; the order of --queues no longer implies precedence.
  • A backend configured with an explicit empty QUEUES list now fails at construction (randrange(0)) instead of at the first acquire (ZeroDivisionError). Nothing shipped reaches it: --queues is nargs="+" and is validated against backend.queues.
  • With a single queue the rotation is a no-op and behaviour is identical.

Tests

Real Redis, no mocks, matching the existing suite: fairness against a fed backlog, rotation order with each task landing in the running set of the queue it came from, the counter staying bounded across acquires, idle polls not advancing it, and the seed varying across constructions. The fairness test fails under the pre-diff script. 201 passed, prek clean, patch coverage 100%.

Two follow-ups from the same read of acquire.lua are filed separately rather than folded in: batch acquire (#49) and the silent task loss when a task hash is missing (#50).

Fixes: #48

`acquire.lua` walked the queue key list in the order the names were passed
and returned on the first non-empty queue, so `--queues a b` was strict
precedence: while `a` had a single ready task, nothing from `b` ran. A worker
serving a latency-sensitive queue next to a bulk one had no correct order,
only a choice of which side to starve.

The scan now starts at a rotating index and wraps:

    local queue_index = (start_index + offset) % num_queues + 1

`RedisTaskBackend` keeps the counter and advances it only after a successful
acquire, so idle polls keep their backoff and do not disturb the rotation. A
ready task in another queue is therefore served after at most `num_queues - 1`
acquisitions of the winning queue.

Two details worth knowing:

- The counter is seeded with `random.randrange(len(self.queues))`. It is
  per-process state, so without a seed every recycled worker (small
  `--max-tasks`) would resume at queue 0 and starve the later queues forever.
- The wrap on the success path is the only modulo needed; the pop and the move
  into the running set stay in the same script call, so atomicity is unchanged.

With a single queue the rotation is a no-op and behaviour is unchanged.
@codingjoe
codingjoe merged commit 91a8371 into main Sep 14, 2026
4 checks passed
@codingjoe
codingjoe deleted the codingjoe-fair-multi-queue-scheduling branch September 14, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve multiple queues fairly instead of by command-line order

1 participant