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
+5 -7
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -102,10 +102,7 @@ export const hasPollEnded = (
time: CurrentGameTime time: CurrentGameTime
): boolean => ): boolean =>
Boolean(poll.closed_at) || Boolean(poll.closed_at) ||
Boolean( Boolean(poll.end_at && (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)));
poll.end_at &&
(poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick))
);
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => { const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null; if (!date) return null;
@@ -290,7 +287,9 @@ export const voteRouter = router({
userCnt, userCnt,
}; };
}), }),
submitVote: authedProcedure // 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고
// 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다.
submitVote: engineAuthedProcedure
.input( .input(
z.object({ z.object({
voteId: z.number().int().positive(), voteId: z.number().int().positive(),
@@ -366,7 +365,6 @@ export const voteRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason }); throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
} }
ctx.changeJournal?.mark('front.general', general.id);
return { ok: true, wonLottery: rewardResult.awardedUnique }; return { ok: true, wonLottery: rewardResult.awardedUnique };
}), }),
addComment: authedProcedure addComment: authedProcedure
@@ -1,9 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import {
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
tryGameSchemaAdvisoryXactLock,
type GamePrismaClient,
} from '@sammo-ts/infra';
import type { GameApiContext } from '../src/context.js'; import type { GameApiContext } from '../src/context.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -79,6 +85,63 @@ integration('vote comment operational timestamp', () => {
await closeDb?.(); await closeDb?.();
}); });
it('lets a separate ENGINE transaction claim the clock fence while submitVote waits', async () => {
const requestId = 'integration:vote-comment-timestamp:submit';
const engineRequestId = `${requestId}:vote.submitVote:engine:0:voteReward`;
const transport = new DatabaseTurnDaemonTransport(db, 2_000);
const context: Partial<GameApiContext> = {
requestId,
db,
auth,
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
turnDaemon: {
sendCommand: transport.sendCommand.bind(transport),
requestStatus: transport.requestStatus.bind(transport),
requestCommand: async (command) => {
// 실제 DB transport의 durable 접수와 별도 connection의 clock fence를
// 검증한다. 보상 계산 자체는 voteReward suite가 검증한다.
const acceptedId = await transport.sendCommand(command);
expect(acceptedId).toBe(engineRequestId);
await db.$transaction(async (transaction) => {
expect(await tryGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK)).toBe(
true
);
const event = await transaction.inputEvent.findUniqueOrThrow({
where: { requestId: acceptedId },
});
expect(event).toMatchObject({
target: 'ENGINE',
status: 'PENDING',
actorUserId: fixtureUserId,
});
await transaction.inputEvent.update({
where: { requestId: acceptedId },
data: {
status: 'SUCCEEDED',
result: {
type: 'voteReward',
ok: true,
voteId: fixtureId,
generalId: fixtureId,
awardedUnique: false,
},
},
});
});
return transport.requestCommand(command);
},
},
};
const caller = appRouter.createCaller(context as GameApiContext);
await expect(caller.vote.submitVote({ voteId: fixtureId, selection: [0] })).resolves.toEqual({
ok: true,
wonLottery: false,
});
expect(await db.inputEvent.count({ where: { requestId: `${requestId}:vote.submitVote` } })).toBe(0);
expect(await db.inputEvent.count({ where: { requestId: engineRequestId } })).toBe(1);
});
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => { it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>` const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
SELECT current_setting('TIMEZONE') AS "timeZone" SELECT current_setting('TIMEZONE') AS "timeZone"
+35 -1
View File
@@ -216,6 +216,40 @@ const buildContext = (options: {
}; };
describe('vote router actor and permission boundaries', () => { describe('vote router actor and permission boundaries', () => {
it('waits for the ENGINE vote without holding an outer API transaction', async () => {
const fixture = buildContext({ requestId: 'vote-boundary' });
const transaction = vi.fn(async () => {
throw new Error('API transaction blocks the vote ENGINE clock fence');
});
Object.assign(fixture.context.db, { $transaction: transaction });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'vote-boundary:vote.submitVote:engine:0:voteReward',
userId: 'user-1',
generalId: 7,
})
);
});
it.each([
{ auth: null, code: 'UNAUTHORIZED' },
{ auth: { ...buildAuth(), sanctions: { bannedUntil: '2999-01-01T00:00:00Z' } }, code: 'FORBIDDEN' },
])(
'rejects voting before dispatch when authentication or sanctions disallow access: %j',
async ({ auth, code }) => {
const fixture = buildContext({ auth });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).rejects.toMatchObject({ code });
expect(fixture.requestCommand).not.toHaveBeenCalled();
}
);
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => { it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
const now = new Date('2026-07-26T00:00:00Z'); const now = new Date('2026-07-26T00:00:00Z');
const time = { const time = {
@@ -259,7 +293,7 @@ describe('vote router actor and permission boundaries', () => {
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe( expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false false
); );
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]); expect(fixture.changeJournal.snapshot()).toEqual([]);
expect(fixture.redisIncr).not.toHaveBeenCalled(); expect(fixture.redisIncr).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled();
}); });
+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, * actor-targeted. General name/nation changes affect the global online list,
* while frontStatusActorIds is the private actor projection. * 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(); 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, 'general.content', changes.generalIds);
markIds(journal, 'city.content', changes.cityIds); markIds(journal, 'city.content', changes.cityIds);
markIds(journal, 'nation.content', changes.nationIds); markIds(journal, 'nation.content', changes.nationIds);
@@ -1981,7 +1989,7 @@ export const createDatabaseTurnHooks = async (
if (worldReadModelSignature !== worldReadModelBaseline) { if (worldReadModelSignature !== worldReadModelBaseline) {
readModelChanges.worldChanged = true; readModelChanges.worldChanged = true;
} }
const journal = createReadModelChangeJournal(readModelChanges); const journal = createReadModelChangeJournal(readModelChanges, commandCompletion?.result);
if (hasDashboardSourceMutation(changes, readModelChanges)) { if (hasDashboardSourceMutation(changes, readModelChanges)) {
journal.mark('dashboard.global'); journal.mark('dashboard.global');
} }
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnDaemonCommandResult, TurnRunResult } from '@sammo-ts/common'; 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 { LogCategory, LogFormat, LogScope, type MapDefinition, type ScenarioConfig } from '@sammo-ts/logic';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -137,7 +137,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
await db.$executeRawUnsafe(` await db.$executeRawUnsafe(`
ALTER TABLE read_model_outbox ALTER TABLE read_model_outbox
ADD CONSTRAINT ${rollbackConstraint} ADD CONSTRAINT ${rollbackConstraint}
CHECK ((payload->>'version')::integer <> 1) CHECK ((payload->>'version')::integer <> 1) NOT VALID
`); `);
world.updateWorldMeta({ durableFixture: 'must-rollback' }); world.updateWorldMeta({ durableFixture: 'must-rollback' });
world.pushLog({ world.pushLog({
@@ -270,5 +270,41 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
expect(secondQueuedReceipt?.changes.worldChanged).toBe(true); expect(secondQueuedReceipt?.changes.worldChanged).toBe(true);
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull(); expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
await expect(db.readModelOutbox.count()).resolves.toBe(5); 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'; import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
describe('durable read-model change journal mapping', () => { 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', () => { it('maps every final engine invalidation to its precise durable domain', () => {
const changes = { const changes = {
...createEmptyRealtimeReadModelChanges(), ...createEmptyRealtimeReadModelChanges(),
@@ -24,8 +24,7 @@ transaction을 만든다. `engineAuthedProcedure`, `accessEngineAuthedProcedure`
- **ENGINE 소유 + 불필요한 API outer**: gameplay durable mutation은 ENGINE이 전부 - **ENGINE 소유 + 불필요한 API outer**: gameplay durable mutation은 ENGINE이 전부
소유하지만 route가 아직 API `input_event`/journal transaction에 감싸여 있다. 소유하지만 route가 아직 API `input_event`/journal transaction에 감싸여 있다.
현재 합계는 **ENGINE 전환 36 + 혼합 3 + 기존 ENGINE 9 + 불필요한 API 현재 합계는 **ENGINE 전환 37 + 혼합 3 + 기존 ENGINE 9 = 49**다.
outer 1 = 49**다.
ENGINE 전환 route의 explicit `requestId`는 각 route가 기존에 사용하던 HTTP 요청 ENGINE 전환 route의 explicit `requestId`는 각 route가 기존에 사용하던 HTTP 요청
또는 user/client-scoped durable identity를 유지한다. `inheritanceAction` 또는 user/client-scoped durable identity를 유지한다. `inheritanceAction`
@@ -49,7 +48,7 @@ selection-pool create/reselect는 client request ID가 있을 때
| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `engineAuthedProcedure`; API는 인증 actor, 현재 국가와 조기 권한만 읽고 semantic mutation을 전달 (`app/game-api/src/router/nation/endpoints/setNotice.ts`, `setScoutMsg.ts`, `setSecretLimit.ts`, `setRate.ts`, `setBlockWar.ts`, `setBill.ts`, `setBlockScout.ts`) | `setNationSetting`이 실행 시점 owner/nation/직책·permission을 다시 검사한다. 전쟁 설정 잔여 횟수 차감과 임관 잠금 검사를 현재 ENGINE state에서 수행하고, 공지는 logical game time과 author snapshot을 국가 meta와 같은 transaction에 저장한다 (`app/game-engine/src/turn/nationSettingMutation.ts`, `worldCommandHandler.ts`). | | `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `engineAuthedProcedure`; API는 인증 actor, 현재 국가와 조기 권한만 읽고 semantic mutation을 전달 (`app/game-api/src/router/nation/endpoints/setNotice.ts`, `setScoutMsg.ts`, `setSecretLimit.ts`, `setRate.ts`, `setBlockWar.ts`, `setBill.ts`, `setBlockScout.ts`) | `setNationSetting`이 실행 시점 owner/nation/직책·permission을 다시 검사한다. 전쟁 설정 잔여 횟수 차감과 임관 잠금 검사를 현재 ENGINE state에서 수행하고, 공지는 logical game time과 author snapshot을 국가 meta와 같은 transaction에 저장한다 (`app/game-engine/src/turn/nationSettingMutation.ts`, `worldCommandHandler.ts`). |
| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessEngineAuthedInputProcedure`; API는 현재 화면값과 unit-set 기반 입력 보조만 수행하고 actor-bound semantic delta와 명시적 nullable revision을 전달 (`app/game-api/src/router/npc/index.ts`) | `setNpcPolicy`가 실행 시점 owner/nation/permission, strict CAS, 현재 troop/city membership, priority와 numeric policy를 검증하고 logical setter snapshot과 delta만 반영한다 (`app/game-engine/src/turn/npcPolicyMutation.ts`, `worldCommandHandler.ts`). | | `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessEngineAuthedInputProcedure`; API는 현재 화면값과 unit-set 기반 입력 보조만 수행하고 actor-bound semantic delta와 명시적 nullable revision을 전달 (`app/game-api/src/router/npc/index.ts`) | `setNpcPolicy`가 실행 시점 owner/nation/permission, strict CAS, 현재 troop/city membership, priority와 numeric policy를 검증하고 logical setter snapshot과 delta만 반영한다 (`app/game-engine/src/turn/npcPolicyMutation.ts`, `worldCommandHandler.ts`). |
합계 **36개 route**다. 이 표의 **36개 route**와 아래 설문 전환 **1개 route**를 합쳐 **37개 route**다.
## 혼합 또는 validation 이관이 먼저 필요한 route ## 혼합 또는 validation 이관이 먼저 필요한 route
@@ -70,14 +69,15 @@ selection-pool create/reselect는 client request ID가 있을 때
합계 **9개 route**다. 합계 **9개 route**다.
## ENGINE이 소유하지만 API outer transaction이 남은 route ## 설문 투표의 ENGINE 전환
| route | 현재 상태 | 남은 일 | `vote.submitVote``engineAuthedProcedure`로 actor/입력만 검증하고 기존
| --- | --- | --- | `<requestId>:vote.submitVote:engine:0:voteReward` identity를 전달한다. API가 clock
| `vote.submitVote` | `authedProcedure`가 API outer `input_event` transaction을 만든다. API는 poll/actor 조기 validation 후 `voteReward`를 보내고 `front.general` journal을 표시한다 (`app/game-api/src/router/vote/index.ts:294-369`). ENGINE은 poll row lock, 선택 validation, vote insert, reward/idempotency marker, 금·아이템·로그 변경을 한 mutation transaction에서 소유한다 (`app/game-engine/src/turn/worldCommandHandler.ts:2430-2847`). | viewer-specific `front.general` invalidation을 ENGINE commit journal로 옮기고, 현재 middleware가 만드는 `voteReward` child identity를 explicit request ID로 보존한 뒤 ENGINE procedure로 전환한다. | fence를 가진 채 ENGINE 결과를 기다리던 교착을 제거했다. 투표·보상·marker는
기존 ENGINE transaction이 소유하고, `databaseHooks.ts`가 성공한 `voteReward`
합계 **1개 route**다. Gameplay DB mutation 소유권 기준으로는 불필요한 outer이지만, 결과의 general ID로 `front.general`을 같은 commit journal에 기록한다.
`front.general` journal이 API에 남아 있으므로 procedure만 바꾸면 실시간 갱신 계약을 잃는다. 재시도 성공도 본인 projection만 갱신하며 거절된 투표는 완료 알림을 만들지 않는다.
따라서 ENGINE 소유 route에 불필요한 API outer transaction은 남지 않는다.
## 검증 계약 ## 검증 계약
@@ -100,8 +100,10 @@ selection-pool create/reselect는 client request ID가 있을 때
`app/game-engine/test/inheritanceActionPersistence.integration.test.ts`: 인증 actor, point/log, `app/game-engine/test/inheritanceActionPersistence.integration.test.ts`: 인증 actor, point/log,
general/message 변경이 `inheritanceAction` ENGINE transaction에 함께 있는지 검증한다. general/message 변경이 `inheritanceAction` ENGINE transaction에 함께 있는지 검증한다.
- `app/game-api/test/voteRouter.test.ts`, `app/game-engine/test/voteReward.test.ts`: API 조기 - `app/game-api/test/voteRouter.test.ts`, `app/game-engine/test/voteReward.test.ts`: API 조기
validation ENGINE의 vote insert/reward/idempotency 경계를 검증한다. API outer/journal validation, API outer transaction 부재와 ENGINE의 vote insert/reward/idempotency 경계를 검증한다.
제거는 아직 검증 대상이 아니다. - `voteCommentTimestamp.integration.test.ts`는 실제 PostgreSQL의 별도 ENGINE clock fence와
durable 접수를, `readModelChangeJournalPersistence.integration.test.ts`는 설문 완료와
본인 알림의 atomic commit/rollback을 검증한다.
- raw inventory 재검색: `rg -n "requestCommand\\(" app/game-api/src/router` - raw inventory 재검색: `rg -n "requestCommand\\(" app/game-api/src/router`
`openAuctionWithDaemon`, `requestInheritanceAction`, `updateNationSetting`, `openAuctionWithDaemon`, `requestInheritanceAction`, `updateNationSetting`,
`requestNpcPolicyMutation`, `requestNpcPolicyMutation`,