ai-chatbot/lib/canvas/image-task-queue.ts
Your Name 9073c3a389
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: raise per-model image queue concurrency
2026-04-30 23:54:06 +08:00

369 lines
10 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 PendingTaskEntry {
taskId: string;
modelKey: string;
}
interface QueueState {
pending: PendingTaskEntry[];
pendingSet: Set<string>;
running: Map<string, string>;
runningByModel: Map<string, Set<string>>;
draining: boolean;
}
const CONCURRENCY_CONFIG_CACHE_MS = 5 * 1000;
const STALE_RUNNING_MS = 5 * 60 * 1000;
const PENDING_LOOKAHEAD_LIMIT = 100;
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 Map<string, string>(),
runningByModel: new Map<string, Set<string>>(),
draining: false,
};
}
return globalObj[queueStateKey] as QueueState;
}
function normalizeTaskModelKey(request: unknown) {
if (!request || typeof request !== "object" || Array.isArray(request)) {
return "__default__";
}
const rawModel = (request as Record<string, unknown>).model;
if (typeof rawModel !== "string") {
return "__default__";
}
const normalizedModel = rawModel.trim();
return normalizedModel || "__default__";
}
async function getTaskModelKey(taskId: string) {
const rows = await db
.select({ request: canvasImageTask.request })
.from(canvasImageTask)
.where(eq(canvasImageTask.id, taskId))
.limit(1);
return normalizeTaskModelKey(rows[0]?.request);
}
function getRunningCountForModel(modelKey: string) {
const state = getQueueState();
return state.runningByModel.get(modelKey)?.size || 0;
}
function markRunningTask(taskId: string, modelKey: string) {
const state = getQueueState();
state.running.set(taskId, modelKey);
const tasks = state.runningByModel.get(modelKey) || new Set<string>();
tasks.add(taskId);
state.runningByModel.set(modelKey, tasks);
}
function releaseRunningTask(taskId: string) {
const state = getQueueState();
const modelKey = state.running.get(taskId);
if (!modelKey) {
return;
}
state.running.delete(taskId);
const tasks = state.runningByModel.get(modelKey);
if (!tasks) {
return;
}
tasks.delete(taskId);
if (tasks.size === 0) {
state.runningByModel.delete(modelKey);
}
}
async function enqueueLocalTask(taskId: string, modelKey?: string) {
const state = getQueueState();
if (!taskId || state.running.has(taskId) || state.pendingSet.has(taskId)) {
return;
}
const resolvedModelKey = modelKey || (await getTaskModelKey(taskId));
state.pending.push({
taskId,
modelKey: resolvedModelKey,
});
state.pendingSet.add(taskId);
}
function hasModelCapacity(modelKey: string, maxConcurrentPerModel: number) {
const state = getQueueState();
return getRunningCountForModel(modelKey) < maxConcurrentPerModel;
}
async function claimNextPendingTask(
maxConcurrentPerModel: number
): Promise<PendingTaskEntry | null> {
const state = getQueueState();
for (let index = 0; index < state.pending.length; index += 1) {
const entry = state.pending[index];
if (!entry) continue;
if (state.running.has(entry.taskId)) continue;
if (!hasModelCapacity(entry.modelKey, maxConcurrentPerModel)) continue;
state.pending.splice(index, 1);
state.pendingSet.delete(entry.taskId);
return entry;
}
const runningIds = Array.from(state.running.keys());
const rows = await db
.select({
id: canvasImageTask.id,
request: canvasImageTask.request,
})
.from(canvasImageTask)
.where(
runningIds.length > 0
? and(eq(canvasImageTask.status, "pending"), notInArray(canvasImageTask.id, runningIds))
: eq(canvasImageTask.status, "pending")
)
.orderBy(asc(canvasImageTask.createdAt))
.limit(PENDING_LOOKAHEAD_LIMIT);
for (const row of rows) {
if (!row?.id) continue;
if (state.pendingSet.has(row.id) || state.running.has(row.id)) continue;
const modelKey = normalizeTaskModelKey(row.request);
if (!hasModelCapacity(modelKey, maxConcurrentPerModel)) {
continue;
}
return {
taskId: row.id,
modelKey,
};
}
return null;
}
async function runSingleTask(entry: PendingTaskEntry) {
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, entry.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, entry.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, entry.taskId));
} finally {
releaseRunningTask(entry.taskId);
void drainCanvasImageTaskQueue();
}
}
export async function drainCanvasImageTaskQueue() {
const state = getQueueState();
if (state.draining) return;
state.draining = true;
try {
const maxConcurrentPerModel = await getQueueConcurrency();
while (true) {
const nextTask = await claimNextPendingTask(maxConcurrentPerModel);
if (!nextTask) break;
if (state.running.has(nextTask.taskId)) continue;
markRunningTask(nextTask.taskId, nextTask.modelKey);
void runSingleTask(nextTask);
}
} finally {
state.draining = false;
}
}
export async function enqueueCanvasImageTask(taskId: string) {
await 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)
)
);
await enqueueLocalTask(task.id, normalizeTaskModelKey(task.request));
await drainCanvasImageTaskQueue();
task.status = "pending" as CanvasImageTaskStatus;
task.startedAt = null;
task.completedAt = null;
}
}
if (task.status === "pending") {
await enqueueLocalTask(task.id, normalizeTaskModelKey(task.request));
await drainCanvasImageTaskQueue();
}
return task;
}