fix(worker): recover abandoned Pending Entries List entries (HIPAA P0) - #10
Merged
Conversation
security-audit-worker consumed audit:events with XREADGROUP ... ">" only
-- strictly new, never-before-delivered messages. A message delivered but
never acked (worker crash before persistence, a transient MySQL failure)
was invisible to every future read_group() call, from that consumer
identity or any other, forever. CONSUMER_NAME defaults to worker-{pid}
and the service runs restart: on-failure, so every crash/restart also
minted a brand-new identity with no path back to its predecessor's
orphaned entries. handle_message()'s own docstring claimed unacked
messages "can be retried"; that was false as implemented.
Adds StreamReader.claim_stale() (XPENDING's extended form, for each
entry's times_delivered, then XCLAIM for the ones still worth retrying)
and worker/main.py::sweep_pending(), called once per run() loop
iteration before read_group(). Reclaimed entries run through the exact
same handle_message() classify/persist/ack path as freshly-read
messages. An entry that has been delivered
AuditConfig.PEL_MAX_DELIVERIES times without succeeding is treated as
poison and ACKed directly, so a deterministically-malformed payload
cannot retry forever.
Concurrency-safe for multiple worker replicas with no added
coordination: XCLAIM only reassigns an entry still idle >= min_idle_ms
at the exact moment it runs, so two workers racing for the same entry
can never both succeed -- proven against a real Redis server in
test_real_concurrent_workers_racing_for_the_same_entry_only_one_wins,
not just asserted.
HMAC verification (audit/signing.py, classify_event_integrity) is
untouched. Event-id-based idempotency (consumers/sink.py's PK-on-
event_id dedup) is untouched and re-verified through the new reclaim
path specifically (test_real_duplicate_delivery_after_reclaim_does_not_
duplicate_row), not just through the pre-existing direct-Sink path.
New env vars (audit/config.py, all optional, safe defaults):
AUDIT_PEL_MIN_IDLE_MS=30000, AUDIT_PEL_MAX_DELIVERIES=5,
AUDIT_PEL_SWEEP_BATCH=100. No compose/deployment changes -- defaults
are production-safe as-is.
273 tests passing (252 baseline + 21 new: 6 unit for claim_stale(), 10
unit for sweep_pending()/run() wiring, 5 real-Redis+real-MySQL
integration covering crash-then-reclaim, concurrent-race, transient-
failure-then-retry, duplicate-delivery-after-reclaim, and bounded
poison-message retry). ruff clean on every touched/new file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 free
to 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.
Problem
security-audit-worker— the authoritative consumer for the signedaudit:eventsHIPAA audit trail — consumed Redis Streams withXREADGROUP ... ">"only: strictly new, never-before-delivered messages. A message that was delivered but never acked (worker crash before persistence, a transient MySQL failure, the process getting killed mid-write) sat in the consumer group's Pending Entries List and was never revisited — nothing in the repo calledXCLAIM/XAUTOCLAIM/XPENDING.CONSUMER_NAMEdefaults toworker-{pid}and the service runsrestart: on-failure, so every crash/restart also minted a brand-new consumer identity with no path back to its predecessor's orphaned entries.handle_message()'s own docstring claimed "leaves the message unacknowledged... so it can be retried — it is never dropped." That was false as implemented. This is the fix.Fix
StreamReader.claim_stale()—XPENDING's extended form (to read each stale entry'stimes_delivered) followed byXCLAIMfor the ones still worth retrying.XPENDING+XCLAIMchosen over the newer single-callXAUTOCLAIMspecifically becauseXAUTOCLAIMdoesn't expose per-entry delivery counts, which is what distinguishes "abandoned, retry" from "poison, give up."worker/main.py::sweep_pending()— called once perrun()loop iteration, beforeread_group(), so an abandoned-entry backlog isn't starved by steady new traffic. Reclaimed entries run through the exact samehandle_message()classify/persist/ack path as freshly-read messages.AUDIT_PEL_MAX_DELIVERIEStimes (default 5) without succeeding is ACKed directly as poison, never retried again — bounds the retry loop so a deterministically-malformed payload can't loop forever.AUDIT_PEL_MIN_IDLE_MS=30000,AUDIT_PEL_MAX_DELIVERIES=5,AUDIT_PEL_SWEEP_BATCH=100. No compose/deployment changes — defaults are production-safe as shipped.handle_message()'s docstring to describe what actually makes retry happen now.Explicitly out of scope (per task brief)
No changes to the six HMAC signing implementations, Control Center's public-read-only work, LIMS, or Redis network topology.
Concurrency safety
XCLAIMonly reassigns an entry that is still idle ≥min_idle_msat the exact moment it runs — two workers racing for the same entry can never both succeed. Proven against a real Redis server, not just asserted against a mock:test_real_concurrent_workers_racing_for_the_same_entry_only_one_winsraces two liveclaim_stale()calls viaThreadPoolExecutorand asserts total claimed == 1.Idempotency / HMAC verification
Untouched.
consumers/sink.py's PK-on-event_iddedup andaudit/signing.py's HMAC verification are not modified. The reclaim path is re-verified against both specifically:test_real_duplicate_delivery_after_reclaim_does_not_duplicate_row— a message persisted successfully by "worker A" but never acked before it "crashes" is reclaimed and reprocessed by "worker B"; exactly one row results.test_real_valid_signed_event_persists_as_valid/ existing signature tests all still pass unmodified.Tests
273 passed (252 baseline + 21 new), 0 regressions:
StreamReader.claim_stale()(mocked Redis) —tests/test_stream.pysweep_pending()/run()wiring (mocked reader) —tests/test_worker_pel_recovery.pytests/test_worker_pel_recovery_integration.py:ruff checkclean on every touched/new file. Repo-wide ruff count actually dropped (80 → 66) by cleaning up dead imports encountered along the way; 3 pre-existing intentional broad-exceptsites inworker/main.pygot the same# noqa: BLE001treatment this repo's ownaudit/logger.pyalready established.Remaining risks (documented, not fixed here)
PEL_MAX_DELIVERIES × sweep intervalstill eventually abandons a message as poison — a deliberate bounded-retry tradeoff, not unlimited durability.security-audit-worker) — that's a separate deploy decision.Do not merge until CI and review pass.
🤖 Generated with Claude Code