diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index d615adef..1c949c50 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common'; import type { TournamentKeys } from './keys.js'; import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js'; @@ -18,31 +19,6 @@ interface RedisClientLike { publish?(channel: string, message: string): Promise; } -const writeWithSourceRevisionScript = ` -local current = redis.call('GET', KEYS[2]) -if current then - if not string.match(current, '^%d+$') then - return redis.error_reply('invalid tournament source revision') - end - if string.len(current) > 18 then - return redis.error_reply('tournament source revision exhausted') - end -end -redis.call('SET', KEYS[1], ARGV[1]) -local revision = redis.call('INCR', KEYS[2]) -return tostring(revision) -`; - -const parseSourceRevision = (value: unknown): string | null => { - if (typeof value === 'number') { - return Number.isSafeInteger(value) && value >= 0 ? String(value) : null; - } - if (typeof value === 'bigint') { - return value >= 0n ? value.toString() : null; - } - return typeof value === 'string' && /^(?:0|[1-9]\d*)$/u.test(value) ? value : null; -}; - const safeJsonParse = (raw: string | null): T | null => { if (!raw) { return null; @@ -89,30 +65,11 @@ export class TournamentStore { } async getSourceRevision(): Promise { - return parseSourceRevision(await this.redis.get(this.keys.sourceRevisionKey)); + return parseTournamentSourceRevision(await this.redis.get(this.keys.sourceRevisionKey)); } private async writeWithSourceRevision(key: string, value: unknown): Promise { - const result = await this.redis.eval(writeWithSourceRevisionScript, { - keys: [key, this.keys.sourceRevisionKey], - arguments: [JSON.stringify(value)], - }); - const sourceRevision = parseSourceRevision(result); - if (sourceRevision === null) { - throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.'); - } - - if (this.redis.publish) { - try { - await this.redis.publish( - this.keys.sourceRevisionChannel, - JSON.stringify({ sourceRevision }) - ); - } catch { - // State and revision are already committed atomically; publication is best effort. - } - } - return sourceRevision; + return writeTournamentProjection(this.redis, this.keys, [{ key, value }]); } async setState(state: TournamentState): Promise { diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index f0c16d13..052ac944 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -28,11 +28,12 @@ class MemoryRedis { } async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise { - const [valueKey, revisionKey] = options.keys; - const [value] = options.arguments; - if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments'); + const revisionKey = options.keys.at(-1); + if (!revisionKey || options.keys.length !== options.arguments.length + 1) { + throw new Error('invalid eval arguments'); + } const revision = Number(this.store.get(revisionKey) ?? '0') + 1; - this.store.set(valueKey, value); + options.arguments.forEach((value, index) => this.store.set(options.keys[index]!, value)); this.store.set(revisionKey, String(revision)); return String(revision); } diff --git a/app/game-engine/src/turn/runtimeClockShift.ts b/app/game-engine/src/turn/runtimeClockShift.ts index 58b05229..84f8b5ed 100644 --- a/app/game-engine/src/turn/runtimeClockShift.ts +++ b/app/game-engine/src/turn/runtimeClockShift.ts @@ -1,7 +1,7 @@ import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; import { randomUUID } from 'node:crypto'; -import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common'; +import { writeTournamentProjection, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common'; import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js'; @@ -17,6 +17,8 @@ interface RuntimeRedisClient { ): Promise; del(key: string): Promise; zAdd(key: string, values: Array<{ score: number; value: string }>): Promise; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; + publish?(channel: string, message: string): Promise; } type TournamentClockState = { @@ -71,6 +73,10 @@ const shiftTournamentClock = async ( deltaMinutes: number ): Promise => { const stateKey = `sammo:${profileName}:tournament:state`; + const sourceKeys = { + sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, + sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, + }; const lockKey = `${stateKey}:mutation-lock`; const token = randomUUID(); const deadline = Date.now() + 2_000; @@ -95,7 +101,7 @@ const shiftTournamentClock = async ( bettingCloseAt: shiftDateText(state.bettingCloseAt, deltaMinutes) as string | undefined, runtimeClockShiftActionIds: [...applied, actionId], }; - await redis.set(stateKey, JSON.stringify(nextState)); + await writeTournamentProjection(redis, sourceKeys, [{ key: stateKey, value: nextState }]); return true; } finally { if ((await redis.get(lockKey)) === token) { diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index a70f9cd5..a80f2d2c 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -1,4 +1,4 @@ -import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { asRecord, LiteHashDRBG, RandUtil, writeTournamentProjection } from '@sammo-ts/common'; import type { RedisConnector } from '@sammo-ts/infra'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; @@ -73,6 +73,8 @@ export const createTournamentAutoStartHandler = (options: { participants: `sammo:${options.profileName}:tournament:participants`, matches: `sammo:${options.profileName}:tournament:matches`, betting: `sammo:${options.profileName}:tournament:betting`, + sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`, + sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`, }; return { onMonthChanged: async (context) => { @@ -139,10 +141,12 @@ export const createTournamentAutoStartHandler = (options: { lastError: undefined, lastErrorAt: undefined, }; - await redis.set(keys.participants, '[]'); - await redis.set(keys.matches, '[]'); - await redis.set(keys.betting, '[]'); - await redis.set(keys.state, JSON.stringify(nextState)); + await writeTournamentProjection(redis, keys, [ + { key: keys.participants, value: [] }, + { key: keys.matches, value: [] }, + { key: keys.betting, value: [] }, + { key: keys.state, value: nextState }, + ]); const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0]; const emperor = world diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index d75f8c2c..b4b24682 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -244,6 +244,13 @@ describe('runtime clock shift projection', () => { }, del: async (key: string) => (values.delete(key) ? 1 : 0), zAdd, + eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => { + const revisionKey = options.keys.at(-1)!; + options.arguments.forEach((value, index) => values.set(options.keys[index]!, value)); + const revision = Number(values.get(revisionKey) ?? '0') + 1; + values.set(revisionKey, String(revision)); + return String(revision); + }, }; const action = { id: actionId, diff --git a/app/game-engine/test/tournamentAutoStart.test.ts b/app/game-engine/test/tournamentAutoStart.test.ts index fb55d21c..02bf4809 100644 --- a/app/game-engine/test/tournamentAutoStart.test.ts +++ b/app/game-engine/test/tournamentAutoStart.test.ts @@ -57,6 +57,13 @@ describe('monthly tournament auto start', () => { values.set(key, value); return 'OK'; }, + eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => { + const revisionKey = options.keys.at(-1)!; + options.arguments.forEach((value, index) => values.set(options.keys[index]!, value)); + const revision = Number(values.get(revisionKey) ?? '0') + 1; + values.set(revisionKey, String(revision)); + return String(revision); + }, } as unknown as RedisConnector['client']; let world: InMemoryTurnWorld | null = null; const consumed: boolean[] = []; @@ -186,6 +193,13 @@ describe('monthly tournament auto start', () => { values.set(key, value); return 'OK'; }, + eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => { + const revisionKey = options.keys.at(-1)!; + options.arguments.forEach((value, index) => values.set(options.keys[index]!, value)); + const revision = Number(values.get(revisionKey) ?? '0') + 1; + values.set(revisionKey, String(revision)); + return String(revision); + }, } as unknown as RedisConnector['client']; let world: InMemoryTurnWorld | null = null; world = new InMemoryTurnWorld(state, snapshot, { diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 08117632..1b247375 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -171,6 +171,7 @@ export const buildTournamentRuntimeKeys = (profileName: string): string[] => [ `sammo:${profileName}:tournament:participants`, `sammo:${profileName}:tournament:matches`, `sammo:${profileName}:tournament:betting`, + `sammo:${profileName}:tournament:source-revision`, ]; export const clearTournamentRuntimeKeys = async ( diff --git a/app/gateway-api/test/tournamentResetState.test.ts b/app/gateway-api/test/tournamentResetState.test.ts index 8b526cd1..d45ac5ea 100644 --- a/app/gateway-api/test/tournamentResetState.test.ts +++ b/app/gateway-api/test/tournamentResetState.test.ts @@ -12,6 +12,7 @@ describe('tournament reset state', () => { 'sammo:che:1010:tournament:participants', 'sammo:che:1010:tournament:matches', 'sammo:che:1010:tournament:betting', + 'sammo:che:1010:tournament:source-revision', ]); expect(buildTournamentRuntimeKeys('hwe:915')).not.toContain('sammo:che:1010:tournament:state'); }); @@ -28,7 +29,7 @@ describe('tournament reset state', () => { 'che:1010' ); - expect(deleted).toBe(4); + expect(deleted).toBe(5); expect(calls).toEqual([buildTournamentRuntimeKeys('che:1010')]); }); }); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index f30d5907..e24e19c2 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -14,6 +14,7 @@ export * from './util/TournamentRNG.js'; export * from './util/sha512.js'; export * from './util/parse.js'; export * from './tournament/autoStart.js'; +export * from './tournament/sourceRevision.js'; export * from './turnDaemon/types.js'; export * from './realtime/keys.js'; export * from './realtime/types.js'; diff --git a/packages/common/src/tournament/autoStart.ts b/packages/common/src/tournament/autoStart.ts index 88753a76..1225da8d 100644 --- a/packages/common/src/tournament/autoStart.ts +++ b/packages/common/src/tournament/autoStart.ts @@ -1,4 +1,5 @@ import { asRecord } from '../util/parse.js'; +import { writeTournamentProjection } from './sourceRevision.js'; interface TournamentState { stage: number; @@ -21,7 +22,8 @@ interface TournamentState { interface RedisClientLike { get(key: string): Promise; - set(key: string, value: string): Promise; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; + publish?(channel: string, message: string): Promise; } const safeJsonParse = (raw: string | null): T | null => { @@ -40,6 +42,8 @@ const buildTournamentKeys = (profileName: string) => ({ participantsKey: `sammo:${profileName}:tournament:participants`, matchesKey: `sammo:${profileName}:tournament:matches`, bettingKey: `sammo:${profileName}:tournament:betting`, + sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, + sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, }); const resolveTermSeconds = (tickSeconds: number): number => { @@ -99,10 +103,12 @@ export const createTournamentAutoStartHandler = (options: { lastErrorAt: undefined, }; - await redis.set(keys.participantsKey, '[]'); - await redis.set(keys.matchesKey, '[]'); - await redis.set(keys.bettingKey, '[]'); - await redis.set(keys.stateKey, JSON.stringify(nextState)); + await writeTournamentProjection(redis, keys, [ + { key: keys.participantsKey, value: [] }, + { key: keys.matchesKey, value: [] }, + { key: keys.bettingKey, value: [] }, + { key: keys.stateKey, value: nextState }, + ]); }; return { @@ -110,4 +116,4 @@ export const createTournamentAutoStartHandler = (options: { void triggerAutoStart(context.currentYear, context.currentMonth); }, }; -}; \ No newline at end of file +}; diff --git a/packages/common/src/tournament/sourceRevision.ts b/packages/common/src/tournament/sourceRevision.ts new file mode 100644 index 00000000..dbfd74a8 --- /dev/null +++ b/packages/common/src/tournament/sourceRevision.ts @@ -0,0 +1,74 @@ +export interface TournamentSourceKeys { + sourceRevisionKey: string; + sourceRevisionChannel: string; +} + +export interface TournamentProjectionRedis { + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; + publish?(channel: string, message: string): Promise; +} + +export interface TournamentProjectionWrite { + key: string; + value: unknown; +} + +const WRITE_TOURNAMENT_PROJECTION_SCRIPT = ` +local revision_key = KEYS[#KEYS] +local current = redis.call('GET', revision_key) +if current then + if not string.match(current, '^%d+$') then + return redis.error_reply('invalid tournament source revision') + end + if string.len(current) > 18 then + return redis.error_reply('tournament source revision exhausted') + end +end +for index = 1, #KEYS - 1 do + redis.call('SET', KEYS[index], ARGV[index]) +end +local revision = redis.call('INCR', revision_key) +return tostring(revision) +`; + +export const parseTournamentSourceRevision = (value: unknown): string | null => { + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value >= 0 ? String(value) : null; + } + if (typeof value === 'bigint') { + return value >= 0n ? value.toString() : null; + } + return typeof value === 'string' && /^(?:0|[1-9]\d*)$/u.test(value) ? value : null; +}; + +/** Atomically stores one or more tournament payloads and advances one profile head. */ +export const writeTournamentProjection = async ( + redis: TournamentProjectionRedis, + keys: TournamentSourceKeys, + writes: readonly TournamentProjectionWrite[] +): Promise => { + if (writes.length === 0) { + throw new Error('Tournament projection write must contain at least one payload.'); + } + if (new Set(writes.map(({ key }) => key)).size !== writes.length) { + throw new Error('Tournament projection write keys must be unique.'); + } + + const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, { + keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey], + arguments: writes.map(({ value }) => JSON.stringify(value)), + }); + const sourceRevision = parseTournamentSourceRevision(result); + if (sourceRevision === null) { + throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.'); + } + + if (redis.publish) { + try { + await redis.publish(keys.sourceRevisionChannel, JSON.stringify({ sourceRevision })); + } catch { + // Payload and revision are committed; publication remains best effort. + } + } + return sourceRevision; +}; diff --git a/packages/common/test/tournamentSourceRevision.test.ts b/packages/common/test/tournamentSourceRevision.test.ts new file mode 100644 index 00000000..9ae61ee3 --- /dev/null +++ b/packages/common/test/tournamentSourceRevision.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { writeTournamentProjection } from '../src/tournament/sourceRevision.js'; + +describe('tournament source revision', () => { + it('passes every payload and one profile revision key to a single atomic script', async () => { + const calls: Array<{ keys: string[]; arguments: string[] }> = []; + const published: string[] = []; + const redis = { + eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => { + calls.push(options); + return '7'; + }, + publish: async (_channel: string, message: string) => { + published.push(message); + return 1; + }, + }; + + await expect( + writeTournamentProjection( + redis, + { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, + [ + { key: 'state', value: { stage: 1 } }, + { key: 'matches', value: [] }, + ] + ) + ).resolves.toBe('7'); + + expect(calls).toEqual([ + { + keys: ['state', 'matches', 'revision'], + arguments: [JSON.stringify({ stage: 1 }), '[]'], + }, + ]); + expect(published).toEqual([JSON.stringify({ sourceRevision: '7' })]); + }); + + it('rejects empty or duplicate writes before evaluating Redis', async () => { + const redis = { eval: async () => '1' }; + await expect( + writeTournamentProjection(redis, { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, []) + ).rejects.toThrow('at least one'); + await expect( + writeTournamentProjection( + redis, + { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, + [ + { key: 'state', value: 1 }, + { key: 'state', value: 2 }, + ] + ) + ).rejects.toThrow('unique'); + }); +});