feat: API 변화 저널과 outbox worker를 연결

입력 이벤트의 업무 변경, 성공 원장, revision 및 outbox를 한 transaction에서 커밋한다. 설문의 commit 전 Redis 발행을 제거하고 접속 점수용 private revision과 재시도·retention을 갖춘 dispatcher lifecycle을 추가한다.
This commit is contained in:
2026-08-16 18:22:57 +00:00
parent b41b161e9c
commit f858ca43f4
15 changed files with 606 additions and 52 deletions
@@ -47,7 +47,13 @@ const buildDb = (
access: { lastRefresh: Date | null; refreshScore: number } | null = null
) => {
const executeRaw = vi.fn(async (_query: unknown) => 1);
const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]);
const queryRaw = vi.fn(async (query: unknown) => {
const sql = (query as { sql?: string }).sql ?? '';
if (sql.includes('read_model_revision')) {
return [{ domain: 'access.general', entityId: 7, revision: 1n, outboxId: 1n }];
}
return [{ id: 41 }];
});
const transaction = vi.fn(
async (
callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise<unknown>
@@ -157,7 +163,7 @@ describe('general access tracking', () => {
select: { id: true, userId: true, turnTime: true },
});
expect(transaction).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(executeRaw).toHaveBeenCalledTimes(2);
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
@@ -185,6 +191,11 @@ describe('general access tracking', () => {
expect(accessStatement.values).toContain(2);
expect(accessStatement.values).toContain(now);
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
const journalStatement = queryRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
expect(journalStatement.sql).toContain('INSERT INTO "read_model_outbox"');
expect(journalStatement.values).toContain('access.general');
expect(journalStatement.values).toContain(7);
});
it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => {
@@ -242,7 +253,12 @@ describe('general access tracking', () => {
const events: string[] = [];
let transactionCount = 0;
const transactionClient = {
$queryRaw: vi.fn(async () => [{ id: 41 }]),
$queryRaw: vi.fn(async (query: unknown) => {
const sql = (query as { sql?: string }).sql ?? '';
return sql.includes('read_model_revision')
? [{ domain: 'access.general', entityId: 7, revision: 1n, outboxId: 1n }]
: [{ id: 41 }];
}),
$executeRaw: vi.fn(async () => 1),
inputEvent: {
update: vi.fn(async () => ({})),
@@ -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';
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import { procedure, router } from '../src/trpc.js';
const testRouter = router({
mutate: procedure
.input(z.object({ fail: z.boolean().optional().default(false) }))
.mutation(({ ctx, input }) => {
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
ctx.changeJournal?.mark('front.general', 7);
if (input.fail) throw new Error('injected rollback');
return { ok: true };
}),
});
const createContext = () => {
const order: string[] = [];
const queryRaw = vi.fn(async () => {
order.push('journal');
return [{ domain: 'front.general', entityId: 7, revision: 1n, outboxId: 11n }];
});
const transaction = {
$queryRaw: queryRaw,
inputEvent: {
update: vi.fn(async () => {
order.push('succeeded');
return {};
}),
},
};
const db = {
inputEvent: {
create: vi.fn(async () => {
order.push('accepted');
return {};
}),
updateMany: vi.fn(async () => ({ count: 0 })),
update: vi.fn(async () => {
order.push('failed');
return {};
}),
},
$transaction: vi.fn(async (callback: (db: typeof transaction) => Promise<unknown>) => {
order.push('transaction-begin');
try {
const result = await callback(transaction);
order.push('commit');
return result;
} catch (error) {
order.push('rollback');
throw error;
}
}),
};
const redisPublish = vi.fn();
const wake = vi.fn(() => order.push('wake'));
const context = {
requestId: 'journal-unit',
db,
redis: { publish: redisPublish },
readModelOutbox: { wake },
testOrder: order,
} as unknown as GameApiContext & { testOrder: string[] };
return { context, order, queryRaw, redisPublish, wake };
};
describe('API input-event change journal boundary', () => {
it('writes the journal with SUCCEEDED, commits, and only then schedules delivery', async () => {
const fixture = createContext();
await expect(testRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
expect(fixture.order).toEqual([
'accepted',
'transaction-begin',
'handler',
'journal',
'succeeded',
'commit',
'wake',
]);
expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).toHaveBeenCalledTimes(1);
});
it('rolls back a handler mark without writing or scheduling an outbox row', async () => {
const fixture = createContext();
await expect(testRouter.createCaller(fixture.context).mutate({ fail: true })).rejects.toThrow(
'injected rollback'
);
expect(fixture.order).toEqual(['accepted', 'transaction-begin', 'handler', 'rollback', 'failed']);
expect(fixture.queryRaw).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,143 @@
import { describe, expect, it, vi } from 'vitest';
import type { RedisConnector } from '@sammo-ts/infra';
import type { ReadModelOutboxDatabase } from '@sammo-ts/infra';
import { ReadModelOutboxWorker } from '../src/realtime/outboxWorker.js';
const payload = (domain: 'front.general' | 'access.general' | 'tournament' | 'betting') => ({
version: 1,
changes: [[domain, domain === 'front.general' || domain === 'access.general' ? 7 : 0, '1']],
});
const createFixture = (rows: readonly object[]) => {
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]);
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
const incr = vi.fn().mockResolvedValue(41);
const publish = vi.fn().mockResolvedValue(1);
const db = {
$queryRaw: queryRaw,
readModelOutbox: { updateMany },
} as unknown as ReadModelOutboxDatabase;
const redis = { incr, publish } as unknown as RedisConnector['client'];
return { db, redis, queryRaw, updateMany, incr, publish };
};
describe('ReadModelOutboxWorker', () => {
it('publishes a legacy internal readModelChanged event and acknowledges the durable row', async () => {
const fixture = createFixture([{ id: 11n, payload: payload('front.general'), attempts: 1 }]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
const event = JSON.parse(String(fixture.publish.mock.calls[0]?.[1]));
expect(event).toMatchObject({
type: 'readModelChanged',
revision: 41,
changes: { frontStatusActorIds: [7] },
});
expect(fixture.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
);
});
it.each(['access.general', 'tournament', 'betting'] as const)(
'marks a %s-only envelope delivered without dashboard Redis publish',
async (domain) => {
const fixture = createFixture([{ id: 12n, payload: payload(domain), attempts: 1 }]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await worker.stop();
expect(fixture.incr).not.toHaveBeenCalled();
expect(fixture.publish).not.toHaveBeenCalled();
}
);
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
let releaseFirst: (() => void) | undefined;
const first = new Promise<readonly object[]>((resolve) => {
releaseFirst = () => resolve([]);
});
const fixture = createFixture([]);
fixture.queryRaw.mockReset();
fixture.queryRaw.mockReturnValueOnce(first).mockResolvedValue([]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
});
worker.start();
worker.wake();
worker.wake();
expect(fixture.queryRaw).toHaveBeenCalledTimes(1);
releaseFirst?.();
await vi.waitFor(() => expect(fixture.queryRaw).toHaveBeenCalledTimes(2));
await worker.stop();
expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
});
it('reports item failures while leaving the row released for dispatcher retry', async () => {
const fixture = createFixture([{ id: 13n, payload: payload('front.general'), attempts: 1 }]);
fixture.publish.mockRejectedValueOnce(new Error('redis unavailable'));
const onError = vi.fn();
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
onError,
});
worker.start();
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
await worker.stop();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
);
expect(fixture.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null },
data: expect.objectContaining({
lockedAt: null,
lockOwner: null,
lastError: expect.stringContaining('redis unavailable'),
}),
})
);
});
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
let now = new Date('2026-08-16T00:00:00.000Z');
const fixture = createFixture([]);
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
owner: 'worker-test',
intervalMs: 60_000,
retentionMs: 24 * 60 * 60 * 1_000,
pruneIntervalMs: 60_000,
pruneLimit: 100,
now: () => now,
});
now = new Date('2026-08-16T00:01:00.000Z');
worker.start();
await vi.waitFor(() => expect(fixture.queryRaw).toHaveBeenCalledTimes(2));
await worker.stop();
const pruneQuery = fixture.queryRaw.mock.calls[1]?.[0] as { sql: string; values: unknown[] };
expect(pruneQuery.sql).toContain('DELETE FROM "read_model_outbox"');
expect(pruneQuery.values).toContainEqual(new Date('2026-08-15T00:01:00.000Z'));
expect(pruneQuery.values).toContain(100);
});
});
+10 -16
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GamePrisma, RedisConnector } from '@sammo-ts/infra';
@@ -106,6 +107,7 @@ const buildContext = (options: {
}));
const redisIncr = vi.fn(async (_key: string) => 41);
const redisPublish = vi.fn(async (_channel: string, _message: string) => 1);
const changeJournal = new ChangeJournal();
const queryRaw = vi.fn(async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM vote_poll') && text.includes('LIMIT 1')) {
@@ -193,8 +195,9 @@ const buildContext = (options: {
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
changeJournal,
};
return { context, requestCommand, queryRaw, db, redisIncr, redisPublish };
return { context, requestCommand, queryRaw, db, redisIncr, redisPublish, changeJournal };
};
describe('vote router actor and permission boundaries', () => {
@@ -221,16 +224,9 @@ describe('vote router actor and permission boundaries', () => {
goldReward: 90,
})
);
expect(fixture.redisIncr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
const published = JSON.parse(String(fixture.redisPublish.mock.calls[0]?.[1]));
expect(published).toMatchObject({
type: 'readModelChanged',
revision: 41,
changes: {
frontStatusActorIds: [7],
frontStatusChanged: false,
},
});
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]);
expect(fixture.redisIncr).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled();
});
it('publishes a global front-status projection after creating a survey', async () => {
@@ -244,11 +240,9 @@ describe('vote router actor and permission boundaries', () => {
})
).resolves.toEqual({ ok: true });
const published = JSON.parse(String(fixture.redisPublish.mock.calls[0]?.[1]));
expect(published).toMatchObject({
type: 'readModelChanged',
changes: { frontStatusChanged: true },
});
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.global', entityId: 0 }]);
expect(fixture.redisIncr).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled();
});
it('uses the current world develcost for the legacy five-times survey reward', async () => {