Stop silent permanent failures after a broker outage
Summary
Three defects turned a transient broker error into a permanently half-dead process that looked healthy to every external signal — heartbeat file fresh, zero restarts — so no orchestrator ever restarted it. Reported by @tobias-dev in #6 (closed) (a 3.5-day scheduler outage) and #7 (closed) (2h20m of accepted-but-invisible events on a batched queue).
Tests land in the first commit and fail; the fix lands in the second and turns them green.
What was wrong
Scheduler.run() died permanently on a transient error. acquire_lock and
get_metadata sat outside the loop's only try, so one ConnectionError ended
the coroutine for the life of the process while the queue consumers on the same
connection logged, backed off, and recovered. The loop body is now guarded per
iteration, the way Worker.consume_queue already was. CancelledError
re-raises so cancellation semantics are unchanged.
Nothing supervised the long-lived tasks. runworker and the embedded ASGI
app both held their asyncio.Task objects for the whole process lifetime and
only awaited them at shutdown, so asyncio's "Task exception was never retrieved"
never fired and the traceback sat in the Task unread. Any unhandled exception
in Worker.run() or Scheduler.run() became a silent half-outage. Crashes are
now logged with their traceback; runworker also stops so an orchestrator
restarts it. The embedded app additionally retains the Task references, which it
was discarding entirely — a garbage-collection hazard independent of the
logging.
fetch_batch reported broker failures as an empty queue. A bare
except Exception: break returned [], exactly what an idle queue returns, so
the error never reached consume_queue's handler and a batch queue could fail
every fetch for hours without a single log line. Failures now propagate.
One deliberate deviation from the fix proposed in #7 (closed): a failure after some
tasks were already collected returns the partial batch and logs a warning
instead of raising. BLMPOP has already removed those tasks from the queue and
they exist only in that list, so raising would lose them outright.
Verification
Reproduced against a real Valkey before and after, not just in the suite.
| before | after | |
|---|---|---|
| scheduler across broker stop/start | 22 ticks → 22, dead forever, 0 log lines | 11 → 23, resumed, 2 Scheduler loop failed, retrying |
Consumer error logs, default vs batch queue |
8 vs 0 | 2 vs 2 |
| SIGTERM shutdown | clean | clean, 0 false crash reports |
The supervision callback ignores tasks that finish while stop_event is already
set, so a normal shutdown is not reported as a crash — checked against a live
SIGTERM, not just in the tests.
Suite: 185 tests green including the valkey-tagged ones. The new tests use fakes
only, so they run in every CI job including test-sqlite, which has no broker
service. Each was also re-checked against a reverted fix to confirm it still
catches the regression.
Also folded in
chore(deps): django-vcache 3.1.1 — lockfile only. The pyproject.toml floor
stays at >=3.0.0 since 3.1.1 is a pyo3 0.29 and dependency refresh with no API
change.
Not addressed
The stall case. A blocking fetch has no wall-clock bound, so a broker that
stops responding without closing the connection still parks both consumers
silently. Reproduced with docker pause. Fixing it needs a decision on the
timeout: BLOCKING_TIMEOUT defaults to 1s but batch queues carry their own
(GlitchTip's ingest uses 2.0s), so the bound has to derive from the per-queue
value plus slack rather than a constant.
#7 (closed)'s permanence is still unexplained. This MR fixes the silence on the batch path, which is reproducible. It does not explain why the reporter's batch consumer never recovered even against a healthy broker — a clean broker restart and a broker stall both recovered here. Leaving #7 (closed) open for that reason.
Heartbeat liveness. /tmp/worker_health still reflects only the heartbeat
loop, not the subsystems. Less pressing now that crashes are fatal under
runworker, but it is still what lets a half-dead embedded process pass a
liveness probe.
Closes #6 (closed). Refs #7 (closed) — partially addressed, see above.
Update (rebased and picked back up)
Rebased onto current main. The branch was 25 days stale and its last green
pipeline was on Django 6.0.6; main has since moved to 6.1 via !28 (merged). The
rebase is clean, and the chore(deps): django-vcache 3.1.1 commit was
automatically dropped as already upstream in that refresh — so the branch no
longer touches uv.lock or .gitlab-ci.yml at all and is purely the fix.
Re-verified on 6.1: 196 tests OK on Postgres 18 + Valkey, 179 OK on
SQLite, ruff clean on the CI-gated scope.
Two commits that existed only locally are now pushed — including
fix: don't report a graceful embedded shutdown as a crash, which was the real
reason this looked abandoned. Without it, every clean deploy logs two spurious
exited unexpectedly ERRORs plus a ValueError: I/O operation on closed file.
Added: the consumer gets the same retry backoff as the scheduler. The
scheduler half of this branch replaced its flat 1s retry with exponential
backoff and a traceback-once rule, and its own comment explains why — a
34-minute outage emitted ~2000 tracebacks into the deployment's own error
tracker. consume_queue had the identical flat retry and did not get the same
treatment, and there it runs per queue, so the same outage produced that flood
multiplied by the queue count. It had received the run_once guard but not the
backoff, which read as the fix stopping halfway. It now mirrors the scheduler,
including resetting the moment a fetch succeeds so a recovered broker returns to
normal cadence. Both new tests fail against the unfixed loop with the intervals
flat at [1, 1, 1].
Independent evidence on #7 (closed), and it supports leaving it open. The
fault-injection harness in !30 (merged) now runs a batch queue and a normal queue under
load simultaneously and asserts per queue, since #7 (closed)'s signature is one queue
dying while its sibling recovers. Against main — with fetch_batch still
swallowing errors — a broker stop/start recovered fully (both queues 100%), and
so did a scenario that starts a worker only after the broker is already healthy,
which is the specific case the reporter could not explain. So the swallowing
produces the reported silence but not, in any shape reproduced so far, the
reported permanence. Refs #7 rather than Closes #7 is the right call.
AI disclosure: Claude Code (Opus 5) wrote the tests, the fix, the changelog entry, and this description, working from the two issue reports. Direction, scoping, and review are mine. The live before/after reproduction against a real Valkey was run and its output inspected, not asserted from reading the code. The rebase, the consumer-backoff commit, and the #7 (closed) harness evidence in this update were also done with Claude Code, and re-verified on Django 6.1.