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
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "IiosDlqItem" (
"id" TEXT NOT NULL,
"sourceType" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"failureStage" TEXT NOT NULL,
"errorCode" TEXT,
"retryCount" INTEGER NOT NULL DEFAULT 0,
"payloadRef" TEXT,
"consumerName" TEXT,
"scopeId" TEXT,
"status" TEXT NOT NULL DEFAULT 'QUARANTINED',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastFailedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolvedAt" TIMESTAMP(3),
CONSTRAINT "IiosDlqItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosDlqItem_scopeId_status_idx" ON "IiosDlqItem"("scopeId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "IiosDlqItem_consumerName_sourceId_key" ON "IiosDlqItem"("consumerName", "sourceId");
@@ -1178,3 +1178,24 @@ model IiosAuditLink {
@@index([resourceType, resourceId]) @@index([resourceType, resourceId])
@@index([scopeId]) @@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])
}
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import type { CloudEvent } from '@insignia/iios-contracts';
import { DlqService } from './dlq.service';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const evt = (id: string): CloudEvent => ({
specversion: '1.0', id, type: 'x', source: 's', time: new Date().toISOString(),
insignia: { scopeSnapshotId: 'scope-A', idempotencyKey: `k:${id}` }, data: {},
});
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
// A DLQ item's replay needs the underlying outbox event to exist.
async function seedOutboxEvent(eventId: string): Promise<void> {
await prisma.iiosOutboxEvent.create({
data: { eventId, aggregateType: 'x', aggregateId: eventId, eventType: 'x', cloudEvent: evt(eventId) as unknown as object, partitionKey: 'p' },
});
}
describe('DlqService (P9 — dead-letter + replay)', () => {
it('record is idempotent per (consumer, sourceId) and bumps retryCount', async () => {
const dlq = new DlqService(asService);
await dlq.record({ sourceType: 'projection', sourceId: 'e1', failureStage: 'c', consumerName: 'c', scopeId: 'scope-A' });
await dlq.record({ sourceType: 'projection', sourceId: 'e1', failureStage: 'c', consumerName: 'c', scopeId: 'scope-A' });
const items = await dlq.listDlq('scope-A');
expect(items).toHaveLength(1);
expect(items[0].retryCount).toBe(1); // 0 on create, +1 on repeat
});
it('onConsumerFailure quarantines the event and clears its claim (replayable)', async () => {
const dlq = new DlqService(asService);
await prisma.iiosProcessedEvent.create({ data: { consumerName: 'c', eventId: 'e2' } }); // claimed then failed
await dlq.onConsumerFailure('c', evt('e2'), new Error('boom'));
expect(await prisma.iiosDlqItem.count({ where: { sourceId: 'e2', consumerName: 'c' } })).toBe(1);
expect(await prisma.iiosProcessedEvent.count({ where: { consumerName: 'c', eventId: 'e2' } })).toBe(0); // claim cleared
});
it('replay re-runs the registered handler → RESOLVED (only when it succeeds)', async () => {
const dlq = new DlqService(asService);
await seedOutboxEvent('e3');
let calls = 0;
dlq.registerHandler('c', async () => { calls++; if (calls === 1) throw new Error('still broken'); });
await dlq.onConsumerFailure('c', evt('e3'), new Error('boom'));
const item = await prisma.iiosDlqItem.findFirstOrThrow({ where: { sourceId: 'e3' } });
const r1 = await dlq.replay(item.id); // handler throws again
expect(r1.status).toBe('QUARANTINED');
expect((await prisma.iiosDlqItem.findUniqueOrThrow({ where: { id: item.id } })).retryCount).toBe(1);
const r2 = await dlq.replay(item.id); // handler now succeeds
expect(r2.status).toBe('RESOLVED');
expect((await prisma.iiosDlqItem.findUniqueOrThrow({ where: { id: item.id } })).resolvedAt).toBeTruthy();
});
});
@@ -0,0 +1,108 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { CloudEvent } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
type ReplayHandler = (event: CloudEvent) => Promise<void>;
export interface DlqRecordInput {
sourceType: string;
sourceId: string;
failureStage: string;
errorCode?: string;
payloadRef?: string;
scopeId?: string;
consumerName?: string;
}
/**
* Dead-letter queue (P9, KG-13). Failures are quarantined here — never silently
* swallowed — and replayed deterministically: a consumer registers its handler, and
* replay clears the idempotency claim then re-runs exactly that consumer (siblings
* stay untouched because the claim ledger makes re-processing a no-op for them).
*/
@Injectable()
export class DlqService {
private readonly handlers = new Map<string, ReplayHandler>();
constructor(private readonly prisma: PrismaService) {}
registerHandler(consumer: string, fn: ReplayHandler): void {
this.handlers.set(consumer, fn);
}
/** Quarantine a failure (idempotent per (consumer, sourceId) — repeats bump retryCount). */
async record(input: DlqRecordInput): Promise<void> {
const existing = await this.prisma.iiosDlqItem.findFirst({
where: { consumerName: input.consumerName ?? null, sourceId: input.sourceId },
});
if (existing) {
await this.prisma.iiosDlqItem.update({
where: { id: existing.id },
data: { retryCount: { increment: 1 }, lastFailedAt: new Date(), errorCode: input.errorCode ?? existing.errorCode, status: 'QUARANTINED' },
});
return;
}
await this.prisma.iiosDlqItem.create({
data: {
sourceType: input.sourceType,
sourceId: input.sourceId,
failureStage: input.failureStage,
errorCode: input.errorCode,
payloadRef: input.payloadRef,
scopeId: input.scopeId,
consumerName: input.consumerName,
},
});
}
/** A projector/consumer threw: dead-letter the event AND clear its claim so it can replay. */
async onConsumerFailure(consumer: string, event: CloudEvent, err: unknown): Promise<void> {
await this.record({
sourceType: 'projection',
sourceId: event.id,
failureStage: consumer,
errorCode: (err as Error)?.message?.slice(0, 160),
payloadRef: event.id,
scopeId: event?.insignia?.scopeSnapshotId,
consumerName: consumer,
});
await this.prisma.iiosProcessedEvent.deleteMany({ where: { consumerName: consumer, eventId: event.id } });
}
async listDlq(scopeId?: string, status?: string) {
return this.prisma.iiosDlqItem.findMany({
where: { ...(scopeId ? { scopeId } : {}), ...(status ? { status } : {}) },
orderBy: { lastFailedAt: 'desc' },
take: 200,
});
}
/** Re-run the failed consumer (deterministic) or hand an outbox event back to the relay. */
async replay(id: string): Promise<{ status: string }> {
const item = await this.prisma.iiosDlqItem.findUnique({ where: { id } });
if (!item) throw new NotFoundException('dlq item not found');
// Projection failure: clear the claim + await the registered handler.
if (item.consumerName && item.payloadRef && this.handlers.has(item.consumerName)) {
await this.prisma.iiosProcessedEvent.deleteMany({ where: { consumerName: item.consumerName, eventId: item.sourceId } });
const evt = await this.prisma.iiosOutboxEvent.findUnique({ where: { eventId: item.payloadRef } });
if (!evt) throw new NotFoundException('event payload not found');
try {
await this.handlers.get(item.consumerName)!(evt.cloudEvent as unknown as CloudEvent);
} catch (err) {
await this.prisma.iiosDlqItem.update({ where: { id }, data: { retryCount: { increment: 1 }, lastFailedAt: new Date(), errorCode: (err as Error)?.message?.slice(0, 160) } });
return { status: 'QUARANTINED' };
}
await this.prisma.iiosDlqItem.update({ where: { id }, data: { status: 'RESOLVED', resolvedAt: new Date() } });
return { status: 'RESOLVED' };
}
// Outbox publish failure: hand the event back to the relay (reset to PENDING).
if (item.payloadRef) {
await this.prisma.iiosOutboxEvent.update({ where: { eventId: item.payloadRef }, data: { status: 'PENDING', nextAttemptAt: new Date(), attempts: 0 } });
await this.prisma.iiosDlqItem.update({ where: { id }, data: { status: 'RESOLVED', resolvedAt: new Date() } });
return { status: 'RESOLVED' };
}
return { status: item.status };
}
}
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { OutboxBus } from './outbox.bus'; import { OutboxBus } from './outbox.bus';
import { OutboxRelay } from './outbox.relay'; import { OutboxRelay } from './outbox.relay';
import { DlqService } from './dlq.service';
@Module({ @Module({
providers: [OutboxBus, OutboxRelay], providers: [OutboxBus, OutboxRelay, DlqService],
exports: [OutboxBus, OutboxRelay], exports: [OutboxBus, OutboxRelay, DlqService],
}) })
export class OutboxModule {} export class OutboxModule {}
@@ -8,7 +8,7 @@ import type { PrismaClient } from '@prisma/client';
export async function resetDb(prisma: PrismaClient): Promise<void> { export async function resetDb(prisma: PrismaClient): Promise<void> {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`TRUNCATE TABLE `TRUNCATE TABLE
"IiosAuditLink", "IiosDlqItem","IiosAuditLink",
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest", "IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow", "IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef", "IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",