From dc6c25f582eeb1baa333a5f855036c6aec6f703b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:51:28 +0100 Subject: [PATCH] fix(daemon): retry season packs after backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed season-pack grab held the season off the pack lane forever: pack_hard_failed was a bare EXISTS over failed grabs, so one bad torrent disabled pack search for good, against §6.2's "it never gives up entirely, it goes quiet". The guard now rides the shared backoff curve (backoff_elapsed, 1h → 6h → 1d → 3d, capped 7d), counting failed pack grabs as attempts and anchoring on the latest one's grabbed_at. Both the targeted and RSS lanes agree. A manual season search waives the window outright — the season deck is §6.2's escape hatch. Closes #181. Co-Authored-By: Claude Fable 5 --- ...e5681e0c00b1f9692a7142043067ff64a00a1.json | 21 --- ...337f18909a58d0f863c794f93f8285b9133c8.json | 16 +- ...55064d5a83eafe5bc95ddda9a9056d5724f10.json | 104 +++++++++-- ...549ae5075a56a668e0bf836d85e22d9202019.json | 28 +++ crates/arr-core/src/grabbing.rs | 16 +- crates/arr-daemon/src/rss.rs | 20 +- crates/arr-daemon/src/tv_grab.rs | 172 ++++++++++++++---- 7 files changed, 288 insertions(+), 89 deletions(-) delete mode 100644 .sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json create mode 100644 .sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json diff --git a/.sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json b/.sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json deleted file mode 100644 index 92c0152..0000000 --- a/.sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT EXISTS (\n SELECT 1 FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'\n ) AS \"failed!: bool\"", - "describe": { - "columns": [ - { - "name": "failed!: bool", - "ordinal": 0, - "type_info": "Integer", - "origin": "Expression" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1" -} diff --git a/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json b/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json index 4720e1d..29cd7e6 100644 --- a/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json +++ b/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json @@ -6,12 +6,24 @@ { "name": "tmdb_id!: i64", "ordinal": 0, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "movies", + "name": "tmdb_id" + } + } }, { "name": "metadata_refreshed_at", "ordinal": 1, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "movies", + "name": "metadata_refreshed_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json b/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json index 42f6e34..a440463 100644 --- a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json +++ b/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json @@ -6,67 +6,145 @@ { "name": "id!: i64", "ordinal": 0, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "id" + } + } }, { "name": "tmdb_id!: i64", "ordinal": 1, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "tmdb_id" + } + } }, { "name": "tvdb_id", "ordinal": 2, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "tvdb_id" + } + } }, { "name": "title!: String", "ordinal": 3, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "title" + } + } }, { "name": "year", "ordinal": 4, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "year" + } + } }, { "name": "original_language", "ordinal": 5, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "original_language" + } + } }, { "name": "root_id!: i64", "ordinal": 6, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "root_id" + } + } }, { "name": "auto_track!: bool", "ordinal": 7, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "auto_track" + } + } }, { "name": "upstream_ended!: bool", "ordinal": 8, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "upstream_ended" + } + } }, { "name": "metadata_refreshed_at", "ordinal": 9, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "metadata_refreshed_at" + } + } }, { "name": "poster_path", "ordinal": 10, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "poster_path" + } + } }, { "name": "backdrop_path", "ordinal": 11, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "backdrop_path" + } + } }, { "name": "vote_average", "ordinal": 12, - "type_info": "Float" + "type_info": "Float", + "origin": { + "Table": { + "table": "series", + "name": "vote_average" + } + } } ], "parameters": { diff --git a/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json b/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json new file mode 100644 index 0000000..207bd32 --- /dev/null +++ b/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json @@ -0,0 +1,28 @@ +{ + "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'", + "describe": { + "columns": [ + { + "name": "failures!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "last_failed_at?: String", + "ordinal": 1, + "type_info": "Text", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019" +} diff --git a/crates/arr-core/src/grabbing.rs b/crates/arr-core/src/grabbing.rs index 7f8ec4e..0c5eb39 100644 --- a/crates/arr-core/src/grabbing.rs +++ b/crates/arr-core/src/grabbing.rs @@ -4,7 +4,8 @@ //! while a season is airing, grab per episode. A completed season with no //! episodes on disk prefers the pack — one torrent, better seeded, consistent //! encode. A pack that hard-failed must not cost the whole season, so the -//! season falls back to per-episode instead of being blacklisted outright. +//! season falls back to per-episode while the failure's §6.2 backoff window +//! is open, and retries the pack once it elapses — quiet, never off. //! //! Re-grabbing a pack once an airing season completes is deliberately not //! done (§14): a season with any episode already on disk grabs per episode. @@ -31,8 +32,9 @@ pub struct SeasonGrabFacts<'a> { /// re-grabs a pack over episodes on disk, and a pack must not re-import /// what exists). pub any_episode_on_disk: bool, - /// Whether a season-pack grab for this season already hard-failed. - pub pack_hard_failed: bool, + /// Whether a failed season-pack grab still holds the season off the + /// pack lane — true only while the §6.2 backoff window is open. + pub pack_backoff_active: bool, } /// Picks the grab mode for one season. @@ -49,7 +51,7 @@ pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode { .iter() .all(|date| date.is_some_and(|date| date <= facts.now)); - if fully_released && !facts.any_episode_on_disk && !facts.pack_hard_failed { + if fully_released && !facts.any_episode_on_disk && !facts.pack_backoff_active { SeasonGrabMode::SeasonPack } else { SeasonGrabMode::PerEpisode @@ -69,7 +71,7 @@ mod tests { air_dates, now: SystemTime::UNIX_EPOCH + 100 * DAY, any_episode_on_disk: false, - pack_hard_failed: false, + pack_backoff_active: false, } } @@ -105,10 +107,10 @@ mod tests { } #[test] - fn a_hard_failed_pack_falls_back_to_per_episode() { + fn a_failed_pack_inside_its_backoff_window_falls_back_to_per_episode() { let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)]; let mut facts = facts(&aired); - facts.pack_hard_failed = true; + facts.pack_backoff_active = true; assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode); } diff --git a/crates/arr-daemon/src/rss.rs b/crates/arr-daemon/src/rss.rs index 03a1cb3..1116b96 100644 --- a/crates/arr-daemon/src/rss.rs +++ b/crates/arr-daemon/src/rss.rs @@ -32,8 +32,8 @@ use arr_dl::TransmissionClient; use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; use crate::grab::{ - store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber, - SeedingRules, + backoff_elapsed, store_episode_release, store_release, Eligible, GrabError, GrabScope, + GrabTarget, Grabber, SeedingRules, }; use crate::indexers::IndexerDirectory; use crate::reconcile::{Action, ActionFuture, Outcome}; @@ -625,11 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result ) .fetch_all(database.pool()) .await?; - let pack_hard_failed = sqlx::query_scalar!( - r#"SELECT EXISTS ( - SELECT 1 FROM grabs - WHERE target_kind = 'season' AND target_id = ? AND state = 'failed' - ) AS "failed!: bool""#, + let failed_packs = sqlx::query!( + r#"SELECT count(*) AS "failures!: i64", + max(grabbed_at) AS "last_failed_at?: String" + FROM grabs + WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, season_id ) .fetch_one(database.pool()) @@ -642,7 +642,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result .collect::>(), now: std::time::SystemTime::now(), any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk), - pack_hard_failed, + pack_backoff_active: failed_packs.failures > 0 + && !backoff_elapsed( + failed_packs.failures, + failed_packs.last_failed_at.as_deref(), + ), }) == SeasonGrabMode::SeasonPack) } diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index 92ae166..a146857 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -79,6 +79,7 @@ impl TvGrabAction { &season, &blacklist, EPISODE_SEARCHES_PER_TICK - episode_searches, + false, ) .await { @@ -102,12 +103,15 @@ impl TvGrabAction { /// Search and grab for one season. `None` means the season was skipped /// without spending an indexer call — nothing due, or no language yet. + /// `ignore_pack_backoff` is the manual trigger's "try again now": it + /// waives the failed-pack backoff window for this one invocation. async fn grab_season( &self, database: &Db, season: &PendingSeason, blacklist: &Blacklist, episode_budget: usize, + ignore_pack_backoff: bool, ) -> Result, GrabError> { // §5.2: without the title's original language there is nothing to // evaluate a release against, and guessing is worse than waiting. @@ -141,7 +145,8 @@ impl TvGrabAction { air_dates: &air_dates, now, any_episode_on_disk: episodes.iter().any(|episode| episode.has_file), - pack_hard_failed: pack_hard_failed(database, season.season_id).await?, + pack_backoff_active: !ignore_pack_backoff + && pack_backoff_active(database, season.season_id).await?, }); let aired_due_gaps: Vec<&SeasonEpisode> = gaps @@ -566,8 +571,10 @@ impl TvGrabAction { } /// The manual season trigger (issue #125's deck, drained per #132): - /// reset every open episode's backoff and run the same pack-or-fall-back - /// lane as the tick, scoped to this one season with no tick budgets. + /// reset every open episode's backoff, waive the failed-pack backoff + /// (§6.2's escape hatch is this deck), and run the same + /// pack-or-fall-back lane as the tick, scoped to this one season with + /// no tick budgets. /// /// A satisfied season — nothing open, or a grab already in flight — /// gets its deck refreshed and no grab; a blocked series refuses. @@ -625,7 +632,7 @@ impl TvGrabAction { ); return Ok(()); } - self.grab_season(database, &season, &blacklist, usize::MAX) + self.grab_season(database, &season, &blacklist, usize::MAX, true) .await?; Ok(()) } @@ -1106,19 +1113,21 @@ async fn load_season_release( })) } -/// Whether a season-pack grab for this season already hard-failed — the -/// fall-back-to-per-episode signal. -async fn pack_hard_failed(database: &Db, season_id: i64) -> Result { - let failed = sqlx::query_scalar!( - r#"SELECT EXISTS ( - SELECT 1 FROM grabs - WHERE target_kind = 'season' AND target_id = ? AND state = 'failed' - ) AS "failed!: bool""#, +/// 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` — failure time itself is not recorded. +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" + FROM grabs + WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, season_id ) .fetch_one(database.pool()) .await?; - Ok(failed) + Ok(row.failures > 0 && !backoff_elapsed(row.failures, row.last_failed_at.as_deref())) } /// A pack for exactly this season. Multi-season packs are never selected @@ -1463,38 +1472,41 @@ mod tests { /// The third acceptance case, selection side: a hard-failed pack put the /// 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. - #[tokio::test] - async fn a_hard_failed_pack_falls_back_to_per_episode() { - let (_dir, database, season_id) = - wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; - // What the import tick left behind: a failed season grab and the - // pack release on the blacklist (§6.3). + /// Seed what the import tick leaves behind after a pack fails: one + /// `failed` season grab per (infohash, age) pair. + async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) { let release_id: i64 = sqlx::query_scalar( "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) - VALUES (7, 'pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920, - 'https://tracker/pack.torrent', '{}', 'eligible') + 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) - VALUES (?, 'season', ?, 'dead', 'failed')", - ) - .bind(release_id) - .bind(season_id) - .execute(database.pool()) - .await - .unwrap(); - arr_db::blacklist::add( - database.pool(), - Some("dead"), - "Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos", - "dolby_vision_profile", - ) - .await - .unwrap(); + for (index, age) in ages.iter().enumerate() { + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, 'season', ?, ?, 'failed', + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", + ) + .bind(release_id) + .bind(season_id) + .bind(format!("dead{index}")) + .bind(age) + .execute(database.pool()) + .await + .unwrap(); + } + } + + /// A failed pack inside its §6.2 backoff window stays per-episode: the + /// window quiets the pack lane, per-episode still makes progress. + #[tokio::test] + async fn a_failed_pack_inside_its_window_falls_back_to_per_episode() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs(&database, season_id, &["-10 minutes"]).await; let indexer = prowlarr().await; let (downloader, fake) = transmission().await; @@ -1520,6 +1532,90 @@ mod tests { ); } + /// §6.2: one failed pack backs the pack lane off for 1h, then the tick + /// retries the pack instead of never trying again. + #[tokio::test] + async fn a_failed_pack_retries_after_its_backoff_window() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs(&database, season_id, &["-2 hours"]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader).tick(&database).await.unwrap(); + + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + assert_eq!( + grabs(&database).await.last().unwrap(), + &("season".to_owned(), season_id, "sent".to_owned()) + ); + } + + /// 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] + async fn a_repeatedly_failed_pack_retries_on_the_capped_curve() { + let inside = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs( + &inside.1, + inside.2, + &["-30 days", "-25 days", "-20 days", "-12 days", "-6 days"], + ) + .await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + action(&indexer, &downloader).tick(&inside.1).await.unwrap(); + assert!( + fake.torrents() + .iter() + .all(|torrent| !torrent.source.ends_with("pack.torrent")), + "6 days into a 7d window the pack lane stays quiet" + ); + + let elapsed = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs( + &elapsed.1, + elapsed.2, + &["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"], + ) + .await; + let (downloader, fake) = transmission().await; + action(&indexer, &downloader) + .tick(&elapsed.1) + .await + .unwrap(); + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + } + + /// The escape hatch (§6.2, issue #181): a manual season search waives + /// the failed-pack backoff and retries the pack right now. + #[tokio::test] + async fn a_manual_season_search_waives_the_pack_backoff() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs(&database, season_id, &["-10 minutes"]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader) + .search_season_now(&database, season_id) + .await + .unwrap(); + + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + assert_eq!( + grabs(&database).await.last().unwrap(), + &("season".to_owned(), season_id, "sent".to_owned()) + ); + assert_eq!( + episode_states(&database).await, + vec!["downloading", "downloading", "downloading"] + ); + } + /// §14: a season with an episode already on disk never grabs the pack, /// even when fully released. The remaining episodes come individually. #[tokio::test]