감사 수집과 저장 장애를 게임 진행에서 분리한다
This commit is contained in:
@@ -14,3 +14,4 @@ export * from './inputEventClock.js';
|
||||
export * from './playAuditDiplomacy.js';
|
||||
export * from './messageEnvelope.js';
|
||||
export * from './webPushOutbox.js';
|
||||
export * from './playAuditBestEffort.js';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user