fix(acquisition): 计划幂等键只挡在途 / 已完成,失败 / 取消的旧计划让位

acq:{slot}:{release} 原先对 failed / cancelled 也冲突,站点 Cookie 修好、下载器恢复、
重置订阅后同一资源对该收录版本永不再建计划。现在在收录版本行锁内查旧计划:
- reserved / submitted / completed:照旧挡住;
- failed / cancelled / expired:旧计划幂等键改名为 …:retired:{id} 让位,再插新计划(同一事务,唯一约束兜底);
- 用户明确不要的(download_removed、cancelled_by_user)和整理阶段失败(organize:*)仍挡住;
- failed 的按该资源已失败次数冷却(30 分钟起翻倍,封顶 6 小时),冷却中写延后 retry_cooldown 并排到冷却结束重评,
  避免持续失败时每轮都建计划、开待处理。

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 20:47:48 +08:00
1 parent 8c10c12bd7
commit 4532587ad5
2 files changed
+168 -3

No files matched your search

@@ -879,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"] });
@@ -1080,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: {
@@ -1099,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)]);
@@ -1191,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" });
@@ -1213,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())