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
+8
View File
@@ -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 } : {}),
};
};
+1
View File
@@ -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';
+164
View File
@@ -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<ReadModelDomain> = 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<void> | 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<ReadModelOutboxDispatchResult> {
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<void> {
this.running = false;
this.rerunRequested = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
await this.inFlight;
}
}
+5 -21
View File
@@ -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<void> => {
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 })),
+10
View File
@@ -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();
+8 -3
View File
@@ -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<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource'>,
ctx: Pick<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource' | 'readModelOutbox'>,
page: AccessPage,
now = new Date()
): Promise<boolean> => recordGeneralAccessWeight(ctx, accessPageWeights[page], now);
export const recordGeneralAccessWeight = async (
ctx: Pick<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource'>,
ctx: Pick<GameApiContext, 'auth' | 'db' | 'profile' | 'profileStatusSource' | 'readModelOutbox'>,
weight: number,
now = new Date()
): Promise<boolean> => {
@@ -450,5 +454,6 @@ export const recordGeneralAccessWeight = async (
periodStartedAt,
scoreStartedAt,
});
ctx.readModelOutbox?.wake();
return true;
};
+11 -1
View File
@@ -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({