perf: 실시간 접속 확인에서 불필요한 context 조회를 제거
This commit is contained in:
@@ -85,6 +85,8 @@ export type DatabaseClient = InfraDatabaseClient;
|
||||
export interface GameApiContext {
|
||||
requestId?: string;
|
||||
generalAccessTracking?: boolean;
|
||||
/** Request-local identity already resolved by the realtime access gate. */
|
||||
realtimeAccessGeneralId?: number;
|
||||
db: DatabaseClient;
|
||||
redis: RedisConnector['client'];
|
||||
turnDaemon: TurnDaemonTransport;
|
||||
|
||||
@@ -9,26 +9,21 @@ import { getTurnCommandTable } from '../turns/index.js';
|
||||
|
||||
const zRevision = z.string().regex(/^[A-Za-z0-9_-]{22}$/u);
|
||||
|
||||
const zContextBundleInput = z
|
||||
.object({
|
||||
include: z.object({
|
||||
context: z.boolean(),
|
||||
commandTable: z.boolean(),
|
||||
boardAccess: z.boolean(),
|
||||
}),
|
||||
known: z
|
||||
.object({
|
||||
context: zRevision.optional(),
|
||||
commandTable: zRevision.optional(),
|
||||
boardAccess: zRevision.optional(),
|
||||
})
|
||||
.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'],
|
||||
});
|
||||
const zContextBundleInput = z.object({
|
||||
include: z.object({
|
||||
context: z.boolean(),
|
||||
commandTable: z.boolean(),
|
||||
boardAccess: z.boolean(),
|
||||
}),
|
||||
known: z
|
||||
.object({
|
||||
context: zRevision.optional(),
|
||||
commandTable: zRevision.optional(),
|
||||
boardAccess: zRevision.optional(),
|
||||
})
|
||||
.optional(),
|
||||
forceSnapshot: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const dashboardRouter = router({
|
||||
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
||||
@@ -37,8 +32,20 @@ export const dashboardRouter = router({
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const currentContext = await getGeneralContext(ctx);
|
||||
const generalId = currentContext?.general.id ?? null;
|
||||
const includesProjection = Object.values(input.include).some(Boolean);
|
||||
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([
|
||||
input.include.commandTable && generalId ? getTurnCommandTable(ctx, generalId) : 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 type GeneralAccessState = {
|
||||
generalId: number;
|
||||
refreshScore: number;
|
||||
refreshLimit: number;
|
||||
level: AccessLimitLevel;
|
||||
@@ -194,6 +195,7 @@ export const getGeneralAccessState = async (
|
||||
: (access?.refreshScore ?? 0);
|
||||
const refreshLimit = resolveAccessRefreshLimit(worldState.tickSeconds, asRecord(worldState.meta).refreshLimit);
|
||||
return {
|
||||
generalId: general.id,
|
||||
refreshScore,
|
||||
refreshLimit,
|
||||
level: resolveAccessLimitLevel(refreshScore, refreshLimit),
|
||||
|
||||
@@ -126,7 +126,12 @@ const generalAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => {
|
||||
message: formatGeneralAccessLimitMessage(state),
|
||||
});
|
||||
}
|
||||
return next();
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
...(state ? { realtimeAccessGeneralId: state.generalId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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 type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
@@ -21,12 +21,47 @@ const auth: GameSessionTokenPayload = {
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const buildContext = (authenticated: boolean) => {
|
||||
const buildContext = (authenticated: boolean, generalAccessTracking = false) => {
|
||||
let generalName = '초기 장수';
|
||||
const redisValues = new Map<string, string>();
|
||||
const findGeneral = vi.fn(async () => ({
|
||||
id: 7,
|
||||
name: generalName,
|
||||
npcState: 0,
|
||||
nationId: 0,
|
||||
cityId: 0,
|
||||
troopId: 0,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
officerLevel: 0,
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
crew: 300,
|
||||
train: 80,
|
||||
atmos: 90,
|
||||
injury: 0,
|
||||
experience: 100,
|
||||
dedication: 200,
|
||||
age: 20,
|
||||
turnTime: new Date('2026-08-11T00:10:00.000Z'),
|
||||
crewTypeId: 0,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
weaponCode: 'None',
|
||||
horseCode: 'None',
|
||||
bookCode: 'None',
|
||||
itemCode: 'None',
|
||||
meta: {},
|
||||
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) => {
|
||||
@@ -36,45 +71,20 @@ const buildContext = (authenticated: boolean) => {
|
||||
},
|
||||
db: {
|
||||
general: {
|
||||
findFirst: async () => ({
|
||||
id: 7,
|
||||
name: generalName,
|
||||
npcState: 0,
|
||||
nationId: 0,
|
||||
cityId: 0,
|
||||
troopId: 0,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
officerLevel: 0,
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
crew: 300,
|
||||
train: 80,
|
||||
atmos: 90,
|
||||
injury: 0,
|
||||
experience: 100,
|
||||
dedication: 200,
|
||||
age: 20,
|
||||
turnTime: new Date('2026-08-11T00:00:00.000Z'),
|
||||
crewTypeId: 0,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
weaponCode: 'None',
|
||||
horseCode: 'None',
|
||||
bookCode: 'None',
|
||||
itemCode: 'None',
|
||||
meta: {},
|
||||
penalty: {},
|
||||
}),
|
||||
findFirst: findGeneral,
|
||||
},
|
||||
city: { findUnique: async () => null },
|
||||
nation: { 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;
|
||||
|
||||
@@ -83,6 +93,7 @@ const buildContext = (authenticated: boolean) => {
|
||||
rename: (name: string) => {
|
||||
generalName = name;
|
||||
},
|
||||
findGeneral,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -128,12 +139,18 @@ describe('dashboardRouter.getContextBundleDelta', () => {
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('rejects an empty bundle request', async () => {
|
||||
const fixture = buildContext(true);
|
||||
it('uses an all-false bundle as an access-only gate without projecting dashboard context', async () => {
|
||||
const fixture = buildContext(true, true);
|
||||
await expect(
|
||||
dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
|
||||
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 { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
|
||||
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 { 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 FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
||||
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
|
||||
type ContextBundleInclude = {
|
||||
context: boolean;
|
||||
commandTable: boolean;
|
||||
boardAccess: boolean;
|
||||
};
|
||||
type DashboardReadModelPatch = {
|
||||
contextSnapshot?: GeneralContext;
|
||||
contextRevision?: string | null;
|
||||
@@ -479,7 +478,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
};
|
||||
|
||||
const fetchContextBundlePatch = async (
|
||||
include: ContextBundleInclude,
|
||||
include: DashboardContextBundleInclude,
|
||||
forceSnapshot = false
|
||||
): Promise<DashboardReadModelPatch> => {
|
||||
const request = (force: boolean) =>
|
||||
@@ -637,13 +636,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (plan.records) recordsError.value = null;
|
||||
if (plan.frontStatus) frontStatusError.value = null;
|
||||
try {
|
||||
const contextPatch = await fetchContextBundlePatch({
|
||||
// Every automatic refresh crosses this access-limit gate. The
|
||||
// context delta is usually unchanged and therefore stays small.
|
||||
context: true,
|
||||
commandTable: plan.commands,
|
||||
boardAccess: plan.boardAccess,
|
||||
});
|
||||
// Every automatic refresh crosses this access-limit gate before
|
||||
// any selected follow-up query starts. An all-false bundle is an
|
||||
// access-only check and does not project general context.
|
||||
const contextPatch = await fetchContextBundlePatch(resolveDashboardContextBundleInclude(plan));
|
||||
accessLimited.value = false;
|
||||
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
|
||||
const mapPromise = plan.map
|
||||
|
||||
@@ -9,12 +9,25 @@ import {
|
||||
|
||||
export type DashboardReadModelIdentity = RealtimeViewerIdentity;
|
||||
export type DashboardRefreshPlan = RealtimeReadModelInvalidation;
|
||||
export type DashboardContextBundleInclude = {
|
||||
context: boolean;
|
||||
commandTable: boolean;
|
||||
boardAccess: boolean;
|
||||
};
|
||||
|
||||
export const resolveDashboardRefreshPlan = (
|
||||
changes: RealtimeReadModelChanges,
|
||||
identity: DashboardReadModelIdentity
|
||||
): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity);
|
||||
|
||||
export const resolveDashboardContextBundleInclude = (
|
||||
plan: DashboardRefreshPlan
|
||||
): DashboardContextBundleInclude => ({
|
||||
context: plan.context,
|
||||
commandTable: plan.commands,
|
||||
boardAccess: plan.boardAccess,
|
||||
});
|
||||
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
export interface MergedReadModelRefreshQueue {
|
||||
|
||||
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
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', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
let nowMs = 0;
|
||||
let nextTimerId = 1;
|
||||
|
||||
Reference in New Issue
Block a user