diff --git a/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json b/.sqlx/query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json similarity index 55% rename from .sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json rename to .sqlx/query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json index 207bd32..0c19f57 100644 --- a/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json +++ b/.sqlx/query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json @@ -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" } diff --git a/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json b/.sqlx/query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json similarity index 55% rename from .sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json rename to .sqlx/query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json index d9045ff..ed923e9 100644 --- a/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json +++ b/.sqlx/query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json @@ -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" } diff --git a/DESIGN.md b/DESIGN.md index fa9f0bd..82dad57 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 12904f8..e437219 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -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 @@ -2852,8 +2855,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) @@ -2869,6 +2875,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)") diff --git a/crates/arr-daemon/src/rss.rs b/crates/arr-daemon/src/rss.rs index 1116b96..2b353c7 100644 --- a/crates/arr-daemon/src/rss.rs +++ b/crates/arr-daemon/src/rss.rs @@ -625,9 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result ) .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. diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index 1f01aa2..706facf 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -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 { 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 = 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]