feat: implement input event system with durable command handling
- Introduced InputEvent model with status tracking (PENDING, PROCESSING, SUCCEEDED, FAILED) and unique request IDs. - Added DatabaseTurnDaemonTransport for sending commands and handling idempotency. - Implemented executeInputEvent function to manage input event lifecycle and error handling. - Created DatabaseTurnDaemonCommandQueue for managing command processing and lease recovery. - Enhanced turn daemon lifecycle to support atomic command execution and error recovery. - Added tests for input event atomicity, command queuing, and error handling scenarios.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
|
||||
import type {
|
||||
TurnDaemonCommand,
|
||||
TurnDaemonCommandResponder,
|
||||
TurnDaemonCommandResult,
|
||||
TurnDaemonControlQueue,
|
||||
TurnDaemonStatus,
|
||||
} from './types.js';
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
|
||||
private readonly localQueue: TurnDaemonCommand[] = [];
|
||||
private readonly workerId = randomUUID();
|
||||
private readonly leaseDurationMs = 60_000;
|
||||
|
||||
constructor(private readonly db: GamePrismaClient) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.recoverExpiredLeases();
|
||||
}
|
||||
|
||||
enqueue(command: TurnDaemonCommand): void {
|
||||
this.localQueue.push(command);
|
||||
}
|
||||
|
||||
async drain(): Promise<TurnDaemonCommand[]> {
|
||||
const local = this.localQueue.splice(0, this.localQueue.length);
|
||||
const remote = await this.claimPending();
|
||||
return local.concat(remote);
|
||||
}
|
||||
|
||||
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||
while (deadlineMs === null || Date.now() < deadlineMs) {
|
||||
const local = this.localQueue.shift();
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
const remote = await this.claimPending(1);
|
||||
if (remote[0]) {
|
||||
return remote[0];
|
||||
}
|
||||
const remaining = deadlineMs === null ? 100 : Math.max(1, Math.min(100, deadlineMs - Date.now()));
|
||||
await delay(remaining);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getDepth(): number {
|
||||
return this.localQueue.length;
|
||||
}
|
||||
|
||||
async publishStatus(requestId: string, status: TurnDaemonStatus): Promise<void> {
|
||||
await this.complete(requestId, { status });
|
||||
}
|
||||
|
||||
async publishCommandResult(requestId: string, result: TurnDaemonCommandResult): Promise<void> {
|
||||
await this.complete(requestId, result);
|
||||
}
|
||||
|
||||
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
|
||||
await this.recoverExpiredLeases();
|
||||
return this.db.$transaction(async (transaction) => {
|
||||
const rows = await transaction.$queryRaw<
|
||||
Array<{
|
||||
sequence: bigint;
|
||||
requestId: string;
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
createdAt: Date;
|
||||
}>
|
||||
>(GamePrisma.sql`
|
||||
SELECT
|
||||
"sequence",
|
||||
"request_id" AS "requestId",
|
||||
"event_type" AS "eventType",
|
||||
"payload",
|
||||
"created_at" AS "createdAt"
|
||||
FROM "input_event"
|
||||
WHERE "target" = 'ENGINE'::"InputEventTarget"
|
||||
AND "status" = 'PENDING'::"InputEventStatus"
|
||||
ORDER BY "sequence" ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
await transaction.inputEvent.updateMany({
|
||||
where: {
|
||||
sequence: { in: rows.map((row) => row.sequence) },
|
||||
target: 'ENGINE',
|
||||
status: 'PENDING',
|
||||
},
|
||||
data: {
|
||||
status: 'PROCESSING',
|
||||
processingAt: new Date(),
|
||||
lockedBy: this.workerId,
|
||||
leaseUntil: new Date(Date.now() + this.leaseDurationMs),
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const commands: TurnDaemonCommand[] = [];
|
||||
for (const row of rows) {
|
||||
const command = normalizeTurnDaemonCommand({
|
||||
requestId: row.requestId,
|
||||
sentAt: row.createdAt.toISOString(),
|
||||
command: row.payload as TurnDaemonCommand,
|
||||
});
|
||||
if (!command) {
|
||||
await transaction.inputEvent.update({
|
||||
where: { sequence: row.sequence },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
error: `Invalid command payload for ${row.eventType}`,
|
||||
completedAt: new Date(),
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
commands.push(command);
|
||||
}
|
||||
return commands;
|
||||
});
|
||||
}
|
||||
|
||||
private async complete(requestId: string, result: unknown): Promise<void> {
|
||||
await this.db.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
result: asJson(result),
|
||||
completedAt: new Date(),
|
||||
error: null,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async recoverExpiredLeases(): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.db.inputEvent.updateMany({
|
||||
where: {
|
||||
target: 'ENGINE',
|
||||
status: 'PROCESSING',
|
||||
leaseUntil: { lt: now },
|
||||
},
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
processingAt: null,
|
||||
lockedBy: null,
|
||||
leaseUntil: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
TurnDaemonCommandHandler,
|
||||
TurnDaemonCommandResponder,
|
||||
TurnDaemonCommandResult,
|
||||
TurnDaemonCommandExecutionContext,
|
||||
} from './types.js';
|
||||
|
||||
type PendingRun = {
|
||||
@@ -21,6 +22,12 @@ type PendingRun = {
|
||||
budget?: TurnRunBudget;
|
||||
};
|
||||
|
||||
type TurnDaemonControlCommand = Extract<
|
||||
TurnDaemonCommand,
|
||||
{ type: 'pause' | 'resume' | 'shutdown' | 'getStatus' | 'run' }
|
||||
>;
|
||||
type TurnDaemonMutationCommand = Exclude<TurnDaemonCommand, TurnDaemonControlCommand>;
|
||||
|
||||
export interface TurnDaemonLifecycleOptions {
|
||||
profile: string;
|
||||
defaultBudget: TurnRunBudget;
|
||||
@@ -217,15 +224,24 @@ export class TurnDaemonLifecycle {
|
||||
this.manualPaused = true;
|
||||
this.status.paused = true;
|
||||
this.status.state = 'paused';
|
||||
if (command.requestId) {
|
||||
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
|
||||
}
|
||||
return;
|
||||
case 'resume':
|
||||
this.manualPaused = false;
|
||||
this.status.paused = this.errorPaused;
|
||||
this.status.state = 'idle';
|
||||
if (command.requestId) {
|
||||
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
|
||||
}
|
||||
return;
|
||||
case 'shutdown':
|
||||
this.status.state = 'stopping';
|
||||
this.stopping = true;
|
||||
if (command.requestId) {
|
||||
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
|
||||
}
|
||||
return;
|
||||
case 'getStatus': {
|
||||
if (command.requestId) {
|
||||
@@ -240,79 +256,61 @@ export class TurnDaemonLifecycle {
|
||||
budget: command.budget,
|
||||
};
|
||||
this.status.pendingReason = command.reason;
|
||||
if (command.requestId) {
|
||||
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
|
||||
}
|
||||
return;
|
||||
case 'troopJoin':
|
||||
case 'troopExit':
|
||||
case 'dieOnPrestart':
|
||||
case 'buildNationCandidate':
|
||||
case 'instantRetreat':
|
||||
case 'vacation':
|
||||
case 'setMySetting':
|
||||
case 'dropItem':
|
||||
case 'auctionFinalize':
|
||||
case 'changePermission':
|
||||
case 'kick':
|
||||
case 'appoint':
|
||||
default:
|
||||
await this.handleMutationCommand(command);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMutationCommand(
|
||||
command: Extract<
|
||||
TurnDaemonCommand,
|
||||
| { type: 'troopJoin' }
|
||||
| { type: 'troopExit' }
|
||||
| { type: 'dieOnPrestart' }
|
||||
| { type: 'buildNationCandidate' }
|
||||
| { type: 'instantRetreat' }
|
||||
| { type: 'vacation' }
|
||||
| { type: 'setMySetting' }
|
||||
| { type: 'dropItem' }
|
||||
| { type: 'auctionFinalize' }
|
||||
| { type: 'changePermission' }
|
||||
| { type: 'kick' }
|
||||
| { type: 'appoint' }
|
||||
>
|
||||
): Promise<void> {
|
||||
let result: TurnDaemonCommandResult | null = null;
|
||||
try {
|
||||
result = this.commandHandler ? await this.commandHandler.handle(command) : null;
|
||||
if (!result) {
|
||||
if (command.type === 'auctionFinalize') {
|
||||
result = {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId: command.auctionId,
|
||||
reason: '턴 데몬이 경매 확정을 처리할 수 없습니다.',
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
type: command.type,
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
|
||||
...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}),
|
||||
} as TurnDaemonCommandResult;
|
||||
private async handleMutationCommand(command: TurnDaemonMutationCommand): Promise<void> {
|
||||
let result: TurnDaemonCommandResult;
|
||||
let committedByExecutionBoundary = false;
|
||||
const executeHandler = async (
|
||||
context?: TurnDaemonCommandExecutionContext
|
||||
): Promise<TurnDaemonCommandResult> => {
|
||||
const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null;
|
||||
return (
|
||||
handled ?? {
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
|
||||
}
|
||||
);
|
||||
};
|
||||
try {
|
||||
if (command.requestId && this.hooks?.executeCommand) {
|
||||
result = await this.hooks.executeCommand(command.requestId, executeHandler);
|
||||
committedByExecutionBoundary = true;
|
||||
} else {
|
||||
result = await executeHandler();
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : 'Unknown command error.';
|
||||
if (command.type === 'auctionFinalize') {
|
||||
result = {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId: command.auctionId,
|
||||
reason,
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
type: command.type,
|
||||
ok: false,
|
||||
generalId: command.generalId,
|
||||
reason,
|
||||
...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}),
|
||||
} as TurnDaemonCommandResult;
|
||||
// A handler may already have changed the in-memory world. Do not commit
|
||||
// either those changes or the inbox completion marker after an exception.
|
||||
// Pausing forces a reload/retry instead of acknowledging a partial event.
|
||||
this.status.state = 'paused';
|
||||
this.status.paused = true;
|
||||
this.errorPaused = true;
|
||||
this.status.lastError = error instanceof Error ? error.message : 'Unknown command error.';
|
||||
await this.hooks?.onRunError?.(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!committedByExecutionBoundary && command.requestId && this.hooks?.commitCommand) {
|
||||
try {
|
||||
await this.hooks.commitCommand(command.requestId, result);
|
||||
} catch (error) {
|
||||
this.status.state = 'paused';
|
||||
this.status.paused = true;
|
||||
this.errorPaused = true;
|
||||
this.status.lastError = error instanceof Error ? error.message : 'Unknown input event commit error.';
|
||||
await this.hooks.onRunError?.(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,12 +345,26 @@ export class TurnDaemonLifecycle {
|
||||
}
|
||||
|
||||
this.status.state = 'flushing';
|
||||
await this.stateStore.saveLastTurnTime(new Date(result.lastTurnTime));
|
||||
await this.stateStore.saveCheckpoint(result.checkpoint);
|
||||
await this.hooks?.flushChanges?.(result);
|
||||
await this.hooks?.publishEvents?.(result);
|
||||
try {
|
||||
await this.stateStore.saveLastTurnTime(new Date(result.lastTurnTime));
|
||||
await this.stateStore.saveCheckpoint(result.checkpoint);
|
||||
await this.hooks?.flushChanges?.(result);
|
||||
} catch (error) {
|
||||
this.status.state = 'paused';
|
||||
this.status.paused = true;
|
||||
this.errorPaused = true;
|
||||
this.status.lastError = error instanceof Error ? error.message : 'Unknown turn flush error.';
|
||||
await this.hooks?.onRunError?.(error);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.applyRunResult(result, startMs);
|
||||
this.status.state = 'idle';
|
||||
try {
|
||||
await this.hooks?.publishEvents?.(result);
|
||||
} catch (error) {
|
||||
this.status.lastError = error instanceof Error ? error.message : 'Unknown event publication error.';
|
||||
}
|
||||
}
|
||||
|
||||
private async applyRunResult(result: TurnRunResult, startMs: number): Promise<void> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
TurnRunBudget,
|
||||
TurnRunResult,
|
||||
} from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
export type {
|
||||
RunReason,
|
||||
@@ -19,7 +20,14 @@ export type {
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
export interface TurnDaemonCommandHandler {
|
||||
handle(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null>;
|
||||
handle(
|
||||
command: TurnDaemonCommand,
|
||||
context?: TurnDaemonCommandExecutionContext
|
||||
): Promise<TurnDaemonCommandResult | null>;
|
||||
}
|
||||
|
||||
export interface TurnDaemonCommandExecutionContext {
|
||||
db?: GamePrisma.TransactionClient;
|
||||
}
|
||||
|
||||
export interface TurnDaemonCommandResponder {
|
||||
@@ -53,6 +61,11 @@ export interface TurnDaemonControlQueue {
|
||||
|
||||
export interface TurnDaemonHooks {
|
||||
flushChanges?(result: TurnRunResult): Promise<void>;
|
||||
commitCommand?(requestId: string, result: TurnDaemonCommandResult): Promise<void>;
|
||||
executeCommand?(
|
||||
requestId: string,
|
||||
execute: (context: TurnDaemonCommandExecutionContext) => Promise<TurnDaemonCommandResult>
|
||||
): Promise<TurnDaemonCommandResult>;
|
||||
publishEvents?(result: TurnRunResult): Promise<void>;
|
||||
onRunError?(error: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user