From 27049dae1f4cd85a30fb8641ab918551a7afa108 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 6 Sep 2026 15:39:30 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=84=A4=EB=AC=B8=20=ED=88=AC=ED=91=9C?= =?UTF-8?q?=EC=9D=98=20API=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EA=B5=90=EC=B0=A9=EA=B3=BC=20=EC=99=84=EB=A3=8C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B2=BD=EA=B3=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/vote/index.ts | 12 ++-- .../voteCommentTimestamp.integration.test.ts | 65 ++++++++++++++++++- app/game-api/test/voteRouter.test.ts | 36 +++++++++- app/game-engine/src/turn/databaseHooks.ts | 12 +++- ...angeJournalPersistence.integration.test.ts | 40 +++++++++++- .../test/realtimeReadModelChanges.test.ts | 28 ++++++++ .../game-api-daemon-procedure-inventory.md | 26 ++++---- 7 files changed, 194 insertions(+), 25 deletions(-) diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 7ec78794..528bf266 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; 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 { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js'; @@ -102,10 +102,7 @@ export const hasPollEnded = ( time: CurrentGameTime ): boolean => Boolean(poll.closed_at) || - Boolean( - poll.end_at && - (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)) - ); + Boolean(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 => { if (!date) return null; @@ -290,7 +287,9 @@ export const voteRouter = router({ userCnt, }; }), - submitVote: authedProcedure + // 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고 + // 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다. + submitVote: engineAuthedProcedure .input( z.object({ voteId: z.number().int().positive(), @@ -366,7 +365,6 @@ export const voteRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason }); } - ctx.changeJournal?.mark('front.general', general.id); return { ok: true, wonLottery: rewardResult.awardedUnique }; }), addComment: authedProcedure diff --git a/app/game-api/test/voteCommentTimestamp.integration.test.ts b/app/game-api/test/voteCommentTimestamp.integration.test.ts index 055c9c50..401ca3e2 100644 --- a/app/game-api/test/voteCommentTimestamp.integration.test.ts +++ b/app/game-api/test/voteCommentTimestamp.integration.test.ts @@ -1,9 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js'; import { appRouter } from '../src/router.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; @@ -79,6 +85,63 @@ integration('vote comment operational timestamp', () => { 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 = { + 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 () => { const [session] = await db.$queryRaw>` SELECT current_setting('TIMEZONE') AS "timeZone" diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index dd20a394..4f5cf2c4 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -216,6 +216,40 @@ const buildContext = (options: { }; 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', () => { const now = new Date('2026-07-26T00:00:00Z'); 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( false ); - expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]); + expect(fixture.changeJournal.snapshot()).toEqual([]); expect(fixture.redisIncr).not.toHaveBeenCalled(); expect(fixture.redisPublish).not.toHaveBeenCalled(); }); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 331ed78f..43177c68 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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'); } diff --git a/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts b/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts index 0b860f1e..1eb9063d 100644 --- a/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts +++ b/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts @@ -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); }); }); diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index 5c43c0b2..7b252a73 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -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(), diff --git a/docs/architecture/game-api-daemon-procedure-inventory.md b/docs/architecture/game-api-daemon-procedure-inventory.md index cd0f5c55..49495275 100644 --- a/docs/architecture/game-api-daemon-procedure-inventory.md +++ b/docs/architecture/game-api-daemon-procedure-inventory.md @@ -24,8 +24,7 @@ transaction을 만든다. `engineAuthedProcedure`, `accessEngineAuthedProcedure` - **ENGINE 소유 + 불필요한 API outer**: gameplay durable mutation은 ENGINE이 전부 소유하지만 route가 아직 API `input_event`/journal transaction에 감싸여 있다. -현재 합계는 **ENGINE 전환 36 + 혼합 3 + 기존 ENGINE 9 + 불필요한 API -outer 1 = 49**다. +현재 합계는 **ENGINE 전환 37 + 혼합 3 + 기존 ENGINE 9 = 49**다. ENGINE 전환 route의 explicit `requestId`는 각 route가 기존에 사용하던 HTTP 요청 또는 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`). | | `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 @@ -70,14 +69,15 @@ selection-pool create/reselect는 client request ID가 있을 때 합계 **9개 route**다. -## ENGINE이 소유하지만 API outer transaction이 남은 route +## 설문 투표의 ENGINE 전환 -| route | 현재 상태 | 남은 일 | -| --- | --- | --- | -| `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로 전환한다. | - -합계 **1개 route**다. Gameplay DB mutation 소유권 기준으로는 불필요한 outer이지만, -`front.general` journal이 API에 남아 있으므로 procedure만 바꾸면 실시간 갱신 계약을 잃는다. +`vote.submitVote`는 `engineAuthedProcedure`로 actor/입력만 검증하고 기존 +`:vote.submitVote:engine:0:voteReward` identity를 전달한다. API가 clock +fence를 가진 채 ENGINE 결과를 기다리던 교착을 제거했다. 투표·보상·marker는 +기존 ENGINE transaction이 소유하고, `databaseHooks.ts`가 성공한 `voteReward` +결과의 general ID로 `front.general`을 같은 commit journal에 기록한다. +재시도 성공도 본인 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, general/message 변경이 `inheritanceAction` ENGINE transaction에 함께 있는지 검증한다. - `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`와 `openAuctionWithDaemon`, `requestInheritanceAction`, `updateNationSetting`, `requestNpcPolicyMutation`,