perf: 실시간 접속 확인에서 불필요한 context 조회를 제거

This commit is contained in:
2026-08-16 17:53:25 +00:00
parent dd2ae20167
commit aa51f1b7d9
8 changed files with 148 additions and 78 deletions
+2
View File
@@ -85,6 +85,8 @@ export type DatabaseClient = InfraDatabaseClient;
export interface GameApiContext { export interface GameApiContext {
requestId?: string; requestId?: string;
generalAccessTracking?: boolean; generalAccessTracking?: boolean;
/** Request-local identity already resolved by the realtime access gate. */
realtimeAccessGeneralId?: number;
db: DatabaseClient; db: DatabaseClient;
redis: RedisConnector['client']; redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport; turnDaemon: TurnDaemonTransport;
+15 -8
View File
@@ -9,8 +9,7 @@ import { getTurnCommandTable } from '../turns/index.js';
const zRevision = z.string().regex(/^[A-Za-z0-9_-]{22}$/u); const zRevision = z.string().regex(/^[A-Za-z0-9_-]{22}$/u);
const zContextBundleInput = z const zContextBundleInput = z.object({
.object({
include: z.object({ include: z.object({
context: z.boolean(), context: z.boolean(),
commandTable: z.boolean(), commandTable: z.boolean(),
@@ -24,10 +23,6 @@ const zContextBundleInput = z
}) })
.optional(), .optional(),
forceSnapshot: z.boolean().optional(), forceSnapshot: z.boolean().optional(),
})
.refine((input) => Object.values(input.include).some(Boolean), {
message: 'At least one dashboard context slice must be requested.',
path: ['include'],
}); });
export const dashboardRouter = router({ export const dashboardRouter = router({
@@ -37,8 +32,20 @@ export const dashboardRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const currentContext = await getGeneralContext(ctx); const includesProjection = Object.values(input.include).some(Boolean);
const generalId = currentContext?.general.id ?? null; const currentContext = input.include.context ? await getGeneralContext(ctx) : undefined;
const generalId =
currentContext?.general.id ??
ctx.realtimeAccessGeneralId ??
(includesProjection
? (
await ctx.db.general.findFirst({
where: { userId: viewerId },
orderBy: { id: 'asc' },
select: { id: true },
})
)?.id ?? null
: null);
const [commandTable, boardAccess] = await Promise.all([ const [commandTable, boardAccess] = await Promise.all([
input.include.commandTable && generalId ? getTurnCommandTable(ctx, generalId) : Promise.resolve(undefined), input.include.commandTable && generalId ? getTurnCommandTable(ctx, generalId) : Promise.resolve(undefined),
input.include.boardAccess && generalId ? getBoardAccess(ctx) : Promise.resolve(undefined), input.include.boardAccess && generalId ? getBoardAccess(ctx) : Promise.resolve(undefined),
@@ -94,6 +94,7 @@ export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
export const generalAccessLimitBeforeRecordEndpoints = new Set<GeneralAccessEndpoint>(['general.getFrontStatus']); export const generalAccessLimitBeforeRecordEndpoints = new Set<GeneralAccessEndpoint>(['general.getFrontStatus']);
export type GeneralAccessState = { export type GeneralAccessState = {
generalId: number;
refreshScore: number; refreshScore: number;
refreshLimit: number; refreshLimit: number;
level: AccessLimitLevel; level: AccessLimitLevel;
@@ -194,6 +195,7 @@ export const getGeneralAccessState = async (
: (access?.refreshScore ?? 0); : (access?.refreshScore ?? 0);
const refreshLimit = resolveAccessRefreshLimit(worldState.tickSeconds, asRecord(worldState.meta).refreshLimit); const refreshLimit = resolveAccessRefreshLimit(worldState.tickSeconds, asRecord(worldState.meta).refreshLimit);
return { return {
generalId: general.id,
refreshScore, refreshScore,
refreshLimit, refreshLimit,
level: resolveAccessLimitLevel(refreshScore, refreshLimit), level: resolveAccessLimitLevel(refreshScore, refreshLimit),
+6 -1
View File
@@ -126,7 +126,12 @@ const generalAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => {
message: formatGeneralAccessLimitMessage(state), message: formatGeneralAccessLimitMessage(state),
}); });
} }
return next(); return next({
ctx: {
...ctx,
...(state ? { realtimeAccessGeneralId: state.generalId } : {}),
},
});
}); });
export const router = t.router; export const router = t.router;
+38 -21
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { applyReadModelDelta } from '@sammo-ts/common'; import { applyReadModelDelta } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
@@ -21,22 +21,10 @@ const auth: GameSessionTokenPayload = {
sanctions: {}, sanctions: {},
}; };
const buildContext = (authenticated: boolean) => { const buildContext = (authenticated: boolean, generalAccessTracking = false) => {
let generalName = '초기 장수'; let generalName = '초기 장수';
const redisValues = new Map<string, string>(); const redisValues = new Map<string, string>();
const context = { const findGeneral = vi.fn(async () => ({
auth: authenticated ? auth : null,
profile: { id: 'hwe', scenario: 'default', name: 'hwe:default' },
redis: {
get: async (key: string) => redisValues.get(key) ?? null,
set: async (key: string, value: string) => {
redisValues.set(key, value);
return 'OK';
},
},
db: {
general: {
findFirst: async () => ({
id: 7, id: 7,
name: generalName, name: generalName,
npcState: 0, npcState: 0,
@@ -58,7 +46,7 @@ const buildContext = (authenticated: boolean) => {
experience: 100, experience: 100,
dedication: 200, dedication: 200,
age: 20, age: 20,
turnTime: new Date('2026-08-11T00:00:00.000Z'), turnTime: new Date('2026-08-11T00:10:00.000Z'),
crewTypeId: 0, crewTypeId: 0,
personalCode: 'None', personalCode: 'None',
specialCode: 'None', specialCode: 'None',
@@ -69,12 +57,34 @@ const buildContext = (authenticated: boolean) => {
itemCode: 'None', itemCode: 'None',
meta: {}, meta: {},
penalty: {}, penalty: {},
}), }));
const context = {
auth: authenticated ? auth : null,
profile: { id: 'hwe', scenario: 'default', name: 'hwe:default' },
generalAccessTracking,
redis: {
get: async (key: string) => redisValues.get(key) ?? null,
set: async (key: string, value: string) => {
redisValues.set(key, value);
return 'OK';
},
},
db: {
general: {
findFirst: findGeneral,
}, },
city: { findUnique: async () => null }, city: { findUnique: async () => null },
nation: { findUnique: async () => null }, nation: { findUnique: async () => null },
generalAccessLog: { findUnique: async () => null }, generalAccessLog: { findUnique: async () => null },
worldState: { findFirst: async () => ({ config: { const: {} } }) }, worldState: {
findFirst: async () => ({
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
config: { const: {} },
meta: { lastTurnTime: '2026-08-11T00:00:00.000Z' },
}),
},
}, },
} as unknown as GameApiContext; } as unknown as GameApiContext;
@@ -83,6 +93,7 @@ const buildContext = (authenticated: boolean) => {
rename: (name: string) => { rename: (name: string) => {
generalName = name; generalName = name;
}, },
findGeneral,
}; };
}; };
@@ -128,12 +139,18 @@ describe('dashboardRouter.getContextBundleDelta', () => {
).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
}); });
it('rejects an empty bundle request', async () => { it('uses an all-false bundle as an access-only gate without projecting dashboard context', async () => {
const fixture = buildContext(true); const fixture = buildContext(true, true);
await expect( await expect(
dashboardRouter.createCaller(fixture.context).getContextBundleDelta({ dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
include: { context: false, commandTable: false, boardAccess: false }, include: { context: false, commandTable: false, boardAccess: false },
}) })
).rejects.toMatchObject({ code: 'BAD_REQUEST' }); ).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 },
});
}); });
}); });
+10 -14
View File
@@ -13,7 +13,11 @@ import { useSessionStore } from './session';
import { createLatestRefreshQueue } from '../utils/latestRefreshQueue'; import { createLatestRefreshQueue } from '../utils/latestRefreshQueue';
import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'; import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
import { structurallyShare } from '../utils/structuralShare'; import { structurallyShare } from '../utils/structuralShare';
import { createMergedReadModelRefreshQueue } from '../utils/dashboardReadModel'; import {
createMergedReadModelRefreshQueue,
resolveDashboardContextBundleInclude,
type DashboardContextBundleInclude,
} from '../utils/dashboardReadModel';
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
@@ -43,11 +47,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number]; type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>; type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>; type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
type ContextBundleInclude = {
context: boolean;
commandTable: boolean;
boardAccess: boolean;
};
type DashboardReadModelPatch = { type DashboardReadModelPatch = {
contextSnapshot?: GeneralContext; contextSnapshot?: GeneralContext;
contextRevision?: string | null; contextRevision?: string | null;
@@ -479,7 +478,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}; };
const fetchContextBundlePatch = async ( const fetchContextBundlePatch = async (
include: ContextBundleInclude, include: DashboardContextBundleInclude,
forceSnapshot = false forceSnapshot = false
): Promise<DashboardReadModelPatch> => { ): Promise<DashboardReadModelPatch> => {
const request = (force: boolean) => const request = (force: boolean) =>
@@ -637,13 +636,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (plan.records) recordsError.value = null; if (plan.records) recordsError.value = null;
if (plan.frontStatus) frontStatusError.value = null; if (plan.frontStatus) frontStatusError.value = null;
try { try {
const contextPatch = await fetchContextBundlePatch({ // Every automatic refresh crosses this access-limit gate before
// Every automatic refresh crosses this access-limit gate. The // any selected follow-up query starts. An all-false bundle is an
// context delta is usually unchanged and therefore stays small. // access-only check and does not project general context.
context: true, const contextPatch = await fetchContextBundlePatch(resolveDashboardContextBundleInclude(plan));
commandTable: plan.commands,
boardAccess: plan.boardAccess,
});
accessLimited.value = false; accessLimited.value = false;
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined); const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
const mapPromise = plan.map const mapPromise = plan.map
@@ -9,12 +9,25 @@ import {
export type DashboardReadModelIdentity = RealtimeViewerIdentity; export type DashboardReadModelIdentity = RealtimeViewerIdentity;
export type DashboardRefreshPlan = RealtimeReadModelInvalidation; export type DashboardRefreshPlan = RealtimeReadModelInvalidation;
export type DashboardContextBundleInclude = {
context: boolean;
commandTable: boolean;
boardAccess: boolean;
};
export const resolveDashboardRefreshPlan = ( export const resolveDashboardRefreshPlan = (
changes: RealtimeReadModelChanges, changes: RealtimeReadModelChanges,
identity: DashboardReadModelIdentity identity: DashboardReadModelIdentity
): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity); ): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity);
export const resolveDashboardContextBundleInclude = (
plan: DashboardRefreshPlan
): DashboardContextBundleInclude => ({
context: plan.context,
commandTable: plan.commands,
boardAccess: plan.boardAccess,
});
type TimerHandle = ReturnType<typeof setTimeout>; type TimerHandle = ReturnType<typeof setTimeout>;
export interface MergedReadModelRefreshQueue { export interface MergedReadModelRefreshQueue {
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createEmptyRealtimeReadModelChanges, createEmptyRealtimeReadModelInvalidation } from '@sammo-ts/common'; import { createEmptyRealtimeReadModelChanges, createEmptyRealtimeReadModelInvalidation } from '@sammo-ts/common';
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts'; import {
createMergedReadModelRefreshQueue,
resolveDashboardContextBundleInclude,
resolveDashboardRefreshPlan,
} from '../src/utils/dashboardReadModel.ts';
void test('last-turn-time-only events do not schedule any dashboard query', () => { void test('last-turn-time-only events do not schedule any dashboard query', () => {
const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), { const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), {
@@ -170,6 +174,30 @@ void test('targets a submitted survey projection to its own general', () => {
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false); assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
}); });
void test('keeps the access bundle projection-free for map, records, and front-status-only plans', () => {
for (const slice of ['map', 'records', 'frontStatus'] as const) {
const plan = { ...createEmptyRealtimeReadModelInvalidation(), [slice]: true };
assert.deepEqual(resolveDashboardContextBundleInclude(plan), {
context: false,
commandTable: false,
boardAccess: false,
});
}
});
void test('selects only the requested context bundle projections', () => {
const plan = {
...createEmptyRealtimeReadModelInvalidation(),
context: true,
commands: true,
};
assert.deepEqual(resolveDashboardContextBundleInclude(plan), {
context: true,
commandTable: true,
boardAccess: false,
});
});
void test('merges browser-safe boolean invalidations and starts at most once per interval', async () => { void test('merges browser-safe boolean invalidations and starts at most once per interval', async () => {
let nowMs = 0; let nowMs = 0;
let nextTimerId = 1; let nextTimerId = 1;