perf: 공유 지도와 토너먼트 revision을 원자화
지도 캐시는 coverage가 확인된 PostgreSQL map.world head만 사용하고 실패 시 전체 계산으로 복구한다. 토너먼트 Redis payload와 source revision은 Lua 한 번으로 갱신한다.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { GameApiContext, WorldStateRow } from '../context.js';
|
||||
import { asRecord, buildGameReadModelDomainRevisionKey, isRecord } from '@sammo-ts/common';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
|
||||
|
||||
export type MapCityCompact = [number, number, number, number, number, number];
|
||||
export type MapNationCompact = [number, string, string, number];
|
||||
@@ -116,38 +117,41 @@ const resolveSpyList = (meta: Record<string, unknown>): Record<number, number> =
|
||||
const buildBaseMapCacheKey = (ctx: GameApiContext, scope: 'base' | 'public' = 'base'): string =>
|
||||
`sammo:map:${scope}:${ctx.profile.id}:${ctx.profile.scenario}`;
|
||||
|
||||
const loadWorldMapRevision = async (ctx: GameApiContext): Promise<string> => {
|
||||
const redis = ctx.redis as unknown as {
|
||||
hGet?: (key: string, field: string) => Promise<string | null>;
|
||||
};
|
||||
if (typeof redis.hGet !== 'function') {
|
||||
return '0';
|
||||
}
|
||||
try {
|
||||
return (await redis.hGet(buildGameReadModelDomainRevisionKey(ctx.profile.name), 'world')) ?? '0';
|
||||
} catch {
|
||||
// Cache revision lookup must not make the map unavailable.
|
||||
return '0';
|
||||
}
|
||||
export const buildRevisionedBaseMapCacheKey = async (
|
||||
ctx: GameApiContext,
|
||||
scope: 'base' | 'public' = 'base'
|
||||
): Promise<string | null> => {
|
||||
const revision = await readMapWorldSourceRevision(ctx.db);
|
||||
return revision === null ? null : `${buildBaseMapCacheKey(ctx, scope)}:pg${revision}`;
|
||||
};
|
||||
|
||||
export const buildRevisionedBaseMapCacheKey = async (ctx: GameApiContext): Promise<string> =>
|
||||
`${buildBaseMapCacheKey(ctx)}:r${await loadWorldMapRevision(ctx)}`;
|
||||
|
||||
const loadBaseMap = async (
|
||||
ctx: GameApiContext,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
cacheKey?: string;
|
||||
cacheScope?: 'base' | 'public';
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
): Promise<BaseMapResult | null> => {
|
||||
const useCache = options?.useCache ?? true;
|
||||
const cacheKey = options?.cacheKey ?? (await buildRevisionedBaseMapCacheKey(ctx));
|
||||
let useCache = options?.useCache ?? true;
|
||||
let cacheKey = options?.cacheKey;
|
||||
if (useCache && !cacheKey) {
|
||||
cacheKey = (await buildRevisionedBaseMapCacheKey(ctx, options?.cacheScope)) ?? undefined;
|
||||
if (!cacheKey) {
|
||||
useCache = false;
|
||||
}
|
||||
}
|
||||
const ttlSeconds = options?.ttlSeconds ?? BASE_MAP_TTL_SECONDS;
|
||||
|
||||
if (useCache) {
|
||||
const cached = await ctx.redis.get(cacheKey);
|
||||
let cached: string | null = null;
|
||||
try {
|
||||
cached = await ctx.redis.get(cacheKey!);
|
||||
} catch {
|
||||
// Redis cache availability must not make the authoritative map unavailable.
|
||||
useCache = false;
|
||||
}
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached) as BaseMapResult;
|
||||
@@ -208,9 +212,13 @@ const loadBaseMap = async (
|
||||
};
|
||||
|
||||
if (useCache) {
|
||||
await ctx.redis.set(cacheKey, JSON.stringify(baseMap), {
|
||||
EX: ttlSeconds,
|
||||
});
|
||||
try {
|
||||
await ctx.redis.set(cacheKey!, JSON.stringify(baseMap), {
|
||||
EX: ttlSeconds,
|
||||
});
|
||||
} catch {
|
||||
// The computed PostgreSQL result remains usable when Redis is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
return baseMap;
|
||||
@@ -219,7 +227,7 @@ const loadBaseMap = async (
|
||||
export const loadPublicMap = async (ctx: GameApiContext, useCache = true): Promise<BaseMapResult | null> => {
|
||||
return loadBaseMap(ctx, {
|
||||
useCache,
|
||||
cacheKey: buildBaseMapCacheKey(ctx, 'public'),
|
||||
cacheScope: 'public',
|
||||
ttlSeconds: PUBLIC_MAP_TTL_SECONDS,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
/** Reserved until every map.world producer is reconciled; runtime coverage remains 0. */
|
||||
export const MAP_WORLD_SOURCE_COVERAGE_VERSION = 1;
|
||||
|
||||
interface MapWorldSourceRevisionRow {
|
||||
coverageVersion: number;
|
||||
revision: bigint | number | string | null;
|
||||
}
|
||||
|
||||
const parseRevision = (value: unknown): string | null => {
|
||||
if (typeof value === 'bigint') return value >= 0n ? value.toString() : null;
|
||||
if (typeof value === 'number') {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
|
||||
}
|
||||
return typeof value === 'string' && /^(?:0|[1-9]\d*)$/u.test(value) ? value : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an authoritative PostgreSQL map.world head only after coverage is
|
||||
* explicitly enabled. Missing meta/revision rows, malformed results, and query
|
||||
* failures disable the shared cache instead of reusing a potentially stale key.
|
||||
*/
|
||||
export const readMapWorldSourceRevision = async (
|
||||
db: Pick<DatabaseClient, '$queryRaw'>
|
||||
): Promise<string | null> => {
|
||||
let rows: MapWorldSourceRevisionRow[];
|
||||
try {
|
||||
rows = await db.$queryRaw<MapWorldSourceRevisionRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
meta."coverage_version" AS "coverageVersion",
|
||||
revision."revision" AS "revision"
|
||||
FROM "read_model_revision_meta" AS meta
|
||||
LEFT JOIN "read_model_revision" AS revision
|
||||
ON revision."domain" = 'map.world'
|
||||
AND revision."entity_id" = 0
|
||||
WHERE meta."id" = 1
|
||||
`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = Array.isArray(rows) && rows.length === 1 ? rows[0] : undefined;
|
||||
if (
|
||||
!row ||
|
||||
!Number.isSafeInteger(row.coverageVersion) ||
|
||||
row.coverageVersion < MAP_WORLD_SOURCE_COVERAGE_VERSION
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parseRevision(row.revision);
|
||||
};
|
||||
@@ -131,11 +131,12 @@ export const tournamentRouter = router({
|
||||
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const [state, participants, matches, bets] = await Promise.all([
|
||||
const [state, participants, matches, bets, sourceRevision] = await Promise.all([
|
||||
store.getState(),
|
||||
store.getParticipants(),
|
||||
store.getMatches(),
|
||||
store.getBettingEntries(),
|
||||
store.getSourceRevision(),
|
||||
]);
|
||||
const participantIds = [...new Set(participants.map((participant) => participant.id))];
|
||||
const iconRows =
|
||||
@@ -154,7 +155,7 @@ export const tournamentRouter = router({
|
||||
imageServer: icon?.imageServer ?? 0,
|
||||
};
|
||||
});
|
||||
return { state, participants: publicParticipants, matches, betCount: bets.length };
|
||||
return { state, participants: publicParticipants, matches, betCount: bets.length, sourceRevision };
|
||||
}),
|
||||
getRankings: authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface TournamentKeys {
|
||||
participantsKey: string;
|
||||
matchesKey: string;
|
||||
bettingKey: string;
|
||||
sourceRevisionKey: string;
|
||||
sourceRevisionChannel: string;
|
||||
}
|
||||
|
||||
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||
@@ -10,4 +12,6 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||
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`,
|
||||
});
|
||||
|
||||
@@ -14,8 +14,35 @@ interface RedisClientLike {
|
||||
}
|
||||
): 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;
|
||||
@@ -61,32 +88,59 @@ export class TournamentStore {
|
||||
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
|
||||
}
|
||||
|
||||
async setState(state: TournamentState): Promise<void> {
|
||||
await this.redis.set(this.keys.stateKey, JSON.stringify(state));
|
||||
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<void> {
|
||||
await this.redis.set(this.keys.participantsKey, JSON.stringify(participants));
|
||||
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<void> {
|
||||
await this.redis.set(this.keys.matchesKey, JSON.stringify(matches));
|
||||
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<void> {
|
||||
await this.redis.set(this.keys.bettingKey, JSON.stringify(entries));
|
||||
async setBettingEntries(entries: TournamentBetEntry[]): Promise<string> {
|
||||
return this.writeWithSourceRevision(this.keys.bettingKey, entries);
|
||||
}
|
||||
|
||||
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
||||
|
||||
Reference in New Issue
Block a user