merge: 원격 지연 접속 점수 배치를 통합

This commit is contained in:
2026-08-17 14:51:23 +00:00
17 changed files with 1022 additions and 83 deletions
-2
View File
@@ -89,8 +89,6 @@ export interface GameApiContext {
generalAccessTracking?: boolean; generalAccessTracking?: boolean;
/** Validated server-issued proof for one realtime refresh burst. */ /** Validated server-issued proof for one realtime refresh burst. */
realtimeAccessGranted?: boolean; realtimeAccessGranted?: boolean;
/** Request-local identity already resolved by the realtime access gate. */
realtimeAccessGeneralId?: number;
/** Set only while an API input-event transaction owns the mutation. */ /** Set only while an API input-event transaction owns the mutation. */
changeJournal?: ChangeJournal; changeJournal?: ChangeJournal;
/** Post-commit scheduling hint for the durable outbox dispatcher. */ /** Post-commit scheduling hint for the durable outbox dispatcher. */
+24 -19
View File
@@ -2,20 +2,19 @@ import { TRPCError } from '@trpc/server';
import type { ReadModelDelta } from '@sammo-ts/common'; import type { ReadModelDelta } from '@sammo-ts/common';
import { z } from 'zod'; import { z } from 'zod';
import { accessLimitAuthedProcedure, router } from '../../trpc.js'; import { deferredAccessLimitAuthedProcedure, router } from '../../trpc.js';
import { import {
canUseDashboardSourceRevision, canUseDashboardSourceRevision,
readDashboardSourceRevisionState, readDashboardSourceRevisionStateForUser,
type DashboardSourceRevisionState, type DashboardSourceRevisionState,
type DashboardSourceSlice, type DashboardSourceSlice,
} from '../../services/dashboardSourceRevision.js'; } from '../../services/dashboardSourceRevision.js';
import { createReadModelDelta } from '../../services/readModelDeltaCache.js'; import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
import { import {
DASHBOARD_PROJECTION_ACCESS_WEIGHT, DASHBOARD_PROJECTION_ACCESS_WEIGHT,
formatGeneralAccessLimitMessage,
getGeneralAccessState,
recordGeneralAccessWeight, recordGeneralAccessWeight,
} from '../../services/generalAccess.js'; } from '../../services/generalAccess.js';
import { enqueueDeferredGeneralAccess } from '../../services/deferredGeneralAccess.js';
import { getBoardAccess } from '../board/index.js'; import { getBoardAccess } from '../board/index.js';
import { getGeneralContext } from '../general/index.js'; import { getGeneralContext } from '../general/index.js';
import { getTurnCommandTable } from '../turns/index.js'; import { getTurnCommandTable } from '../turns/index.js';
@@ -102,7 +101,7 @@ const createDashboardSliceDelta = async <T>(options: {
}; };
export const dashboardRouter = router({ 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 authUser = ctx.auth?.user;
const viewerId = authUser?.id; const viewerId = authUser?.id;
if (!authUser || !viewerId) { if (!authUser || !viewerId) {
@@ -110,10 +109,13 @@ export const dashboardRouter = router({
} }
const includesProjection = Object.values(input.include).some(Boolean); 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) { if (includesProjection) {
generalId = generalId =
ctx.realtimeAccessGeneralId ?? generalId ??
( (
await ctx.db.general.findFirst({ await ctx.db.general.findFirst({
where: { userId: viewerId }, where: { userId: viewerId },
@@ -123,7 +125,6 @@ export const dashboardRouter = router({
)?.id ?? )?.id ??
null; null;
} }
let sourceState = generalId ? await readDashboardSourceRevisionState(ctx.db, generalId, authUser) : null;
const buildSliceRequests = (): DashboardSliceRequest[] => [ const buildSliceRequests = (): DashboardSliceRequest[] => [
{ {
included: input.include.context, included: input.include.context,
@@ -152,17 +153,21 @@ export const dashboardRouter = router({
]; ];
const sliceRequests = buildSliceRequests(); const sliceRequests = buildSliceRequests();
const rebuildsPostgresProjection = sliceRequests.some(requiresDashboardProjection); const rebuildsPostgresProjection = sliceRequests.some(requiresDashboardProjection);
if (rebuildsPostgresProjection && ctx.generalAccessTracking === true && ctx.realtimeAccessGranted !== true) { if (ctx.generalAccessTracking === true) {
const recorded = await recordGeneralAccessWeight(ctx, DASHBOARD_PROJECTION_ACCESS_WEIGHT); if (rebuildsPostgresProjection && ctx.realtimeAccessGranted !== true && generalId !== null) {
const accessState = await getGeneralAccessState(ctx); try {
if (accessState?.level === 2) { await enqueueDeferredGeneralAccess(
throw new TRPCError({ ctx.redis,
code: 'TOO_MANY_REQUESTS', ctx.profile.name,
message: formatGeneralAccessLimitMessage(accessState), ctx.auth,
}); generalId,
} DASHBOARD_PROJECTION_ACCESS_WEIGHT
if (recorded && generalId !== null) { );
sourceState = await readDashboardSourceRevisionState(ctx.db, generalId, authUser); } 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 { import {
accessEngineAuthedProcedure, accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure, accessEngineAuthedInputProcedure,
accessLimitAuthedProcedure,
authedProcedure, authedProcedure,
deferredAccessLimitAuthedProcedure,
engineAuthedProcedure, engineAuthedProcedure,
router, router,
} from '../../trpc.js'; } from '../../trpc.js';
@@ -739,7 +739,7 @@ export const generalRouter = router({
})), })),
}; };
}), }),
getRecentRecords: accessLimitAuthedProcedure getRecentRecords: deferredAccessLimitAuthedProcedure
.input( .input(
z.object({ z.object({
lastGeneralRecordId: z.number().int().nonnegative().default(0), lastGeneralRecordId: z.number().int().nonnegative().default(0),
@@ -792,7 +792,7 @@ export const generalRouter = router({
// 메인 화면은 SSE invalidation, 탭 복귀와 직접 갱신이 같은 read model을 // 메인 화면은 SSE invalidation, 탭 복귀와 직접 갱신이 같은 read model을
// 호출한다. 클라이언트가 주장하는 갱신 원인을 신뢰해 구분하지 않고 이 // 호출한다. 클라이언트가 주장하는 갱신 원인을 신뢰해 구분하지 않고 이
// projection 전체를 무가점으로 두되, 이미 제한된 사용자의 gate는 유지한다. // projection 전체를 무가점으로 두되, 이미 제한된 사용자의 gate는 유지한다.
getFrontStatus: accessLimitAuthedProcedure.query(async ({ ctx }) => { getFrontStatus: deferredAccessLimitAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx); const me = await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst({ const worldState = await ctx.db.worldState.findFirst({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
+15
View File
@@ -41,6 +41,7 @@ import { AccountIconResetReconciler } from './services/accountIconResetReconcile
import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js'; import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js';
import { RemoteContentImageStore } from './services/remoteContentImageStore.js'; import { RemoteContentImageStore } from './services/remoteContentImageStore.js';
import { ReadModelOutboxWorker } from './realtime/outboxWorker.js'; import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => { const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) { if (!value) {
@@ -154,9 +155,22 @@ export const createGameApiServer = async () => {
const readModelOutboxWorker = new ReadModelOutboxWorker(postgres.prisma, redis.client, config.profileName, { const readModelOutboxWorker = new ReadModelOutboxWorker(postgres.prisma, redis.client, config.profileName, {
onError: (error) => app.log.error({ err: error }, 'read-model outbox dispatch failed'), onError: (error) => app.log.error({ err: error }, 'read-model outbox dispatch failed'),
}); });
const deferredGeneralAccessWorker = new DeferredGeneralAccessWorker(
postgres.prisma,
redis.client,
config.profileName,
profileStatusSource,
{
onError: (error) => app.log.error({ err: error }, 'deferred general access flush failed'),
}
);
let flushSubscriberStarted = false; let flushSubscriberStarted = false;
let realtimeHubStarted = false; let realtimeHubStarted = false;
const closeResources = createBestEffortResourceCloser([ const closeResources = createBestEffortResourceCloser([
{
name: 'deferred-general-access-worker',
run: () => deferredGeneralAccessWorker.stop(),
},
{ {
name: 'read-model-outbox-worker', name: 'read-model-outbox-worker',
run: () => readModelOutboxWorker.stop(), run: () => readModelOutboxWorker.stop(),
@@ -374,6 +388,7 @@ export const createGameApiServer = async () => {
await flushSubscriber.start(); await flushSubscriber.start();
flushSubscriberStarted = true; flushSubscriberStarted = true;
readModelOutboxWorker.start(); readModelOutboxWorker.start();
deferredGeneralAccessWorker.start();
accountIconResetReconciler.start(); accountIconResetReconciler.start();
} catch (error) { } catch (error) {
await closeResources(); await closeResources();
@@ -12,7 +12,7 @@ import type { DatabaseClient } from '../context.js';
export const DASHBOARD_SOURCE_REVISION_COVERAGE_VERSION = 1; export const DASHBOARD_SOURCE_REVISION_COVERAGE_VERSION = 1;
const SOURCE_REVISION_LENGTH = 22; const SOURCE_REVISION_LENGTH = 22;
const SOURCE_REVISION_CODE_VERSION = 'dashboard-private-slices-v2'; const SOURCE_REVISION_CODE_VERSION = 'dashboard-private-slices-v3';
export type DashboardSourceSlice = 'context' | 'commandTable' | 'boardAccess'; export type DashboardSourceSlice = 'context' | 'commandTable' | 'boardAccess';
@@ -36,7 +36,6 @@ interface DashboardSourceRevisionRow {
cityRevision: bigint; cityRevision: bigint;
nationRevision: bigint; nationRevision: bigint;
worldRevision: bigint; worldRevision: bigint;
accessRevision: bigint;
} }
type RevisionTuple = readonly [domain: string, entityId: number, revision: string]; type RevisionTuple = readonly [domain: string, entityId: number, revision: string];
@@ -46,7 +45,6 @@ type DashboardRevisionVector = {
city: string; city: string;
nation: string; nation: string;
world: string; world: string;
access: string;
}; };
export interface DashboardAuthSource { export interface DashboardAuthSource {
@@ -124,11 +122,10 @@ const buildSourceRevisions = (
const city = ['city.content', identity.cityId, identity.cityId > 0 ? revisions.city : '0'] as const; const city = ['city.content', identity.cityId, identity.cityId > 0 ? revisions.city : '0'] as const;
const nation = ['nation.content', identity.nationId, identity.nationId > 0 ? revisions.nation : '0'] as const; const nation = ['nation.content', identity.nationId, identity.nationId > 0 ? revisions.nation : '0'] as const;
const world = ['world.content', 0, revisions.world] as const; const world = ['world.content', 0, revisions.world] as const;
const access = ['access.general', identity.generalId, revisions.access] as const;
const auth = ['auth.context', 0, authSourceRevision] as const; const auth = ['auth.context', 0, authSourceRevision] as const;
return { return {
context: digestSourceRevision('context', [global, general, city, nation, world, access, auth]), context: digestSourceRevision('context', [global, general, city, nation, world, auth]),
commandTable: digestSourceRevision('commandTable', [global, general, city, nation, world]), commandTable: digestSourceRevision('commandTable', [global, general, city, nation, world]),
boardAccess: digestSourceRevision('boardAccess', [general, nation]), boardAccess: digestSourceRevision('boardAccess', [general, nation]),
}; };
@@ -139,15 +136,11 @@ const buildSourceRevisions = (
* dependency heads in one indexed statement. Missing revision rows are revision 0. * dependency heads in one indexed statement. Missing revision rows are revision 0.
* A missing actor/meta row, query failure, or malformed result disables the optimization. * A missing actor/meta row, query failure, or malformed result disables the optimization.
*/ */
export const readDashboardSourceRevisionState = async ( const readDashboardSourceRevisionStateByActor = async (
db: Pick<DatabaseClient, '$queryRaw'>, db: Pick<DatabaseClient, '$queryRaw'>,
generalId: number, actorCondition: GamePrisma.Sql,
authSource?: DashboardAuthSource authSource?: DashboardAuthSource
): Promise<DashboardSourceRevisionState | null> => { ): Promise<DashboardSourceRevisionState | null> => {
if (!Number.isSafeInteger(generalId) || generalId <= 0) {
return null;
}
let rows: DashboardSourceRevisionRow[]; let rows: DashboardSourceRevisionRow[];
try { try {
rows = await db.$queryRaw<DashboardSourceRevisionRow[]>(GamePrisma.sql` rows = await db.$queryRaw<DashboardSourceRevisionRow[]>(GamePrisma.sql`
@@ -160,8 +153,7 @@ export const readDashboardSourceRevisionState = async (
COALESCE(general_revision."revision", 0) AS "generalRevision", COALESCE(general_revision."revision", 0) AS "generalRevision",
COALESCE(city_revision."revision", 0) AS "cityRevision", COALESCE(city_revision."revision", 0) AS "cityRevision",
COALESCE(nation_revision."revision", 0) AS "nationRevision", COALESCE(nation_revision."revision", 0) AS "nationRevision",
COALESCE(world_revision."revision", 0) AS "worldRevision", COALESCE(world_revision."revision", 0) AS "worldRevision"
COALESCE(access_revision."revision", 0) AS "accessRevision"
FROM "general" AS actor FROM "general" AS actor
CROSS JOIN "read_model_revision_meta" AS meta CROSS JOIN "read_model_revision_meta" AS meta
LEFT JOIN "read_model_revision" AS global_revision LEFT JOIN "read_model_revision" AS global_revision
@@ -179,10 +171,7 @@ export const readDashboardSourceRevisionState = async (
LEFT JOIN "read_model_revision" AS world_revision LEFT JOIN "read_model_revision" AS world_revision
ON world_revision."domain" = 'world.content' ON world_revision."domain" = 'world.content'
AND world_revision."entity_id" = 0 AND world_revision."entity_id" = 0
LEFT JOIN "read_model_revision" AS access_revision WHERE ${actorCondition}
ON access_revision."domain" = 'access.general'
AND access_revision."entity_id" = actor."id"
WHERE actor."id" = ${generalId}
AND meta."id" = 1 AND meta."id" = 1
`); `);
} catch { } catch {
@@ -206,10 +195,9 @@ export const readDashboardSourceRevisionState = async (
city: parseNonNegativeRevision(row.cityRevision), city: parseNonNegativeRevision(row.cityRevision),
nation: parseNonNegativeRevision(row.nationRevision), nation: parseNonNegativeRevision(row.nationRevision),
world: parseNonNegativeRevision(row.worldRevision), world: parseNonNegativeRevision(row.worldRevision),
access: parseNonNegativeRevision(row.accessRevision),
}; };
if ( if (
identity.generalId !== generalId || identity.generalId === null ||
identity.cityId === null || identity.cityId === null ||
identity.nationId === null || identity.nationId === null ||
coverageVersion === null || coverageVersion === null ||
@@ -219,7 +207,7 @@ export const readDashboardSourceRevisionState = async (
} }
const validIdentity = { const validIdentity = {
generalId, generalId: identity.generalId,
cityId: identity.cityId, cityId: identity.cityId,
nationId: identity.nationId, nationId: identity.nationId,
}; };
@@ -234,6 +222,28 @@ export const readDashboardSourceRevisionState = async (
}; };
}; };
export const readDashboardSourceRevisionState = async (
db: Pick<DatabaseClient, '$queryRaw'>,
generalId: number,
authSource?: DashboardAuthSource
): Promise<DashboardSourceRevisionState | null> => {
if (!Number.isSafeInteger(generalId) || generalId <= 0) {
return null;
}
return readDashboardSourceRevisionStateByActor(db, GamePrisma.sql`actor."id" = ${generalId}`, authSource);
};
export const readDashboardSourceRevisionStateForUser = async (
db: Pick<DatabaseClient, '$queryRaw'>,
userId: string,
authSource?: DashboardAuthSource
): Promise<DashboardSourceRevisionState | null> => {
if (!userId) {
return null;
}
return readDashboardSourceRevisionStateByActor(db, GamePrisma.sql`actor."user_id" = ${userId}`, authSource);
};
export const canUseDashboardSourceRevision = (options: { export const canUseDashboardSourceRevision = (options: {
state: DashboardSourceRevisionState | null; state: DashboardSourceRevisionState | null;
slice: DashboardSourceSlice; slice: DashboardSourceSlice;
@@ -0,0 +1,535 @@
import { createHash, randomUUID } from 'node:crypto';
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient } from '../context.js';
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
import { resolveAccessWindows } from './generalAccess.js';
export const DEFERRED_GENERAL_ACCESS_FLUSH_INTERVAL_MS = 5_000;
const ACTIVE_BATCH_TTL_MS = 24 * 60 * 60 * 1_000;
const COMPLETED_BATCH_RETENTION_DAYS = 7;
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
interface DeferredAccessRedis {
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
scanIterator(options: { MATCH: string; COUNT: number }): AsyncGenerator<string[], void>;
hGetAll(key: string): Promise<Record<string, string>>;
get(key: string): Promise<string | null>;
set(key: string, value: string, options: { PX: number }): Promise<string | null>;
del(key: string): Promise<number>;
}
export type DeferredGeneralAccessEntry = {
generalId: number;
userId: string;
weight: number;
lastRefresh: Date;
};
export type DeferredGeneralAccessLimit = {
nextAccessAt: Date;
};
type DeferredGeneralAccessFlushRow = {
generalId: number;
userId: string;
refreshScore: number;
nextAccessAt: Date;
};
const ENQUEUE_SCRIPT = `
local weight_field = 'weight:' .. ARGV[1]
local user_field = 'user:' .. ARGV[1]
local time_field = 'time:' .. ARGV[1]
local next_weight = redis.call('HINCRBY', KEYS[1], weight_field, ARGV[3])
redis.call('HSET', KEYS[1], user_field, ARGV[2])
local current_time = redis.call('HGET', KEYS[1], time_field)
if not current_time or tonumber(ARGV[4]) > tonumber(current_time) then
redis.call('HSET', KEYS[1], time_field, ARGV[4])
end
redis.call('PEXPIRE', KEYS[1], ARGV[5])
return next_weight
`;
const ROTATE_SCRIPT = `
if redis.call('EXISTS', KEYS[1]) == 0 then
return 0
end
if redis.call('EXISTS', KEYS[2]) == 1 then
return -1
end
redis.call('RENAME', KEYS[1], KEYS[2])
return 1
`;
const activeKey = (profileName: string): string => `sammo:game:general-access:pending:${profileName}`;
const batchPrefix = (profileName: string): string => `sammo:game:general-access:batch:${profileName}:`;
const batchKey = (profileName: string, batchId: string): string => `${batchPrefix(profileName)}${batchId}`;
const limitKey = (profileName: string, userId: string): string =>
`sammo:game:general-access:limited:${profileName}:${createHash('sha256').update(userId).digest('base64url')}`;
const isEligibleUser = (auth: GameSessionTokenPayload | null): auth is GameSessionTokenPayload =>
Boolean(auth && !auth.user.roles.some((role) => adminRoles.has(role)));
export const enqueueDeferredGeneralAccess = async (
redis: Pick<DeferredAccessRedis, 'eval'>,
profileName: string,
auth: GameSessionTokenPayload | null,
generalId: number,
weight: number,
now = new Date()
): Promise<boolean> => {
if (!Number.isSafeInteger(generalId) || generalId <= 0) {
return false;
}
if (!Number.isInteger(weight) || weight < 0) {
throw new RangeError('Deferred general access weight must be a non-negative integer.');
}
if (!isEligibleUser(auth)) {
return false;
}
await redis.eval(ENQUEUE_SCRIPT, {
keys: [activeKey(profileName)],
arguments: [String(generalId), auth.user.id, String(weight), String(now.getTime()), String(ACTIVE_BATCH_TTL_MS)],
});
return true;
};
export const getDeferredGeneralAccessLimit = async (
redis: Pick<DeferredAccessRedis, 'get'>,
profileName: string,
auth: GameSessionTokenPayload | null,
now = new Date()
): Promise<DeferredGeneralAccessLimit | null> => {
if (!isEligibleUser(auth)) {
return null;
}
const raw = await redis.get(limitKey(profileName, auth.user.id));
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as { nextAccessAt?: unknown };
if (typeof parsed.nextAccessAt !== 'string') {
return null;
}
const nextAccessAt = new Date(parsed.nextAccessAt);
if (Number.isNaN(nextAccessAt.getTime()) || nextAccessAt.getTime() <= now.getTime()) {
return null;
}
return { nextAccessAt };
} catch {
return null;
}
};
const parseBatchHash = (values: Record<string, string>): DeferredGeneralAccessEntry[] => {
const result: DeferredGeneralAccessEntry[] = [];
for (const [field, rawWeight] of Object.entries(values)) {
if (!field.startsWith('weight:')) continue;
const idText = field.slice('weight:'.length);
const generalId = Number(idText);
const weight = Number(rawWeight);
const userId = values[`user:${idText}`];
const timestamp = Number(values[`time:${idText}`]);
if (
!Number.isSafeInteger(generalId) ||
generalId <= 0 ||
!Number.isSafeInteger(weight) ||
weight < 0 ||
!userId ||
!Number.isSafeInteger(timestamp)
) {
continue;
}
const lastRefresh = new Date(timestamp);
if (!Number.isNaN(lastRefresh.getTime())) {
result.push({ generalId, userId, weight, lastRefresh });
}
}
return result.sort((left, right) => left.generalId - right.generalId);
};
const markBatchProcessed = async (db: Pick<DatabaseClient, '$executeRaw'>, batchId: string): Promise<void> => {
await db.$executeRaw(GamePrisma.sql`
INSERT INTO "general_access_batch" ("id")
VALUES (${batchId})
ON CONFLICT ("id") DO NOTHING
`);
};
export const flushDeferredGeneralAccessBatch = async (
db: Pick<DatabaseClient, '$queryRaw' | '$executeRaw' | 'worldState'>,
batchId: string,
entries: readonly DeferredGeneralAccessEntry[]
): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => {
if (entries.length === 0) {
await markBatchProcessed(db, batchId);
return { states: [], refreshLimit: 0 };
}
const worldState = await db.worldState.findFirst({
orderBy: { id: 'asc' },
select: {
id: true,
currentYear: true,
currentMonth: true,
tickSeconds: true,
meta: true,
},
});
if (!worldState) {
throw new Error('Deferred general access flush requires a world state.');
}
const meta = asRecord(worldState.meta);
const isUnited = Number(meta.isUnited ?? meta.isunited ?? 0);
const openTime = typeof meta.opentime === 'string' ? new Date(meta.opentime) : null;
const newestRefresh = entries.reduce(
(latest, entry) => (entry.lastRefresh.getTime() > latest.getTime() ? entry.lastRefresh : latest),
entries[0]!.lastRefresh
);
if (isUnited === 2 || (openTime && !Number.isNaN(openTime.getTime()) && openTime > newestRefresh)) {
await markBatchProcessed(db, batchId);
return {
states: [],
refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit),
};
}
const { periodStartedAt } = resolveAccessWindows(newestRefresh, worldState.tickSeconds, meta);
const payload = JSON.stringify(
entries.map(({ generalId, userId, weight, lastRefresh }) => ({
generalId,
userId,
weight,
lastRefresh: lastRefresh.toISOString(),
}))
);
const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1;
const states = await db.$queryRaw<DeferredGeneralAccessFlushRow[]>(GamePrisma.sql`
WITH inserted_batch AS (
INSERT INTO "general_access_batch" ("id")
VALUES (${batchId})
ON CONFLICT ("id") DO NOTHING
RETURNING "id"
),
input AS (
SELECT
value."generalId" AS "general_id",
value."userId" AS "user_id",
value."weight",
value."lastRefresh" AS "last_refresh"
FROM jsonb_to_recordset(CAST(${payload} AS jsonb)) AS value(
"generalId" INTEGER,
"userId" TEXT,
"weight" INTEGER,
"lastRefresh" TIMESTAMPTZ
)
),
resolved AS (
SELECT
input."general_id",
input."user_id",
input."weight",
input."last_refresh",
actor."turn_time"
FROM input
JOIN "general" AS actor
ON actor."id" = input."general_id"
AND actor."user_id" = input."user_id"
WHERE EXISTS (SELECT 1 FROM inserted_batch)
AND input."weight" >= 0
),
latest_period AS (
SELECT MAX("year" * 12 + "month" - 1)::INTEGER AS "period_key"
FROM "traffic_period"
WHERE "world_state_id" = ${worldState.id}
),
missing_periods AS (
INSERT INTO "traffic_period" (
"world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
)
SELECT
${worldState.id},
(missing_key / 12)::INTEGER,
(missing_key % 12 + 1)::INTEGER,
CAST(${periodStartedAt} AS TIMESTAMP) - (
(${periodKey} - missing_key) * ${worldState.tickSeconds}
) * INTERVAL '1 second',
CAST(${periodStartedAt} AS TIMESTAMP) - (
(${periodKey} - missing_key - 1) * ${worldState.tickSeconds}
) * INTERVAL '1 second',
0,
0
FROM latest_period
CROSS JOIN LATERAL generate_series(latest_period."period_key" + 1, ${periodKey} - 1) AS missing_key
WHERE latest_period."period_key" IS NOT NULL
AND EXISTS (SELECT 1 FROM resolved)
ON CONFLICT ("world_state_id", "year", "month") DO NOTHING
RETURNING "id"
),
period_upsert AS (
INSERT INTO "traffic_period" (
"world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
)
SELECT
${worldState.id},
${worldState.currentYear},
${worldState.currentMonth},
${periodStartedAt},
MAX(resolved."last_refresh"),
SUM(resolved."weight")::INTEGER,
COUNT(*)::INTEGER
FROM resolved
HAVING COUNT(*) > 0
ON CONFLICT ("world_state_id", "year", "month") DO UPDATE SET
"started_at" = LEAST("traffic_period"."started_at", EXCLUDED."started_at"),
"last_refresh" = GREATEST("traffic_period"."last_refresh", EXCLUDED."last_refresh"),
"refresh" = "traffic_period"."refresh" + EXCLUDED."refresh",
"online" = "traffic_period"."online" + (
SELECT COUNT(*)::INTEGER
FROM resolved
WHERE NOT EXISTS (
SELECT 1
FROM "traffic_period_general" AS existing_member
WHERE existing_member."period_id" = "traffic_period"."id"
AND existing_member."general_id" = resolved."general_id"
)
)
RETURNING "id"
),
inserted_generals AS (
INSERT INTO "traffic_period_general" (
"period_id", "general_id", "user_id", "refresh", "last_refresh"
)
SELECT
period_upsert."id",
resolved."general_id",
resolved."user_id",
resolved."weight",
resolved."last_refresh"
FROM resolved
CROSS JOIN period_upsert
ON CONFLICT ("period_id", "general_id") DO NOTHING
RETURNING "period_id", "general_id"
),
updated_generals AS (
UPDATE "traffic_period_general" AS existing
SET
"user_id" = resolved."user_id",
"refresh" = existing."refresh" + resolved."weight",
"last_refresh" = GREATEST(existing."last_refresh", resolved."last_refresh")
FROM resolved
CROSS JOIN period_upsert
WHERE existing."period_id" = period_upsert."id"
AND existing."general_id" = resolved."general_id"
AND NOT EXISTS (
SELECT 1 FROM inserted_generals
WHERE inserted_generals."period_id" = existing."period_id"
AND inserted_generals."general_id" = existing."general_id"
)
RETURNING existing."general_id"
),
access_updates AS (
INSERT INTO "general_access_log" (
"general_id", "user_id", "last_refresh", "refresh", "refresh_total",
"refresh_score", "refresh_score_total"
)
SELECT
resolved."general_id",
resolved."user_id",
resolved."last_refresh",
resolved."weight",
resolved."weight",
CASE
WHEN resolved."last_refresh" >= resolved."turn_time" - (
${worldState.tickSeconds} * INTERVAL '1 second'
) THEN resolved."weight"
ELSE 0
END,
resolved."weight"
FROM resolved
ON CONFLICT ("general_id") DO UPDATE SET
"user_id" = EXCLUDED."user_id",
"last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"),
"refresh" = CASE
WHEN "general_access_log"."last_refresh" IS NULL
OR "general_access_log"."last_refresh" < ${periodStartedAt}
THEN EXCLUDED."refresh"
ELSE "general_access_log"."refresh" + EXCLUDED."refresh"
END,
"refresh_total" = "general_access_log"."refresh_total" + EXCLUDED."refresh_total",
"refresh_score" = CASE
WHEN "general_access_log"."last_refresh" IS NULL
OR "general_access_log"."last_refresh" < (
SELECT resolved."turn_time" - (${worldState.tickSeconds} * INTERVAL '1 second')
FROM resolved
WHERE resolved."general_id" = EXCLUDED."general_id"
)
THEN EXCLUDED."refresh_score"
ELSE "general_access_log"."refresh_score" + EXCLUDED."refresh_score"
END,
"refresh_score_total" =
"general_access_log"."refresh_score_total" + EXCLUDED."refresh_score_total"
RETURNING "general_id", "user_id", "refresh_score"
),
pruned_batches AS (
DELETE FROM "general_access_batch"
WHERE "created_at" < CURRENT_TIMESTAMP - (${COMPLETED_BATCH_RETENTION_DAYS} * INTERVAL '1 day')
RETURNING "id"
)
SELECT
actor."id" AS "generalId",
input."user_id" AS "userId",
COALESCE(access_updates."refresh_score", access_log."refresh_score", 0) AS "refreshScore",
actor."turn_time" AS "nextAccessAt"
FROM input
JOIN "general" AS actor
ON actor."id" = input."general_id"
AND actor."user_id" = input."user_id"
LEFT JOIN access_updates ON access_updates."general_id" = actor."id"
LEFT JOIN "general_access_log" AS access_log ON access_log."general_id" = actor."id"
ORDER BY actor."id"
`);
return {
states,
refreshLimit: resolveAccessRefreshLimit(worldState.tickSeconds, meta.refreshLimit),
};
};
export interface DeferredGeneralAccessWorkerOptions {
intervalMs?: number;
now?: () => Date;
createBatchId?: () => string;
onError?: (error: unknown) => void;
}
export class DeferredGeneralAccessWorker {
private timer: NodeJS.Timeout | null = null;
private inFlight: Promise<void> | null = null;
private running = false;
private readonly intervalMs: number;
private readonly now: () => Date;
private readonly createBatchId: () => string;
private readonly onError: (error: unknown) => void;
constructor(
private readonly db: Pick<DatabaseClient, '$queryRaw' | '$executeRaw' | 'worldState'>,
private readonly redis: DeferredAccessRedis,
private readonly profileName: string,
private readonly profileStatusSource: ProfileStatusSource,
options: DeferredGeneralAccessWorkerOptions = {}
) {
this.intervalMs = Math.max(1, Math.floor(options.intervalMs ?? DEFERRED_GENERAL_ACCESS_FLUSH_INTERVAL_MS));
this.now = options.now ?? (() => new Date());
this.createBatchId = options.createBatchId ?? randomUUID;
this.onError = options.onError ?? (() => undefined);
}
private async listBatchKeys(): Promise<string[]> {
const keys = new Set<string>();
for await (const page of this.redis.scanIterator({ MATCH: `${batchPrefix(this.profileName)}*`, COUNT: 100 })) {
for (const key of page) keys.add(key);
}
const batchId = this.createBatchId();
const rotatedKey = batchKey(this.profileName, batchId);
const rotated = Number(
await this.redis.eval(ROTATE_SCRIPT, {
keys: [activeKey(this.profileName), rotatedKey],
arguments: [],
})
);
if (rotated === 1) keys.add(rotatedKey);
return [...keys].sort();
}
private async applyLimitStates(
states: readonly DeferredGeneralAccessFlushRow[],
refreshLimit: number
): Promise<void> {
const now = this.now();
await Promise.all(
states.map(async (state) => {
const key = limitKey(this.profileName, state.userId);
if (
resolveAccessLimitLevel(state.refreshScore, refreshLimit) !== 2 ||
state.nextAccessAt.getTime() <= now.getTime()
) {
await this.redis.del(key);
return;
}
await this.redis.set(
key,
JSON.stringify({ nextAccessAt: state.nextAccessAt.toISOString() }),
{ PX: Math.max(1, state.nextAccessAt.getTime() - now.getTime()) }
);
})
);
}
private async processBatch(key: string, runningProfile: boolean): Promise<void> {
if (!runningProfile) {
await this.redis.del(key);
return;
}
const values = await this.redis.hGetAll(key);
const entries = parseBatchHash(values);
const id = key.slice(batchPrefix(this.profileName).length);
if (!id) {
return;
}
const result = await flushDeferredGeneralAccessBatch(this.db, id, entries);
await this.applyLimitStates(result.states, result.refreshLimit);
await this.redis.del(key);
}
private async runOnce(): Promise<void> {
const keys = await this.listBatchKeys();
if (keys.length === 0) return;
let runningProfile = false;
try {
runningProfile = (await this.profileStatusSource.get(this.profileName)) === 'RUNNING';
} catch {
// Preserve the synchronous fail-open contract: if Gateway status
// cannot be established, do not retain a batch that may penalize
// the user after the status service recovers.
}
for (const key of keys) {
await this.processBatch(key, runningProfile);
}
}
async flushOnce(): Promise<void> {
if (this.inFlight) {
await this.inFlight;
return;
}
this.inFlight = this.runOnce().finally(() => {
this.inFlight = null;
});
await this.inFlight;
}
start(): void {
if (this.running) return;
this.running = true;
this.timer = setInterval(() => {
void this.flushOnce().catch(this.onError);
}, this.intervalMs);
this.timer.unref?.();
void this.flushOnce().catch(this.onError);
}
async stop(): Promise<void> {
this.running = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
await this.inFlight;
}
}
+1 -3
View File
@@ -1,5 +1,5 @@
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
import { GamePrisma, writeReadModelChangeJournal } from '@sammo-ts/infra'; import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js'; import type { GameApiContext } from '../context.js';
@@ -371,8 +371,6 @@ export const upsertGeneralAccess = async (
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total general_access_log.refresh_score_total + EXCLUDED.refresh_score_total
` `
); );
await writeReadModelChangeJournal(transaction, [{ domain: 'access.general', entityId: input.generalId }]);
}); });
}; };
+19 -6
View File
@@ -15,6 +15,7 @@ import {
resolveGeneralAccessEndpointWeight, resolveGeneralAccessEndpointWeight,
type GeneralAccessEndpoint, type GeneralAccessEndpoint,
} from './services/generalAccess.js'; } from './services/generalAccess.js';
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
const t = initTRPC.context<GameApiContext>().create(); const t = initTRPC.context<GameApiContext>().create();
@@ -126,12 +127,21 @@ const generalAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => {
message: formatGeneralAccessLimitMessage(state), message: formatGeneralAccessLimitMessage(state),
}); });
} }
return next({ return next();
ctx: { });
...ctx,
...(state ? { realtimeAccessGeneralId: state.generalId } : {}), const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => {
}, if (ctx.generalAccessTracking !== true) {
}); return next();
}
const state = await getDeferredGeneralAccessLimit(ctx.redis, ctx.profile.name, ctx.auth);
if (state) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
return next();
}); });
export const router = t.router; export const router = t.router;
@@ -165,6 +175,9 @@ export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(require
export const accessLimitAuthedProcedure: typeof procedure = t.procedure export const accessLimitAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware) .use(requireAuthMiddleware)
.use(generalAccessLimitMiddleware); .use(generalAccessLimitMiddleware);
export const deferredAccessLimitAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(deferredGeneralAccessLimitMiddleware);
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를 // 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다. // 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
export const accessInputProcedure: typeof procedure.input = (input) => export const accessInputProcedure: typeof procedure.input = (input) =>
+4 -13
View File
@@ -73,6 +73,7 @@ const buildContext = (authenticated: boolean, generalAccessTracking = false) =>
generalAccessTracking, generalAccessTracking,
redis: { redis: {
get: async (key: string) => redisValues.get(key) ?? null, get: async (key: string) => redisValues.get(key) ?? null,
eval: async () => 1,
set: async (key: string, value: string) => { set: async (key: string, value: string) => {
redisValues.set(key, value); redisValues.set(key, value);
return 'OK'; return 'OK';
@@ -109,7 +110,6 @@ const installSourceRevisionState = (
cityRevision: bigint; cityRevision: bigint;
nationRevision: bigint; nationRevision: bigint;
worldRevision: bigint; worldRevision: bigint;
accessRevision: bigint;
}> = {} }> = {}
) => { ) => {
let row = { let row = {
@@ -122,12 +122,10 @@ const installSourceRevisionState = (
cityRevision: 0n, cityRevision: 0n,
nationRevision: 0n, nationRevision: 0n,
worldRevision: 1n, worldRevision: 1n,
accessRevision: 1n,
...initial, ...initial,
}; };
const queryRaw = vi.fn(async () => [row]); const queryRaw = vi.fn(async () => [row]);
Object.assign(context.db, { $queryRaw: queryRaw }); Object.assign(context.db, { $queryRaw: queryRaw });
context.realtimeAccessGeneralId = 7;
return { return {
queryRaw, queryRaw,
update: (next: Partial<typeof row>) => { update: (next: Partial<typeof row>) => {
@@ -227,7 +225,7 @@ describe('dashboardRouter.getContextBundleDelta', () => {
).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
}); });
it('uses an all-false bundle as an access-only gate without projecting dashboard context', async () => { it('uses an all-false bundle as a Redis-only access gate without projecting dashboard context', async () => {
const fixture = buildContext(true, true); const fixture = buildContext(true, true);
const queryRaw = vi.fn(async (_query: unknown) => []); const queryRaw = vi.fn(async (_query: unknown) => []);
Object.assign(fixture.context.db, { $queryRaw: queryRaw }); Object.assign(fixture.context.db, { $queryRaw: queryRaw });
@@ -236,12 +234,7 @@ describe('dashboardRouter.getContextBundleDelta', () => {
include: { context: false, commandTable: false, boardAccess: false }, include: { context: false, commandTable: false, boardAccess: false },
}) })
).resolves.toEqual({ context: undefined, commandTable: undefined, boardAccess: undefined }); ).resolves.toEqual({ context: undefined, commandTable: undefined, boardAccess: undefined });
expect(fixture.findGeneral).toHaveBeenCalledTimes(1); expect(fixture.findGeneral).not.toHaveBeenCalled();
expect(fixture.findGeneral).toHaveBeenCalledWith({
where: { userId: auth.user.id },
orderBy: { id: 'asc' },
select: { id: true, turnTime: true },
});
expect(queryRaw).not.toHaveBeenCalled(); expect(queryRaw).not.toHaveBeenCalled();
}); });
@@ -340,8 +333,6 @@ describe('dashboardRouter.getContextBundleDelta', () => {
Object.assign(fixture.context.db, { Object.assign(fixture.context.db, {
$queryRaw: vi.fn(async () => Promise.reject(new Error('revision table unavailable'))), $queryRaw: vi.fn(async () => Promise.reject(new Error('revision table unavailable'))),
}); });
fixture.context.realtimeAccessGeneralId = 7;
const result = await dashboardRouter.createCaller(fixture.context).getContextBundleDelta({ const result = await dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
...contextOnly, ...contextOnly,
known: { context: 'A'.repeat(22) }, known: { context: 'A'.repeat(22) },
@@ -349,6 +340,6 @@ describe('dashboardRouter.getContextBundleDelta', () => {
}); });
expect(result.context?.kind).toBe('snapshot'); expect(result.context?.kind).toBe('snapshot');
expect(fixture.findGeneral).toHaveBeenCalledTimes(1); expect(fixture.findGeneral).toHaveBeenCalledTimes(2);
}); });
}); });
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js'; import type { DatabaseClient } from '../src/context.js';
import { readDashboardSourceRevisionState } from '../src/services/dashboardSourceRevision.js'; import {
readDashboardSourceRevisionState,
readDashboardSourceRevisionStateForUser,
} from '../src/services/dashboardSourceRevision.js';
const row = (overrides: Record<string, unknown> = {}) => ({ const row = (overrides: Record<string, unknown> = {}) => ({
generalId: 7, generalId: 7,
@@ -13,7 +16,6 @@ const row = (overrides: Record<string, unknown> = {}) => ({
cityRevision: 12n, cityRevision: 12n,
nationRevision: 13n, nationRevision: 13n,
worldRevision: 14n, worldRevision: 14n,
accessRevision: 15n,
...overrides, ...overrides,
}); });
@@ -36,7 +38,6 @@ describe('dashboard source revision', () => {
cityRevision: 0n, cityRevision: 0n,
nationRevision: 0n, nationRevision: 0n,
worldRevision: 0n, worldRevision: 0n,
accessRevision: 0n,
}), }),
]); ]);
@@ -47,17 +48,16 @@ describe('dashboard source revision', () => {
expect.stringMatching(/^[A-Za-z0-9_-]{22}$/u), expect.stringMatching(/^[A-Za-z0-9_-]{22}$/u),
]); ]);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string }; const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql.match(/COALESCE\([^)]*\."revision", 0\)/gu)).toHaveLength(6); expect(statement.sql.match(/COALESCE\([^)]*\."revision", 0\)/gu)).toHaveLength(5);
}); });
it('hashes exactly the documented context, command, and board dependency vectors', async () => { it('hashes exactly the documented context, command, and board dependency vectors', async () => {
const initial = (await read([row()])).state; const initial = (await read([row()])).state;
const globalChanged = (await read([row({ globalRevision: 99n })])).state; const globalChanged = (await read([row({ globalRevision: 99n })])).state;
const cityChanged = (await read([row({ cityRevision: 99n })])).state; const cityChanged = (await read([row({ cityRevision: 99n })])).state;
const accessChanged = (await read([row({ accessRevision: 99n })])).state;
const worldChanged = (await read([row({ worldRevision: 99n })])).state; const worldChanged = (await read([row({ worldRevision: 99n })])).state;
const nationChanged = (await read([row({ nationRevision: 99n })])).state; const nationChanged = (await read([row({ nationRevision: 99n })])).state;
if (!initial || !globalChanged || !cityChanged || !accessChanged || !worldChanged || !nationChanged) { if (!initial || !globalChanged || !cityChanged || !worldChanged || !nationChanged) {
throw new Error('source revision state missing'); throw new Error('source revision state missing');
} }
@@ -67,9 +67,6 @@ describe('dashboard source revision', () => {
expect(cityChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context); expect(cityChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(cityChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable); expect(cityChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
expect(cityChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess); expect(cityChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
expect(accessChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(accessChanged.sourceRevisions.commandTable).toBe(initial.sourceRevisions.commandTable);
expect(accessChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
expect(worldChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context); expect(worldChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(worldChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable); expect(worldChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
expect(worldChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess); expect(worldChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
@@ -78,6 +75,19 @@ describe('dashboard source revision', () => {
expect(nationChanged.sourceRevisions.boardAccess).not.toBe(initial.sourceRevisions.boardAccess); expect(nationChanged.sourceRevisions.boardAccess).not.toBe(initial.sourceRevisions.boardAccess);
}); });
it('resolves the authenticated actor and revision vector in one user-owned statement', async () => {
const queryRaw = vi.fn(async (_query: unknown) => [row()]);
await expect(
readDashboardSourceRevisionStateForUser(
{ $queryRaw: queryRaw } as Pick<DatabaseClient, '$queryRaw'>,
'viewer-7'
)
).resolves.toMatchObject({ identity: { generalId: 7 } });
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('actor."user_id"');
expect(statement.values).toContain('viewer-7');
});
it('includes only the authenticated icon projection in the context source', async () => { it('includes only the authenticated icon projection in the context source', async () => {
const initial = ( const initial = (
await read([row()], { await read([row()], {
@@ -0,0 +1,160 @@
import { randomUUID } from 'node:crypto';
import {
createGamePostgresConnector,
createRedisConnector,
resolveRedisConfigFromEnv,
type GamePrismaClient,
type RedisConnector,
} from '@sammo-ts/infra';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
DeferredGeneralAccessWorker,
enqueueDeferredGeneralAccess,
getDeferredGeneralAccessLimit,
} from '../src/services/deferredGeneralAccess.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const liveIntegration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL);
liveIntegration('deferred general access with PostgreSQL and Redis', () => {
const runId = randomUUID();
const profile = `deferred-access-${runId}`;
const userId = `deferred-user-${runId}`;
const scenarioCode = `deferred-scenario-${runId}`;
const batchId = `deferred-batch-${runId}`;
const generalId = 9_981_000 + Math.floor(Math.random() * 900);
const now = new Date('2026-08-17T03:05:00.000Z');
const nextAccessAt = new Date('2026-08-17T03:10:00.000Z');
const auth: GameSessionTokenPayload = {
version: 1,
profile,
issuedAt: '2026-08-17T03:00:00.000Z',
expiresAt: '2026-08-18T03:00:00.000Z',
sessionId: `deferred-session-${runId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles: [],
},
sanctions: {},
};
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let redis: RedisConnector;
let worldStateId: number;
beforeAll(async () => {
const dbConnector = createGamePostgresConnector({ url: databaseUrl! });
await dbConnector.connect();
db = dbConnector.prisma;
closeDb = () => dbConnector.disconnect();
redis = createRedisConnector(resolveRedisConfigFromEnv());
await redis.connect();
const world = await db.worldState.create({
data: {
scenarioCode,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {
lastTurnTime: '2026-08-17T03:00:00.000Z',
refreshLimit: 1,
},
},
});
worldStateId = world.id;
await db.general.create({
data: {
id: generalId,
userId,
name: '지연접속검증',
turnTime: nextAccessAt,
},
});
});
afterAll(async () => {
if (redis) {
const keys: string[] = [];
for await (const page of redis.client.scanIterator({
MATCH: `sammo:game:general-access:*:${profile}*`,
COUNT: 100,
})) {
keys.push(...page);
}
if (keys.length > 0) await redis.client.del(keys);
await redis.disconnect();
}
if (db) {
await db.generalAccessBatch.deleteMany({ where: { id: batchId } });
await db.generalAccessLog.deleteMany({ where: { generalId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.worldState.deleteMany({ where: { id: worldStateId } });
await closeDb?.();
}
});
it('aggregates Redis increments, flushes once, and installs a lazy limit marker', async () => {
const workerDb = {
worldState: {
findFirst: () =>
db.worldState.findUniqueOrThrow({
where: { id: worldStateId },
select: {
id: true,
currentYear: true,
currentMonth: true,
tickSeconds: true,
meta: true,
},
}),
},
$queryRaw: db.$queryRaw.bind(db),
$executeRaw: db.$executeRaw.bind(db),
};
const worker = new DeferredGeneralAccessWorker(
workerDb as never,
redis.client,
profile,
{ get: async () => 'RUNNING' },
{ now: () => now, createBatchId: () => batchId }
);
await enqueueDeferredGeneralAccess(redis.client, profile, auth, generalId, 1, now);
await enqueueDeferredGeneralAccess(redis.client, profile, auth, generalId, 1, now);
await expect(db.generalAccessLog.findUnique({ where: { generalId } })).resolves.toBeNull();
await worker.flushOnce();
await expect(db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).resolves.toMatchObject({
userId,
refresh: 2,
refreshTotal: 2,
refreshScore: 2,
refreshScoreTotal: 2,
});
await expect(
db.trafficPeriod.findUniqueOrThrow({
where: {
worldStateId_year_month: {
worldStateId,
year: 200,
month: 1,
},
},
})
).resolves.toMatchObject({ refresh: 2, online: 1 });
await expect(getDeferredGeneralAccessLimit(redis.client, profile, auth, now)).resolves.toEqual({
nextAccessAt,
});
await worker.flushOnce();
await expect(db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).resolves.toMatchObject({
refreshTotal: 2,
});
});
});
@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
DeferredGeneralAccessWorker,
enqueueDeferredGeneralAccess,
flushDeferredGeneralAccessBatch,
getDeferredGeneralAccessLimit,
} from '../src/services/deferredGeneralAccess.js';
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
version: 1,
profile: 'hwe:default',
issuedAt: '2026-08-17T00:00:00.000Z',
expiresAt: '2026-08-18T00:00:00.000Z',
sessionId: 'deferred-access-session',
user: {
id: 'deferred-user',
username: 'deferred-user',
displayName: '지연 접속 사용자',
roles,
},
sanctions: {},
});
describe('deferred general access', () => {
it('adds a server-owned access weight with one Redis script and skips admins', async () => {
const evalScript = vi.fn(
async (_script: string, _options: { keys: string[]; arguments: string[] }) => 1
);
const now = new Date('2026-08-17T03:00:00.000Z');
await expect(
enqueueDeferredGeneralAccess({ eval: evalScript }, 'hwe:default', auth(), 7, 1, now)
).resolves.toBe(true);
expect(evalScript).toHaveBeenCalledTimes(1);
expect(evalScript.mock.calls[0]?.[1]).toMatchObject({
keys: ['sammo:game:general-access:pending:hwe:default'],
arguments: ['7', 'deferred-user', '1', String(now.getTime()), String(24 * 60 * 60 * 1_000)],
});
await expect(
enqueueDeferredGeneralAccess({ eval: evalScript }, 'hwe:default', auth(['admin']), 7, 1, now)
).resolves.toBe(false);
expect(evalScript).toHaveBeenCalledTimes(1);
});
it('reads only an unexpired Redis limit marker', async () => {
const nextAccessAt = '2026-08-17T03:10:00.000Z';
await expect(
getDeferredGeneralAccessLimit(
{ get: vi.fn(async () => JSON.stringify({ nextAccessAt })) },
'hwe:default',
auth(),
new Date('2026-08-17T03:05:00.000Z')
)
).resolves.toEqual({ nextAccessAt: new Date(nextAccessAt) });
await expect(
getDeferredGeneralAccessLimit(
{ get: vi.fn(async () => JSON.stringify({ nextAccessAt })) },
'hwe:default',
auth(),
new Date(nextAccessAt)
)
).resolves.toBeNull();
});
it('flushes an aggregated batch with one PostgreSQL write statement and no read-model journal', async () => {
const queryRaw = vi.fn(async (_query: unknown) => [
{
generalId: 7,
userId: 'deferred-user',
refreshScore: 2,
nextAccessAt: new Date('2026-08-17T03:10:00.000Z'),
},
]);
const db = {
worldState: {
findFirst: vi.fn(async () => ({
id: 1,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
meta: { lastTurnTime: '2026-08-17T03:00:00.000Z', refreshLimit: 50 },
})),
},
$queryRaw: queryRaw,
$executeRaw: vi.fn(async () => 1),
};
await expect(
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
{
generalId: 7,
userId: 'deferred-user',
weight: 2,
lastRefresh: new Date('2026-08-17T03:05:00.000Z'),
},
])
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
expect(queryRaw).toHaveBeenCalledTimes(1);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
expect(statement.sql).toContain('jsonb_to_recordset');
expect(statement.sql).toContain('INSERT INTO "traffic_period"');
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
expect(statement.sql).toContain('INSERT INTO "general_access_log"');
expect(statement.sql).not.toContain('read_model_revision');
expect(statement.sql).not.toContain('read_model_outbox');
});
it('drops a rotated batch when profile status is unavailable instead of penalizing it later', async () => {
const del = vi.fn(async (_key: string) => 1);
const hGetAll = vi.fn(async (_key: string) => ({}));
const redis = {
scanIterator: async function* (_options: { MATCH: string; COUNT: number }) {
yield [];
},
eval: vi.fn(async () => 1),
hGetAll,
get: vi.fn(async () => null),
set: vi.fn(async () => 'OK'),
del,
};
const worker = new DeferredGeneralAccessWorker(
{} as never,
redis,
'hwe:default',
{ get: async () => Promise.reject(new Error('gateway unavailable')) },
{ createBatchId: () => 'status-failure-batch' }
);
await worker.flushOnce();
expect(del).toHaveBeenCalledWith('sammo:game:general-access:batch:hwe:default:status-failure-batch');
expect(hGetAll).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
@@ -7,6 +7,7 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
import { upsertGeneralAccess } from '../src/services/generalAccess.js'; import { upsertGeneralAccess } from '../src/services/generalAccess.js';
import { flushDeferredGeneralAccessBatch } from '../src/services/deferredGeneralAccess.js';
import { accessAuthedInputProcedure, router } from '../src/trpc.js'; import { accessAuthedInputProcedure, router } from '../src/trpc.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -20,6 +21,7 @@ const endpointUserId = `access-endpoint-user-${endpointGeneralId}`;
const scenarioCode = `traffic-period-${generalId}`; const scenarioCode = `traffic-period-${generalId}`;
const endpointRequestPrefix = `access-endpoint-${endpointGeneralId}`; const endpointRequestPrefix = `access-endpoint-${endpointGeneralId}`;
const yearbookProfile = `access-profile-${endpointGeneralId}`; const yearbookProfile = `access-profile-${endpointGeneralId}`;
const deferredBatchId = `deferred-access-${endpointGeneralId}`;
const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({ const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({
version: 1, version: 1,
@@ -84,6 +86,7 @@ integration('general access tracking persistence', () => {
}, },
}, },
}); });
await db.generalAccessBatch.deleteMany({ where: { id: deferredBatchId } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
await db.worldState.deleteMany({ where: { scenarioCode } }); await db.worldState.deleteMany({ where: { scenarioCode } });
const world = await db.worldState.create({ const world = await db.worldState.create({
@@ -135,6 +138,7 @@ integration('general access tracking persistence', () => {
}, },
}, },
}); });
await db.generalAccessBatch.deleteMany({ where: { id: deferredBatchId } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } }); await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } }); await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.general.deleteMany({ where: { id: endpointGeneralId } }); await db.general.deleteMany({ where: { id: endpointGeneralId } });
@@ -390,6 +394,7 @@ integration('general access tracking persistence', () => {
}); });
it('runs parser, access, business event, zero-weight, admin, and yearbook cache boundaries on PostgreSQL', async () => { it('runs parser, access, business event, zero-weight, admin, and yearbook cache boundaries on PostgreSQL', async () => {
const redisEval = vi.fn(async () => 1);
const context = { const context = {
auth: endpointAuth(), auth: endpointAuth(),
db, db,
@@ -404,6 +409,7 @@ integration('general access tracking persistence', () => {
redis: { redis: {
get: async () => null, get: async () => null,
set: async () => 'OK', set: async () => 'OK',
eval: redisEval,
}, },
} as unknown as GameApiContext; } as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context); const boundaryCaller = endpointBoundaryRouter.createCaller(context);
@@ -419,8 +425,21 @@ integration('general access tracking persistence', () => {
expect(initialDashboard).toMatchObject({ context: { kind: 'snapshot' } }); expect(initialDashboard).toMatchObject({ context: { kind: 'snapshot' } });
const initialContext = initialDashboard.context; const initialContext = initialDashboard.context;
if (!initialContext?.sourceRevision) { if (!initialContext?.sourceRevision) {
throw new Error('dashboard snapshot did not include its post-access source revision'); throw new Error('dashboard snapshot did not include its source revision');
} }
await expect(
db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })
).resolves.toBeNull();
expect(redisEval).toHaveBeenCalledTimes(1);
await flushDeferredGeneralAccessBatch(db, deferredBatchId, [
{
generalId: endpointGeneralId,
userId: endpointUserId,
weight: 1,
lastRefresh: new Date('2026-07-26T02:55:00.000Z'),
},
]);
await expect( await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({ refresh: 1, refreshTotal: 1 }); ).resolves.toMatchObject({ refresh: 1, refreshTotal: 1 });
@@ -5,7 +5,12 @@ import { z } from 'zod';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import type { DatabaseClient } from '../src/context.js'; import type { DatabaseClient } from '../src/context.js';
import { accessAuthedInputProcedure, accessLimitAuthedProcedure, router } from '../src/trpc.js'; import {
accessAuthedInputProcedure,
accessLimitAuthedProcedure,
deferredAccessLimitAuthedProcedure,
router,
} from '../src/trpc.js';
import { import {
accessPageWeights, accessPageWeights,
generalAccessEndpointWeights, generalAccessEndpointWeights,
@@ -164,7 +169,7 @@ describe('general access tracking', () => {
select: { id: true, userId: true, turnTime: true }, select: { id: true, userId: true, turnTime: true },
}); });
expect(transaction).toHaveBeenCalledTimes(1); expect(transaction).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(1);
expect(executeRaw).toHaveBeenCalledTimes(2); expect(executeRaw).toHaveBeenCalledTimes(2);
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
@@ -193,10 +198,6 @@ describe('general access tracking', () => {
expect(accessStatement.values).toContain(now); expect(accessStatement.values).toContain(now);
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z')); expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
const journalStatement = queryRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
expect(journalStatement.sql).toContain('INSERT INTO "read_model_outbox"');
expect(journalStatement.values).toContain('access.general');
expect(journalStatement.values).toContain(7);
}); });
it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => { it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => {
@@ -243,6 +244,32 @@ describe('general access tracking', () => {
expect(resolver).not.toHaveBeenCalled(); expect(resolver).not.toHaveBeenCalled();
}); });
it('checks a deferred limit with Redis only and does not query PostgreSQL', async () => {
const fixture = buildDb();
const resolver = vi.fn(() => ({ ok: true }));
const limitedRouter = router({ read: deferredAccessLimitAuthedProcedure.query(resolver) });
const get = vi.fn(async () =>
JSON.stringify({ nextAccessAt: '2099-07-26T03:10:00.000Z' })
);
await expect(
limitedRouter
.createCaller({
...accessContext(fixture.db),
redis: { get },
} as unknown as GameApiContext)
.read()
).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
message: expect.stringContaining('다음 접속 가능 시각'),
});
expect(get).toHaveBeenCalledTimes(1);
expect(fixture.findGeneral).not.toHaveBeenCalled();
expect(fixture.findWorld).not.toHaveBeenCalled();
expect(fixture.db.generalAccessLog.findUnique).not.toHaveBeenCalled();
expect(resolver).not.toHaveBeenCalled();
});
it('rejects weights that cannot come from a server-owned Ref call boundary', async () => { it('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
const fixture = buildDb(); const fixture = buildDb();
await expect(recordGeneralAccessWeight(accessContext(fixture.db), -1)).rejects.toBeInstanceOf(RangeError); await expect(recordGeneralAccessWeight(accessContext(fixture.db), -1)).rejects.toBeInstanceOf(RangeError);
+8
View File
@@ -280,6 +280,14 @@ model GeneralAccessLog {
@@map("general_access_log") @@map("general_access_log")
} }
model GeneralAccessBatch {
id String @id @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
@@index([createdAt])
@@map("general_access_batch")
}
model TrafficPeriod { model TrafficPeriod {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
worldStateId Int @map("world_state_id") worldStateId Int @map("world_state_id")
@@ -0,0 +1,9 @@
CREATE TABLE "general_access_batch" (
"id" VARCHAR(64) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "general_access_batch_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "general_access_batch_created_at_idx"
ON "general_access_batch"("created_at");
@@ -35,6 +35,7 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여
- `city.trade` nullable, `city.trust` REAL - `city.trade` nullable, `city.trust` REAL
- `auction_bid.meta` JSONB NOT NULL - `auction_bid.meta` JSONB NOT NULL
- `traffic_period`, `traffic_period_general`과 unique key - `traffic_period`, `traffic_period_general`과 unique key
- `general_access_batch`, primary key와 `created_at` index
- `select_npc_token`, `select_npc_token_valid_until_idx` - `select_npc_token`, `select_npc_token_valid_until_idx`
- `general_user_id_key` - `general_user_id_key`