feat: 이전 기수 감사 표본을 제한된 batch로 정리
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user