Files
UHD_Media_Tool/apps/server/test/invoke-limits.test.ts
T
rootandClaude Opus 5.5 da33cad81d feat(worker): IO Worker 分道(WK-1)
io worker 进程内按任务声明的 lane 分文件 / 网络 / 维护三条道,各自并发(缺省 2/4/1)、
各自排队;维护道在文件道或网络道有排队/运行时让路(连续 10 分钟放行一个),调度器内联的
清理/回填/保留期裁剪也走维护道。并发在 设置 → 自动化 → 高级 热生效,env 覆盖;旧
WORKER_CONCURRENCY 映射到网络道。运行状态页按道显示运行中/并发/排队。归属表写进
docs/runbook/dev.md,ci 与注册表逐项核对。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 00:24:21 +08:00

111 lines
4.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { createLogger } from "../src/logger.ts";
import {
createInvokeLimiter,
externalUpstream,
Semaphore,
UpstreamBusyError,
UpstreamTimeoutError,
} from "../src/modules/agent/invoke-limits.ts";
import { startLoopLagProbe, workerPlan } from "../src/worker/index.ts";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
describe("能力调用分级(HTTP 路径)", () => {
it("只把会打外部上游的调用归类", () => {
expect(externalUpstream("release.search", { query: "x", allowLiveSearch: true })).toBe("site");
expect(externalUpstream("release.search", { query: "x", allowLiveSearch: false })).toBeNull();
expect(externalUpstream("release.inspect", { refreshDetail: true })).toBe("site");
expect(externalUpstream("release.inspect", {})).toBeNull();
expect(externalUpstream("identity.resolve", {})).toBe("tmdb");
expect(externalUpstream("catalog.lookup", { includeRemote: true })).toBe("tmdb");
expect(externalUpstream("catalog.lookup", { includeRemote: false })).toBeNull();
expect(externalUpstream("inventory.query", {})).toBeNull();
});
it("超时:HTTP 立即放弃,许可等底层结束才归还", async () => {
const lim = createInvokeLimiter({ timeoutMs: 50, limits: { tmdb: 1 } });
let finished = false;
const slow = lim.run("identity.resolve", {}, undefined, async () => {
await sleep(200);
finished = true;
return 1;
});
await expect(slow).rejects.toBeInstanceOf(UpstreamTimeoutError);
expect(finished).toBe(false);
expect(lim.inUse("tmdb")).toBe(1);
// 许可仍被占:排队到超时 → busy
await expect(lim.run("identity.resolve", {}, undefined, async () => 2)).rejects.toBeInstanceOf(UpstreamBusyError);
await sleep(250);
expect(finished).toBe(true);
expect(lim.inUse("tmdb")).toBe(0);
await expect(lim.run("identity.resolve", {}, undefined, async () => 3)).resolves.toBe(3);
});
it("并发上限:同时在跑的外部调用不超过 limit;本地调用不受限", async () => {
const lim = createInvokeLimiter({ timeoutMs: 5000, limits: { site: 2 } });
let running = 0;
let peak = 0;
const job = () =>
lim.run("release.search", { query: "q", allowLiveSearch: true }, undefined, async () => {
running++;
peak = Math.max(peak, running);
await sleep(30);
running--;
});
await Promise.all(Array.from({ length: 7 }, job));
expect(peak).toBe(2);
await expect(lim.run("inventory.query", {}, undefined, async () => "local")).resolves.toBe("local");
});
it("Semaphore:等待者被中止时移出队列", async () => {
const s = new Semaphore(1);
const r1 = await s.acquire(new AbortController().signal);
const ac = new AbortController();
const p = s.acquire(ac.signal);
ac.abort(new Error("gone"));
await expect(p).rejects.toThrow("gone");
r1();
expect(s.inUse).toBe(0);
});
});
describe("worker 分组与事件循环探针", () => {
const tasks = [
{ name: "evaluateAcquisition", lane: "network" as const },
{ name: "resolveDecision", lane: "agent" as const },
{ name: "runAgent" },
{ name: "verifyAndImport", lane: "file" as const },
{ name: "purgeTrash", lane: "maintenance" as const },
];
it("io / agent / split 的任务选择(io 按 lane 分三条道;没声明 lane 的 runAgent 兜底归 agent)", () => {
expect(workerPlan("io", tasks).map((p) => [p.label, p.versions])).toEqual([
["file", ["verifyAndImport"]],
["network", ["evaluateAcquisition"]],
["maintenance", ["purgeTrash"]],
]);
expect(workerPlan("agent", tasks, { agent: 2 })).toEqual([
{ label: "agent", versions: ["resolveDecision", "runAgent"], concurrency: 2 },
]);
expect(workerPlan("split", tasks, { agent: 1 }).map((p) => p.label)).toEqual([
"file",
"network",
"maintenance",
"agent",
]);
expect(workerPlan("all", tasks, {})).toEqual([{ label: "all" }]);
});
it("主线程被同步占住超过阈值 → 计数", async () => {
const probe = startLoopLagProbe(createLogger({ level: "silent" }), { intervalMs: 20, warnMs: 100 });
const until = performance.now() + 250;
while (performance.now() < until) {
// 同步忙等,模拟阻塞事件循环
}
await sleep(60);
probe.stop();
expect(probe.stats().loopLagWarnings).toBeGreaterThanOrEqual(1);
expect(probe.stats().maxLoopLagMs).toBeGreaterThan(100);
});
});