merge: fix/c-acquisition

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 21:11:22 +08:00
commit 0f22902c17
5 files changed
+614 -24

No files matched your search

@@ -34,6 +34,7 @@ import { hashJson, jsonb, startOfDay, toApi } from "../util.ts";
import {
type AoDeps,
automationBlock,
CANDIDATE_ARTIFACT_TITLE,
downloaderPathOf,
type Exec,
enqueueTracked,
@@ -47,9 +48,13 @@ import { autoFillCheck, type ForceFill } from "./evaluate-autofill.ts";
import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts";
import {
clearDeferral,
clearReleaseDeferrals,
clearSubscriptionDeferrals,
gateRetryDelayMs,
rangeOf,
recordDeferral,
scheduleSlotReevaluate,
scheduleSlotRetry,
seasonLedgers,
slotCoveredUnits,
slotInflightPlans,
@@ -245,6 +250,11 @@ export async function createSlotsFromTemplates(
}
}
/**
* 预算用量。并发 = 在跑的下载任务(不含 stalled:无进展任务不占名额,否则卡住几个就把追更全挡住)
* + 已预留但还没建下载任务的计划(刚预留、等补投;防止一轮评估连续预留超过并发上限)。
* 已建下载任务的预留计划按任务计,不重复算。
*/
async function budgetUsage(exec: Exec, now: Date) {
const day = startOfDay(now);
const r = await sql<{ new_titles: number; bytes: number; reserved: number; active: number; reserved_count: number }>`
@@ -253,8 +263,11 @@ async function budgetUsage(exec: Exec, now: Date) {
and evaluation->>'mode' = 'new_title')::int as new_titles,
(select coalesce(sum(reserved_bytes),0) from acquisition_plan where created_at >= ${day} and status in ('submitted','completed'))::bigint as bytes,
(select coalesce(sum(reserved_bytes),0) from acquisition_plan where created_at >= ${day} and status = 'reserved')::bigint as reserved,
(select count(*) from download_task where managed and removed_at is null and status in ('queued','submitting','downloading','stalled'))::int as active,
(select count(*) from acquisition_plan where status = 'reserved')::int as reserved_count`.execute(exec);
(select count(*) from download_task where managed and removed_at is null and status in ('queued','submitting','downloading'))::int as active,
(select count(*) from acquisition_plan p where p.status = 'reserved'
and not exists (select 1 from download_task dt where dt.acquisition_plan_id = p.id and dt.removed_at is null))::int as reserved_count`.execute(
exec,
);
const row = r.rows[0]!;
return {
newTitlesToday: row.new_titles,
@@ -360,11 +373,22 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
const { d, now } = c;
const db = d.ctx.db;
const skip = (reason: string) => c.out.skipped.push({ sourceReleaseId: rel.id, reason });
if (!rel.media_entity_id || rel.resolution_status !== "resolved") return skip("release_unresolved");
if (rel.availability !== "active") return skip(`release_${rel.availability}`);
// 不再参与评估的资源:延后行一并清掉,免得到期空转(L4)
const drop = async (reason: string) => {
await clearReleaseDeferrals(db, rel.id);
return skip(reason);
};
if (!rel.media_entity_id || rel.resolution_status !== "resolved") return drop("release_unresolved");
if (rel.availability !== "active") return drop(`release_${rel.availability}`);
const entity = await resolveEntity(db, rel.media_entity_id);
if (!entity || entity.status === "archived") return skip("entity_unavailable");
if (entity.kind !== "movie" && episodeUnresolved(rel)) return skip("episode_unresolved");
if (!entity || entity.status === "archived") return drop("entity_unavailable");
if (entity.kind !== "movie" && episodeUnresolved(rel)) {
// 订阅侧可见:相关收录版本各记一条候选评估;确认后重发 release.resolved 正常评估时按 (收录版本, 资源) 覆盖
await noteEpisodeUnresolved(c, rel, entity.id, onlySlotId);
return drop("episode_unresolved");
}
// 已确认的资源:先清掉之前的"集数待确认"记录(换算到别的季时下面可能不再进入该收录版本,不会被覆盖)
if (entity.kind !== "movie" && episodeResolutionOf(rel)) await clearEpisodeUnresolvedNotes(c, rel.id, entity.id);
const parsed = parsedOf(rel);
const units = await releaseUnits(
db,
@@ -463,6 +487,7 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
.set({ status: "completed", status_reason: "stop_after_reached" })
.where("id", "=", s.id)
.execute();
await clearSubscriptionDeferrals(db, s.id);
s.status = "completed";
}
}
@@ -477,7 +502,11 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
for (const slot of slots) {
if (onlySlotId && slot.id !== onlySlotId) continue;
const sub = slot.subscription_id ? activeSubs.get(slot.subscription_id) : undefined;
if (slot.subscription_id && !sub) continue; // 订阅已暂停/完成/取消
if (slot.subscription_id && !sub) {
// 订阅已暂停/完成/取消:不评估,也不留延后行
await clearDeferral(db, slot.id, rel.id);
continue;
}
if (slot.content_unit_id && !units.includes(slot.content_unit_id)) {
// Slot 限定某季:季单元是否是本发布单元的父级
const seasonOk = await db
@@ -523,6 +552,37 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
}
}
/** 集数待确认:给仍在追踪的收录版本各写一条 skipped 候选评估(原因 episode_unresolved) */
async function noteEpisodeUnresolved(c: Ctx, rel: SourceReleaseRow, entityId: string, onlySlotId?: string) {
let q = c.d.ctx.db
.selectFrom("acquisition_slot as s")
.leftJoin("subscription as sub", "sub.id", "s.subscription_id")
.select("s.id")
.where("s.media_entity_id", "=", entityId)
.where((eb) => eb.or([eb("s.subscription_id", "is", null), eb("sub.status", "in", [...TRACKING_SUB_STATUSES])]));
if (onlySlotId) q = q.where("s.id", "=", onlySlotId);
for (const s of await q.execute())
await note(c, {
slotId: s.id,
sourceReleaseId: rel.id,
title: rel.title,
planId: null,
decision: "skipped",
mode: null,
rejected: [{ code: "episode_unresolved", message: "集数待确认,确认后自动重新评估" }],
reasons: ["episode_unresolved"],
});
}
async function clearEpisodeUnresolvedNotes(c: Ctx, sourceReleaseId: string, entityId: string) {
await sql`
delete from artifact
where title = ${CANDIDATE_ARTIFACT_TITLE} and subject_type = 'acquisition_slot'
and subject_id in (select id from acquisition_slot where media_entity_id = ${entityId})
and content->>'sourceReleaseId' = ${sourceReleaseId}
and content->'reasons' ? 'episode_unresolved'`.execute(c.d.ctx.db);
}
/** 站点字段 → 评估上下文(做种数、促销下载系数、副标题/标签、站点 id、发布后分钟数) */
export function contextOf(rel: SourceReleaseRow, now: Date = new Date()): EvaluationContext {
const promo = (rel.promotion ?? {}) as { kind?: string; raw?: { downloadFactor?: number | null } };
@@ -607,6 +667,10 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
}
const inflight = await slotInflightPlans(db, slot.id);
const inflightKeys = inflight.flatMap((x) => x.unitKeys);
const deferWithRetry = async (slotId: string, releaseId: string, reason: string, unitKeys: string[]) => {
const attempts = await recordDeferral(db, { slotId, sourceReleaseId: releaseId, reason, unitKeys });
await scheduleSlotRetry(db, slotId, new Date(now.getTime() + gateRetryDelayMs(attempts)));
};
const defer = async (reason: string, unitKeys: string[], mode: AcquisitionMode | null = null) => {
await recordDeferral(db, { slotId: slot.id, sourceReleaseId: rel.id, reason, unitKeys });
await note(c, {
@@ -785,9 +849,21 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
planUnits = fill.planUnits;
const gate = await gates(c, rel, mode, sub);
if (!gate.ok) {
await note(c, { ...base, decision: "skipped", mode, rejected: gate.rejected, reasons: gate.reasons, ...evalExtra });
// 被挡住不是"不要":写延后并按次数退避排重评(电影 / 已满足的剧集收录版本没有周期重评,全靠这里)
const reason = `gate:${gate.kind}`;
if (!c.draft) await deferWithRetry(slot.id, rel.id, reason, planUnits);
await note(c, {
...base,
decision: "deferred",
mode,
rejected: gate.rejected,
reasons: [`deferred:${reason}`, ...gate.reasons],
...evalExtra,
unitKeys: planUnits,
});
c.out.skipped.push({ sourceReleaseId: rel.id, reason: gate.reasons[0] ?? "gated" });
return done;
if (!c.draft) c.out.deferred.push({ sourceReleaseId: rel.id, slotId: slot.id, reason });
return { planId: null, deferred: !c.draft };
}
const r = await reserve(c, {
slot,
@@ -803,6 +879,27 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
...(fill.action === "fill" ? { priority: fill.priority, evaluationExtra: fill.evaluation } : {}),
});
if (r.conflict) return defer("units_inflight", r.conflict, mode);
if (r.retryAt) {
// 同一资源上次计划失败,冷却中:延后到冷却结束再试
await recordDeferral(db, {
slotId: slot.id,
sourceReleaseId: rel.id,
reason: "retry_cooldown",
unitKeys: planUnits,
});
await scheduleSlotRetry(db, slot.id, r.retryAt);
await note(c, {
...base,
decision: "deferred",
mode,
rejected: [],
reasons: ["deferred:retry_cooldown", `retry_at:${r.retryAt.toISOString()}`],
...evalExtra,
unitKeys: planUnits,
});
c.out.deferred.push({ sourceReleaseId: rel.id, slotId: slot.id, reason: "retry_cooldown" });
return { planId: null, deferred: true };
}
const planId = r.planId;
if (!planId) {
await note(c, { ...base, decision: "skipped", mode, rejected: [], reasons: ["slot_changed_or_reserved"] });
@@ -905,8 +1002,11 @@ interface GateOk {
volumeKey: string;
savePath: string;
}
/** 闸门类型:写进延后原因(gate:automation / gate:downloader / gate:budget / gate:cache) */
type GateKind = "automation" | "downloader" | "budget" | "cache";
interface GateNo {
ok: false;
kind: GateKind;
reasons: string[];
rejected: { code: string; message: string }[];
}
@@ -920,13 +1020,13 @@ async function gates(
): Promise<GateOk | GateNo> {
const { d } = c;
const blocked = await automationBlock(d);
if (blocked) return { ok: false, reasons: [blocked], rejected: [] };
if (blocked) return { ok: false, kind: "automation", reasons: [blocked], rejected: [] };
const sp = (sub?.params ?? {}) as { downloaderInstanceId?: string; downloadSavePath?: string };
const inst = await pickInstance(d.ctx.db, await loadInstances(d.ctx.db), {
explicitId: sp.downloaderInstanceId,
siteId: rel.site_id,
});
if (!inst?.savePath) return { ok: false, reasons: ["no_downloader_instance"], rejected: [] };
if (!inst?.savePath) return { ok: false, kind: "downloader", reasons: ["no_downloader_instance"], rejected: [] };
// 订阅指定的保存路径:本机路径经下载器路径映射换成下载器侧路径;本来就是下载器侧路径的原样用
let savePath = inst.savePath;
if (sp.downloadSavePath) {
@@ -934,6 +1034,7 @@ async function gates(
if (!mapped)
return {
ok: false,
kind: "downloader",
reasons: ["download_save_path_unmapped"],
rejected: [
{
@@ -958,6 +1059,7 @@ async function gates(
if (b.decision !== "allow") {
return {
ok: false,
kind: "budget",
reasons: [b.decision === "unknown" ? "budget_unknown_size" : "budget_exceeded"],
rejected: b.violations.map((v) => ({
code: `budget.${v.kind}`,
@@ -969,11 +1071,12 @@ async function gates(
const vol = (await loadVolumes(d, d.ctx.db, await loadInstances(d.ctx.db))).find(
(v) => v.volumeKey === inst.volumeKey,
);
if (!vol) return { ok: false, reasons: ["volume_state_unknown"], rejected: [] };
if (!vol) return { ok: false, kind: "cache", reasons: ["volume_state_unknown"], rejected: [] };
const dv = decideVolume(vol, cache, rel.size_bytes !== null ? { bytes: rel.size_bytes } : {});
if (dv.decision !== "allow") {
return {
ok: false,
kind: "cache",
reasons: dv.reasons.length ? dv.reasons.map((r) => `cache:${r}`) : ["cache:blocked"],
rejected: [
{
@@ -998,6 +1101,16 @@ export function subscriptionSavePath(t: Pick<InstanceTopology, "inst" | "mapping
return hostPathOf(t, p) ? p : null;
}
/**
* 旧计划是否让位给新计划(T1-3c):失败 / 取消 / 过期可以重建(站点 Cookie 修好、下载器恢复、集数映射改号后重评),
* 但用户明确不要的(在下载器里移除、手动取消)和整理阶段失败的(文件已下好,重下同一种子无意义,交给整理重试)仍挡住。
*/
function planRetryable(p: { status: string; status_reason: string | null }): boolean {
if (!["failed", "cancelled", "expired"].includes(p.status)) return false;
const r = p.status_reason ?? "";
return r !== "download_removed" && r !== "cancelled_by_user" && !r.startsWith("organize:");
}
async function reserve(
c: Ctx,
p: {
@@ -1017,7 +1130,7 @@ async function reserve(
priority?: AcquisitionPriority;
evaluationExtra?: Record<string, unknown>;
},
): Promise<{ planId: string | null; conflict?: string[] }> {
): Promise<{ planId: string | null; conflict?: string[]; retryAt?: Date }> {
const { d, now } = c;
const immediate = p.sub?.priority === "immediate";
const priority = immediate ? PRIORITY.immediate : (p.priority ?? PRIORITY[priorityOf(p.mode, immediate)]);
@@ -1109,6 +1222,35 @@ async function reserve(
} else if (slot.revision !== p.slot.revision || busy) {
return { planId: null };
}
// 幂等键只挡在途 / 已完成的计划;失败 / 取消 / 过期的旧计划改名让位(Slot 行锁内,同 Slot 的预留串行;
// 改名与插入在同一事务,唯一约束仍兜底)
const key = `acq:${slot.id}:${p.rel.id}`;
const prev = await trx
.selectFrom("acquisition_plan")
.select(["id", "status", "status_reason", "updated_at"])
.where("idempotency_key", "=", key)
.forUpdate()
.executeTakeFirst();
if (prev) {
if (!planRetryable(prev)) return { planId: null };
if (prev.status === "failed") {
// 失败的冷却一段再重试(按该资源已失败次数翻倍),免得持续失败时每轮都建计划、开待处理
const retired = await trx
.selectFrom("acquisition_plan")
.select((eb) => eb.fn.countAll<number>().as("n"))
.where("slot_id", "=", slot.id)
.where("source_release_id", "=", p.rel.id)
.where("idempotency_key", "like", `${key}:retired:%`)
.executeTakeFirstOrThrow();
const retryAt = new Date(prev.updated_at.getTime() + gateRetryDelayMs(Number(retired.n) + 1));
if (retryAt > now) return { planId: null, retryAt };
}
await trx
.updateTable("acquisition_plan")
.set({ idempotency_key: `${key}:retired:${prev.id}` })
.where("id", "=", prev.id)
.execute();
}
let st: SlotState = { status: slot.status, hasAsset: slot.current_asset_id !== null };
if (!busy) {
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
@@ -1131,7 +1273,7 @@ async function reserve(
}),
payload_hash: hashJson(payload),
expected_revision: slot.revision + 1,
idempotency_key: `acq:${slot.id}:${p.rel.id}`,
idempotency_key: key,
unit_keys: p.unitKeys,
})
.onConflict((oc) => oc.column("idempotency_key").doNothing())
@@ -577,6 +577,7 @@ export async function completeSubscription(
.returningAll()
.executeTakeFirst();
if (!done) return false;
await clearSubscriptionDeferrals(trx, done.id);
await trx
.insertInto("subscription_history")
.values({
@@ -597,16 +598,56 @@ export async function completeSubscription(
// ── 延后与重评调度 ──
/** 写 / 更新延后行,返回累计次数(首次 1) */
export async function recordDeferral(
exec: Exec,
input: { slotId: string; sourceReleaseId: string; reason: string; unitKeys: readonly string[] },
): Promise<void> {
await sql`
): Promise<number> {
const r = await sql<{ attempts: number }>`
insert into slot_deferral (slot_id, source_release_id, reason, unit_keys)
values (${input.slotId}, ${input.sourceReleaseId}, ${input.reason}, ${[...input.unitKeys]})
on conflict (slot_id, source_release_id) do update
set reason = excluded.reason, unit_keys = excluded.unit_keys, attempts = slot_deferral.attempts + 1,
updated_at = now()`.execute(exec);
updated_at = now()
returning attempts`.execute(exec);
return r.rows[0]?.attempts ?? 1;
}
/** 某资源的全部延后行(资源过期 / 不可评估时不再留着空转) */
export async function clearReleaseDeferrals(exec: Exec, sourceReleaseId: string): Promise<void> {
await exec.deleteFrom("slot_deferral").where("source_release_id", "=", sourceReleaseId).execute();
}
/** 某订阅下收录版本的全部延后行(订阅完成 / 暂停 / 取消后不再重评) */
export async function clearSubscriptionDeferrals(exec: Exec, subscriptionId: string): Promise<void> {
await exec
.deleteFrom("slot_deferral")
.where("slot_id", "in", (eb) =>
eb.selectFrom("acquisition_slot").select("id").where("subscription_id", "=", subscriptionId),
)
.execute();
}
/**
* 被闸门挡住(自动化开关 / 下载器 / 预算 / 缓存卷)或失败计划冷却中的延后:按次数退避,
* 30 分钟起翻倍,封顶 6 小时。不等事件触发(预算释放、缓存卷恢复没有统一事件),到点由 sweepDueSlots 重评。
*/
export const GATE_RETRY_BASE_MS = 30 * 60_000;
export const GATE_RETRY_MAX_MS = 6 * 3600_000;
export const gateRetryDelayMs = (attempts: number): number =>
Math.min(GATE_RETRY_BASE_MS * 2 ** Math.max(0, Math.min(attempts, 16) - 1), GATE_RETRY_MAX_MS);
/**
* 最晚在 at 重评该收录版本(已排得更早的不推迟);电影收录版本与已满足的剧集收录版本也适用
* (scheduleSlotReevaluate 只给未满足的剧集排周期)。
*/
export async function scheduleSlotRetry(exec: Exec, slotId: string, at: Date): Promise<void> {
await exec
.updateTable("acquisition_slot")
.set({ next_evaluate_at: at })
.where("id", "=", slotId)
.where((eb) => eb.or([eb("next_evaluate_at", "is", null), eb("next_evaluate_at", ">", at)]))
.execute();
}
export async function clearDeferral(exec: Exec, slotId: string, sourceReleaseId: string): Promise<void> {