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,86 @@
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from './context.js';
|
||||
|
||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||
|
||||
export class DuplicateInputEventError extends Error {
|
||||
constructor(readonly requestId: string) {
|
||||
super(`Input event ${requestId} was already accepted.`);
|
||||
this.name = 'DuplicateInputEventError';
|
||||
}
|
||||
}
|
||||
|
||||
export const executeInputEvent = async <T>(options: {
|
||||
db: DatabaseClient;
|
||||
requestId: string;
|
||||
eventType: string;
|
||||
actorUserId?: string | null;
|
||||
execute(db: DatabaseClient): Promise<T>;
|
||||
}): Promise<T> => {
|
||||
const { db, requestId, eventType, actorUserId, execute } = options;
|
||||
if (!db.$transaction) {
|
||||
return execute(db);
|
||||
}
|
||||
|
||||
const processingAt = new Date();
|
||||
try {
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'API',
|
||||
eventType,
|
||||
payload: asJson({}),
|
||||
actorUserId: actorUserId ?? null,
|
||||
status: 'PROCESSING',
|
||||
processingAt,
|
||||
attempts: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const isUniqueConflict =
|
||||
typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
|
||||
if (!isUniqueConflict) {
|
||||
throw error;
|
||||
}
|
||||
const claimedRetry = await db.inputEvent.updateMany({
|
||||
where: { requestId, status: 'FAILED' },
|
||||
data: {
|
||||
status: 'PROCESSING',
|
||||
error: null,
|
||||
processingAt,
|
||||
completedAt: null,
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (claimedRetry.count === 0) {
|
||||
throw new DuplicateInputEventError(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await db.$transaction(async (transaction) => {
|
||||
const result = await execute(transaction);
|
||||
await transaction.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'SUCCEEDED',
|
||||
result: asJson({ ok: true }),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
|
||||
await db.inputEvent.update({
|
||||
where: { requestId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
error: message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user