fix: pack backoff runs from the failure

#239 moved §5.7's attention window to `failed_at` and left §6.2's pack
ladder on `grabbed_at`. A torrent that stalls for weeks before ffprobe
condemns it at import has elapsed the whole ladder the moment it fails,
so the pack lane retried a source that had just failed — the one thing
the backoff exists to prevent.

The ladder now measures from the failure, the same anchor and the same
column §5.7 reads, with `grabbed_at` as the fallback for rows written
before the column existed. All three sites read
`max(coalesce(failed_at, grabbed_at))`, so the `last_failed_at` alias
holds what its name says — including the one the season deck feeds into
`reopens_at` and `pack_retry_at`, which was showing a grab time under a
name §5.7 had redefined.

DESIGN.md §6.2 states the anchor the way §5.7 states its own.

Tests cover a pack grabbed 35 days ago and failed 10 minutes ago on the
targeted lane, the RSS lane and the season deck.

`just ci` through the gate: 519/519 tests pass, web checks clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-25 11:49:44 +01:00
parent 9a413b10ed
commit 55373d228c
6 changed files with 159 additions and 18 deletions
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
"hash": "3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
"hash": "c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5"
}
+16
View File
@@ -480,6 +480,22 @@ its own, the escape hatch is the season release deck, not a lane exception.
Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the
title's metadata changes. It never gives up entirely, it goes quiet.
**The ladder runs from the failure, not the grab.** A failed season-pack grab
quiets the pack lane on that same curve, and the rung is measured from the
moment the grab entered `failed``grabs.failed_at`, the column §5.7's
attention window reads — not from when it was sent. The two are usually
minutes apart, but a torrent can stall on a dead swarm for five weeks before
`ffprobe` condemns it at import. Measured from the grab, the whole ladder has
already elapsed by the time the failure lands, so the lane retries the source
that just failed at once, which is the one thing the backoff exists to
prevent. The ladder's job is to stay off a source that has recently failed,
and "recently" can only mean recently failed.
One anchor covers both features. §5.7's window and this ladder ask the same
question of the same event and read the same column; a target still broken
keeps producing fresh failures, and each one both re-arms this backoff and
holds the target in the attention queue.
**Do not search before the release exists.** TMDB carries release dates; a
movie with no digital release date gets zero targeted searches. This is the
single largest source of wasted queries in Radarr and it is free to avoid.
+30 -4
View File
@@ -1672,9 +1672,12 @@ pub async fn season_pack_state(
.await?;
// The same failed-pack tally the grab lane backs off on (#181), read
// here so the deck can name the window instead of guessing at one.
// `last_failed_at` really is the failure time (#245): `reopens_at` and
// `pack_retry_at` below hand it to the deck, so an alias holding a grab
// time would put a grab under a name §5.7 gave to something else.
let failed = sqlx::query!(
r#"SELECT count(*) AS "failures!: i64",
max(grabbed_at) AS "last_failed_at?: String"
r#"SELECT count(*) AS "failures!: i64",
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
season_id
@@ -2851,8 +2854,11 @@ mod tests {
.await
.expect("release");
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
grabbed_at, failed_at)
VALUES (?, 'season', ?, 'hash', 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
)
.bind(release_id)
.bind(season_id)
@@ -2868,6 +2874,26 @@ mod tests {
"the deck offers a date, not just a closed door"
);
// §6.2/#245: push the grab five weeks back and leave the failure
// where it is. The deck reads the failure, so the window it names is
// unmoved — anchored on the grab it would have expired long ago and
// the deck would claim the lane was open.
sqlx::query(
"UPDATE grabs SET grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days')
WHERE target_kind = 'season' AND target_id = ?",
)
.bind(season_id)
.execute(pool)
.await
.expect("age the grab");
let stalled = state_of(url.clone()).await;
assert_eq!(stalled["lane"], "per_episode");
assert_eq!(stalled["reason"], "pack_backoff");
assert_eq!(
stalled["pack_retry_at"], quiet["pack_retry_at"],
"the window is anchored on the failure, so aging the grab moves nothing"
);
// §14 outranks it: a pack would re-import what is on disk, so
// clearing the failure would not earn a pack anyway.
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)")
+58 -2
View File
@@ -625,9 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
)
.fetch_all(database.pool())
.await?;
// §6.2's ladder runs from the failure, not the grab (#245), with
// `grabbed_at` as the fallback for rows older than #239's column.
let failed_packs = sqlx::query!(
r#"SELECT count(*) AS "failures!: i64",
max(grabbed_at) AS "last_failed_at?: String"
r#"SELECT count(*) AS "failures!: i64",
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
season_id
@@ -1164,6 +1166,60 @@ mod tests {
);
}
/// §6.2, issue #245: the RSS lane reads the same ladder, anchored on the
/// failure. A pack sent five weeks ago and condemned at import ten
/// minutes ago holds the lane shut, where anchoring on the grab would
/// have handed it the very release class that just failed.
#[tokio::test]
async fn a_pack_that_stalled_for_weeks_stays_backed_off_on_rss() {
let (_dir, database) = wanted(&[]).await;
let (season_id, episodes) =
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
'https://tracker/oldpack.torrent', '{}', 'eligible')
RETURNING id",
)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
grabbed_at, failed_at)
VALUES (?, 'season', ?, 'dead', 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-10 minutes'))",
)
.bind(release_id)
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
let sent: Vec<(String, i64, String)> = tv_grabs(&database)
.await
.into_iter()
.filter(|(_, _, state)| state == "sent")
.collect();
assert!(
!sent.iter().any(|(kind, _, _)| kind == "season"),
"the failure is ten minutes old, so the pack lane is shut: {sent:?}"
);
assert_eq!(
sent,
vec![
("episode".to_owned(), episodes[0], "sent".to_owned()),
("episode".to_owned(), episodes[1], "sent".to_owned()),
("episode".to_owned(), episodes[2], "sent".to_owned()),
]
);
}
/// §6.2 with #117's guard: an episode already on disk keeps the season
/// per-episode here too — the pack is skipped and the open gaps take
/// their singles.
+51 -8
View File
@@ -1139,12 +1139,15 @@ async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabErr
/// Whether failed season-pack grabs still hold this season off the pack
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
/// (each failed grab is one attempt), it never disables it. Anchored on the
/// latest failed grab's `grabbed_at`, not `failed_at` — moving §6.2's retry
/// cadence to failure time is its own decision, not #239's.
/// latest `failed_at` (#245), the same anchor §5.7's window uses: a torrent
/// can stall for weeks before `ffprobe` condemns it, and measured from the
/// grab the whole ladder would already have elapsed when the failure lands.
/// `grabbed_at` is the fallback for rows written before #239 added the
/// column.
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
let row = sqlx::query!(
r#"SELECT count(*) AS "failures!: i64",
max(grabbed_at) AS "last_failed_at?: String"
r#"SELECT count(*) AS "failures!: i64",
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
season_id
@@ -1497,8 +1500,17 @@ mod tests {
/// release on the blacklist and the season falls back to per-episode —
/// the pack is not tried again and the episodes are not written off.
/// Seed what the import tick leaves behind after a pack fails: one
/// `failed` season grab per (infohash, age) pair.
/// `failed` season grab per (infohash, age) pair, grabbed and failed at
/// the same age, which is the usual case — the two are minutes apart.
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
let pairs: Vec<(&str, &str)> = ages.iter().map(|age| (*age, *age)).collect();
stalled_failed_packs(database, season_id, &pairs).await;
}
/// The same seed, but with the grab and the failure at different ages —
/// the #245 case, where a torrent stalls for weeks before `ffprobe`
/// condemns it at import.
async fn stalled_failed_packs(database: &Db, season_id: i64, ages: &[(&str, &str)]) {
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
@@ -1508,16 +1520,19 @@ mod tests {
.fetch_one(database.pool())
.await
.unwrap();
for (index, age) in ages.iter().enumerate() {
for (index, (grabbed_age, failed_age)) in ages.iter().enumerate() {
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
grabbed_at, failed_at)
VALUES (?, 'season', ?, ?, 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(season_id)
.bind(format!("dead{index}"))
.bind(age)
.bind(grabbed_age)
.bind(failed_age)
.execute(database.pool())
.await
.unwrap();
@@ -1576,6 +1591,34 @@ mod tests {
);
}
/// §6.2, issue #245: the ladder runs from the failure, not the grab. A
/// pack sent five weeks ago and condemned by `ffprobe` ten minutes ago
/// is one minute into a 1h window, not five weeks past it — the lane
/// stays quiet and the episodes carry the season instead.
#[tokio::test]
async fn a_pack_that_stalled_for_weeks_backs_off_from_the_failure() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
stalled_failed_packs(&database, season_id, &[("-35 days", "-10 minutes")]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
let sources: Vec<String> = fake
.torrents()
.into_iter()
.map(|torrent| torrent.source)
.collect();
assert!(
sources
.iter()
.all(|source| !source.ends_with("pack.torrent")),
"the grab is five weeks old but the failure is ten minutes old: {sources:?}"
);
assert_eq!(sources.len(), 3, "{sources:?}");
}
/// Repeated failures ride the capped curve: five failed packs mean a 7d
/// window — still closed at 6d, open at 8d. Quiet, never off.
#[tokio::test]