감사 수집과 저장 장애를 게임 진행에서 분리한다
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { persistAuditDiplomacyEvents, type AuditDiplomacyEventDraft, type GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
withPlayAuditSavepoint,
|
||||
persistAuditDiplomacyEvents,
|
||||
type AuditDiplomacyEventDraft,
|
||||
type GamePrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
import type { ApiInputExecutionContext } from '../inputEventBoundary.js';
|
||||
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
@@ -104,6 +109,7 @@ const persistEffects = async (
|
||||
}
|
||||
): Promise<void> => {
|
||||
const changes: AuditDiplomacyEventDraft[] = [];
|
||||
let collectionFailed = false;
|
||||
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
|
||||
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
|
||||
state: row.stateCode,
|
||||
@@ -129,22 +135,26 @@ const persistEffects = async (
|
||||
},
|
||||
});
|
||||
if (audit) {
|
||||
const key = `${effect.srcNationId}:${effect.destNationId}`;
|
||||
const previous = states.get(key);
|
||||
if (!previous) throw new Error('Missing locked diplomacy audit state');
|
||||
const before = project(previous);
|
||||
const after = project(updated);
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
changes.push({
|
||||
...audit.base,
|
||||
srcNationId: effect.srcNationId,
|
||||
destNationId: effect.destNationId,
|
||||
ordinal: changes.length + 1,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
try {
|
||||
const key = `${effect.srcNationId}:${effect.destNationId}`;
|
||||
const previous = states.get(key);
|
||||
if (!previous) throw new Error('Missing locked diplomacy audit state');
|
||||
const before = project(previous);
|
||||
const after = project(updated);
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
changes.push({
|
||||
...audit.base,
|
||||
srcNationId: effect.srcNationId,
|
||||
destNationId: effect.destNationId,
|
||||
ordinal: changes.length + 1,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
states.set(key, updated);
|
||||
} catch {
|
||||
collectionFailed = true;
|
||||
}
|
||||
states.set(key, updated);
|
||||
}
|
||||
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
|
||||
const patch = effect.patch;
|
||||
@@ -159,7 +169,11 @@ const persistEffects = async (
|
||||
}
|
||||
}
|
||||
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
|
||||
await persistAuditDiplomacyEvents(db, changes);
|
||||
if (changes.length || collectionFailed)
|
||||
await withPlayAuditSavepoint(db, async (auditDb) => {
|
||||
if (collectionFailed) throw new Error('Audit relation collection failed');
|
||||
await persistAuditDiplomacyEvents(auditDb, changes);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
||||
|
||||
@@ -117,6 +117,12 @@ export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
|
||||
startYear,
|
||||
startMonth,
|
||||
collectionStart,
|
||||
historyGap: (() => {
|
||||
const gap = asRecord(meta.playAuditGap);
|
||||
return gap.serverId === serverId && Number.isInteger(gap.firstYear) && Number.isInteger(gap.firstMonth)
|
||||
? { firstYear: Number(gap.firstYear), firstMonth: Number(gap.firstMonth) }
|
||||
: null;
|
||||
})(),
|
||||
tick: world.lastTurnTick?.toString() ?? null,
|
||||
asOf: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GamePrisma,
|
||||
hashAuditDiplomacyDocument,
|
||||
persistAuditDiplomacyEvents,
|
||||
withPlayAuditSavepoint,
|
||||
projectAuditDocumentState,
|
||||
readTurnRuntimeReady,
|
||||
type AuditDiplomacyEventDraft,
|
||||
@@ -83,6 +84,7 @@ const readCoordinate = async (ctx: GameApiContext) => {
|
||||
};
|
||||
|
||||
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
|
||||
let collectionFailed = false;
|
||||
const changes: {
|
||||
letter: Letter;
|
||||
before: Record<string, unknown> | null;
|
||||
@@ -92,48 +94,55 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
|
||||
return {
|
||||
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
|
||||
if (!ctx.auditInput) return;
|
||||
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
|
||||
try {
|
||||
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
|
||||
} catch {
|
||||
collectionFailed = true;
|
||||
}
|
||||
},
|
||||
flush: async (): Promise<void> => {
|
||||
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
|
||||
const input = ctx.auditInput;
|
||||
if (!input || !changes.length) return;
|
||||
if (!input || (!changes.length && !collectionFailed)) return;
|
||||
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
|
||||
throw new Error('Play audit diplomacy actor mismatch');
|
||||
const coordinate = await readCoordinate(ctx);
|
||||
if (!coordinate) return;
|
||||
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
|
||||
schemaVersion: 1,
|
||||
serverId: coordinate.serverId,
|
||||
srcNationId: change.letter.srcNationId,
|
||||
destNationId: change.letter.destNationId,
|
||||
category: 'DOCUMENT',
|
||||
source: 'API',
|
||||
eventType: change.eventType,
|
||||
documentId: change.letter.id,
|
||||
documentHash: hashAuditDiplomacyDocument(change.letter),
|
||||
previousDocumentId: change.letter.prevId,
|
||||
year: coordinate.year,
|
||||
month: coordinate.month,
|
||||
tick: coordinate.tick,
|
||||
clockRevision: coordinate.clockRevision,
|
||||
executionId: `api:${input.requestId}`,
|
||||
ordinal: index + 1,
|
||||
requestId: input.requestId,
|
||||
inputSequence: input.sequence,
|
||||
actor: {
|
||||
userId: actor.userId,
|
||||
generalId: actor.id,
|
||||
name: actor.name,
|
||||
nationId: actor.nationId,
|
||||
officerLevel: actor.officerLevel,
|
||||
npcState: actor.npcState,
|
||||
permission,
|
||||
},
|
||||
before: change.before,
|
||||
after: change.after,
|
||||
}));
|
||||
await persistAuditDiplomacyEvents(ctx.db, events);
|
||||
await withPlayAuditSavepoint(ctx.db, async (auditDb) => {
|
||||
if (collectionFailed) throw new Error('Audit document collection failed');
|
||||
const coordinate = await readCoordinate({ ...ctx, db: auditDb });
|
||||
if (!coordinate) return;
|
||||
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
|
||||
schemaVersion: 1,
|
||||
serverId: coordinate.serverId,
|
||||
srcNationId: change.letter.srcNationId,
|
||||
destNationId: change.letter.destNationId,
|
||||
category: 'DOCUMENT',
|
||||
source: 'API',
|
||||
eventType: change.eventType,
|
||||
documentId: change.letter.id,
|
||||
documentHash: hashAuditDiplomacyDocument(change.letter),
|
||||
previousDocumentId: change.letter.prevId,
|
||||
year: coordinate.year,
|
||||
month: coordinate.month,
|
||||
tick: coordinate.tick,
|
||||
clockRevision: coordinate.clockRevision,
|
||||
executionId: `api:${input.requestId}`,
|
||||
ordinal: index + 1,
|
||||
requestId: input.requestId,
|
||||
inputSequence: input.sequence,
|
||||
actor: {
|
||||
userId: actor.userId,
|
||||
generalId: actor.id,
|
||||
name: actor.name,
|
||||
nationId: actor.nationId,
|
||||
officerLevel: actor.officerLevel,
|
||||
npcState: actor.npcState,
|
||||
permission,
|
||||
},
|
||||
before: change.before,
|
||||
after: change.after,
|
||||
}));
|
||||
await persistAuditDiplomacyEvents(auditDb, events);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
@@ -742,7 +743,7 @@ integration('diplomacy document message persistence', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('rolls back the document and notices if the audit insert fails', async () => {
|
||||
it('commits the document and notices with a gap when audit insert fails', async () => {
|
||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event
|
||||
@@ -754,15 +755,19 @@ integration('diplomacy document message persistence', () => {
|
||||
brief: '감사 실패',
|
||||
detail: '롤백',
|
||||
})
|
||||
).rejects.toThrow('injected audit insert failure');
|
||||
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0);
|
||||
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0);
|
||||
).resolves.toBeDefined();
|
||||
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(1);
|
||||
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBeGreaterThan(0);
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
|
||||
expect(
|
||||
await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` },
|
||||
})
|
||||
).toMatchObject({ status: 'FAILED', attempts: 1 });
|
||||
).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
|
||||
expect(
|
||||
asRecord((await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldStateId } })).meta)
|
||||
.playAuditGap
|
||||
).toMatchObject({ serverId: requestPrefix });
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
|
||||
|
||||
Reference in New Issue
Block a user