231 lines
8.0 KiB
TypeScript
231 lines
8.0 KiB
TypeScript
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;
|
|
private readonly maxAttempts = 3;
|
|
|
|
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);
|
|
}
|
|
|
|
async publishCommandError(requestId: string, error: unknown): Promise<void> {
|
|
const message = error instanceof Error ? error.message : 'Unknown command error.';
|
|
await this.db.$transaction(async (transaction) => {
|
|
const event = await transaction.inputEvent.findUnique({
|
|
where: { requestId },
|
|
select: {
|
|
status: true,
|
|
target: true,
|
|
lockedBy: true,
|
|
attempts: true,
|
|
},
|
|
});
|
|
if (
|
|
!event ||
|
|
event.target !== 'ENGINE' ||
|
|
event.status !== 'PROCESSING' ||
|
|
event.lockedBy !== this.workerId
|
|
) {
|
|
return;
|
|
}
|
|
const terminal = event.attempts >= this.maxAttempts;
|
|
await transaction.inputEvent.updateMany({
|
|
where: {
|
|
requestId,
|
|
target: 'ENGINE',
|
|
status: 'PROCESSING',
|
|
lockedBy: this.workerId,
|
|
attempts: event.attempts,
|
|
},
|
|
data: {
|
|
status: terminal ? 'FAILED' : 'PENDING',
|
|
processingAt: null,
|
|
lockedBy: null,
|
|
leaseUntil: null,
|
|
completedAt: terminal ? new Date() : null,
|
|
result: GamePrisma.DbNull,
|
|
error: message,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
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> {
|
|
const completed = await this.db.inputEvent.updateMany({
|
|
where: {
|
|
requestId,
|
|
target: 'ENGINE',
|
|
status: 'PROCESSING',
|
|
lockedBy: this.workerId,
|
|
},
|
|
data: {
|
|
status: 'SUCCEEDED',
|
|
result: asJson(result),
|
|
completedAt: new Date(),
|
|
error: null,
|
|
lockedBy: null,
|
|
leaseUntil: null,
|
|
},
|
|
});
|
|
if (completed.count > 0) {
|
|
return;
|
|
}
|
|
// Database hooks commit mutation results atomically with game state and
|
|
// may already have set SUCCEEDED. Only the worker that still owns the
|
|
// lease may clear that committed row's claim metadata.
|
|
await this.db.inputEvent.updateMany({
|
|
where: {
|
|
requestId,
|
|
target: 'ENGINE',
|
|
status: 'SUCCEEDED',
|
|
lockedBy: this.workerId,
|
|
},
|
|
data: {
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
}
|