feat: unify site-wide image concurrency
This commit is contained in:
parent
9073c3a389
commit
e7cf10bb28
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
197
lib/canvas/image-concurrency.ts
Normal file
197
lib/canvas/image-concurrency.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user