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:
Miguel Palhas
2026-08-25 11:05:59 +01:00
parent 0a0c6359a4
commit 528aadf59c
15 changed files with 147 additions and 77 deletions
+62 -30
View File
@@ -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"
);
}
}