ifc_lite_server/admission.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Request admission control: bounded parse concurrency + a byte-weighted
6//! memory budget + a coarse RSS circuit breaker.
7//!
8//! Every parse handler acquires an [`AdmissionGuard`] BEFORE buffering the
9//! upload. Without this, N concurrent large uploads each buffer the full file
10//! and spawn blocking decode/mesh work whose working set is a multiple of the
11//! file size; nothing bounds N, so the container is OOM-killed (a full outage
12//! on the single-replica deployment). The byte-weighted semaphore is the
13//! load-bearing bound: total admitted upload bytes can never exceed the
14//! configured budget. The CPU semaphore keeps the blocking pool from
15//! thrashing, and the RSS breaker sheds load before the kernel does.
16//!
17//! Explicit semaphores were chosen over `tower::limit::GlobalConcurrencyLimit`:
18//! the tower layer makes over-limit requests wait unboundedly with no 503/
19//! `Retry-After`, wraps cheap GET routes too, and cannot express a byte budget.
20
21use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
22use std::sync::Arc;
23
24use tokio::sync::{OwnedSemaphorePermit, Semaphore};
25
26use crate::error::ApiError;
27
28/// Tuning knobs, derived from [`crate::config::Config`] at startup.
29#[derive(Debug, Clone)]
30pub struct AdmissionCfg {
31 /// Parse jobs allowed to run at once (CPU gate).
32 pub max_concurrent_parses: usize,
33 /// Total upload bytes allowed to be admitted at once. `0` disables the
34 /// memory gate (the CPU gate still applies).
35 pub mem_budget_bytes: u64,
36 /// Requests allowed to WAIT for a permit before immediate rejection.
37 pub queue_depth: usize,
38 /// Longest a queued request waits for a permit before rejection.
39 pub queue_timeout: std::time::Duration,
40 /// RSS high-water percentage of `mem_budget_bytes` above which new parse
41 /// jobs are shed outright (the circuit breaker). Inert when the budget or
42 /// the sampled RSS is 0.
43 pub shed_pct: u8,
44}
45
46/// One memory-budget permit spans this many upload bytes, so the semaphore's
47/// permit count stays far below `Semaphore::MAX_PERMITS` even for multi-GB
48/// budgets.
49const BYTES_PER_PERMIT: u64 = 1024 * 1024;
50
51/// Shared admission state (one per process, in `AppState`).
52pub struct Admission {
53 cpu: Arc<Semaphore>,
54 /// `None` when the memory gate is disabled (`mem_budget_bytes == 0`).
55 mem: Option<Arc<Semaphore>>,
56 queued: AtomicUsize,
57 in_flight: AtomicUsize,
58 rejected_overload: AtomicU64,
59 rejected_queue_full: AtomicU64,
60 rejected_shed: AtomicU64,
61 /// Resident set size sampled by the background loop (bytes). Tests write
62 /// this directly; production writes come from [`spawn_rss_sampler`].
63 resident_bytes: AtomicU64,
64 cfg: AdmissionCfg,
65}
66
67/// RAII permit pair; drops release the slots.
68#[derive(Debug)]
69pub struct AdmissionGuard {
70 _cpu: OwnedSemaphorePermit,
71 _mem: Option<OwnedSemaphorePermit>,
72 admission: Arc<Admission>,
73}
74
75impl std::fmt::Debug for Admission {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct("Admission")
78 .field("cfg", &self.cfg)
79 .field("in_flight", &self.in_flight.load(Ordering::Relaxed))
80 .field("queued", &self.queued.load(Ordering::Relaxed))
81 .finish()
82 }
83}
84
85impl Drop for AdmissionGuard {
86 fn drop(&mut self) {
87 self.admission.in_flight.fetch_sub(1, Ordering::Relaxed);
88 }
89}
90
91impl Admission {
92 pub fn new(cfg: AdmissionCfg) -> Self {
93 let mem = (cfg.mem_budget_bytes > 0).then(|| {
94 let permits = (cfg.mem_budget_bytes / BYTES_PER_PERMIT).max(1) as usize;
95 Arc::new(Semaphore::new(permits))
96 });
97 Self {
98 cpu: Arc::new(Semaphore::new(cfg.max_concurrent_parses.max(1))),
99 mem,
100 queued: AtomicUsize::new(0),
101 in_flight: AtomicUsize::new(0),
102 rejected_overload: AtomicU64::new(0),
103 rejected_queue_full: AtomicU64::new(0),
104 rejected_shed: AtomicU64::new(0),
105 resident_bytes: AtomicU64::new(0),
106 cfg,
107 }
108 }
109
110 /// Admit one parse job that will buffer up to `reserve_bytes` of upload.
111 ///
112 /// Multipart rarely declares a length up front, so callers reserve the
113 /// configured max file size — a conservative reservation that guarantees
114 /// the byte bound holds (tighter packing is a follow-up once the actual
115 /// size is known post-extract).
116 pub async fn acquire(
117 self: &Arc<Self>,
118 reserve_bytes: u64,
119 ) -> Result<AdmissionGuard, ApiError> {
120 // 1. RSS circuit breaker: shed before the kernel OOM-killer does.
121 if self.cfg.mem_budget_bytes > 0 && self.cfg.shed_pct > 0 {
122 let rss = self.resident_bytes.load(Ordering::Relaxed);
123 let watermark = self.cfg.mem_budget_bytes / 100 * self.cfg.shed_pct as u64;
124 if rss > 0 && rss >= watermark {
125 self.rejected_shed.fetch_add(1, Ordering::Relaxed);
126 return Err(ApiError::Overloaded {
127 retry_after_secs: self.cfg.queue_timeout.as_secs().max(1) * 2,
128 });
129 }
130 }
131
132 struct QueuedCount<'a>(&'a AtomicUsize);
133 impl Drop for QueuedCount<'_> {
134 fn drop(&mut self) {
135 self.0.fetch_sub(1, Ordering::Relaxed);
136 }
137 }
138
139 // 2. CPU slot: non-blocking first - a free slot admits regardless of
140 // queue configuration. Only WAITERS are subject to the bounded queue:
141 // reject outright when enough requests are already waiting, otherwise
142 // wait for a slot up to the queue timeout.
143 let cpu = match Arc::clone(&self.cpu).try_acquire_owned() {
144 Ok(permit) => permit,
145 Err(_) => {
146 // Reserve the queue slot atomically (CAS): a load-then-add
147 // would let concurrent waiters race past the depth check.
148 let mut cur = self.queued.load(Ordering::Relaxed);
149 loop {
150 if cur >= self.cfg.queue_depth {
151 self.rejected_queue_full.fetch_add(1, Ordering::Relaxed);
152 return Err(ApiError::Overloaded {
153 retry_after_secs: self.cfg.queue_timeout.as_secs().max(1),
154 });
155 }
156 match self.queued.compare_exchange_weak(
157 cur,
158 cur + 1,
159 Ordering::Relaxed,
160 Ordering::Relaxed,
161 ) {
162 Ok(_) => break,
163 Err(actual) => cur = actual,
164 }
165 }
166 let _queued = QueuedCount(&self.queued);
167 match tokio::time::timeout(
168 self.cfg.queue_timeout,
169 Arc::clone(&self.cpu).acquire_owned(),
170 )
171 .await
172 {
173 Ok(Ok(permit)) => permit,
174 _ => {
175 self.rejected_overload.fetch_add(1, Ordering::Relaxed);
176 return Err(ApiError::Overloaded {
177 retry_after_secs: self.cfg.queue_timeout.as_secs().max(1),
178 });
179 }
180 }
181 }
182 };
183
184 // 4. Byte-weighted memory permits (bounded wait). A request larger
185 // than the whole budget is clamped to it, so the largest allowed
186 // upload can always run - alone.
187 //
188 // Ordering is deliberate: CPU first, THEN memory. Memory-first would
189 // let up to queue_depth waiters pin budget BYTES while queueing for a
190 // CPU slot - the scarcer resource held longest. CPU-first bounds the
191 // number of memory waiters to max_concurrent_parses, and a CPU permit
192 // held briefly while memory times out costs only that waiter's slot.
193 let mem = if let Some(mem) = &self.mem {
194 let budget_permits = (self.cfg.mem_budget_bytes / BYTES_PER_PERMIT).max(1);
195 let want = reserve_bytes.div_ceil(BYTES_PER_PERMIT).max(1).min(budget_permits) as u32;
196 match tokio::time::timeout(
197 self.cfg.queue_timeout,
198 Arc::clone(mem).acquire_many_owned(want),
199 )
200 .await
201 {
202 Ok(Ok(permit)) => Some(permit),
203 _ => {
204 self.rejected_overload.fetch_add(1, Ordering::Relaxed);
205 return Err(ApiError::Overloaded {
206 retry_after_secs: self.cfg.queue_timeout.as_secs().max(1),
207 });
208 }
209 }
210 } else {
211 None
212 };
213
214 self.in_flight.fetch_add(1, Ordering::Relaxed);
215 Ok(AdmissionGuard { _cpu: cpu, _mem: mem, admission: Arc::clone(self) })
216 }
217
218 /// Current sampled resident set size in bytes (0 = unknown/not sampled).
219 pub fn resident_bytes(&self) -> u64 {
220 self.resident_bytes.load(Ordering::Relaxed)
221 }
222
223 /// True when the RSS breaker would currently shed new work (readiness).
224 pub fn is_shedding(&self) -> bool {
225 if self.cfg.mem_budget_bytes == 0 || self.cfg.shed_pct == 0 {
226 return false;
227 }
228 let rss = self.resident_bytes.load(Ordering::Relaxed);
229 rss > 0 && rss >= self.cfg.mem_budget_bytes / 100 * self.cfg.shed_pct as u64
230 }
231
232 /// Prometheus text exposition of the admission gauges/counters.
233 pub fn metrics_text(&self) -> String {
234 format!(
235 "# TYPE ifc_server_resident_bytes gauge\n\
236 ifc_server_resident_bytes {}\n\
237 # TYPE ifc_server_mem_budget_bytes gauge\n\
238 ifc_server_mem_budget_bytes {}\n\
239 # TYPE ifc_server_admission_in_flight gauge\n\
240 ifc_server_admission_in_flight {}\n\
241 # TYPE ifc_server_admission_queued gauge\n\
242 ifc_server_admission_queued {}\n\
243 # TYPE ifc_server_admission_rejected_total counter\n\
244 ifc_server_admission_rejected_total{{reason=\"overloaded\"}} {}\n\
245 ifc_server_admission_rejected_total{{reason=\"queue_full\"}} {}\n\
246 ifc_server_admission_rejected_total{{reason=\"rss_shed\"}} {}\n",
247 self.resident_bytes.load(Ordering::Relaxed),
248 self.cfg.mem_budget_bytes,
249 self.in_flight.load(Ordering::Relaxed),
250 self.queued.load(Ordering::Relaxed),
251 self.rejected_overload.load(Ordering::Relaxed),
252 self.rejected_queue_full.load(Ordering::Relaxed),
253 self.rejected_shed.load(Ordering::Relaxed),
254 )
255 }
256
257 /// Test/simulation hook: set the sampled RSS directly.
258 pub fn set_resident_bytes(&self, bytes: u64) {
259 self.resident_bytes.store(bytes, Ordering::Relaxed);
260 }
261}
262
263/// Resident set size of this process in bytes, or 0 when unknown.
264///
265/// Linux (`/proc/self/statm`) covers every production target (Docker,
266/// Railway, the three release binaries). Other platforms return 0, which
267/// leaves the RSS breaker inert on dev machines - the admission semaphores
268/// still bound memory there. Kept behind this helper so the source can be
269/// swapped (e.g. for jemalloc stats) without touching call sites; the
270/// allocator swap itself was deliberately skipped because the release matrix
271/// includes a musl target where jemalloc is a known build risk.
272pub fn resident_bytes_now() -> u64 {
273 #[cfg(target_os = "linux")]
274 {
275 if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") {
276 // Field 1 (0-based) is resident pages.
277 if let Some(pages) = statm
278 .split_whitespace()
279 .nth(1)
280 .and_then(|v| v.parse::<u64>().ok())
281 {
282 // Page size is 4096 on every target we ship; sysconf would need libc.
283 return pages * 4096;
284 }
285 }
286 0
287 }
288 #[cfg(not(target_os = "linux"))]
289 {
290 0
291 }
292}
293
294/// Background sampler: refreshes the shared RSS gauge every 500 ms so the
295/// admission breaker reads a cheap atomic, never the filesystem, per request.
296pub fn spawn_rss_sampler(admission: Arc<Admission>) {
297 tokio::spawn(async move {
298 let mut tick = tokio::time::interval(std::time::Duration::from_millis(500));
299 loop {
300 tick.tick().await;
301 admission.set_resident_bytes(resident_bytes_now());
302 }
303 });
304}
305
306// The unit tests live in the ratchet-exempt sibling file `admission_tests.rs`
307// (kept out of this module to stay under the module-size budget). `#[path]`
308// points at the sibling while it remains a child module, so `use super::*`
309// still reaches this file's private helpers.
310#[cfg(test)]
311#[path = "admission_tests.rs"]
312mod tests;