Why
The Archiver-operated broker now runs with an explicit maxmemory cap (CannObserv/archiver#128 → archiver#129). Before that change maxmemory 0 + noeviction was inert: no write was ever refused, so the "error and let the producer retry" degradation the config implied never engaged. With a cap in place, memory pressure surfaces as OOM command not allowed when used memory > 'maxmemory' on XADD, instance-wide — every producer on the broker is refused at the same moment, whichever stream caused the pressure.
That is a new error path on Replicator's publish call that did not exist when the byte path was written.
The bug
redis.exceptions.OutOfMemoryError subclasses ResponseError. ResponseError is not in src/worker/loop.py's _TRANSIENT_ERRORS, so an OOM raised by handler.py::_publish on the blob_available XADD falls through to _handle_unclassified and counts against REPLICATOR_MAX_DELIVERY_ATTEMPTS — 5 reclaims at the 60 s REPLICATOR_CLAIM_MIN_IDLE_MS cadence, so roughly five minutes of incident exhausts the ceiling.
This is precisely the bug Archiver hardened against in the same change, and for the same reason: ResponseError subclassing makes it an error of omission.
Why it has not bitten yet
Instance-wide OOM refuses the <topic>.dlq XADD too, so during a steady incident the dead-letter write fails as well, _close raises, and the message survives as a cycle failure with backoff. That is luck, not design, and it does not hold in two cases:
- A flapping cap. Memory oscillating around
maxmemory burns the delivery counter without ever completing the DLQ write. times_delivered only ever advances, so after the incident any later unclassified failure of any kind dead-letters immediately, with none of the 5-attempt grace the ceiling exists to provide.
- The clearing edge. A command whose publish is refused at attempt ≥ 5 and whose DLQ write then succeeds as memory frees is dead-lettered and closed with
fetch_failed(handler_error) — for bytes that stored successfully and are sitting on disk. The issuer is told its content will never arrive while the blob exists.
Fix
Add OutOfMemoryError to _TRANSIENT_ERRORS in src/worker/loop.py:
from redis.exceptions import OutOfMemoryError
Transient is the correct classification on the same argument the tuple's existing members carry: the command is valid, the failure is someone else's incident, and a transient failure is exempt from the delivery ceiling so a long-but-genuine outage can never silently drop good work. The durable record of intent is already the PEL — the message stays unacked, claim_stale re-runs it, and content-addressed storage makes the re-run a no-op.
Watch the tuple's existing NOTE while doing this: the gate assumes co-core-aio propagates redis exception types unwrapped.
Not in scope
Producer-durability answer for archiver's deploy/README.md
The table currently reads unasserted for both streams. Once this lands:
| Stream |
Replicator's producer durability under OOM |
content.fetch |
n/a — Replicator does not produce on it. The only writer in this repo is scripts/seed_fetch.py, an operator tool. |
content.blobs |
Durable via the consumer-group PEL. _publish re-raises, the message stays unacked, claim_stale re-runs it. No DB and no outbox, by charter (docs/contracts/replicator-boundaries.md). |
Acceptance
Refs
Why
The Archiver-operated broker now runs with an explicit
maxmemorycap (CannObserv/archiver#128 → archiver#129). Before that changemaxmemory 0+noevictionwas inert: no write was ever refused, so the "error and let the producer retry" degradation the config implied never engaged. With a cap in place, memory pressure surfaces asOOM command not allowed when used memory > 'maxmemory'onXADD, instance-wide — every producer on the broker is refused at the same moment, whichever stream caused the pressure.That is a new error path on Replicator's publish call that did not exist when the byte path was written.
The bug
redis.exceptions.OutOfMemoryErrorsubclassesResponseError.ResponseErroris not insrc/worker/loop.py's_TRANSIENT_ERRORS, so an OOM raised byhandler.py::_publishon theblob_availableXADD falls through to_handle_unclassifiedand counts againstREPLICATOR_MAX_DELIVERY_ATTEMPTS— 5 reclaims at the 60 sREPLICATOR_CLAIM_MIN_IDLE_MScadence, so roughly five minutes of incident exhausts the ceiling.This is precisely the bug Archiver hardened against in the same change, and for the same reason:
ResponseErrorsubclassing makes it an error of omission.Why it has not bitten yet
Instance-wide OOM refuses the
<topic>.dlqXADD too, so during a steady incident the dead-letter write fails as well,_closeraises, and the message survives as a cycle failure with backoff. That is luck, not design, and it does not hold in two cases:maxmemoryburns the delivery counter without ever completing the DLQ write.times_deliveredonly ever advances, so after the incident any later unclassified failure of any kind dead-letters immediately, with none of the 5-attempt grace the ceiling exists to provide.fetch_failed(handler_error)— for bytes that stored successfully and are sitting on disk. The issuer is told its content will never arrive while the blob exists.Fix
Add
OutOfMemoryErrorto_TRANSIENT_ERRORSinsrc/worker/loop.py:Transient is the correct classification on the same argument the tuple's existing members carry: the command is valid, the failure is someone else's incident, and a transient failure is exempt from the delivery ceiling so a long-but-genuine outage can never silently drop good work. The durable record of intent is already the PEL — the message stays unacked,
claim_stalere-runs it, and content-addressed storage makes the re-run a no-op.Watch the tuple's existing NOTE while doing this: the gate assumes co-core-aio propagates redis exception types unwrapped.
Not in scope
fetch_failedreporter's swallow stays as it is.src/worker/reporter.pydeliberately absorbs a failed fact-publish because the DLQ entry is already the durable record; an OOM there loses a terminal fact and the issuer's reaper is the designed backstop. Changing it would burn the delivery ceiling to reach the same DLQ minutes later.content.fetch-policyreader (Consume content.fetch-policy — replace the single env default with Watcher's per-host numbers (cannobserv#285) #19) is unaffected.XREADis not a denyoom command.Producer-durability answer for archiver's
deploy/README.mdThe table currently reads unasserted for both streams. Once this lands:
content.fetchscripts/seed_fetch.py, an operator tool.content.blobs_publishre-raises, the message stays unacked,claim_stalere-runs it. No DB and no outbox, by charter (docs/contracts/replicator-boundaries.md).Acceptance
OutOfMemoryErrorclassified transient insrc/worker/loop.pyOutOfMemoryErrorreturnsOutcome.RETRYand does not touch the delivery counter or the DLQdeploy/README.mdcells can move off unassertedRefs