perf: 공유 지도와 토너먼트 revision을 원자화

지도 캐시는 coverage가 확인된 PostgreSQL map.world head만 사용하고 실패 시 전체 계산으로 복구한다. 토너먼트 Redis payload와 source revision은 Lua 한 번으로 갱신한다.
This commit is contained in:
2026-08-16 18:37:48 +00:00
parent 7e099326ec
commit e4ac2a60b1
13 changed files with 482 additions and 55 deletions
+32 -24
View File
@@ -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);
};