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
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -58,5 +58,5 @@
false false
] ]
}, },
"hash": "d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1" "hash": "0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ", "query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -46,5 +46,5 @@
true true
] ]
}, },
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be" "hash": "35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) 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 ORDER BY title", "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -190,5 +190,5 @@
true true
] ]
}, },
"hash": "55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5" "hash": "3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -94,5 +94,5 @@
false false
] ]
}, },
"hash": "b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b" "hash": "4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -58,5 +58,5 @@
false false
] ]
}, },
"hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86" "hash": "6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8"
} }
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE grabs SET state = 'failed' WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -82,5 +82,5 @@
false false
] ]
}, },
"hash": "b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37" "hash": "dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777"
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE grabs\n SET state = 'failed',\n failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1"
}
+7
View File
@@ -385,6 +385,13 @@ happened within the last 30 days. One bad torrent is not a decision — a
release that hard-failed is blacklisted (§6.3) and the next candidate is release that hard-failed is blacklisted (§6.3) and the next candidate is
grabbed, which is the system working. grabbed, which is the system working.
The window runs from the failure, not the grab. The two are usually minutes
apart, but a torrent can sit stalling on a dead swarm for five weeks before
`ffprobe` finally condemns it — and that failure is fresh evidence the target
is broken now, not history. Measured from the grab it would be born outside
the window and a genuinely broken target could never surface. So `grabs`
records `failed_at` alongside `grabbed_at`, and the window reads it.
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
without it the queue only ever grows and the one season that wants attention without it the queue only ever grows and the one season that wants attention
sits behind eight that were dealt with months ago. It is the queue's version of sits behind eight that were dealt with months ago. It is the queue's version of
+14 -10
View File
@@ -788,7 +788,7 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#) let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
.fetch_all(pool(&state)?) .fetch_all(pool(&state)?)
.await?; .await?;
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) 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 ORDER BY title"#, arr_db::ATTENTION_WINDOW) let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW)
.fetch_all(pool(&state)?) .fetch_all(pool(&state)?)
.await?; .await?;
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?; let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
@@ -884,7 +884,7 @@ async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result<Vec<SeriesAtte
JOIN seasons se ON se.id = e.season_id JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed' 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' AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
HAVING count(DISTINCT g.release_id) >= 2 HAVING count(DISTINCT g.release_id) >= 2
@@ -902,7 +902,7 @@ async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result<Vec<SeriesAtte
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed' 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 ( AND EXISTS (
SELECT 1 FROM episodes e SELECT 1 FROM episodes e
WHERE e.season_id = se.id WHERE e.season_id = se.id
@@ -1913,7 +1913,7 @@ mod tests {
.expect("release") .expect("release")
.last_insert_rowid(); .last_insert_rowid();
release_ids.push(release_id); release_ids.push(release_id);
sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) VALUES (?, 'movie', ?, ?, 'failed')") sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, failed_at) VALUES (?, 'movie', ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))")
.bind(release_id) .bind(release_id)
.bind(movie_id) .bind(movie_id)
.bind(format!("hash-{suffix}")) .bind(format!("hash-{suffix}"))
@@ -2088,8 +2088,9 @@ mod tests {
.get(0) .get(0)
} }
/// A hard-failed grab, stamped `age_days` in the past so the §5.7 window /// A hard-failed grab, its failure stamped `age_days` in the past so the
/// can be exercised without waiting a month. /// §5.7 window — which runs from the failure — can be exercised without
/// waiting a month.
async fn insert_failed_grab( async fn insert_failed_grab(
pool: &sqlx::SqlitePool, pool: &sqlx::SqlitePool,
release_id: i64, release_id: i64,
@@ -2099,14 +2100,17 @@ mod tests {
age_days: i64, age_days: i64,
) { ) {
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 (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", VALUES (?, ?, ?, ?, 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
) )
.bind(release_id) .bind(release_id)
.bind(target_kind) .bind(target_kind)
.bind(target_id) .bind(target_id)
.bind(infohash) .bind(infohash)
.bind(format!("-{age_days} days")) .bind(format!("-{age_days} days"))
.bind(format!("-{age_days} days"))
.execute(pool) .execute(pool)
.await .await
.expect("failed grab"); .expect("failed grab");
@@ -2426,8 +2430,8 @@ mod tests {
.expect("release") .expect("release")
.get(0); .get(0);
sqlx::query( sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, failed_at)
VALUES (?, ?, ?, ?, 'failed')", VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
) )
.bind(release_id) .bind(release_id)
.bind(kind) .bind(kind)
+62 -30
View File
@@ -144,7 +144,7 @@ impl AttentionAction {
FROM grabs g FROM grabs g
WHERE g.target_kind = 'movie' AND g.target_id = movies.id WHERE g.target_kind = 'movie' AND g.target_id = movies.id
AND g.state = 'failed' 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 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 seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed' 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' AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.title, s.year, e.id GROUP BY s.id, s.title, s.year, e.id
HAVING count(DISTINCT g.release_id) >= 2 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 seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed' 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 ( AND EXISTS (
SELECT 1 FROM episodes e SELECT 1 FROM episodes e
WHERE e.season_id = se.id WHERE e.season_id = se.id
@@ -424,14 +424,16 @@ mod tests {
} }
/// A failed grab by `release_guid` against `target_kind`/`target_id`, /// 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 /// grabbed `grab_age_days` ago and failed `fail_age_days` ago, so §5.7's
/// without waiting a month. /// window — which runs from the failure — can be exercised without
async fn insert_aged_failed_grab( /// waiting a month.
async fn insert_dated_failed_grab(
database: &Db, database: &Db,
target_kind: &str, target_kind: &str,
target_id: i64, target_id: i64,
release_guid: &str, release_guid: &str,
age_days: i64, grab_age_days: i64,
fail_age_days: i64,
) { ) {
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)
@@ -443,19 +445,42 @@ mod tests {
.await .await
.unwrap(); .unwrap();
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 (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", VALUES (?, ?, ?, ?, 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
) )
.bind(release_id) .bind(release_id)
.bind(target_kind) .bind(target_kind)
.bind(target_id) .bind(target_id)
.bind(format!("hash-{release_guid}")) .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()) .execute(database.pool())
.await .await
.unwrap(); .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`, /// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// standing in for what the import tick leaves behind post-probe. /// standing in for what the import tick leaves behind post-probe.
async fn insert_failed_grab( async fn insert_failed_grab(
@@ -464,26 +489,7 @@ mod tests {
target_id: i64, target_id: i64,
release_guid: &str, release_guid: &str,
) { ) {
let release_id: i64 = sqlx::query_scalar( insert_dated_failed_grab(database, target_kind, target_id, release_guid, 0, 0).await;
"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();
} }
async fn action(server: &MockServer) -> AttentionAction { async fn action(server: &MockServer) -> AttentionAction {
@@ -834,4 +840,30 @@ mod tests {
"still breaking: back in the queue" "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"
);
}
} }
+20 -4
View File
@@ -621,7 +621,10 @@ impl ImportAction {
) )
.await?; .await?;
sqlx::query!( 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 pending.grab_id
) )
.execute(database.pool()) .execute(database.pool())
@@ -759,7 +762,10 @@ impl ImportAction {
) )
.await?; .await?;
sqlx::query!( 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 pending.grab_id
) )
.execute(database.pool()) .execute(database.pool())
@@ -1564,11 +1570,16 @@ mod tests {
assert_eq!(normalised, arr_parse::normalise(RELEASE_NAME)); assert_eq!(normalised, arr_parse::normalise(RELEASE_NAME));
assert_eq!(reason, "dolby_vision_profile"); assert_eq!(reason, "dolby_vision_profile");
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs") let (grab_state, failed_at): (String, Option<String>) =
sqlx::query_as("SELECT state, failed_at FROM grabs")
.fetch_one(h.database.pool()) .fetch_one(h.database.pool())
.await .await
.unwrap(); .unwrap();
assert_eq!(grab_state, "failed"); 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") let movie_state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
.fetch_one(h.database.pool()) .fetch_one(h.database.pool())
.await .await
@@ -2202,11 +2213,16 @@ mod tests {
)], )],
"only the release is blacklisted, never the season" "only the release is blacklisted, never the season"
); );
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs") let (grab_state, failed_at): (String, Option<String>) =
sqlx::query_as("SELECT state, failed_at FROM grabs")
.fetch_one(h.database.pool()) .fetch_one(h.database.pool())
.await .await
.unwrap(); .unwrap();
assert_eq!(grab_state, "failed"); 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)> = let states: Vec<(String, bool)> =
sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number") sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number")
.fetch_all(h.database.pool()) .fetch_all(h.database.pool())
+2 -1
View File
@@ -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 /// 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` — 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> { 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",
@@ -0,0 +1,8 @@
-- #239: §5.7's 30-day attention window runs from the failure, but only
-- grabbed_at existed, so a torrent that stalled past the window and then
-- hard-failed at import was born outside it. failed_at records the moment
-- the grab entered 'failed'. Existing failed rows get grabbed_at as the
-- best available approximation.
ALTER TABLE grabs ADD COLUMN failed_at TEXT;
UPDATE grabs SET failed_at = grabbed_at WHERE state = 'failed';
+4 -2
View File
@@ -18,11 +18,13 @@ use sqlx::{migrate::MigrateError, SqlitePool};
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision /// §5.7: how long a failed grab keeps counting toward the needs-a-decision
/// queue, as a SQLite time modifier. /// queue, as a SQLite time modifier. Measured from `failed_at` — the moment
/// the grab hard-failed, not the moment it was grabbed — so a torrent that
/// stalls past the window and then fails still counts (#239).
/// ///
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows /// Nothing ever clears a `grabs` row, so without a bound the queue only grows
/// and the one season that wants attention sits behind the ones that do not. /// and the one season that wants attention sits behind the ones that do not.
/// Callers pair it with the `grabbed_at` format: /// Callers pair it with the `failed_at` format:
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`. /// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
pub const ATTENTION_WINDOW: &str = "-30 days"; pub const ATTENTION_WINDOW: &str = "-30 days";