Skip to main content

structured_proxy/shield/
store.rs

1//! In-process GCRA state store.
2//!
3//! Holds one value per key: the theoretical arrival time (TAT), as milliseconds
4//! since the store's base instant. The check is synchronous and lock-free across
5//! keys (a `DashMap` shard lock is held only for the single key being updated),
6//! so it adds no measurable latency to the request path.
7//!
8//! This is always the authority for the local limit decision. Cross-instance
9//! reconciliation, when enabled, sits beside it and is updated asynchronously;
10//! it never turns this check into a blocking operation.
11
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::{Duration, Instant};
14
15use dashmap::mapref::entry::Entry;
16
17use super::gcra::{Gcra, Verdict};
18
19/// Run eviction of drained entries at most once per this interval.
20const SWEEP_INTERVAL_MS: u64 = 60_000;
21
22/// When the map grows past this, run a drained-key eviction sooner than the
23/// normal timer (throttled to [`HW_SWEEP_MIN_MS`]), bounding peak memory during
24/// a burst of distinct keys. This only reclaims *drained* keys (TAT in the
25/// past), never active (rate-limited) ones: dropping a limited key would reset
26/// its GCRA budget and hand a flooding attacker a fresh burst, so the store
27/// deliberately holds active limiter state. An all-active flood is shed by the
28/// fleet gate and upstream, not by discarding the very state that enforces the
29/// limit.
30const SWEEP_HIGH_WATER: usize = 200_000;
31
32/// Minimum spacing between high-water sweeps. The high-water path can't reclaim
33/// active TATs, so under an all-active flood the map stays over the mark and,
34/// without this floor, every request would run a full O(n) `retain` (a CPU
35/// DoS). Throttling to one scan per second bounds that cost.
36const HW_SWEEP_MIN_MS: u64 = 1_000;
37
38/// In-process per-instance GCRA store.
39// no-std: caller-provided Clock + spin/hashbrown map.
40#[derive(Debug)]
41pub struct GcraStore {
42    /// key → TAT in nanoseconds since `base`.
43    tats: dashmap::DashMap<String, u64>,
44    base: Instant,
45    last_sweep_ms: AtomicU64,
46}
47
48impl Default for GcraStore {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl GcraStore {
55    /// Create an empty store.
56    pub fn new() -> Self {
57        Self {
58            tats: dashmap::DashMap::new(),
59            base: Instant::now(),
60            last_sweep_ms: AtomicU64::new(0),
61        }
62    }
63
64    /// Evaluate one request for `key` against `gcra`, recording the new TAT when
65    /// the request is admitted.
66    pub fn check(&self, key: &str, gcra: &Gcra) -> Verdict {
67        let now = self.now();
68        self.maybe_sweep(now);
69
70        match self.tats.entry(key.to_string()) {
71            Entry::Occupied(mut o) => {
72                let stored = Some(Duration::from_nanos(*o.get()));
73                let verdict = gcra.check(stored, now);
74                if verdict.allowed {
75                    *o.get_mut() = dur_nanos(verdict.new_tat);
76                }
77                verdict
78            }
79            Entry::Vacant(v) => {
80                let verdict = gcra.check(None, now);
81                if verdict.allowed {
82                    v.insert(dur_nanos(verdict.new_tat));
83                }
84                verdict
85            }
86        }
87    }
88
89    /// Milliseconds elapsed since the store's base instant.
90    fn now(&self) -> Duration {
91        self.base.elapsed()
92    }
93
94    /// Drop entries whose TAT is in the past: a key with `TAT <= now` has fully
95    /// drained and is indistinguishable from a first-seen key, so re-inserting it
96    /// on the next hit yields the same result. Without eviction, client-controlled
97    /// key cardinality (IP / principal) would grow the map without bound.
98    fn evict_drained(&self, now: Duration) {
99        let now_nanos = dur_nanos(now);
100        self.tats.retain(|_, tat| *tat > now_nanos);
101    }
102
103    /// Evict at most once per [`SWEEP_INTERVAL_MS`]; the first caller past the
104    /// interval claims the sweep so it stays an infrequent O(n) pass.
105    fn maybe_sweep(&self, now: Duration) {
106        let now_ms = u64::try_from(now.as_millis()).unwrap_or(u64::MAX);
107        let last = self.last_sweep_ms.load(Ordering::Relaxed);
108        let elapsed = now_ms.saturating_sub(last);
109        // Normal cadence is SWEEP_INTERVAL_MS. Over the high-water mark we sweep
110        // sooner, but still throttled to HW_SWEEP_MIN_MS so an all-active flood
111        // (whose entries can't be reclaimed) can't trigger a full O(n) retain on
112        // every request.
113        let due = elapsed >= SWEEP_INTERVAL_MS
114            || (elapsed >= HW_SWEEP_MIN_MS && self.tats.len() > SWEEP_HIGH_WATER);
115        if !due {
116            return;
117        }
118        if self
119            .last_sweep_ms
120            .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed)
121            .is_ok()
122        {
123            self.evict_drained(now);
124        }
125    }
126
127    /// Number of live entries (test/introspection helper).
128    #[cfg(test)]
129    fn len(&self) -> usize {
130        self.tats.len()
131    }
132}
133
134/// A `Duration` as whole nanoseconds, saturating at `u64::MAX`. Nanosecond TATs
135/// preserve precision for sub-millisecond emission intervals (very high rates);
136/// `u64` nanoseconds span ~584 years, far beyond any process uptime.
137fn dur_nanos(d: Duration) -> u64 {
138    u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn gcra(rate: u64, burst: u64) -> Gcra {
146        Gcra::from_profile(super::super::gcra::Profile {
147            rate,
148            window: Duration::from_secs(60),
149            burst,
150        })
151    }
152
153    #[test]
154    fn admits_burst_then_blocks() {
155        let store = GcraStore::new();
156        let g = gcra(60, 2); // 1/s, burst 2
157        assert!(store.check("k", &g).allowed);
158        assert!(store.check("k", &g).allowed);
159        // Burst of 2 spent within the same millisecond window.
160        assert!(!store.check("k", &g).allowed);
161    }
162
163    #[test]
164    fn keys_are_independent() {
165        let store = GcraStore::new();
166        let g = gcra(60, 1); // burst 1
167        assert!(store.check("a", &g).allowed);
168        assert!(store.check("b", &g).allowed);
169        assert!(!store.check("a", &g).allowed);
170    }
171
172    #[test]
173    fn eviction_reclaims_drained_keys() {
174        let store = GcraStore::new();
175        let g = gcra(600, 1); // 10/s → 100ms emission, burst 1
176        store.check("a", &g);
177        store.check("b", &g);
178        assert_eq!(store.len(), 2);
179        // Force the base far into the past so both TATs are drained, then sweep.
180        std::thread::sleep(Duration::from_millis(120));
181        store.evict_drained(store.now());
182        assert_eq!(store.len(), 0);
183    }
184}