Skip to main content

maskforge_core/
mem_gate.rs

1//! A process-wide, byte-bounded backpressure gate: bounds both the COUNT of simultaneous callers
2//! and their estimated transient bytes, so concurrent callers cannot multiply a sound per-call limit.
3
4use std::sync::{Condvar, Mutex};
5use std::time::{Duration, Instant};
6
7use crate::error::{CompileError, ErrorCode, Stage};
8
9/// Default bound on how long a caller waits for a permit before giving up with a typed timeout.
10const DEFAULT_WAIT: Duration = Duration::from_secs(30);
11
12/// A backpressure gate bounding both concurrent callers and their summed byte estimate; `stage` tags every error it raises.
13#[derive(Debug)]
14pub(crate) struct BuildGate {
15    inner: Mutex<GateInner>,
16    avail: Condvar,
17    byte_budget: usize,
18    max_wait: Duration,
19    stage: Stage,
20}
21
22#[derive(Debug, Default)]
23struct GateInner {
24    permits: usize,
25    bytes_avail: usize,
26    in_use: usize,
27    active_bytes: usize,
28    peak: usize,
29    peak_bytes: usize,
30    rejected: u64,
31}
32
33impl BuildGate {
34    pub(crate) fn new(max_concurrent: usize, byte_budget: usize, stage: Stage) -> Self {
35        Self::with_max_wait(max_concurrent, byte_budget, DEFAULT_WAIT, stage)
36    }
37
38    pub(crate) fn with_max_wait(
39        max_concurrent: usize,
40        byte_budget: usize,
41        max_wait: Duration,
42        stage: Stage,
43    ) -> Self {
44        let byte_budget = byte_budget.max(1);
45        Self {
46            inner: Mutex::new(GateInner {
47                permits: max_concurrent.max(1),
48                bytes_avail: byte_budget,
49                ..GateInner::default()
50            }),
51            avail: Condvar::new(),
52            byte_budget,
53            max_wait,
54            stage,
55        }
56    }
57
58    fn gate_limit(&self, msg: &'static str) -> CompileError {
59        CompileError::new(ErrorCode::InternalLimitExceeded, self.stage, msg)
60    }
61
62    /// Waits for a count permit and `est` bytes. Rejects immediately if `est` alone exceeds the
63    /// whole budget; times out with a typed error rather than blocking a caller forever.
64    pub(crate) fn acquire(&self, est: usize) -> Result<PermitGuard<'_>, CompileError> {
65        if est > self.byte_budget {
66            self.inner
67                .lock()
68                .unwrap_or_else(|p| p.into_inner())
69                .rejected += 1;
70            return Err(self.gate_limit(
71                "estimated transient memory exceeds the configured build-memory budget",
72            ));
73        }
74        let deadline = Instant::now() + self.max_wait;
75        let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
76        while g.permits == 0 || g.bytes_avail < est {
77            let remaining = deadline.saturating_duration_since(Instant::now());
78            if remaining.is_zero() {
79                g.rejected += 1;
80                return Err(self.gate_limit("timed out waiting for a build-memory permit"));
81            }
82            let (guard, result) = self
83                .avail
84                .wait_timeout(g, remaining)
85                .unwrap_or_else(|p| p.into_inner());
86            g = guard;
87            if result.timed_out() && (g.permits == 0 || g.bytes_avail < est) {
88                g.rejected += 1;
89                return Err(self.gate_limit("timed out waiting for a build-memory permit"));
90            }
91        }
92        g.permits -= 1;
93        g.bytes_avail -= est;
94        g.in_use += 1;
95        g.active_bytes += est;
96        g.peak = g.peak.max(g.in_use);
97        g.peak_bytes = g.peak_bytes.max(g.active_bytes);
98        Ok(PermitGuard {
99            gate: self,
100            charge: est,
101        })
102    }
103
104    /// Admits `est` immediately if it fits, else fails immediately - NEVER waits. For latency-
105    /// sensitive serving callers, which must not queue behind a large build holding the budget.
106    pub(crate) fn try_acquire(&self, est: usize) -> Result<PermitGuard<'_>, CompileError> {
107        let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
108        if est > self.byte_budget || g.permits == 0 || g.bytes_avail < est {
109            g.rejected += 1;
110            return Err(
111                self.gate_limit("serving memory budget is fully committed; try again shortly")
112            );
113        }
114        g.permits -= 1;
115        g.bytes_avail -= est;
116        g.in_use += 1;
117        g.active_bytes += est;
118        g.peak = g.peak.max(g.in_use);
119        g.peak_bytes = g.peak_bytes.max(g.active_bytes);
120        Ok(PermitGuard {
121            gate: self,
122            charge: est,
123        })
124    }
125
126    pub(crate) fn peak_concurrent(&self) -> usize {
127        self.inner.lock().unwrap_or_else(|p| p.into_inner()).peak
128    }
129
130    pub(crate) fn peak_bytes(&self) -> usize {
131        self.inner
132            .lock()
133            .unwrap_or_else(|p| p.into_inner())
134            .peak_bytes
135    }
136
137    /// Bytes currently admitted (in-flight across every live permit).
138    pub(crate) fn active_bytes(&self) -> usize {
139        self.inner
140            .lock()
141            .unwrap_or_else(|p| p.into_inner())
142            .active_bytes
143    }
144
145    /// Count of `acquire` calls that failed (over-budget or timed out).
146    pub(crate) fn rejected(&self) -> u64 {
147        self.inner
148            .lock()
149            .unwrap_or_else(|p| p.into_inner())
150            .rejected
151    }
152}
153
154/// Releases a caller's count permit + byte charge and wakes waiters on drop (incl. error or panic).
155/// Fields stay private: the only way to obtain one is `acquire`/`acquire_vocab_build_bytes`.
156#[derive(Debug)]
157pub struct PermitGuard<'a> {
158    gate: &'a BuildGate,
159    charge: usize,
160}
161
162impl Drop for PermitGuard<'_> {
163    fn drop(&mut self) {
164        let mut g = self.gate.inner.lock().unwrap_or_else(|p| p.into_inner());
165        g.permits += 1;
166        g.bytes_avail += self.charge;
167        g.in_use -= 1;
168        g.active_bytes -= self.charge;
169        drop(g);
170        self.gate.avail.notify_all(); // multiple waiters may now fit
171    }
172}
173
174impl PermitGuard<'_> {
175    /// `Err` unless this permit's charged bytes cover `required` - the check every `*_admitted`
176    /// constructor runs before trusting a caller-supplied permit as real admission proof.
177    pub fn ensure_covers(&self, required: usize) -> Result<(), CompileError> {
178        if required > self.charge {
179            return Err(self
180                .gate
181                .gate_limit("build permit does not cover the projected peak"));
182        }
183        Ok(())
184    }
185}
186
187/// Default BUILD gate budget: one process-wide byte pool for packed construction and ingestion.
188/// `1 << 31` = 2 GiB.
189const DEFAULT_VOCAB_BUILD_BYTES: usize = 1 << 31;
190
191/// Default cap on simultaneous admitted builds.
192const DEFAULT_VOCAB_BUILD_CONCURRENCY: usize = 16;
193
194/// Default SERVING gate budget, kept separate so a latency-sensitive query never waits behind a
195/// build holding the build budget.
196const DEFAULT_VOCAB_SERVING_BYTES: usize = 1 << 28;
197
198/// Default cap on simultaneous admitted lazy queries.
199const DEFAULT_VOCAB_SERVING_CONCURRENCY: usize = 64;
200
201/// Default AUTOMATON gate budget, separate from vocabulary build/ingestion: `1 << 31` = 2 GiB.
202const DEFAULT_AUTOMATON_BUILD_BYTES: usize = 1 << 31;
203
204/// Default cap on simultaneous admitted automaton (regex/DFA) builds.
205const DEFAULT_AUTOMATON_BUILD_CONCURRENCY: usize = 16;
206
207/// One `OnceLock` per gate: `configure_*` and `*_gate()` race on the SAME cell, so whichever wins
208/// atomically IS the live policy.
209static VOCAB_BUILD_GATE: std::sync::OnceLock<BuildGate> = std::sync::OnceLock::new();
210static VOCAB_SERVING_GATE: std::sync::OnceLock<BuildGate> = std::sync::OnceLock::new();
211static AUTOMATON_BUILD_GATE: std::sync::OnceLock<BuildGate> = std::sync::OnceLock::new();
212
213/// Why `configure_vocab_{build,serving}_gate` failed - a stable, machine-matchable pair instead of
214/// a free-text string, so a caller (or the Python FFI) can branch on WHICH failure it was.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum GateConfigError {
217    /// `max_concurrent` or `byte_budget` was zero.
218    InvalidValue,
219    /// The gate already has a policy (an earlier configure call, or a prior default-triggering use).
220    AlreadyInitialized,
221}
222
223impl GateConfigError {
224    /// Advisory text for the human-readable side of an error report.
225    #[must_use]
226    pub fn message(self) -> &'static str {
227        match self {
228            Self::InvalidValue => "max_concurrent and byte_budget must both be at least 1",
229            Self::AlreadyInitialized => {
230                "the gate already has a policy; configure it before the first use"
231            }
232        }
233    }
234}
235
236impl std::fmt::Display for GateConfigError {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        f.write_str(self.message())
239    }
240}
241
242impl std::error::Error for GateConfigError {}
243
244/// Installs the BUILD gate's `(max_concurrent, byte_budget)`. `Err` if it already has a policy
245/// (configured or defaulted by a prior use) or either value is zero.
246pub(crate) fn configure_vocab_build_gate(
247    max_concurrent: usize,
248    byte_budget: usize,
249) -> Result<(), GateConfigError> {
250    if max_concurrent == 0 || byte_budget == 0 {
251        return Err(GateConfigError::InvalidValue);
252    }
253    VOCAB_BUILD_GATE
254        .set(BuildGate::new(max_concurrent, byte_budget, Stage::L4Bind))
255        .map_err(|_| GateConfigError::AlreadyInitialized)
256}
257
258/// Installs the SERVING gate's `(max_concurrent, byte_budget)`. Same contract as
259/// `configure_vocab_build_gate`.
260pub(crate) fn configure_vocab_serving_gate(
261    max_concurrent: usize,
262    byte_budget: usize,
263) -> Result<(), GateConfigError> {
264    if max_concurrent == 0 || byte_budget == 0 {
265        return Err(GateConfigError::InvalidValue);
266    }
267    VOCAB_SERVING_GATE
268        .set(BuildGate::new(max_concurrent, byte_budget, Stage::L4Bind))
269        .map_err(|_| GateConfigError::AlreadyInitialized)
270}
271
272/// Installs the AUTOMATON gate's `(max_concurrent, byte_budget)`; same contract as `configure_vocab_build_gate`.
273pub(crate) fn configure_automaton_build_gate(
274    max_concurrent: usize,
275    byte_budget: usize,
276) -> Result<(), GateConfigError> {
277    if max_concurrent == 0 || byte_budget == 0 {
278        return Err(GateConfigError::InvalidValue);
279    }
280    AUTOMATON_BUILD_GATE
281        .set(BuildGate::new(max_concurrent, byte_budget, Stage::L3))
282        .map_err(|_| GateConfigError::AlreadyInitialized)
283}
284
285/// The process-wide BUILD gate (packed table construction, packed-buffer ingestion), separate from
286/// `TrieCache`'s own build gate and the serving gate below.
287pub(crate) fn vocab_build_gate() -> &'static BuildGate {
288    VOCAB_BUILD_GATE.get_or_init(|| {
289        BuildGate::new(
290            DEFAULT_VOCAB_BUILD_CONCURRENCY,
291            DEFAULT_VOCAB_BUILD_BYTES,
292            Stage::L4Bind,
293        )
294    })
295}
296
297/// The process-wide SERVING gate (lazy per-state mask queries only).
298pub(crate) fn vocab_serving_gate() -> &'static BuildGate {
299    VOCAB_SERVING_GATE.get_or_init(|| {
300        BuildGate::new(
301            DEFAULT_VOCAB_SERVING_CONCURRENCY,
302            DEFAULT_VOCAB_SERVING_BYTES,
303            Stage::L4Bind,
304        )
305    })
306}
307
308/// The process-wide AUTOMATON gate (regex/DFA compilation), separate from the vocabulary gates.
309pub(crate) fn automaton_build_gate() -> &'static BuildGate {
310    AUTOMATON_BUILD_GATE.get_or_init(|| {
311        BuildGate::new(
312            DEFAULT_AUTOMATON_BUILD_CONCURRENCY,
313            DEFAULT_AUTOMATON_BUILD_BYTES,
314            Stage::L3,
315        )
316    })
317}
318
319/// `(active_bytes, peak_bytes, peak_concurrent, rejected)`, all zero if the BUILD gate has never
320/// been used - reading stats must never itself install the default policy.
321pub(crate) fn vocab_build_gate_stats() -> (usize, usize, usize, u64) {
322    VOCAB_BUILD_GATE.get().map_or((0, 0, 0, 0), |g| {
323        (
324            g.active_bytes(),
325            g.peak_bytes(),
326            g.peak_concurrent(),
327            g.rejected(),
328        )
329    })
330}
331
332/// Same non-initializing contract as `vocab_build_gate_stats`, for the SERVING gate.
333pub(crate) fn vocab_serving_gate_stats() -> (usize, usize, usize, u64) {
334    VOCAB_SERVING_GATE.get().map_or((0, 0, 0, 0), |g| {
335        (
336            g.active_bytes(),
337            g.peak_bytes(),
338            g.peak_concurrent(),
339            g.rejected(),
340        )
341    })
342}
343
344/// Same non-initializing contract as `vocab_build_gate_stats`, for the AUTOMATON gate.
345pub(crate) fn automaton_build_gate_stats() -> (usize, usize, usize, u64) {
346    AUTOMATON_BUILD_GATE.get().map_or((0, 0, 0, 0), |g| {
347        (
348            g.active_bytes(),
349            g.peak_bytes(),
350            g.peak_concurrent(),
351            g.rejected(),
352        )
353    })
354}
355
356/// Acquires `estimated_bytes` from the shared BUILD gate, waiting if necessary. The returned guard
357/// must be held for as long as the estimated memory stays live; dropping it releases the charge.
358pub(crate) fn acquire_vocab_build_bytes(
359    estimated_bytes: usize,
360) -> Result<PermitGuard<'static>, CompileError> {
361    vocab_build_gate().acquire(estimated_bytes)
362}
363
364/// Fail-fast admission against the shared SERVING gate: never waits, so a lazy query cannot queue
365/// behind a large build.
366pub(crate) fn try_acquire_vocab_serving_bytes(
367    estimated_bytes: usize,
368) -> Result<PermitGuard<'static>, CompileError> {
369    vocab_serving_gate().try_acquire(estimated_bytes)
370}
371
372/// Acquires `estimated_bytes` from the shared AUTOMATON gate, waiting if necessary.
373pub(crate) fn acquire_automaton_build_bytes(
374    estimated_bytes: usize,
375) -> Result<PermitGuard<'static>, CompileError> {
376    automaton_build_gate().acquire(estimated_bytes)
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn permit_releases_on_panic_not_just_normal_drop() {
385        let gate = BuildGate::new(4, 100, Stage::L4Bind);
386        let result = std::thread::scope(|scope| {
387            scope
388                .spawn(|| {
389                    let _permit = gate.acquire(60).unwrap();
390                    panic!("simulated failure while a permit is held");
391                })
392                .join()
393        });
394        assert!(result.is_err(), "the spawned thread must have panicked");
395        assert_eq!(
396            gate.active_bytes(),
397            0,
398            "PermitGuard::drop must run during unwind and release the charge"
399        );
400    }
401
402    #[test]
403    fn configure_vocab_gates_reject_a_zero_value_without_touching_the_singleton() {
404        // Rejected before the OnceLock is ever touched, so this is safe regardless of whether
405        // another test in this binary already initialized the real global gate.
406        assert_eq!(
407            configure_vocab_build_gate(0, 100),
408            Err(GateConfigError::InvalidValue)
409        );
410        assert_eq!(
411            configure_vocab_build_gate(4, 0),
412            Err(GateConfigError::InvalidValue)
413        );
414        assert_eq!(
415            configure_vocab_serving_gate(0, 100),
416            Err(GateConfigError::InvalidValue)
417        );
418        assert_eq!(
419            configure_vocab_serving_gate(4, 0),
420            Err(GateConfigError::InvalidValue)
421        );
422    }
423
424    #[test]
425    fn acquire_rejects_an_estimate_over_the_whole_budget() {
426        let gate = BuildGate::new(4, 100, Stage::L4Bind);
427        assert!(gate.acquire(101).is_err());
428        assert_eq!(gate.rejected(), 1);
429    }
430
431    #[test]
432    fn acquire_admits_up_to_the_budget_and_releases_on_drop() {
433        let gate = BuildGate::new(4, 100, Stage::L4Bind);
434        let p1 = gate.acquire(60).unwrap();
435        assert_eq!(gate.active_bytes(), 60);
436        drop(p1);
437        assert_eq!(gate.active_bytes(), 0);
438        let p2 = gate.acquire(100).unwrap();
439        assert_eq!(gate.active_bytes(), 100);
440        drop(p2);
441    }
442
443    #[test]
444    fn a_second_caller_times_out_while_the_budget_is_fully_held() {
445        let gate = BuildGate::with_max_wait(4, 100, Duration::from_millis(50), Stage::L4Bind);
446        let _held = gate.acquire(100).unwrap();
447        let err = gate.acquire(1).expect_err("no bytes are available");
448        assert_eq!(err.code, ErrorCode::InternalLimitExceeded);
449        assert_eq!(gate.rejected(), 1);
450    }
451
452    #[test]
453    fn every_rejection_path_reports_the_gate_own_stage_not_a_hardcoded_one() {
454        let gate = BuildGate::new(1, 100, Stage::L3);
455        assert_eq!(gate.acquire(200).unwrap_err().stage, Stage::L3);
456        assert_eq!(gate.try_acquire(200).unwrap_err().stage, Stage::L3);
457        let permit = gate.acquire(50).unwrap();
458        assert_eq!(permit.ensure_covers(51).unwrap_err().stage, Stage::L3);
459
460        let other = BuildGate::new(1, 100, Stage::L4Bind);
461        assert_eq!(other.acquire(200).unwrap_err().stage, Stage::L4Bind);
462    }
463
464    #[test]
465    fn peak_bytes_and_peak_concurrent_track_the_high_water_mark() {
466        let gate = BuildGate::new(4, 1000, Stage::L4Bind);
467        let p1 = gate.acquire(100).unwrap();
468        let p2 = gate.acquire(200).unwrap();
469        assert_eq!(gate.peak_concurrent(), 2);
470        assert_eq!(gate.peak_bytes(), 300);
471        drop(p1);
472        drop(p2);
473        assert_eq!(
474            gate.peak_concurrent(),
475            2,
476            "peak is a high-water mark, not current"
477        );
478        assert_eq!(gate.peak_bytes(), 300);
479    }
480
481    #[test]
482    #[ignore = "timing sanity check, not a correctness gate; run with --ignored --nocapture"]
483    fn uncontended_acquire_release_cost() {
484        let gate = BuildGate::new(16, 1 << 31, Stage::L4Bind);
485        let n = 100_000;
486        let t = std::time::Instant::now();
487        for _ in 0..n {
488            let _p = gate.acquire(1024).unwrap();
489        }
490        let elapsed = t.elapsed();
491        println!(
492            "{n} uncontended acquire+release cycles: {:?} total, {:?}/cycle",
493            elapsed,
494            elapsed / n
495        );
496    }
497
498    #[test]
499    #[ignore = "timing sanity check, not a correctness gate; run with --release --ignored --nocapture"]
500    fn contended_acquire_release_cost_1_8_16_32_threads() {
501        fn stats(mut xs: Vec<f64>) -> (f64, f64, f64, f64, f64, f64) {
502            xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
503            let n = xs.len();
504            let median = xs[n / 2];
505            let p95 = xs[((n as f64 * 0.95) as usize).min(n - 1)];
506            let p99 = xs[((n as f64 * 0.99) as usize).min(n - 1)];
507            let mean = xs.iter().sum::<f64>() / n as f64;
508            let var = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
509            (median, p95, p99, var.sqrt(), xs[0], xs[n - 1])
510        }
511        // A large budget so every acquire admits (no forced waiting) - this isolates lock/atomic
512        // contention cost as thread count rises, not admission-refusal behavior.
513        const ITERS_PER_THREAD: usize = 5_000;
514        for &threads in &[1usize, 8, 16, 32] {
515            let gate = BuildGate::new(threads * 4, 1 << 34, Stage::L4Bind);
516            let samples: Vec<f64> = std::thread::scope(|scope| {
517                let handles: Vec<_> = (0..threads)
518                    .map(|_| {
519                        let gate = &gate;
520                        scope.spawn(move || {
521                            let mut xs = Vec::with_capacity(ITERS_PER_THREAD);
522                            for _ in 0..ITERS_PER_THREAD {
523                                let t = std::time::Instant::now();
524                                let _p = gate.acquire(1024).unwrap();
525                                xs.push(t.elapsed().as_secs_f64() * 1e9); // ns
526                            }
527                            xs
528                        })
529                    })
530                    .collect();
531                handles
532                    .into_iter()
533                    .flat_map(|h| h.join().unwrap())
534                    .collect()
535            });
536            let (median, p95, p99, stddev, min, max) = stats(samples.clone());
537            println!(
538                "threads={threads}: n={} median={median:.1} p95={p95:.1} p99={p99:.1} \
539                 stddev={stddev:.1} min={min:.1} max={max:.1} ns/acquire",
540                samples.len()
541            );
542        }
543    }
544}