feat(analysis): make OCR the active backend, keep vision dormant
The worker now always dispatches through the in-process ocrs engine. `AnalysisConfig::effective_backend()` returns Ocr regardless of the parsed `ANALYSIS_BACKEND` (warning if `vision` was requested), and `spawn_analysis_daemon` selects on it. The vision dispatcher, client, and readiness probe stay compiled and intact behind the unreachable match arm, so re-enabling is a one-line change. The startup-reclaim regression test no longer leaned on the vision arm to skip model loading; it now points the engine at a missing model path and asserts the orphaned lease is still reclaimed before the (failing) engine build — proving reclaim runs independently of engine readiness. Also gitignore backend/models/*.rten so locally-downloaded ocrs models for native `cargo run` aren't committed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4
backend/.gitignore
vendored
4
backend/.gitignore
vendored
@@ -1,3 +1,7 @@
|
|||||||
/target
|
/target
|
||||||
/.sqlx
|
/.sqlx
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local OCR models for native dev (downloaded, not source)
|
||||||
|
models/
|
||||||
|
*.rten
|
||||||
|
|||||||
@@ -434,7 +434,7 @@ async fn spawn_analysis_daemon(
|
|||||||
let (dispatcher, readiness): (
|
let (dispatcher, readiness): (
|
||||||
Arc<dyn crate::analysis::daemon::AnalyzeDispatcher>,
|
Arc<dyn crate::analysis::daemon::AnalyzeDispatcher>,
|
||||||
Option<Arc<dyn crate::analysis::daemon::VisionReadiness>>,
|
Option<Arc<dyn crate::analysis::daemon::VisionReadiness>>,
|
||||||
) = match cfg.backend {
|
) = match cfg.effective_backend() {
|
||||||
crate::config::AnalysisBackend::Ocr => {
|
crate::config::AnalysisBackend::Ocr => {
|
||||||
// Load the `.rten` models once; a bad path is a loud boot error.
|
// Load the `.rten` models once; a bad path is a loud boot error.
|
||||||
let engine = crate::analysis::ocr::OcrsEngine::from_model_paths(
|
let engine = crate::analysis::ocr::OcrsEngine::from_model_paths(
|
||||||
@@ -1473,29 +1473,30 @@ mod tests {
|
|||||||
// Bind to a local so the TempDir lives for the rest of the test.
|
// Bind to a local so the TempDir lives for the rest of the test.
|
||||||
// `tempfile::tempdir().unwrap().path()` would drop the TempDir
|
// `tempfile::tempdir().unwrap().path()` would drop the TempDir
|
||||||
// at end-of-expression and `LocalStorage` would hold a path to
|
// at end-of-expression and `LocalStorage` would hold a path to
|
||||||
// a deleted directory. (Today this is fine because the dispatch
|
// a deleted directory.
|
||||||
// fails before storage is touched, but it makes the test fragile
|
|
||||||
// to any future code rearrangement.)
|
|
||||||
let storage_dir = tempfile::tempdir().unwrap();
|
let storage_dir = tempfile::tempdir().unwrap();
|
||||||
let storage: Arc<dyn Storage> =
|
let storage: Arc<dyn Storage> =
|
||||||
Arc::new(LocalStorage::new(storage_dir.path()));
|
Arc::new(LocalStorage::new(storage_dir.path()));
|
||||||
let mut cfg = crate::config::AnalysisConfig::default();
|
let mut cfg = crate::config::AnalysisConfig::default();
|
||||||
// Use the vision backend so the daemon doesn't try to load the ocrs
|
// The worker always runs OCR now (vision is dormant — see
|
||||||
// `.rten` models (absent in unit CI). This test only exercises the
|
// `effective_backend`), and the `.rten` models aren't shipped to unit
|
||||||
// pre-worker lease reclaim, which is engine-agnostic; no dispatch runs.
|
// CI. Point the engine at a path that can't exist so the *engine build*
|
||||||
cfg.backend = crate::config::AnalysisBackend::Vision;
|
// fails deterministically. Reclaim runs at the very top of
|
||||||
|
// `spawn_analysis_daemon`, before — and independently of — engine
|
||||||
|
// readiness, so the row must still be reclaimed even though spawn
|
||||||
|
// returns Err. That's exactly the regression this test guards (an
|
||||||
|
// analysis-only deploy must reclaim orphaned leases at startup).
|
||||||
|
cfg.ocr_detection_model = "/nonexistent/text-detection.rten".to_string();
|
||||||
|
cfg.ocr_recognition_model = "/nonexistent/text-recognition.rten".to_string();
|
||||||
cfg.workers = 1;
|
cfg.workers = 1;
|
||||||
cfg.job_timeout = Duration::from_secs(1);
|
cfg.job_timeout = Duration::from_secs(1);
|
||||||
let events = Arc::new(crate::analysis::events::AnalysisEvents::new());
|
let events = Arc::new(crate::analysis::events::AnalysisEvents::new());
|
||||||
|
|
||||||
let handle = spawn_analysis_daemon(pool.clone(), storage, &cfg, events)
|
let spawned = spawn_analysis_daemon(pool.clone(), storage, &cfg, events).await;
|
||||||
.await
|
assert!(
|
||||||
.expect("spawn");
|
spawned.is_err(),
|
||||||
// Immediately shut down — we're only here to prove reclaim ran.
|
"engine build must fail with a missing model path"
|
||||||
// The workers may briefly pick up the now-pending row; that's
|
);
|
||||||
// fine, but we cancel before any real dispatch (the LocalStorage
|
|
||||||
// key doesn't exist, so a dispatch would fail anyway).
|
|
||||||
handle.shutdown().await;
|
|
||||||
|
|
||||||
// The reclaim must have moved the row back to pending with the
|
// The reclaim must have moved the row back to pending with the
|
||||||
// attempt refunded (attempts goes from 1 → 0). Reaching it on the
|
// attempt refunded (attempts goes from 1 → 0). Reaching it on the
|
||||||
|
|||||||
@@ -262,6 +262,24 @@ impl Default for AnalysisConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AnalysisConfig {
|
impl AnalysisConfig {
|
||||||
|
/// The backend the worker actually dispatches through.
|
||||||
|
///
|
||||||
|
/// Vision is **temporarily disabled**: the engine code (`analysis::vision`,
|
||||||
|
/// `RealAnalyzeDispatcher`, the readiness probe) is kept intact but never
|
||||||
|
/// selected. Until it's re-enabled, this returns [`AnalysisBackend::Ocr`]
|
||||||
|
/// regardless of the parsed `backend`, logging a warning if `vision` was
|
||||||
|
/// requested so an env override isn't silently ignored. Re-enabling vision
|
||||||
|
/// is then a one-line change here (return `self.backend`).
|
||||||
|
pub fn effective_backend(&self) -> AnalysisBackend {
|
||||||
|
if self.backend == AnalysisBackend::Vision {
|
||||||
|
tracing::warn!(
|
||||||
|
"ANALYSIS_BACKEND=vision requested but the vision backend is temporarily \
|
||||||
|
disabled; running OCR instead"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AnalysisBackend::Ocr
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
let d = AnalysisConfig::default();
|
let d = AnalysisConfig::default();
|
||||||
Self {
|
Self {
|
||||||
@@ -840,6 +858,19 @@ mod tests {
|
|||||||
assert_eq!(AnalysisConfig::from_env().backend, AnalysisBackend::Ocr);
|
assert_eq!(AnalysisConfig::from_env().backend, AnalysisBackend::Ocr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_backend_is_ocr_even_when_vision_requested() {
|
||||||
|
// Vision is temporarily disabled: the worker always runs OCR no matter
|
||||||
|
// what `ANALYSIS_BACKEND` parsed to. `backend` still reflects the raw
|
||||||
|
// request (so the override is visible/loggable), but `effective_backend`
|
||||||
|
// is the value the daemon actually dispatches through.
|
||||||
|
let mut cfg = AnalysisConfig::default();
|
||||||
|
cfg.backend = AnalysisBackend::Vision;
|
||||||
|
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
|
||||||
|
cfg.backend = AnalysisBackend::Ocr;
|
||||||
|
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ocr_model_paths_parse_from_env() {
|
fn ocr_model_paths_parse_from_env() {
|
||||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
|||||||
Reference in New Issue
Block a user