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}