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
@@ -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;
},
};
};