fix(daemon): measure attention window from failure
Closes the gap #239 describes: §5.7's 30-day window was filtered on grabbed_at, so a torrent stalling past the window before hard-failing at import never surfaced in the needs-a-decision queue. grabs gains failed_at (migration 0030, backfilled from grabbed_at for existing failed rows), the import tick stamps it on hard fail, and every window query in the daemon notifier and the attention endpoint reads it. §5.7 now states the anchor explicitly. §6.2's pack backoff stays on grabbed_at deliberately; noted on the issue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -144,7 +144,7 @@ impl AttentionAction {
|
||||
FROM grabs g
|
||||
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
|
||||
AND g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
|
||||
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
@@ -266,7 +266,7 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
GROUP BY s.id, s.title, s.year, e.id
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
@@ -289,7 +289,7 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM episodes e
|
||||
WHERE e.season_id = se.id
|
||||
@@ -424,14 +424,16 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// stamped `age_days` in the past, so §5.7's window can be exercised
|
||||
/// without waiting a month.
|
||||
async fn insert_aged_failed_grab(
|
||||
/// grabbed `grab_age_days` ago and failed `fail_age_days` ago, so §5.7's
|
||||
/// window — which runs from the failure — can be exercised without
|
||||
/// waiting a month.
|
||||
async fn insert_dated_failed_grab(
|
||||
database: &Db,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
release_guid: &str,
|
||||
age_days: i64,
|
||||
grab_age_days: i64,
|
||||
fail_age_days: i64,
|
||||
) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
@@ -443,19 +445,42 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, ?, ?, ?, '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 (?, ?, ?, ?, 'failed',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(target_kind)
|
||||
.bind(target_id)
|
||||
.bind(format!("hash-{release_guid}"))
|
||||
.bind(format!("-{age_days} days"))
|
||||
.bind(format!("-{grab_age_days} days"))
|
||||
.bind(format!("-{fail_age_days} days"))
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// failed `age_days` in the past.
|
||||
async fn insert_aged_failed_grab(
|
||||
database: &Db,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
release_guid: &str,
|
||||
age_days: i64,
|
||||
) {
|
||||
insert_dated_failed_grab(
|
||||
database,
|
||||
target_kind,
|
||||
target_id,
|
||||
release_guid,
|
||||
age_days,
|
||||
age_days,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// standing in for what the import tick leaves behind post-probe.
|
||||
async fn insert_failed_grab(
|
||||
@@ -464,26 +489,7 @@ mod tests {
|
||||
target_id: i64,
|
||||
release_guid: &str,
|
||||
) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(release_guid)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
||||
VALUES (?, ?, ?, ?, 'failed')",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(target_kind)
|
||||
.bind(target_id)
|
||||
.bind(format!("hash-{release_guid}"))
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_dated_failed_grab(database, target_kind, target_id, release_guid, 0, 0).await;
|
||||
}
|
||||
|
||||
async fn action(server: &MockServer) -> AttentionAction {
|
||||
@@ -834,4 +840,30 @@ mod tests {
|
||||
"still breaking: back in the queue"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.7/#239: the window runs from the failure, not the grab. A torrent
|
||||
/// that stalls on a slow swarm for five weeks and then hard-fails at
|
||||
/// import is fresh evidence the target is broken, however old the grab.
|
||||
#[tokio::test]
|
||||
async fn a_grab_stalled_past_the_window_before_failing_still_counts() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
// Both grabbed 40 days ago — outside the window — but failed today.
|
||||
insert_dated_failed_grab(&database, "season", season_id, "stalled-one", 40, 0).await;
|
||||
insert_dated_failed_grab(&database, "season", season_id, "stalled-two", 40, 0).await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"grab age is irrelevant: two fresh failures queue the target"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +621,10 @@ impl ImportAction {
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE grabs SET state = 'failed' WHERE id = ?",
|
||||
"UPDATE grabs
|
||||
SET state = 'failed',
|
||||
failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
pending.grab_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
@@ -759,7 +762,10 @@ impl ImportAction {
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE grabs SET state = 'failed' WHERE id = ?",
|
||||
"UPDATE grabs
|
||||
SET state = 'failed',
|
||||
failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
pending.grab_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
@@ -1564,11 +1570,16 @@ mod tests {
|
||||
assert_eq!(normalised, arr_parse::normalise(RELEASE_NAME));
|
||||
assert_eq!(reason, "dolby_vision_profile");
|
||||
|
||||
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
|
||||
.fetch_one(h.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let (grab_state, failed_at): (String, Option<String>) =
|
||||
sqlx::query_as("SELECT state, failed_at FROM grabs")
|
||||
.fetch_one(h.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(grab_state, "failed");
|
||||
assert!(
|
||||
failed_at.is_some(),
|
||||
"§5.7's window runs from the failure, so the failure is stamped"
|
||||
);
|
||||
let movie_state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
|
||||
.fetch_one(h.database.pool())
|
||||
.await
|
||||
@@ -2202,11 +2213,16 @@ mod tests {
|
||||
)],
|
||||
"only the release is blacklisted, never the season"
|
||||
);
|
||||
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
|
||||
.fetch_one(h.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let (grab_state, failed_at): (String, Option<String>) =
|
||||
sqlx::query_as("SELECT state, failed_at FROM grabs")
|
||||
.fetch_one(h.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(grab_state, "failed");
|
||||
assert!(
|
||||
failed_at.is_some(),
|
||||
"§5.7's window runs from the failure, so the failure is stamped"
|
||||
);
|
||||
let states: Vec<(String, bool)> =
|
||||
sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number")
|
||||
.fetch_all(h.database.pool())
|
||||
|
||||
@@ -1139,7 +1139,8 @@ 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` — failure time itself is not recorded.
|
||||
/// 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.
|
||||
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
|
||||
Reference in New Issue
Block a user