import "server-only"; import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { canvasImageTask } from "@/lib/db/schema"; import { generateCanvasImage, type CanvasGenerateImageRequest, type CanvasGenerateImageResult, } from "./generate-image"; type CanvasImageTaskStatus = "pending" | "running" | "succeeded" | "failed"; interface QueueState { pending: string[]; pendingSet: Set; running: Set; draining: boolean; } const DEFAULT_CONCURRENCY = 1; const STALE_RUNNING_MS = 5 * 60 * 1000; const queueStateKey = "__canvas_image_task_queue_state__"; function getQueueConcurrency(): number { const raw = Number.parseInt(process.env.CANVAS_IMAGE_TASK_CONCURRENCY || "", 10); if (Number.isFinite(raw) && raw > 0) { return Math.min(raw, 4); } return DEFAULT_CONCURRENCY; } function getQueueState(): QueueState { const globalObj = globalThis as typeof globalThis & { [queueStateKey]?: QueueState; }; if (!globalObj[queueStateKey]) { globalObj[queueStateKey] = { pending: [], pendingSet: new Set(), running: new Set(), draining: false, }; } return globalObj[queueStateKey] as QueueState; } function enqueueLocalTask(taskId: string) { const state = getQueueState(); if (!taskId || state.running.has(taskId) || state.pendingSet.has(taskId)) { return; } state.pending.push(taskId); state.pendingSet.add(taskId); } async function claimNextPendingTask(): Promise { const state = getQueueState(); while (state.pending.length > 0) { const taskId = state.pending.shift(); if (!taskId) continue; state.pendingSet.delete(taskId); if (!state.running.has(taskId)) { return taskId; } } const runningIds = Array.from(state.running); const whereCondition = runningIds.length > 0 ? and(eq(canvasImageTask.status, "pending"), notInArray(canvasImageTask.id, runningIds)) : eq(canvasImageTask.status, "pending"); const rows = await db .select({ id: canvasImageTask.id }) .from(canvasImageTask) .where(whereCondition) .orderBy(asc(canvasImageTask.createdAt)) .limit(1); return rows[0]?.id ?? null; } async function runSingleTask(taskId: string) { const state = getQueueState(); state.running.add(taskId); try { const [task] = await db .update(canvasImageTask) .set({ status: "running", startedAt: new Date(), updatedAt: new Date(), completedAt: null, error: null, attempts: sql`${canvasImageTask.attempts} + 1`, }) .where(and(eq(canvasImageTask.id, taskId), eq(canvasImageTask.status, "pending"))) .returning({ id: canvasImageTask.id, request: canvasImageTask.request, }); if (!task) { return; } const result = await generateCanvasImage(task.request as CanvasGenerateImageRequest); await db .update(canvasImageTask) .set({ status: "succeeded", result: result as CanvasGenerateImageResult, error: null, completedAt: new Date(), updatedAt: new Date(), }) .where(eq(canvasImageTask.id, taskId)); } catch (error) { await db .update(canvasImageTask) .set({ status: "failed", error: error instanceof Error ? error.message : String(error), completedAt: new Date(), updatedAt: new Date(), }) .where(eq(canvasImageTask.id, taskId)); } finally { state.running.delete(taskId); void drainCanvasImageTaskQueue(); } } export async function drainCanvasImageTaskQueue() { const state = getQueueState(); if (state.draining) return; state.draining = true; try { const maxConcurrent = getQueueConcurrency(); while (state.running.size < maxConcurrent) { const taskId = await claimNextPendingTask(); if (!taskId) break; if (state.running.has(taskId)) continue; void runSingleTask(taskId); } } finally { state.draining = false; } } export async function enqueueCanvasImageTask(taskId: string) { enqueueLocalTask(taskId); await drainCanvasImageTaskQueue(); } export async function createCanvasImageTask(params: { userId: string; request: CanvasGenerateImageRequest; }) { const [task] = await db .insert(canvasImageTask) .values({ userId: params.userId, status: "pending", request: params.request as unknown as Record, attempts: 0, createdAt: new Date(), updatedAt: new Date(), }) .returning({ id: canvasImageTask.id, status: canvasImageTask.status, createdAt: canvasImageTask.createdAt, }); return task; } export async function getCanvasImageTaskForUser(params: { taskId: string; userId: string; }) { const rows = await db .select({ id: canvasImageTask.id, status: canvasImageTask.status, request: canvasImageTask.request, result: canvasImageTask.result, error: canvasImageTask.error, attempts: canvasImageTask.attempts, startedAt: canvasImageTask.startedAt, completedAt: canvasImageTask.completedAt, createdAt: canvasImageTask.createdAt, updatedAt: canvasImageTask.updatedAt, }) .from(canvasImageTask) .where(and(eq(canvasImageTask.id, params.taskId), eq(canvasImageTask.userId, params.userId))) .limit(1); const task = rows[0]; if (!task) return null; if (task.status === "running" && task.startedAt) { const staleBefore = new Date(Date.now() - STALE_RUNNING_MS); if (task.startedAt < staleBefore) { await db .update(canvasImageTask) .set({ status: "pending", startedAt: null, completedAt: null, updatedAt: new Date(), }) .where( and( eq(canvasImageTask.id, task.id), eq(canvasImageTask.status, "running"), lt(canvasImageTask.startedAt, staleBefore) ) ); enqueueLocalTask(task.id); await drainCanvasImageTaskQueue(); task.status = "pending" as CanvasImageTaskStatus; task.startedAt = null; task.completedAt = null; } } if (task.status === "pending") { enqueueLocalTask(task.id); await drainCanvasImageTaskQueue(); } return task; }