fix(turn): roll back checkpoint with world state

This commit is contained in:
2026-07-31 16:11:56 +00:00
parent b193b2a0f6
commit 885018c7fd
4 changed files with 58 additions and 9 deletions
@@ -4,7 +4,6 @@ import type { InMemoryTurnWorld } from './inMemoryWorld.js';
export class InMemoryTurnStateStore implements TurnStateStore { export class InMemoryTurnStateStore implements TurnStateStore {
// 인메모리 월드의 턴 상태를 TurnDaemonLifecycle에 제공한다. // 인메모리 월드의 턴 상태를 TurnDaemonLifecycle에 제공한다.
private readonly world: InMemoryTurnWorld; private readonly world: InMemoryTurnWorld;
private checkpoint?: TurnCheckpoint;
constructor(world: InMemoryTurnWorld) { constructor(world: InMemoryTurnWorld) {
this.world = world; this.world = world;
@@ -15,7 +14,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
} }
async loadNextGeneralTurnTime(): Promise<Date | null> { async loadNextGeneralTurnTime(): Promise<Date | null> {
return this.world.getNextGeneralTurnTime(this.checkpoint); return this.world.getNextGeneralTurnTime(this.world.getCheckpoint());
} }
async saveLastTurnTime(turnTime: Date): Promise<void> { async saveLastTurnTime(turnTime: Date): Promise<void> {
@@ -23,11 +22,10 @@ export class InMemoryTurnStateStore implements TurnStateStore {
} }
async loadCheckpoint(): Promise<TurnCheckpoint | undefined> { async loadCheckpoint(): Promise<TurnCheckpoint | undefined> {
return this.checkpoint; return this.world.getCheckpoint();
} }
async saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void> { async saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void> {
this.checkpoint = checkpoint;
this.world.setCheckpoint(checkpoint); this.world.setCheckpoint(checkpoint);
} }
} }
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { EngineStateManager } from '../src/turn/engineStateManager.js'; import { EngineStateManager } from '../src/turn/engineStateManager.js';
import { InMemoryTurnStateStore } from '../src/turn/inMemoryStateStore.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
@@ -181,6 +182,46 @@ describe('EngineStateManager', () => {
expect(world.listEvents()).toEqual([]); 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 () => { it('creates reusable test savepoints without sharing mutable inspection data', async () => {
const manager = new EngineStateManager(); const manager = new EngineStateManager();
let state = { nested: { value: 1 } }; let state = { nested: { value: 1 } };
+5 -2
View File
@@ -38,7 +38,8 @@ artifact를 만들며 `Pm2ProcessManager`가 profile process를 조정합니다.
`GatewayRuntimeAction`으로 접수합니다. Profile별 `REQUESTED`/`PARTIAL` `GatewayRuntimeAction`으로 접수합니다. Profile별 `REQUESTED`/`PARTIAL`
DB partial unique index로 한 건만 허용합니다. Turn daemon은 자신의 lease를 DB partial unique index로 한 건만 허용합니다. Turn daemon은 자신의 lease를
확인한 뒤 action ID로 결정적인 `InputEvent`를 만들고, world·전 장수·OPEN 확인한 뒤 action ID로 결정적인 `InputEvent`를 만들고, world·전 장수·OPEN
경매·checkpoint를 같은 PostgreSQL transaction에서 이동합니다. Commit 뒤 경매 같은 PostgreSQL transaction에서, checkpoint는 이를 감싼 동일
`EngineStateManager` snapshot 경계에서 이동합니다. Commit 뒤
경매 timer와 활성 토너먼트 시각을 Redis에 idempotent하게 투영합니다. 경매 timer와 활성 토너먼트 시각을 Redis에 idempotent하게 투영합니다.
Redis 단계가 실패하면 action은 `PARTIAL`과 backoff 상태로 남고 DB 시간은 Redis 단계가 실패하면 action은 `PARTIAL`과 backoff 상태로 남고 DB 시간은
다시 이동하지 않습니다. 다시 이동하지 않습니다.
@@ -89,13 +90,15 @@ lease/fencing 확인
-> EngineStateManager transaction -> EngineStateManager transaction
-> world/general/nation/city/turn/log flush -> world/general/nation/city/turn/log flush
-> input_event result/status 갱신 -> input_event result/status 갱신
-> checkpoint 갱신 -> in-memory world checkpoint 갱신
-> commit -> commit
-> realtime 알림 -> realtime 알림
``` ```
Transaction 실패 시 in-memory snapshot을 복원하고 event는 재시도 가능한 실패 Transaction 실패 시 in-memory snapshot을 복원하고 event는 재시도 가능한 실패
상태로 남깁니다. Lease 소유권을 잃은 daemon은 flush를 확정하지 않습니다. 상태로 남깁니다. Lease 소유권을 잃은 daemon은 flush를 확정하지 않습니다.
Checkpoint의 단일 소유자는 `InMemoryTurnWorld`이며 state store는 이를
위임 조회합니다. 별도 checkpoint 복사본이 snapshot보다 앞서 나가지 않습니다.
예약 턴은 revision/CAS와 lease를 사용합니다. API의 편집과 daemon의 실행이 예약 턴은 revision/CAS와 lease를 사용합니다. API의 편집과 daemon의 실행이
경합해도 오래된 revision이 새 queue를 덮어쓰지 않게 합니다. 경합해도 오래된 revision이 새 queue를 덮어쓰지 않게 합니다.
+10 -3
View File
@@ -38,7 +38,7 @@ request
-> lease owner claim -> lease owner claim
-> in-memory world mutation -> in-memory world mutation
-> EngineStateManager transaction flush -> EngineStateManager transaction flush
-> event 결과와 checkpoint commit -> PostgreSQL event 결과 commit과 in-memory world checkpoint 확정
-> SSE/realtime -> SSE/realtime
``` ```
@@ -57,10 +57,14 @@ request
4. action module과 command handler가 state patch, log, message를 만듭니다. 4. action module과 command handler가 state patch, log, message를 만듭니다.
5. world에 patch를 적용하고 dirty entity를 기록합니다. 5. world에 patch를 적용하고 dirty entity를 기록합니다.
6. 월 경계를 지났으면 scenario event action을 정해진 순서로 실행합니다. 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을 복원합니다. Transaction 실패 시 `EngineStateManager`가 in-memory snapshot을 복원합니다.
Lease를 잃은 process는 fencing 검사에서 commit하지 못합니다. Lease를 잃은 process는 fencing 검사에서 commit하지 못합니다.
`InMemoryTurnStateStore`는 checkpoint를 별도로 복제하지 않고 rollback 대상인
`InMemoryTurnWorld`에서 읽습니다. 따라서 flush 실패 뒤 다음 run도 복원된
checkpoint에서 시작합니다.
## 저장 위치 ## 저장 위치
@@ -70,12 +74,15 @@ Lease를 잃은 process는 fencing 검사에서 commit하지 못합니다.
| 예약 명령과 revision | `GeneralTurn*`, `NationTurn*` | | 예약 명령과 revision | `GeneralTurn*`, `NationTurn*` |
| 내구성 입력 | `InputEvent` | | 내구성 입력 | `InputEvent` |
| daemon 소유권 | `TurnDaemonLease` | | daemon 소유권 | `TurnDaemonLease` |
| checkpoint와 calendar meta | `WorldState` | | calendar meta | `WorldState` |
| 부분 run checkpoint | `InMemoryTurnWorld` snapshot |
| 사용자 출력 | `LogEntry`, message·board 관련 model | | 사용자 출력 | `LogEntry`, message·board 관련 model |
| fan-out | Redis/SSE | | fan-out | Redis/SSE |
Redis notification 실패는 이미 commit된 PostgreSQL mutation을 되돌리지 Redis notification 실패는 이미 commit된 PostgreSQL mutation을 되돌리지
않습니다. 재연결 client는 DB 조회로 상태를 복구합니다. 않습니다. 재연결 client는 DB 조회로 상태를 복구합니다.
부분 run checkpoint 자체는 DB row가 아닙니다. 재시작 시에는 이미 commit된
`General.turnTime``WorldState` calendar가 처리 완료 범위를 결정합니다.
## RNG ## RNG