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}