From 885018c7fd30b41105cccce64a6a91b2bc6fed25 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 16:11:56 +0000 Subject: [PATCH] fix(turn): roll back checkpoint with world state --- .../src/turn/inMemoryStateStore.ts | 6 +-- .../test/engineStateManager.test.ts | 41 +++++++++++++++++++ docs/architecture/runtime.md | 7 +++- docs/developer/request-turn-persistence.md | 13 ++++-- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/app/game-engine/src/turn/inMemoryStateStore.ts b/app/game-engine/src/turn/inMemoryStateStore.ts index ff44bc8..cdf7362 100644 --- a/app/game-engine/src/turn/inMemoryStateStore.ts +++ b/app/game-engine/src/turn/inMemoryStateStore.ts @@ -4,7 +4,6 @@ import type { InMemoryTurnWorld } from './inMemoryWorld.js'; export class InMemoryTurnStateStore implements TurnStateStore { // 인메모리 월드의 턴 상태를 TurnDaemonLifecycle에 제공한다. private readonly world: InMemoryTurnWorld; - private checkpoint?: TurnCheckpoint; constructor(world: InMemoryTurnWorld) { this.world = world; @@ -15,7 +14,7 @@ export class InMemoryTurnStateStore implements TurnStateStore { } async loadNextGeneralTurnTime(): Promise { - return this.world.getNextGeneralTurnTime(this.checkpoint); + return this.world.getNextGeneralTurnTime(this.world.getCheckpoint()); } async saveLastTurnTime(turnTime: Date): Promise { @@ -23,11 +22,10 @@ export class InMemoryTurnStateStore implements TurnStateStore { } async loadCheckpoint(): Promise { - return this.checkpoint; + return this.world.getCheckpoint(); } async saveCheckpoint(checkpoint?: TurnCheckpoint): Promise { - this.checkpoint = checkpoint; this.world.setCheckpoint(checkpoint); } } diff --git a/app/game-engine/test/engineStateManager.test.ts b/app/game-engine/test/engineStateManager.test.ts index 49e2dba..13703e7 100644 --- a/app/game-engine/test/engineStateManager.test.ts +++ b/app/game-engine/test/engineStateManager.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { EngineStateManager } from '../src/turn/engineStateManager.js'; +import { InMemoryTurnStateStore } from '../src/turn/inMemoryStateStore.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; @@ -181,6 +182,46 @@ describe('EngineStateManager', () => { expect(world.listEvents()).toEqual([]); }); + it('restores the concrete state-store checkpoint when a flush transaction fails', async () => { + const world = buildWorld(); + const stateStore = new InMemoryTurnStateStore(world); + const previousCheckpoint = { + turnTime: '0189-01-01T00:00:00.000Z', + generalId: 1, + year: 189, + month: 1, + }; + const nextCheckpoint = { + turnTime: '0189-01-01T00:10:00.000Z', + generalId: 2, + year: 189, + month: 1, + }; + await stateStore.saveCheckpoint(previousCheckpoint); + + const manager = new EngineStateManager(); + manager.register('world', { + capture: () => world.captureState(), + restore: (snapshot) => world.restoreState(snapshot), + }); + + await expect( + manager.transaction(async () => { + await stateStore.saveCheckpoint(nextCheckpoint); + throw new Error('injected flush failure'); + }) + ).rejects.toThrow('injected flush failure'); + + expect(await stateStore.loadCheckpoint()).toEqual(previousCheckpoint); + expect(world.getCheckpoint()).toEqual(previousCheckpoint); + expect(manager.getRevision()).toBe(0); + + await manager.transaction(() => stateStore.saveCheckpoint(nextCheckpoint)); + expect(await stateStore.loadCheckpoint()).toEqual(nextCheckpoint); + expect(world.getCheckpoint()).toEqual(nextCheckpoint); + expect(manager.getRevision()).toBe(1); + }); + it('creates reusable test savepoints without sharing mutable inspection data', async () => { const manager = new EngineStateManager(); let state = { nested: { value: 1 } }; diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 1647439..facb4b3 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -38,7 +38,8 @@ artifact를 만들며 `Pm2ProcessManager`가 profile process를 조정합니다. `GatewayRuntimeAction`으로 접수합니다. Profile별 `REQUESTED`/`PARTIAL`은 DB partial unique index로 한 건만 허용합니다. Turn daemon은 자신의 lease를 확인한 뒤 action ID로 결정적인 `InputEvent`를 만들고, world·전 장수·OPEN -경매·checkpoint를 같은 PostgreSQL transaction에서 이동합니다. Commit 뒤 +경매는 같은 PostgreSQL transaction에서, checkpoint는 이를 감싼 동일 +`EngineStateManager` snapshot 경계에서 이동합니다. Commit 뒤 경매 timer와 활성 토너먼트 시각을 Redis에 idempotent하게 투영합니다. Redis 단계가 실패하면 action은 `PARTIAL`과 backoff 상태로 남고 DB 시간은 다시 이동하지 않습니다. @@ -89,13 +90,15 @@ lease/fencing 확인 -> EngineStateManager transaction -> world/general/nation/city/turn/log flush -> input_event result/status 갱신 - -> checkpoint 갱신 + -> in-memory world checkpoint 갱신 -> commit -> realtime 알림 ``` Transaction 실패 시 in-memory snapshot을 복원하고 event는 재시도 가능한 실패 상태로 남깁니다. Lease 소유권을 잃은 daemon은 flush를 확정하지 않습니다. +Checkpoint의 단일 소유자는 `InMemoryTurnWorld`이며 state store는 이를 +위임 조회합니다. 별도 checkpoint 복사본이 snapshot보다 앞서 나가지 않습니다. 예약 턴은 revision/CAS와 lease를 사용합니다. API의 편집과 daemon의 실행이 경합해도 오래된 revision이 새 queue를 덮어쓰지 않게 합니다. diff --git a/docs/developer/request-turn-persistence.md b/docs/developer/request-turn-persistence.md index 9aead11..8fe392a 100644 --- a/docs/developer/request-turn-persistence.md +++ b/docs/developer/request-turn-persistence.md @@ -38,7 +38,7 @@ request -> lease owner claim -> in-memory world mutation -> EngineStateManager transaction flush - -> event 결과와 checkpoint commit + -> PostgreSQL event 결과 commit과 in-memory world checkpoint 확정 -> SSE/realtime ``` @@ -57,10 +57,14 @@ request 4. action module과 command handler가 state patch, log, message를 만듭니다. 5. world에 patch를 적용하고 dirty entity를 기록합니다. 6. 월 경계를 지났으면 scenario event action을 정해진 순서로 실행합니다. -7. transaction에서 dirty state, turn queue, log, event와 checkpoint를 flush합니다. +7. PostgreSQL transaction에서 dirty state, turn queue, log와 event를 flush하고, + 같은 `EngineStateManager` 경계에서 world checkpoint를 확정합니다. Transaction 실패 시 `EngineStateManager`가 in-memory snapshot을 복원합니다. Lease를 잃은 process는 fencing 검사에서 commit하지 못합니다. +`InMemoryTurnStateStore`는 checkpoint를 별도로 복제하지 않고 rollback 대상인 +`InMemoryTurnWorld`에서 읽습니다. 따라서 flush 실패 뒤 다음 run도 복원된 +checkpoint에서 시작합니다. ## 저장 위치 @@ -70,12 +74,15 @@ Lease를 잃은 process는 fencing 검사에서 commit하지 못합니다. | 예약 명령과 revision | `GeneralTurn*`, `NationTurn*` | | 내구성 입력 | `InputEvent` | | daemon 소유권 | `TurnDaemonLease` | -| checkpoint와 calendar meta | `WorldState` | +| calendar meta | `WorldState` | +| 부분 run checkpoint | `InMemoryTurnWorld` snapshot | | 사용자 출력 | `LogEntry`, message·board 관련 model | | fan-out | Redis/SSE | Redis notification 실패는 이미 commit된 PostgreSQL mutation을 되돌리지 않습니다. 재연결 client는 DB 조회로 상태를 복구합니다. +부분 run checkpoint 자체는 DB row가 아닙니다. 재시작 시에는 이미 commit된 +`General.turnTime`과 `WorldState` calendar가 처리 완료 범위를 결정합니다. ## RNG