Repository navigation
feat(redis): dlq_channel — one shared DLQ stream, provenance on every entry - #277
Conversation
… entry
Add an opt-in `dlq_channel` subscribe option so many handlers (or services)
dead-letter into ONE stream a single consumer can subscribe to, instead of
one `{channel}.{handler_suffix}.dlq` key per handler. The retry stream stays
per-handler (#225); only the terminal sink is shared.
- Agent.subscribe / Channel.subscribe accept a Channel or a topic name
(namespaced like Channel(name) via resolve_dlq_channel); the transport
takes a full key. Requires retry_on_idle_ms and a non-None max_retries;
rejects the subscribed channel and the handler's retry stream as targets.
- Every DLQ entry now carries _dlq_source, _dlq_handler, _dlq_at and
_dlq_reason in its JSON body (body, not XADD fields: FastStream's decoder
reads only __data__). ReclaimerConfig gains source_stream/handler.
- Poison entries (unparseable envelopes) are wrapped in a well-formed
envelope with the original bytes as _dlq_raw_b64 instead of being copied
verbatim, so a DLQ subscriber always receives a decodable JSON object.
- Extract _encode_envelope from _inject_retry_metadata and share it.
Default behaviour without dlq_channel is unchanged.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K48zTJV8n9ecRV4x9ME5Sv
QualOps Code Quality AnalysisStatus: Summary
🟠 High Issues (1)
🟡 Medium Issues (5)
...and 2 more medium issues 📊 Full ReportPowered by QualOps |
XADD MAXLEN trims the whole stream regardless of which writer appended or whether an entry was consumed. With dlq_channel several writers share one stream, so each writer's retry_max_len would be applied to everyone else's unconsumed dead letters — the smallest cap among writers silently wins. Split the DLQ cap from the retry cap on ReclaimerConfig (dlq_max_len) and pass None for an explicit dlq_channel; per-handler DLQs and retry streams keep retry_max_len as before. Retention of a shared DLQ is the sink's job. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01K48zTJV8n9ecRV4x9ME5Sv
- Reset _retry_count to "0" on the DLQ write and record the exhausted budget as _dlq_retries, so a DLQ consumer with its own retries starts fresh instead of inheriting an already-exceeded count (zero retries, escalated first backoff). Provenance (_dlq_*) is now set-if-absent, so a second dead-lettering keeps the original origin. - Validate dlq_channel before the broker subscriber is registered, and reject any ".retry" key (another handler's retry stream is auto-consumed by that handler), not just the handler's own. - Agent.subscribe works on a copy of its kwargs: one decorator applied to several handlers no longer re-namespaces the resolved key. - Poison entries get _retry_count="0" and keep envelope headers; poison detection now also covers non-object bodies, non-integer _retry_count and entries without __data__ (previously aborted the reclaim cycle after XCLAIM or looped through the retry stream forever). - Parse once, encode once per reclaimed entry; on_dlq receives the same body dict a DLQ subscriber sees. resolve_dlq_channel goes through Channel(name) instead of re-spelling the namespace rule. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01K48zTJV8n9ecRV4x9ME5Sv
- resolve_dlq_channel raises when a string already starts with EGGAI_NAMESPACE: a pasted full key would otherwise be namespaced twice and dead letters would land in an unwatched "<ns>.<ns>.dlq". - RedisTransport.subscribe raises when retry_on_idle_ms is set without a consumer group; the reclaimer works on a group's PEL and previously just errored on every cycle for direct transport callers without handler_id. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01K48zTJV8n9ecRV4x9ME5Sv
tsiaeggai
left a comment
There was a problem hiding this comment.
Reviewed all four commits and ran the branch locally. The code does what the description says, I found no correctness bug, and the default (no dlq_channel) path is unchanged apart from the documented DLQ-entry content changes. Inline comments are nits and one design note; none block merging once the draft is ready.
Verification
- Scratch venv with faststream 0.7.5 (the
poetry.lockpin), redis-py 8.1, Python 3.12, Redis 7 in Docker. ruff check/ruff format --check: clean.mypy eggai: clean.pytest tests: 105 passed, 13 skipped (Kafka). Includes the 17 new tests intest_redis_shared_dlq.pyand the three adapted assertions intest_redis.py.- Diff is on top of current
main(merge-base ==main).
Things I specifically checked
- Double namespacing:
Agent.subscriberesolvesdlq_channelto a full key andChannel.subscriberesolves too, so I traced whether a key could be resolved twice.Agent.start()calls the transport directly, notChannel.subscribe, so no. The decorator-reuse test covers the other way that could happen. - Validation runs before
StreamSub/broker.subscriber()and before_stream_subscriptions.add, so a rejected subscribe leaves nothing behind. Confirmed by the test that asserts noh-4/h-4bgroups are registered. - The recursive
_internal_retrysubscribe does not receivedlq_channel(popped earlier), and both reclaimers of one handler get the samedlq_stream,source_stream=channelandhandler=handler_suffix. ReclaimerConfigregistry key is(stream, group, consumer), so two handlers sharing one DLQ cannot collide._parse_entrynow also guards_read_retry_count, so a non-integer_retry_countis treated as count 0 and then poisoned on claim rather than skipped forever.- Downstream compatibility: I checked two production services I maintain that use
on_dlqand read_original_message_id/_retry_count. Both keep working: theretry_countargument is unchanged,_original_message_idis still present, and_retry_countis only read off live deliveries, not DLQ entries. Their integration tests only assert that_retry_countis present in the DLQ payload, which stays true.
Answers to the maintainer questions, for what they're worth
- Per-subscription only is the right first cut. A transport-level default can be added later without breaking this shape.
- Resetting
_retry_counteverywhere is the consistent choice. Scoping it to shared DLQs would make the per-handler and shared entry shapes differ, which is worse than the one-time content change. - Same for poison wrapping.
- I'd go 0.5.0. The
_retry_countreset and the poison shape are observable by existing users (any redrive tooling or assertion reading the exhausted count), and a minor bump is the cheapest way to flag that. Functionally both are improvements. - Agree that error text belongs in a follow-up.
One design note, not a blocker
A shared DLQ is written with no MAXLEN, for good reasons. Combined with the new poison wrapping, a producer that floods a stream with unparseable payloads now grows the shared DLQ without bound, each entry carrying the raw bytes base64-encoded. The docs say retention is the sink's job, which is fine, but it may be worth an explicit sentence recommending a scheduled XTRIM and, later, an opt-in RedisTransport(dlq_max_len=...) for operators who prefer a hard cap over the risk.
…onsumers - _stamp_dead_letter: origin keys (_dlq_source/_dlq_handler/_dlq_at) stay set-if-absent, hop keys (_dlq_reason/_dlq_retries) are overwritten on every dead-lettering so an entry says why it is in *this* DLQ while still recording where it originally failed. - Fix stale on_dlq comment on ReclaimerConfig (callback gets the body dict). - Agent.subscribe: explicit raise instead of assert (disabled under -O); plugins see the caller's kwargs before dlq_channel is resolved. - CHANGELOG: lead the release entry with a migration note for anyone who reads DLQ entries or the on_dlq dict (_dlq_retries, _dlq_* keys, _dlq_raw_b64, entries without __data__ no longer retried). Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01K48zTJV8n9ecRV4x9ME5Sv
RedisTransport(dlq_max_len=...) caps a shared dlq_channel stream with XADD MAXLEN ~. Default None keeps the shared DLQ untrimmed: MAXLEN is stream-wide and consumption-blind, so a writer-side cap can delete other writers' unread dead letters. Per-handler DLQs keep retry_max_len. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01K48zTJV8n9ecRV4x9ME5Sv
tsiaeggai
left a comment
There was a problem hiding this comment.
Re-checked 371def8 and 9a777cc. All four threads addressed as discussed and resolved:
on_dlqcomment updated._stamp_dead_letter: origin keys first-wins, hop keys (_dlq_reason/_dlq_retries) overwritten; docstring, test, README, docs and changelog match.RedisTransport(dlq_max_len=...)opt-in cap for shared DLQs, defaultNone, per-handler DLQs keepretry_max_len. Happy to settle the default andXTRIMguidance on the call.- Namespace-prefixed string rejection kept, rationale makes sense.
Re-ran locally with faststream 0.7.5 (lock) against Redis 7: ruff, mypy and 105 tests green; CI green on 3.10–3.13. The changelog migration note covers everything a DLQ reader needs. Downstream services I checked remain compatible.
On the QualOps high (on_dlq gets new_count rather than new_count - 1): that is pre-existing, documented behaviour and changing it would alter what existing callbacks log, so I'd leave it.
Approving.
Summary
Adds an opt-in
dlq_channelsubscribe option to the Redis transport so many handlers (or many services) can dead-letter into one stream that a single consumer subscribes to, instead of one{channel}.{handler_suffix}.dlqkey per handler. Every DLQ entry now also carries provenance in its body (_dlq_source,_dlq_handler,_dlq_at,_dlq_reason,_dlq_retries), is safe to consume (not just inspect), and poison entries are wrapped in a decodable envelope instead of being copied as raw bytes.Default behaviour without
dlq_channelis unchanged. Opened as a draft to discuss the API shape before it lands; see "Questions for maintainers" at the end.Why
The DLQ is already an
XADDinto a plain stream with the normal FastStream envelope, so an eggaiChannelcan consume it today. What makes "subscribe to the DLQ" impractical is everything around that:<agent>-<function>-<n>. A listener has to know every key, and a renamed handler leaves it watching an empty stream. Handlers registered viaChannel.subscribeeven embed the namespace twice in the key._retry_count/_original_message_id.Concrete driver: a pipeline with six handlers where exhausted retries need to move an email to a "Processing Error" folder via another service. Today that means six fragile subscriptions and no way to tell the entries apart.
What changes
dlq_channel(new subscribe option)Agent.subscribe/Channel.subscribeaccept aChannelor a topic name; a name is namespaced by going throughChannel(name)itself ("dlq"→<EGGAI_NAMESPACE>.dlq). Resolution lives inchannel.py(resolve_dlq_channel), so the transport only ever sees a full key and exactly one layer prefixes (the RedisTransport: with a non-default EGGAI_NAMESPACE the reclaimer, retry and DLQ streams use eggai.<ns>.<topic> while consumption uses <ns>.<topic>, so retries never fire #261/Agent.subscribe() with no channel hardcodes "eggai.channel", ignoring EGGAI_NAMESPACE #264 lesson).RedisTransport.subscribetakes the full key (consistent with itschannelargument).dlq_channel, else the existing per-handler default. Both reclaimers of a subscription (main stream and retry stream) target the same DLQ, as before.agent.pyand again in the transport before the broker subscriber is registered (that registration cannot be undone): requiresretry_on_idle_ms; requiresmax_retriesnotNone; rejects the subscribed channel (self-loop); rejects any key ending in.retry(every.retrystream is auto-consumed by some handler, so dead-lettering into one feeds that handler's retry loop).Agent.subscribeworks on a copy of its kwargs, so one decorator object applied to several handlers no longer re-namespaces an already-resolved key.A DLQ entry is safe to consume, not just inspect
A DLQ entry is two things at once: the record of a finished retry history, and, once something subscribes to the DLQ, a first delivery to a new consumer. Two rules follow (
_stamp_dead_letterinpending_reclaimer.py):_retry_countis reset to"0"on the DLQ write; the exhausted budget moves to_dlq_retries(==max_retries,"0"for poison). Without this a DLQ consumer with its ownretry_on_idle_msinherited an already-exceeded count: zero retries, dead-lettered again on its first transient failure, and with backoff a first reclaim threshold ofbase × multiplier^exhausted._dlq_source/_dlq_handler/_dlq_atsurvive instead of being overwritten with the consumer's own._dlq_source_dlq_handler_dlq_at_dlq_reason"max_retries"or"poison"_dlq_retries_retry_count"0"Keys go in the JSON body rather than as extra
XADDfields because FastStream's decoder reads only__data__.ReclaimerConfiggainssource_streamandhandlerto carry this. Theon_dlqcallback receives the same body dict a subscriber would see.No
MAXLENon a shared DLQXADD MAXLENtrims the whole stream regardless of which writer appended or whether an entry was consumed. With several services writing to one DLQ, each with its ownretry_max_len, the smallest cap among them would silently delete the others' unconsumed dead letters.ReclaimerConfignow has a separatedlq_max_len; the transport passesRedisTransport(dlq_max_len=...)(defaultNone, i.e. untrimmed, retention is the sink's job) for an explicitdlq_channelandretry_max_lenfor the per-handler default, as before.Poison entries are wrapped, and more of them are caught
_poison_bodybuilds a fresh envelope whose body holds the_dlq_*fields,_original_message_id, a zero budget, and the original bytes as_dlq_raw_b64. Envelope headers (correlation id, traceparent) are kept when only the body was unusable. Every DLQ entry now decodes to a JSON object.Poison detection (
_parse_entry) also now covers three cases that previously escaped it, all pre-existing onmain: a parseable envelope with a non-object JSON body or a non-integer_retry_countraised outside the guarded parse and aborted the whole reclaim cycle afterXCLAIM, head-of-line blocking every later pending entry; an entry with no__data__field (non-eggai producer) ping-ponged between main and retry stream forever with a count that could never grow.Refactor
The envelope is parsed once and encoded once per reclaimed entry.
_inject_retry_metadatanow returns(headers, body, new_count) | None; the DLQ path merges provenance into the same dict and encodes;on_dlqgets that dict._encode_envelopeis shared by all writers (the #263 byte-length fix is preserved and still covered by its test).Backward compatibility
dlq_channeloption_dlq_*body keys_retry_count_retry_countreset to"0"on DLQ entries_dlq_retries;on_dlq'sretry_countargument is unchangedon_dlqgets that dict instead of{b"__data__": bytes}.retryrejected asdlq_channelKafka and in-memory transports are untouched; they have no retry/DLQ machinery and already don't accept the other
retry_*kwargs.Version: 0.5.0 (agreed in review), to flag the DLQ-entry content changes to existing users.
Files
eggai/transport/pending_reclaimer.py—source_stream/handler/dlq_max_lenonReclaimerConfig;_encode_envelope,_parse_entry,_encode_body,_stamp_dead_letter,_poison_body,_fields_as_json; single parse/encode in_reclaim_once;_invoke_on_dlqtakes the body dicteggai/transport/redis.py— pop + validatedlq_channelbefore broker registration; choosedlq_stream/dlq_max_len; pass provenance into both_setup_reclaimercalls; docstringseggai/channel.py—resolve_dlq_channel;Channel.subscriberesolves the optioneggai/agent.py— kwargs copied per application; decoration-time validation and resolutiontests/test_redis_shared_dlq.py— new module, 20 tests (below);tests/test_redis.py— three tests adapted to the new return shape /_dlq_retriesREADME.md,docs/docs/sdk/redis-transport.md,CHANGELOG.mdTests
New
tests/test_redis_shared_dlq.py(auto-skipped without Redis on 6379, liketest_redis.py):resolve_dlq_channelnamespacing;_stamp_dead_letterresets the budget and keeps the first origin on a second dead-lettering;_parse_entryrejects garbage, list bodies and non-integer counts;_poison_bodyround-trips the raw bytes, keeps headers, zero budget;_dlq_metadatarecords the original channel from a retry-stream configdlq_stream, two distinct retry streams, correct(source_stream, handler)pairs; shared DLQ hasdlq_max_len=Nonewhile retry streams and per-handler DLQs keep the cap; default unchanged when the option is absent; every validation error at transport level (incl. another handler's.retry, and nothing registered afterwards), atAgent.subscribedecoration time, andChannel.subscriberesolution; decorator reuse does not double-namespacegroup_start="0"and sees_dlq_source/_dlq_handler/_dlq_reason/_dlq_retries/_retry_count == "0"__data__and an entry with no__data__left in the PEL are both dead-lettered as decodable envelopes with_dlq_reason="poison"and_dlq_raw_b64, the originals are acked, nothing hits the retry streamLocally: full
tests/suite green against Redis (105 passed, Kafka tests skipped),ruff check/format --checkclean,mypy eggaiclean. CI on the first commit was green across 3.10–3.13.Review history on this PR
QualOps' second round (on the second commit) reported four highs. Two are false positives:
_read_retry_countdoes read the stream the entry lives in (the retry reclaimer is configured withstream=retry_stream), and the envelope header count is pairs, exactly what FastStream's parser loops over (covered by the non-ASCII round-trip test). The other two are addressed in the fourth commit:retry_on_idle_mswithout a consumer group is now aValueError(pre-existing gap, direct transport callers only), and adlq_channelstring that already starts with the namespace is rejected instead of being namespaced twice.Two independent reviews ran on the first commit (a high-effort local review and QualOps). Everything they confirmed is addressed in the follow-up commits: shared-DLQ
MAXLENtrimming,_retry_countcarry-over and provenance overwrite, retry-stream collision checked after broker registration and only against the handler's own stream, decorator kwargs mutation, poison entries without_retry_count, non-object bodies / missing__data__escaping the poison path,on_dlqpoison shape undocumented, duplicated namespacing rule, triple parse/encode.Open questions, resolved in review
dlq_channelonly; a transport-level default can come later without breaking this form._retry_countreset applies to every DLQ entry, shared or per-handler, so both have one shape.Still open, to be discussed offline: retention of a shared DLQ under a poison flood (scheduled
XTRIMguidance vs. an opt-inRedisTransport(dlq_max_len=...)).🤖 Generated with Claude Code
https://claude.ai/code/session_01K48zTJV8n9ecRV4x9ME5Sv