feat(analysis): worker daemon + real dispatcher + app wiring

The background analysis worker that drains analyze_page jobs:

- analysis::daemon: a lean sibling of the crawler daemon — leases only
  KIND_ANALYZE_PAGE, skip-if-done-unless-force, lease heartbeat, panic +
  timeout isolation, ack done/failed, and a failed page_analysis row on
  terminal (dead-lettered) failure. AnalyzeDispatcher trait seam.
- RealAnalyzeDispatcher: load page → storage.get → VisionClient.analyze →
  persist_analysis (skips a deleted page; caps image bytes).
- app::build spawns the daemon (own plain reqwest client) when
  ANALYSIS_ENABLED; AppHandle/main shut it down alongside the crawler.
- repo::page::find_by_id.

Tests: dispatch+ack-done, skip-when-done, force re-dispatch, terminal
failure writes failed row, panic isolation, ignores non-analyze jobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 18:51:53 +02:00
parent dbde6e02c4
commit 7d80a437bf
9 changed files with 600 additions and 5 deletions

View File

@@ -21,7 +21,11 @@ async fn main() -> anyhow::Result<()> {
let config = mangalord::config::Config::from_env()?;
let addr: SocketAddr = config.bind_address.parse()?;
let mangalord::app::AppHandle { router, daemon } = mangalord::app::build(config).await?;
let mangalord::app::AppHandle {
router,
daemon,
analysis_daemon,
} = mangalord::app::build(config).await?;
tracing::info!(%addr, "mangalord listening");
let listener = tokio::net::TcpListener::bind(addr).await?;
@@ -43,6 +47,17 @@ async fn main() -> anyhow::Result<()> {
);
}
}
if let Some(a) = analysis_daemon {
if tokio::time::timeout(CRAWLER_SHUTDOWN_TIMEOUT, a.shutdown())
.await
.is_err()
{
tracing::warn!(
timeout_s = CRAWLER_SHUTDOWN_TIMEOUT.as_secs(),
"analysis daemon shutdown exceeded timeout; abandoning"
);
}
}
Ok(())
}