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;