feat: API 변화 저널과 outbox worker를 연결
입력 이벤트의 업무 변경, 성공 원장, revision 및 outbox를 한 transaction에서 커밋한다. 설문의 commit 전 Redis 발행을 제거하고 접속 점수용 private revision과 재시도·retention을 갖춘 dispatcher lifecycle을 추가한다.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { DuplicateInputEventError, executeInputEvent } from '../src/inputEventBoundary.js';
|
||||
import { procedure, router } from '../src/trpc.js';
|
||||
import {
|
||||
ConflictingTurnDaemonCommandError,
|
||||
DatabaseTurnDaemonTransport,
|
||||
@@ -10,10 +13,36 @@ import {
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const journalGeneralIds = [9_980_081, 9_980_082] as const;
|
||||
|
||||
const journalBoundaryRouter = router({
|
||||
mutate: procedure
|
||||
.input(z.object({ generalId: z.number().int(), fail: z.boolean().optional().default(false) }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
ctx.changeJournal?.mark('front.general', input.generalId);
|
||||
if (input.fail) {
|
||||
throw new Error('injected journal rollback');
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
const payloadHasGeneral = (payload: unknown, generalId: number): boolean => {
|
||||
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false;
|
||||
const changes = (payload as { changes?: unknown }).changes;
|
||||
return (
|
||||
Array.isArray(changes) &&
|
||||
changes.some(
|
||||
(change) =>
|
||||
Array.isArray(change) && change[0] === 'front.general' && change[1] === generalId
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
integration('API input event boundary', () => {
|
||||
let close: (() => Promise<void>) | undefined;
|
||||
let db: GamePrismaClient;
|
||||
const createdOutboxIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
@@ -23,12 +52,21 @@ integration('API input event boundary', () => {
|
||||
await db.inputEvent.deleteMany({
|
||||
where: { requestId: { startsWith: 'integration:api:' } },
|
||||
});
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.inputEvent.deleteMany({
|
||||
where: { requestId: { startsWith: 'integration:api:' } },
|
||||
});
|
||||
if (createdOutboxIds.length > 0) {
|
||||
await db.readModelOutbox.deleteMany({ where: { id: { in: createdOutboxIds } } });
|
||||
}
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
||||
});
|
||||
await close?.();
|
||||
});
|
||||
|
||||
@@ -64,6 +102,80 @@ integration('API input event boundary', () => {
|
||||
expect(marker.status).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('persists an API journal with SUCCEEDED and only wakes delivery after commit', async () => {
|
||||
const requestId = 'integration:api:journal-success';
|
||||
const redisPublish = vi.fn();
|
||||
let wakeSnapshot: Promise<unknown> | undefined;
|
||||
const context = {
|
||||
db,
|
||||
requestId,
|
||||
redis: { publish: redisPublish },
|
||||
readModelOutbox: {
|
||||
wake: () => {
|
||||
wakeSnapshot = Promise.all([
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:mutate` } }),
|
||||
db.readModelRevision.findUniqueOrThrow({
|
||||
where: {
|
||||
domain_entityId: {
|
||||
domain: 'front.general',
|
||||
entityId: journalGeneralIds[0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
},
|
||||
},
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
await expect(
|
||||
journalBoundaryRouter.createCaller(context).mutate({ generalId: journalGeneralIds[0] })
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(wakeSnapshot).toBeDefined();
|
||||
const [event, revision] = (await wakeSnapshot) as [
|
||||
{ status: string },
|
||||
{ revision: bigint },
|
||||
];
|
||||
expect(event.status).toBe('SUCCEEDED');
|
||||
expect(revision.revision).toBe(1n);
|
||||
expect(redisPublish).not.toHaveBeenCalled();
|
||||
|
||||
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||
const outbox = outboxes.find(({ payload }) => payloadHasGeneral(payload, journalGeneralIds[0]));
|
||||
expect(outbox).toBeDefined();
|
||||
if (outbox) createdOutboxIds.push(outbox.id);
|
||||
});
|
||||
|
||||
it('rolls back an API journal and never schedules delivery when the handler fails', async () => {
|
||||
const requestId = 'integration:api:journal-rollback';
|
||||
const wake = vi.fn();
|
||||
const context = {
|
||||
db,
|
||||
requestId,
|
||||
readModelOutbox: { wake },
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
await expect(
|
||||
journalBoundaryRouter
|
||||
.createCaller(context)
|
||||
.mutate({ generalId: journalGeneralIds[1], fail: true })
|
||||
).rejects.toThrow('injected journal rollback');
|
||||
|
||||
await expect(
|
||||
db.readModelRevision.findUnique({
|
||||
where: {
|
||||
domain_entityId: {
|
||||
domain: 'front.general',
|
||||
entityId: journalGeneralIds[1],
|
||||
},
|
||||
},
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
const outboxes = await db.readModelOutbox.findMany({ select: { payload: true } });
|
||||
expect(outboxes.some(({ payload }) => payloadHasGeneral(payload, journalGeneralIds[1]))).toBe(false);
|
||||
expect(wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back business writes, records failure, and permits one explicit retry', async () => {
|
||||
const requestId = 'integration:api:retry';
|
||||
const markerId = 'integration:api:retry:marker';
|
||||
|
||||
Reference in New Issue
Block a user