fix(turn): roll back checkpoint with world state
This commit is contained in:
@@ -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 } };
|
||||||
|
|||||||
@@ -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를 덮어쓰지 않게 합니다.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user