fix(backend): survive a lost worker instead of stranding the session (#398) #448

Merged
claude-bot merged 2 commits from fix/398-survive-a-lost-worker into main 2026-08-29 22:56:07 +00:00
Contributor

Closes #398 (CRITICAL).

The defect

Celery acknowledges a task the moment it is received, not when it finishes. A worker killed mid-job therefore took the task with it: nothing redelivered, nothing logged. The session row had already been flipped to processing on intake, and both the GM's retry endpoint and the admin's require failed and 409 on processing — so the session sat at "Processing…" permanently, with a hand-written UPDATE as the only exit.

The trigger is not exotic. An ordinary docker compose up -d --build during a three-hour transcription does it.

What ships

task_acks_late + task_reject_on_worker_lost — the broker redelivers what a dying worker was holding. Safe only because #397 landed first: a redelivered process_audio now refuses to overwrite an existing transcript rather than clobbering GM edits. That ordering was a stated precondition of this issue, and it held.

Time limits and worker_prefetch_multiplier=1, in the same change. This is the part the issue didn't ask for and the fix is wrong without. I found no time limits configured at all — so acks_late on its own converts a task that kills its worker into one that comes back and kills the next worker, indefinitely. A 6h soft limit turns that into an ordinary task failure the existing on_failure path already records as failed, which the retry buttons can act on. Six hours sits just above max_recording_hours (6), so it is a bound on pathology and never cuts off a legitimate full-length transcription. prefetch=1 keeps a worker loss to the one job actually in flight instead of a reserved batch.

An hourly watchdog (unstick_lost_tasks) for what redelivery cannot reach — a broker restart, an enqueue that never landed. Four pipelines flip a row to in-progress on intake and all four are covered: audio processing, lore pending/extracting/matching, LoreEntryDraft.generating, and GenerationResult pending. It moves rows only to failed, because that is the single state the retry paths accept; it touches no files and leaves audio_file_path intact, so the recording is still there to reprocess. Idempotent and state-driven — a second pass changes nothing and writes no duplicate audit entry.

The 8-hour threshold is deliberately above the 6h task limit: anything still in progress past it cannot be a running task any more.

Bounded retries on transport errors only. process_audio retries twice with backoff on httpx.TransportError / TimeoutError / ConnectionError. Deliberately not a blanket Exception — retrying a malformed input just delays the honest error the admin panel is waiting to show.

Verification

Mutation-checked in both halves:

  • task_acks_late=TrueFalse: 1 test fails.
  • watchdog body stubbed to a no-op (i.e. the pre-fix world): 3 fail, including the issue's own scenario — a session stranded in processing by a deploy.

The idempotency test was tightened after the first mutation run: it passed vacuously against the stub, since "the second pass changed nothing" is also true of a watchdog that never ran. It now asserts the first pass did the work.

1,417 backend tests pass (was 1,409). Lint and format clean at CI's pinned ruff 0.4.4.

🤖 Generated with Claude Code

Closes #398 (CRITICAL). ## The defect Celery acknowledges a task the moment it is **received**, not when it finishes. A worker killed mid-job therefore took the task with it: nothing redelivered, nothing logged. The session row had already been flipped to `processing` on intake, and both the GM's retry endpoint and the admin's require `failed` and 409 on `processing` — so the session sat at "Processing…" permanently, with a hand-written `UPDATE` as the only exit. The trigger is not exotic. An ordinary `docker compose up -d --build` during a three-hour transcription does it. ## What ships **`task_acks_late` + `task_reject_on_worker_lost`** — the broker redelivers what a dying worker was holding. Safe only because #397 landed first: a redelivered `process_audio` now refuses to overwrite an existing transcript rather than clobbering GM edits. That ordering was a stated precondition of this issue, and it held. **Time limits and `worker_prefetch_multiplier=1`, in the same change.** This is the part the issue didn't ask for and the fix is wrong without. I found **no time limits configured at all** — so `acks_late` on its own converts a task that kills its worker into one that comes back and kills the next worker, indefinitely. A 6h soft limit turns that into an ordinary task failure the existing `on_failure` path already records as `failed`, which the retry buttons can act on. Six hours sits just above `max_recording_hours` (6), so it is a bound on pathology and never cuts off a legitimate full-length transcription. `prefetch=1` keeps a worker loss to the one job actually in flight instead of a reserved batch. **An hourly watchdog** (`unstick_lost_tasks`) for what redelivery cannot reach — a broker restart, an enqueue that never landed. Four pipelines flip a row to in-progress on intake and all four are covered: audio `processing`, lore `pending`/`extracting`/`matching`, `LoreEntryDraft.generating`, and `GenerationResult` `pending`. It moves rows only to `failed`, because that is the single state the retry paths accept; it touches no files and leaves `audio_file_path` intact, so the recording is still there to reprocess. Idempotent and state-driven — a second pass changes nothing and writes no duplicate audit entry. The 8-hour threshold is deliberately above the 6h task limit: anything still in progress past it cannot be a running task any more. **Bounded retries on transport errors only.** `process_audio` retries twice with backoff on `httpx.TransportError` / `TimeoutError` / `ConnectionError`. Deliberately *not* a blanket `Exception` — retrying a malformed input just delays the honest error the admin panel is waiting to show. ## Verification Mutation-checked in both halves: - `task_acks_late=True` → `False`: 1 test fails. - watchdog body stubbed to a no-op (i.e. the pre-fix world): 3 fail, including the issue's own scenario — a session stranded in `processing` by a deploy. The idempotency test was tightened after the first mutation run: it passed vacuously against the stub, since "the second pass changed nothing" is also true of a watchdog that never ran. It now asserts the first pass did the work. **1,417 backend tests pass** (was 1,409). Lint and format clean at CI's pinned ruff 0.4.4. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(backend): survive a lost worker instead of stranding the session (#398)
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 46s
CI / Docker image build (pull_request) Successful in 23s
CI / Backend lint (ruff) (pull_request) Successful in 45s
CI / Bot/backend version sync (pull_request) Successful in 40s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m42s
CI / Bot tests and audit (pull_request) Successful in 2m31s
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
92b43666b3
Celery acked on receipt, so a worker killed mid-job took the job with it —
no redelivery, no log line. The row was already `processing` from intake and
both retry paths 409 on that state, so a manual UPDATE was the only exit. A
routine rebuild during a long transcription was enough to trigger it.

acks_late makes the broker redeliver, safe now that #397's re-submission
guard refuses to overwrite an existing transcript. It does not ship alone:
without a time limit a task that kills its worker comes back and kills the
next one forever, so a 6h soft limit (just above max_recording_hours, so no
legitimate run is cut) turns that into an ordinary failure the existing
on_failure path records, and prefetch=1 keeps a worker loss to one job.

The hourly watchdog covers what redelivery cannot reach — a broker restart,
an enqueue that never landed — across all four pipelines that flip a row to
in-progress on intake. It only moves rows to `failed`, which is what the
retry buttons require; it touches no files and leaves audio_file_path alone.

Mutation-checked: with acks_late reverted one test fails; with the watchdog
stubbed out, three do, including the issue's own scenario.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test(backend): exercise the watchdog's draft and workbench branches (#398)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 25s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 48s
CI / Docker image build (pull_request) Successful in 23s
CI / Bot/backend version sync (pull_request) Successful in 53s
CI / Bot tests and audit (pull_request) Successful in 1m17s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m22s
CI / Backend migration, tests, and audit (pull_request) Successful in 4m50s
65fedde351
The terminal-row test walked all four queries but only the audio and lore
assignments ever ran. A wrong column name in the draft or workbench branch
would have passed CI and failed the first time a worker actually died.

Both new tests also assert the accumulated work survives — draft body and
iteration_count, generation params — since preserving it is the reason the
watchdog fails rows rather than discarding them.

Mutation-checked: with the watchdog stubbed, five tests now fail rather than
three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch fix/398-survive-a-lost-worker 2026-08-29 22:56:07 +00:00
Sign in to join this conversation.
No description provided.