276 lines
7.3 KiB
TypeScript
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;
|
|
}
|