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        // A key seen before is updated in place, with no owned copy of it.
71        if let Some(mut tat) = self.tats.get_mut(key) {
72            let verdict = gcra.check(Some(Duration::from_nanos(*tat)), now);
73            if verdict.allowed {
74                *tat = dur_nanos(verdict.new_tat);
75            }
76            return verdict;
77        }
78        match self.tats.entry(key.to_string()) {
79            Entry::Occupied(mut o) => {
80                let stored = Some(Duration::from_nanos(*o.get()));
81                let verdict = gcra.check(stored, now);
82                if verdict.allowed {
83                    *o.get_mut() = dur_nanos(verdict.new_tat);
84                }
85                verdict
86            }
87            Entry::Vacant(v) => {
88                let verdict = gcra.check(None, now);
89                if verdict.allowed {
90                    v.insert(dur_nanos(verdict.new_tat));
91                }
92                verdict
93            }
94        }
95    }
96
97    /// Milliseconds elapsed since the store's base instant.
98    fn now(&self) -> Duration {
99        self.base.elapsed()
100    }
101
102    /// Drop entries whose TAT is in the past: a key with `TAT <= now` has fully
103    /// drained and is indistinguishable from a first-seen key, so re-inserting it
104    /// on the next hit yields the same result. Without eviction, client-controlled
105    /// key cardinality (IP / principal) would grow the map without bound.
106    fn evict_drained(&self, now: Duration) {
107        let now_nanos = dur_nanos(now);
108        self.tats.retain(|_, tat| *tat > now_nanos);
109    }
110
111    /// Evict at most once per [`SWEEP_INTERVAL_MS`]; the first caller past the
112    /// interval claims the sweep so it stays an infrequent O(n) pass.
113    fn maybe_sweep(&self, now: Duration) {
114        let now_ms = u64::try_from(now.as_millis()).unwrap_or(u64::MAX);
115        let last = self.last_sweep_ms.load(Ordering::Relaxed);
116        let elapsed = now_ms.saturating_sub(last);
117        // Normal cadence is SWEEP_INTERVAL_MS. Over the high-water mark we sweep
118        // sooner, but still throttled to HW_SWEEP_MIN_MS so an all-active flood
119        // (whose entries can't be reclaimed) can't trigger a full O(n) retain on
120        // every request.
121        let due = elapsed >= SWEEP_INTERVAL_MS
122            || (elapsed >= HW_SWEEP_MIN_MS && self.tats.len() > SWEEP_HIGH_WATER);
123        if !due {
124            return;
125        }
126        if self
127            .last_sweep_ms
128            .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed)
129            .is_ok()
130        {
131            self.evict_drained(now);
132        }
133    }
134
135    /// Number of live entries (test/introspection helper).
136    #[cfg(test)]
137    fn len(&self) -> usize {
138        self.tats.len()
139    }
140}
141
142/// A `Duration` as whole nanoseconds, saturating at `u64::MAX`. Nanosecond TATs
143/// preserve precision for sub-millisecond emission intervals (very high rates);
144/// `u64` nanoseconds span ~584 years, far beyond any process uptime.
145fn dur_nanos(d: Duration) -> u64 {
146    u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn gcra(rate: u64, burst: u64) -> Gcra {
154        Gcra::from_profile(super::super::gcra::Profile {
155            rate,
156            window: Duration::from_secs(60),
157            burst,
158        })
159    }
160
161    #[test]
162    fn admits_burst_then_blocks() {
163        let store = GcraStore::new();
164        let g = gcra(60, 2); // 1/s, burst 2
165        assert!(store.check("k", &g).allowed);
166        assert!(store.check("k", &g).allowed);
167        // Burst of 2 spent within the same millisecond window.
168        assert!(!store.check("k", &g).allowed);
169    }
170
171    #[test]
172    fn keys_are_independent() {
173        let store = GcraStore::new();
174        let g = gcra(60, 1); // burst 1
175        assert!(store.check("a", &g).allowed);
176        assert!(store.check("b", &g).allowed);
177        assert!(!store.check("a", &g).allowed);
178    }
179
180    #[test]
181    fn eviction_reclaims_drained_keys() {
182        let store = GcraStore::new();
183        let g = gcra(600, 1); // 10/s → 100ms emission, burst 1
184        store.check("a", &g);
185        store.check("b", &g);
186        assert_eq!(store.len(), 2);
187        // Force the base far into the past so both TATs are drained, then sweep.
188        std::thread::sleep(Duration::from_millis(120));
189        store.evict_drained(store.now());
190        assert_eq!(store.len(), 0);
191    }
192}