Skip to main content

autumn_web/shadow/
mod.rs

1//! Live-traffic shadow mirroring and primary-vs-shadow response diffing
2//! (issue #1653).
3//!
4//! Canary, blue/green, and rolling deploys all answer the same question — *are
5//! the aggregate metrics still healthy?* — and all of them route **real**
6//! traffic to the new build to answer it. None of them catches the regression
7//! that returns `200 OK` with a dropped field, a reordered list, or an
8//! off-by-one total, because nothing ever compares two responses to the same
9//! request. This module does.
10//!
11//! ```text
12//!                        ┌──────────────────┐
13//!   client ──request──▶  │  live build      │ ──response──▶ client
14//!                        └────────┬─────────┘        │
15//!                                 │ (copy)           │ (tee)
16//!                        ┌────────▼─────────┐   ┌────▼─────┐
17//!                        │ candidate build  │──▶│  differ  │──▶ /actuator/shadow
18//!                        └──────────────────┘   └──────────┘        + metrics
19//! ```
20//!
21//! The candidate's response reaches the differ and nothing else. It is never a
22//! `Response`, never touches the primary's state, and never delays the client.
23//!
24//! # Scope of this slice
25//!
26//! Idempotent (`GET`/`HEAD`) traffic only, against an operator-provided shadow
27//! target. Mirroring mutating methods requires virtualizing the candidate's
28//! effects — DB writes, outbound HTTP, `#[job]` enqueues — which is the
29//! deliberate follow-up slice. The method allowlist is therefore a constant,
30//! not a config key.
31//!
32//! # Wiring
33//!
34//! Configured through [`ShadowConfig`](crate::shadow::ShadowConfig) (`[shadow]` in
35//! `autumn.toml`) and
36//! assembled into the ingress stack by the framework router; nothing here needs
37//! to be called by hand. See `docs/guide/staged-deploys.md`.
38
39// Private modules behind a curated re-export list, matching
40// `crate::middleware`: the module split is an implementation detail, and
41// everything below is deliberate public surface. `transport` stays public
42// because a third party may want to implement [`ShadowTransport`] against a
43// non-HTTP candidate.
44pub(crate) mod config;
45pub(crate) mod diff;
46pub(crate) mod layer;
47pub(crate) mod registry;
48pub(crate) mod sample;
49pub mod transport;
50
51pub use config::ShadowConfig;
52pub use diff::{
53    Comparison, Divergence, DivergenceKind, NormalizedBody, ResponseFacts, TRUNCATION_MARKER,
54    compare, normalize_body, redact_path_and_query, status_class,
55};
56pub use layer::{
57    COMPARISONS_METRIC, DIVERGENCES_METRIC, MirrorSettings, ShadowMirrorLayer, ShadowMirrorService,
58};
59pub use registry::{
60    DivergenceRecord, LabelledCount, OVERFLOW_ROUTE_LABEL, Recorded, RequestContext,
61    ShadowRegistry, ShadowSnapshot, ShadowStats,
62};
63pub use sample::{
64    MIRRORABLE_METHODS, MirrorDecision, MirrorSelector, SHADOW_HEADER, SHADOW_HEADER_VALUE,
65    SkipReason, roll_from,
66};
67#[cfg(feature = "http-client")]
68pub use transport::HttpShadowTransport;
69pub use transport::{ShadowError, ShadowFuture, ShadowRequest, ShadowTransport};
70
71/// What `{actuator-prefix}/shadow` needs to report on a mirror run: the
72/// registry plus the two facts that live in config rather than in it.
73///
74/// Installed into [`crate::AppState`]'s runtime extension map when the mirror
75/// layer is assembled, so a replica with mirroring switched off simply has no
76/// handle and the endpoint reports a disabled mirror.
77#[derive(Clone, Debug)]
78pub struct ShadowHandle {
79    /// Shared counters and the bounded divergence ring.
80    pub registry: ShadowRegistry,
81    /// Whether mirroring is switched on for this replica.
82    pub enabled: bool,
83    /// The configured candidate target.
84    pub target: Option<String>,
85}
86
87impl ShadowHandle {
88    /// Build the payload the actuator endpoint publishes.
89    #[must_use]
90    pub fn snapshot(&self) -> ShadowSnapshot {
91        self.registry.snapshot(self.enabled, self.target.as_deref())
92    }
93
94    /// The payload for a replica that never assembled a mirror.
95    #[must_use]
96    pub fn disabled_snapshot() -> ShadowSnapshot {
97        ShadowSnapshot::disabled()
98    }
99
100    /// A handle for a replica that configured `[shadow]` but could not assemble
101    /// a mirror — today, a build without the `http-client` feature, which has
102    /// no way to dial the candidate.
103    ///
104    /// Installed so the actuator can distinguish "configured but inert here"
105    /// from "never configured": both report `enabled: false`, but only this one
106    /// reports the target the operator asked for.
107    #[must_use]
108    pub fn inactive(target: impl Into<String>) -> Self {
109        Self {
110            registry: ShadowRegistry::new(1),
111            enabled: false,
112            target: Some(target.into()),
113        }
114    }
115}