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}