DLQ replay — design (AB#14842)
Status: Proposed. Evidence:
deploy-availability-and-event-delivery.md, AB#14842, AB#14568 (the metrics that made this visible), AB#14628 (the gauge fix that lets a drained DLQ clear its alert).
The problem, stated precisely
A dead letter in dlq:<topic> is terminal. Nothing re-drives it when the condition
that blocked it resolves.
This is not a defect in the handler. handleReservationModified rethrows P2025
deliberately (AB#10602) so a modify consumed before its reservation.created parent is
redelivered rather than silently ACKed — the old swallow-and-ACK lost a sharer 4/4 on
test. The DLQ is the designed terminal state, bounded so a genuinely orphaned modify
cannot wedge the stream.
What fails is the budget against reality: redelivery is MAX_RETRIES (3) at
CLAIM_MIN_IDLE_MS (30 s) ≈ 90 seconds. Measured on prod 2026-09-04: the parent row
for e0fcd349… was created at 10:37:55Z against a modify at 05:41:15Z — a
five-hour lag. No budget of that shape could have covered it. The event correctly
dead-lettered, and then stayed dead after the blocker cleared.
Two ways the obvious design is wrong
1. Do not re-publish to evt:<topic>. XADD fans out to every consumer group on
the stream. reservation.modified carries 22 groups; 10 of them processed the event
successfully. Re-publishing converts one stuck event into 20+ duplicate deliveries, and
this platform's handler idempotency is load-bearing but not guaranteed. The existing
channel-service replay (AP-T11-016) does exactly this — acceptable there because its
DLQ is a Postgres table with a single consumer, but not transferable here.
2. Do not replay unconditionally. 40fc9273… is still absent from the read-model.
Replaying it re-fails, re-dead-letters, and churns. A replay must be gated on the
blocking condition having actually resolved.
Design
Ownership: the consuming service, not a central sweeper
Only the consuming service knows two things a sweeper cannot: which consumerGroup is
its own (the DLQ entry records it), and whether the blocker resolved (that question
is service-specific — here, "does this reservation exist in my read-model yet?").
Mechanism: invoke the handler, never the stream
Replay calls the handler directly with the stored payload. No XADD, so no fan-out,
no duplicate delivery to the groups that already succeeded. The service owns both the
DLQ entry and the handler, so it can do this without any new transport.
Shape
A generic primitive in @hmp/messaging, wired per service — mirroring how
OutboxRelayService is generic and each service provides its own model and schedule.
replayDlq({
topic, // dlq:<topic> to scan
ownsGroup: (g) => boolean, // is this entry mine? (matches on consumerGroup)
canReplay: async (payload) => boolean, // has the blocker resolved?
handler: async (payload) => void, // the SAME handler that failed
maxAttempts, maxAgeDays,
})
Lifecycle, and where the safety lives
- Scan
dlq:<topic>; skip entries whoseconsumerGroupis not ours. canReplay(payload)false → leave it, try again next cycle. No attempt is counted: a precondition that is not yet met is not a failure.canReplaytrue → invoke the handler.- success →
XDELthe entry. Only then. - throw → leave the entry, increment attempts, record the error.
- success →
attempts >= maxAttemptsor age>= maxAgeDays→ stop trying and surface it. An entry that can never be replayed must become visible, not retried forever.
XDEL-after-success is the property that matters: a replay that fails loses nothing,
because the entry is still in the DLQ.
Concurrency
Multiple replicas will scan the same DLQ. Borrow the pattern channel-service already
proved: claim before acting, release on failure, so two replicas cannot both drive
one entry. A Redis SET NX on dlqreplay:<topic>:<id> with a short TTL is sufficient —
the claim need only outlive one handler invocation.
Idempotency is still required
A handler may have partially applied before throwing. Replay therefore demands the same
idempotency the reclaim path already demands (@hmp/messaging redelivers on any
non-ACK). This design does not weaken that requirement; it inherits it.
Why this is observable now, and was not before
hmp_messaging_dlq_size did not exist until AB#14568 — the metric was a no-op in every
service. And until AB#14628 a gauge label set was never removed, so a drained DLQ would
have kept reporting its old depth forever and HMPMessagingDLQNonEmpty could never
have cleared. Both had to land before a replay was worth building: without them, draining
the queue would have been invisible and the alert would have stayed red.
Scope of the first implementation
reservation.modified on hmp-frontdesk-service, with
canReplay = (p) => reservation row exists for p.reservationId. That is the measured
case: 2 reservations, 5 events, one now replayable (e0fcd349…, parent present since
2026-09-04T10:37) and one correctly not (40fc9273…, still absent).
Explicitly out of scope: the 8 cohort-A dead letters from June, whose consumer groups were destroyed by the AB#14628 sweep. Nothing owns them, so no service can claim them — they need a one-off decision (replay under a current group, or discard with a reason), not an automated path.
Acceptance criteria
- GIVEN a dead letter whose blocker has resolved, WHEN the replay runs, THEN the handler
is invoked and the entry is
XDELed only after it returns successfully. - GIVEN a dead letter whose blocker has NOT resolved, THEN it is left untouched and no attempt is counted.
- GIVEN a replay, THEN no message is published to
evt:<topic>— proven by asserting the other consumer groups'last-delivered-idis unchanged. - GIVEN an entry belonging to another service's consumer group, THEN it is skipped.
- GIVEN two replicas replaying concurrently, THEN each entry is driven at most once.
- GIVEN an entry past
maxAttemptsormaxAgeDays, THEN it is surfaced, not retried. - GIVEN a drained DLQ, THEN
hmp_messaging_dlq_sizefalls to 0 andHMPMessagingDLQNonEmptyclears — the end-to-end proof that the loop closes.