perf: 실시간 접속 확인에서 불필요한 context 조회를 제거
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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,11 +23,7 @@ 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({
|
||||||
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user