feat: unify site-wide image concurrency
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-01 00:17:25 +08:00
parent 9073c3a389
commit e7cf10bb28
4 changed files with 233 additions and 60 deletions

View File

@ -5110,10 +5110,10 @@ export function AdminSettingsClient() {
normalizeCanvasImageTaskQueueConfig(payload?.setting?.value || normalized)
);
await mutateCanvasImageTaskQueueConfig();
toast.success("生图任务单模型并发配置保存成功");
toast.success("全站生图单模型并发配置保存成功");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存生图任务单模型并发配置失败"
error instanceof Error ? error.message : "保存全站生图单模型并发配置失败"
);
}
};
@ -20149,16 +20149,16 @@ export function AdminSettingsClient() {
</CardContent>
</Card>
{/* 生图任务单模型并发 */}
{/* 全站生图单模型并发 */}
<Card>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2 text-xl">
<ImageIcon className="h-5 w-5" />
生图任务单模型并发
全站生图单模型并发
</CardTitle>
<CardDescription>
控制风格化/画布异步生图队列中,同一个模型允许同时运行的任务数。配置保存到数据库,运行队列会自动读取,无需重启服务。
控制全站生图链路中,同一个模型允许同时运行的任务数。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
</CardDescription>
</div>
<LocalAiSectionToggleButton
@ -20176,9 +20176,9 @@ export function AdminSettingsClient() {
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="font-medium">生效范围</div>
<div className="mt-2 space-y-1 text-xs leading-5 text-amber-800">
<p>• 影响通过异步队列提交的风格化/画布生图任务。</p>
<p>• 多用户共享队列,但会按模型分别计数,不同模型之间不再互相阻塞。</p>
<p>• 当前配置的是单模型并发,不是全站总并发。</p>
<p>• 影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。</p>
<p>• 多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。</p>
<p>• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。</p>
<p>• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key 池继续细分。</p>
</div>
</div>

View File

@ -12,6 +12,10 @@ import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
import { createKrea } from "@/lib/ai/providers/krea";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import {
acquireCanvasImageModelSlot,
normalizeCanvasImageModelKey,
} from "./image-concurrency";
export interface CanvasGenerateImageRequest {
prompt: string;
@ -445,6 +449,19 @@ export async function generateCanvasImage(
const targetImageCount = Math.max(1, imageCount);
const collectedImages: ParsedImage[] = [];
const maxBatches = Math.max(1, Math.ceil(targetImageCount / providerBatchLimit));
const resolvedModelKey = normalizeCanvasImageModelKey(
selectedModel.model.modelId || selectedModel.model.id || model
);
const slot = await acquireCanvasImageModelSlot({
modelKey: resolvedModelKey,
debugLabel,
debugMeta: {
selectedModelId: selectedModel.model.modelId,
selectedModelRef: selectedModel.model.id,
providerType: provider.type,
...debugMeta,
},
});
try {
for (let batchIndex = 0; batchIndex < maxBatches; batchIndex += 1) {
@ -490,6 +507,8 @@ export async function generateCanvasImage(
console.info("[Canvas Generate Image] Completed:", {
debugLabel,
elapsedMs: Date.now() - startedAt,
waitMs: slot.waitMs,
singleModelConcurrency: slot.maxConcurrent,
finalImageCount: finalImages.length,
providerType: provider.type,
selectedModelRef: selectedModel.model.id,
@ -507,6 +526,8 @@ export async function generateCanvasImage(
console.error("[Canvas Generate Image] Failed:", {
debugLabel,
elapsedMs: Date.now() - startedAt,
waitMs: slot.waitMs,
singleModelConcurrency: slot.maxConcurrent,
providerType: provider.type,
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
@ -514,5 +535,7 @@ export async function generateCanvasImage(
...debugMeta,
});
throw error;
} finally {
await slot.release();
}
}

View File

@ -0,0 +1,197 @@
import "server-only";
import { getSystemSetting } from "@/lib/db/queries";
import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY,
normalizeCanvasImageTaskQueueConfig,
} from "./image-task-config";
const CONCURRENCY_CONFIG_CACHE_MS = 5 * 1000;
const concurrencyStateKey = "__canvas_image_model_concurrency__";
interface ModelWaiter {
ticketId: string;
resolve: () => void;
}
interface ModelConcurrencyState {
running: Set<string>;
waiters: ModelWaiter[];
}
interface CanvasImageConcurrencyState {
models: Map<string, ModelConcurrencyState>;
}
let cachedConcurrency:
| {
expiresAt: number;
value: number;
}
| null = null;
function getGlobalConcurrencyState(): CanvasImageConcurrencyState {
const globalState = globalThis as typeof globalThis & {
[concurrencyStateKey]?: CanvasImageConcurrencyState;
};
if (!globalState[concurrencyStateKey]) {
globalState[concurrencyStateKey] = {
models: new Map<string, ModelConcurrencyState>(),
};
}
return globalState[concurrencyStateKey] as CanvasImageConcurrencyState;
}
function getOrCreateModelState(modelKey: string) {
const state = getGlobalConcurrencyState();
const existing = state.models.get(modelKey);
if (existing) {
return existing;
}
const created: ModelConcurrencyState = {
running: new Set<string>(),
waiters: [],
};
state.models.set(modelKey, created);
return created;
}
function cleanupModelState(modelKey: string) {
const state = getGlobalConcurrencyState();
const modelState = state.models.get(modelKey);
if (!modelState) {
return;
}
if (modelState.running.size === 0 && modelState.waiters.length === 0) {
state.models.delete(modelKey);
}
}
export function normalizeCanvasImageModelKey(value: unknown) {
if (typeof value !== "string") {
return "__default__";
}
const normalized = value.trim();
return normalized || "__default__";
}
export async function getCanvasImageSingleModelConcurrency(): 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 Concurrency] Failed to load DB 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;
}
export function getCanvasImageRunningCountForModel(modelKey: string) {
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
return getGlobalConcurrencyState().models.get(normalizedModelKey)?.running.size || 0;
}
async function drainModelWaiters(modelKey: string) {
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
const modelState = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!modelState || modelState.waiters.length === 0) {
cleanupModelState(normalizedModelKey);
return;
}
const maxConcurrent = await getCanvasImageSingleModelConcurrency();
while (modelState.running.size < maxConcurrent && modelState.waiters.length > 0) {
const waiter = modelState.waiters.shift();
if (!waiter) {
break;
}
modelState.running.add(waiter.ticketId);
waiter.resolve();
}
cleanupModelState(normalizedModelKey);
}
export async function acquireCanvasImageModelSlot(params: {
modelKey: string;
debugLabel?: string;
debugMeta?: Record<string, unknown>;
}) {
const normalizedModelKey = normalizeCanvasImageModelKey(params.modelKey);
const maxConcurrent = await getCanvasImageSingleModelConcurrency();
const modelState = getOrCreateModelState(normalizedModelKey);
const ticketId = `${normalizedModelKey}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`;
const waitingSince = Date.now();
if (modelState.waiters.length === 0 && modelState.running.size < maxConcurrent) {
modelState.running.add(ticketId);
return {
release: async () => {
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!state) return;
state.running.delete(ticketId);
await drainModelWaiters(normalizedModelKey);
},
waitMs: 0,
maxConcurrent,
};
}
await new Promise<void>((resolve) => {
modelState.waiters.push({
ticketId,
resolve,
});
void drainModelWaiters(normalizedModelKey);
});
const waitMs = Date.now() - waitingSince;
if (waitMs > 250) {
console.info("[Canvas Image Concurrency] Waited for model slot:", {
debugLabel: params.debugLabel || "canvas-image",
modelKey: normalizedModelKey,
waitMs,
...params.debugMeta,
});
}
return {
release: async () => {
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!state) return;
state.running.delete(ticketId);
await drainModelWaiters(normalizedModelKey);
},
waitMs,
maxConcurrent,
};
}

View File

@ -2,13 +2,11 @@ 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";
getCanvasImageSingleModelConcurrency,
normalizeCanvasImageModelKey,
} from "./image-concurrency";
import {
generateCanvasImage,
type CanvasGenerateImageRequest,
@ -30,49 +28,9 @@ interface QueueState {
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 & {
@ -96,12 +54,7 @@ function normalizeTaskModelKey(request: unknown) {
}
const rawModel = (request as Record<string, unknown>).model;
if (typeof rawModel !== "string") {
return "__default__";
}
const normalizedModel = rawModel.trim();
return normalizedModel || "__default__";
return normalizeCanvasImageModelKey(rawModel);
}
async function getTaskModelKey(taskId: string) {
@ -268,7 +221,7 @@ export async function drainCanvasImageTaskQueue() {
state.draining = true;
try {
const maxConcurrentPerModel = await getQueueConcurrency();
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
while (true) {
const nextTask = await claimNextPendingTask(maxConcurrentPerModel);
if (!nextTask) break;