ai-chatbot/lib/canvas/image-task-queue.ts
Your Name 2108d06715
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: support cross-provider image failover and workflow model settings
2026-04-27 20:10:48 +08:00

276 lines
7.3 KiB
TypeScript

import "server-only";
import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getSystemSetting } from "@/lib/db/queries";
import { canvasImageTask } from "@/lib/db/schema";
import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY,
normalizeCanvasImageTaskQueueConfig,
} from "./image-task-config";
import {
generateCanvasImage,
type CanvasGenerateImageRequest,
type CanvasGenerateImageResult,
} from "./generate-image";
type CanvasImageTaskStatus = "pending" | "running" | "succeeded" | "failed";
interface QueueState {
pending: string[];
pendingSet: Set<string>;
running: Set<string>;
draining: boolean;
}
const CONCURRENCY_CONFIG_CACHE_MS = 5 * 1000;
const STALE_RUNNING_MS = 5 * 60 * 1000;
const queueStateKey = "__canvas_image_task_queue_state__";
let cachedConcurrency:
| {
expiresAt: number;
value: number;
}
| null = null;
async function getQueueConcurrency(): Promise<number> {
const now = Date.now();
if (cachedConcurrency && cachedConcurrency.expiresAt > now) {
return cachedConcurrency.value;
}
const envConcurrency = process.env.CANVAS_IMAGE_TASK_CONCURRENCY;
let value = normalizeCanvasImageTaskQueueConfig(undefined, {
envConcurrency,
}).concurrency;
try {
const setting = await getSystemSetting({
key: CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
});
if (setting?.value !== undefined && setting.value !== null) {
value = normalizeCanvasImageTaskQueueConfig(setting.value, {
envConcurrency,
}).concurrency;
}
} catch (error) {
console.warn("[Canvas Image Task Queue] Failed to load DB concurrency config, using fallback", {
error: error instanceof Error ? error.message : String(error),
});
}
cachedConcurrency = {
expiresAt: now + CONCURRENCY_CONFIG_CACHE_MS,
value: value || DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY,
};
return cachedConcurrency.value;
}
function getQueueState(): QueueState {
const globalObj = globalThis as typeof globalThis & {
[queueStateKey]?: QueueState;
};
if (!globalObj[queueStateKey]) {
globalObj[queueStateKey] = {
pending: [],
pendingSet: new Set<string>(),
running: new Set<string>(),
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<string | null> {
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 = await 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<string, unknown>,
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;
}