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",
|
||||
"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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -2851,8 +2854,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)
|
||||
@@ -2868,6 +2874,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)")
|
||||
|
||||
@@ -625,9 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
||||
)
|
||||
.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.
|
||||
|
||||
@@ -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<bool, GrabError> {
|
||||
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<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
|
||||
/// window — still closed at 6d, open at 8d. Quiet, never off.
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user