perf(server): parallel media-server inventory, queued identify remote lookups, dev DB cleanup
- GET /inventory/media-servers: per-instance Promise.allSettled, per-instance
concurrency cap (2) and timeout (8s); failed/timed-out instances are unknown,
response carries partial=true (field not yet in contracts)
- S-7: POST /files/identify returns local matches plus remoteTaskId/remotePending
and enqueues identifyRemote; the worker runs the TMDB lookups and publishes SSE
identify.completed {taskId,status,items,truncated,excludedBySize,error};
?wait=true keeps the synchronous path, capped at 15s overall
- catalog.lookup / release.search honour the caller's abort signal
- scripts/dev-db-clean.ts: dry-run by default, --yes to drop leftover test
schemas and w32-/w33-/fe4-/qa- prefixed entities; refuses non localhost:55433
- runbook: dev DB cleanup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5b8927d1dc
commit
fd2f7bc33d
10 files changed
+683
-67
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { appendFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { IDENTIFY_COMPLETED_EVENT, runIdentifyRemote } from "../../src/modules/file-manager/identify.ts";
|
||||
import {
|
||||
createFmHarness,
|
||||
EXEC_KEY,
|
||||
@@ -69,16 +70,74 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
|
||||
expect(pool).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("identify:远端候选(catalog.lookup)", async () => {
|
||||
const r = await h.api("POST", "/files/identify", {
|
||||
it("identify:远端候选(catalog.lookup,wait=true 同步模式)", async () => {
|
||||
const r = await h.api("POST", "/files/identify?wait=true", {
|
||||
items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }],
|
||||
minSizeBytes: 0,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.remoteTaskId).toBeNull();
|
||||
const c = r.body.data.items[0].candidates;
|
||||
expect(c[0]).toMatchObject({ provider: "tmdb", externalId: "999", mediaEntityId: null, kind: "movie" });
|
||||
});
|
||||
|
||||
it("identify:缺省入队远端查询,先回本地命中 + remoteTaskId;任务完成发 identify.completed(S-7)", async () => {
|
||||
const before = h.engine.enqueued.length;
|
||||
const body = {
|
||||
items: [
|
||||
{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" },
|
||||
{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" },
|
||||
],
|
||||
minSizeBytes: 0,
|
||||
};
|
||||
const r = await h.api("POST", "/files/identify", body);
|
||||
expect(r.status).toBe(200);
|
||||
const [remote, local] = r.body.data.items;
|
||||
// 本地命中照常返回;远端候选不在请求内查
|
||||
expect(remote.candidates).toEqual([]);
|
||||
expect(remote.warnings.some((w: string) => w.includes("后台"))).toBe(true);
|
||||
expect(local.candidates[0]).toMatchObject({ mediaEntityId: film });
|
||||
expect(r.body.data.remotePending).toBe(2);
|
||||
const job = h.engine.enqueued.slice(before).find((e) => e.task === "identifyRemote");
|
||||
expect(job).toBeTruthy();
|
||||
expect(r.body.data.remoteTaskId).toMatch(/^run-/);
|
||||
|
||||
// 模拟 worker 执行任务
|
||||
const out = await runIdentifyRemote(
|
||||
h.module.deps(),
|
||||
r.body.data.remoteTaskId,
|
||||
job!.input as Parameters<typeof runIdentifyRemote>[2],
|
||||
);
|
||||
expect(out.status).toBe("succeeded");
|
||||
const ev = h.events.filter((e) => e.type === IDENTIFY_COMPLETED_EVENT).at(-1);
|
||||
expect(ev?.payload.taskId).toBe(r.body.data.remoteTaskId);
|
||||
const done = ev!.payload.items.find((i: { path: string }) => i.path === "Remote.Film.2019.1080p.mkv");
|
||||
expect(done.candidates[0]).toMatchObject({ provider: "tmdb", externalId: "999" });
|
||||
expect(done.warnings.some((w: string) => w.includes("后台"))).toBe(false);
|
||||
});
|
||||
|
||||
it("identify:无需远端查询时不入队(remoteTaskId=null)", async () => {
|
||||
const before = h.engine.enqueued.length;
|
||||
await mkdir(join(h.dl, "untitled"), { recursive: true });
|
||||
await writeFile(join(h.dl, "untitled", "1080p.mkv"), "x");
|
||||
const r = await h.api("POST", "/files/identify", { items: [{ root: h.dlRoot, path: "untitled/1080p.mkv" }] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.remoteTaskId).toBeNull();
|
||||
expect(h.engine.enqueued.slice(before).some((e) => e.task === "identifyRemote")).toBe(false);
|
||||
});
|
||||
|
||||
it("identify:任务里识别失败也发 identify.completed(status=failed)", async () => {
|
||||
const out = await runIdentifyRemote(h.module.deps(), "run-x", {
|
||||
body: { items: [{ root: h.dlRoot, path: "no-such-file.mkv" }] } as never,
|
||||
actorId: "system",
|
||||
});
|
||||
expect(out.status).toBe("failed");
|
||||
expect(h.events.at(-1)).toMatchObject({
|
||||
type: IDENTIFY_COMPLETED_EVENT,
|
||||
payload: { taskId: "run-x", status: "failed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("identify:去重后的远端查询单次上限 20,超出的项只本地召回并给 warning(验收 07 S-6)", async () => {
|
||||
await mkdir(join(h.dl, "many"), { recursive: true });
|
||||
const names = Array.from({ length: 23 }, (_, i) => `Distinct Title ${String.fromCharCode(65 + i)}.2019.1080p.mkv`);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createHarness,
|
||||
type Harness,
|
||||
pgAvailable,
|
||||
seedEntity,
|
||||
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
import { MEDIA_SERVER_LIMITS } from "../../src/modules/acquisition-organize/routes/library.ts";
|
||||
|
||||
// C1-D:GET /inventory/media-servers 按实例并行(allSettled)+ 实例内并发上限 + 整实例超时;部分失败 partial=true。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe.skipIf(!hasPg)("GET /inventory/media-servers 并行 + 超时 + partial", () => {
|
||||
let h: Harness;
|
||||
let entityId: string;
|
||||
const ids: Record<string, string> = {};
|
||||
const saved = { ...MEDIA_SERVER_LIMITS };
|
||||
const inflight: Record<string, { now: number; max: number }> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("msinv");
|
||||
MEDIA_SERVER_LIMITS.timeoutMs = 400;
|
||||
MEDIA_SERVER_LIMITS.perInstanceConcurrency = 2;
|
||||
entityId = await seedEntity(h.db, { kind: "movie", title: "并行库存", year: 2020 });
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values(
|
||||
["tmdb", "imdb", "tvdb"].map((provider, i) => ({
|
||||
media_entity_id: entityId,
|
||||
provider,
|
||||
entity_type: "movie",
|
||||
external_id: `x${i}`,
|
||||
status: "accepted",
|
||||
})) as never,
|
||||
)
|
||||
.execute();
|
||||
for (const name of ["a-ok", "b-slow", "c-boom"]) {
|
||||
const r = await h.db
|
||||
.insertInto("media_server_instance")
|
||||
.values({ name, base_url: `http://${name}.invalid` })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
ids[name] = r.id;
|
||||
inflight[name] = { now: 0, max: 0 };
|
||||
}
|
||||
h.d.ports.mediaServer = (async (inst: { name: string }) => {
|
||||
if (inst.name === "c-boom") throw new Error("connect refused");
|
||||
return {
|
||||
async findByProviderId(provider: string) {
|
||||
const f = inflight[inst.name]!;
|
||||
f.now++;
|
||||
f.max = Math.max(f.max, f.now);
|
||||
try {
|
||||
await sleep(inst.name === "b-slow" ? 5_000 : 60);
|
||||
return provider === "Tmdb"
|
||||
? {
|
||||
status: "present" as const,
|
||||
items: [{ itemId: "i1", type: "Movie", name: "并行库存", year: 2020, path: "/m", versions: [] }],
|
||||
}
|
||||
: { status: "not_found" as const };
|
||||
} finally {
|
||||
f.now--;
|
||||
}
|
||||
},
|
||||
};
|
||||
}) as never;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
Object.assign(MEDIA_SERVER_LIMITS, saved);
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("实例并行、慢实例超时、异常实例 unknown,整体 partial", async () => {
|
||||
const t0 = Date.now();
|
||||
const r = await h.api("GET", `/inventory/media-servers?mediaEntityId=${entityId}`);
|
||||
const took = Date.now() - t0;
|
||||
expect(r.status).toBe(200);
|
||||
type Inst = { name: string; status: string; reason: string | null; items: unknown[] };
|
||||
const by = Object.fromEntries((r.body.data.instances as Inst[]).map((x) => [x.name, x]));
|
||||
expect(by["a-ok"]).toMatchObject({ status: "present", reason: null });
|
||||
expect(by["a-ok"]!.items).toHaveLength(1);
|
||||
expect(by["b-slow"]).toMatchObject({ status: "unknown" });
|
||||
expect(by["b-slow"]!.reason).toMatch(/^timeout/);
|
||||
expect(by["c-boom"]).toMatchObject({ status: "unknown", reason: "connect refused" });
|
||||
expect(r.body.data.partial).toBe(true);
|
||||
// 串行时至少 0.4s(b 超时)+ 3×60ms;并行应接近单个超时
|
||||
expect(took).toBeLessThan(2_000);
|
||||
// 实例内并发上限 2(3 个外部 ID)
|
||||
expect(inflight["a-ok"]!.max).toBe(2);
|
||||
});
|
||||
|
||||
it("全部成功时 partial=false;无外部 ID 的 unknown 不算失败", async () => {
|
||||
await h.db.updateTable("media_server_instance").set({ enabled: false }).where("name", "!=", "a-ok").execute();
|
||||
const r = await h.api("GET", `/inventory/media-servers?mediaEntityId=${entityId}`);
|
||||
expect(r.body.data.partial).toBe(false);
|
||||
const bareId = await seedEntity(h.db, { kind: "movie", title: "无外部ID", year: 2021 });
|
||||
const r2 = await h.api("GET", `/inventory/media-servers?mediaEntityId=${bareId}`);
|
||||
expect(r2.body.data.instances[0]).toMatchObject({ status: "unknown", reason: "no_external_id" });
|
||||
expect(r2.body.data.partial).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user