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}