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
@@ -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);