524 lines
14 KiB
TypeScript
524 lines
14 KiB
TypeScript
import { validateGroupBatch, groupDependencyState } from "./group-execution";
|
|
import { canResumeCanvasTask, hasRecoverableCanvasTask } from "./task-recovery";
|
|
import "server-only";
|
|
import { and, asc, desc, eq, gte, lt, sql, inArray } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import { canvasCapabilityTask as table, canvasProject } from "@/lib/db/schema";
|
|
import {
|
|
type CanvasTaskRequest,
|
|
type CanvasGenerationTask,
|
|
} from "./task-contract";
|
|
|
|
const capabilityId = "video-canvas.generation";
|
|
const scope = (userId: string, projectId: string) =>
|
|
and(
|
|
eq(table.userId, userId),
|
|
eq(table.projectId, projectId),
|
|
eq(table.capabilityId, capabilityId)
|
|
);
|
|
const serialize = (row: typeof table.$inferSelect): CanvasGenerationTask => ({
|
|
id: row.id,
|
|
requestId: row.requestId,
|
|
status: row.status,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
attempts: row.attempts,
|
|
request: row.request as CanvasTaskRequest,
|
|
result: row.result as Record<string, any> | null,
|
|
error: row.error,
|
|
});
|
|
export async function ownsTaskProject(userId: string, projectId: string) {
|
|
return Boolean(
|
|
(
|
|
await db
|
|
.select({ id: canvasProject.id })
|
|
.from(canvasProject)
|
|
.where(
|
|
and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))
|
|
)
|
|
.limit(1)
|
|
)[0]
|
|
);
|
|
}
|
|
export async function createCanvasGenerationTask(
|
|
userId: string,
|
|
request: CanvasTaskRequest
|
|
) {
|
|
return db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${request.projectId}))`
|
|
);
|
|
const existing = (
|
|
await tx
|
|
.select()
|
|
.from(table)
|
|
.where(
|
|
and(
|
|
scope(userId, request.projectId),
|
|
eq(table.requestId, request.requestId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
)[0];
|
|
if (existing) {
|
|
if (
|
|
JSON.stringify(existing.request) !==
|
|
JSON.stringify(JSON.parse(JSON.stringify(request)))
|
|
) {
|
|
// JSONB property order is not stable. Compare the immutable logical request.
|
|
const old = existing.request as CanvasTaskRequest;
|
|
if (
|
|
old.node.id !== request.node.id ||
|
|
old.kind !== request.kind ||
|
|
JSON.stringify(sortObject(old.attachment)) !==
|
|
JSON.stringify(sortObject(request.attachment)) ||
|
|
JSON.stringify(sortObject(old.params)) !==
|
|
JSON.stringify(sortObject(request.params))
|
|
)
|
|
throw Object.assign(Error("同一任务 ID 不能用于不同请求"), {
|
|
status: 409,
|
|
});
|
|
}
|
|
return serialize(existing);
|
|
}
|
|
const active = await tx
|
|
.select()
|
|
.from(table)
|
|
.where(
|
|
and(
|
|
scope(userId, request.projectId),
|
|
sql`${table.status} in ('pending','running')`
|
|
)
|
|
);
|
|
const duplicate = active.find(
|
|
(row) => (row.request as CanvasTaskRequest).node.id === request.node.id
|
|
);
|
|
if (duplicate) return serialize(duplicate);
|
|
const interrupted = await tx
|
|
.select()
|
|
.from(table)
|
|
.where(
|
|
and(scope(userId, request.projectId), eq(table.status, "interrupted"))
|
|
);
|
|
const recovering = interrupted.find(
|
|
(row) =>
|
|
(row.request as CanvasTaskRequest).node.id === request.node.id &&
|
|
hasRecoverableCanvasTask(serialize(row))
|
|
);
|
|
if (recovering) return serialize(recovering);
|
|
|
|
if (active.length >= 40)
|
|
throw Object.assign(
|
|
Error("当前画布排队任务已达 40 个,请等待完成后继续"),
|
|
{ status: 429 }
|
|
);
|
|
const [created] = await tx
|
|
.insert(table)
|
|
.values({
|
|
requestId: request.requestId,
|
|
userId,
|
|
projectId: request.projectId,
|
|
capabilityId,
|
|
source: "video-canvas",
|
|
request,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.returning();
|
|
return serialize(created);
|
|
});
|
|
}
|
|
function sortObject(value: any): any {
|
|
return Array.isArray(value)
|
|
? value.map(sortObject)
|
|
: value && typeof value === "object"
|
|
? Object.fromEntries(
|
|
Object.keys(value)
|
|
.sort()
|
|
.map((key) => [key, sortObject(value[key])])
|
|
)
|
|
: value;
|
|
}
|
|
export async function listCanvasGenerationTasks(
|
|
userId: string,
|
|
projectId: string
|
|
) {
|
|
// A worker lost to process shutdown must never silently re-submit a paid request.
|
|
await db
|
|
.update(table)
|
|
.set({
|
|
status: "interrupted",
|
|
error:
|
|
"服务端执行连接中断,供应商任务可能仍在运行。请核实结果后再决定是否重新生成,系统不会自动重复提交。",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
scope(userId, projectId),
|
|
eq(table.status, "running"),
|
|
lt(table.updatedAt, new Date(Date.now() - 120000))
|
|
)
|
|
);
|
|
const rows = await db
|
|
.select()
|
|
.from(table)
|
|
.where(scope(userId, projectId))
|
|
.orderBy(desc(table.createdAt))
|
|
.limit(240);
|
|
return rows.map(serialize).reverse();
|
|
}
|
|
export async function claimCanvasGenerationTask(
|
|
userId: string,
|
|
projectId: string
|
|
) {
|
|
return db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${projectId}))`
|
|
);
|
|
if (
|
|
(
|
|
await tx
|
|
.select({ id: table.id })
|
|
.from(table)
|
|
.where(and(scope(userId, projectId), eq(table.status, "running")))
|
|
.limit(1)
|
|
).length
|
|
)
|
|
return null;
|
|
const pending = await tx
|
|
.select()
|
|
.from(table)
|
|
.where(and(scope(userId, projectId), eq(table.status, "pending")))
|
|
.orderBy(asc(table.createdAt))
|
|
.limit(40);
|
|
const dependencyIds = [
|
|
...new Set(
|
|
pending.flatMap(
|
|
(row) =>
|
|
(row.request as CanvasTaskRequest).workflow?.inputs
|
|
.map((input) => input.requestId)
|
|
.filter((id): id is string => Boolean(id)) || []
|
|
)
|
|
),
|
|
];
|
|
const predecessors = dependencyIds.length
|
|
? await tx
|
|
.select()
|
|
.from(table)
|
|
.where(
|
|
and(
|
|
scope(userId, projectId),
|
|
inArray(table.requestId, dependencyIds)
|
|
)
|
|
)
|
|
: [];
|
|
const tasks = [
|
|
...new Map(
|
|
[...pending, ...predecessors].map((row) => [row.id, serialize(row)])
|
|
).values(),
|
|
];
|
|
let next: typeof table.$inferSelect | undefined;
|
|
for (const candidate of pending) {
|
|
const state = groupDependencyState(
|
|
candidate.request as CanvasTaskRequest,
|
|
tasks
|
|
);
|
|
if (state === "failed") {
|
|
await tx
|
|
.update(table)
|
|
.set({
|
|
status: "failed",
|
|
error: "上游任务失败或取消,未提交此节点",
|
|
updatedAt: new Date(),
|
|
completedAt: new Date(),
|
|
})
|
|
.where(eq(table.id, candidate.id));
|
|
tasks.find((t) => t.id === candidate.id)!.status = "failed";
|
|
} else if (state === "ready" && !next) next = candidate;
|
|
}
|
|
if (!next) {
|
|
const interrupted = await tx
|
|
.select()
|
|
.from(table)
|
|
.where(
|
|
and(
|
|
scope(userId, projectId),
|
|
eq(table.status, "interrupted"),
|
|
gte(table.createdAt, new Date(Date.now() - 86400000)),
|
|
lt(table.attempts, 20)
|
|
)
|
|
)
|
|
.orderBy(asc(table.updatedAt));
|
|
next = interrupted.find((row) => canResumeCanvasTask(serialize(row)))!;
|
|
}
|
|
if (!next) return null;
|
|
const [claimed] = await tx
|
|
.update(table)
|
|
.set({
|
|
status: "running",
|
|
error: null,
|
|
startedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
attempts: sql`${table.attempts}+1`,
|
|
})
|
|
.where(and(eq(table.id, next.id), eq(table.status, next.status)))
|
|
.returning();
|
|
return claimed ? serialize(claimed) : null;
|
|
});
|
|
}
|
|
export async function heartbeatCanvasGenerationTask(
|
|
id: string,
|
|
attempt?: number
|
|
) {
|
|
await db
|
|
.update(table)
|
|
.set({ updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(table.id, id),
|
|
eq(table.status, "running"),
|
|
attempt === undefined ? undefined : eq(table.attempts, attempt)
|
|
)
|
|
);
|
|
}
|
|
export async function finishCanvasGenerationTask(
|
|
id: string,
|
|
result?: Record<string, any>,
|
|
error?: string,
|
|
attempt?: number
|
|
) {
|
|
// A late real result can recover an interrupted task, but cannot replace a newer request ID.
|
|
await db
|
|
.update(table)
|
|
.set({
|
|
status: result ? "succeeded" : "failed",
|
|
result: result || null,
|
|
error: error || null,
|
|
updatedAt: new Date(),
|
|
completedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(table.id, id),
|
|
sql`${table.status} in ('running','interrupted')`,
|
|
attempt === undefined ? undefined : eq(table.attempts, attempt)
|
|
)
|
|
);
|
|
}
|
|
export async function cancelCanvasGenerationTask(
|
|
userId: string,
|
|
projectId: string,
|
|
id: string
|
|
) {
|
|
const [cancelled] = await db
|
|
.update(table)
|
|
.set({
|
|
status: "cancelled",
|
|
error: "已取消排队,未提交供应商",
|
|
updatedAt: new Date(),
|
|
completedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
scope(userId, projectId),
|
|
eq(table.id, id),
|
|
eq(table.status, "pending")
|
|
)
|
|
)
|
|
.returning();
|
|
if (cancelled) return { ok: true, task: serialize(cancelled) };
|
|
const [current] = await db
|
|
.select()
|
|
.from(table)
|
|
.where(and(scope(userId, projectId), eq(table.id, id)))
|
|
.limit(1);
|
|
return {
|
|
ok: current?.status === "cancelled",
|
|
task: current ? serialize(current) : null,
|
|
};
|
|
}
|
|
export async function hideCanvasGenerationNode(
|
|
userId: string,
|
|
projectId: string,
|
|
id: string,
|
|
nodeId: string
|
|
) {
|
|
return db.transaction(async (tx) => {
|
|
const [task] = await tx
|
|
.select()
|
|
.from(table)
|
|
.where(and(scope(userId, projectId), eq(table.id, id)))
|
|
.for("update");
|
|
if (!task) return false;
|
|
const request = task.request as CanvasTaskRequest & {
|
|
hiddenNodeIds?: string[];
|
|
};
|
|
if (
|
|
nodeId !== request.node.id &&
|
|
!nodeId.startsWith(`${request.node.id}-result-`)
|
|
)
|
|
return false;
|
|
await tx
|
|
.update(table)
|
|
.set({
|
|
request: {
|
|
...request,
|
|
hiddenNodeIds: [
|
|
...new Set([...(request.hiddenNodeIds || []), nodeId]),
|
|
],
|
|
},
|
|
})
|
|
.where(eq(table.id, id));
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function checkpointCanvasGenerationTask(
|
|
id: string,
|
|
result: Record<string, unknown>,
|
|
attempt?: number
|
|
) {
|
|
await db
|
|
.update(table)
|
|
.set({ result, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(table.id, id),
|
|
eq(table.status, "running"),
|
|
attempt === undefined ? undefined : eq(table.attempts, attempt)
|
|
)
|
|
);
|
|
}
|
|
export async function interruptCanvasGenerationTask(
|
|
id: string,
|
|
error: string,
|
|
attempt?: number
|
|
) {
|
|
await db
|
|
.update(table)
|
|
.set({ status: "interrupted", error, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(table.id, id),
|
|
eq(table.status, "running"),
|
|
attempt === undefined ? undefined : eq(table.attempts, attempt)
|
|
)
|
|
);
|
|
}
|
|
|
|
export async function ownsCanvasTaskLease(id: string, attempt: number) {
|
|
const [row] = await db
|
|
.select({ id: table.id })
|
|
.from(table)
|
|
.where(
|
|
and(
|
|
eq(table.id, id),
|
|
eq(table.attempts, attempt),
|
|
eq(table.status, "running")
|
|
)
|
|
)
|
|
.limit(1);
|
|
return Boolean(row);
|
|
}
|
|
|
|
export async function createCanvasGroupTasks(
|
|
userId: string,
|
|
requests: CanvasTaskRequest[]
|
|
) {
|
|
validateGroupBatch(requests);
|
|
return db.transaction(async (tx) => {
|
|
const projectId = requests[0].projectId;
|
|
await tx.execute(
|
|
sql`select pg_advisory_xact_lock(hashtext(${capabilityId}), hashtext(${projectId}))`
|
|
);
|
|
const same = await tx
|
|
.select()
|
|
.from(table)
|
|
.where(
|
|
and(
|
|
scope(userId, projectId),
|
|
sql`${table.request}->'workflow'->>'runId' = ${requests[0].workflow!.runId}`
|
|
)
|
|
);
|
|
if (same.length) {
|
|
if (
|
|
same.length !== requests.length ||
|
|
requests.some((r) => {
|
|
const old = same.find((t) => t.requestId === r.requestId);
|
|
return (
|
|
!old ||
|
|
JSON.stringify(sortObject(old.request)) !==
|
|
JSON.stringify(sortObject(r))
|
|
);
|
|
})
|
|
)
|
|
throw Object.assign(Error("同一编组执行编号不能用于不同参数"), {
|
|
status: 409,
|
|
});
|
|
return requests.map((request) =>
|
|
serialize(same.find((row) => row.requestId === request.requestId)!)
|
|
);
|
|
}
|
|
const candidates = await tx
|
|
.select()
|
|
.from(table)
|
|
.where(
|
|
and(
|
|
scope(userId, projectId),
|
|
sql`(${table.status} in ('pending','running') or (${table.status}='interrupted' and ${table.createdAt} > now() - interval '24 hours'))`
|
|
)
|
|
);
|
|
const active = candidates.filter(
|
|
(t) =>
|
|
["pending", "running"].includes(t.status) ||
|
|
(t.status === "interrupted" && hasRecoverableCanvasTask(serialize(t)))
|
|
);
|
|
if (active.length + requests.length > 40)
|
|
throw Object.assign(Error("画布任务队列已满"), { status: 429 });
|
|
if (
|
|
active.some((t) =>
|
|
requests.some(
|
|
(r) => r.node.id === (t.request as CanvasTaskRequest).node.id
|
|
)
|
|
)
|
|
)
|
|
throw Object.assign(Error("编组中已有节点正在执行,请等待完成"), {
|
|
status: 409,
|
|
});
|
|
const now = Date.now();
|
|
const rows = await tx
|
|
.insert(table)
|
|
.values(
|
|
requests.map((request, i) => ({
|
|
requestId: request.requestId,
|
|
userId,
|
|
projectId,
|
|
capabilityId,
|
|
source: "video-canvas",
|
|
request,
|
|
createdAt: new Date(now + i),
|
|
updatedAt: new Date(now),
|
|
}))
|
|
)
|
|
.returning();
|
|
return rows.map(serialize);
|
|
});
|
|
}
|
|
export async function groupTaskInputs(
|
|
userId: string,
|
|
request: CanvasTaskRequest
|
|
) {
|
|
const rows = await db
|
|
.select()
|
|
.from(table)
|
|
.where(
|
|
and(
|
|
scope(userId, request.projectId),
|
|
sql`${table.request}->'workflow'->>'runId' = ${request.workflow?.runId || ""}`
|
|
)
|
|
);
|
|
return rows
|
|
.map(serialize)
|
|
.filter((t) => t.request.workflow?.runId === request.workflow?.runId);
|
|
}
|