fix: 모든 토너먼트 writer의 source revision을 원자화
API store뿐 아니라 월 자동 개막과 runtime clock shift도 공통 Lua writer를 사용한다. 프로필 초기화 시 revision key도 함께 제거한다.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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<string | null>;
|
||||
set(key: string, value: string): Promise<unknown>;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
publish?(channel: string, message: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
const safeJsonParse = <T>(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);
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface TournamentSourceKeys {
|
||||
sourceRevisionKey: string;
|
||||
sourceRevisionChannel: string;
|
||||
}
|
||||
|
||||
export interface TournamentProjectionRedis {
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
publish?(channel: string, message: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
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<string> => {
|
||||
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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user