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
@@ -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 { RouteService } from './route.service';
/**
@@ -18,11 +19,13 @@ export class RouteProjector implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly bus: OutboxBus,
private readonly dlq: DlqService,
private readonly routes: RouteService,
) {}
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> {
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient, IiosRouteMode } from '@prisma/client';
import { DlqService } from '../outbox/dlq.service';
import { resetDb } from '../test-utils/reset-db';
import { makeFakePorts } from '@insignia/iios-testkit';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
@@ -159,7 +160,7 @@ describe('RouteService approve/deny → execute (P6)', () => {
describe('RouteProjector — AUTOMATIC forwarding (P6)', () => {
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())), actors);
const projector = () => new RouteProjector(asService, new OutboxBus(), routeSvc());
const projector = () => new RouteProjector(asService, new OutboxBus(), new DlqService(asService), routeSvc());
// Ingest an inbound (channel-bearing) interaction — routable, unlike native DMs.
async function ingestInbound(text: string): Promise<{ interactionId: string; scopeId: string; event: CloudEvent }> {