Files
Mangalord/backend/tests/analysis_readiness.rs
MechaCat02 b86aa80c87 feat(analysis): gate page leasing on a vision readiness probe
The analysis worker leases an analyze_page job (incrementing attempts in
SQL) before calling vision, so a stopped/loading vision burns all retries
and writes a permanent failed page_analysis row. With the vision-manager
autoscaler idle-stopping the container, that would poison the queue on
every cold start.

Add an optional VisionReadiness seam (HTTP GET /health in production) wired
via the new env-only ANALYSIS_VISION_HEALTH_URL. When set, the worker parks
without leasing until the probe answers 2xx; jobs stay pending with
attempts untouched and resume the instant vision is ready. None preserves
today's behavior for always-on endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:57:48 +02:00

150 lines
5.1 KiB
Rust

//! Integration tests for the analysis worker's *vision readiness gate*.
//!
//! When a readiness probe is wired in and reports "not ready" (the vision
//! container is stopped, or running but the model is still loading), the
//! worker must NOT lease any `analyze_page` job. Leasing increments
//! `attempts` in SQL and a down vision then burns all retries and writes a
//! permanent `failed` row — poisoning the queue while the autoscaler is
//! mid-cold-start. So a not-ready gate must leave jobs untouched
//! (`state='pending'`, `attempts=0`, no `page_analysis` row) and resume the
//! instant the probe flips to ready.
use std::sync::Arc;
use std::time::Duration;
use mangalord::analysis::daemon::{
self,
test_support::{CountingDispatcher, ToggleReadiness},
AnalysisDaemonConfig,
};
use mangalord::repo::page_analysis;
use sqlx::PgPool;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
async fn seed_page(pool: &PgPool) -> Uuid {
let manga_id: Uuid =
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ('M') RETURNING id")
.fetch_one(pool)
.await
.unwrap();
let chapter_id: Uuid =
sqlx::query_scalar("INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id")
.bind(manga_id)
.fetch_one(pool)
.await
.unwrap();
sqlx::query_scalar(
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
VALUES ($1, 1, 'k/1.png', 'image/png') RETURNING id",
)
.bind(chapter_id)
.fetch_one(pool)
.await
.unwrap()
}
async fn job_row(pool: &PgPool, page_id: Uuid) -> (String, i32) {
sqlx::query_as(
"SELECT state, attempts FROM crawler_jobs \
WHERE payload->>'kind' = 'analyze_page' AND payload->>'page_id' = $1",
)
.bind(page_id.to_string())
.fetch_one(pool)
.await
.unwrap()
}
async fn wait_for_state(pool: &PgPool, page_id: Uuid, want: &str) {
let result = tokio::time::timeout(Duration::from_secs(8), async {
loop {
if job_row(pool, page_id).await.0 == want {
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await;
assert!(result.is_ok(), "job for {page_id} never reached state {want}");
}
fn spawn_with(
pool: &PgPool,
dispatcher: Arc<CountingDispatcher>,
readiness: Option<Arc<dyn daemon::VisionReadiness>>,
) -> daemon::AnalysisDaemonHandle {
daemon::spawn(
pool.clone(),
CancellationToken::new(),
AnalysisDaemonConfig {
dispatcher,
workers: 1,
job_timeout: Duration::from_secs(5),
events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
readiness,
},
)
}
#[sqlx::test(migrations = "./migrations")]
async fn not_ready_gate_never_leases_or_fails_the_page(pool: PgPool) {
let page_id = seed_page(&pool).await;
page_analysis::enqueue_for_page(&pool, page_id, false)
.await
.unwrap();
let dispatcher = CountingDispatcher::ok();
let readiness = ToggleReadiness::new(false);
let handle = spawn_with(&pool, dispatcher.clone(), Some(readiness.clone()));
// Give the worker several poll cycles to (wrongly) lease if the gate is
// broken.
tokio::time::sleep(Duration::from_millis(800)).await;
handle.shutdown().await;
assert_eq!(dispatcher.call_count(), 0, "must not dispatch while not ready");
let (state, attempts) = job_row(&pool, page_id).await;
assert_eq!(state, "pending", "job must stay pending while vision is down");
assert_eq!(attempts, 0, "job must not be leased (attempts must stay 0)");
assert!(
page_analysis::load(&pool, page_id).await.unwrap().is_none(),
"no failed/any page_analysis row may be written while not ready"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn resumes_when_readiness_flips_to_ready(pool: PgPool) {
let page_id = seed_page(&pool).await;
page_analysis::enqueue_for_page(&pool, page_id, false)
.await
.unwrap();
let dispatcher = CountingDispatcher::ok();
let readiness = ToggleReadiness::new(false);
let handle = spawn_with(&pool, dispatcher.clone(), Some(readiness.clone()));
// Confirm it is parked, then open the gate.
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(dispatcher.call_count(), 0);
readiness.set(true);
wait_for_state(&pool, page_id, "done").await;
handle.shutdown().await;
assert_eq!(dispatcher.call_count(), 1, "must process once ready");
}
#[sqlx::test(migrations = "./migrations")]
async fn no_gate_processes_as_before(pool: PgPool) {
// readiness = None preserves today's behavior for always-on endpoints.
let page_id = seed_page(&pool).await;
page_analysis::enqueue_for_page(&pool, page_id, false)
.await
.unwrap();
let dispatcher = CountingDispatcher::ok();
let handle = spawn_with(&pool, dispatcher.clone(), None);
wait_for_state(&pool, page_id, "done").await;
handle.shutdown().await;
assert_eq!(dispatcher.call_count(), 1);
}