Requeue near the tail in O(offset) instead of scanning the queue with LINSERT - #436
Merged
Merged
Conversation
… LINSERT requeue.lua and reserve.lua put a requeued test back `offset` + 1 entries from the tail with `LINSERT BEFORE <pivot>`. LINSERT finds the pivot by scanning from the head, so every requeue costs O(queue length) on Redis's single command thread: about 10 ms per call on a 650k-entry queue, and about 40 ms once earlier requeues have piled up near the tail. A burst of requeues on a large queue can then block every other build that shares the Redis. Instead, read the `offset` + 1 tail entries with LRANGE, drop them with LTRIM, push the requeued entry, and push them back. That gives the same list in O(offset). Queues of at most `offset` + 1 entries, and offsets of zero or less, still push to the head. One intended difference: when the pivot's value also appears nearer the head (a duplicate entry), LINSERT inserted before that first copy, often sending the test to the back of the queue. It now lands at the offset.
tekmaven
marked this pull request as ready for review
October 2, 2026 02:21
bmaynard
approved these changes
Oct 2, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
requeue.luaandreserve.luaput a requeued test backoffset+ 1 entries from the tail of the queue (the endRPOPreserves from) withLINSERT BEFORE <pivot>(requeue.lua, reserve.lua).LINSERTfinds its pivot by scanning from the head, and the pivot sits next to the tail, so every requeue walks the whole queue on Redis's single command thread. Replicas repeat the scan.On large queues that adds up. In our CI, a burst of requeues on a queue of several hundred thousand tests kept a Redis main thread ~80% busy in
LINSERTfor about 15 minutes, at ~46 ms per call. Other builds sharing that Redis hit the client's 2-second read timeout. One build's leader timed out inpush_batch, and its workers then failed withLostMaster.What
This replaces the pivot lookup with a tail rotation, which is O(
offset) instead of O(queue length):LRANGE queue -(offset + 1) -1reads the entries that should stay ahead of the test.LTRIM queue 0 -(offset + 2)drops them.RPUSHthe requeued entry, thenRPUSHthem back in order.Queues of at most
offset+ 1 entries, and offsets of zero or less, stillLPUSHto the head. The list never becomes empty during the rotation, so the queue key keeps its TTL.KEYSandARGVare unchanged, so neither the Ruby nor the Python client changes. The helper is duplicated in both scripts because the Python client loads scripts as-is and doesn't resolve-- @include.Behavior
The new scripts give the same results as the
LINSERTversion. I checked this by running both against Redis 8.6.3 over 2,450 cases:requeue.lua, andreserve.luadeferring 1, 2 or 4 of its own requeued tests.Return values, queue contents,
requeued-by,runningand the queue's TTL matched in every case.Two edge cases differ, by design:
LINSERTinserted before that first copy. That was often the head, which is the back of the line. The test now lands at the requested offset.LINSERTinserted at index-offset - 1from the head. Offsets are positive in practice; the default is 42.Benchmarks
Setup: local Redis 8.6.3 on an Apple-silicon laptop, ~250-byte entries,
requeue_offset4. Times are Redis-reported time perEVALSHA(INFO commandstats).LINSERT(before)PINGlatency went from p50 10.1 ms / p99 13.7 ms to p50 0.13 ms / p99 0.49 ms. Throughput went from 81 to 4,127 requeues/s.Tests
Four new tests in
ruby/test/ci/queue/redis_test.rb, written against the public API (poll,requeue,to_a):offset+ 1 tests.offset+ 1 tests left, it runs last and the queue keeps its TTL.offset+ 1 tests.All four pass against the old
LINSERTscripts. Each one fails against at least one deliberately broken copy of the new code:LTRIMkeeping one entry too many;LRANGEoff by one;reserve.luaalways pushing to the head.Results locally on Redis 8.6.3:
Rollout
There's no version bump in this PR. Workers on old and new versions can share a queue: the list layout is unchanged, and both versions put requeued tests in the same place.