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:
@@ -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 { OutboxBus } from './outbox.bus';
|
||||
import { OutboxRelay } from './outbox.relay';
|
||||
import { DlqService } from './dlq.service';
|
||||
|
||||
@Module({
|
||||
providers: [OutboxBus, OutboxRelay],
|
||||
exports: [OutboxBus, OutboxRelay],
|
||||
providers: [OutboxBus, OutboxRelay, DlqService],
|
||||
exports: [OutboxBus, OutboxRelay, DlqService],
|
||||
})
|
||||
export class OutboxModule {}
|
||||
|
||||
Reference in New Issue
Block a user