diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 3053dbe3..ff4d5003 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import type { ChangeJournal } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra'; import { normalizeScenarioEffect, SCENARIO_EFFECT_KEYS } from '@sammo-ts/logic'; @@ -10,6 +11,7 @@ import type { RedisAccessTokenStore } from './auth/accessTokenStore.js'; import type { AccountIconSource } from './auth/accountIconSource.js'; import type { ProfileStatusSource } from './auth/profileStatusSource.js'; import type { ContentImageUploadStore } from './services/remoteContentImageStore.js'; +import type { ReadModelOutboxWakeup } from './realtime/outboxWorker.js'; export interface GameProfile { id: string; @@ -87,6 +89,10 @@ export interface GameApiContext { generalAccessTracking?: boolean; /** Request-local identity already resolved by the realtime access gate. */ realtimeAccessGeneralId?: number; + /** Set only while an API input-event transaction owns the mutation. */ + changeJournal?: ChangeJournal; + /** Post-commit scheduling hint for the durable outbox dispatcher. */ + readModelOutbox?: ReadModelOutboxWakeup; db: DatabaseClient; redis: RedisConnector['client']; turnDaemon: TurnDaemonTransport; @@ -125,6 +131,7 @@ export const createGameApiContext = (options: { gameTokenSecret: string; accountIconSource?: AccountIconSource; profileStatusSource: ProfileStatusSource; + readModelOutbox?: ReadModelOutboxWakeup; }): GameApiContext => { return { requestId: options.requestId, @@ -145,5 +152,6 @@ export const createGameApiContext = (options: { gameTokenSecret: options.gameTokenSecret, ...(options.accountIconSource ? { accountIconSource: options.accountIconSource } : {}), profileStatusSource: options.profileStatusSource, + ...(options.readModelOutbox ? { readModelOutbox: options.readModelOutbox } : {}), }; }; diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index 9a9c65f1..50aa774f 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -29,6 +29,7 @@ export * from './tournament/keys.js'; export * from './tournament/store.js'; export * from './tournament/types.js'; export * from './tournament/worker.js'; +export * from './realtime/outboxWorker.js'; // Types for TRPC consumer export type { MessageView } from './messages/store.js'; diff --git a/app/game-api/src/realtime/outboxWorker.ts b/app/game-api/src/realtime/outboxWorker.ts new file mode 100644 index 00000000..69bb5ecd --- /dev/null +++ b/app/game-api/src/realtime/outboxWorker.ts @@ -0,0 +1,164 @@ +import { randomUUID } from 'node:crypto'; +import { + readModelOutboxPayloadToChanges, + type ReadModelDomain, +} from '@sammo-ts/common'; +import { + dispatchReadModelOutboxBatch, + pruneDeliveredReadModelOutbox, + type ReadModelOutboxDatabase, + type ReadModelOutboxDispatchResult, + type RedisConnector, +} from '@sammo-ts/infra'; + +import { publishRealtimeReadModelChanges } from './publisher.js'; + +// access.general is an authoritative DB-only source revision. Tournament and +// betting still have separate Redis-owned source revisions. None of the three +// should wake the legacy dashboard channel solely because its outbox row ran. +const NON_DASHBOARD_DOMAINS: ReadonlySet = new Set([ + 'access.general', + 'tournament', + 'betting', +]); + +export interface ReadModelOutboxWakeup { + wake(): void; +} + +export interface ReadModelOutboxWorkerOptions { + intervalMs?: number; + batchSize?: number; + leaseMs?: number; + retentionMs?: number; + pruneIntervalMs?: number; + pruneLimit?: number; + owner?: string; + now?: () => Date; + onError?: (error: unknown) => void; +} + +const normalizePositiveInteger = (value: number | undefined, fallback: number): number => + Math.max(1, Math.floor(value ?? fallback)); + +/** + * Polls the durable API/engine outbox without overlapping batches. `wake()` is + * only a scheduling hint; delivery always reclaims committed PostgreSQL rows. + */ +export class ReadModelOutboxWorker implements ReadModelOutboxWakeup { + private timer: NodeJS.Timeout | null = null; + private inFlight: Promise | null = null; + private rerunRequested = false; + private running = false; + private readonly intervalMs: number; + private readonly batchSize: number; + private readonly leaseMs: number; + private readonly retentionMs: number; + private readonly pruneIntervalMs: number; + private readonly pruneLimit: number; + private readonly owner: string; + private readonly now: () => Date; + private readonly onError: (error: unknown) => void; + private nextPruneAt: number; + + constructor( + private readonly db: ReadModelOutboxDatabase, + private readonly redis: RedisConnector['client'], + private readonly profileName: string, + options: ReadModelOutboxWorkerOptions = {} + ) { + this.intervalMs = normalizePositiveInteger(options.intervalMs, 1_000); + this.batchSize = normalizePositiveInteger(options.batchSize, 50); + this.leaseMs = normalizePositiveInteger(options.leaseMs, 30_000); + this.retentionMs = normalizePositiveInteger(options.retentionMs, 24 * 60 * 60 * 1_000); + this.pruneIntervalMs = normalizePositiveInteger(options.pruneIntervalMs, 60_000); + this.pruneLimit = normalizePositiveInteger(options.pruneLimit, 100); + this.owner = options.owner ?? `game-api:${profileName}:${process.pid}:${randomUUID()}`; + this.now = options.now ?? (() => new Date()); + this.onError = options.onError ?? (() => undefined); + this.nextPruneAt = this.now().getTime() + this.pruneIntervalMs; + } + + private async dispatchOnce(): Promise { + const result = await dispatchReadModelOutboxBatch( + this.db, + async (payload) => { + if (payload.changes.every(([domain]) => NON_DASHBOARD_DOMAINS.has(domain))) { + return; + } + const changes = readModelOutboxPayloadToChanges(payload); + await publishRealtimeReadModelChanges(this.redis, this.profileName, changes); + }, + { + owner: this.owner, + limit: this.batchSize, + leaseMs: this.leaseMs, + } + ); + if (result.failed > 0) { + this.reportError(new Error(`${result.failed} read-model outbox delivery attempt(s) failed.`)); + } + + const now = this.now(); + if (now.getTime() >= this.nextPruneAt) { + this.nextPruneAt = now.getTime() + this.pruneIntervalMs; + await pruneDeliveredReadModelOutbox(this.db, { + deliveredBefore: new Date(now.getTime() - this.retentionMs), + limit: this.pruneLimit, + }); + } + return result; + } + + private reportError(error: unknown): void { + try { + this.onError(error); + } catch { + // Observability callbacks must not stop durable retry polling. + } + } + + private runScheduledBatch(): void { + if (!this.running || this.inFlight) { + return; + } + this.rerunRequested = false; + this.inFlight = this.dispatchOnce() + .then(() => undefined) + .catch((error: unknown) => this.reportError(error)) + .finally(() => { + this.inFlight = null; + if (this.running && this.rerunRequested) { + this.runScheduledBatch(); + } + }); + } + + start(): void { + if (this.running) { + return; + } + this.running = true; + this.timer = setInterval(() => this.wake(), this.intervalMs); + this.timer.unref?.(); + this.wake(); + } + + wake(): void { + if (!this.running) { + return; + } + this.rerunRequested = true; + this.runScheduledBatch(); + } + + async stop(): Promise { + this.running = false; + this.rerunRequested = false; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + await this.inFlight; + } +} diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index c27816a2..a8884ed4 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord, createEmptyRealtimeReadModelChanges, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; import { ITEM_KEYS, @@ -17,24 +17,8 @@ import { } from '@sammo-ts/logic'; import { authedProcedure, router } from '../../trpc.js'; -import type { GameApiContext } from '../../context.js'; import { getMyGeneral } from '../shared/general.js'; import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; -import { publishRealtimeReadModelChanges } from '../../realtime/publisher.js'; - -const publishFrontStatusChange = async ( - ctx: GameApiContext, - options: { generalId?: number; global?: boolean } -): Promise => { - const changes = createEmptyRealtimeReadModelChanges(); - if (options.generalId) changes.frontStatusActorIds = [options.generalId]; - if (options.global) changes.frontStatusChanged = true; - try { - await publishRealtimeReadModelChanges(ctx.redis, ctx.profile.name, changes); - } catch { - // 설문 DB mutation은 이미 commit되었으므로 실시간 알림 실패로 되돌리지 않는다. - } -}; const hasAdminRole = (roles: string[], profileName: string): boolean => { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { @@ -523,7 +507,7 @@ export const voteRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason }); } - await publishFrontStatusChange(ctx, { generalId: general.id }); + ctx.changeJournal?.mark('front.general', general.id); return { ok: true, wonLottery: rewardResult.awardedUnique }; }), addComment: authedProcedure @@ -634,7 +618,7 @@ export const voteRouter = router({ ) `); - await publishFrontStatusChange(ctx, { global: true }); + ctx.changeJournal?.mark('front.global'); return { ok: true }; }), updatePoll: adminProcedure @@ -732,7 +716,7 @@ export const voteRouter = router({ `); if (input.title !== undefined || endAt !== undefined) { - await publishFrontStatusChange(ctx, { global: true }); + ctx.changeJournal?.mark('front.global'); } return { ok: true }; }), @@ -748,7 +732,7 @@ export const voteRouter = router({ if (!rows[0]?.id) { throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); } - await publishFrontStatusChange(ctx, { global: true }); + ctx.changeJournal?.mark('front.global'); return { ok: true }; }), getAdminStatus: adminProcedure.query(async () => ({ ok: true })), diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 0fa0ab29..8f37ac7e 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -30,6 +30,7 @@ import { createAdminProfileIconResetFlushHandler } from './services/accountIconS import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js'; import { RemoteContentImageStore } from './services/remoteContentImageStore.js'; +import { ReadModelOutboxWorker } from './realtime/outboxWorker.js'; const extractBearerToken = (value: string | string[] | undefined): string | null => { if (!value) { @@ -140,9 +141,16 @@ export const createGameApiServer = async () => { throw error; } const realtimeHub = new RedisRealtimeEventHub(realtimeSubscriberClient, buildGameEventChannel(config.profileName)); + const readModelOutboxWorker = new ReadModelOutboxWorker(postgres.prisma, redis.client, config.profileName, { + onError: (error) => app.log.error({ err: error }, 'read-model outbox dispatch failed'), + }); let flushSubscriberStarted = false; let realtimeHubStarted = false; const closeResources = createBestEffortResourceCloser([ + { + name: 'read-model-outbox-worker', + run: () => readModelOutboxWorker.stop(), + }, { name: 'account-icon-reset-reconciler', run: () => accountIconResetReconciler.stop(), @@ -220,6 +228,7 @@ export const createGameApiServer = async () => { gameTokenSecret: config.gameTokenSecret, accountIconSource, profileStatusSource, + readModelOutbox: readModelOutboxWorker, }); }, }, @@ -334,6 +343,7 @@ export const createGameApiServer = async () => { realtimeHubStarted = true; await flushSubscriber.start(); flushSubscriberStarted = true; + readModelOutboxWorker.start(); accountIconResetReconciler.start(); } catch (error) { await closeResources(); diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index c32bf76b..662e721b 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -1,5 +1,5 @@ import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common'; -import { GamePrisma } from '@sammo-ts/infra'; +import { GamePrisma, writeReadModelChangeJournal } from '@sammo-ts/infra'; import type { GameApiContext } from '../context.js'; @@ -371,17 +371,21 @@ export const upsertGeneralAccess = async ( general_access_log.refresh_score_total + EXCLUDED.refresh_score_total ` ); + + await writeReadModelChangeJournal(transaction, [ + { domain: 'access.general', entityId: input.generalId }, + ]); }); }; export const recordGeneralAccess = async ( - ctx: Pick, + ctx: Pick, page: AccessPage, now = new Date() ): Promise => recordGeneralAccessWeight(ctx, accessPageWeights[page], now); export const recordGeneralAccessWeight = async ( - ctx: Pick, + ctx: Pick, weight: number, now = new Date() ): Promise => { @@ -450,5 +454,6 @@ export const recordGeneralAccessWeight = async ( periodStartedAt, scoreStartedAt, }); + ctx.readModelOutbox?.wake(); return true; }; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 09ca85f5..496de08f 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto'; import { initTRPC, TRPCError } from '@trpc/server'; +import { ChangeJournal } from '@sammo-ts/common'; import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions'; +import { writeReadModelChangeJournal } from '@sammo-ts/infra'; import type { GameApiContext } from './context.js'; import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js'; @@ -45,8 +47,10 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { } const requestId = `${ctx.requestId ?? randomUUID()}:${path}`; + const changeJournal = new ChangeJournal(); + let journalPersisted = false; try { - return await executeInputEvent({ + const result = await executeInputEvent({ db: ctx.db, requestId, eventType: path, @@ -56,15 +60,21 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { ctx: { ...ctx, db: transaction, + changeJournal, turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId), }, }); if (!result.ok) { throw result.error; } + journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot())); return result; }, }); + if (journalPersisted) { + ctx.readModelOutbox?.wake(); + } + return result; } catch (error) { if (error instanceof DuplicateInputEventError) { throw new TRPCError({ diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 5697fef3..30439e66 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -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 @@ -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 () => ({})), diff --git a/app/game-api/test/inputEventBoundary.integration.test.ts b/app/game-api/test/inputEventBoundary.integration.test.ts index efcb0115..55e07850 100644 --- a/app/game-api/test/inputEventBoundary.integration.test.ts +++ b/app/game-api/test/inputEventBoundary.integration.test.ts @@ -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) | 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 | 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'; diff --git a/app/game-api/test/inputEventJournal.test.ts b/app/game-api/test/inputEventJournal.test.ts new file mode 100644 index 00000000..c54b4b1d --- /dev/null +++ b/app/game-api/test/inputEventJournal.test.ts @@ -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) => { + 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(); + }); +}); diff --git a/app/game-api/test/readModelOutboxWorker.test.ts b/app/game-api/test/readModelOutboxWorker.test.ts new file mode 100644 index 00000000..8a140461 --- /dev/null +++ b/app/game-api/test/readModelOutboxWorker.test.ts @@ -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((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); + }); +}); diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index 502795ab..d2c02277 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -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 () => { diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 579cadef..2d6a89e7 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -44,4 +44,5 @@ export interface DatabaseClient { vote: GamePrisma.VoteDelegate; inputEvent: GamePrisma.InputEventDelegate; turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate; + readModelOutbox: GamePrisma.ReadModelOutboxDelegate; } diff --git a/packages/infra/src/readModelChangeJournal.ts b/packages/infra/src/readModelChangeJournal.ts index 186dc14c..5a501564 100644 --- a/packages/infra/src/readModelChangeJournal.ts +++ b/packages/infra/src/readModelChangeJournal.ts @@ -6,7 +6,9 @@ import { type ReadModelRevisionKey, } from '@sammo-ts/common'; -import { GamePrisma } from './gamePrisma.js'; +import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; + +export type ReadModelJournalDatabase = Pick; interface ReadModelJournalWriteRow { domain: string; @@ -61,7 +63,7 @@ const assertWriteRows = ( * that owns the domain mutation; this function never opens or commits one. */ export const writeReadModelChangeJournal = async ( - transaction: GamePrisma.TransactionClient, + transaction: ReadModelJournalDatabase, candidates: Iterable ): Promise => { const keys = normalizeReadModelRevisionKeys(candidates); diff --git a/packages/infra/src/readModelOutboxDispatcher.ts b/packages/infra/src/readModelOutboxDispatcher.ts index 26c2e7b6..5a317a2b 100644 --- a/packages/infra/src/readModelOutboxDispatcher.ts +++ b/packages/infra/src/readModelOutboxDispatcher.ts @@ -2,6 +2,10 @@ import { parseReadModelOutboxPayload, type ReadModelOutboxPayloadV1 } from '@sam import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; +export interface ReadModelOutboxDatabase extends Pick { + readModelOutbox: GamePrisma.ReadModelOutboxDelegate; +} + export interface ClaimedReadModelOutbox { id: bigint; payload: unknown; @@ -46,7 +50,7 @@ const retryDelayMs = (attempts: number, baseMs: number, maxMs: number): number = }; export const claimReadModelOutboxBatch = async ( - db: GamePrismaClient, + db: ReadModelOutboxDatabase, options: Pick & { now?: Date } ): Promise => { if (!options.owner.trim()) { @@ -82,7 +86,7 @@ export const claimReadModelOutboxBatch = async ( }; export const markReadModelOutboxDelivered = async ( - db: GamePrismaClient, + db: ReadModelOutboxDatabase, input: { id: bigint; owner: string; deliveredAt?: Date } ): Promise => { const result = await db.readModelOutbox.updateMany({ @@ -98,7 +102,7 @@ export const markReadModelOutboxDelivered = async ( }; export const releaseReadModelOutbox = async ( - db: GamePrismaClient, + db: ReadModelOutboxDatabase, input: { id: bigint; owner: string; error: unknown; availableAt: Date } ): Promise => { const result = await db.readModelOutbox.updateMany({ @@ -114,7 +118,7 @@ export const releaseReadModelOutbox = async ( }; export const dispatchReadModelOutboxBatch = async ( - db: GamePrismaClient, + db: ReadModelOutboxDatabase, publish: (payload: ReadModelOutboxPayloadV1, outboxId: bigint) => Promise, options: ReadModelOutboxDispatchOptions ): Promise => { @@ -156,7 +160,7 @@ export const dispatchReadModelOutboxBatch = async ( }; export const pruneDeliveredReadModelOutbox = async ( - db: GamePrismaClient, + db: ReadModelOutboxDatabase, input: { deliveredBefore: Date; limit?: number } ): Promise => { const limit = normalizeLimit(input.limit);