dwara_core/resilience/
adaptive.rs

1//! Adaptive + origin-driven rate-limit tuning (DW-089).
2//!
3//! An [`AdaptiveController`] holds per-policy atomic EWMA state for the
4//! upstream error rate and latency, plus a current adaptive factor that
5//! scales a policy's rate-limit quotas at check time. The controller is
6//! compiled once per config generation from the `policies[].adaptive`
7//! blocks (only policies with `adaptive.enabled` contribute state); it
8//! is shared via `Arc` from the dataplane generation and updated on the
9//! response path (`record_outcome`), then read on the request path
10//! (`factor_for`) — both lock-free, the same atomic-EWMA pattern as the
11//! balancer's `PeakEwmaTracker` (in `dataplane::balance`).
12//!
13//! # Factor logic
14//!
15//! The adaptive factor is bounded `[min_factor, max_factor]` (never 0,
16//! never unbounded). When the upstream is stressed (error EWMA above
17//! `error_threshold` OR latency EWMA above `latency_threshold_ms`) the
18//! factor DECREASES toward `min_factor` (tighten — each request "costs
19//! more" against the GCRA bucket, so the effective rate drops). When the
20//! upstream is healthy the factor INCREASES toward `max_factor` (relax —
21//! each request "costs less", so the effective rate rises). Tightening
22//! is faster than relaxing (asymmetric: `TIGHTEN_RATE` < 1.0 multiplies
23//! the factor down by ~5% per stressed update, `RELAX_RATE` > 1.0
24//! multiplies it up by ~1% per healthy update), so the gateway errs
25//! toward protecting upstreams.
26//!
27//! # Origin signals
28//!
29//! When `retry_after` is in `origin_signals` and the upstream response
30//! carries a `Retry-After` header, `record_outcome` sets a backoff
31//! deadline (`now + retry_after_duration`) and drops the factor to
32//! `min_factor` immediately. While within the backoff window,
33//! `factor_for` returns `min_factor` regardless of the EWMA state; once
34//! the window elapses the EWMA-driven factor resumes.
35//!
36//! # Atomic f64 storage
37//!
38//! Error EWMA, latency EWMA, the factor, and the backoff deadline are
39//! stored as `AtomicU64` holding the bits of an `f64` (`f64::to_bits` /
40//! `f64::from_bits`), exactly like the balancer's `PeakEwmaTracker`
41//! `cost_ns`. The
42//! stamp is nanoseconds since the Unix epoch (the same
43//! `system_now_ns` clock the balancer uses). All loads/stores are
44//! `Ordering::Relaxed`: the values are advisory (a stale factor merely
45//! tightens or relaxes one request early), so the relaxed ordering is
46//! correct and the hot path stays lock-free.
47
48use std::collections::HashMap;
49use std::sync::atomic::{AtomicU64, Ordering};
50use std::sync::Arc;
51use std::time::Duration;
52
53use crate::config::{AdaptiveRateLimit, Gateway, OriginSignal};
54use crate::observability::Observability;
55
56/// Per-stressed-update multiplier: the factor shrinks toward `min_factor`
57/// by this fraction each update while the upstream is stressed. ~5% per
58/// update tightens fast (a sustained 5xx storm reaches the floor in
59/// roughly `ln(min)/ln(0.95)` updates).
60const TIGHTEN_RATE: f64 = 0.95;
61/// Per-healthy-update multiplier: the factor grows toward `max_factor`
62/// by this fraction each update while the upstream is healthy. ~1% per
63/// update relaxes slowly (recovery is deliberately gentler than the
64/// tightening that preceded it).
65const RELAX_RATE: f64 = 1.01;
66
67/// Unix-epoch nanosecond clock (the same clock the balancer's
68/// PeakEwmaTracker uses). System clock in production; the stamp only
69/// needs to be monotonic-ish for the decay delta.
70fn system_now_ns() -> u64 {
71    std::time::SystemTime::now()
72        .duration_since(std::time::UNIX_EPOCH)
73        .map(|d| d.as_nanos() as u64)
74        .unwrap_or(0)
75}
76
77/// Per-policy adaptive state (DW-089). All fields are atomic so
78/// `record_outcome` (response path) and `factor_for` (request path) are
79/// lock-free. The `f64` EWMA values and the factor are stored as bits in
80/// `AtomicU64`; the backoff deadline is nanoseconds since the Unix epoch.
81struct AdaptivePolicyState {
82    config: AdaptiveRateLimit,
83    /// EWMA of the error indicator (1.0 for a 5xx, 0.0 otherwise),
84    /// stored as bits of an `f64` in `[0, 1]`.
85    error_ewma: AtomicU64,
86    /// EWMA of the upstream latency in milliseconds, stored as bits of
87    /// an `f64`.
88    latency_ewma: AtomicU64,
89    /// Stamp of the last EWMA update (nanoseconds since the Unix epoch).
90    stamp: AtomicU64,
91    /// Current adaptive factor, stored as bits of an `f64` in
92    /// `[min_factor, max_factor]`. Starts at 1.0 (the configured rate).
93    factor: AtomicU64,
94    /// Retry-After backoff deadline: nanoseconds since the Unix epoch
95    /// until which `factor_for` returns `min_factor`. 0 when no backoff
96    /// is in effect.
97    retry_after_until: AtomicU64,
98    /// Whether this policy honors the `retry_after` origin signal
99    /// (cached from `config.origin_signals` for the hot path).
100    honors_retry_after: bool,
101    /// Nanosecond clock (system clock in production; a test may inject a
102    /// controllable clock the same way `Breaker::with_clock` does).
103    now_ns: fn() -> u64,
104}
105
106impl AdaptivePolicyState {
107    fn new(config: AdaptiveRateLimit, now_ns: fn() -> u64) -> Self {
108        let honors_retry_after = config
109            .origin_signals
110            .iter()
111            .any(|s| matches!(s, OriginSignal::RetryAfter));
112        let now = now_ns();
113        AdaptivePolicyState {
114            config,
115            error_ewma: AtomicU64::new(0.0f64.to_bits()),
116            latency_ewma: AtomicU64::new(0.0f64.to_bits()),
117            stamp: AtomicU64::new(now),
118            factor: AtomicU64::new(1.0f64.to_bits()),
119            retry_after_until: AtomicU64::new(0),
120            honors_retry_after,
121            now_ns,
122        }
123    }
124
125    /// Load the current factor, honoring an active Retry-After backoff
126    /// window. Within the window the factor is `min_factor`; outside it
127    /// the EWMA-driven factor applies.
128    fn factor(&self) -> f64 {
129        let now = (self.now_ns)();
130        let until = self.retry_after_until.load(Ordering::Relaxed);
131        if until > 0 && now < until {
132            return self.config.min_factor;
133        }
134        f64::from_bits(self.factor.load(Ordering::Relaxed))
135    }
136}
137
138/// EWMA-feedback-driven limiter tuning (DW-089). Compiled once per
139/// config generation from the `policies[].adaptive` blocks; shared via
140/// `Arc` from the dataplane generation. `record_outcome` runs on the
141/// response path (after the upstream exchange resolves) and `factor_for`
142/// runs on the request path (inside the rate-limit check) — both
143/// lock-free. Policies without an enabled `adaptive` block are absent
144/// from the map; `factor_for` returns `1.0` for them (no adaptive
145/// tuning, the configured quotas apply as-is).
146pub struct AdaptiveController {
147    states: HashMap<String, AdaptivePolicyState>,
148    /// Observability handle for the adaptive-factor / origin-signal /
149    /// tighten / relax metrics. `None` only in direct unit-test
150    /// construction; the dataplane always wires the real registry.
151    obs: Option<Arc<Observability>>,
152    /// Nanosecond clock (system clock in production; a test may inject a
153    /// controllable clock via [`Self::compile_with_clock`]).
154    now_ns: fn() -> u64,
155}
156
157impl std::fmt::Debug for AdaptiveController {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("AdaptiveController")
160            .field("policies", &self.states.len())
161            .finish()
162    }
163}
164
165impl AdaptiveController {
166    /// New empty controller (no adaptive policies). The fast path:
167    /// `factor_for` returns `1.0` for every policy.
168    pub fn new() -> Self {
169        AdaptiveController {
170            states: HashMap::new(),
171            obs: None,
172            now_ns: system_now_ns,
173        }
174    }
175
176    /// Compile the controller from a gateway's policies. Only policies
177    /// with an `adaptive` block whose `enabled` is true contribute
178    /// state; the rest are absent (their `factor_for` returns `1.0`).
179    /// The observability handle wires the metric families; pass `None`
180    /// for direct unit-test construction (metrics are then a no-op).
181    pub fn compile(gateway: &Gateway, obs: Option<Arc<Observability>>) -> Self {
182        Self::compile_with_clock(gateway, obs, system_now_ns)
183    }
184
185    /// [`Self::compile`] with a caller-supplied nanosecond clock (Unix
186    /// epoch). Intended for tests that need to advance time deterministically
187    /// (the same pattern as `Breaker::with_clock`); production keeps the
188    /// system clock via [`Self::compile`].
189    pub fn compile_with_clock(
190        gateway: &Gateway,
191        obs: Option<Arc<Observability>>,
192        now_ns: fn() -> u64,
193    ) -> Self {
194        let mut states = HashMap::new();
195        for policy in &gateway.policies {
196            if let Some(cfg) = &policy.adaptive {
197                if cfg.enabled {
198                    states.insert(
199                        policy.name.clone(),
200                        AdaptivePolicyState::new(cfg.clone(), now_ns),
201                    );
202                }
203            }
204        }
205        AdaptiveController {
206            states,
207            obs,
208            now_ns,
209        }
210    }
211
212    /// Whether any adaptive policy is compiled in at all (the fast path:
213    /// configs with no adaptive policies skip the per-request factor
214    /// lookup entirely).
215    pub fn is_empty(&self) -> bool {
216        self.states.is_empty()
217    }
218
219    /// The current adaptive factor for `policy_name`. Returns `1.0`
220    /// (no tuning) when the policy has no adaptive block. Within an
221    /// active Retry-After backoff window the factor is `min_factor`.
222    pub fn factor_for(&self, policy_name: &str) -> f64 {
223        match self.states.get(policy_name) {
224            Some(state) => state.factor(),
225            None => 1.0,
226        }
227    }
228
229    /// Record one upstream outcome for `policy_name` (DW-089): update
230    /// the error and latency EWMAs, apply any Retry-After backoff, and
231    /// recompute the adaptive factor. A no-op when the policy has no
232    /// adaptive block. `status` is the upstream HTTP status (5xx counts
233    /// as an error); `latency` is the upstream round-trip; `retry_after`
234    /// is the parsed `Retry-After` duration (only honored when
235    /// `retry_after` is in the policy's `origin_signals`).
236    pub fn record_outcome(
237        &self,
238        policy_name: &str,
239        status: u16,
240        latency: Duration,
241        retry_after: Option<Duration>,
242    ) {
243        let Some(state) = self.states.get(policy_name) else {
244            return;
245        };
246        let now = (self.now_ns)();
247        let tau = (state.config.ewma_window_secs as f64) * 1e9;
248
249        // Retry-After backoff: set the deadline and drop the factor to
250        // min immediately (the EWMA-driven factor resumes once the
251        // window elapses). Only honored when the policy opts in.
252        if let Some(ra) = retry_after {
253            if state.honors_retry_after {
254                let until = now.saturating_add(ra.as_nanos() as u64);
255                state.retry_after_until.store(until, Ordering::Relaxed);
256                state
257                    .factor
258                    .store(state.config.min_factor.to_bits(), Ordering::Relaxed);
259                if let Some(obs) = &self.obs {
260                    obs.record_adaptive_origin_signal(policy_name, "retry_after");
261                    obs.set_adaptive_factor(policy_name, state.config.min_factor);
262                }
263            }
264        }
265
266        // EWMA update: w = exp(-td / tau) decays the old value toward
267        // the new observation. td is the time since the last update; a
268        // first update (td ~ 0) keeps w near 1, so the EWMA seeds from
269        // the first observation.
270        let prev_stamp = state.stamp.load(Ordering::Relaxed);
271        let td = now.saturating_sub(prev_stamp) as f64;
272        let w = if tau > 0.0 { (-td / tau).exp() } else { 0.0 };
273        let error = if status >= 500 { 1.0 } else { 0.0 };
274        let prev_err = f64::from_bits(state.error_ewma.load(Ordering::Relaxed));
275        let new_err = prev_err * w + error * (1.0 - w);
276        state.error_ewma.store(new_err.to_bits(), Ordering::Relaxed);
277
278        let latency_ms = latency.as_millis() as f64;
279        let prev_lat = f64::from_bits(state.latency_ewma.load(Ordering::Relaxed));
280        let new_lat = prev_lat * w + latency_ms * (1.0 - w);
281        state
282            .latency_ewma
283            .store(new_lat.to_bits(), Ordering::Relaxed);
284        state.stamp.store(now, Ordering::Relaxed);
285
286        // Factor adjustment: stressed (error OR latency above
287        // threshold) tightens; healthy relaxes. Tightening is faster
288        // than relaxing (asymmetric). The factor is clamped to
289        // [min_factor, max_factor] and never 0.
290        let prev_factor = f64::from_bits(state.factor.load(Ordering::Relaxed));
291        let stressed = new_err > state.config.error_threshold
292            || new_lat > state.config.max_latency_threshold();
293        let new_factor = if stressed {
294            (prev_factor * TIGHTEN_RATE).max(state.config.min_factor)
295        } else {
296            (prev_factor * RELAX_RATE).min(state.config.max_factor)
297        };
298        // Within an active Retry-After window the factor stays at min.
299        let until = state.retry_after_until.load(Ordering::Relaxed);
300        let effective = if until > 0 && now < until {
301            state.config.min_factor
302        } else {
303            new_factor
304        };
305        state.factor.store(effective.to_bits(), Ordering::Relaxed);
306
307        // Metrics (resilience may import observability per the
308        // dependency direction; the controller holds the handle so the
309        // dataplane caller does not need to thread metrics back).
310        if let Some(obs) = &self.obs {
311            obs.set_adaptive_factor(policy_name, effective);
312            if stressed && effective < prev_factor {
313                obs.record_adaptive_tightened(policy_name);
314            } else if !stressed && effective > prev_factor {
315                obs.record_adaptive_relaxed(policy_name);
316            }
317        }
318    }
319}
320
321impl Default for AdaptiveController {
322    fn default() -> Self {
323        AdaptiveController::new()
324    }
325}
326
327/// Helper: the latency threshold as an `f64` (ms) for the comparison.
328impl AdaptiveRateLimit {
329    fn max_latency_threshold(&self) -> f64 {
330        self.latency_threshold_ms as f64
331    }
332}