structured_proxy/shield/global.rs
1//! Cross-instance reconciliation of the fleet-wide limit view via a shared store.
2//!
3//! The request path never touches the store. It reads a cached estimate of what
4//! the *rest* of the fleet has consumed for a key (updated by the background
5//! task) plus this instance's own count, and gates on that. A background task
6//! pushes this instance's deltas (`INCRBY`) and pulls the aggregate (`MGET`) on
7//! an interval, so a store outage degrades to per-instance limiting rather than
8//! failing requests.
9//!
10//! Counts are held in a sliding window (see [`super::window`]) so the fleet gate
11//! has no boundary burst. The worst-case fleet overshoot is bounded by one sync
12//! interval of the other instances' traffic, about
13//! `(N-1) * rate * (interval / window)` requests (interval and window in the
14//! same unit).
15
16use std::sync::Arc;
17use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
18
19use dashmap::mapref::entry::Entry;
20
21use super::window;
22
23/// Redis key namespace for a rate-limit key at a given epoch.
24fn epoch_key(key: &str, epoch: u64) -> String {
25 format!("sp:rl:{key}:{epoch}")
26}
27
28/// Wall-clock time since the Unix epoch (shared reference so every instance
29/// agrees on window boundaries).
30fn unix_now() -> Duration {
31 SystemTime::now()
32 .duration_since(UNIX_EPOCH)
33 .unwrap_or(Duration::ZERO)
34}
35
36/// Drop per-key state untouched for this long (bounds memory under churning
37/// key cardinality).
38const KEY_TTL: Duration = Duration::from_secs(600);
39
40/// Hard cap on tracked keys. [`evict_stale`](GlobalCounters::evict_stale) bounds
41/// retention *time*, but only runs once per reconcile tick; a burst of distinct
42/// keys between ticks could still grow the map. Past this cap, a *new* key is not
43/// fleet-tracked and simply degrades to per-instance limiting (the local
44/// [`GcraStore`](super::store::GcraStore) still caps it) rather than being fleet
45/// under-counted; this is the same best-effort tolerance as a dropped push.
46/// Already-tracked keys are unaffected, so a real principal's budget is kept.
47const MAX_KEYS: usize = 500_000;
48
49/// Per-key reconciliation state.
50///
51/// Admit accounting uses a claim model: `local_count` holds only admits *not yet
52/// claimed* by a reconcile pass. A pass claims the count (zeroes it under the
53/// lock) before the async push, so a concurrent epoch roll or a push failure
54/// can't double-publish or drop it.
55#[derive(Debug, Clone)]
56struct KeyState {
57 epoch: u64,
58 /// Window length in seconds (from the resolved profile).
59 window_secs: u64,
60 /// Admits in the current epoch not yet claimed for a push.
61 local_count: u64,
62 /// Unclaimed admits owed to a prior epoch (`carryover_epoch`) that rolled
63 /// before they were claimed, so a boundary crossing doesn't drop them.
64 carryover: u64,
65 /// The epoch `carryover` is owed to.
66 carryover_epoch: u64,
67 /// The fleet's sliding consumption (including this instance's own claimed
68 /// admits, which now live in the shared counter), from the last pull.
69 remote_estimate: u64,
70 /// When `remote_estimate` was last refreshed from the store. If it goes
71 /// stale (the store is unreachable for several intervals), the gate stops
72 /// trusting it and degrades to per-instance limiting.
73 estimate_at: Instant,
74 last_seen: Instant,
75 /// Set when the key is touched (recorded or gated) and cleared by each
76 /// reconcile pass. Gates whether an idle, zero-delta key still needs its
77 /// estimate refreshed: an untouched key's estimate isn't being read, so
78 /// re-reading it every tick is pure load. A later request marks it again and
79 /// the next tick refreshes.
80 seen_since_tick: bool,
81}
82
83impl KeyState {
84 fn new(epoch: u64, window_secs: u64) -> Self {
85 let now = Instant::now();
86 Self {
87 epoch,
88 window_secs,
89 local_count: 0,
90 carryover: 0,
91 carryover_epoch: 0,
92 remote_estimate: 0,
93 estimate_at: now,
94 last_seen: now,
95 seen_since_tick: true,
96 }
97 }
98
99 /// Advance to `epoch`, moving any unclaimed admits to `carryover` owed to the
100 /// epoch being left so the reconciler still publishes them to that epoch's
101 /// counter. The remote estimate is kept (the background task refreshes it) so
102 /// a fresh epoch does not briefly open the full budget fleet-wide.
103 ///
104 /// A single carryover slot assumes the reconcile interval is much shorter
105 /// than the window (the default 500ms vs seconds+), so at most one unclaimed
106 /// prior epoch exists between reconciles.
107 fn roll_to(&mut self, epoch: u64) {
108 if self.epoch != epoch {
109 if self.local_count > 0 {
110 if self.carryover > 0 && self.carryover_epoch != self.epoch {
111 // An unclaimed carryover from an earlier epoch still exists:
112 // reconcile has not run across two boundaries (interval
113 // misconfigured longer than the window). Keep only the most
114 // recent window rather than collapsing two epochs' counts
115 // under one label, which would corrupt both. Dropping the
116 // older is a bounded under-count consistent with best-effort.
117 self.carryover = self.local_count;
118 } else {
119 self.carryover += self.local_count;
120 }
121 self.carryover_epoch = self.epoch;
122 }
123 self.epoch = epoch;
124 self.local_count = 0;
125 }
126 }
127}
128
129/// Cross-instance counter reconciliation over a shared Redis-protocol store.
130pub struct GlobalCounters {
131 client: redis::Client,
132 conn: tokio::sync::OnceCell<redis::aio::ConnectionManager>,
133 interval: Duration,
134 states: dashmap::DashMap<String, KeyState>,
135}
136
137impl GlobalCounters {
138 /// Open the shared store and return the reconciler.
139 ///
140 /// # Errors
141 /// Returns the underlying error when the store URL is invalid.
142 pub fn build(url: &str, interval: Duration) -> Result<Arc<Self>, String> {
143 let client = redis::Client::open(url)
144 .map_err(|e| format!("invalid shared-store URL for rate limiting: {e}"))?;
145 Ok(Arc::new(Self {
146 client,
147 conn: tokio::sync::OnceCell::new(),
148 interval: interval.max(Duration::from_millis(50)),
149 states: dashmap::DashMap::new(),
150 }))
151 }
152
153 /// Fleet budget still available for `key` (0 = over budget). Read-only: the
154 /// cached remote estimate plus this instance's unclaimed count, subtracted
155 /// from `budget`. Does not touch the store and does not record the admit
156 /// (call [`record`](Self::record) after the local check also passes).
157 ///
158 /// This is deliberately a read then a separate record, not an atomic
159 /// reserve/rollback. The per-instance [`GcraStore`](super::store::GcraStore)
160 /// is the hard local cap and is atomic per key; this fleet gate is only an
161 /// approximate cross-instance cap. A race where concurrent requests all pass
162 /// the gate before any records is bounded by the local burst plus the
163 /// documented one-interval overshoot, which is the accepted trade-off for
164 /// keeping the hot path lock-free and non-blocking.
165 pub fn fleet_remaining(&self, key: &str, budget: u64, window: Duration) -> u64 {
166 // When the map is full and this key is new, don't fleet-gate it: report
167 // the full budget so the local limiter alone decides (degrade-to-
168 // per-instance). Tracking it would breach the memory cap under a flood.
169 let Some(state) = self.state_for(key, window) else {
170 return budget;
171 };
172 // Ignore the remote estimate once it is stale (store unreachable for
173 // several intervals): keep gating on this instance's own counts only,
174 // which is the documented degrade-to-per-instance behaviour, instead of
175 // subtracting a frozen estimate forever.
176 let stale_after = (self.interval * 4).max(Duration::from_secs(2));
177 let remote = if state.estimate_at.elapsed() < stale_after {
178 state.remote_estimate
179 } else {
180 0
181 };
182 // Subtract carryover too: unpushed previous-window admits still count in
183 // the sliding window until the next tick publishes them.
184 let used = remote + state.local_count + state.carryover;
185 budget.saturating_sub(used)
186 }
187
188 /// Record one admitted request for `key`, to be pushed to the store on the
189 /// next background tick.
190 pub fn record(&self, key: &str, window: Duration) {
191 // If the map is full and this key is untracked, skip: the admit is
192 // enforced locally and simply isn't published to the fleet (bounded
193 // under-count), which is preferable to breaching the memory cap.
194 if let Some(mut state) = self.state_for(key, window) {
195 state.local_count += 1;
196 }
197 }
198
199 /// Look up (or create) the per-key state, rolled to the current epoch and
200 /// stamped as seen. If the resolved `window` differs from the one the entry
201 /// was created with, the entry is reset: a different window is a different
202 /// accounting unit, so mixing counts across it would corrupt the estimate.
203 fn state_for(
204 &self,
205 key: &str,
206 window: Duration,
207 ) -> Option<dashmap::mapref::one::RefMut<'_, String, KeyState>> {
208 let now = unix_now();
209 let window_secs = window.as_secs().max(1);
210 let ep = window::epoch(now, window);
211 // Soft cap check before the entry: a new key past the cap is not tracked.
212 // The `len()` read races with concurrent inserts, but the cap is a memory
213 // guard, not an exact limit, so a few entries of overshoot are harmless.
214 let over_cap = self.states.len() >= MAX_KEYS;
215 let mut state = match self.states.entry(key.to_string()) {
216 Entry::Occupied(o) => o.into_ref(),
217 Entry::Vacant(_) if over_cap => return None,
218 Entry::Vacant(v) => v.insert(KeyState::new(ep, window_secs)),
219 };
220 if state.window_secs != window_secs {
221 // A window change is a different accounting unit, so the entry resets.
222 // Any unpushed admits from the old window are dropped rather than
223 // remapped (a different window can't share a counter). The window only
224 // changes when the resolved tier does, and the tier comes from the
225 // signed JWT, the limit service, or config, never from client input,
226 // so this is not client-triggerable: it is a rare, bounded one-window
227 // under-count on a legitimate tier change, consistent with the fleet
228 // layer's best-effort, never-double contract.
229 *state = KeyState::new(ep, window_secs);
230 }
231 state.roll_to(ep);
232 state.last_seen = Instant::now();
233 state.seen_since_tick = true;
234 Some(state)
235 }
236
237 /// Spawn the background reconciliation loop. Returns `false` (without
238 /// spawning) when called outside a Tokio runtime, so the caller can drop the
239 /// fleet gate and fall back to per-instance limiting rather than gating on a
240 /// view that would never be reconciled.
241 #[must_use]
242 pub fn spawn(self: &Arc<Self>) -> bool {
243 if tokio::runtime::Handle::try_current().is_err() {
244 tracing::warn!("rate-limit reconciler not started: no Tokio runtime in this context");
245 return false;
246 }
247 let this = self.clone();
248 tokio::spawn(async move {
249 let mut ticker = tokio::time::interval(this.interval);
250 loop {
251 ticker.tick().await;
252 this.reconcile().await;
253 }
254 });
255 true
256 }
257
258 /// A cloneable, self-reconnecting handle to the shared store. `ConnectionManager`
259 /// re-establishes the underlying connection internally after a drop (e.g. a
260 /// Redis restart), so reconciled mode recovers without a proxy restart.
261 async fn connection(&self) -> redis::RedisResult<redis::aio::ConnectionManager> {
262 self.conn
263 .get_or_try_init(|| redis::aio::ConnectionManager::new(self.client.clone()))
264 .await
265 .cloned()
266 }
267
268 /// One reconciliation pass at the current wall clock.
269 async fn reconcile(&self) {
270 self.reconcile_at(unix_now()).await;
271 }
272
273 /// One reconciliation pass at instant `now` (parameterised for tests): push
274 /// this instance's deltas, pull the aggregate, refresh each key's remote
275 /// estimate, and evict stale keys.
276 async fn reconcile_at(&self, now: Duration) {
277 self.evict_stale();
278
279 let plans = self.claim_plans(now);
280 if plans.is_empty() {
281 return;
282 }
283
284 // Claimed deltas are fire-and-forget: once claimed (zeroed), a push that
285 // fails (connection or transaction) simply drops them rather than
286 // restoring. This is deliberate, not a leak:
287 // * Restoring risks publishing a committed-but-unacked batch twice (a
288 // false 429), and across a sustained outage it would accumulate
289 // unbounded local_count and then dump a huge spike on recovery.
290 // * Dropping instead under-counts only this instance's last interval,
291 // which is exactly the documented "store unreachable → degrade to
292 // per-instance limiting" behaviour, and never over-counts.
293 // So the failure mode is a bounded, self-correcting under-count (slight
294 // over-admit), never a false rejection or a recovery spike.
295 let mut conn = match self.connection().await {
296 Ok(c) => c,
297 Err(e) => {
298 tracing::warn!("rate-limit shared store unavailable, staying local: {e}");
299 return;
300 }
301 };
302
303 // Push each claimed delta as an atomic INCRBY inside MULTI/EXEC (never a
304 // SET, so concurrent instances' increments accumulate).
305 if let Err(e) = self.push_deltas(&mut conn, &plans).await {
306 tracing::warn!("rate-limit delta push failed, dropping this interval: {e}");
307 return;
308 }
309
310 // Claimed admits are now in the shared counter; there is nothing to
311 // commit. A read failure below just skips this tick's estimate refresh.
312 let reads = match self.read_epochs(&mut conn, &plans).await {
313 Ok(r) => r,
314 Err(e) => {
315 tracing::warn!("rate-limit aggregate read failed: {e}");
316 return;
317 }
318 };
319 self.apply_estimates(now, &plans, &reads);
320 }
321
322 /// Phase 1 (locked per key, brief): CLAIM each key's unclaimed admits by
323 /// zeroing them now, so a concurrent epoch roll or a push failure can't
324 /// double-publish or drop them. Emit a plan for every tracked key (delta may
325 /// be 0) so its estimate is refreshed even when the key is only being rejected
326 /// on a stale remote estimate. The delta is pushed to the epoch it was
327 /// accumulated in (`push_epoch`); the estimate reads the current epoch
328 /// (`read_epoch`).
329 fn claim_plans(&self, now: Duration) -> Vec<PushPlan> {
330 let keys: Vec<String> = self.states.iter().map(|e| e.key().clone()).collect();
331 let mut plans: Vec<PushPlan> = Vec::new();
332 for key in keys {
333 if let Some(mut s) = self.states.get_mut(&key) {
334 // Skip a key that is idle (untouched since the last tick) and has
335 // nothing pending: its estimate isn't being read, so refreshing it
336 // is wasted store load. Always clear the flag so the next tick sees
337 // only keys touched since. A key with a delta/carryover is active
338 // by definition and always processed.
339 let active = s.local_count > 0 || s.carryover > 0 || s.seen_since_tick;
340 s.seen_since_tick = false;
341 if !active {
342 continue;
343 }
344 let window = Duration::from_secs(s.window_secs);
345 let read_epoch = window::epoch(now, window);
346 let claim = s.local_count;
347 s.local_count = 0;
348 plans.push(PushPlan {
349 key: key.clone(),
350 push_epoch: s.epoch,
351 read_epoch,
352 window,
353 delta: claim,
354 is_carryover: false,
355 });
356 if s.carryover > 0 {
357 let carry = s.carryover;
358 s.carryover = 0;
359 plans.push(PushPlan {
360 key: key.clone(),
361 push_epoch: s.carryover_epoch,
362 read_epoch,
363 window,
364 delta: carry,
365 is_carryover: true,
366 });
367 }
368 }
369 }
370 plans
371 }
372
373 /// Refresh each key's cached estimate of the fleet's consumption.
374 fn apply_estimates(&self, now: Duration, plans: &[PushPlan], reads: &[(u64, u64)]) {
375 for (p, (cur, prev)) in plans.iter().zip(reads) {
376 // Carryover plans only publish a past epoch's delta; the estimate is
377 // driven by the current-epoch plan for the same key.
378 if p.is_carryover {
379 continue;
380 }
381 if let Some(mut s) = self.states.get_mut(&p.key) {
382 // A request may have reset the key to a new window, or rolled it
383 // to a newer epoch, while the read was in flight. The estimate we
384 // computed is for the plan's (window, epoch); applying it would
385 // clobber the fresh state with a stale value, so skip it.
386 if s.window_secs != p.window.as_secs() || s.epoch > p.read_epoch {
387 continue;
388 }
389 let elapsed = window::elapsed_in_window(now, p.window);
390 // The counter already includes this instance's claimed admits;
391 // the gate adds only the still-unclaimed `local_count` on top, so
392 // the full sliding estimate is used with no self-subtraction.
393 let est = window::sliding_estimate(*cur, *prev, elapsed, p.window);
394 // Round the fractional sliding estimate UP: under-counting the
395 // fleet would let the gate admit past the budget, so bias to the
396 // conservative side.
397 s.remote_estimate = est.ceil().max(0.0) as u64;
398 s.estimate_at = Instant::now();
399 }
400 }
401 }
402
403 /// `INCRBY` each key with a claimed delta and re-arm its TTL. Wrapped in a
404 /// `MULTI`/`EXEC` transaction so the batch applies all-or-nothing: a
405 /// mid-pipeline failure can't leave some `INCRBY`s applied while the caller
406 /// restores the claims and re-pushes the same deltas next tick.
407 async fn push_deltas(
408 &self,
409 conn: &mut redis::aio::ConnectionManager,
410 plans: &[PushPlan],
411 ) -> redis::RedisResult<()> {
412 let mut pipe = redis::pipe();
413 pipe.atomic();
414 let mut any = false;
415 for p in plans.iter().filter(|p| p.delta > 0) {
416 any = true;
417 let k = epoch_key(&p.key, p.push_epoch);
418 let ttl_ms = (p.window.as_millis() as u64).saturating_mul(2).max(1);
419 pipe.cmd("INCRBY").arg(&k).arg(p.delta).ignore();
420 pipe.cmd("PEXPIRE").arg(&k).arg(ttl_ms).ignore();
421 }
422 if !any {
423 return Ok(());
424 }
425 pipe.query_async(conn).await
426 }
427
428 /// `MGET` the current and previous epoch counts for every plan, returning
429 /// `(cur, prev)` per plan in order.
430 async fn read_epochs(
431 &self,
432 conn: &mut redis::aio::ConnectionManager,
433 plans: &[PushPlan],
434 ) -> redis::RedisResult<Vec<(u64, u64)>> {
435 let mut keys: Vec<String> = Vec::with_capacity(plans.len() * 2);
436 for p in plans {
437 keys.push(epoch_key(&p.key, p.read_epoch));
438 keys.push(epoch_key(&p.key, p.read_epoch.saturating_sub(1)));
439 }
440 let vals: Vec<Option<i64>> = redis::cmd("MGET").arg(&keys).query_async(conn).await?;
441 Ok(plans
442 .iter()
443 .enumerate()
444 .map(|(i, _)| {
445 let cur = vals.get(i * 2).copied().flatten().unwrap_or(0).max(0) as u64;
446 let prev = vals.get(i * 2 + 1).copied().flatten().unwrap_or(0).max(0) as u64;
447 (cur, prev)
448 })
449 .collect())
450 }
451
452 /// Drop per-key state that is idle and carries no unpublished admits. The
453 /// idle threshold is at least two windows, so a key on a long window (e.g.
454 /// `100/hour`) is not evicted mid-window; a key with pending `local_count` or
455 /// `carryover` is always kept so a store outage can't lose fleet counts.
456 fn evict_stale(&self) {
457 let now = Instant::now();
458 self.states.retain(|_, s| {
459 if s.local_count > 0 || s.carryover > 0 {
460 return true;
461 }
462 let threshold = KEY_TTL.max(Duration::from_secs(s.window_secs.saturating_mul(2)));
463 now.duration_since(s.last_seen) < threshold
464 });
465 }
466}
467
468/// A per-key push plan captured under lock, used without holding locks.
469struct PushPlan {
470 key: String,
471 /// Epoch the pending delta was accumulated in (target of the `INCRBY`).
472 push_epoch: u64,
473 /// Current epoch, whose sliding window the estimate reads.
474 read_epoch: u64,
475 window: Duration,
476 delta: u64,
477 /// True for a plan publishing a prior epoch's carried-over delta (no estimate).
478 is_carryover: bool,
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 const W: Duration = Duration::from_secs(60);
486
487 // Opening a client does not connect, so gate/record can be exercised without
488 // a running store.
489 fn counters() -> Arc<GlobalCounters> {
490 GlobalCounters::build("redis://127.0.0.1/", Duration::from_millis(500)).unwrap()
491 }
492
493 #[test]
494 fn budget_admits_until_local_count_reaches_it() {
495 let g = counters();
496 // Budget 3: three admits leave room, the fourth is over budget.
497 for _ in 0..3 {
498 assert!(g.fleet_remaining("k", 3, W) > 0);
499 g.record("k", W);
500 }
501 assert_eq!(g.fleet_remaining("k", 3, W), 0);
502 }
503
504 #[test]
505 fn budget_accounts_for_remote_estimate() {
506 let g = counters();
507 // Simulate the background task having observed 2 requests elsewhere.
508 let now = unix_now();
509 let ep = window::epoch(now, W);
510 let mut state = KeyState::new(ep, W.as_secs());
511 state.remote_estimate = 2;
512 g.states.insert("k".to_string(), state);
513 // Budget 3, remote 2 → one local admit fits, the next is over budget.
514 assert!(g.fleet_remaining("k", 3, W) > 0);
515 g.record("k", W);
516 assert_eq!(g.fleet_remaining("k", 3, W), 0);
517 }
518
519 #[test]
520 fn idle_zero_delta_keys_are_not_refreshed_each_tick() {
521 use std::collections::HashSet;
522 let g = counters();
523 let now = unix_now();
524 // Three keys become tracked: one with a delta, one only gated (read), one
525 // that goes idle after the first tick.
526 g.record("delta", W);
527 let _ = g.fleet_remaining("gated", 100, W);
528 g.record("idle", W);
529 // First tick claims everything and clears the per-tick activity.
530 let _ = g.claim_plans(now);
531 // Between ticks, only `delta` and `gated` see activity; `idle` is untouched.
532 g.record("delta", W);
533 let _ = g.fleet_remaining("gated", 100, W);
534 let plans = g.claim_plans(now);
535 let keys: HashSet<&str> = plans.iter().map(|p| p.key.as_str()).collect();
536 assert!(
537 keys.contains("delta"),
538 "a key with a pending delta must be pushed"
539 );
540 assert!(
541 keys.contains("gated"),
542 "an actively-gated key must be refreshed"
543 );
544 // An idle key with no delta must not be MGET'd every tick, or a one-shot
545 // key-cardinality burst becomes sustained load for the whole KEY_TTL.
546 assert!(
547 !keys.contains("idle"),
548 "an idle zero-delta key must not be refreshed every tick"
549 );
550 }
551
552 #[test]
553 fn roll_preserves_unclaimed_deltas_as_carryover() {
554 // Admits accumulated in an epoch that rolls before a reconcile claims
555 // them must survive as carryover owed to the old epoch, not be dropped.
556 let mut s = KeyState::new(10, 60);
557 s.local_count = 5;
558 s.roll_to(11);
559 assert_eq!(s.carryover, 5);
560 assert_eq!(s.carryover_epoch, 10);
561 assert_eq!(s.local_count, 0);
562 assert_eq!(s.epoch, 11);
563 }
564
565 #[test]
566 fn consecutive_rolls_do_not_collapse_epochs() {
567 // If reconcile does not run between two boundary crossings (interval
568 // misconfigured longer than the window), a second roll must not add a new
569 // epoch's count onto the previous carryover under one epoch label. It
570 // keeps the most recent window (dropping the older) rather than corrupting
571 // both with a collapsed count.
572 let mut s = KeyState::new(10, 60);
573 s.local_count = 3;
574 s.roll_to(11); // carryover = 3 owed to epoch 10
575 s.local_count = 4;
576 s.roll_to(12); // second roll before any reconcile claimed the carryover
577 assert_eq!(
578 s.carryover, 4,
579 "keeps the latest window, not 3 + 4 collapsed"
580 );
581 assert_eq!(s.carryover_epoch, 11);
582 }
583
584 #[test]
585 fn stale_estimate_not_applied_after_window_reset() {
586 let g = counters();
587 let key = "k";
588 // The key currently lives on a 120s window with a fresh remote estimate.
589 g.record(key, Duration::from_secs(120));
590 g.states.get_mut(key).unwrap().remote_estimate = 5;
591
592 // A reconcile plan captured earlier for the OLD 60s window resumes after
593 // its read. Its estimate must not clobber the freshly-reset state.
594 let now = unix_now();
595 let plan = PushPlan {
596 key: key.to_string(),
597 push_epoch: window::epoch(now, W),
598 read_epoch: window::epoch(now, W),
599 window: W,
600 delta: 0,
601 is_carryover: false,
602 };
603 g.apply_estimates(now, &[plan], &[(100, 0)]);
604
605 assert_eq!(
606 g.states.get(key).unwrap().remote_estimate,
607 5,
608 "an old-window estimate must not overwrite the reset state"
609 );
610 }
611
612 #[test]
613 fn independent_keys_have_independent_budgets() {
614 let g = counters();
615 assert!(g.fleet_remaining("a", 1, W) > 0);
616 g.record("a", W);
617 assert_eq!(g.fleet_remaining("a", 1, W), 0);
618 // A different key is unaffected.
619 assert!(g.fleet_remaining("b", 1, W) > 0);
620 }
621
622 /// End-to-end reconciliation against a live Redis-protocol store: one
623 /// instance's admits become visible to another after a reconcile pass, so
624 /// the two enforce one combined budget.
625 ///
626 /// Requires a reachable store; the URL comes from `SHIELD_REDIS_TEST_URL`
627 /// (CI sets it to its Redis service). Without it the test reports that it was
628 /// skipped for lack of infrastructure rather than silently passing.
629 #[tokio::test]
630 async fn reconciles_across_instances() {
631 let Ok(url) = std::env::var("SHIELD_REDIS_TEST_URL") else {
632 eprintln!("SKIP reconciles_across_instances: SHIELD_REDIS_TEST_URL not set");
633 return;
634 };
635 // Unique key per run so repeated runs / parallel suites do not collide.
636 let nonce = SystemTime::now()
637 .duration_since(UNIX_EPOCH)
638 .unwrap()
639 .as_nanos();
640 let key = format!("it:{}:{nonce}", std::process::id());
641
642 let a = GlobalCounters::build(&url, Duration::from_millis(200)).unwrap();
643 let b = GlobalCounters::build(&url, Duration::from_millis(200)).unwrap();
644
645 // Instance A admits two requests and pushes them to the store.
646 a.record(&key, W);
647 a.record(&key, W);
648 a.reconcile().await;
649
650 // B's first contact with the key: it has not reconciled this key yet, so
651 // it admits once (the documented one-interval lag / bounded overshoot).
652 assert!(
653 b.fleet_remaining(&key, 3, W) > 0,
654 "B admits its first request for the key"
655 );
656 b.record(&key, W);
657
658 // After a reconcile pass B has pushed its own admit and pulled the
659 // aggregate: the combined view is 3 (A's 2 + B's 1) = the budget, so B
660 // rejects the next request. The two instances enforce one combined limit.
661 b.reconcile().await;
662 assert_eq!(
663 b.fleet_remaining(&key, 3, W),
664 0,
665 "B must reject once the combined budget is reached"
666 );
667 }
668
669 /// A carried-over delta from a rolled epoch is published to that epoch's
670 /// counter (not the current one) and then cleared.
671 #[tokio::test]
672 async fn carryover_is_pushed_to_its_epoch() {
673 let Ok(url) = std::env::var("SHIELD_REDIS_TEST_URL") else {
674 eprintln!("SKIP carryover_is_pushed_to_its_epoch: SHIELD_REDIS_TEST_URL not set");
675 return;
676 };
677 let win = Duration::from_secs(3600);
678 let nonce = SystemTime::now()
679 .duration_since(UNIX_EPOCH)
680 .unwrap()
681 .as_nanos();
682 let key = format!("it3:{}:{nonce}", std::process::id());
683
684 let g = GlobalCounters::build(&url, Duration::from_millis(200)).unwrap();
685 let cur = window::epoch(unix_now(), win);
686 let mut st = KeyState::new(cur, win.as_secs());
687 st.carryover = 3;
688 st.carryover_epoch = cur - 1;
689 g.states.insert(key.clone(), st);
690
691 g.reconcile().await;
692
693 let client = redis::Client::open(url).unwrap();
694 let mut conn = client.get_multiplexed_async_connection().await.unwrap();
695 let prev: i64 = redis::cmd("GET")
696 .arg(epoch_key(&key, cur - 1))
697 .query_async(&mut conn)
698 .await
699 .unwrap_or(0);
700 assert_eq!(prev, 3, "carryover must be published to its own epoch");
701 assert_eq!(
702 g.states.get(&key).unwrap().carryover,
703 0,
704 "carryover must be cleared once published"
705 );
706 }
707
708 /// A key with no local admits (only being rejected on a stale remote
709 /// estimate) must still have its estimate refreshed by a reconcile pass, so
710 /// it can recover once the fleet stops spending the budget.
711 #[tokio::test]
712 async fn estimate_refreshes_without_local_deltas() {
713 let Ok(url) = std::env::var("SHIELD_REDIS_TEST_URL") else {
714 eprintln!(
715 "SKIP estimate_refreshes_without_local_deltas: SHIELD_REDIS_TEST_URL not set"
716 );
717 return;
718 };
719 let win = Duration::from_secs(3600);
720 let nonce = SystemTime::now()
721 .duration_since(UNIX_EPOCH)
722 .unwrap()
723 .as_nanos();
724 let key = format!("it4:{}:{nonce}", std::process::id());
725
726 let g = GlobalCounters::build(&url, Duration::from_millis(200)).unwrap();
727 // Seed a stale-high remote estimate with no local admits (delta 0). The
728 // shared counter for this fresh key is empty, so a reconcile must pull it
729 // and decay the estimate to 0 rather than leaving the key stuck.
730 let cur = window::epoch(unix_now(), win);
731 let mut st = KeyState::new(cur, win.as_secs());
732 st.remote_estimate = 99;
733 g.states.insert(key.clone(), st);
734
735 g.reconcile().await;
736
737 assert_eq!(
738 g.states.get(&key).unwrap().remote_estimate,
739 0,
740 "estimate must be refreshed even with no local deltas"
741 );
742 }
743
744 /// Repeated reconcile passes with no new admits must not re-push the same
745 /// delta: the shared counter reflects each admit exactly once, even if a
746 /// pass's aggregate read had failed on an earlier tick.
747 #[tokio::test]
748 async fn repeated_reconcile_does_not_double_push() {
749 let Ok(url) = std::env::var("SHIELD_REDIS_TEST_URL") else {
750 eprintln!(
751 "SKIP repeated_reconcile_does_not_double_push: SHIELD_REDIS_TEST_URL not set"
752 );
753 return;
754 };
755 // Hour-long window so no epoch boundary is crossed mid-test.
756 let win = Duration::from_secs(3600);
757 let nonce = SystemTime::now()
758 .duration_since(UNIX_EPOCH)
759 .unwrap()
760 .as_nanos();
761 let key = format!("it2:{}:{nonce}", std::process::id());
762
763 let a = GlobalCounters::build(&url, Duration::from_millis(200)).unwrap();
764 a.record(&key, win);
765 a.record(&key, win);
766 // Three passes: only the first has a non-zero delta to push.
767 a.reconcile().await;
768 a.reconcile().await;
769 a.reconcile().await;
770
771 let epoch = window::epoch(unix_now(), win);
772 let redis_key = epoch_key(&key, epoch);
773 let client = redis::Client::open(url).unwrap();
774 let mut conn = client.get_multiplexed_async_connection().await.unwrap();
775 let count: i64 = redis::cmd("GET")
776 .arg(&redis_key)
777 .query_async(&mut conn)
778 .await
779 .unwrap_or(0);
780 assert_eq!(
781 count, 2,
782 "shared counter must reflect the 2 admits exactly once"
783 );
784 }
785}