feat: 이전 기수 감사 표본을 제한된 batch로 정리

This commit is contained in:
2026-09-16 04:22:09 +00:00
parent 9e14306fe4
commit 10f3f0ebc6
10 changed files with 475 additions and 3 deletions
@@ -167,6 +167,31 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await db.inputEvent.deleteMany(); await db.inputEvent.deleteMany();
await db.logEntry.deleteMany(); await db.logEntry.deleteMany();
worldStateId = (await db.worldState.findFirstOrThrow()).id; 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) { if (process.env.REDIS_URL) {
const realtimeSubscriber = createRedisConnector(resolveRedisConfigFromEnv()); const realtimeSubscriber = createRedisConnector(resolveRedisConfigFromEnv());
@@ -198,10 +223,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
} }
unsubscribeRealtime(); unsubscribeRealtime();
await realtimeHub?.stop(); await realtimeHub?.stop();
await db?.playAuditMonth.deleteMany({
where: { id: { in: ['select-pool-audit-old', 'select-pool-audit-active'] } },
});
await closeDb?.(); await closeDb?.();
}, 30_000); }, 30_000);
it('creates and reselects in one durable DB and in-memory command boundary', async () => { 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( await expect(
appRouter.createCaller(buildContext('select-pool-public-lobby')).lobby.info() appRouter.createCaller(buildContext('select-pool-public-lobby')).lobby.info()
).resolves.toMatchObject({ selectionPoolEnabled: true }); ).resolves.toMatchObject({ selectionPoolEnabled: true });
@@ -680,6 +710,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
enableLeaseHeartbeat: false, enableLeaseHeartbeat: false,
leaseOwnerId: `frozen-pool-${phase}`, 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); turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
daemonLoop = runtime.lifecycle.start(); daemonLoop = runtime.lifecycle.start();
await turnDaemon.requestStatus(10_000); await turnDaemon.requestStatus(10_000);
@@ -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<AuditRetentionResult> => {
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 }
);
};
@@ -0,0 +1,40 @@
import type { AuditRetentionResult } from './retention.js';
/** 진행할 자료가 있을 때만 계속 실행한다. 일반 게임 턴/페이지 요청에 정리를 결합하지 않는다. */
export const startAuditRetentionWorker = (options: {
prune: () => Promise<AuditRetentionResult>;
onError: (error: unknown) => void;
}): { stop: () => Promise<void> } => {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let running: Promise<void> | 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;
},
};
};
+23 -2
View File
@@ -1,3 +1,4 @@
import { prunePreviousAuditBatch } from '../playAudit/retention.js';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { import {
@@ -81,7 +82,9 @@ export interface ScenarioSeedOptions {
export interface ScenarioSeedResult { export interface ScenarioSeedResult {
seed: WorldSeedPayload; seed: WorldSeedPayload;
warnings: ScenarioBootstrapWarning[]; warnings: Array<
ScenarioBootstrapWarning | { code: 'audit_retention_pending' | 'audit_retention_failed'; message: string }
>;
applied: boolean; applied: boolean;
} }
@@ -372,7 +375,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
} }
await connector.connect(); await connector.connect();
try { try {
const result: ScenarioSeedResult = { seed, warnings, applied: true }; const result: ScenarioSeedResult = { seed, warnings: [...warnings], applied: true };
const applied = await connector.prisma.$transaction( const applied = await connector.prisma.$transaction(
async (prisma) => { async (prisma) => {
await prisma.$queryRawUnsafe( await prisma.$queryRawUnsafe(
@@ -765,6 +768,24 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
{ maxWait: 10_000, timeout: 60_000 } { maxWait: 10_000, timeout: 60_000 }
); );
result.applied = applied; 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; return result;
} finally { } finally {
await connector.disconnect(); await connector.disconnect();
@@ -1,3 +1,4 @@
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
import { persistAuditMonth } from '../playAudit/persistence.js'; import { persistAuditMonth } from '../playAudit/persistence.js';
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js'; import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { areSeasonRecordsFinalized } from './seasonRecords.js';
@@ -78,6 +79,7 @@ export interface DatabaseTurnHooks {
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>; applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
synchronizeClockAuthority(): Promise<boolean>; synchronizeClockAuthority(): Promise<boolean>;
prepareRealtimeRecovery(options?: { paused?: boolean }): Promise<void>; prepareRealtimeRecovery(options?: { paused?: boolean }): Promise<void>;
prunePreviousAudit(expectedServerId: string): Promise<AuditRetentionResult>;
} }
export interface CommittedReadModelChangeReceipt { export interface CommittedReadModelChangeReceipt {
@@ -2150,6 +2152,7 @@ export const createDatabaseTurnHooks = async (
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
return synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world); return synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
}, transactionOptions), }, transactionOptions),
prunePreviousAudit: (expectedServerId) => prunePreviousAuditBatch(prisma, expectedServerId),
close: () => connector.disconnect(), close: () => connector.disconnect(),
}; };
}; };
+23 -1
View File
@@ -1,3 +1,4 @@
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
import { createPlayAuditHandler } from '../playAudit/collection.js'; import { createPlayAuditHandler } from '../playAudit/collection.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createRuntimePauseGate } from './runtimePauseGate.js'; import { createRuntimePauseGate } from './runtimePauseGate.js';
@@ -720,6 +721,7 @@ const createTurnDaemonRuntimeWithLease = async (
let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined; let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined;
let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined; let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined;
let prepareClockRecovery: DatabaseTurnHooks['prepareRealtimeRecovery'] | undefined; let prepareClockRecovery: DatabaseTurnHooks['prepareRealtimeRecovery'] | undefined;
let prunePreviousAudit: DatabaseTurnHooks['prunePreviousAudit'] | undefined;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module])); const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const monthlyActionModules = await loadActionModuleBundle( const monthlyActionModules = await loadActionModuleBundle(
@@ -955,6 +957,7 @@ const createTurnDaemonRuntimeWithLease = async (
applyClockProjection = dbHooks.applyClockProjection; applyClockProjection = dbHooks.applyClockProjection;
synchronizeClockAuthority = dbHooks.synchronizeClockAuthority; synchronizeClockAuthority = dbHooks.synchronizeClockAuthority;
prepareClockRecovery = dbHooks.prepareRealtimeRecovery; prepareClockRecovery = dbHooks.prepareRealtimeRecovery;
prunePreviousAudit = dbHooks.prunePreviousAudit;
close = async () => { close = async () => {
if (auctionBidder) { if (auctionBidder) {
await auctionBidder.close(); await auctionBidder.close();
@@ -1110,6 +1113,22 @@ const createTurnDaemonRuntimeWithLease = async (
controlQueue: resolvedControlQueue, 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 { return {
lifecycle, lifecycle,
world, world,
@@ -1119,7 +1138,10 @@ const createTurnDaemonRuntimeWithLease = async (
processor, processor,
reservedTurns: reservedTurnStoreHandle?.store ?? null, reservedTurns: reservedTurnStoreHandle?.store ?? null,
hooks, hooks,
close, close: async () => {
await auditRetention?.stop();
await close();
},
}; };
}; };
@@ -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<void>;
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: '이전 플레이 감사 자료의 나머지는 서버 시작 후 정리합니다.',
});
});
});
@@ -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<AuditRetentionResult>>()
.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<AuditRetentionResult>((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<AuditRetentionResult> => ({ status: 'identityChanged', deleted: 0 }));
const worker = startAuditRetentionWorker({ prune, onError: vi.fn() });
await vi.advanceTimersByTimeAsync(600_000);
expect(prune).toHaveBeenCalledOnce();
await worker.stop();
});
});
+31
View File
@@ -88,6 +88,37 @@ R1을 완료했다고 판단하지 않는다.
world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다. world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다.
PREOPEN은 wall-clock 대기 상태이며, 검증하는 것은 공식 개방 때의 논리 게임 달력이다. 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`에서 현재 기수와 장수·기록 종류를 제한하고 `generalLogs`는 기존 `LogEntry`에서 현재 기수와 장수·기록 종류를 제한하고
@@ -8,6 +8,7 @@ GATEWAY_RELEASE_DATABASE_URL gateway_runtime
GENERAL_LIFECYCLE_DATABASE_URL core GENERAL_LIFECYCLE_DATABASE_URL core
IMMEDIATE_ACTION_DATABASE_URL immediate_action IMMEDIATE_ACTION_DATABASE_URL immediate_action
INPUT_EVENT_DATABASE_URL core INPUT_EVENT_DATABASE_URL core
PLAY_AUDIT_RETENTION_DATABASE_URL external_fixture
LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie
NPC_POSSESSION_DATABASE_URL npc_possession NPC_POSSESSION_DATABASE_URL npc_possession
NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession
1 # Environment variable Execution mode
8 GENERAL_LIFECYCLE_DATABASE_URL core
9 IMMEDIATE_ACTION_DATABASE_URL immediate_action
10 INPUT_EVENT_DATABASE_URL core
11 PLAY_AUDIT_RETENTION_DATABASE_URL external_fixture
12 LIVE_SORTIE_PERSISTENCE_DATABASE_URL reference_live_sortie
13 NPC_POSSESSION_DATABASE_URL npc_possession
14 NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession