fix: 모든 토너먼트 writer의 source revision을 원자화

API store뿐 아니라 월 자동 개막과 runtime clock shift도 공통 Lua writer를 사용한다. 프로필 초기화 시 revision key도 함께 제거한다.
This commit is contained in:
2026-08-16 18:49:33 +00:00
parent dd4645e4d0
commit 346f796cfc
12 changed files with 192 additions and 64 deletions
@@ -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<unknown>;
del(key: string): Promise<unknown>;
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
publish?(channel: string, message: string): Promise<unknown>;
}
type TournamentClockState = {
@@ -71,6 +73,10 @@ const shiftTournamentClock = async (
deltaMinutes: number
): Promise<boolean> => {
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) {
@@ -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
@@ -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,
@@ -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, {