feat(crawler): reconcile pass to enqueue mangas missing from the DB (0.85.0)

The interleaved metadata pass misses mangas to list drift (a title slips a
pagination slot during the slow detail walk). Add a reconcile pass: a cheap,
full, list-only walk (refs only, no detail visit, no early stop) that
set-diffs the walked keys against manga_sources and enqueues the strictly-
missing ones as SyncManga jobs. A set-diff is immune to drift — a manga only
has to appear somewhere in the list.

- Build the previously-dead SyncManga worker by extending RealChapterDispatcher
  to run the shared pipeline::process_manga_ref (fetch → upsert → cover →
  chapters), refactored out of run_metadata_pass so both paths stay in lockstep.
  The crawl worker now leases both sync_chapter_content and sync_manga
  (jobs::lease_kinds); both serialize on the single exclusive browser.
- SyncManga payload carries url + title so the worker can rebuild the ref and
  the dead-jobs/history UI can label a missing manga that has no manga row yet.
- "Missing" = strict NOT EXISTS (dropped rows count as present). Re-enqueue is
  skipped when a pending/running/dead SyncManga job already exists, so a gone
  manga (detail 404 → retries → dead) is left dead and not retried.
- New POST /v1/admin/crawler/reconcile (fire-and-forget, shares
  manual_pass_lock) + "Reconcile missing" admin button + Reconciling status
  phase streamed over SSE.
- dead-jobs/history queries surface payload title/url/key; tables fall back to
  them for sync_manga rows; both searches match the payload title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-16 21:50:54 +02:00
parent 35664bccc7
commit dd300a150c
26 changed files with 1387 additions and 218 deletions

View File

@@ -91,6 +91,18 @@ impl ChapterDispatcher for PanickingDispatcher {
}
}
/// Always returns an error — used to drive a job to the dead-letter state.
struct FailingDispatcher {
seen: AtomicUsize,
}
#[async_trait::async_trait]
impl ChapterDispatcher for FailingDispatcher {
async fn dispatch(&self, _payload: JobPayload) -> anyhow::Result<SyncOutcome> {
self.seen.fetch_add(1, Ordering::AcqRel);
anyhow::bail!("simulated detail-page failure")
}
}
/// Never completes — used to verify the worker's outer dispatch timeout.
struct HangingDispatcher {
seen: AtomicUsize,
@@ -226,6 +238,79 @@ async fn workers_drain_jobs_through_dispatcher(pool: PgPool) {
assert_eq!(count_state(&pool, "done").await, 3);
}
async fn enqueue_sync_manga_job(pool: &PgPool, key: &str) -> Uuid {
let res = jobs::enqueue(
pool,
&JobPayload::SyncManga {
source_id: "target".into(),
source_manga_key: key.into(),
url: format!("http://x/{key}"),
title: format!("M {key}"),
},
)
.await
.unwrap();
match res {
jobs::EnqueueResult::Inserted(id) => id,
jobs::EnqueueResult::Skipped => unreachable!("fresh key"),
}
}
#[sqlx::test(migrations = "./migrations")]
async fn worker_leases_sync_manga_and_failure_dead_letters(pool: PgPool) {
// The crawl worker must pick up reconcile-enqueued SyncManga jobs (the
// lease_kinds change), and a failure must dead-letter (leave-dead). Set
// max_attempts=1 so a single failure is terminal — no waiting through
// exponential backoff.
let id = enqueue_sync_manga_job(&pool, "missing-1").await;
sqlx::query("UPDATE crawler_jobs SET max_attempts = 1 WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.unwrap();
let dispatcher = Arc::new(FailingDispatcher {
seen: AtomicUsize::new(0),
});
let session_expired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let cancel = CancellationToken::new();
let handle = daemon::spawn(
pool.clone(),
cancel.clone(),
make_cfg(None, dispatcher.clone(), session_expired, 1),
);
for _ in 0..60 {
if count_state(&pool, "dead").await >= 1 {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
handle.shutdown().await;
assert!(
dispatcher.seen.load(Ordering::Acquire) >= 1,
"worker must lease the sync_manga job"
);
assert_eq!(
count_state(&pool, "dead").await,
1,
"a failed sync_manga job dead-letters (leave-dead)"
);
let last_error: Option<String> =
sqlx::query_scalar("SELECT last_error FROM crawler_jobs WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.unwrap();
assert!(
last_error
.unwrap_or_default()
.contains("simulated detail-page failure"),
"dead job records the failure reason"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn workers_idle_while_session_expired(pool: PgPool) {
let id = enqueue_chapter_job(&pool).await;