feat: port scenario 903 select-pool flow

This commit is contained in:
2026-07-30 23:27:59 +00:00
parent 9bd057456b
commit 115218ded8
80 changed files with 6859 additions and 48 deletions
@@ -17,6 +17,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
private readonly localQueue: TurnDaemonCommand[] = [];
private readonly workerId = randomUUID();
private readonly leaseDurationMs = 60_000;
private readonly maxAttempts = 3;
constructor(private readonly db: GamePrismaClient) {}
@@ -62,6 +63,48 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
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) => {
@@ -132,8 +175,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
}
private async complete(requestId: string, result: unknown): Promise<void> {
await this.db.inputEvent.update({
where: { requestId },
const completed = await this.db.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
},
data: {
status: 'SUCCEEDED',
result: asJson(result),
@@ -143,6 +191,24 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
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> {
@@ -310,6 +310,17 @@ export class TurnDaemonLifecycle {
this.status.paused = true;
this.errorPaused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown command error.';
if (command.requestId && this.commandResponder?.publishCommandError) {
try {
await this.commandResponder.publishCommandError(command.requestId, error);
} catch (reportError) {
const reportMessage =
reportError instanceof Error
? reportError.message
: 'Unknown command failure reporting error.';
this.status.lastError = `${this.status.lastError} (failure report: ${reportMessage})`;
}
}
await this.hooks?.onRunError?.(error);
return;
}
+1
View File
@@ -33,6 +33,7 @@ export interface TurnDaemonCommandExecutionContext {
export interface TurnDaemonCommandResponder {
publishStatus(requestId: string, status: TurnDaemonStatus): Promise<void>;
publishCommandResult(requestId: string, result: TurnDaemonCommandResult): Promise<void>;
publishCommandError?(requestId: string, error: unknown): Promise<void>;
}
export type { Clock } from '@sammo-ts/common';