perf: 접속 점수를 Redis 배치로 지연 반영

대시보드 벌점을 read-model source와 outbox에서 제외하고 수동 projection의 가점을 Redis에 합산한다. 5초 worker가 월별 traffic과 접속 점수를 멱등 PostgreSQL batch로 반영하며 메인 제한 gate는 Redis TTL marker만 조회한다.
This commit is contained in:
2026-08-17 14:50:34 +00:00
parent fb6add83a5
commit 5bc1e3473f
17 changed files with 1022 additions and 83 deletions
+4 -13
View File
@@ -73,6 +73,7 @@ const buildContext = (authenticated: boolean, generalAccessTracking = false) =>
generalAccessTracking,
redis: {
get: async (key: string) => redisValues.get(key) ?? null,
eval: async () => 1,
set: async (key: string, value: string) => {
redisValues.set(key, value);
return 'OK';
@@ -109,7 +110,6 @@ const installSourceRevisionState = (
cityRevision: bigint;
nationRevision: bigint;
worldRevision: bigint;
accessRevision: bigint;
}> = {}
) => {
let row = {
@@ -122,12 +122,10 @@ const installSourceRevisionState = (
cityRevision: 0n,
nationRevision: 0n,
worldRevision: 1n,
accessRevision: 1n,
...initial,
};
const queryRaw = vi.fn(async () => [row]);
Object.assign(context.db, { $queryRaw: queryRaw });
context.realtimeAccessGeneralId = 7;
return {
queryRaw,
update: (next: Partial<typeof row>) => {
@@ -227,7 +225,7 @@ describe('dashboardRouter.getContextBundleDelta', () => {
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('uses an all-false bundle as an access-only gate without projecting dashboard context', async () => {
it('uses an all-false bundle as a Redis-only access gate without projecting dashboard context', async () => {
const fixture = buildContext(true, true);
const queryRaw = vi.fn(async (_query: unknown) => []);
Object.assign(fixture.context.db, { $queryRaw: queryRaw });
@@ -236,12 +234,7 @@ describe('dashboardRouter.getContextBundleDelta', () => {
include: { context: false, commandTable: false, boardAccess: false },
})
).resolves.toEqual({ context: undefined, commandTable: undefined, boardAccess: undefined });
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
expect(fixture.findGeneral).toHaveBeenCalledWith({
where: { userId: auth.user.id },
orderBy: { id: 'asc' },
select: { id: true, turnTime: true },
});
expect(fixture.findGeneral).not.toHaveBeenCalled();
expect(queryRaw).not.toHaveBeenCalled();
});
@@ -340,8 +333,6 @@ describe('dashboardRouter.getContextBundleDelta', () => {
Object.assign(fixture.context.db, {
$queryRaw: vi.fn(async () => Promise.reject(new Error('revision table unavailable'))),
});
fixture.context.realtimeAccessGeneralId = 7;
const result = await dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
...contextOnly,
known: { context: 'A'.repeat(22) },
@@ -349,6 +340,6 @@ describe('dashboardRouter.getContextBundleDelta', () => {
});
expect(result.context?.kind).toBe('snapshot');
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
expect(fixture.findGeneral).toHaveBeenCalledTimes(2);
});
});
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js';
import { readDashboardSourceRevisionState } from '../src/services/dashboardSourceRevision.js';
import {
readDashboardSourceRevisionState,
readDashboardSourceRevisionStateForUser,
} from '../src/services/dashboardSourceRevision.js';
const row = (overrides: Record<string, unknown> = {}) => ({
generalId: 7,
@@ -13,7 +16,6 @@ const row = (overrides: Record<string, unknown> = {}) => ({
cityRevision: 12n,
nationRevision: 13n,
worldRevision: 14n,
accessRevision: 15n,
...overrides,
});
@@ -36,7 +38,6 @@ describe('dashboard source revision', () => {
cityRevision: 0n,
nationRevision: 0n,
worldRevision: 0n,
accessRevision: 0n,
}),
]);
@@ -47,17 +48,16 @@ describe('dashboard source revision', () => {
expect.stringMatching(/^[A-Za-z0-9_-]{22}$/u),
]);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql.match(/COALESCE\([^)]*\."revision", 0\)/gu)).toHaveLength(6);
expect(statement.sql.match(/COALESCE\([^)]*\."revision", 0\)/gu)).toHaveLength(5);
});
it('hashes exactly the documented context, command, and board dependency vectors', async () => {
const initial = (await read([row()])).state;
const globalChanged = (await read([row({ globalRevision: 99n })])).state;
const cityChanged = (await read([row({ cityRevision: 99n })])).state;
const accessChanged = (await read([row({ accessRevision: 99n })])).state;
const worldChanged = (await read([row({ worldRevision: 99n })])).state;
const nationChanged = (await read([row({ nationRevision: 99n })])).state;
if (!initial || !globalChanged || !cityChanged || !accessChanged || !worldChanged || !nationChanged) {
if (!initial || !globalChanged || !cityChanged || !worldChanged || !nationChanged) {
throw new Error('source revision state missing');
}
@@ -67,9 +67,6 @@ describe('dashboard source revision', () => {
expect(cityChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(cityChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
expect(cityChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
expect(accessChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(accessChanged.sourceRevisions.commandTable).toBe(initial.sourceRevisions.commandTable);
expect(accessChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
expect(worldChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(worldChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
expect(worldChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
@@ -78,6 +75,19 @@ describe('dashboard source revision', () => {
expect(nationChanged.sourceRevisions.boardAccess).not.toBe(initial.sourceRevisions.boardAccess);
});
it('resolves the authenticated actor and revision vector in one user-owned statement', async () => {
const queryRaw = vi.fn(async (_query: unknown) => [row()]);
await expect(
readDashboardSourceRevisionStateForUser(
{ $queryRaw: queryRaw } as Pick<DatabaseClient, '$queryRaw'>,
'viewer-7'
)
).resolves.toMatchObject({ identity: { generalId: 7 } });
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('actor."user_id"');
expect(statement.values).toContain('viewer-7');
});
it('includes only the authenticated icon projection in the context source', async () => {
const initial = (
await read([row()], {
@@ -0,0 +1,160 @@
import { randomUUID } from 'node:crypto';
import {
createGamePostgresConnector,
createRedisConnector,
resolveRedisConfigFromEnv,
type GamePrismaClient,
type RedisConnector,
} from '@sammo-ts/infra';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
DeferredGeneralAccessWorker,
enqueueDeferredGeneralAccess,
getDeferredGeneralAccessLimit,
} from '../src/services/deferredGeneralAccess.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const liveIntegration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL);
liveIntegration('deferred general access with PostgreSQL and Redis', () => {
const runId = randomUUID();
const profile = `deferred-access-${runId}`;
const userId = `deferred-user-${runId}`;
const scenarioCode = `deferred-scenario-${runId}`;
const batchId = `deferred-batch-${runId}`;
const generalId = 9_981_000 + Math.floor(Math.random() * 900);
const now = new Date('2026-08-17T03:05:00.000Z');
const nextAccessAt = new Date('2026-08-17T03:10:00.000Z');
const auth: GameSessionTokenPayload = {
version: 1,
profile,
issuedAt: '2026-08-17T03:00:00.000Z',
expiresAt: '2026-08-18T03:00:00.000Z',
sessionId: `deferred-session-${runId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles: [],
},
sanctions: {},
};
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let redis: RedisConnector;
let worldStateId: number;
beforeAll(async () => {
const dbConnector = createGamePostgresConnector({ url: databaseUrl! });
await dbConnector.connect();
db = dbConnector.prisma;
closeDb = () => dbConnector.disconnect();
redis = createRedisConnector(resolveRedisConfigFromEnv());
await redis.connect();
const world = await db.worldState.create({
data: {
scenarioCode,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {
lastTurnTime: '2026-08-17T03:00:00.000Z',
refreshLimit: 1,
},
},
});
worldStateId = world.id;
await db.general.create({
data: {
id: generalId,
userId,
name: '지연접속검증',
turnTime: nextAccessAt,
},
});
});
afterAll(async () => {
if (redis) {
const keys: string[] = [];
for await (const page of redis.client.scanIterator({
MATCH: `sammo:game:general-access:*:${profile}*`,
COUNT: 100,
})) {
keys.push(...page);
}
if (keys.length > 0) await redis.client.del(keys);
await redis.disconnect();
}
if (db) {
await db.generalAccessBatch.deleteMany({ where: { id: batchId } });
await db.generalAccessLog.deleteMany({ where: { generalId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.worldState.deleteMany({ where: { id: worldStateId } });
await closeDb?.();
}
});
it('aggregates Redis increments, flushes once, and installs a lazy limit marker', async () => {
const workerDb = {
worldState: {
findFirst: () =>
db.worldState.findUniqueOrThrow({
where: { id: worldStateId },
select: {
id: true,
currentYear: true,
currentMonth: true,
tickSeconds: true,
meta: true,
},
}),
},
$queryRaw: db.$queryRaw.bind(db),
$executeRaw: db.$executeRaw.bind(db),
};
const worker = new DeferredGeneralAccessWorker(
workerDb as never,
redis.client,
profile,
{ get: async () => 'RUNNING' },
{ now: () => now, createBatchId: () => batchId }
);
await enqueueDeferredGeneralAccess(redis.client, profile, auth, generalId, 1, now);
await enqueueDeferredGeneralAccess(redis.client, profile, auth, generalId, 1, now);
await expect(db.generalAccessLog.findUnique({ where: { generalId } })).resolves.toBeNull();
await worker.flushOnce();
await expect(db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).resolves.toMatchObject({
userId,
refresh: 2,
refreshTotal: 2,
refreshScore: 2,
refreshScoreTotal: 2,
});
await expect(
db.trafficPeriod.findUniqueOrThrow({
where: {
worldStateId_year_month: {
worldStateId,
year: 200,
month: 1,
},
},
})
).resolves.toMatchObject({ refresh: 2, online: 1 });
await expect(getDeferredGeneralAccessLimit(redis.client, profile, auth, now)).resolves.toEqual({
nextAccessAt,
});
await worker.flushOnce();
await expect(db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).resolves.toMatchObject({
refreshTotal: 2,
});
});
});
@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
DeferredGeneralAccessWorker,
enqueueDeferredGeneralAccess,
flushDeferredGeneralAccessBatch,
getDeferredGeneralAccessLimit,
} from '../src/services/deferredGeneralAccess.js';
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
version: 1,
profile: 'hwe:default',
issuedAt: '2026-08-17T00:00:00.000Z',
expiresAt: '2026-08-18T00:00:00.000Z',
sessionId: 'deferred-access-session',
user: {
id: 'deferred-user',
username: 'deferred-user',
displayName: '지연 접속 사용자',
roles,
},
sanctions: {},
});
describe('deferred general access', () => {
it('adds a server-owned access weight with one Redis script and skips admins', async () => {
const evalScript = vi.fn(
async (_script: string, _options: { keys: string[]; arguments: string[] }) => 1
);
const now = new Date('2026-08-17T03:00:00.000Z');
await expect(
enqueueDeferredGeneralAccess({ eval: evalScript }, 'hwe:default', auth(), 7, 1, now)
).resolves.toBe(true);
expect(evalScript).toHaveBeenCalledTimes(1);
expect(evalScript.mock.calls[0]?.[1]).toMatchObject({
keys: ['sammo:game:general-access:pending:hwe:default'],
arguments: ['7', 'deferred-user', '1', String(now.getTime()), String(24 * 60 * 60 * 1_000)],
});
await expect(
enqueueDeferredGeneralAccess({ eval: evalScript }, 'hwe:default', auth(['admin']), 7, 1, now)
).resolves.toBe(false);
expect(evalScript).toHaveBeenCalledTimes(1);
});
it('reads only an unexpired Redis limit marker', async () => {
const nextAccessAt = '2026-08-17T03:10:00.000Z';
await expect(
getDeferredGeneralAccessLimit(
{ get: vi.fn(async () => JSON.stringify({ nextAccessAt })) },
'hwe:default',
auth(),
new Date('2026-08-17T03:05:00.000Z')
)
).resolves.toEqual({ nextAccessAt: new Date(nextAccessAt) });
await expect(
getDeferredGeneralAccessLimit(
{ get: vi.fn(async () => JSON.stringify({ nextAccessAt })) },
'hwe:default',
auth(),
new Date(nextAccessAt)
)
).resolves.toBeNull();
});
it('flushes an aggregated batch with one PostgreSQL write statement and no read-model journal', async () => {
const queryRaw = vi.fn(async (_query: unknown) => [
{
generalId: 7,
userId: 'deferred-user',
refreshScore: 2,
nextAccessAt: new Date('2026-08-17T03:10:00.000Z'),
},
]);
const db = {
worldState: {
findFirst: vi.fn(async () => ({
id: 1,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
meta: { lastTurnTime: '2026-08-17T03:00:00.000Z', refreshLimit: 50 },
})),
},
$queryRaw: queryRaw,
$executeRaw: vi.fn(async () => 1),
};
await expect(
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
{
generalId: 7,
userId: 'deferred-user',
weight: 2,
lastRefresh: new Date('2026-08-17T03:05:00.000Z'),
},
])
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
expect(queryRaw).toHaveBeenCalledTimes(1);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
expect(statement.sql).toContain('jsonb_to_recordset');
expect(statement.sql).toContain('INSERT INTO "traffic_period"');
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
expect(statement.sql).toContain('INSERT INTO "general_access_log"');
expect(statement.sql).not.toContain('read_model_revision');
expect(statement.sql).not.toContain('read_model_outbox');
});
it('drops a rotated batch when profile status is unavailable instead of penalizing it later', async () => {
const del = vi.fn(async (_key: string) => 1);
const hGetAll = vi.fn(async (_key: string) => ({}));
const redis = {
scanIterator: async function* (_options: { MATCH: string; COUNT: number }) {
yield [];
},
eval: vi.fn(async () => 1),
hGetAll,
get: vi.fn(async () => null),
set: vi.fn(async () => 'OK'),
del,
};
const worker = new DeferredGeneralAccessWorker(
{} as never,
redis,
'hwe:default',
{ get: async () => Promise.reject(new Error('gateway unavailable')) },
{ createBatchId: () => 'status-failure-batch' }
);
await worker.flushOnce();
expect(del).toHaveBeenCalledWith('sammo:game:general-access:batch:hwe:default:status-failure-batch');
expect(hGetAll).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { TRPCError } from '@trpc/server';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
@@ -7,6 +7,7 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
import { upsertGeneralAccess } from '../src/services/generalAccess.js';
import { flushDeferredGeneralAccessBatch } from '../src/services/deferredGeneralAccess.js';
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -20,6 +21,7 @@ const endpointUserId = `access-endpoint-user-${endpointGeneralId}`;
const scenarioCode = `traffic-period-${generalId}`;
const endpointRequestPrefix = `access-endpoint-${endpointGeneralId}`;
const yearbookProfile = `access-profile-${endpointGeneralId}`;
const deferredBatchId = `deferred-access-${endpointGeneralId}`;
const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({
version: 1,
@@ -84,6 +86,7 @@ integration('general access tracking persistence', () => {
},
},
});
await db.generalAccessBatch.deleteMany({ where: { id: deferredBatchId } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
await db.worldState.deleteMany({ where: { scenarioCode } });
const world = await db.worldState.create({
@@ -135,6 +138,7 @@ integration('general access tracking persistence', () => {
},
},
});
await db.generalAccessBatch.deleteMany({ where: { id: deferredBatchId } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.general.deleteMany({ where: { id: endpointGeneralId } });
@@ -390,6 +394,7 @@ integration('general access tracking persistence', () => {
});
it('runs parser, access, business event, zero-weight, admin, and yearbook cache boundaries on PostgreSQL', async () => {
const redisEval = vi.fn(async () => 1);
const context = {
auth: endpointAuth(),
db,
@@ -404,6 +409,7 @@ integration('general access tracking persistence', () => {
redis: {
get: async () => null,
set: async () => 'OK',
eval: redisEval,
},
} as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
@@ -419,8 +425,21 @@ integration('general access tracking persistence', () => {
expect(initialDashboard).toMatchObject({ context: { kind: 'snapshot' } });
const initialContext = initialDashboard.context;
if (!initialContext?.sourceRevision) {
throw new Error('dashboard snapshot did not include its post-access source revision');
throw new Error('dashboard snapshot did not include its source revision');
}
await expect(
db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })
).resolves.toBeNull();
expect(redisEval).toHaveBeenCalledTimes(1);
await flushDeferredGeneralAccessBatch(db, deferredBatchId, [
{
generalId: endpointGeneralId,
userId: endpointUserId,
weight: 1,
lastRefresh: new Date('2026-07-26T02:55:00.000Z'),
},
]);
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({ refresh: 1, refreshTotal: 1 });
@@ -5,7 +5,12 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import type { DatabaseClient } from '../src/context.js';
import { accessAuthedInputProcedure, accessLimitAuthedProcedure, router } from '../src/trpc.js';
import {
accessAuthedInputProcedure,
accessLimitAuthedProcedure,
deferredAccessLimitAuthedProcedure,
router,
} from '../src/trpc.js';
import {
accessPageWeights,
generalAccessEndpointWeights,
@@ -164,7 +169,7 @@ describe('general access tracking', () => {
select: { id: true, userId: true, turnTime: true },
});
expect(transaction).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(executeRaw).toHaveBeenCalledTimes(2);
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
@@ -193,10 +198,6 @@ describe('general access tracking', () => {
expect(accessStatement.values).toContain(now);
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
const journalStatement = queryRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
expect(journalStatement.sql).toContain('INSERT INTO "read_model_outbox"');
expect(journalStatement.values).toContain('access.general');
expect(journalStatement.values).toContain(7);
});
it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => {
@@ -243,6 +244,32 @@ describe('general access tracking', () => {
expect(resolver).not.toHaveBeenCalled();
});
it('checks a deferred limit with Redis only and does not query PostgreSQL', async () => {
const fixture = buildDb();
const resolver = vi.fn(() => ({ ok: true }));
const limitedRouter = router({ read: deferredAccessLimitAuthedProcedure.query(resolver) });
const get = vi.fn(async () =>
JSON.stringify({ nextAccessAt: '2099-07-26T03:10:00.000Z' })
);
await expect(
limitedRouter
.createCaller({
...accessContext(fixture.db),
redis: { get },
} as unknown as GameApiContext)
.read()
).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
message: expect.stringContaining('다음 접속 가능 시각'),
});
expect(get).toHaveBeenCalledTimes(1);
expect(fixture.findGeneral).not.toHaveBeenCalled();
expect(fixture.findWorld).not.toHaveBeenCalled();
expect(fixture.db.generalAccessLog.findUnique).not.toHaveBeenCalled();
expect(resolver).not.toHaveBeenCalled();
});
it('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
const fixture = buildDb();
await expect(recordGeneralAccessWeight(accessContext(fixture.db), -1)).rejects.toBeInstanceOf(RangeError);