From e4ac2a60b1af46926158752968dc0a447065318c Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 16 Aug 2026 18:37:48 +0000 Subject: [PATCH] =?UTF-8?q?perf:=20=EA=B3=B5=EC=9C=A0=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=EC=99=80=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20revision?= =?UTF-8?q?=EC=9D=84=20=EC=9B=90=EC=9E=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지도 캐시는 coverage가 확인된 PostgreSQL map.world head만 사용하고 실패 시 전체 계산으로 복구한다. 토너먼트 Redis payload와 source revision은 Lua 한 번으로 갱신한다. --- app/game-api/src/maps/worldMap.ts | 56 ++++--- .../src/maps/worldMapSourceRevision.ts | 54 +++++++ app/game-api/src/router/tournament/index.ts | 5 +- app/game-api/src/tournament/keys.ts | 4 + app/game-api/src/tournament/store.ts | 70 ++++++++- app/game-api/test/inGameInfoRouter.test.ts | 1 + .../test/publicCachedMapHistory.test.ts | 1 + app/game-api/test/tournamentRouter.test.ts | 16 ++ ...ournamentStoreRevision.integration.test.ts | 47 ++++++ .../test/tournamentStoreRevision.test.ts | 90 ++++++++++++ app/game-api/test/tournamentWorker.test.ts | 14 ++ .../worldMapRevisionCache.integration.test.ts | 41 ++++++ .../test/worldMapRevisionCache.test.ts | 138 +++++++++++++++--- 13 files changed, 482 insertions(+), 55 deletions(-) create mode 100644 app/game-api/src/maps/worldMapSourceRevision.ts create mode 100644 app/game-api/test/tournamentStoreRevision.integration.test.ts create mode 100644 app/game-api/test/tournamentStoreRevision.test.ts create mode 100644 app/game-api/test/worldMapRevisionCache.integration.test.ts diff --git a/app/game-api/src/maps/worldMap.ts b/app/game-api/src/maps/worldMap.ts index 9bd65a3a..53dd257e 100644 --- a/app/game-api/src/maps/worldMap.ts +++ b/app/game-api/src/maps/worldMap.ts @@ -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): Record = const buildBaseMapCacheKey = (ctx: GameApiContext, scope: 'base' | 'public' = 'base'): string => `sammo:map:${scope}:${ctx.profile.id}:${ctx.profile.scenario}`; -const loadWorldMapRevision = async (ctx: GameApiContext): Promise => { - const redis = ctx.redis as unknown as { - hGet?: (key: string, field: string) => Promise; - }; - 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 => { + const revision = await readMapWorldSourceRevision(ctx.db); + return revision === null ? null : `${buildBaseMapCacheKey(ctx, scope)}:pg${revision}`; }; -export const buildRevisionedBaseMapCacheKey = async (ctx: GameApiContext): Promise => - `${buildBaseMapCacheKey(ctx)}:r${await loadWorldMapRevision(ctx)}`; - const loadBaseMap = async ( ctx: GameApiContext, options?: { useCache?: boolean; cacheKey?: string; + cacheScope?: 'base' | 'public'; ttlSeconds?: number; } ): Promise => { - 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 => { return loadBaseMap(ctx, { useCache, - cacheKey: buildBaseMapCacheKey(ctx, 'public'), + cacheScope: 'public', ttlSeconds: PUBLIC_MAP_TTL_SECONDS, }); }; diff --git a/app/game-api/src/maps/worldMapSourceRevision.ts b/app/game-api/src/maps/worldMapSourceRevision.ts new file mode 100644 index 00000000..d74eca43 --- /dev/null +++ b/app/game-api/src/maps/worldMapSourceRevision.ts @@ -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 +): Promise => { + let rows: MapWorldSourceRevisionRow[]; + try { + rows = await db.$queryRaw(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); +}; diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index fec115e6..7c8dba4c 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -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); diff --git a/app/game-api/src/tournament/keys.ts b/app/game-api/src/tournament/keys.ts index 5880fe52..f0fefcef 100644 --- a/app/game-api/src/tournament/keys.ts +++ b/app/game-api/src/tournament/keys.ts @@ -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`, }); diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index 2da037cb..d615adef 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -14,8 +14,35 @@ interface RedisClientLike { } ): Promise; del?(key: string): Promise; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; + publish?(channel: string, message: string): Promise; } +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 = (raw: string | null): T | null => { if (!raw) { return null; @@ -61,32 +88,59 @@ export class TournamentStore { return safeJsonParse(await this.redis.get(this.keys.stateKey)); } - async setState(state: TournamentState): Promise { - await this.redis.set(this.keys.stateKey, JSON.stringify(state)); + async getSourceRevision(): Promise { + return parseSourceRevision(await this.redis.get(this.keys.sourceRevisionKey)); + } + + private async writeWithSourceRevision(key: string, value: unknown): Promise { + 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 { + return this.writeWithSourceRevision(this.keys.stateKey, state); } async getParticipants(): Promise { return safeJsonParse(await this.redis.get(this.keys.participantsKey)) ?? []; } - async setParticipants(participants: TournamentParticipantEntry[]): Promise { - await this.redis.set(this.keys.participantsKey, JSON.stringify(participants)); + async setParticipants(participants: TournamentParticipantEntry[]): Promise { + return this.writeWithSourceRevision(this.keys.participantsKey, participants); } async getMatches(): Promise { return safeJsonParse(await this.redis.get(this.keys.matchesKey)) ?? []; } - async setMatches(matches: TournamentMatchEntry[]): Promise { - await this.redis.set(this.keys.matchesKey, JSON.stringify(matches)); + async setMatches(matches: TournamentMatchEntry[]): Promise { + return this.writeWithSourceRevision(this.keys.matchesKey, matches); } async getBettingEntries(): Promise { return safeJsonParse(await this.redis.get(this.keys.bettingKey)) ?? []; } - async setBettingEntries(entries: TournamentBetEntry[]): Promise { - await this.redis.set(this.keys.bettingKey, JSON.stringify(entries)); + async setBettingEntries(entries: TournamentBetEntry[]): Promise { + return this.writeWithSourceRevision(this.keys.bettingKey, entries); } async appendBettingEntry(entry: TournamentBetEntry): Promise { diff --git a/app/game-api/test/inGameInfoRouter.test.ts b/app/game-api/test/inGameInfoRouter.test.ts index 18f37b22..95a16ddf 100644 --- a/app/game-api/test/inGameInfoRouter.test.ts +++ b/app/game-api/test/inGameInfoRouter.test.ts @@ -160,6 +160,7 @@ const context = ( diplomacy: { findMany: vi.fn(async () => []) }, $queryRaw: vi .fn() + .mockResolvedValueOnce([{ coverageVersion: 0, revision: null }]) .mockResolvedValueOnce( cities.map((item) => ({ id: item.id, diff --git a/app/game-api/test/publicCachedMapHistory.test.ts b/app/game-api/test/publicCachedMapHistory.test.ts index 8fc17e45..bd69c2a9 100644 --- a/app/game-api/test/publicCachedMapHistory.test.ts +++ b/app/game-api/test/publicCachedMapHistory.test.ts @@ -44,6 +44,7 @@ const buildContext = () => { }, $queryRaw: vi .fn() + .mockResolvedValueOnce([{ coverageVersion: 0, revision: null }]) .mockResolvedValueOnce([{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }]) .mockResolvedValueOnce([{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }]), }; diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index 5cef826e..1e84de66 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -28,6 +28,20 @@ class MemoryRedis { async del(key: string): Promise { return this.values.delete(key) ? 1 : 0; } + + async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise { + const [valueKey, revisionKey] = options.keys; + const [value] = options.arguments; + if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments'); + const revision = Number(this.values.get(revisionKey) ?? '0') + 1; + this.values.set(valueKey, value); + this.values.set(revisionKey, String(revision)); + return String(revision); + } + + async publish(): Promise { + return 0; + } } class TournamentTransport implements TurnDaemonTransport { @@ -324,6 +338,7 @@ describe('tournament router permissions and mutations', () => { termSeconds: 60, nextAt: '2026-07-26T01:00:00.000Z', }); + await redis.set('sammo:che:default:tournament:source-revision', '41'); const caller = appRouter.createCaller( buildContext({ redis, transport, generals: [owner, rival], userId: 'user-1' }) ); @@ -334,6 +349,7 @@ describe('tournament router permissions and mutations', () => { expect.objectContaining({ id: 11, picture: '11.jpg', imageServer: 1 }), expect.objectContaining({ id: 12, picture: '12.jpg', imageServer: 0 }), ]); + expect(snapshot.sourceRevision).toBe('41'); }); it('refunds gold when the tournament bet rank update fails', async () => { diff --git a/app/game-api/test/tournamentStoreRevision.integration.test.ts b/app/game-api/test/tournamentStoreRevision.integration.test.ts new file mode 100644 index 00000000..cca74c1b --- /dev/null +++ b/app/game-api/test/tournamentStoreRevision.integration.test.ts @@ -0,0 +1,47 @@ +import { randomUUID } from 'node:crypto'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createRedisConnector, resolveRedisConfigFromEnv, type RedisConnector } from '@sammo-ts/infra'; + +import { buildTournamentKeys } from '../src/tournament/keys.js'; +import { TournamentStore } from '../src/tournament/store.js'; + +const integration = describe.skipIf(!process.env.REDIS_URL); + +integration('TournamentStore Redis source revision', () => { + let connector: RedisConnector; + const profile = `test:tournament-revision:${randomUUID()}`; + const keys = buildTournamentKeys(profile); + + beforeAll(async () => { + connector = createRedisConnector(resolveRedisConfigFromEnv()); + await connector.connect(); + }); + + afterAll(async () => { + if (!connector) return; + await connector.client.del([ + keys.stateKey, + keys.participantsKey, + keys.matchesKey, + keys.bettingKey, + keys.sourceRevisionKey, + ]); + await connector.disconnect(); + }); + + it('commits concurrent payload writes with unique monotonic revisions', async () => { + const store = new TournamentStore(connector.client, keys); + const revisions = await Promise.all( + Array.from({ length: 20 }, (_, index) => + store.setMatches([ + { id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 }, + ]) + ) + ); + + expect(new Set(revisions).size).toBe(20); + await expect(store.getSourceRevision()).resolves.toBe('20'); + await expect(store.getMatches()).resolves.toHaveLength(1); + }); +}); diff --git a/app/game-api/test/tournamentStoreRevision.test.ts b/app/game-api/test/tournamentStoreRevision.test.ts new file mode 100644 index 00000000..cea7a1dd --- /dev/null +++ b/app/game-api/test/tournamentStoreRevision.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTournamentKeys } from '../src/tournament/keys.js'; +import { TournamentStore } from '../src/tournament/store.js'; + +class AtomicMemoryRedis { + readonly events: string[] = []; + readonly published: Array<{ channel: string; message: string }> = []; + failNextEval = false; + private readonly values = new Map(); + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async set(key: string, value: string): Promise { + this.values.set(key, value); + return 'OK'; + } + + async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise { + if (this.failNextEval) { + this.failNextEval = false; + throw new Error('injected Redis write failure'); + } + const [valueKey, revisionKey] = options.keys; + const [value] = options.arguments; + if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments'); + + const revision = Number(this.values.get(revisionKey) ?? '0') + 1; + this.values.set(valueKey, value); + this.values.set(revisionKey, String(revision)); + this.events.push(`commit:${revision}`); + return String(revision); + } + + async publish(channel: string, message: string): Promise { + this.events.push(`publish:${JSON.parse(message).sourceRevision as string}`); + this.published.push({ channel, message }); + return 1; + } +} + +describe('TournamentStore source revision', () => { + it('publishes only after the payload and source revision commit atomically', async () => { + const redis = new AtomicMemoryRedis(); + const keys = buildTournamentKeys('che:default'); + const store = new TournamentStore(redis, keys); + + await expect(store.setParticipants([{ id: 7, name: '관우', leadership: 90, strength: 97, intel: 75, level: 5 }])) + .resolves.toBe('1'); + + await expect(store.getSourceRevision()).resolves.toBe('1'); + await expect(store.getParticipants()).resolves.toHaveLength(1); + expect(redis.events).toEqual(['commit:1', 'publish:1']); + expect(redis.published).toEqual([ + { channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) }, + ]); + }); + + it('does not advance the source revision or publish when the atomic write fails', async () => { + const redis = new AtomicMemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys('hwe:default')); + redis.failNextEval = true; + + await expect(store.setParticipants([{ id: 1, name: '실패', leadership: 1, strength: 1, intel: 1, level: 1 }])) + .rejects.toThrow('injected Redis write failure'); + + await expect(store.getParticipants()).resolves.toEqual([]); + await expect(store.getSourceRevision()).resolves.toBeNull(); + expect(redis.published).toEqual([]); + }); + + it('serializes concurrent writes into monotonic per-profile revisions', async () => { + const redis = new AtomicMemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys('pwe:default')); + + const revisions = await Promise.all( + Array.from({ length: 50 }, (_, index) => + store.setMatches([ + { id: index + 1, stage: 7, roundIndex: index, attackerId: 1, defenderId: 2 }, + ]) + ) + ); + + expect(new Set(revisions).size).toBe(50); + await expect(store.getSourceRevision()).resolves.toBe('50'); + expect(redis.published).toHaveLength(50); + }); +}); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 87015e51..f0c16d13 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -26,6 +26,20 @@ class MemoryRedis { this.store.set(key, value); return 'OK'; } + + async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise { + const [valueKey, revisionKey] = options.keys; + const [value] = options.arguments; + if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments'); + const revision = Number(this.store.get(revisionKey) ?? '0') + 1; + this.store.set(valueKey, value); + this.store.set(revisionKey, String(revision)); + return String(revision); + } + + async publish(): Promise { + return 0; + } } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; diff --git a/app/game-api/test/worldMapRevisionCache.integration.test.ts b/app/game-api/test/worldMapRevisionCache.integration.test.ts new file mode 100644 index 00000000..9e6e6535 --- /dev/null +++ b/app/game-api/test/worldMapRevisionCache.integration.test.ts @@ -0,0 +1,41 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); + +integration('world map PostgreSQL source revision', () => { + let db: GamePrismaClient; + let close: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + close = () => connector.disconnect(); + }); + + afterAll(async () => close?.()); + + it('reads coverage and map.world from the same transaction snapshot', async () => { + const rollback = new Error('rollback map revision fixture'); + await expect( + db.$transaction(async (transaction) => { + await transaction.readModelRevisionMeta.upsert({ + where: { id: 1 }, + create: { id: 1, coverageVersion: 1 }, + update: { coverageVersion: 1 }, + }); + await transaction.readModelRevision.upsert({ + where: { domain_entityId: { domain: 'map.world', entityId: 0 } }, + create: { domain: 'map.world', entityId: 0, revision: 37n }, + update: { revision: 37n }, + }); + await expect(readMapWorldSourceRevision(transaction)).resolves.toBe('37'); + throw rollback; + }) + ).rejects.toBe(rollback); + }); +}); diff --git a/app/game-api/test/worldMapRevisionCache.test.ts b/app/game-api/test/worldMapRevisionCache.test.ts index 1c812edc..2681eb57 100644 --- a/app/game-api/test/worldMapRevisionCache.test.ts +++ b/app/game-api/test/worldMapRevisionCache.test.ts @@ -1,36 +1,132 @@ -import { describe, expect, it } from 'vitest'; -import { buildGameReadModelDomainRevisionKey } from '@sammo-ts/common'; +import { describe, expect, it, vi } from 'vitest'; -import { buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js'; import type { GameApiContext } from '../src/context.js'; +import { loadWorldMap, buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js'; +import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js'; + +const revisionRow = (overrides: Record = {}) => ({ + coverageVersion: 1, + revision: 12n, + ...overrides, +}); describe('world map revision cache', () => { - it('selects a new shared base-map key after a committed world revision', async () => { - const reads: Array<[string, string]> = []; + it('keys shared base and public maps from the authoritative PostgreSQL map.world head', async () => { + const queryRaw = vi.fn(async (_query: unknown) => [revisionRow()]); const ctx = { - profile: { id: 'hwe', name: 'hwe', scenario: 'scenario_2400' }, - redis: { - hGet: async (key: string, field: string) => { - reads.push([key, field]); - return '12'; - }, + profile: { id: 'hwe', name: 'hwe:default', scenario: 'scenario_2400' }, + db: { $queryRaw: queryRaw }, + } as unknown as GameApiContext; + + await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe( + 'sammo:map:base:hwe:scenario_2400:pg12' + ); + await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe( + 'sammo:map:public:hwe:scenario_2400:pg12' + ); + expect(queryRaw).toHaveBeenCalledTimes(2); + const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] }; + expect(statement.sql).toContain('read_model_revision_meta'); + expect(statement.sql).toContain("revision.\"domain\" = 'map.world'"); + expect(statement.values).toEqual([]); + }); + + it('disables shared caching for coverage zero, missing rows, malformed results, and query errors', async () => { + for (const rows of [ + [revisionRow({ coverageVersion: 0 })], + [revisionRow({ revision: null })], + [revisionRow({ revision: 'bad' })], + [], + ]) { + await expect( + readMapWorldSourceRevision({ $queryRaw: vi.fn(async (_query: unknown) => rows) } as never) + ).resolves.toBeNull(); + } + await expect( + readMapWorldSourceRevision({ + $queryRaw: vi.fn(async (_query: unknown) => Promise.reject(new Error('db unavailable'))), + } as never) + ).resolves.toBeNull(); + }); + + it('caches only the public base while composing viewer-private fields per request', async () => { + const cache = new Map(); + const redis = { + get: vi.fn(async (key: string) => cache.get(key) ?? null), + set: vi.fn(async (key: string, value: string) => { + cache.set(key, value); + return 'OK'; + }), + }; + const worldState = { + currentYear: 185, + currentMonth: 4, + config: { const: {} }, + meta: { scenarioMeta: { startYear: 184 } }, + }; + const generals = new Map([ + [7, { id: 7, cityId: 3, nationId: 2 }], + [8, { id: 8, cityId: 4, nationId: 3 }], + ]); + const nations = new Map([ + [2, { id: 2, meta: { spyList: { 5: 9 } } }], + [3, { id: 3, meta: { spyList: { 6: 8 } } }], + ]); + const queryRaw = vi.fn(async (statement: { sql?: string }) => { + const sql = statement.sql ?? ''; + if (sql.includes('read_model_revision_meta')) return [revisionRow()]; + if (sql.includes('FROM city')) { + return [{ id: 3, level: 1, nationId: 2, region: 1, supplyState: 1, meta: { state: 0 } }]; + } + if (sql.includes('FROM nation')) { + return [{ id: 2, name: '위', color: '#123456', capitalCityId: 3, meta: {} }]; + } + if (sql.includes('SELECT DISTINCT city_id')) return [{ cityId: 3 }]; + return []; + }); + const ctx = { + profile: { id: 'hwe', name: 'hwe:default', scenario: 'scenario_2400' }, + redis, + db: { + $queryRaw: queryRaw, + worldState: { findFirst: vi.fn(async () => worldState) }, + general: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => generals.get(where.id)) }, + nation: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => nations.get(where.id)) }, }, } as unknown as GameApiContext; - await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe( - 'sammo:map:base:hwe:scenario_2400:r12' - ); - expect(reads).toEqual([[buildGameReadModelDomainRevisionKey('hwe'), 'world']]); + const first = await loadWorldMap(ctx, { generalId: 7, useCache: true }); + const second = await loadWorldMap(ctx, { generalId: 8, useCache: true }); + + expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } }); + expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } }); + expect(redis.set).toHaveBeenCalledTimes(1); + const shared = JSON.parse(cache.values().next().value as string) as Record; + expect(shared).not.toHaveProperty('spyList'); + expect(shared).not.toHaveProperty('shownByGeneralList'); + expect(shared).not.toHaveProperty('myCity'); + expect(shared).not.toHaveProperty('myNation'); }); - it('falls back to revision zero when Redis is temporarily unavailable', async () => { + it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => { + const redis = { get: vi.fn(), set: vi.fn() }; + const queryRaw = vi.fn(async (statement: { sql?: string }) => { + const sql = statement.sql ?? ''; + if (sql.includes('read_model_revision_meta')) return [revisionRow({ coverageVersion: 0 })]; + if (sql.includes('FROM city') || sql.includes('FROM nation')) return []; + return []; + }); const ctx = { - profile: { id: 'hwe', name: 'hwe', scenario: 'scenario_2400' }, - redis: { hGet: async () => Promise.reject(new Error('redis unavailable')) }, + profile: { id: 'hwe', name: 'hwe:default', scenario: 'scenario_2400' }, + redis, + db: { + $queryRaw: queryRaw, + worldState: { findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })) }, + }, } as unknown as GameApiContext; - await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe( - 'sammo:map:base:hwe:scenario_2400:r0' - ); + await expect(loadWorldMap(ctx, { useCache: true })).resolves.toMatchObject({ result: true }); + expect(redis.get).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); }); });