Skip to content

perf(queue): cut redis round-trips out of the worker hot path - #6

Merged
LouisHaftmann merged 2 commits into
mainfrom
t3code/benchmark-and-optimize-workflows
Aug 4, 2026
Merged

perf(queue): cut redis round-trips out of the worker hot path#6
LouisHaftmann merged 2 commits into
mainfrom
t3code/benchmark-and-optimize-workflows

Conversation

@LouisHaftmann

Copy link
Copy Markdown
Contributor

A CPU profile of a concurrency-50 drain showed the worker 69% idle — round-trip bound, not CPU bound. So this is not micro-optimisation; every change deletes a Redis round-trip from a hot path. Adds an e2e benchmark first so the numbers are measured rather than asserted.

Results

Adjacent runs on the same (loaded) machine, best of three:

scenario before after
drain 1 worker c=1 2 543 3 303 ops/s (+30%)
drain 1 worker c=50 8 986 15 677 ops/s (+74%)
drain c=50, 3 steps 5 536 8 319 ops/s (+50%)
drain c=50, 10 groups 8 716 14 066 ops/s (+61%)
run → wait round trip 0.85 ms 0.46 ms (-46%)
enqueue 8 315 / 37 263 7 966 / 31 872

The enqueue path is untouched, so it scores identically — that was the control. Any run where it moved was a run where the machine, not the code, had changed.

What changed

One reserve per wake, claiming a batch. It used to take four commands to get round the loop: a schedule poll, a ms-to-next-schedule poll, one reserve per job claimed, and a stalled scan. Now reserve claims a batch atomically and returns both timers and the due-schedule list with it. The schedule polls were pure overhead for the common case of a workflow with no cron schedules at all.

A saturated worker parks locally. It was doing a network round-trip to be told that a slot it owns had freed — and that hop sat directly between one job finishing and the next being claimed. It now waits on an in-process signal. When slots are free it still BRPOPs, because then the interesting event genuinely is remote.

Step memo ships with the claim. Every step.do began with an HGET that, on a first attempt, is a guaranteed miss. The claim makes the worker the sole writer of that hash, so HGETALL inside the reserve script is an exact snapshot and step reads now cost nothing. Writes still persist before a step counts as done.

The done publish carries the result. It was a content-free doorbell: the waiter got woken, then had to go ask what happened. wait() goes from four blocking round-trips to two.

Local throttle on the stalled scan, which Redis was rejecting via its gate anyway — the worker was paying a round-trip to find out.

Notes for review

  • RESERVE_BATCH_CAP = 64 bounds the batch. Redis runs Lua single-threaded, so an unbounded concurrency would turn one wake into thousands of serialized commands and pause every client on the instance. Filling a larger concurrency just takes more passes.
  • resolvePublished's non-record fallback is load-bearing, not defensive coding. During a rolling deploy a peer on the previous build publishes "1"; parsing that as a result would silently hand the caller an empty string.
  • Queue.getStepData is deleted. Not a breaking change — src/index.ts doesn't export Queue.
  • Namespace and workflow caps are client-side config, passed into the Lua on every reserve rather than stored in Redis. Pre-existing, not from this PR, but it means a process configured with a looser cap silently widens it for the whole fleet. Surfaced while writing the multi-process tests.
  • keepFailed's retention score is millisecond-granular, so same-ms failures evict in uuid order rather than finish order. Always true; removing a round-trip from wait() just made it reachable, and one existing test was relying on the latency. Test premise fixed, library behaviour unchanged.

Tests

75 passing. New coverage targets what the batch and the local park could break silently: group and namespace caps under batch claiming, distinct claim tokens within one batch, close() racing an in-flight batch, a worker held off by a cap a dead peer owns, a saturated worker still firing cron, a retry replaying steps on a different worker, the legacy publish shape, and four real OS processes draining a backlog exactly once.

Drives only the public API — `run()` / `work()` / `job.wait()` — against a real
Redis, so every number includes superjson, the Lua round-trips, the worker wake
loop and pub/sub delivery rather than a synthetic slice of them.

Five scenarios: enqueue (serial and pipelined), steady-state drain at
concurrency 1 and 50, a drain with steps, a drain across groups, and
round-trip latency. Throughput on a shared box swings several-fold under load,
so each scenario reports the best of three runs alongside its worst.
A CPU profile of a concurrency-50 drain showed the worker 69% idle: it was
round-trip bound, not CPU bound. Every change here deletes a round-trip.

`reserve` now claims a batch in one atomic call (bounded by RESERVE_BATCH_CAP,
since Redis runs Lua single-threaded and a large `concurrency` would otherwise
pause every client sharing the instance) and returns the delayed-job timer, the
cron timer and the due-schedule list with it. That replaces four commands per
wake — a schedule poll, a ms-to-next-schedule poll, one reserve per job, and a
stalled scan — with one. Claim tokens are derived in-script from a single UUID,
so a batch still gets globally unique tokens.

A worker with every slot busy now parks on an in-process signal instead of
BRPOP. It was paying a network hop to be told a slot it owns had freed, and
that hop sat directly between one job finishing and the next being claimed.

`reserve` also ships the job's step hash with the claim. The claim makes the
worker the only writer of that hash, so the read is an exact snapshot and
`step.do` resolves a memoized step with no round-trip at all — writes still
persist before a step counts as done. `Queue.getStepData` goes with it.

`complete` and `fail` publish the result record itself rather than a bare "1",
so `wait()` no longer re-reads the key the notification just carried. A
non-record payload still falls back to the stored record: during a rolling
deploy a peer on the older build publishes "1", and parsing that as a result
would hand the caller an empty string.

The stalled-recovery scan gets a local throttle so a worker stops paying a
round-trip per wake to be told a peer already holds the interval gate.

Measured against the previous commit, adjacent runs on the same loaded machine:
drain c=50 8986 -> 17427 ops/s, drain with 3 steps 5536 -> 8319, drain across
10 groups 8716 -> 14066, drain c=1 2543 -> 3303, round-trip latency 0.85 ->
0.46 ms. The enqueue path is untouched and scores identically, which is the
control.

Tests cover the invariants the batch and the local park could silently break:
group and namespace caps under batch claiming, distinct tokens within a batch,
close() racing an in-flight batch, a worker held off by a cap a dead peer owns,
a saturated worker still firing cron, a retry replaying steps on a different
worker, and four real OS processes draining a backlog exactly once.
@LouisHaftmann
LouisHaftmann merged commit d224188 into main Aug 4, 2026
3 checks passed
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.

1 participant