Skip to content

feat: hold tasks in a per-worker in-flight list so none can be lost in transit - #17

Open
adhikjoshi wants to merge 1 commit into
fix/redis-blocking-read-hangfrom
feat/blmove-inflight-no-task-loss
Open

feat: hold tasks in a per-worker in-flight list so none can be lost in transit#17
adhikjoshi wants to merge 1 commit into
fix/redis-blocking-read-hangfrom
feat/blmove-inflight-no-task-loss

Conversation

@adhikjoshi

@adhikjoshi adhikjoshi commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Stacks on #16. Based on fix/redis-blocking-read-hang because it rewrites the same queue-read line. GitHub will retarget this to main automatically once #16 merges. Review #16 first.

The hole

BLPOP removes the task from the queue and then writes it to the client. If that write is lost, the task exists nowhere:

  • not in ml_tasks — it was popped
  • not in processing_tasks — that is only populated after the reply arrives
  • therefore invisible to requeue_stuck_processing_tasks and every other recovery path

This is not theoretical — I reproduced it in production

On 2026-08-15, with a worker's connection blackholed at the Redis host (verified biting: 23 packets dropped Redis→worker, 92 worker→Redis):

Marked probe pushed to ml_tasks 09:53:48
Redis served it to the blocked client client idle reset at that exact moment — pop and write both happened
In the queue afterwards 0 copies, llen=0
Worker log nothing — never received it

Both BLPOP clients were on the blackholed IP, so no other replica could have taken it. The task was destroyed.

This very likely explains the generation records seen stuck in processing with nothing queued behind them — 15 on text_to_audio and 20 on flux_2_dev that same morning.

The fix

BLMOVE makes taking the task and recording who took it one atomic step.

  • The worker moves the task into inflight:{server_id}:{worker_id} and holds custody until done — whatever the outcome — via a finally.
  • Every in-flight list is registered in a set, so recovery enumerates them rather than SCANning (this codebase has been bitten by SCAN storms before).
  • At startup a server drains its own lists: those can only be debris from a previous run of the same id.
  • The pruning loop drains lists whose owning server is no longer registered, running after prune_inactive_servers so the dead are already deregistered on the same pass.

The one flag that must not be wrong

include_self. At startup our lists are debris and must be drained. From the periodic sweep they belong to our own running workers, and draining those would hand a live task to a second worker while the first is still executing it. Both directions have a test.

drain_inflight is bounded by the list length read up front rather than looping until empty — an unbounded loop would spin the day the no-appender assumption breaks.

Compatibility

Requires Redis 6.2+ for BLMOVE/LMOVE. The fleet runs 7.4.2.

Testing

10 new tests in tests/test_inflight_custody.py; suite 62 → 72. The bounded-read test from #16 now asserts the timeout on BLMOVE rather than BLPOP — same property, new command.

Mutation-tested, and worth being explicit about: reverting to BLPOP and dropping the custody release initially survived. My first assertions only inspected list lengths after the fact, which is true either way — a test that passes both ways is worse than no test. The custody test now observes a genuinely in-flight task mid-run, holding the worker inside the task body with an event:

mutation result
revert BLMOVEBLPOP red
drop the lrem custody release red
let the sweep drain live workers' lists red

What this does not fix

Only loss in transit to a worker. A worker that receives a task and then dies mid-execution is covered by the existing processing_tasks sweep, unchanged here. And this is orthogonal to #16: #16 stops workers stalling, this stops tasks vanishing during whatever stall remains.


View with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is enabled.

…n transit

BLPOP removes the task from the queue and *then* writes it to the client. If
that write is lost, the task exists nowhere: not in ml_tasks, not in
processing_tasks (which is only populated after the reply arrives), and so
invisible to requeue_stuck_processing_tasks and every other recovery path.

This is not theoretical. Reproduced against production on 2026-08-15 by
blackholing a worker's connection at the Redis host and pushing a marked probe
task. Redis served it to the blocked client — the client's idle timer reset at
the moment of the push, so the pop and the write both happened — and the task
was then absent from the queue with no worker ever logging receipt. It very
likely explains the generation records seen stuck in "processing" with nothing
queued behind them.

BLMOVE makes taking the task and recording who took it a single atomic step:

- the worker moves the task into `inflight:{server_id}:{worker_id}` and holds
  custody until it is done, whatever the outcome, via a finally
- every in-flight list is registered in a set, so recovery enumerates them
  instead of SCANning for them
- at startup a server drains its own lists, which can only be debris from a
  previous run of the same id
- the pruning loop drains lists whose owning server is no longer registered,
  running after prune_inactive_servers so the dead are already deregistered

include_self is the one flag that must not be wrong. At startup our lists are
debris and must be drained; from the periodic sweep they belong to our own
running workers, and draining those would hand a live task to a second worker.
Both directions are tested.

drain_inflight is bounded by the list length read up front rather than looping
until empty, so it cannot spin if the no-appender assumption ever breaks.

Requires Redis 6.2+ for BLMOVE/LMOVE. The fleet runs 7.4.2.

The bounded-read test from the connection-liveness work now asserts the
timeout on BLMOVE rather than BLPOP; the property it pins is unchanged.

Mutation-tested: reverting to BLPOP, dropping the custody release, and letting
the sweep drain live workers' lists each turn a test red. The first two
initially survived against weaker assertions, which is why the custody test now
observes a genuinely in-flight task mid-run rather than inspecting list
lengths after the fact.
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