Add atomic task deduplication with partial unique index

- Add partial unique index for active resource-scoped tasks
- Implement createTaskRunRecordAtomic for race-free task creation
- Update findOrEnqueueTask to use atomic insert first
- Add tests for concurrent task creation deduplication
This commit is contained in:
2026-03-15 14:40:38 -04:00
parent a7f7be50b4
commit ed4420b8db
5 changed files with 225 additions and 6 deletions

View File

@@ -153,6 +153,83 @@ export async function createTaskRunRecord(input: CreateTaskInput) {
});
}
export type AtomicCreateResult =
| { task: Task; created: true }
| { task: null; created: false };
const SQLITE_CONSTRAINT_UNIQUE = 2067;
async function attemptAtomicInsert(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
input: CreateTaskInput,
now: string
): Promise<{ task: Task; created: true } | null> {
try {
const [row] = await tx
.insert(taskRun)
.values({
id: input.id,
user_id: input.user_id,
task_type: input.task_type,
status: 'queued',
stage: 'queued',
stage_detail: null,
stage_context: null,
resource_key: input.resource_key ?? null,
notification_read_at: null,
notification_silenced_at: null,
priority: input.priority,
payload: input.payload,
result: null,
error: null,
attempts: 0,
max_attempts: input.max_attempts,
workflow_run_id: null,
created_at: now,
updated_at: now,
finished_at: null
})
.returning();
if (!row) {
return null;
}
await insertTaskStageEvent(tx, {
task_id: row.id,
user_id: row.user_id,
stage: row.stage as TaskStage,
stage_detail: row.stage_detail,
stage_context: row.stage_context ?? null,
status: row.status,
created_at: now
});
return { task: toTask(row), created: true };
} catch (error) {
const sqliteError = error as { code?: number; message?: string };
if (
sqliteError.code === SQLITE_CONSTRAINT_UNIQUE ||
sqliteError.message?.includes('UNIQUE constraint failed')
) {
return null;
}
throw error;
}
}
export async function createTaskRunRecordAtomic(input: CreateTaskInput): Promise<AtomicCreateResult> {
const now = new Date().toISOString();
return await db.transaction(async (tx) => {
const result = await attemptAtomicInsert(tx, input, now);
if (result) {
return result;
}
return { task: null, created: false };
});
}
export async function setTaskWorkflowRunId(taskId: string, workflowRunId: string) {
await db
.update(taskRun)