//! 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, readiness: Option>, ) -> 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); }