Files
core2026/app/game-api/src/tournament/store.ts
T
Hide_D e4ac2a60b1 perf: 공유 지도와 토너먼트 revision을 원자화
지도 캐시는 coverage가 확인된 PostgreSQL map.world head만 사용하고 실패 시 전체 계산으로 복구한다. 토너먼트 Redis payload와 source revision은 Lua 한 번으로 갱신한다.
2026-08-16 18:37:48 +00:00

160 lines
5.5 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import type { TournamentKeys } from './keys.js';
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
interface RedisClientLike {
get(key: string): Promise<string | null>;
set(
key: string,
value: string,
options?: {
NX?: boolean;
PX?: number;
}
): Promise<unknown>;
del?(key: string): Promise<unknown>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
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;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
};
export class TournamentStore {
constructor(
private readonly redis: RedisClientLike,
private readonly keys: TournamentKeys
) {}
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
if (!this.redis.del) {
return operation();
}
const lockKey = `${this.keys.stateKey}:mutation-lock`;
const token = randomUUID();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) {
try {
return await operation();
} finally {
if ((await this.redis.get(lockKey)) === token) {
await this.redis.del(lockKey);
}
}
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error('토너먼트 요청이 처리 중입니다. 잠시 후 다시 시도해주세요.');
}
async getState(): Promise<TournamentState | null> {
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
}
async getSourceRevision(): Promise<string | null> {
return parseSourceRevision(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;
}
async setState(state: TournamentState): Promise<string> {
return this.writeWithSourceRevision(this.keys.stateKey, state);
}
async getParticipants(): Promise<TournamentParticipantEntry[]> {
return safeJsonParse<TournamentParticipantEntry[]>(await this.redis.get(this.keys.participantsKey)) ?? [];
}
async setParticipants(participants: TournamentParticipantEntry[]): Promise<string> {
return this.writeWithSourceRevision(this.keys.participantsKey, participants);
}
async getMatches(): Promise<TournamentMatchEntry[]> {
return safeJsonParse<TournamentMatchEntry[]>(await this.redis.get(this.keys.matchesKey)) ?? [];
}
async setMatches(matches: TournamentMatchEntry[]): Promise<string> {
return this.writeWithSourceRevision(this.keys.matchesKey, matches);
}
async getBettingEntries(): Promise<TournamentBetEntry[]> {
return safeJsonParse<TournamentBetEntry[]>(await this.redis.get(this.keys.bettingKey)) ?? [];
}
async setBettingEntries(entries: TournamentBetEntry[]): Promise<string> {
return this.writeWithSourceRevision(this.keys.bettingKey, entries);
}
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
const entries = await this.getBettingEntries();
const existing = entries.find(
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
);
if (existing) {
existing.amount += entry.amount;
} else {
entries.push(entry);
}
await this.setBettingEntries(entries);
return entries;
}
}