Skip to main content

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}