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

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 };
}
};
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '../src/gamePrisma.js';
import { withPlayAuditSavepoint } from '../src/playAuditBestEffort.js';
describe('optional gameplay audit transaction', () => {
it('propagates broken transaction recovery instead of claiming a safe gameplay commit', async () => {
const db = {
$queryRaw: vi.fn().mockResolvedValue([{ statement: '0', lock: '0' }]),
$executeRaw: vi.fn().mockImplementation(async (sql: TemplateStringsArray) => {
if (sql[0]!.startsWith('ROLLBACK')) throw new Error('connection lost');
return 0;
}),
} as unknown as GamePrisma.TransactionClient;
await expect(
withPlayAuditSavepoint(db, async () => {
throw new Error('audit insert');
})
).rejects.toThrow('connection lost');
});
it('stops subsequent batches when the whole audit budget is spent', async () => {
let now = 0;
const timer = vi.spyOn(performance, 'now').mockImplementation(() => now);
const write = vi.fn().mockImplementation(async () => {
now += 600;
return { count: 1 };
});
const db = {
$queryRaw: vi.fn().mockResolvedValue([{ statement: '0', lock: '0' }]),
$executeRaw: vi.fn().mockResolvedValue(0),
playAuditPolicy: { createMany: write },
} as unknown as GamePrisma.TransactionClient;
try {
const result = await withPlayAuditSavepoint(db, async (auditDb) => {
for (let i = 0; i < 100; i++) await auditDb.playAuditPolicy.createMany({ data: [] });
});
expect(result.ok).toBe(false);
expect(write).toHaveBeenCalledTimes(2);
expect(
vi
.mocked(db.$executeRaw)
.mock.calls.some(
([sql]) => Array.isArray(sql) && sql[0] === 'ROLLBACK TO SAVEPOINT play_audit_optional'
)
).toBe(true);
} finally {
timer.mockRestore();
}
});
});