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>
This commit is contained in:
149
backend/tests/analysis_readiness.rs
Normal file
149
backend/tests/analysis_readiness.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ fn spawn_with(
|
||||
events: std::sync::Arc::new(
|
||||
mangalord::analysis::events::AnalysisEvents::new(),
|
||||
),
|
||||
readiness: None,
|
||||
},
|
||||
);
|
||||
(handle, cancel)
|
||||
@@ -227,6 +228,7 @@ async fn worker_publishes_started_and_completed_events(pool: PgPool) {
|
||||
workers: 1,
|
||||
job_timeout: Duration::from_secs(5),
|
||||
events: events.clone(),
|
||||
readiness: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -277,6 +279,7 @@ async fn worker_publishes_failed_event_on_dispatch_error(pool: PgPool) {
|
||||
workers: 1,
|
||||
job_timeout: Duration::from_secs(5),
|
||||
events: events.clone(),
|
||||
readiness: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user