감사 수집과 저장 장애를 게임 진행에서 분리한다

This commit is contained in:
2026-09-26 16:26:02 +00:00
parent daf7a87799
commit 6b65633324
22 changed files with 1108 additions and 550 deletions
+1
View File
@@ -14,3 +14,4 @@ export * from './inputEventClock.js';
export * from './playAuditDiplomacy.js';
export * from './messageEnvelope.js';
export * from './webPushOutbox.js';
export * from './playAuditBestEffort.js';
+65
View File
@@ -0,0 +1,65 @@
import type { GamePrisma } from './gamePrisma.js';
/** Only audit work belongs here. A broken outer transaction/connection still propagates. */
export const withPlayAuditSavepoint = async <
T,
Db extends Pick<GamePrisma.TransactionClient, '$executeRaw' | '$queryRaw'>,
>(
db: Db,
collect: (auditDb: Db) => Promise<T>
): Promise<{ ok: true; value: T } | { ok: false }> => {
await db.$executeRaw`SAVEPOINT play_audit_optional`;
try {
const [settings] = await db.$queryRaw<Array<{ statement: string; lock: string }>>`
SELECT current_setting('statement_timeout') AS statement, current_setting('lock_timeout') AS lock
`;
// Bound audit lock waits and individual statements, without relaxing tighter caller limits.
await db.$queryRaw`
SELECT set_config('statement_timeout',
CASE WHEN current_setting('statement_timeout') = '0' THEN '1000ms'
ELSE LEAST(EXTRACT(EPOCH FROM current_setting('statement_timeout')::interval) * 1000, 1000)::text || 'ms' END, true),
set_config('lock_timeout', CASE WHEN current_setting('lock_timeout') = '0' THEN '250ms' ELSE LEAST(EXTRACT(EPOCH FROM current_setting('lock_timeout')::interval) * 1000, 250)::text || 'ms' END, true)
`;
const deadline = performance.now() + 1000;
const checkBudget = () => {
if (performance.now() >= deadline) throw new Error('Audit budget exhausted');
};
const bound = <ObjectType extends object>(target: ObjectType): ObjectType =>
new Proxy(target, {
get(object, property) {
const value: unknown = Reflect.get(object, property);
if (typeof value === 'function')
return async (...args: unknown[]) => {
checkBudget();
const result: unknown = await Reflect.apply(value, object, args);
checkBudget();
return result;
};
return value && typeof value === 'object' ? bound(value) : value;
},
});
const value = await collect(bound(db));
checkBudget();
await db.$queryRaw`SELECT set_config('statement_timeout', ${settings!.statement}, true), set_config('lock_timeout', ${settings!.lock}, true)`;
await db.$executeRaw`RELEASE SAVEPOINT play_audit_optional`;
return { ok: true, value };
} catch (error) {
// SQL errors poison PostgreSQL transactions. Catch alone is not sufficient.
await db.$executeRaw`ROLLBACK TO SAVEPOINT play_audit_optional`;
await db.$executeRaw`RELEASE SAVEPOINT play_audit_optional`;
// This notice uses the core row, not the possibly unavailable audit schema.
await db.$executeRaw`UPDATE world_state SET meta = jsonb_set(COALESCE(meta, '{}'::jsonb), '{playAuditGap}',
jsonb_build_object('serverId', meta->'serverId',
'firstYear', CASE WHEN meta->'playAuditGap'->>'serverId' = meta->>'serverId' THEN COALESCE(meta->'playAuditGap'->'firstYear', to_jsonb(current_year)) ELSE to_jsonb(current_year) END,
'firstMonth', CASE WHEN meta->'playAuditGap'->>'serverId' = meta->>'serverId' THEN COALESCE(meta->'playAuditGap'->'firstMonth', to_jsonb(current_month)) ELSE to_jsonb(current_month) END,
'lastYear', current_year, 'lastMonth', current_month, 'stage', 'persistence'))
WHERE id = (SELECT id FROM world_state ORDER BY id LIMIT 1)`;
// Never log payloads, SQL or actor details here.
const code =
error && typeof error === 'object' && 'code' in error && /^[A-Z0-9_]{1,20}$/.test(String(error.code))
? String(error.code)
: 'AUDIT_ERROR';
console.warn(`[play-audit] ${code}: audit batch discarded; gameplay continues with a history gap.`);
return { ok: false };
}
};