fix: 설문 투표의 API 트랜잭션 교착과 완료 알림 경계 수정

This commit is contained in:
2026-09-06 15:39:30 +00:00
parent 09dfe96ff1
commit 27049dae1f
7 changed files with 194 additions and 25 deletions
+10 -2
View File
@@ -454,8 +454,16 @@ const markIds = (journal: ChangeJournal, domain: ReadModelDomain, ids: readonly
* actor-targeted. General name/nation changes affect the global online list,
* while frontStatusActorIds is the private actor projection.
*/
export const createReadModelChangeJournal = (changes: RealtimeReadModelChanges): ChangeJournal => {
export const createReadModelChangeJournal = (
changes: RealtimeReadModelChanges,
commandResult?: TurnDaemonCommandResult
): ChangeJournal => {
const journal = new ChangeJournal();
// 설문 완료 여부는 투표한 actor만의 projection이다. 보상과 같은 ENGINE
// transaction에 기록해야 API 응답 실패·재시도에도 commit 뒤 알림이 보존된다.
if (commandResult?.type === 'voteReward' && commandResult.ok) {
journal.mark('front.general', commandResult.generalId);
}
markIds(journal, 'general.content', changes.generalIds);
markIds(journal, 'city.content', changes.cityIds);
markIds(journal, 'nation.content', changes.nationIds);
@@ -1981,7 +1989,7 @@ export const createDatabaseTurnHooks = async (
if (worldReadModelSignature !== worldReadModelBaseline) {
readModelChanges.worldChanged = true;
}
const journal = createReadModelChangeJournal(readModelChanges);
const journal = createReadModelChangeJournal(readModelChanges, commandCompletion?.result);
if (hasDashboardSourceMutation(changes, readModelChanges)) {
journal.mark('dashboard.global');
}
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnDaemonCommandResult, TurnRunResult } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope, type MapDefinition, type ScenarioConfig } from '@sammo-ts/logic';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -137,7 +137,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
await db.$executeRawUnsafe(`
ALTER TABLE read_model_outbox
ADD CONSTRAINT ${rollbackConstraint}
CHECK ((payload->>'version')::integer <> 1)
CHECK ((payload->>'version')::integer <> 1) NOT VALID
`);
world.updateWorldMeta({ durableFixture: 'must-rollback' });
world.pushLog({
@@ -270,5 +270,41 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
expect(secondQueuedReceipt?.changes.worldChanged).toBe(true);
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
await expect(db.readModelOutbox.count()).resolves.toBe(5);
await db.inputEvent.update({
where: { requestId },
data: { eventType: 'voteReward', status: 'PROCESSING', result: GamePrisma.DbNull },
});
const voteResult: TurnDaemonCommandResult = {
type: 'voteReward',
ok: true,
voteId: 1,
generalId: directLogGeneralId,
awardedUnique: false,
};
await db.$executeRawUnsafe(`
ALTER TABLE read_model_outbox ADD CONSTRAINT ${rollbackConstraint}
CHECK ((payload->>'version')::integer <> 1) NOT VALID
`);
await expect(hooks.hooks.executeCommand?.(requestId, async () => voteResult)).rejects.toThrow(
rollbackConstraint
);
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'PROCESSING',
result: null,
});
await expect(db.readModelOutbox.count()).resolves.toBe(5);
await db.$executeRawUnsafe(`ALTER TABLE read_model_outbox DROP CONSTRAINT ${rollbackConstraint}`);
await hooks.hooks.executeCommand?.(requestId, async () => voteResult);
expect(hooks.takeCommittedReadModelChangeReceipt()?.invalidation.revisions).toEqual([
{ domain: 'front.general', entityId: directLogGeneralId, revision: 1n },
]);
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: voteResult,
});
await expect(db.readModelOutbox.count()).resolves.toBe(6);
});
});
@@ -16,6 +16,34 @@ import type { TurnWorldChanges } from '../src/turn/inMemoryWorld.js';
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
describe('durable read-model change journal mapping', () => {
it.each([false, true])(
'invalidates only the voting actor on successful vote completion (replay=%s)',
(alreadyApplied) => {
expect(
createReadModelChangeJournal(createEmptyRealtimeReadModelChanges(), {
type: 'voteReward',
ok: true,
voteId: 1,
generalId: 7,
awardedUnique: false,
alreadyApplied,
}).snapshot()
).toEqual([{ domain: 'front.general', entityId: 7 }]);
}
);
it('does not publish vote completion for a rejected vote', () => {
expect(
createReadModelChangeJournal(createEmptyRealtimeReadModelChanges(), {
type: 'voteReward',
ok: false,
voteId: 1,
generalId: 7,
reason: 'closed',
}).snapshot()
).toEqual([]);
});
it('maps every final engine invalidation to its precise durable domain', () => {
const changes = {
...createEmptyRealtimeReadModelChanges(),