feat(p9): IiosDlqItem + DlqService (dead-letter + deterministic replay)

Task D.1: mandated dlq_item table (sourceType/sourceId/failureStage/errorCode/
retryCount/payloadRef/consumerName/scopeId/status). DlqService.record (idempotent
per consumer+sourceId, bumps retryCount), onConsumerFailure (quarantine + clear the
IiosProcessedEvent claim so it's replayable — fixes claim-then-fail), registerHandler
+ replay (clears claim, awaits the registered consumer handler → RESOLVED on success,
QUARANTINED+retry on repeat throw; outbox-sourced items reset to PENDING). Wired into
OutboxModule; resetDb truncates it. 3 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 01:28:48 +05:30
parent e5d3ce5ecd
commit 208fb0b4f0
6 changed files with 219 additions and 3 deletions
@@ -1178,3 +1178,24 @@ model IiosAuditLink {
@@index([resourceType, resourceId])
@@index([scopeId])
}
/// Dead-letter / replay item (P9, mandated dlq_item). A consumer/relay failure is
/// quarantined here (never silently swallowed — KG-13) and can be replayed by ops.
model IiosDlqItem {
id String @id @default(cuid())
sourceType String // 'projection' | 'outbox' | 'outbound' | ...
sourceId String
failureStage String
errorCode String?
retryCount Int @default(0)
payloadRef String? // the outbox eventId to re-dispatch
consumerName String?
scopeId String? // tenant quarantine
status String @default("QUARANTINED") // QUARANTINED | RESOLVED | ARCHIVED
createdAt DateTime @default(now())
lastFailedAt DateTime @default(now())
resolvedAt DateTime?
@@unique([consumerName, sourceId])
@@index([scopeId, status])
}