fix(analysis): bound concurrent OCR inferences with a shared semaphore

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 07:14:19 +02:00
parent 66ae4b221b
commit 5b76e0cc37
6 changed files with 54 additions and 4 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.90.1"
version = "0.90.2"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.90.1"
version = "0.90.2"
edition = "2021"
default-run = "mangalord"

View File

@@ -143,6 +143,18 @@ impl OcrEngine for OcrsEngine {
}
}
/// Bound on concurrent CPU-bound OCR inferences, given the number of analysis
/// workers and the host's available parallelism.
///
/// Each worker's `dispatch` fires one `spawn_blocking` OCR run, so without a
/// bound `ANALYSIS_WORKERS` blocking tasks can run at once. OCR is fully
/// CPU-bound, so running more than there are cores just thrashes the scheduler
/// (and balloons the blocking pool). Cap at the core count, but never below 1
/// and never above the worker count (more permits than workers is pointless).
pub fn ocr_concurrency_limit(workers: usize, cores: usize) -> usize {
workers.min(cores.max(1)).max(1)
}
/// Production dispatcher for the OCR backend: load the page, read its image
/// from storage, run OCR on the blocking pool, and persist the lines. Mirrors
/// [`crate::analysis::daemon::RealAnalyzeDispatcher`] but with no network I/O.
@@ -151,6 +163,10 @@ pub struct OcrAnalyzeDispatcher {
pub storage: Arc<dyn Storage>,
pub engine: Arc<dyn OcrEngine>,
pub max_image_bytes: usize,
/// Caps concurrent CPU-bound OCR inferences across all workers (see
/// [`ocr_concurrency_limit`]). Shared via the `Arc<dyn AnalyzeDispatcher>`,
/// so one permit pool covers every worker.
pub ocr_permits: Arc<tokio::sync::Semaphore>,
}
#[async_trait]
@@ -174,7 +190,13 @@ impl AnalyzeDispatcher for OcrAnalyzeDispatcher {
);
}
// OCR inference is CPU-bound and synchronous — keep it off the async
// worker's runtime thread.
// worker's runtime thread, and gate it behind the shared permit pool so
// ANALYSIS_WORKERS > cores can't oversubscribe the blocking pool.
let _permit = self
.ocr_permits
.acquire()
.await
.map_err(|e| anyhow::anyhow!("OCR semaphore closed: {e}"))?;
let engine = Arc::clone(&self.engine);
let lines = tokio::task::spawn_blocking(move || engine.recognize(&bytes))
.await
@@ -267,4 +289,23 @@ mod tests {
"expected a decode error, got: {err}"
);
}
#[test]
fn ocr_concurrency_limit_caps_at_cores() {
// More workers than cores → clamp to cores (don't oversubscribe).
assert_eq!(ocr_concurrency_limit(8, 4), 4);
}
#[test]
fn ocr_concurrency_limit_caps_at_workers() {
// Fewer workers than cores → only `workers` ever run anyway.
assert_eq!(ocr_concurrency_limit(2, 16), 2);
}
#[test]
fn ocr_concurrency_limit_never_zero() {
// Degenerate inputs still yield at least one permit.
assert_eq!(ocr_concurrency_limit(0, 0), 1);
assert_eq!(ocr_concurrency_limit(1, 0), 1);
}
}

View File

@@ -443,11 +443,19 @@ async fn spawn_analysis_daemon(
cfg.ocr_max_decode_pixels,
)
.context("build ocrs engine")?;
// Cap concurrent CPU-bound OCR runs across all workers so a high
// ANALYSIS_WORKERS can't oversubscribe the blocking pool.
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
let permits =
crate::analysis::ocr::ocr_concurrency_limit(cfg.workers, cores);
let dispatcher = Arc::new(crate::analysis::ocr::OcrAnalyzeDispatcher {
db: db.clone(),
storage,
engine: Arc::new(engine),
max_image_bytes: cfg.max_image_bytes,
ocr_permits: Arc::new(tokio::sync::Semaphore::new(permits)),
});
// In-process engine is always ready — no gate.
(dispatcher, None)

View File

@@ -56,6 +56,7 @@ fn ocr_dispatcher(
storage,
engine: StubOcrEngine::new(lines),
max_image_bytes: 8 * 1024 * 1024,
ocr_permits: Arc::new(tokio::sync::Semaphore::new(1)),
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.90.1",
"version": "0.90.2",
"private": true,
"type": "module",
"scripts": {