perf: stop reading every task_result payload to prune keys Redis already expires - #15
Open
adhikjoshi wants to merge 1 commit into
Open
perf: stop reading every task_result payload to prune keys Redis already expires#15adhikjoshi wants to merge 1 commit into
adhikjoshi wants to merge 1 commit into
Conversation
…ady expires prune_old_task_results() ran from _pruning_loop every PRUNE_CHECK_INTERVAL and GET the JSON of every task_result key just to compare one timestamp. It could never delete anything: task_result keys are written with a TTL (ex=3600 when a task finishes), while the prune only deletes when now - finished_at exceeds TASK_RESULT_RETENTION (86400). Redis expiry always wins that race. Measured on a production shard over 26 days: 196.7M SCAN + 228.8M GET to issue 2 DELs -- ~2.2 hours of blocked event loop and 13.5TB of network output, roughly 96% of all command time on that shard. Sampling 1,200 live task_result keys found zero without a TTL. The stalls surfaced to callers as intermittent "read error on connection" enqueue failures. Drop the call from _pruning_loop, since Redis TTL is the mechanism. Keep the method as a manual repair entry point for the one case Redis cannot handle by itself: a key whose TTL went missing. That version pipelines TTL, an 8-byte reply, and reads a value only for keys already known to be broken, so a healthy keyspace costs zero payload transfers. Deletions use UNLINK so multi-MB frees land off the main thread, and scan_iter uses count=500 instead of the default 10. Bulk-reading with MGET would be worse, not better: task_result payloads reach 7MB, so a batched read builds one huge client output buffer, which is what pushes RSS past the container limit and gets redis-server OOM-killed. Behaviour is otherwise unchanged -- an old TTL-less key is still deleted with its task: twin, so the existing test passes untouched. Adds the control case (50 healthy keys cost 0 value reads), a single-leaked-key read count, a recent orphan being bounded rather than deleted, and a guard that the loop no longer calls the scan.
adhikjoshi
force-pushed
the
perf/prune-task-results-without-reading-payloads
branch
from
August 15, 2026 09:55
b91543e to
6b20900
Compare
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.
The problem
prune_old_task_results()ran from_pruning_loopeveryPRUNE_CHECK_INTERVAL(60s), in every worker. It SCANned the entire keyspace andGETthe JSON of everytask_result:*key just to compare one timestamp.It could never delete anything.
task_resultkeys are written with a TTL —ex=3600when a task finishes — while the prune only deletes whennow - finished_at > TASK_RESULT_RETENTION(86400). The key self-destructs 23 hours before the prune would ever consider it. Redis expiry always wins the race.Measured on a production shard (
redis-va8u7z49) over 26 days:SCANGETDELSCAN+GETwere ~96% of all command time on that shard: ~2.2 hours of blocked event loop and 13.5 TB of network output, to issue 2 deletes. Sampling 1,200 livetask_resultkeys across three shards found zero without a TTL.Redis is single-threaded, so this landed on callers as
GET task_result:*entries at 27–68 ms in the slowlog and intermittentModelQ enqueue failed: read error on connectionerrors when an enqueue timed out behind the scan.The change
_pruning_loop. Redis TTL is the mechanism; nothing needs to poll for it.ex=, aRENAME/RESTOREthat dropped it), which would otherwise live forever.TTL— an 8-byte reply — and reads a value only for keys already known to be broken. A healthy keyspace is walked without transferring a single payload.UNLINKinstead ofdelete, so multi-MB frees land off the main thread.scan_iter(count=500)instead of the default 10.Concretely, on a shard holding 17,209
task_resultkeys, one pass goes from ~1,721 SCAN round-trips plus 17,209 payload reads (gigabytes) to ~35 SCAN round-trips plus 17,209 pipelined TTL replies — about 138 KB total.Behaviour is otherwise unchanged: an old TTL-less key is still deleted along with its
task:twin. A TTL-less key that is not yet old gets an expiry set, so it can't leak.Why not MGET
Batching reads makes this worse, not better.
task_resultpayloads reach 7 MB, so oneMGETbuilds a single enormous client output buffer — and that is precisely what pushes RSS past the container memory limit and getsredis-serverOOM-killed.Testing
51 → 55 tests, all passing.The pre-existingtest_prune_old_task_resultspasses unchanged, which is the signal that the contract held.New coverage uses a wrapper that counts value reads, pipelined ones included:
test_prune_does_not_read_keys_that_have_a_ttl— the control case: 50 healthy keys cost 0 value readstest_prune_reads_only_the_key_that_lost_its_ttl— 50 healthy + 1 leaked costs exactly 1 readtest_prune_bounds_a_recent_orphan_instead_of_deleting_it— a fresh TTL-less key is kept but given an expirytest_pruning_loop_does_not_scan_task_results— the loop no longer calls the scanMutation-tested, each reverted independently and confirmed to turn the suite red:
get_calls50 instead of 0)_pruning_loop→ loop test failstask:twinunlink→ the original prune test failsWorth noting the original test built its fixture with
set()and no TTL — the one case that never occurs in production. That is why this went unnoticed.Related
The PHP port is ModelsLab/modelq-php#2. Both need releasing before the fleet sees any of this; workers currently run 1.0.14.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is enabled.