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
@@ -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