From 1a2c7ba7759db0e9774bb96e5797931164dda3db Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 3 Sep 2026 01:58:45 +0000 Subject: [PATCH] =?UTF-8?q?=EC=A0=91=EC=86=8D=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EA=B5=90=EC=B0=A9=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?=ED=84=B4=20=EC=A0=95=EC=A7=80=EB=A5=BC=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/deferredGeneralAccess.ts | 17 +++++++++++++---- app/game-api/src/services/generalAccess.ts | 3 ++- .../deferredGeneralAccess.integration.test.ts | 1 + app/game-api/test/deferredGeneralAccess.test.ts | 5 +++++ app/game-api/test/generalAccessTracking.test.ts | 14 +++++++++----- app/game-engine/src/turn/databaseHooks.ts | 15 +++++++++++++++ packages/infra/src/gameSchemaAdvisoryLock.ts | 3 +++ 7 files changed, 48 insertions(+), 10 deletions(-) diff --git a/app/game-api/src/services/deferredGeneralAccess.ts b/app/game-api/src/services/deferredGeneralAccess.ts index 4e6b9389..54ec73e4 100644 --- a/app/game-api/src/services/deferredGeneralAccess.ts +++ b/app/game-api/src/services/deferredGeneralAccess.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common'; -import { GamePrisma } from '@sammo-ts/infra'; +import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { DatabaseClient } from '../context.js'; @@ -163,7 +163,7 @@ const markBatchProcessed = async (db: Pick, batch }; export const flushDeferredGeneralAccessBatch = async ( - db: Pick, + db: Pick, batchId: string, entries: readonly DeferredGeneralAccessEntry[] ): Promise<{ states: DeferredGeneralAccessFlushRow[]; refreshLimit: number }> => { @@ -171,6 +171,9 @@ export const flushDeferredGeneralAccessBatch = async ( await markBatchProcessed(db, batchId); return { states: [], refreshLimit: 0 }; } + if (!db.$transaction) { + throw new Error('Deferred general access persistence requires transaction support.'); + } const worldState = await db.worldState.findFirst({ orderBy: { id: 'asc' }, select: { @@ -210,7 +213,11 @@ export const flushDeferredGeneralAccessBatch = async ( })) ); const periodKey = worldState.currentYear * 12 + worldState.currentMonth - 1; - const states = await db.$queryRaw(GamePrisma.sql` + const states = await db.$transaction(async (transaction) => { + // The turn flush and synchronous access path must enter through the same + // schema-scoped lock before either traffic or access rows are mutated. + await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK); + return transaction.$queryRaw(GamePrisma.sql` WITH inserted_batch AS ( INSERT INTO "general_access_batch" ("id") VALUES (${batchId}) @@ -353,6 +360,7 @@ export const flushDeferredGeneralAccessBatch = async ( END, resolved."weight" FROM resolved + ORDER BY resolved."general_id" ON CONFLICT ("general_id") DO UPDATE SET "user_id" = EXCLUDED."user_id", "last_refresh" = GREATEST("general_access_log"."last_refresh", EXCLUDED."last_refresh"), @@ -394,7 +402,8 @@ export const flushDeferredGeneralAccessBatch = async ( 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), diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index 97681bf1..82726c6c 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -1,5 +1,5 @@ import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common'; -import { GamePrisma } from '@sammo-ts/infra'; +import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra'; import type { GameApiContext } from '../context.js'; @@ -228,6 +228,7 @@ export const upsertGeneralAccess = async ( throw new Error('Traffic access persistence requires transaction support.'); } await db.$transaction(async (transaction) => { + await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK); const periodKey = input.year * 12 + input.month - 1; const periodRows = await transaction.$queryRaw>( GamePrisma.sql` diff --git a/app/game-api/test/deferredGeneralAccess.integration.test.ts b/app/game-api/test/deferredGeneralAccess.integration.test.ts index 466ebc4d..07cf3e57 100644 --- a/app/game-api/test/deferredGeneralAccess.integration.test.ts +++ b/app/game-api/test/deferredGeneralAccess.integration.test.ts @@ -114,6 +114,7 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => { }, }), }, + $transaction: db.$transaction.bind(db), $queryRaw: db.$queryRaw.bind(db), $executeRaw: db.$executeRaw.bind(db), }; diff --git a/app/game-api/test/deferredGeneralAccess.test.ts b/app/game-api/test/deferredGeneralAccess.test.ts index 5d18b6d9..493d6c18 100644 --- a/app/game-api/test/deferredGeneralAccess.test.ts +++ b/app/game-api/test/deferredGeneralAccess.test.ts @@ -88,6 +88,9 @@ describe('deferred general access', () => { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 1), }; + Object.assign(db, { + $transaction: vi.fn(async (run: (transaction: typeof db) => Promise) => run(db)), + }); await expect( flushDeferredGeneralAccessBatch(db as never, 'batch-1', [ @@ -101,12 +104,14 @@ describe('deferred general access', () => { ).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] }); expect(queryRaw).toHaveBeenCalledTimes(1); + expect(db.$executeRaw).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).toContain('ORDER BY resolved."general_id"'); expect(statement.sql).not.toContain('read_model_revision'); expect(statement.sql).not.toContain('read_model_outbox'); }); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index bd9330dc..41b8f4cd 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -194,7 +194,11 @@ describe('general access tracking', () => { }); expect(transaction).toHaveBeenCalledTimes(1); expect(queryRaw).toHaveBeenCalledTimes(1); - expect(executeRaw).toHaveBeenCalledTimes(2); + expect(executeRaw).toHaveBeenCalledTimes(3); + + const lockStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; + expect(lockStatement.sql).toContain('pg_advisory_xact_lock'); + expect(lockStatement.values).toContain('general-access:persistence'); const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; expect(periodStatement.sql).toContain('INSERT INTO traffic_period'); @@ -208,12 +212,12 @@ describe('general access tracking', () => { expect(periodStatement.values).toContain(now); expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z')); - const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; + const memberStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] }; expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general'); expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING'); expect(memberStatement.sql).toContain('SET online = traffic_period.online'); - const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] }; + const accessStatement = executeRaw.mock.calls[2]![0] as { sql: string; values: unknown[] }; expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE'); expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh'); expect(accessStatement.values).toContain(7); @@ -230,9 +234,9 @@ describe('general access tracking', () => { await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true); expect(transaction).toHaveBeenCalledTimes(1); expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0); - expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0); expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0); - expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now); + expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(0); + expect((executeRaw.mock.calls[2]![0] as { values: unknown[] }).values).toContain(now); }); it('blocks above the strict limit and lazily clears a score from before the own turn', async () => { diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 4ad11556..b9bbdefb 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,6 +1,7 @@ import { acquireGameSchemaAdvisoryXactLock, createGamePostgresConnector, + GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma, writeReadModelChangeJournal, enqueuePrivateMessageWebPush, @@ -1322,6 +1323,20 @@ export const createDatabaseTurnHooks = async ( const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle'); const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle'); + const writesGeneralAccess = + accessScoreResetGeneralIds.length > 0 || + lifecycleEvents.length > 0 || + deletedGenerals.length > 0 || + generals.some( + (general) => + typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal) + ); + if (writesGeneralAccess) { + // API access writers acquire this before traffic/access rows. + // Match that order before lifecycle and monthly score writes. + await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK); + } + await persistInheritancePointAdjustments(beforeLifecycleAdjustments); await persistInheritanceLogs(beforeLifecycleLogs); await persistGeneralLifecycleEvents( diff --git a/packages/infra/src/gameSchemaAdvisoryLock.ts b/packages/infra/src/gameSchemaAdvisoryLock.ts index 3048ca3d..ebb0efc0 100644 --- a/packages/infra/src/gameSchemaAdvisoryLock.ts +++ b/packages/infra/src/gameSchemaAdvisoryLock.ts @@ -6,6 +6,9 @@ interface TryLockRow { acquired: boolean; } +/** Serializes score/traffic writers whose table lock order otherwise differs by entry point. */ +export const GENERAL_ACCESS_PERSISTENCE_LOCK = 'general-access:persistence'; + const lockKeySql = (logicalKey: string): GamePrisma.Sql => GamePrisma.sql`hashtextextended(current_schema() || chr(31) || ${logicalKey}, 0)`;