feat(service): outbox relay + thread read endpoint + replay (P1.5, P1 complete)

OutboxRelay: FOR UPDATE SKIP LOCKED claim, in-process bus publish, PENDING->
PUBLISHED with 30s backoff + idempotent-consumer ledger. GET /v1/threads/:id/
messages (policy-scoped). Replay + read specs green; vitest fileParallelism off
(shared DB). End-to-end HTTP smoke verified: idempotent ingest, read-back, 400
on missing key. 19 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:29:00 +05:30
parent 4b7dc98c6b
commit 072c541e09
10 changed files with 279 additions and 1 deletions
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter } from 'node:events';
/**
* In-process event bus (P1 delivery surface). A real broker (Kafka/Redpanda)
* replaces this in P9; the relay contract stays the same. Delivery surfaces are
* never the source of truth — the outbox table is.
*/
@Injectable()
export class OutboxBus {
private readonly emitter = new EventEmitter();
publish(eventType: string, payload: unknown): void {
this.emitter.emit(eventType, payload);
this.emitter.emit('*', eventType, payload);
}
on(eventType: string, handler: (payload: unknown) => void): void {
this.emitter.on(eventType, handler);
}
onAny(handler: (eventType: string, payload: unknown) => void): void {
this.emitter.on('*', handler);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { OutboxBus } from './outbox.bus';
import { OutboxRelay } from './outbox.relay';
@Module({
providers: [OutboxBus, OutboxRelay],
exports: [OutboxBus, OutboxRelay],
})
export class OutboxModule {}
@@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { OutboxBus } from './outbox.bus';
/**
* Transactional-outbox relay (Bottom-Up §12). At-least-once by design; the
* idempotent-consumer ledger (IiosProcessedEvent) makes effects exactly-once.
*
* relayOnce(): claim PENDING rows with FOR UPDATE SKIP LOCKED, publish each to
* the bus once, mark PUBLISHED. On failure, reset to PENDING with a 30s backoff.
* A scheduler (P9) calls this on an interval; tests call it directly.
*/
@Injectable()
export class OutboxRelay {
private readonly consumer = 'outbox-relay';
constructor(
private readonly prisma: PrismaService,
private readonly bus: OutboxBus,
) {}
async relayOnce(batchSize = 100): Promise<number> {
const eventIds = await this.prisma.$transaction(async (tx) => {
const picked = await tx.$queryRaw<Array<{ eventId: string }>>`
SELECT "eventId" FROM "IiosOutboxEvent"
WHERE status = 'PENDING' AND "nextAttemptAt" <= now()
ORDER BY "createdAt" ASC
LIMIT ${batchSize}
FOR UPDATE SKIP LOCKED`;
const ids = picked.map((r) => r.eventId);
if (ids.length > 0) {
await tx.iiosOutboxEvent.updateMany({
where: { eventId: { in: ids } },
data: { status: 'SENDING', attempts: { increment: 1 } },
});
}
return ids;
});
let published = 0;
for (const eventId of eventIds) {
const evt = await this.prisma.iiosOutboxEvent.findUniqueOrThrow({ where: { eventId } });
try {
const fresh = await this.consumeOnce(eventId);
if (fresh) this.bus.publish(evt.eventType, evt.cloudEvent);
await this.prisma.iiosOutboxEvent.update({
where: { eventId },
data: { status: 'PUBLISHED', publishedAt: new Date() },
});
published++;
} catch {
await this.prisma.iiosOutboxEvent.update({
where: { eventId },
data: { status: 'PENDING', nextAttemptAt: new Date(Date.now() + 30_000) },
});
}
}
return published;
}
/** Records the event as processed exactly once. Returns false if already seen. */
private async consumeOnce(eventId: string): Promise<boolean> {
const res = await this.prisma.iiosProcessedEvent.createMany({
data: [{ consumerName: this.consumer, eventId }],
skipDuplicates: true,
});
return res.count > 0;
}
}
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { makeFakePorts, portalMessageBasic, replayTwice, isIdempotent } from '@insignia/iios-testkit';
import { IngestService } from '../interactions/ingest.service';
import { OutboxRelay } from './outbox.relay';
import { OutboxBus } from './outbox.bus';
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;
async function clean(): Promise<void> {
await prisma.iiosOutboxEvent.deleteMany();
await prisma.iiosProcessedEvent.deleteMany();
await prisma.iiosMessagePart.deleteMany();
await prisma.iiosInteraction.deleteMany();
await prisma.iiosThreadParticipant.deleteMany();
await prisma.iiosThread.deleteMany();
await prisma.iiosActorRef.deleteMany();
await prisma.iiosSourceHandle.deleteMany();
await prisma.iiosChannel.deleteMany();
await prisma.iiosScope.deleteMany();
}
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await clean(); });
describe('outbox relay + replay', () => {
it('replaying a fixture twice produces no duplicate state', async () => {
const ingest = new IngestService(asService, makeFakePorts());
const result = await replayTwice(
portalMessageBasic,
(f) => ingest.ingest(f, f.providerEventId!),
() => prisma.iiosInteraction.count(),
);
expect(isIdempotent(result)).toBe(true);
expect(result.countAfterSecond).toBe(1);
});
it('relay publishes exactly one event per interaction, then nothing new', async () => {
const ingest = new IngestService(asService, makeFakePorts());
const bus = new OutboxBus();
const received: string[] = [];
bus.onAny((eventType) => received.push(eventType));
const relay = new OutboxRelay(asService, bus);
await ingest.ingest(portalMessageBasic, portalMessageBasic.providerEventId!);
const published = await relay.relayOnce();
expect(published).toBe(1);
expect(received).toEqual(['com.insignia.iios.interaction.normalized.v1']);
expect(await prisma.iiosOutboxEvent.count({ where: { status: 'PUBLISHED' } })).toBe(1);
expect(await prisma.iiosProcessedEvent.count()).toBe(1);
// A second relay pass finds nothing PENDING.
expect(await relay.relayOnce()).toBe(0);
});
});