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}