export interface VolumeSnapshot { volumeId: string; totalBytes: number; usedBytes: number; } export interface InflightTask { taskId: string; planId?: string; volumeId: string; /** 种子选中文件总大小;未知(磁力未取到元数据)时为 undefined */ sizeBytes?: number; /** 已占用磁盘的字节(已下载或已预分配,取二者较大者由调用方给出) */ onDiskBytes: number; /** 是否在管理标签范围内 */ managed: boolean; status: "queued" | "downloading" | "paused" | "completed" | "other"; } export interface Reservation { planId: string; volumeId: string; bytes: number; } export interface ThresholdConfig { /** 硬阈值使用率(%),例如 90 */ hardPct: number; /** 恢复水位(%),例如 85,必须低于 hardPct */ resumePct: number; } export interface CacheVolumeInput { /** 读不到磁盘状态时传 null */ volume: VolumeSnapshot | null; config: ThresholdConfig; inflight: readonly InflightTask[]; reservations: readonly Reservation[]; /** 待提交的新下载;不传表示只做巡检 */ request?: { bytes?: number }; /** 当前是否处于"因本阈值暂停"状态 */ pausedByThreshold: boolean; } export interface CacheVolumeDecision { /** allow = 可提交;block = 阻止新增;pause_managed = 阻止新增并暂停纳管的下载中任务 */ decision: "allow" | "block" | "pause_managed"; /** 降到恢复水位:恢复"因本阈值暂停"的任务(不含用户手动暂停) */ resumeThresholdPaused: boolean; pauseTaskIds: string[]; reasons: string[]; usedPct?: number; projectedPct?: number; pendingBytes: number; reservedBytes: number; unknownSizeTaskIds: string[]; /** 未纳管任务仍在增长:不能声称暂停后磁盘不再增长 */ unmanagedGrowthRisk: boolean; } /** * 缓存卷硬阈值判定(REQ §8.4.1)。 * 预测占用 = 当前已用 + 在途任务剩余需求(size - 已落盘,已预分配不重复扣) + 未提交计划预留 + 本次请求; * 已提交的计划只按在途任务计算一次,不再算预留。多个 qB 共用卷时由调用方按 volumeId 汇总传入。 */ export function evaluateCacheVolume(input: CacheVolumeInput): CacheVolumeDecision { const { config } = input; if (!(config.resumePct < config.hardPct)) throw new Error("resumePct 必须低于 hardPct"); const base = { pauseTaskIds: [] as string[], pendingBytes: 0, reservedBytes: 0, unknownSizeTaskIds: [] as string[], unmanagedGrowthRisk: false, }; const v = input.volume; if (!v || !(v.totalBytes > 0)) { return { ...base, decision: "block", resumeThresholdPaused: false, reasons: ["volume_state_unknown"] }; } const tasks = input.inflight.filter((t) => t.volumeId === v.volumeId); let pending = 0; const unknownSize: string[] = []; let unmanagedGrowth = false; for (const t of tasks) { if (t.status === "completed") continue; if (t.sizeBytes === undefined) { unknownSize.push(t.taskId); continue; } const rest = Math.max(0, t.sizeBytes - t.onDiskBytes); pending += rest; if (!t.managed && rest > 0) unmanagedGrowth = true; } const submittedPlans = new Set(tasks.map((t) => t.planId).filter((p): p is string => p !== undefined)); const reserved = input.reservations .filter((r) => r.volumeId === v.volumeId && !submittedPlans.has(r.planId)) .reduce((s, r) => s + r.bytes, 0); const reqBytes = input.request?.bytes ?? 0; const usedPct = (v.usedBytes / v.totalBytes) * 100; const projectedPct = ((v.usedBytes + pending + reserved + reqBytes) / v.totalBytes) * 100; const out = { ...base, pendingBytes: pending, reservedBytes: reserved, unknownSizeTaskIds: unknownSize, unmanagedGrowthRisk: unmanagedGrowth, usedPct, projectedPct, }; const reasons: string[] = []; if (usedPct >= config.hardPct) { return { ...out, decision: "pause_managed", resumeThresholdPaused: false, pauseTaskIds: tasks.filter((t) => t.managed && t.status === "downloading").map((t) => t.taskId), reasons: ["hard_threshold_reached"], }; } const resume = input.pausedByThreshold && usedPct <= config.resumePct; if (input.pausedByThreshold && !resume) reasons.push("awaiting_resume_watermark"); if (input.request && input.request.bytes === undefined) reasons.push("request_size_unknown"); if (input.request && unknownSize.length > 0) reasons.push("inflight_size_unknown"); if (projectedPct >= config.hardPct) reasons.push("projected_over_threshold"); if (resume) reasons.push("below_resume_watermark"); const blocked = reasons.some((r) => r !== "below_resume_watermark"); return { ...out, decision: blocked ? "block" : "allow", resumeThresholdPaused: resume, reasons }; }