You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Feature] No broker actor caps redelivery or has a dead-letter path, so a poison message hot-loops on AMQP requeue, redelivers forever on JetStream and sits in the Redis PEL with nothing to reclaim it #991
Every broker actor in src/io/broker/ gives the application a way to say "I could not process this" — negativeAcknowledgment on Kafka and AMQP, nak on JetStream, a missing acknowledgment on Redis Streams. None of them has anywhere for the message to go. There is no retry counter, no attempt cap, no dead-letter destination, and nothing that distinguishes "the database was briefly down" from "this payload will never parse".
The consequences are protocol-specific and all of them are production-relevant:
AMQP hot-loops.nack defaults to requeue: true, so a permanently-malformed message returns to the queue immediately and is redelivered at once. With prefetch: 1 and a single consumer that is a tight loop between the broker and the actor, at full CPU, forever. No exchange is declared as a DLX in assertQueue either, so RabbitMQ has nowhere to route it even if the application nacked with requeue: false.
JetStream redelivers forever. The consumer config the actor builds has no max_deliver; NATS defaults to unlimited. A message that is nak'd on every delivery is redelivered until the stream's retention policy removes it. (term()is exposed per message, so an application can opt out one message at a time — there is just no policy that does it automatically.)
Redis Streams neither redelivers nor drops. An unacked entry sits in the group's pending-entries list with nothing to reclaim it. The code says so in a comment.
Kafka re-delivers on rebalance with no attempt counter, so the same record can cycle indefinitely across restarts.
What is missing is one policy, applied uniformly: cap the attempts, then route the message somewhere an operator can look at it.
Proposed shape
A shared, opt-in deadLetter block in BrokerCommonOptionsType, implemented once in BrokerActor and specialised by each subclass at the one point it settles a message:
readonly deadLetter?: {/** Attempts before the message is considered poison. Default 5. */
readonly maxAttempts?: number;/** What to do when the cap is reached. */
readonly action?: 'drop'|'dead-letter'|'protocol';// default 'dead-letter'/** Where dead-lettered payloads go when `action: 'dead-letter'`. */
readonly target?: ActorRef<PoisonMessage<unknown>>;};
with 'protocol' meaning "use the broker's own facility", which is where the per-subclass work is:
AMQP — declare a DLX/DLQ pair alongside assertQueue when deadLetter is configured, and nack with requeue: false once the cap is reached so the broker routes it. queueOptions already exists as the place to thread deadLetterExchange through.
JetStream — set max_deliver on the consumer (the field is simply absent from ConsumerAddConfig today) and call term() rather than nak() at the cap.
Kafka — count attempts per (topic, partition, offset), and at the cap either produce to a <topic>.DLT and commit, or drop-and-commit; the pendingCommits map is already keyed exactly right for the counter.
MQTT — nothing to do until the QoS/ack story is resolved (filed separately in this batch).
The framework already has system.deadLetters and #433 is the durable-DLQ-with-replay track, so action: 'dead-letter' should route there rather than inventing a second inspection surface.
Evidence for the current state
AMQP nacks back into the queue by default, src/io/broker/AmqpActor.ts:195-201:
Redis Streams states the gap in its own comment, src/io/broker/RedisStreamsActor.ts:131-141:
src/io/broker/RedisStreamsActor.ts:131-141/** * Fire-and-forget — awaiting the `XACK` would stall the mailbox behind a * broker round-trip. A failure is logged and the entry stays in the * group's pending list; nothing reclaims it yet (see #462). */privateonAcknowledgment(command: AcknowledgmentCommand): void{if(this.redis&&this.options.consumerGroup){voidthis.redis.xack(command.stream,this.options.consumerGroup.group,command.id).catch((e: Error)=>this.log.warn(`xack failed: ${e.message}`));}}
Kafka's nack path is a warn and a rejection, with no counter, src/io/broker/KafkaActor.ts:450-460:
src/io/broker/KafkaActor.ts:450-460privateonNegativeAcknowledgment(command: NegativeAcknowledgmentCommand): void{
const key=pendingKey(command.topic,command.partition,command.offset);constpending=this.pendingCommits.get(key);if(!pending)return;this.log.warn(`KafkaActor: nack for ${key}${command.reason ? ` (${command.reason})` : ''} — `+`re-delivery will happen on next rebalance`,);pending.fail(newError(command.reason??'KafkaActor: nack from handler'));this.pendingCommits.delete(key);}
Acceptance
A deadLetter block exists in BrokerCommonOptionsType with maxAttempts, an action and a target, resolvable from HOCON like every other broker option.
AMQP declares a DLX/DLQ when configured and nacks with requeue: false at the cap, so a poison message cannot hot-loop.
max_deliver is settable on the JetStream consumer and is set from maxAttempts.
Kafka counts attempts per record and stops re-delivering at the cap.
Delivery-attempt counts are visible — a metric per broker actor, and the count carried on the inbound message type.
Docs (EN + DE) explain the poison-message model per protocol.
Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: confirmed by reading. No broker driver is installed in this tree and the review does not start brokers, so the AMQP requeue loop was not observed live; every element of the finding is a textual absence checkable in the source — the requeue ?? true default, the assertQueue call with no dead-letter arguments, the ConsumerAddConfig type with no max_deliver member, and the Redis comment stating that nothing reclaims the PEL. The one correction to the original finding: JetStream does expose term() per message (src/io/broker/JetStreamActor.ts:632), so an application can terminate a single poison message by hand; what is missing is the automatic cap, not the primitive.
Adjacent issues: #462 (RedisStreams PEL reclaim) is a prerequisite for the Redis leg and is not a duplicate. #433 (persistent DLQ with inspection and replay) is the destination this would route to. #222 (poison-pill quarantine after N failures on the same message digest) is the mailbox-level analogue and shares the counting idea but not the protocol integration. #650/#875 are the projection-side failure strategy — same problem shape, different subsystem. #670 (unify the broker subscribe/unsubscribe vocabulary) is the sibling consolidation and should probably land alongside this one.
Part of the production-readiness review batch — tracked in #913.
Use case
Every broker actor in
src/io/broker/gives the application a way to say "I could not process this" —negativeAcknowledgmenton Kafka and AMQP,nakon JetStream, a missingacknowledgmenton Redis Streams. None of them has anywhere for the message to go. There is no retry counter, no attempt cap, no dead-letter destination, and nothing that distinguishes "the database was briefly down" from "this payload will never parse".The consequences are protocol-specific and all of them are production-relevant:
nackdefaults torequeue: true, so a permanently-malformed message returns to the queue immediately and is redelivered at once. Withprefetch: 1and a single consumer that is a tight loop between the broker and the actor, at full CPU, forever. No exchange is declared as a DLX inassertQueueeither, so RabbitMQ has nowhere to route it even if the application nacked withrequeue: false.max_deliver; NATS defaults to unlimited. A message that is nak'd on every delivery is redelivered until the stream's retention policy removes it. (term()is exposed per message, so an application can opt out one message at a time — there is just no policy that does it automatically.)What is missing is one policy, applied uniformly: cap the attempts, then route the message somewhere an operator can look at it.
Proposed shape
A shared, opt-in
deadLetterblock inBrokerCommonOptionsType, implemented once inBrokerActorand specialised by each subclass at the one point it settles a message:with
'protocol'meaning "use the broker's own facility", which is where the per-subclass work is:assertQueuewhendeadLetteris configured, and nack withrequeue: falseonce the cap is reached so the broker routes it.queueOptionsalready exists as the place to threaddeadLetterExchangethrough.max_deliveron the consumer (the field is simply absent fromConsumerAddConfigtoday) and callterm()rather thannak()at the cap.(topic, partition, offset), and at the cap either produce to a<topic>.DLTand commit, or drop-and-commit; thependingCommitsmap is already keyed exactly right for the counter.XAUTOCLAIM/XCLAIM) first; with reclaim in place, an entry whosedelivery_countexceeds the cap isXACKed and copied to a dead-letter stream.The framework already has
system.deadLettersand #433 is the durable-DLQ-with-replay track, soaction: 'dead-letter'should route there rather than inventing a second inspection surface.Evidence for the current state
AMQP nacks back into the queue by default,
src/io/broker/AmqpActor.ts:195-201:and the queue is asserted with no dead-letter routing,
src/io/broker/AmqpActor.ts:105-109:The JetStream consumer config has no
max_deliverfield to set,src/io/broker/JetStreamActor.ts:637-646:Redis Streams states the gap in its own comment,
src/io/broker/RedisStreamsActor.ts:131-141:Kafka's nack path is a warn and a rejection, with no counter,
src/io/broker/KafkaActor.ts:450-460:Acceptance
deadLetterblock exists inBrokerCommonOptionsTypewithmaxAttempts, an action and a target, resolvable from HOCON like every other broker option.requeue: falseat the cap, so a poison message cannot hot-loop.max_deliveris settable on the JetStream consumer and is set frommaxAttempts.Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: confirmed by reading. No broker driver is installed in this tree and the review does not start brokers, so the AMQP requeue loop was not observed live; every element of the finding is a textual absence checkable in the source — therequeue ?? truedefault, theassertQueuecall with no dead-letter arguments, theConsumerAddConfigtype with nomax_delivermember, and the Redis comment stating that nothing reclaims the PEL. The one correction to the original finding: JetStream does exposeterm()per message (src/io/broker/JetStreamActor.ts:632), so an application can terminate a single poison message by hand; what is missing is the automatic cap, not the primitive.Adjacent issues: #462 (RedisStreams PEL reclaim) is a prerequisite for the Redis leg and is not a duplicate. #433 (persistent DLQ with inspection and replay) is the destination this would route to. #222 (poison-pill quarantine after N failures on the same message digest) is the mailbox-level analogue and shares the counting idea but not the protocol integration. #650/#875 are the projection-side failure strategy — same problem shape, different subsystem. #670 (unify the broker subscribe/unsubscribe vocabulary) is the sibling consolidation and should probably land alongside this one.
Part of the production-readiness review batch — tracked in #913.