diff --git a/src/api/deobfuscate.rs b/src/api/deobfuscate.rs index a48f8b3..6f74ddc 100644 --- a/src/api/deobfuscate.rs +++ b/src/api/deobfuscate.rs @@ -1,5 +1,7 @@ use axum::Json; +use once_cell::sync::Lazy; use serde::Deserialize; +use tokio::sync::Semaphore; use crate::{ deobfuscator::LineEngine, @@ -7,6 +9,25 @@ use crate::{ mapping::{download::load_mappings, Mappings}, }; +/// Concurrency gate for the heavy load+deobfuscate path. +/// +/// There is no mapping cache by design: each concurrent request holds a full +/// per-version table set (~30MB). On memory-constrained hosts, unbounded +/// `spawn_blocking` would multiply that per in-flight request. The semaphore +/// pins peak memory to `SPINYARN_MAX_CONCURRENCY` x one version. +/// +/// Default 32: LogShare's real traffic is ~1600 RPM (~27 req/s), which at +/// ~150ms/request means a steady-state concurrency of ~16. A limit of 32 never +/// engages in steady state; it only converts burst OOM risk (~30MB per +/// in-flight request) into short queueing. +static GATE: Lazy = Lazy::new(|| { + let n = std::env::var("SPINYARN_MAX_CONCURRENCY") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(32); + Semaphore::new(n) +}); + #[derive(Debug, Deserialize)] pub struct DeobfuscateRequest { pub content: String, @@ -51,8 +72,13 @@ pub async fn handler( return Ok(Json(crate::api::response::ApiResponse::success(passthrough(req)))); } + // Bound peak memory: at most N in-flight mapping table sets. + let _permit = GATE + .acquire() + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let version = req.version.clone(); - let content = req.content.clone(); // CPU-bound: gzip decompress + parse. let loaded: Result, ApiError> = tokio::task::spawn_blocking(move || { @@ -70,6 +96,7 @@ pub async fn handler( Err(e) => return Err(e), }; + let content = req.content.clone(); let deobfuscated = tokio::task::spawn_blocking(move || { let engine = LineEngine::new(mappings); engine.deobfuscate(&content)