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
+3 -46
View File
@@ -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<unknown>;
}
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 = <T>(raw: string | null): T | null => {
if (!raw) {
return null;
@@ -89,30 +65,11 @@ export class TournamentStore {
}
async getSourceRevision(): Promise<string | null> {
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<string> {
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<string> {
+5 -4
View File
@@ -28,11 +28,12 @@ class MemoryRedis {
}
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
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);
}