Merge #181: retry season packs after backoff

Closes #181
This commit is contained in:
Miguel Palhas
2026-08-24 19:52:40 +01:00
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]