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:
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
|
"hash": "3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
|
"hash": "c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5"
|
||||||
}
|
}
|
||||||
@@ -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
|
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.
|
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
|
**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
|
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.
|
single largest source of wasted queries in Radarr and it is free to avoid.
|
||||||
|
|||||||
@@ -1672,9 +1672,12 @@ pub async fn season_pack_state(
|
|||||||
.await?;
|
.await?;
|
||||||
// The same failed-pack tally the grab lane backs off on (#181), read
|
// 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.
|
// 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!(
|
let failed = sqlx::query!(
|
||||||
r#"SELECT count(*) AS "failures!: i64",
|
r#"SELECT count(*) AS "failures!: i64",
|
||||||
max(grabbed_at) AS "last_failed_at?: String"
|
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||||
FROM grabs
|
FROM grabs
|
||||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||||
season_id
|
season_id
|
||||||
@@ -2851,8 +2854,11 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("release");
|
.expect("release");
|
||||||
sqlx::query(
|
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,
|
||||||
VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
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(release_id)
|
||||||
.bind(season_id)
|
.bind(season_id)
|
||||||
@@ -2868,6 +2874,26 @@ mod tests {
|
|||||||
"the deck offers a date, not just a closed door"
|
"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
|
// §14 outranks it: a pack would re-import what is on disk, so
|
||||||
// clearing the failure would not earn a pack anyway.
|
// 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)")
|
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)")
|
||||||
|
|||||||
@@ -625,9 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
|||||||
)
|
)
|
||||||
.fetch_all(database.pool())
|
.fetch_all(database.pool())
|
||||||
.await?;
|
.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!(
|
let failed_packs = sqlx::query!(
|
||||||
r#"SELECT count(*) AS "failures!: i64",
|
r#"SELECT count(*) AS "failures!: i64",
|
||||||
max(grabbed_at) AS "last_failed_at?: String"
|
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||||
FROM grabs
|
FROM grabs
|
||||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||||
season_id
|
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
|
/// §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
|
/// per-episode here too — the pack is skipped and the open gaps take
|
||||||
/// their singles.
|
/// their singles.
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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
|
/// (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
|
/// latest `failed_at` (#245), the same anchor §5.7's window uses: a torrent
|
||||||
/// cadence to failure time is its own decision, not #239's.
|
/// 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> {
|
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
r#"SELECT count(*) AS "failures!: i64",
|
r#"SELECT count(*) AS "failures!: i64",
|
||||||
max(grabbed_at) AS "last_failed_at?: String"
|
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||||
FROM grabs
|
FROM grabs
|
||||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||||
season_id
|
season_id
|
||||||
@@ -1497,8 +1500,17 @@ mod tests {
|
|||||||
/// release on the blacklist and the season falls back to per-episode —
|
/// 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.
|
/// 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
|
/// 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]) {
|
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(
|
let release_id: i64 = sqlx::query_scalar(
|
||||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||||
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
||||||
@@ -1508,16 +1520,19 @@ mod tests {
|
|||||||
.fetch_one(database.pool())
|
.fetch_one(database.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for (index, age) in ages.iter().enumerate() {
|
for (index, (grabbed_age, failed_age)) in ages.iter().enumerate() {
|
||||||
sqlx::query(
|
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',
|
VALUES (?, 'season', ?, ?, 'failed',
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
|
||||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||||
)
|
)
|
||||||
.bind(release_id)
|
.bind(release_id)
|
||||||
.bind(season_id)
|
.bind(season_id)
|
||||||
.bind(format!("dead{index}"))
|
.bind(format!("dead{index}"))
|
||||||
.bind(age)
|
.bind(grabbed_age)
|
||||||
|
.bind(failed_age)
|
||||||
.execute(database.pool())
|
.execute(database.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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
|
/// Repeated failures ride the capped curve: five failed packs mean a 7d
|
||||||
/// window — still closed at 6d, open at 8d. Quiet, never off.
|
/// window — still closed at 6d, open at 8d. Quiet, never off.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user