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>
150 lines
5.1 KiB
Rust
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);
|
|
}
|