perf: 공유 지도와 토너먼트 revision을 원자화
지도 캐시는 coverage가 확인된 PostgreSQL map.world head만 사용하고 실패 시 전체 계산으로 복구한다. 토너먼트 Redis payload와 source revision은 Lua 한 번으로 갱신한다.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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: {} }]),
|
||||
};
|
||||
|
||||
@@ -28,6 +28,20 @@ class MemoryRedis {
|
||||
async del(key: string): Promise<number> {
|
||||
return this.values.delete(key) ? 1 : 0;
|
||||
}
|
||||
|
||||
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 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<number> {
|
||||
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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>();
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<string> {
|
||||
this.values.set(key, value);
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
|
||||
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<number> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,20 @@ class MemoryRedis {
|
||||
this.store.set(key, value);
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
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 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<number> {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
|
||||
@@ -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<void>) | 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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<string, string>();
|
||||
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<string, unknown>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user