diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 46cc0b0..91317d3 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.93.7" +version = "0.93.8" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b6fd05d..b0eef30 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.93.7" +version = "0.93.8" edition = "2021" default-run = "mangalord" diff --git a/backend/src/crawler/daemon.rs b/backend/src/crawler/daemon.rs index 9132583..4aee574 100644 --- a/backend/src/crawler/daemon.rs +++ b/backend/src/crawler/daemon.rs @@ -301,9 +301,9 @@ impl CronContext { } Err(e) => tracing::error!(?e, "cron: enqueue_bookmarked_pending failed"), } - match jobs::reap_done(pool, retention_days).await { - Ok(n) => tracing::info!(reaped = n, "cron: done-job reaper finished"), - Err(e) => tracing::error!(?e, "cron: done-job reaper failed"), + match jobs::reap_terminal(pool, retention_days).await { + Ok(n) => tracing::info!(reaped = n, "cron: terminal-job reaper finished"), + Err(e) => tracing::error!(?e, "cron: terminal-job reaper failed"), } match crate::repo::crawl_metrics::reap(pool, metrics_retention_days).await { Ok(n) => tracing::info!(reaped = n, "cron: crawl-metrics reaper finished"), diff --git a/backend/src/crawler/jobs.rs b/backend/src/crawler/jobs.rs index 9ab0224..d5ba276 100644 --- a/backend/src/crawler/jobs.rs +++ b/backend/src/crawler/jobs.rs @@ -485,16 +485,19 @@ pub async fn reclaim_orphaned(pool: &PgPool) -> sqlx::Result { Ok(result.rows_affected()) } -/// Delete `done` jobs whose `updated_at` is older than `retention_days` -/// days. `0` disables the reaper without touching the table. Returns the -/// number of rows removed. -pub async fn reap_done(pool: &PgPool, retention_days: u32) -> sqlx::Result { +/// Delete **terminal** jobs (`done` or `dead`) whose `updated_at` is older +/// than `retention_days` days. Both states are end-of-life — `done` +/// succeeded, `dead` exhausted its retries — and neither is ever leased +/// again, so reaping only `done` left `dead` rows to accumulate forever. +/// `pending` / `running` are active and never touched. `0` disables the +/// reaper without touching the table. Returns the number of rows removed. +pub async fn reap_terminal(pool: &PgPool, retention_days: u32) -> sqlx::Result { if retention_days == 0 { return Ok(0); } let result = sqlx::query( "DELETE FROM crawler_jobs \ - WHERE state = 'done' \ + WHERE state IN ('done', 'dead') \ AND updated_at < now() - ($1::bigint || ' days')::interval", ) .bind(retention_days as i64) diff --git a/backend/src/repo/crawl_metrics.rs b/backend/src/repo/crawl_metrics.rs index 663e54e..bc3f7e4 100644 --- a/backend/src/repo/crawl_metrics.rs +++ b/backend/src/repo/crawl_metrics.rs @@ -187,7 +187,7 @@ pub async fn list_ops( } /// Delete metric rows older than `retention_days`. `0` disables the reaper -/// (returns 0 without touching the table). Mirrors `jobs::reap_done`. +/// (returns 0 without touching the table). Mirrors `jobs::reap_terminal`. pub async fn reap(pool: &PgPool, retention_days: u32) -> sqlx::Result { if retention_days == 0 { return Ok(0); diff --git a/backend/src/repo/crawler.rs b/backend/src/repo/crawler.rs index 4eaf3b8..beeb88f 100644 --- a/backend/src/repo/crawler.rs +++ b/backend/src/repo/crawler.rs @@ -903,9 +903,8 @@ pub struct JobHistoryFilter<'a> { /// manga/chapter/page context (best-effort) so the table can label rows. /// Returns the page slice plus the filtered total for pagination. /// -/// History depth is bounded by the done-job reaper (`reap_done`): completed -/// jobs older than the retention window are gone. Terminal `dead` jobs -/// persist until requeued. +/// History depth is bounded by the terminal-job reaper (`reap_terminal`): +/// `done` and `dead` jobs older than the retention window are gone. pub async fn list_job_history( pool: &PgPool, filter: JobHistoryFilter<'_>, diff --git a/backend/tests/crawler_jobs.rs b/backend/tests/crawler_jobs.rs index 8d67828..41ef479 100644 --- a/backend/tests/crawler_jobs.rs +++ b/backend/tests/crawler_jobs.rs @@ -718,42 +718,49 @@ async fn release_returns_to_pending_and_undoes_attempt_increment(pool: PgPool) { } #[sqlx::test(migrations = "./migrations")] -async fn reap_done_deletes_old_rows_keeps_fresh(pool: PgPool) { - // Two done rows: one old (updated_at 10 days ago), one fresh. - let old_id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4())) - .await - .unwrap() - { - EnqueueResult::Inserted(id) => id, - _ => unreachable!(), - }; - let fresh_id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4())) - .await - .unwrap() - { - EnqueueResult::Inserted(id) => id, - _ => unreachable!(), - }; +async fn reap_terminal_deletes_old_done_and_dead_keeps_fresh_and_active(pool: PgPool) { + // Helper: enqueue a fresh pending job and return its id. + async fn enqueue_one(pool: &PgPool) -> Uuid { + match jobs::enqueue(pool, &chapter_content_payload(Uuid::new_v4())) + .await + .unwrap() + { + EnqueueResult::Inserted(id) => id, + _ => unreachable!(), + } + } + let old_done = enqueue_one(&pool).await; + let old_dead = enqueue_one(&pool).await; + let fresh_done = enqueue_one(&pool).await; + let fresh_dead = enqueue_one(&pool).await; + let old_pending = enqueue_one(&pool).await; + + // Old terminal rows (10 days) in both terminal states — both must reap. sqlx::query("UPDATE crawler_jobs SET state='done', updated_at = now() - interval '10 days' WHERE id = $1") - .bind(old_id) - .execute(&pool) - .await - .unwrap(); + .bind(old_done).execute(&pool).await.unwrap(); + sqlx::query("UPDATE crawler_jobs SET state='dead', updated_at = now() - interval '10 days' WHERE id = $1") + .bind(old_dead).execute(&pool).await.unwrap(); + // Fresh terminal rows — inside the retention window, kept. sqlx::query("UPDATE crawler_jobs SET state='done' WHERE id = $1") - .bind(fresh_id) - .execute(&pool) - .await - .unwrap(); + .bind(fresh_done).execute(&pool).await.unwrap(); + sqlx::query("UPDATE crawler_jobs SET state='dead' WHERE id = $1") + .bind(fresh_dead).execute(&pool).await.unwrap(); + // Old but still active (pending) — never reaped regardless of age. + sqlx::query("UPDATE crawler_jobs SET updated_at = now() - interval '10 days' WHERE id = $1") + .bind(old_pending).execute(&pool).await.unwrap(); - let deleted = jobs::reap_done(&pool, 7).await.unwrap(); - assert_eq!(deleted, 1); + let deleted = jobs::reap_terminal(&pool, 7).await.unwrap(); + assert_eq!(deleted, 2, "both old done and old dead rows are reaped"); - let remaining: Vec = sqlx::query_scalar("SELECT id FROM crawler_jobs ORDER BY id") + let mut remaining: Vec = sqlx::query_scalar("SELECT id FROM crawler_jobs") .fetch_all(&pool) .await .unwrap(); - assert_eq!(remaining, vec![fresh_id], "only fresh row remains"); + remaining.sort(); + let mut expected = vec![fresh_done, fresh_dead, old_pending]; + expected.sort(); + assert_eq!(remaining, expected, "fresh terminal + active-pending rows survive"); } #[sqlx::test(migrations = "./migrations")] @@ -840,7 +847,7 @@ async fn lease_ties_on_scheduled_at_break_by_created_at(pool: PgPool) { } #[sqlx::test(migrations = "./migrations")] -async fn reap_done_zero_is_a_no_op(pool: PgPool) { +async fn reap_terminal_zero_is_a_no_op(pool: PgPool) { let id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4())) .await .unwrap() @@ -854,7 +861,7 @@ async fn reap_done_zero_is_a_no_op(pool: PgPool) { .await .unwrap(); - let deleted = jobs::reap_done(&pool, 0).await.unwrap(); + let deleted = jobs::reap_terminal(&pool, 0).await.unwrap(); assert_eq!(deleted, 0); assert_eq!(job_count(&pool).await, 1); } diff --git a/frontend/package.json b/frontend/package.json index 3437356..2ea9ac7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.93.7", + "version": "0.93.8", "private": true, "type": "module", "scripts": {