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
+10 -14
View File
@@ -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;