dwara_core/dataplane/
canary.rs

1//! Auto-canary analysis (DW-091): metrics-driven promotion/rollback of
2//! canary split weights.
3//!
4//! A [`CanaryController`] holds per-group state (one group per service
5//! split or AI model alias that configures `canary_analysis`). The
6//! controller maintains its OWN per-version sliding windows for error
7//! rate and latency (no new Prometheus histograms) using atomic
8//! counters and a bounded `VecDeque` of latency samples — the same
9//! lightweight pattern as the resilience domain's `AdaptiveController`
10//! (DW-089) and the balancer's `PeakEwmaTracker`.
11//!
12//! # Lifecycle
13//!
14//! Compiled once per config generation from the `canary_analysis`
15//! blocks on service splits and AI model aliases; shared via `Arc`
16//! from the dataplane generation. `record_outcome` runs on the
17//! response path (after the upstream exchange resolves) and
18//! `evaluate` runs on the background [`CanaryRunner`] loop. Weight
19//! changes are TRANSIENT: the runner calls
20//! `DataPlane::apply_service_split_weights` /
21//! `apply_ai_canary_weights`, which clone the current generation's
22//! split/runtime, rebuild with new weights, and ArcSwap-store. A
23//! config reload rebuilds the generation from the published config,
24//! reverting any transient weight changes (the controller's sliding
25//! windows reset with the generation, like the adaptive controller).
26//!
27//! # Step semantics
28//!
29//! Configurable step in weight units. Promote: increase canary weight
30//! by `step` (up to `total_weight`). Rollback: decrease by `step`
31//! (down to 0). Severe regression (>2x the rollback threshold):
32//! immediate rollback to 0. The total weight stays constant: when
33//! the canary weight changes, the baseline absorbs the difference,
34//! preserving the hash distribution for existing sessions (the
35//! DW-040 invariant).
36//!
37//! # Guards
38//!
39//! - **Cooldown**: after one adjustment, the next is suppressed until
40//!   `cooldown_seconds` elapses.
41//! - **min_requests**: the controller does not act until at least
42//!   `min_requests` total observations (baseline + canary) have been
43//!   recorded.
44//! - **Disabled**: a `canary_analysis` block with `enabled: false`
45//!   compiles no group (the fast path: `record_outcome` and
46//!   `evaluate` are no-ops).
47
48use std::collections::{HashMap, VecDeque};
49use std::sync::atomic::{AtomicU64, Ordering};
50use std::sync::{Arc, Mutex};
51use std::time::Duration;
52
53use crate::config::{CanaryAnalysis, CanaryMetric, Gateway};
54use crate::dataplane::proxy::DataPlane;
55use crate::events::{EventKind, EventPayload};
56use crate::observability::Observability;
57
58/// Maximum latency samples kept per side (the sliding window cap).
59/// 1000 samples is enough for a stable p99 estimate without
60/// unbounded memory growth. The `window_seconds` config field
61/// documents the intended look-back period; the actual window is
62/// sample-count-bounded (the last 1000 observations), which is
63/// simpler and avoids a time-based eviction timer on the hot path.
64const MAX_LATENCY_SAMPLES: usize = 1000;
65
66/// Unix-epoch second clock (the same clock the adaptive controller
67/// uses, in seconds). System clock in production; swappable for
68/// deterministic clocks in tests via [`CanaryController::compile_with_clock`].
69fn system_now_secs() -> u64 {
70    std::time::SystemTime::now()
71        .duration_since(std::time::UNIX_EPOCH)
72        .map(|d| d.as_secs())
73        .unwrap_or(0)
74}
75
76/// Whether a canary group is a service-level split or an AI model
77/// canary (DW-091). Determines which `apply_*_weights` method the
78/// runner calls.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80pub enum CanaryKind {
81    /// A service-level traffic split (`services[].split.canary_analysis`).
82    Service,
83    /// An AI model canary split (`ai.models.<alias>.canary_analysis`).
84    Ai,
85}
86
87/// One side of a canary comparison (baseline or canary). All fields
88/// are atomic or mutex-guarded so `record_outcome` is lock-free for
89/// the counters and briefly locks only the latency window.
90struct CanarySide {
91    /// Total requests observed on this side.
92    requests: AtomicU64,
93    /// Requests that returned a 5xx status.
94    errors: AtomicU64,
95    /// Sliding window of latency samples (milliseconds), capped at
96    /// [`MAX_LATENCY_SAMPLES`]. A `Mutex<VecDeque<f64>>` — the lock
97    /// is held only for the push/trim, never across a network call.
98    latency_samples: Mutex<VecDeque<f64>>,
99}
100
101impl CanarySide {
102    fn new() -> Self {
103        CanarySide {
104            requests: AtomicU64::new(0),
105            errors: AtomicU64::new(0),
106            latency_samples: Mutex::new(VecDeque::with_capacity(MAX_LATENCY_SAMPLES)),
107        }
108    }
109
110    fn record(&self, status: u16, latency_ms: f64) {
111        self.requests.fetch_add(1, Ordering::Relaxed);
112        if status >= 500 {
113            self.errors.fetch_add(1, Ordering::Relaxed);
114        }
115        if let Ok(mut samples) = self.latency_samples.lock() {
116            if samples.len() >= MAX_LATENCY_SAMPLES {
117                samples.pop_front();
118            }
119            samples.push_back(latency_ms);
120        }
121    }
122
123    fn request_count(&self) -> u64 {
124        self.requests.load(Ordering::Relaxed)
125    }
126
127    fn error_count(&self) -> u64 {
128        self.errors.load(Ordering::Relaxed)
129    }
130
131    /// Error rate as a fraction in [0.0, 1.0]. Returns 0.0 when no
132    /// requests have been observed (avoids division by zero).
133    fn error_rate(&self) -> f64 {
134        let req = self.request_count();
135        if req == 0 {
136            return 0.0;
137        }
138        self.error_count() as f64 / req as f64
139    }
140
141    /// The requested latency percentile in milliseconds. Returns 0.0
142    /// when no samples have been collected. Uses a simple sort-based
143    /// selection (the window is capped at 1000 samples, so the sort
144    /// is cheap and runs only on the evaluation loop, never on the
145    /// request path).
146    fn percentile(&self, pct: f64) -> f64 {
147        let Ok(samples) = self.latency_samples.lock() else {
148            return 0.0;
149        };
150        if samples.is_empty() {
151            return 0.0;
152        }
153        let mut sorted: Vec<f64> = samples.iter().copied().collect();
154        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
155        let idx = ((pct / 100.0) * (sorted.len() as f64 - 1.0)).round() as usize;
156        sorted[idx.min(sorted.len() - 1)]
157    }
158}
159
160/// One canary group: a service split or AI model alias with
161/// `canary_analysis` configured. Holds the baseline and canary
162/// sliding windows, the current canary weight, and the cooldown
163/// timestamp.
164struct CanaryGroup {
165    name: String,
166    kind: CanaryKind,
167    config: CanaryAnalysis,
168    baseline: CanarySide,
169    canary: CanarySide,
170    /// Timestamp (seconds) of the last weight adjustment. 0 = never.
171    last_adjustment: AtomicU64,
172    /// Current canary weight (transient; reverts on config reload).
173    current_canary_weight: AtomicU64,
174    /// Total weight (constant; baseline + canary = total).
175    total_weight: u64,
176    /// Second clock (system clock in production; injected in tests).
177    now_secs: fn() -> u64,
178}
179
180impl CanaryGroup {
181    fn new(
182        name: String,
183        kind: CanaryKind,
184        config: CanaryAnalysis,
185        total_weight: u64,
186        initial_canary_weight: u64,
187        now_secs: fn() -> u64,
188    ) -> Self {
189        CanaryGroup {
190            name,
191            kind,
192            config,
193            baseline: CanarySide::new(),
194            canary: CanarySide::new(),
195            last_adjustment: AtomicU64::new(0),
196            current_canary_weight: AtomicU64::new(initial_canary_weight),
197            total_weight,
198            now_secs,
199        }
200    }
201
202    /// The current transient canary weight.
203    pub fn current_canary_weight(&self) -> u64 {
204        self.current_canary_weight.load(Ordering::Relaxed)
205    }
206
207    /// Compute the metric value for one side per the rule's metric kind.
208    fn metric_for(&self, side: &CanarySide, metric: CanaryMetric) -> f64 {
209        match metric {
210            CanaryMetric::ErrorRate => side.error_rate(),
211            CanaryMetric::LatencyP99 => side.percentile(99.0),
212            CanaryMetric::LatencyP95 => side.percentile(95.0),
213            CanaryMetric::LatencyP50 => side.percentile(50.0),
214        }
215    }
216
217    /// Evaluate this group and return an action if one is warranted.
218    /// Returns `None` when cooldown or min_requests suppresses the
219    /// evaluation, or when the canary is neither promoting nor
220    /// regressing.
221    fn evaluate(&self) -> Option<CanaryAction> {
222        let now = (self.now_secs)();
223        let last = self.last_adjustment.load(Ordering::Relaxed);
224        if last > 0 {
225            let elapsed = now.saturating_sub(last);
226            if elapsed < self.config.cooldown_seconds {
227                return None;
228            }
229        }
230        let baseline_req = self.baseline.request_count();
231        let canary_req = self.canary.request_count();
232        let total_req = baseline_req + canary_req;
233        if total_req < self.config.min_requests {
234            return None;
235        }
236        let current_weight = self.current_canary_weight();
237        // Severe regression (>2x rollback threshold): immediate
238        // rollback to 0, ignoring the step.
239        let canary_rollback_metric = self.metric_for(&self.canary, self.config.rollback.metric);
240        if canary_rollback_metric > self.config.rollback.threshold * 2.0 {
241            if current_weight == 0 {
242                return None;
243            }
244            return Some(CanaryAction {
245                group: self.name.clone(),
246                kind: self.kind,
247                new_canary_weight: 0,
248                reason: "severe_regression".to_string(),
249                metric_value: canary_rollback_metric,
250                threshold: self.config.rollback.threshold,
251            });
252        }
253        // Rollback: canary metric above the rollback threshold.
254        if canary_rollback_metric > self.config.rollback.threshold {
255            let new_weight = current_weight.saturating_sub(self.config.step as u64);
256            if new_weight == current_weight {
257                return None;
258            }
259            return Some(CanaryAction {
260                group: self.name.clone(),
261                kind: self.kind,
262                new_canary_weight: new_weight,
263                reason: "rollback".to_string(),
264                metric_value: canary_rollback_metric,
265                threshold: self.config.rollback.threshold,
266            });
267        }
268        // Promote: canary metric below the promote threshold (good).
269        let canary_promote_metric = self.metric_for(&self.canary, self.config.promote.metric);
270        if canary_promote_metric < self.config.promote.threshold {
271            let new_weight = (current_weight + self.config.step as u64).min(self.total_weight);
272            if new_weight == current_weight {
273                return None;
274            }
275            return Some(CanaryAction {
276                group: self.name.clone(),
277                kind: self.kind,
278                new_canary_weight: new_weight,
279                reason: "promote".to_string(),
280                metric_value: canary_promote_metric,
281                threshold: self.config.promote.threshold,
282            });
283        }
284        None
285    }
286}
287
288/// One evaluation result: the controller recommends adjusting
289/// `group`'s canary weight to `new_canary_weight` for the given
290/// `reason`.
291#[derive(Debug, Clone, PartialEq)]
292pub struct CanaryAction {
293    /// The group name (service name or AI alias).
294    pub group: String,
295    /// Whether this is a service-level or AI canary.
296    pub kind: CanaryKind,
297    /// The recommended new canary weight.
298    pub new_canary_weight: u64,
299    /// Why: "promote", "rollback", or "severe_regression".
300    pub reason: String,
301    /// The metric value that triggered the action.
302    pub metric_value: f64,
303    /// The threshold the metric was compared against.
304    pub threshold: f64,
305}
306
307/// The per-generation canary analysis controller (DW-091). Compiled
308/// once per config generation from the `canary_analysis` blocks on
309/// service splits and AI model aliases; shared via `Arc` from the
310/// dataplane generation. `record_outcome` runs on the response path
311/// and `evaluate` runs on the background [`CanaryRunner`] loop.
312pub struct CanaryController {
313    groups: HashMap<String, CanaryGroup>,
314    /// Observability handle for the canary metrics. `None` only in
315    /// direct unit-test construction; the dataplane always wires the
316    /// real registry.
317    obs: Option<Arc<Observability>>,
318    /// Second clock (system clock in production; a test may inject a
319    /// controllable clock via [`Self::compile_with_clock`]).
320    now_secs: fn() -> u64,
321}
322
323impl std::fmt::Debug for CanaryController {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        f.debug_struct("CanaryController")
326            .field("groups", &self.groups.len())
327            .finish()
328    }
329}
330
331impl CanaryController {
332    /// New empty controller (no canary groups). The fast path:
333    /// `record_outcome` and `evaluate` are no-ops.
334    pub fn new() -> Self {
335        CanaryController {
336            groups: HashMap::new(),
337            obs: None,
338            now_secs: system_now_secs,
339        }
340    }
341
342    /// Compile the controller from a gateway's service splits and AI
343    /// model aliases. Only services/aliases with an enabled
344    /// `canary_analysis` block contribute a group. The observability
345    /// handle wires the metric families; pass `None` for direct
346    /// unit-test construction (metrics are then a no-op).
347    pub fn compile(gateway: &Gateway, obs: Option<Arc<Observability>>) -> Self {
348        Self::compile_with_clock(gateway, obs, system_now_secs)
349    }
350
351    /// [`Self::compile`] with a caller-supplied second clock (Unix
352    /// epoch). Intended for tests that need to advance time
353    /// deterministically (the same pattern as
354    /// `AdaptiveController::compile_with_clock`); production keeps the
355    /// system clock via [`Self::compile`].
356    pub fn compile_with_clock(
357        gateway: &Gateway,
358        obs: Option<Arc<Observability>>,
359        now_secs: fn() -> u64,
360    ) -> Self {
361        let mut groups = HashMap::new();
362        // Service-level splits: canary_analysis on a 2-target split.
363        for service in &gateway.services {
364            let Some(split) = &service.split else {
365                continue;
366            };
367            let Some(analysis) = &split.canary_analysis else {
368                continue;
369            };
370            if !analysis.enabled {
371                continue;
372            }
373            // Only 2-target splits are canary-shaped (baseline + canary).
374            if split.targets.len() != 2 {
375                continue;
376            }
377            let total: u64 = split.targets.iter().map(|t| t.weight as u64).sum();
378            if total == 0 {
379                continue;
380            }
381            // The canary is the SECOND target (index 1); the baseline
382            // is the first (index 0). The initial canary weight is
383            // the configured weight of the second target.
384            let initial_canary = split.targets[1].weight as u64;
385            groups.insert(
386                service.name.clone(),
387                CanaryGroup::new(
388                    service.name.clone(),
389                    CanaryKind::Service,
390                    analysis.clone(),
391                    total,
392                    initial_canary,
393                    now_secs,
394                ),
395            );
396        }
397        // AI model canaries: canary_analysis on a 2-version canary.
398        if let Some(ai) = &gateway.ai {
399            for (alias, model) in &ai.models {
400                let Some(analysis) = &model.canary_analysis else {
401                    continue;
402                };
403                if !analysis.enabled {
404                    continue;
405                }
406                // Only 2-version canaries are canary-shaped.
407                if model.canary.len() != 2 {
408                    continue;
409                }
410                let total: u64 = model.canary.iter().map(|v| v.weight as u64).sum();
411                if total == 0 {
412                    continue;
413                }
414                let initial_canary = model.canary[1].weight as u64;
415                groups.insert(
416                    alias.clone(),
417                    CanaryGroup::new(
418                        alias.clone(),
419                        CanaryKind::Ai,
420                        analysis.clone(),
421                        total,
422                        initial_canary,
423                        now_secs,
424                    ),
425                );
426            }
427        }
428        // Seed the canary weight gauges for the initial state.
429        if let Some(obs) = &obs {
430            for group in groups.values() {
431                obs.set_canary_weight(&group.name, group.current_canary_weight() as f64);
432            }
433        }
434        CanaryController {
435            groups,
436            obs,
437            now_secs,
438        }
439    }
440
441    /// Whether any canary group is compiled in at all (the fast path:
442    /// configs with no canary_analysis skip the per-request
443    /// record_outcome entirely).
444    pub fn is_empty(&self) -> bool {
445        self.groups.is_empty()
446    }
447
448    /// Record one upstream outcome for `group_name` (DW-091):
449    /// increment the appropriate side's request/error counters and
450    /// add the latency sample. A no-op when the group has no
451    /// canary_analysis. `is_canary` selects the side (true = canary,
452    /// false = baseline); `status` is the upstream HTTP status (5xx
453    /// counts as an error); `latency_ms` is the upstream round-trip
454    /// in milliseconds.
455    pub fn record_outcome(&self, group_name: &str, is_canary: bool, status: u16, latency_ms: f64) {
456        let Some(group) = self.groups.get(group_name) else {
457            return;
458        };
459        if is_canary {
460            group.canary.record(status, latency_ms);
461        } else {
462            group.baseline.record(status, latency_ms);
463        }
464    }
465
466    /// Evaluate all groups and return the actions the runner should
467    /// apply. Each action carries the recommended new canary weight.
468    /// The runner applies them and emits events; the controller's
469    /// `last_adjustment` and `current_canary_weight` are updated by
470    /// [`Self::note_applied`].
471    pub fn evaluate(&self) -> Vec<CanaryAction> {
472        let mut actions = Vec::new();
473        for group in self.groups.values() {
474            if let Some(action) = group.evaluate() {
475                actions.push(action);
476            }
477        }
478        actions
479    }
480
481    /// Note that an action was successfully applied (the runner calls
482    /// this after `apply_*_weights` returns true). Updates the
483    /// group's `last_adjustment` and `current_canary_weight`, and
484    /// records the promotion/rollback metric.
485    pub fn note_applied(&self, action: &CanaryAction) {
486        let Some(group) = self.groups.get(&action.group) else {
487            return;
488        };
489        let now = (self.now_secs)();
490        group.last_adjustment.store(now, Ordering::Relaxed);
491        group
492            .current_canary_weight
493            .store(action.new_canary_weight, Ordering::Relaxed);
494        if let Some(obs) = &self.obs {
495            obs.set_canary_weight(&action.group, action.new_canary_weight as f64);
496            if action.new_canary_weight == 0
497                || action.reason == "rollback"
498                || action.reason == "severe_regression"
499            {
500                obs.record_canary_rollback(&action.group);
501            } else {
502                obs.record_canary_promotion(&action.group);
503            }
504        }
505    }
506
507    /// The current transient canary weight for a group (tests /
508    /// diagnostics). Returns `None` when the group does not exist.
509    pub fn current_canary_weight(&self, group: &str) -> Option<u64> {
510        self.groups.get(group).map(|g| g.current_canary_weight())
511    }
512
513    /// The total weight for a group (tests / diagnostics).
514    pub fn total_weight(&self, group: &str) -> Option<u64> {
515        self.groups.get(group).map(|g| g.total_weight)
516    }
517}
518
519impl Default for CanaryController {
520    fn default() -> Self {
521        CanaryController::new()
522    }
523}
524
525/// The background canary evaluation loop (DW-091). Spawned by
526/// dwara-bin after the dataplane is initialized; periodically calls
527/// `CanaryController::evaluate`, applies the returned actions through
528/// the dataplane's transient weight-swap methods, and emits
529/// `CanaryPromoted` / `CanaryRolledBack` events. Dropping the runner
530/// (or shutdown) aborts the loop.
531pub struct CanaryRunner {
532    handle: Option<tokio::task::JoinHandle<()>>,
533}
534
535impl CanaryRunner {
536    /// Spawn the background evaluation loop. `interval` is the
537    /// evaluation period (the operator picks it; a shorter interval
538    /// reacts faster but does more work). The loop stops when the
539    /// dataplane is dropped or the task is aborted.
540    pub fn spawn(dp: Arc<DataPlane>, interval: Duration) -> Self {
541        let handle = tokio::spawn(async move {
542            let mut timer = tokio::time::interval(interval);
543            timer.tick().await; // skip the first immediate tick
544            loop {
545                timer.tick().await;
546                let gen = dp.current();
547                let Some(canary) = gen.canary() else {
548                    continue;
549                };
550                if canary.is_empty() {
551                    continue;
552                }
553                let actions = canary.evaluate();
554                for action in actions {
555                    let success = match action.kind {
556                        CanaryKind::Service => {
557                            dp.apply_service_split_weights(&action.group, action.new_canary_weight)
558                        }
559                        CanaryKind::Ai => {
560                            dp.apply_ai_canary_weights(&action.group, action.new_canary_weight)
561                        }
562                    };
563                    if success {
564                        canary.note_applied(&action);
565                        let kind = if action.new_canary_weight == 0
566                            || action.reason == "rollback"
567                            || action.reason == "severe_regression"
568                        {
569                            EventKind::CanaryRolledBack
570                        } else {
571                            EventKind::CanaryPromoted
572                        };
573                        dp.events().emitter().emit(
574                            kind,
575                            EventPayload {
576                                canary_group: Some(action.group.clone()),
577                                canary_action: Some(action.reason.clone()),
578                                canary_weight: Some(action.new_canary_weight),
579                                canary_metric_value: Some(action.metric_value),
580                                ..EventPayload::default()
581                            },
582                        );
583                    }
584                }
585            }
586        });
587        CanaryRunner {
588            handle: Some(handle),
589        }
590    }
591
592    /// Abort the evaluation loop (stops at the next await point).
593    pub fn abort(&mut self) {
594        if let Some(h) = self.handle.take() {
595            h.abort();
596        }
597    }
598}
599
600impl Drop for CanaryRunner {
601    fn drop(&mut self) {
602        self.abort();
603    }
604}