feat(iios): wire projectors + relay into DLQ (P9 chaos-safe)

Projectors register a replay handler and route bus errors to
dlq.onConsumerFailure (dead-letter + clear the claim-then-fail claim)
instead of swallowing them. Relay dead-letters an outbox event after
IIOS_OUTBOX_MAX_ATTEMPTS instead of retrying forever. Chaos test proves
poison → DLQ → replay resolves while sibling events keep flowing (KG-13/KG-06).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 01:43:57 +05:30
parent 208fb0b4f0
commit d3d557766e
14 changed files with 128 additions and 18 deletions
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { DlqService } from '../outbox/dlq.service';
import { resetDb } from '../test-utils/reset-db';
import { makeFakePorts, makeFakeInference } from '@insignia/iios-testkit';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
@@ -104,7 +105,7 @@ describe('AI × routing (P7, KG-05: AI proposes, never approves)', () => {
it('AiProjector auto-classifies a channel-bearing interaction, idempotently', async () => {
const { interactionId, event } = await ingestInbound('There is a party at 9 PM tonight!');
const p = new AiProjector(asService, new OutboxBus(), ai());
const p = new AiProjector(asService, new OutboxBus(), new DlqService(asService), ai());
await p.onNormalized(event);
await p.onNormalized(event); // replay
expect(await prisma.iiosAiJob.count({ where: { interactionId, jobType: 'CLASSIFY' } })).toBe(1);
+4 -1
View File
@@ -2,6 +2,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
import { OutboxBus } from '../outbox/outbox.bus';
import { DlqService } from '../outbox/dlq.service';
import { AiJobService } from './ai.service';
/**
@@ -18,11 +19,13 @@ export class AiProjector implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly bus: OutboxBus,
private readonly dlq: DlqService,
private readonly ai: AiJobService,
) {}
onModuleInit(): void {
this.bus.on(IIOS_EVENTS.interactionNormalized, (p) => void this.onNormalized(p as CloudEvent).catch(() => {}));
this.dlq.registerHandler(this.consumer, (e) => this.onNormalized(e));
this.bus.on(IIOS_EVENTS.interactionNormalized, (p) => void this.onNormalized(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)));
}
async onNormalized(event: CloudEvent): Promise<void> {