dwara_core/dataplane/
split.rs

1//! Service-level traffic splitting (DW-040, feature analysis section
2//! 5 "Traffic"): canary releases, blue-green switches, and the sticky
3//! cookie that pins a session through them.
4//!
5//! # The pick
6//!
7//! A split service dispatches each request to ONE of its target
8//! upstreams by a deterministic weighted pick over the SAME FNV-1a
9//! hash the balancer's consistent-hash ring uses
10//! (`balance::key_hash` — the stability argument for that choice,
11//! toolchain-stable hashing, applies identically here):
12//!
13//! - key = the sticky cookie's VALUE when the service configures
14//!   [`crate::config::StickyAffinity`] and the request carries the
15//!   cookie — a session lands on the same branch for every request
16//!   while the weights are unchanged;
17//! - key = the request id otherwise — per-request distribution whose
18//!   realized ratios converge on the configured weights statistically
19//!   (the issue's acceptance shape: "split ratios verified
20//!   statistically").
21//!
22//! The slot is `hash % total_weight`, walked over the targets'
23//! cumulative weights. DISPLACEMENT IS BOUNDED BY AN INVARIANT TOTAL:
24//! when a weight change KEEPS the total constant, only the changed
25//! share of hash space moves — 95/5 -> 90/10 moves exactly the 5% that
26//! became canary, and the blue-green flip 100/0 -> 0/100 (total still
27//! 100) moves exactly the stable side. A change that ALTERS the total
28//! changes the modulus and therefore reshuffles EVERY key — ramp a
29//! canary by RE-BALANCING the pair (95/5 -> 90/10), never by growing
30//! the canary alone (95/5 -> 95/10 reshuffles all sessions). The pick
31//! is STATELESS — no counters, no locks, no per-service runtime state
32//! — which is what makes the blue-green switch instant: a re-publish
33//! rebuilds the compiled split, and the very next request dispatches
34//! by the new generation.
35//!
36//! # Stickiness is layered
37//!
38//! The cookie guarantees BRANCH affinity (which upstream). Endpoint
39//! affinity within a branch comes from the branch upstream's own
40//! balancer: when it runs `ip_hash`, the sticky value becomes the
41//! ring key (the proxy passes it as the dispatch hash key), so the
42//! session pins one endpoint through the same ketama machinery that
43//! pins client IPs. With other balancers the branch pick is the
44//! affinity guarantee and the endpoint is free to float — documented,
45//! not hidden: ring-based endpoint pinning and round-robin spreading
46//! are different tools and the operator picks per branch.
47//!
48//! The cookie value is an opaque affinity handle generated by the
49//! gateway (time + a process counter, hex): NOT a secret, carrying no
50//! identity, never trusted as one. Reusing a presented cookie is safe
51//! precisely because its only power is choosing among upstreams the
52//! operator already configured — a client "forging" one can only
53//! pick a branch it could have landed on anyway.
54//!
55//! One documented edge: a session whose FIRST request is a response
56//! cache HIT is served without a dispatch, so no cookie is minted
57//! until its first cache MISS — while hits last there is nothing to
58//! pin (no upstream was contacted), and the miss mints exactly as the
59//! uncached path would.
60
61use std::sync::Arc;
62
63use crate::dataplane::balance::key_hash;
64use crate::dataplane::upstream::UpstreamHandle;
65
66/// One compiled target of a service split (DW-040).
67pub struct SplitEntry {
68    handle: Arc<UpstreamHandle>,
69    weight: u64,
70    /// Inclusive upper bound of this entry's hash-slot range (the
71    /// cumulative weight through this entry); the entry owns slots
72    /// `(bound - weight, bound]`.
73    bound: u64,
74}
75
76/// The compiled weighted split of one service (DW-040): built per
77/// config generation by the upstream registry, held by the dataplane
78/// dispatch path. Immutable once built; the pick is stateless.
79pub struct ServiceSplit {
80    entries: Vec<SplitEntry>,
81    /// Sum of all weights (the hash modulus; validation guarantees > 0).
82    total: u64,
83}
84
85impl ServiceSplit {
86    /// Compile from validated config targets plus the registry's
87    /// handles. Targets with weight 0 are kept (their handles exist,
88    /// their slots are empty — the parked side of a blue-green pair),
89    /// preserving index stability across weight flips.
90    pub fn new(targets: &[(Arc<UpstreamHandle>, u64)]) -> Self {
91        let mut entries = Vec::with_capacity(targets.len());
92        let mut total = 0u64;
93        for (handle, weight) in targets {
94            total += weight;
95            entries.push(SplitEntry {
96                handle: Arc::clone(handle),
97                weight: *weight,
98                bound: total,
99            });
100        }
101        ServiceSplit { entries, total }
102    }
103
104    /// The total weight (0 only for an unvalidated empty split).
105    pub fn total_weight(&self) -> u64 {
106        self.total
107    }
108
109    /// Pick the target upstream for one dispatch by the deterministic
110    /// weighted hash of `key` (the sticky cookie value or the request
111    /// id — see the module docs). The handle is the branch; its own
112    /// balancer (and the hash key threaded to it) picks the endpoint.
113    pub fn pick(&self, key: &str) -> &Arc<UpstreamHandle> {
114        let slot = key_hash(key) % self.total;
115        // The entry whose cumulative bound first exceeds the slot.
116        // Linear over at most 8 entries — cheaper than any index.
117        for e in &self.entries {
118            if slot < e.bound {
119                return &e.handle;
120            }
121        }
122        // Unreachable: the last bound is the total, and slot < total.
123        // Guarded fallthrough keeps a hostile zero-weight total from
124        // panicking: dispatch to the first entry rather than crash.
125        &self.entries[0].handle
126    }
127
128    /// DW-091: pick the target upstream AND its index (0 = baseline,
129    /// 1 = canary for a 2-target canary split). The auto-canary
130    /// controller needs the index to record the outcome on the
131    /// correct side. Same hash as [`Self::pick`].
132    pub fn pick_with_index(&self, key: &str) -> (&Arc<UpstreamHandle>, usize) {
133        let slot = key_hash(key) % self.total;
134        for (i, e) in self.entries.iter().enumerate() {
135            if slot < e.bound {
136                return (&e.handle, i);
137            }
138        }
139        (&self.entries[0].handle, 0)
140    }
141
142    /// The entry weights in config order (diagnostics and tests).
143    pub fn weights(&self) -> Vec<u64> {
144        self.entries.iter().map(|e| e.weight).collect()
145    }
146}
147
148/// Read one cookie's value from a request's `Cookie` headers (the
149/// first match across all header lines wins; `Cookie` is a
150/// semicolon-separated `name=value` list per RFC 6265). Returns the
151/// trimmed value; a malformed pair cannot match a validated cookie
152/// name and is skipped silently (the gateway then mints a fresh
153/// handle — the safe direction: a bad cookie re-sessions rather than
154/// mis-routing).
155pub fn read_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
156    for value in headers.get_all(hyper::header::COOKIE) {
157        let Ok(line) = value.to_str() else { continue };
158        for pair in line.split(';') {
159            let pair = pair.trim();
160            if let Some((k, v)) = pair.split_once('=') {
161                if k.trim() == name {
162                    let v = v.trim();
163                    if !v.is_empty() {
164                        return Some(v.to_string());
165                    }
166                }
167            }
168        }
169    }
170    None
171}
172
173/// One process-wide affinity-handle counter (monotonic; combined with
174/// the wall clock it makes handles unique per mint).
175static AFFINITY_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
176
177/// Mint a fresh affinity handle: `hex(unix ms) hex(counter)` — 24
178/// printable hex characters. Opaque, unique per process, NOT a secret
179/// and never treated as one (its only power is choosing among
180/// upstreams the operator already configured).
181pub fn mint_affinity_id() -> String {
182    let now = std::time::SystemTime::now()
183        .duration_since(std::time::UNIX_EPOCH)
184        .map(|d| d.as_millis() as u64)
185        .unwrap_or(0);
186    let n = AFFINITY_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
187    format!("{now:012x}{n:06x}")
188}