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
+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]