fix(daemon): retry season packs after backoff

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 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-24 19:51:28 +01:00
parent a3897df0ab
commit dc6c25f582
7 changed files with 288 additions and 89 deletions
@@ -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"
}
@@ -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": {
@@ -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": {
@@ -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"
}
+9 -7
View File
@@ -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);
}
+12 -8
View File
@@ -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<bool, GrabError>
)
.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<bool, GrabError>
.collect::<Vec<_>>(),
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)
}
+134 -38
View File
@@ -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<Option<SeasonWork>, 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<bool, GrabError> {
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<bool, GrabError> {
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]