fix(crawler): harden crash & shutdown job recovery
Three failure-mode fixes surfaced by a crawler recovery audit: - Graceful shutdown mid-dispatch now releases the in-flight job back to pending without burning a retry attempt. process_lease wraps the dispatch in a biased tokio::select! cancel arm that aborts the heartbeat and calls jobs::release; previously a mid-job SIGTERM left the row 'running' until lease expiry and cost one of max_attempts. - Boot-time jobs::reclaim_orphaned resets 'running' jobs with an expired lease back to pending (attempt refunded), run once at daemon startup before workers start. Crash recovery is now immediate instead of waiting a full lease window for the lazy lease-expiry path. Safe under multi-replica: only already-expired leases are touched, which a healthy heartbeating peer never has. - Manga-list parsing now warns when listing anchors are dropped for a missing/empty href or title (split into parse_manga_list_anchors so the drop count is testable), turning silent source markup drift into an observable signal. Returned refs are byte-identical to before. Tests added: shutdown_mid_dispatch_releases_lease_without_burning_attempt, reclaim_orphaned_resets_only_expired_running_jobs, and a drop-count unit test. Patch bump 0.81.0 -> 0.81.1 (both manifests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,60 @@ async fn worker_times_out_a_hung_dispatch_and_acks_failed(pool: PgPool) {
|
||||
assert!(dispatcher.seen.load(Ordering::Acquire) >= 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn shutdown_mid_dispatch_releases_lease_without_burning_attempt(pool: PgPool) {
|
||||
// A worker that is mid-dispatch when shutdown is signalled must return
|
||||
// its job to `pending` with the attempt refunded (attempts back to 0),
|
||||
// rather than leaving it `running` until lease expiry with a burned
|
||||
// attempt. Uses a never-completing dispatcher so the job is provably
|
||||
// in-flight when we cancel.
|
||||
enqueue_chapter_job(&pool).await;
|
||||
let dispatcher = Arc::new(HangingDispatcher {
|
||||
seen: AtomicUsize::new(0),
|
||||
});
|
||||
let session_expired = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let cancel = CancellationToken::new();
|
||||
let mut cfg = make_cfg(None, dispatcher.clone(), session_expired, 1);
|
||||
// Long timeout so the dispatch can't be acked-failed by the outer
|
||||
// timeout before we cancel.
|
||||
cfg.job_timeout = Duration::from_secs(60);
|
||||
let handle = daemon::spawn(pool.clone(), cancel.clone(), cfg);
|
||||
|
||||
// Wait until the job is actually leased (running) and the dispatch has
|
||||
// entered the hanging future.
|
||||
let mut entered = false;
|
||||
for _ in 0..40 {
|
||||
if dispatcher.seen.load(Ordering::Acquire) >= 1 && count_state(&pool, "running").await == 1
|
||||
{
|
||||
entered = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
assert!(entered, "dispatch must be in-flight (running) before shutdown");
|
||||
|
||||
handle.shutdown().await;
|
||||
|
||||
assert_eq!(
|
||||
count_state(&pool, "running").await,
|
||||
0,
|
||||
"no job left running after graceful shutdown"
|
||||
);
|
||||
assert_eq!(
|
||||
count_state(&pool, "pending").await,
|
||||
1,
|
||||
"the in-flight job is released back to pending"
|
||||
);
|
||||
let attempts: i32 = sqlx::query_scalar("SELECT attempts FROM crawler_jobs")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
attempts, 0,
|
||||
"graceful shutdown must refund the lease attempt (no burn)"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn workers_drain_jobs_through_dispatcher(pool: PgPool) {
|
||||
enqueue_chapter_job(&pool).await;
|
||||
|
||||
@@ -341,6 +341,79 @@ async fn stale_running_lease_can_be_reclaimed(pool: PgPool) {
|
||||
assert_eq!(second[0].attempts, 2, "attempts bumped again");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn reclaim_orphaned_resets_only_expired_running_jobs(pool: PgPool) {
|
||||
// Three jobs: (a) running with an expired lease — a crash orphan that
|
||||
// must be reclaimed; (b) running with a live lease — a healthy peer's
|
||||
// in-flight job that must be left untouched; (c) pending — irrelevant,
|
||||
// must be untouched. Reclaim must touch only (a), refund its attempt,
|
||||
// and report exactly 1.
|
||||
let orphan = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
EnqueueResult::Inserted(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let live = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
EnqueueResult::Inserted(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let pending = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
EnqueueResult::Inserted(id) => id,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// Lease the two that should become running (attempts -> 1 each).
|
||||
jobs::lease(&pool, None, 10, Duration::from_secs(60))
|
||||
.await
|
||||
.unwrap();
|
||||
// Leave `pending` pending by returning it (release refunds its attempt).
|
||||
jobs::release(&pool, pending).await.unwrap();
|
||||
assert_eq!(job_state(&pool, pending).await, "pending");
|
||||
|
||||
// Orphan: rewind its lease into the past (worker crashed). Live: keep
|
||||
// its lease in the future (peer still heartbeating).
|
||||
sqlx::query("UPDATE crawler_jobs SET leased_until = now() - interval '1 minute' WHERE id = $1")
|
||||
.bind(orphan)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(job_state(&pool, live).await, "running");
|
||||
assert_eq!(job_attempts(&pool, live).await, 1);
|
||||
|
||||
let reclaimed = jobs::reclaim_orphaned(&pool).await.unwrap();
|
||||
assert_eq!(reclaimed, 1, "only the expired-lease running job is reclaimed");
|
||||
|
||||
// Orphan: back to pending, attempt refunded, lease cleared.
|
||||
assert_eq!(job_state(&pool, orphan).await, "pending");
|
||||
assert_eq!(
|
||||
job_attempts(&pool, orphan).await,
|
||||
0,
|
||||
"reclaim refunds the crashed attempt"
|
||||
);
|
||||
let orphan_leased: Option<chrono::DateTime<chrono::Utc>> =
|
||||
sqlx::query_scalar("SELECT leased_until FROM crawler_jobs WHERE id = $1")
|
||||
.bind(orphan)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(orphan_leased.is_none(), "reclaim clears the lease");
|
||||
|
||||
// Live peer job: untouched.
|
||||
assert_eq!(job_state(&pool, live).await, "running");
|
||||
assert_eq!(job_attempts(&pool, live).await, 1);
|
||||
|
||||
// Pending job: untouched.
|
||||
assert_eq!(job_state(&pool, pending).await, "pending");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn ack_done_transitions_state_and_clears_lease(pool: PgPool) {
|
||||
let id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
|
||||
|
||||
Reference in New Issue
Block a user