fix(acquisition): 闸门挡住的资源写延后并排重评;并发预算不计 stalled;清理空转延后行
- 自动化开关 / 下载器 / 预算 / 缓存卷挡住时,除候选记录(改为 deferred)外写 slot_deferral, 原因区分 gate:automation / gate:downloader / gate:budget / gate:cache,并排该收录版本重评; 电影收录版本与已满足的剧集收录版本也会被重评(scheduleSlotRetry,不推迟已排得更早的)。 - 重评时间按延后次数退避:30 分钟起翻倍、封顶 6 小时;预算释放 / 缓存卷恢复没有统一事件,不做事件触发。 - 并发预算:stalled 任务不占名额;已预留计划只算还没建下载任务的,不与任务重复计。 - 延后行清理:资源过期 / 不可评估 / 集数待确认时清该资源的延后;订阅完成(含 stopAfter)清其收录版本的延后; 订阅暂停 / 取消后重评跳过时清对应延后。 - continuous-series e2e:三集被接收后 once 订阅即完成、延后行随之清掉,改以候选评估记录断言"另一版本延后"。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
4d24edd94c
commit
8c10c12bd7
4 files changed
+366
-22
No files matched your search
@@ -0,0 +1,249 @@
|
||||
import { createMockQb, type MockQb } from "@umt/testkit";
|
||||
import { sql } from "kysely";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import {
|
||||
completeSubscription,
|
||||
GATE_RETRY_BASE_MS,
|
||||
sweepDueSlots,
|
||||
} from "../../src/modules/acquisition-organize/services/subscriptions.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
|
||||
|
||||
// 获取环节被挡住 / 失败后的重评(T1-3):闸门不过写延后并排重评(电影、已满足剧集收录版本);
|
||||
// 并发预算不计 stalled;订阅完成 / 资源过期清延后行。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 300_000_000;
|
||||
|
||||
describe.skipIf(!hasPg)("被挡住与失败后的重评", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
const disk = fakeDisk(10_000_000_000);
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
disk.setPct(10);
|
||||
h = await createHarness("blocked_reevaluate", { diskUsage: disk.fn });
|
||||
base = await seedBase(h.db, { libraryPath: "/tmp/umt-none-lib", cachePath: "/tmp/umt-none-cache", qbUrl: qb.url });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
});
|
||||
|
||||
const db = () => h.db;
|
||||
// 用例之间不共享在途:上一个用例留下的预留计划会占并发预算
|
||||
beforeEach(async () => {
|
||||
await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("status", "=", "reserved").execute();
|
||||
});
|
||||
const hash = () => Math.random().toString(16).slice(2).padEnd(40, "0").slice(0, 40);
|
||||
|
||||
async function movieSlot(title: string) {
|
||||
const entityId = await seedEntity(db(), { kind: "movie", title, year: 2024 });
|
||||
const sub = await db()
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: entityId, media_kind: "movie", mode: "once", status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const slot = await db()
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
subscription_id: sub.id,
|
||||
name: "1080p",
|
||||
hard_conditions: JSON.stringify({ resolutions: ["1080p"] }),
|
||||
upgrade_threshold: JSON.stringify({}),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return { entityId, subId: sub.id, slotId: slot.id };
|
||||
}
|
||||
|
||||
async function seriesSlot(title: string, status: "empty" | "satisfied" = "empty") {
|
||||
const entityId = await seedEntity(db(), { kind: "series", title, year: 2024 });
|
||||
const sub = await db()
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: entityId, media_kind: "series", mode: "continuous", status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const slot = await db()
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
subscription_id: sub.id,
|
||||
name: "1080p",
|
||||
status,
|
||||
hard_conditions: JSON.stringify({ resolutions: ["1080p"] }),
|
||||
upgrade_threshold: JSON.stringify({}),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return { entityId, subId: sub.id, slotId: slot.id };
|
||||
}
|
||||
|
||||
const release = (entityId: string, title: string) =>
|
||||
seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: hash() });
|
||||
const deferrals = (slotId: string) =>
|
||||
db().selectFrom("slot_deferral").selectAll().where("slot_id", "=", slotId).execute();
|
||||
const slotRow = (slotId: string) =>
|
||||
db().selectFrom("acquisition_slot").selectAll().where("id", "=", slotId).executeTakeFirstOrThrow();
|
||||
const candidate = async (slotId: string, releaseId: string) =>
|
||||
(
|
||||
await sql<{ content: { decision: string; reasons: string[] } }>`
|
||||
select content from artifact where title = 'candidate_evaluation' and subject_id = ${slotId}
|
||||
and content->>'sourceReleaseId' = ${releaseId}`.execute(db())
|
||||
).rows[0]?.content;
|
||||
/** 缓存卷超阈值挡住 → 延后 + 排重评;恢复后到期重评能建计划 */
|
||||
async function gateThenRecover(slotId: string, releaseId: string) {
|
||||
disk.setPct(95);
|
||||
const t0 = Date.now();
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: releaseId });
|
||||
expect(ev.plans).toHaveLength(0);
|
||||
expect(ev.skipped[0]?.reason).toMatch(/^cache:/);
|
||||
expect(ev.deferred).toContainEqual({ sourceReleaseId: releaseId, slotId, reason: "gate:cache" });
|
||||
const rows = await deferrals(slotId);
|
||||
expect(rows.map((r) => [r.source_release_id, r.reason])).toEqual([[releaseId, "gate:cache"]]);
|
||||
expect((await candidate(slotId, releaseId))?.decision).toBe("deferred");
|
||||
const due = (await slotRow(slotId)).next_evaluate_at;
|
||||
expect(due).not.toBeNull();
|
||||
const delay = due!.getTime() - t0;
|
||||
expect(delay).toBeGreaterThan(GATE_RETRY_BASE_MS - 60_000);
|
||||
expect(delay).toBeLessThanOrEqual(GATE_RETRY_BASE_MS + 60_000);
|
||||
|
||||
// 恢复后到期:巡检入队按收录版本重评,先评延后的资源
|
||||
disk.setPct(10);
|
||||
await db()
|
||||
.updateTable("acquisition_slot")
|
||||
.set({ next_evaluate_at: new Date(Date.now() - 1000) })
|
||||
.where("id", "=", slotId)
|
||||
.execute();
|
||||
h.engine.enqueued.length = 0;
|
||||
expect(await sweepDueSlots(h.d)).toBeGreaterThanOrEqual(1);
|
||||
const job = h.engine.enqueued.find(
|
||||
(e) =>
|
||||
e.task === "evaluateAcquisition" && (e.input as { acquisitionSlotId?: string }).acquisitionSlotId === slotId,
|
||||
);
|
||||
expect(job).toBeDefined();
|
||||
const ev2 = await evaluateAcquisition(h.d, job!.input as { acquisitionSlotId: string });
|
||||
expect(ev2.plans, JSON.stringify(ev2)).toHaveLength(1);
|
||||
expect(await deferrals(slotId)).toHaveLength(0);
|
||||
expect((await candidate(slotId, releaseId))?.decision).toBe("accept");
|
||||
}
|
||||
|
||||
it("电影收录版本:缓存卷超阈值写延后 gate:cache 并排重评,恢复后重评建计划", async () => {
|
||||
const { entityId, slotId } = await movieSlot("Gate Movie");
|
||||
const rel = await release(entityId, "Gate.Movie.2024.1080p.WEB-DL.x264-GRP");
|
||||
await gateThenRecover(slotId, rel);
|
||||
});
|
||||
|
||||
it("已满足的剧集收录版本:新集被挡住同样写延后并重评", async () => {
|
||||
const { entityId, slotId } = await seriesSlot("Gate Series", "satisfied");
|
||||
const rel = await release(entityId, "Gate.Series.S01E05.1080p.WEB-DL.x264-GRP");
|
||||
await gateThenRecover(slotId, rel);
|
||||
});
|
||||
|
||||
it("再次被挡住按次数退避(第 2 次 60 分钟)", async () => {
|
||||
const { entityId, slotId } = await movieSlot("Backoff Movie");
|
||||
const rel = await release(entityId, "Backoff.Movie.2024.1080p.WEB-DL.x264-GRP");
|
||||
await h.setSettings("automation", { enabled: false });
|
||||
try {
|
||||
await evaluateAcquisition(h.d, { sourceReleaseId: rel });
|
||||
await db().updateTable("acquisition_slot").set({ next_evaluate_at: null }).where("id", "=", slotId).execute();
|
||||
const t0 = Date.now();
|
||||
await evaluateAcquisition(h.d, { sourceReleaseId: rel });
|
||||
const [row] = await deferrals(slotId);
|
||||
expect(row).toMatchObject({ reason: "gate:automation", attempts: 2 });
|
||||
const delay = (await slotRow(slotId)).next_evaluate_at!.getTime() - t0;
|
||||
expect(delay).toBeGreaterThan(2 * GATE_RETRY_BASE_MS - 60_000);
|
||||
expect(delay).toBeLessThanOrEqual(2 * GATE_RETRY_BASE_MS + 60_000);
|
||||
} finally {
|
||||
await h.setSettings("automation", { enabled: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("预算并发:stalled 任务不占并发名额,在跑的任务占", async () => {
|
||||
await h.setSettings("budget", { maxConcurrentDownloads: 1 });
|
||||
try {
|
||||
const busyTask = await db()
|
||||
.insertInto("download_task")
|
||||
.values({
|
||||
instance_id: base.instanceId,
|
||||
torrent_hash: hash(),
|
||||
name: "stalled-one",
|
||||
size_bytes: 1_000_000,
|
||||
status: "stalled",
|
||||
managed: true,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const a = await movieSlot("Budget Movie A");
|
||||
const relA = await release(a.entityId, "Budget.Movie.A.2024.1080p.WEB-DL.x264-GRP");
|
||||
const evA = await evaluateAcquisition(h.d, { sourceReleaseId: relA });
|
||||
expect(evA.plans, JSON.stringify(evA)).toHaveLength(1);
|
||||
// 刚预留、还没建下载任务的计划算一个名额
|
||||
const b = await movieSlot("Budget Movie B");
|
||||
const relB = await release(b.entityId, "Budget.Movie.B.2024.1080p.WEB-DL.x264-GRP");
|
||||
const evB = await evaluateAcquisition(h.d, { sourceReleaseId: relB });
|
||||
expect(evB.plans).toHaveLength(0);
|
||||
expect(evB.skipped[0]?.reason).toBe("budget_exceeded");
|
||||
expect((await deferrals(b.slotId))[0]?.reason).toBe("gate:budget");
|
||||
// A 结束、stalled 任务变成下载中:仍占满
|
||||
await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("id", "=", evA.plans[0]!).execute();
|
||||
await db().updateTable("download_task").set({ status: "downloading" }).where("id", "=", busyTask.id).execute();
|
||||
expect((await evaluateAcquisition(h.d, { sourceReleaseId: relB })).plans).toHaveLength(0);
|
||||
await db().updateTable("download_task").set({ status: "stalled" }).where("id", "=", busyTask.id).execute();
|
||||
const evB2 = await evaluateAcquisition(h.d, { sourceReleaseId: relB });
|
||||
expect(evB2.plans).toHaveLength(1);
|
||||
expect(await deferrals(b.slotId)).toHaveLength(0);
|
||||
await db().deleteFrom("download_task").where("id", "=", busyTask.id).execute();
|
||||
await db()
|
||||
.updateTable("acquisition_plan")
|
||||
.set({ status: "cancelled" })
|
||||
.where("id", "=", evB2.plans[0]!)
|
||||
.execute();
|
||||
} finally {
|
||||
await h.setSettings("budget", {});
|
||||
}
|
||||
});
|
||||
|
||||
it("订阅完成、订阅暂停、资源过期时清掉延后行", async () => {
|
||||
disk.setPct(95);
|
||||
try {
|
||||
// 完成
|
||||
const a = await movieSlot("Cleanup Movie A");
|
||||
const relA = await release(a.entityId, "Cleanup.Movie.A.2024.1080p.WEB-DL.x264-GRP");
|
||||
await evaluateAcquisition(h.d, { sourceReleaseId: relA });
|
||||
expect(await deferrals(a.slotId)).toHaveLength(1);
|
||||
const subA = await db()
|
||||
.selectFrom("subscription")
|
||||
.selectAll()
|
||||
.where("id", "=", a.subId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(await completeSubscription(h.d, db(), subA, "test")).toBe(true);
|
||||
expect(await deferrals(a.slotId)).toHaveLength(0);
|
||||
|
||||
// 暂停:到期重评时跳过并清掉
|
||||
const b = await movieSlot("Cleanup Movie B");
|
||||
const relB = await release(b.entityId, "Cleanup.Movie.B.2024.1080p.WEB-DL.x264-GRP");
|
||||
await evaluateAcquisition(h.d, { sourceReleaseId: relB });
|
||||
expect(await deferrals(b.slotId)).toHaveLength(1);
|
||||
await db().updateTable("subscription").set({ status: "paused" }).where("id", "=", b.subId).execute();
|
||||
await evaluateAcquisition(h.d, { acquisitionSlotId: b.slotId });
|
||||
expect(await deferrals(b.slotId)).toHaveLength(0);
|
||||
|
||||
// 资源过期
|
||||
const c = await movieSlot("Cleanup Movie C");
|
||||
const relC = await release(c.entityId, "Cleanup.Movie.C.2024.1080p.WEB-DL.x264-GRP");
|
||||
await evaluateAcquisition(h.d, { sourceReleaseId: relC });
|
||||
expect(await deferrals(c.slotId)).toHaveLength(1);
|
||||
await db().updateTable("source_release").set({ availability: "expired" }).where("id", "=", relC).execute();
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relC });
|
||||
expect(ev.skipped[0]?.reason).toBe("release_expired");
|
||||
expect(await deferrals(c.slotId)).toHaveLength(0);
|
||||
} finally {
|
||||
disk.setPct(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import { completeDownload, createE2E, type E2E, pgAvailable, seedBase, waitFor }
|
||||
// E9 连续更新剧集(W5-C,对照 MP 缺集 − note 与批量下载):全部经真实任务引擎 + outbox 中继。
|
||||
// 季共 3 集(mock TMDB),新作品策略按 once 订阅。E01 还在下载(Slot 忙)时 E02、E03 与 E02 的另一个版本先后到达:
|
||||
// · E02/E03 与在途的 E01 单元不相交 → 各自得到计划并同时下载(Slot×单元预留),不再因 slot_busy 丢集;
|
||||
// · E02 另一版本与在途 E02 单元冲突 → 延后(slot_deferral),计划结束后重评时已覆盖,不重复下载;
|
||||
// · E02 另一版本与在途 E02 单元冲突 → 延后(候选评估 deferred;订阅接收即完成后延后行清掉),不重复下载;
|
||||
// · refreshMetadata(编排循环按周期入队)把 TMDB 季集数写进季单元 → 三集全部入库后 once 订阅完成并写历史。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
@@ -216,14 +216,24 @@ describe.skipIf(!hasPg)("E9 连续更新剧集:Slot 忙时到达的集全部入
|
||||
60_000,
|
||||
"另一版本识别",
|
||||
);
|
||||
// 三集都被下载器接收后 once 订阅即完成,完成时延后行随之清掉;以候选评估记录为准
|
||||
await waitFor(
|
||||
async () =>
|
||||
e.db
|
||||
.selectFrom("slot_deferral")
|
||||
.selectAll()
|
||||
.where("slot_id", "=", slotId)
|
||||
.where("source_release_id", "=", loserRel.id)
|
||||
.executeTakeFirst(),
|
||||
(
|
||||
await e.db
|
||||
.selectFrom("artifact")
|
||||
.select("content")
|
||||
.where("title", "=", "candidate_evaluation")
|
||||
.where("subject_id", "=", slotId)
|
||||
.execute()
|
||||
).find((r) => {
|
||||
const c = r.content as { sourceReleaseId: string; decision: string; reasons: string[] };
|
||||
return (
|
||||
c.sourceReleaseId === loserRel.id &&
|
||||
c.decision === "deferred" &&
|
||||
c.reasons.includes("deferred:units_inflight")
|
||||
);
|
||||
}),
|
||||
60_000,
|
||||
"另一版本延后",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user