Uh oh!
There was an error while loading. Please reload this page.
perf: stop reading every task_result payload to prune keys Redis already expires - #2
Merged
adhikjoshi merged 1 commit intoAug 15, 2026
Conversation
…ady expires pruneOldTaskResults() SCANned the whole keyspace 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 (setex 3600 in storeResult(), TASK_RESULT_RETENTION in the webhook paths), while the prune only deletes when now - finished_at exceeds TASK_RESULT_RETENTION. 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. Across the fleet that scan was roughly 85% of all Redis CPU time. Sampling 1,200 live task_result keys found zero without a TTL. Drop the call from the startWorkers() loop, since Redis TTL is the mechanism. Keep the method as a manual repair entry point (now public) 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. 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. Adds integration coverage asserting the control case (50 healthy keys cost 0 value reads), that exactly one read happens for one leaked key, that a recent orphan is bounded rather than deleted, and that the worker loop no longer calls the scan.
This was referenced Aug 13, 2026
Uh oh!
There was an error while loading. Please reload this page.
adhikjoshi
deleted the
perf/prune-task-results-without-reading-payloads
branch
August 15, 2026 07:31
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 freeto 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
pruneOldTaskResults()ran everyPRUNE_CHECK_INTERVALinside thestartWorkers()loop. It SCANned the entire keyspace andGETthe JSON of everytask_result:*key just to compare one timestamp.It could never delete anything.
task_resultkeys are always written with a TTL —setex(..., 3600, ...)instoreResult(),TASK_RESULT_RETENTIONin the webhook paths — while the prune only deletes whennow - finished_at > TASK_RESULT_RETENTION(86400). A 1-hour key is gone 23 hours before the prune would look at it; a 24-hour key expires at the exact moment the condition becomes true. Redis expiry always wins the race.Measured on a production shard (
redis-va8u7z49) over 26 days:SCANGETDELThat is ~2.2 hours of blocked event loop and 13.5 TB of network output to issue 2 deletes. Across the fleet this scan was roughly 85% of all Redis CPU time, and it is the direct cause of the
LRANGE/SCANentries dominating the slowlog and of intermittentModelQ enqueue failed: read error on connectionerrors.Sampling 1,200 live
task_resultkeys across three shards found zero without a TTL.The change
startWorkers()loop. Redis TTL is the mechanism; nothing needs to poll for it.public) for the one case Redis cannot handle by itself: a key whose TTL went missing (a write path that forgot theEX, 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 now walked without transferring a single payload.UNLINKinstead ofDEL, so multi-MB frees land off the main thread.SCAN COUNT100 → 500.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
New
tests/Integration/PruneTaskResultsTest.phpuses aRedissubclass that counts value reads. Since phpredismulti(Redis::PIPELINE)returns the same object, this catches pipelinedGETs too.testHealthyKeysAreNeverRead— the control case: 50 healthy keys cost 0 value readstestOnlyTheKeyThatLostItsTtlIsRead— 50 healthy + 1 leaked costs exactly 1 read, and thetask:twin is deletedtestRecentOrphanIsBoundedNotDeleted— a fresh TTL-less key is kept but given an expirytestWorkerLoopDoesNotScanTaskResults— the loop no longer calls the scanMutation-tested, each reverted independently and confirmed to turn the suite red:
getCalls50 instead of 0task:twinUNLINK→ twin assertion failsFull suite: 134 tests, same 13 pre-existing Integration failures as
main(task execution / streaming / enqueue — unrelated and failing before this branch). PHPStan level 6: identical 35 pre-existing errors, none introduced.Note
The Python
modelqlibrary has the same dead loop atmodelq/app/base.py. The equivalent fix is prepared there and needs its own release.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is enabled.