@@ -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
|
||||
@@ -2852,8 +2855,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)
|
||||
@@ -2869,6 +2875,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