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
+24 -19
View File
@@ -2,20 +2,19 @@ import { TRPCError } from '@trpc/server';
import type { ReadModelDelta } from '@sammo-ts/common';
import { z } from 'zod';
import { accessLimitAuthedProcedure, router } from '../../trpc.js';
import { deferredAccessLimitAuthedProcedure, router } from '../../trpc.js';
import {
canUseDashboardSourceRevision,
readDashboardSourceRevisionState,
readDashboardSourceRevisionStateForUser,
type DashboardSourceRevisionState,
type DashboardSourceSlice,
} from '../../services/dashboardSourceRevision.js';
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
import {
DASHBOARD_PROJECTION_ACCESS_WEIGHT,
formatGeneralAccessLimitMessage,
getGeneralAccessState,
recordGeneralAccessWeight,
} from '../../services/generalAccess.js';
import { enqueueDeferredGeneralAccess } from '../../services/deferredGeneralAccess.js';
import { getBoardAccess } from '../board/index.js';
import { getGeneralContext } from '../general/index.js';
import { getTurnCommandTable } from '../turns/index.js';
@@ -102,7 +101,7 @@ const createDashboardSliceDelta = async <T>(options: {
};
export const dashboardRouter = router({
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
getContextBundleDelta: deferredAccessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
const authUser = ctx.auth?.user;
const viewerId = authUser?.id;
if (!authUser || !viewerId) {
@@ -110,10 +109,13 @@ export const dashboardRouter = router({
}
const includesProjection = Object.values(input.include).some(Boolean);
let generalId: number | null = null;
const sourceState = includesProjection
? await readDashboardSourceRevisionStateForUser(ctx.db, viewerId, authUser)
: null;
let generalId: number | null = sourceState?.identity.generalId ?? null;
if (includesProjection) {
generalId =
ctx.realtimeAccessGeneralId ??
generalId ??
(
await ctx.db.general.findFirst({
where: { userId: viewerId },
@@ -123,7 +125,6 @@ export const dashboardRouter = router({
)?.id ??
null;
}
let sourceState = generalId ? await readDashboardSourceRevisionState(ctx.db, generalId, authUser) : null;
const buildSliceRequests = (): DashboardSliceRequest[] => [
{
included: input.include.context,
@@ -152,17 +153,21 @@ export const dashboardRouter = router({
];
const sliceRequests = buildSliceRequests();
const rebuildsPostgresProjection = sliceRequests.some(requiresDashboardProjection);
if (rebuildsPostgresProjection && ctx.generalAccessTracking === true && ctx.realtimeAccessGranted !== true) {
const recorded = await recordGeneralAccessWeight(ctx, DASHBOARD_PROJECTION_ACCESS_WEIGHT);
const accessState = await getGeneralAccessState(ctx);
if (accessState?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(accessState),
});
}
if (recorded && generalId !== null) {
sourceState = await readDashboardSourceRevisionState(ctx.db, generalId, authUser);
if (ctx.generalAccessTracking === true) {
if (rebuildsPostgresProjection && ctx.realtimeAccessGranted !== true && generalId !== null) {
try {
await enqueueDeferredGeneralAccess(
ctx.redis,
ctx.profile.name,
ctx.auth,
generalId,
DASHBOARD_PROJECTION_ACCESS_WEIGHT
);
} catch {
// Redis is the low-cost path. Preserve the access record when it is
// unavailable, accepting the older synchronous SQL only on failure.
await recordGeneralAccessWeight(ctx, DASHBOARD_PROJECTION_ACCESS_WEIGHT);
}
}
}
+3 -3
View File
@@ -8,8 +8,8 @@ import type { GameApiContext } from '../../context.js';
import {
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
accessLimitAuthedProcedure,
authedProcedure,
deferredAccessLimitAuthedProcedure,
engineAuthedProcedure,
router,
} from '../../trpc.js';
@@ -739,7 +739,7 @@ export const generalRouter = router({
})),
};
}),
getRecentRecords: accessLimitAuthedProcedure
getRecentRecords: deferredAccessLimitAuthedProcedure
.input(
z.object({
lastGeneralRecordId: z.number().int().nonnegative().default(0),
@@ -792,7 +792,7 @@ export const generalRouter = router({
// 메인 화면은 SSE invalidation, 탭 복귀와 직접 갱신이 같은 read model을
// 호출한다. 클라이언트가 주장하는 갱신 원인을 신뢰해 구분하지 않고 이
// projection 전체를 무가점으로 두되, 이미 제한된 사용자의 gate는 유지한다.
getFrontStatus: accessLimitAuthedProcedure.query(async ({ ctx }) => {
getFrontStatus: deferredAccessLimitAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },