diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 8773a613..6b45f8c6 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -167,6 +167,31 @@ integration('scenario 903 select pool through the durable turn daemon', () => { await db.inputEvent.deleteMany(); await db.logEntry.deleteMany(); worldStateId = (await db.worldState.findFirstOrThrow()).id; + await db.playAuditMonth.deleteMany({ + where: { id: { in: ['select-pool-audit-old', 'select-pool-audit-active'] } }, + }); + await db.playAuditMonth.createMany({ + data: [ + { + id: 'select-pool-audit-old', + serverId: `${profile}:previous`, + year: 190, + month: 1, + kind: 'MONTH_END', + hash: 'old', + settlementsComplete: true, + }, + { + id: 'select-pool-audit-active', + serverId: profile, + year: 190, + month: 1, + kind: 'MONTH_END', + hash: 'current', + settlementsComplete: true, + }, + ], + }); if (process.env.REDIS_URL) { const realtimeSubscriber = createRedisConnector(resolveRedisConfigFromEnv()); @@ -198,10 +223,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => { } unsubscribeRealtime(); await realtimeHub?.stop(); + await db?.playAuditMonth.deleteMany({ + where: { id: { in: ['select-pool-audit-old', 'select-pool-audit-active'] } }, + }); await closeDb?.(); }, 30_000); it('creates and reselects in one durable DB and in-memory command boundary', async () => { + await expect.poll(() => db.playAuditMonth.findUnique({ where: { id: 'select-pool-audit-old' } })).toBeNull(); + expect(await db.playAuditMonth.findUnique({ where: { id: 'select-pool-audit-active' } })).not.toBeNull(); await expect( appRouter.createCaller(buildContext('select-pool-public-lobby')).lobby.info() ).resolves.toMatchObject({ selectionPoolEnabled: true }); @@ -680,6 +710,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => { enableLeaseHeartbeat: false, leaseOwnerId: `frozen-pool-${phase}`, }); + await expect + .poll(() => db.playAuditMonth.findUnique({ where: { id: 'select-pool-audit-old' } })) + .toBeNull(); + expect(await db.playAuditMonth.findUnique({ where: { id: 'select-pool-audit-active' } })).not.toBeNull(); turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000); daemonLoop = runtime.lifecycle.start(); await turnDaemon.requestStatus(10_000); diff --git a/app/game-engine/src/playAudit/retention.ts b/app/game-engine/src/playAudit/retention.ts new file mode 100644 index 00000000..32833791 --- /dev/null +++ b/app/game-engine/src/playAudit/retention.ts @@ -0,0 +1,72 @@ +import { asRecord } from '@sammo-ts/common'; +import type { GamePrismaClient } from '@sammo-ts/infra'; + +export const AUDIT_RETENTION_BATCH_SIZE = 200; +export type AuditRetentionResult = { status: 'progress' | 'complete' | 'busy' | 'identityChanged'; deleted: number }; + +/** 새 기수가 활성화된 뒤에만 이전 감사 projection을 작은 transaction으로 정리한다. */ +export const prunePreviousAuditBatch = async ( + db: GamePrismaClient, + expectedServerId: string +): Promise => { + if (!expectedServerId.trim()) return { status: 'identityChanged', deleted: 0 }; + return db.$transaction( + async (tx) => { + await tx.$executeRaw`SET LOCAL statement_timeout = '2000ms'`; + // seeder와 동일한 잠금이다. RESET을 기다리게 하지 않고 다음 batch에서 재시도한다. + const [lock] = await tx.$queryRaw<{ locked: boolean }[]>` + SELECT pg_try_advisory_xact_lock(hashtextextended(current_schema(), 0)) AS locked + `; + if (!lock?.locked) return { status: 'busy', deleted: 0 }; + const world = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } }); + if (asRecord(world?.meta).serverId !== expectedServerId) return { status: 'identityChanged', deleted: 0 }; + // 부모를 잠가 늦은 child INSERT와 빈 header 삭제의 경쟁도 차단한다. + const [sample] = await tx.$queryRaw<{ id: string }[]>` + SELECT id FROM play_audit_month WHERE server_id <> ${expectedServerId} + ORDER BY id LIMIT 1 FOR UPDATE + `; + if (!sample) return { status: 'complete', deleted: 0 }; + const where = { sampleId: sample.id }; + const generals = await tx.playAuditGeneral.findMany({ + where, + orderBy: { generalId: 'asc' }, + take: AUDIT_RETENTION_BATCH_SIZE, + select: { generalId: true }, + }); + if (generals.length) { + const deleted = await tx.playAuditGeneral.deleteMany({ + where: { ...where, generalId: { in: generals.map((row) => row.generalId) } }, + }); + return { status: 'progress', deleted: deleted.count }; + } + const cities = await tx.playAuditCity.findMany({ + where, + orderBy: { cityId: 'asc' }, + take: AUDIT_RETENTION_BATCH_SIZE, + select: { cityId: true }, + }); + if (cities.length) { + const deleted = await tx.playAuditCity.deleteMany({ + where: { ...where, cityId: { in: cities.map((row) => row.cityId) } }, + }); + return { status: 'progress', deleted: deleted.count }; + } + const nations = await tx.playAuditNation.findMany({ + where, + orderBy: { nationId: 'asc' }, + take: AUDIT_RETENTION_BATCH_SIZE, + select: { nationId: true }, + }); + if (nations.length) { + const deleted = await tx.playAuditNation.deleteMany({ + where: { ...where, nationId: { in: nations.map((row) => row.nationId) } }, + }); + return { status: 'progress', deleted: deleted.count }; + } + // 모든 child가 빈 뒤 부모만 삭제한다. 거대한 FK cascade를 정리 수단으로 쓰지 않는다. + await tx.playAuditMonth.delete({ where: { id: sample.id } }); + return { status: 'progress', deleted: 1 }; + }, + { maxWait: 1000, timeout: 5000 } + ); +}; diff --git a/app/game-engine/src/playAudit/retentionWorker.ts b/app/game-engine/src/playAudit/retentionWorker.ts new file mode 100644 index 00000000..4c8a8670 --- /dev/null +++ b/app/game-engine/src/playAudit/retentionWorker.ts @@ -0,0 +1,40 @@ +import type { AuditRetentionResult } from './retention.js'; + +/** 진행할 자료가 있을 때만 계속 실행한다. 일반 게임 턴/페이지 요청에 정리를 결합하지 않는다. */ +export const startAuditRetentionWorker = (options: { + prune: () => Promise; + onError: (error: unknown) => void; +}): { stop: () => Promise } => { + let stopped = false; + let timer: ReturnType | undefined; + let running: Promise | undefined; + const schedule = (delay: number) => { + if (stopped) return; + timer = setTimeout(() => { + timer = undefined; + running = run(); + }, delay); + timer.unref(); + }; + const run = async () => { + let delay = 1000; + try { + const result = await options.prune(); + if (result.status === 'complete' || result.status === 'identityChanged') stopped = true; + if (result.status === 'busy') delay = 30_000; + } catch (error) { + delay = 30_000; + options.onError(error); + } finally { + schedule(delay); + } + }; + schedule(0); + return { + stop: async () => { + stopped = true; + if (timer) clearTimeout(timer); + await running; + }, + }; +}; diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 5df77261..eb8d659a 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -1,3 +1,4 @@ +import { prunePreviousAuditBatch } from '../playAudit/retention.js'; import { randomBytes } from 'node:crypto'; import { @@ -81,7 +82,9 @@ export interface ScenarioSeedOptions { export interface ScenarioSeedResult { seed: WorldSeedPayload; - warnings: ScenarioBootstrapWarning[]; + warnings: Array< + ScenarioBootstrapWarning | { code: 'audit_retention_pending' | 'audit_retention_failed'; message: string } + >; applied: boolean; } @@ -372,7 +375,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom } await connector.connect(); try { - const result: ScenarioSeedResult = { seed, warnings, applied: true }; + const result: ScenarioSeedResult = { seed, warnings: [...warnings], applied: true }; const applied = await connector.prisma.$transaction( async (prisma) => { await prisma.$queryRawUnsafe( @@ -765,6 +768,24 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom { maxWait: 10_000, timeout: 60_000 } ); result.applied = applied; + if ((options.resetTables ?? true) && typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim()) { + // RESERVED에는 daemon이 없을 수 있으므로 commit 뒤 작은 batch 하나를 시작한다. + // 정리 실패는 이미 확정된 seed를 되돌리지 않으며, 나머지는 runtime 시작 시 재시도한다. + try { + const cleanup = await prunePreviousAuditBatch(connector.prisma, worldMeta.serverId); + if (cleanup.status === 'progress' || cleanup.status === 'busy') { + result.warnings.push({ + code: 'audit_retention_pending', + message: '이전 플레이 감사 자료의 나머지는 서버 시작 후 정리합니다.', + }); + } + } catch { + result.warnings.push({ + code: 'audit_retention_failed', + message: '이전 플레이 감사 자료 정리를 완료하지 못했습니다. 서버 시작 후 재시도합니다.', + }); + } + } return result; } finally { await connector.disconnect(); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 22688376..5cf4570c 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,3 +1,4 @@ +import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js'; import { persistAuditMonth } from '../playAudit/persistence.js'; import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js'; import { areSeasonRecordsFinalized } from './seasonRecords.js'; @@ -78,6 +79,7 @@ export interface DatabaseTurnHooks { applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise; synchronizeClockAuthority(): Promise; prepareRealtimeRecovery(options?: { paused?: boolean }): Promise; + prunePreviousAudit(expectedServerId: string): Promise; } export interface CommittedReadModelChangeReceipt { @@ -2150,6 +2152,7 @@ export const createDatabaseTurnHooks = async ( await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); return synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world); }, transactionOptions), + prunePreviousAudit: (expectedServerId) => prunePreviousAuditBatch(prisma, expectedServerId), close: () => connector.disconnect(), }; }; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 15ebc9fa..6d41e475 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,3 +1,4 @@ +import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js'; import { createPlayAuditHandler } from '../playAudit/collection.js'; import { randomUUID } from 'node:crypto'; import { createRuntimePauseGate } from './runtimePauseGate.js'; @@ -720,6 +721,7 @@ const createTurnDaemonRuntimeWithLease = async ( let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined; let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined; let prepareClockRecovery: DatabaseTurnHooks['prepareRealtimeRecovery'] | undefined; + let prunePreviousAudit: DatabaseTurnHooks['prunePreviousAudit'] | undefined; const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module])); const monthlyActionModules = await loadActionModuleBundle( @@ -955,6 +957,7 @@ const createTurnDaemonRuntimeWithLease = async ( applyClockProjection = dbHooks.applyClockProjection; synchronizeClockAuthority = dbHooks.synchronizeClockAuthority; prepareClockRecovery = dbHooks.prepareRealtimeRecovery; + prunePreviousAudit = dbHooks.prunePreviousAudit; close = async () => { if (auctionBidder) { await auctionBidder.close(); @@ -1110,6 +1113,22 @@ const createTurnDaemonRuntimeWithLease = async ( controlQueue: resolvedControlQueue, }); + const auditServerId = world.getState().meta.serverId; + const pruneAudit = prunePreviousAudit; + const auditRetention = + pruneAudit && typeof auditServerId === 'string' && auditServerId.trim() + ? startAuditRetentionWorker({ + prune: () => + turnDaemonLease?.isLost() + ? Promise.resolve({ status: 'identityChanged', deleted: 0 }) + : pruneAudit(auditServerId), + onError: () => { + // 원문 DB 오류에는 연결 정보가 포함될 수 있어 고정된 운영 신호만 남긴다. + console.warn('[play-audit] Previous-season cleanup failed; retrying in 30 seconds.'); + }, + }) + : null; + return { lifecycle, world, @@ -1119,7 +1138,10 @@ const createTurnDaemonRuntimeWithLease = async ( processor, reservedTurns: reservedTurnStoreHandle?.store ?? null, hooks, - close, + close: async () => { + await auditRetention?.stop(); + await close(); + }, }; }; diff --git a/app/game-engine/test/playAuditRetention.integration.test.ts b/app/game-engine/test/playAuditRetention.integration.test.ts new file mode 100644 index 00000000..16e4c630 --- /dev/null +++ b/app/game-engine/test/playAuditRetention.integration.test.ts @@ -0,0 +1,187 @@ +import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { AUDIT_RETENTION_BATCH_SIZE, prunePreviousAuditBatch } from '../src/playAudit/retention.js'; + +const databaseUrl = process.env.PLAY_AUDIT_RETENTION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +integration('bounded previous-season audit retention', () => { + let db: GamePrismaClient; + let close: () => Promise; + beforeAll(async () => { + if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('_retention_fixture')) { + throw new Error('Audit retention requires its dedicated fixture schema'); + } + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + close = () => connector.disconnect(); + }); + afterAll(async () => { + await close?.(); + }); + + it('keeps the active season, bounds each transaction and retries after rollback', async () => { + await db.playAuditMonth.deleteMany(); + await db.worldState.deleteMany(); + const world = await db.worldState.create({ + data: { + scenarioCode: 'audit-retention', + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { serverId: 'active' }, + }, + }); + const sample = (id: string, serverId: string) => ({ + id, + serverId, + year: 190, + month: 1, + kind: 'MONTH_END', + hash: id, + settlementsComplete: true, + }); + await db.playAuditMonth.createMany({ data: [sample('old-sample', 'old'), sample('active-sample', 'active')] }); + await db.playAuditGeneral.createMany({ + data: Array.from({ length: 401 }, (_, index) => ({ + sampleId: 'old-sample', + generalId: index + 1, + nationId: 1, + cityId: 1, + npcState: 2, + data: { marker: 'old' }, + })), + }); + await db.playAuditCity.createMany({ + data: Array.from({ length: 201 }, (_, index) => ({ + sampleId: 'old-sample', + cityId: index + 1, + nationId: 1, + data: {}, + })), + }); + await db.playAuditNation.create({ data: { sampleId: 'old-sample', nationId: 1, data: {} } }); + await db.playAuditGeneral.create({ + data: { + sampleId: 'active-sample', + generalId: 1, + nationId: 1, + cityId: 1, + npcState: 2, + data: { marker: 'preserved' }, + }, + }); + expect(await prunePreviousAuditBatch(db, 'wrong')).toEqual({ status: 'identityChanged', deleted: 0 }); + expect(await prunePreviousAuditBatch(db, '')).toEqual({ status: 'identityChanged', deleted: 0 }); + expect(await prunePreviousAuditBatch(db, 'active')).toEqual({ + status: 'progress', + deleted: AUDIT_RETENTION_BATCH_SIZE, + }); + expect(await db.playAuditGeneral.count({ where: { sampleId: 'old-sample' } })).toBe(201); + await db.$executeRawUnsafe( + `CREATE OR REPLACE FUNCTION audit_retention_test_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'retention fixture rollback'; END $$` + ); + await db.$executeRawUnsafe( + `CREATE TRIGGER audit_retention_test_failure BEFORE DELETE ON play_audit_general FOR EACH ROW EXECUTE FUNCTION audit_retention_test_failure()` + ); + try { + await expect(prunePreviousAuditBatch(db, 'active')).rejects.toThrow(); + expect(await db.playAuditGeneral.count({ where: { sampleId: 'old-sample' } })).toBe(201); + } finally { + await db.$executeRawUnsafe('DROP TRIGGER audit_retention_test_failure ON play_audit_general'); + await db.$executeRawUnsafe('DROP FUNCTION audit_retention_test_failure()'); + } + // A RESET holder makes cleanup defer without waiting for the reset transaction. + await db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(current_schema(), 0))::text`; + expect(await prunePreviousAuditBatch(db, 'active')).toEqual({ status: 'busy', deleted: 0 }); + }); + const deleted = []; + for (let attempt = 0; attempt < 10; attempt++) { + const result = await prunePreviousAuditBatch(db, 'active'); + if (result.status === 'complete') break; + expect(result.status).toBe('progress'); + expect(result.deleted).toBeLessThanOrEqual(AUDIT_RETENTION_BATCH_SIZE); + deleted.push(result.deleted); + } + expect(deleted).toEqual([200, 1, 200, 1, 1, 1]); + expect(await db.playAuditMonth.findUnique({ where: { id: 'old-sample' } })).toBeNull(); + expect( + await db.playAuditGeneral.findUnique({ + where: { sampleId_generalId: { sampleId: 'active-sample', generalId: 1 } }, + }) + ).toMatchObject({ data: { marker: 'preserved' } }); + expect(await prunePreviousAuditBatch(db, 'active')).toEqual({ status: 'complete', deleted: 0 }); + // A worker from the old runtime must stop after the world identity changes. + await db.worldState.update({ where: { id: world.id }, data: { meta: { serverId: 'next' } } }); + expect(await prunePreviousAuditBatch(db, 'active')).toEqual({ status: 'identityChanged', deleted: 0 }); + expect(await db.playAuditMonth.count()).toBe(1); + }); + it('starts bounded cleanup only after seed commits, including a reserved opening', async () => { + await db.playAuditMonth.deleteMany(); + await db.worldState.deleteMany(); + await db.worldState.create({ + data: { + scenarioCode: 'before-reset', + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { serverId: 'before-reset' }, + }, + }); + await db.playAuditMonth.create({ + data: { + id: 'reset-old-sample', + serverId: 'before-reset', + year: 190, + month: 1, + kind: 'MONTH_END', + hash: 'reset', + settlementsComplete: true, + }, + }); + await db.playAuditGeneral.createMany({ + data: Array.from({ length: 201 }, (_, index) => ({ + sampleId: 'reset-old-sample', + generalId: index + 1, + nationId: 1, + cityId: 1, + npcState: 2, + data: {}, + })), + }); + const options = { + scenarioId: 1010, + databaseUrl: databaseUrl!, + resetTables: true, + now: new Date('2030-01-01T00:00:00Z'), + wallNow: new Date('2030-01-01T00:00:00Z'), + installOptions: { + serverId: 'after-reset', + preopenAt: new Date('2030-01-03T00:00:00Z'), + openAt: new Date('2030-01-04T00:00:00Z'), + }, + }; + await expect( + seedScenarioToDatabase({ + ...options, + onBeforeCommit: async () => { + throw new Error('seed fixture rollback'); + }, + }) + ).rejects.toThrow('seed fixture rollback'); + expect(await db.playAuditGeneral.count({ where: { sampleId: 'reset-old-sample' } })).toBe(201); + expect(await db.worldState.findFirst()).toMatchObject({ meta: { serverId: 'before-reset' } }); + const result = await seedScenarioToDatabase(options); + expect(result.applied).toBe(true); + expect(await db.worldState.findFirst()).toMatchObject({ meta: { serverId: 'after-reset' } }); + expect(await db.playAuditGeneral.count({ where: { sampleId: 'reset-old-sample' } })).toBe(1); + expect(result.warnings).toContainEqual({ + code: 'audit_retention_pending', + message: '이전 플레이 감사 자료의 나머지는 서버 시작 후 정리합니다.', + }); + }); +}); diff --git a/app/game-engine/test/playAuditRetentionWorker.test.ts b/app/game-engine/test/playAuditRetentionWorker.test.ts new file mode 100644 index 00000000..83c762de --- /dev/null +++ b/app/game-engine/test/playAuditRetentionWorker.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { startAuditRetentionWorker } from '../src/playAudit/retentionWorker.js'; +import type { AuditRetentionResult } from '../src/playAudit/retention.js'; + +afterEach(() => vi.useRealTimers()); +describe('audit retention scheduling', () => { + it('backs off errors and lock contention, then stops polling when no previous season remains', async () => { + vi.useFakeTimers(); + const prune = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('database failure')) + .mockResolvedValueOnce({ status: 'busy', deleted: 0 }) + .mockResolvedValueOnce({ status: 'progress', deleted: 200 }) + .mockResolvedValue({ status: 'complete', deleted: 0 }); + const onError = vi.fn(); + const worker = startAuditRetentionWorker({ prune, onError }); + await vi.advanceTimersByTimeAsync(0); + expect(onError).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(29_999); + expect(prune).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(30_001); + expect(prune).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(1000); + expect(prune).toHaveBeenCalledTimes(4); + await vi.advanceTimersByTimeAsync(600_000); + expect(prune).toHaveBeenCalledTimes(4); + await worker.stop(); + }); + it('never overlaps batches and waits for the in-flight transaction when closing', async () => { + vi.useFakeTimers(); + let finish!: (result: AuditRetentionResult) => void; + const prune = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const worker = startAuditRetentionWorker({ prune, onError: vi.fn() }); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(prune).toHaveBeenCalledOnce(); + let closed = false; + const stop = worker.stop().then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); + finish({ status: 'progress', deleted: 200 }); + await stop; + await vi.advanceTimersByTimeAsync(60_000); + expect(prune).toHaveBeenCalledOnce(); + }); + it('does not use an old runtime identity after reset', async () => { + vi.useFakeTimers(); + const prune = vi.fn(async (): Promise => ({ status: 'identityChanged', deleted: 0 })); + const worker = startAuditRetentionWorker({ prune, onError: vi.fn() }); + await vi.advanceTimersByTimeAsync(600_000); + expect(prune).toHaveBeenCalledOnce(); + await worker.stop(); + }); +}); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 9f8c027b..fff0cb43 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -88,6 +88,37 @@ R1을 완료했다고 판단하지 않는다. world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다. PREOPEN은 wall-clock 대기 상태이며, 검증하는 것은 공식 개방 때의 논리 게임 달력이다. +## 이전 기수 월별 표본 정리 + +새 daemon runtime은 실제 `serverId`를 고정해 이전 월별 감사 표본 정리를 시작한다. +기수 변경 직후 조회 차단은 기존 API identity 필터가 담당한다. 정리는 gameplay flush와 +별도 transaction이며, seeder와 같은 schema advisory lock을 try-lock한 뒤 DB의 현재 +identity를 재확인한다. 이전 runtime/누락 identity는 정리하지 않는다. 부모 표본의 row lock으로 +child 추가와 빈 부모 삭제의 경쟁을 막고, 장수→도시→국가 child의 PK만 최대200개 읽어 +그 행을 삭제한다. 모두 빈 뒤 header1개를 삭제하여 대량 cascade를 피한다. + +batch는 SQL statement2초/transaction5초 상한이고 성공 후1초, lock경합/오류 후30초에 +재시도한다. 동시에 두 batch를 수행하지 않는다. 기수가 바뀌거나 이전 자료가 없으면 +worker가 끝나므로 평상시 idle polling은 없다. 프로세스 재기동은 DB의 남은 key부터 다시 +시작한다. 종료는 진행 중 transaction을 기다린 뒤 connector를 닫는다. 원문 DB 오류 대신 +고정 경고를 운영 로그에 남기며 정리 실패로 gameplay 결과를 실패 처리하지 않는다. + +현재 정리 대상은 구현된 PlayAuditMonth/General/City/Nation뿐이다. 기존 연감/계정 원장과 +LogEntry 보존 정책은 바꾸지 않는다. 외교·정책·trace 테이블을 추가할 때 같은 수명주기와 +key 단위 삭제를 연결해야 한다. Gateway RESET의 기존 process중지→seed commit→재기동 +경로에서 시작한다. 예약 상태에서는 runtime이 없을 수 있어 seed commit 직후에도 batch +하나를 시도한다. 실패한 seed에는 정리가 실행되지 않고, 정리 실패는 seed 결과의 warning으로 +남긴 뒤 이후 runtime이 재시도한다. RESERVED에서 남은 물리 정리는 PREOPEN 기동까지 +연기되지만 새 identity로의 조회 차단은 즉시 적용된다. 취소 CANCELLED는 기존 정책상 API도 +중지되므로 관리자 감사 접근과 최종 표본 수집의 별도 lifecycle 보완은 아직 남는다. + +검증 fixture는 `PLAY_AUDIT_RETENTION_DATABASE_URL`의 `_retention_fixture` 전용 schema를 +요구한다. schema에 정식 migration을 적용한 뒤 `playAuditRetention.integration.test.ts`를 +실행한다. 삭제·trigger rollback fixture이므로 다른 통합 suite의 DB를 공유하지 않는다. +conditional registry는 external_fixture로 분류하며 일반 core DB URL에 자동 연결하지 않는다. +실제 DB에서401장수/201도시/1국가를 여러 batch로 정리하고 현재 기수를 보존했다. +추가 real daemon startup 검증은 기존 selectPool integration에 연결했다. + ## 기존 장수 로그의 기수별 조회 `generalLogs`는 기존 `LogEntry`에서 현재 기수와 장수·기록 종류를 제한하고 diff --git a/tools/conditional-integration-registry.tsv b/tools/conditional-integration-registry.tsv index d69227f6..f1bbb1c6 100644 --- a/tools/conditional-integration-registry.tsv +++ b/tools/conditional-integration-registry.tsv @@ -8,6 +8,7 @@ GATEWAY_RELEASE_DATABASE_URL gateway_runtime GENERAL_LIFECYCLE_DATABASE_URL core IMMEDIATE_ACTION_DATABASE_URL immediate_action INPUT_EVENT_DATABASE_URL core +PLAY_AUDIT_RETENTION_DATABASE_URL external_fixture LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie NPC_POSSESSION_DATABASE_URL npc_possession NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession