plecto_control/upstream/mod.rs
1//! Upstream instances, active-health-check state, and round-robin load balancing (ADR 000017).
2//!
3//! A manifest [`crate::manifest::Upstream`] becomes an [`UpstreamGroup`] of [`UpstreamInstance`]s.
4//! Each instance owns a single health state machine fed by BOTH sources: the background
5//! active-health prober (the fast-path server runs it) and passive signals from real forwarded
6//! requests (a connect failure demotes). The fast path picks a healthy instance per request by
7//! round-robin; when none are healthy the upstream is fail-closed (the server responds 503).
8//!
9//! **The registry lives on `Control`, OUTSIDE the atomically-swapped `ActiveConfig`**, so health
10//! state SURVIVES a reload (ADR 000017). [`UpstreamRegistry::reconcile`] diffs the manifest's
11//! upstreams against the running set by `(name, address)`: an unchanged instance keeps its health,
12//! a new address starts pessimistic (unhealthy), a removed one is dropped. Routing's
13//! `CompiledRoute` holds an `Arc<UpstreamGroup>` rebuilt to point at the reconciled group on every
14//! reload, so the per-request hot path never touches the registry lock.
15//!
16//! Split by concern: `instance` (per-instance health FSM), `lb` (pick algorithms — round-robin /
17//! least-request / maglev — plus `Pick` / `HashInput`), `circuit_breaker` (the per-upstream
18//! in-flight cap), `outlier` (outlier detection), `registry` (`UpstreamRegistry` / `reconcile`).
19//! `UpstreamGroup` itself stays one struct (its fields are genuinely one cohesive unit of
20//! per-upstream state); only its `impl` block is split across those files.
21
22mod circuit_breaker;
23mod instance;
24mod lb;
25mod outlier;
26mod registry;
27
28use std::sync::Arc;
29use std::sync::atomic::AtomicUsize;
30use std::time::{Duration, SystemTime, UNIX_EPOCH};
31
32pub use instance::UpstreamInstance;
33pub use lb::{HashInput, HashKeySource, Pick};
34pub use registry::UpstreamRegistry;
35
36use crate::maglev::MaglevTable;
37use crate::manifest::{HealthConfig, LbAlgorithm};
38use lb::LbState;
39
40/// Wall-clock milliseconds since the epoch, for outlier-ejection windows (ADR 000032) and as the
41/// clock `lb`'s eligibility check reads. Non-monotonic, but the windows are coarse (seconds), so a
42/// backward clock step merely shortens or lengthens one window — never a panic on untrusted input.
43fn now_millis() -> u64 {
44 SystemTime::now()
45 .duration_since(UNIX_EPOCH)
46 .map(|d| d.as_millis() as u64)
47 .unwrap_or(0)
48}
49
50/// The swappable endpoint set of a group: the instances plus the LB state compiled from them
51/// (a Maglev table indexes into `instances`, so the two must swap together). Behind an `ArcSwap`
52/// on the group so periodic DNS re-resolution (the standard periodic-DNS endpoint-discovery
53/// technique — the shape of nginx `resolve` / Envoy STRICT_DNS) can replace the set in place
54/// while routes keep holding their `Arc<UpstreamGroup>`.
55#[derive(Debug)]
56pub struct Endpoints {
57 /// The instances, in configured (or resolved) address order.
58 pub instances: Vec<Arc<UpstreamInstance>>,
59 pub(in crate::upstream) lb: LbState,
60}
61
62impl Endpoints {
63 /// Compile the LB state for `instances` (ADR 000035) and wrap the pair. A Maglev upstream
64 /// recomputes its lookup table from the (possibly re-resolved) instance set.
65 pub(super) fn build(
66 instances: Vec<Arc<UpstreamInstance>>,
67 algorithm: LbAlgorithm,
68 maglev_table_size: usize,
69 ) -> Self {
70 let lb = match algorithm {
71 LbAlgorithm::RoundRobin => LbState::RoundRobin,
72 LbAlgorithm::LeastRequest => LbState::LeastRequest,
73 LbAlgorithm::Maglev => {
74 let entries: Vec<(&str, u32)> = instances
75 .iter()
76 .map(|i| (i.address(), i.weight()))
77 .collect();
78 LbState::Maglev(MaglevTable::build(&entries, maglev_table_size))
79 }
80 };
81 Self { instances, lb }
82 }
83}
84
85/// A named upstream: its endpoint set, the round-robin cursor, and the health policy (ADR 000017).
86#[derive(Debug)]
87pub struct UpstreamGroup {
88 /// The upstream `name` routes refer to.
89 pub name: String,
90 /// The active-health-check policy (the prober reads `path` / `interval_ms` / `timeout_ms`).
91 pub health: HealthConfig,
92 /// The current endpoint set + its compiled LB state. Swapped atomically by DNS re-resolution
93 /// (`update_endpoints`); otherwise fixed for the life of this group value — a reload builds a
94 /// NEW group, reusing unchanged instances' `Arc`s to preserve their health.
95 endpoints: arc_swap::ArcSwap<Endpoints>,
96 /// The manifest-declared `(address, weight)` list — the re-resolution input (each hostname is
97 /// re-expanded to its current A/AAAA records; an IP literal passes through unchanged).
98 configured: Vec<(String, u32)>,
99 /// How often hostname addresses are re-resolved (`resolve_interval_ms`); `ZERO` = never (the
100 /// default — hostnames still resolve per connect, but the endpoint set stays as configured).
101 resolve_interval: Duration,
102 /// The LB algorithm + Maglev table size, retained so `update_endpoints` can recompile the LB
103 /// state for a re-resolved instance set.
104 lb_algorithm: LbAlgorithm,
105 maglev_table_size: usize,
106 /// Per-try timeout for ONE forward attempt to this upstream (ADR 000019, reframed as the per-try
107 /// bound by ADR 000031); `Duration::ZERO` disables it. Bounds one attempt's time-to-response-
108 /// headers, failing closed 504 on overrun. Not part of `health`, so a timeout-only change
109 /// rebuilds the group but preserves instance health.
110 request_timeout: Duration,
111 /// Overall request deadline across the WHOLE transaction — every attempt PLUS the backoff between
112 /// them (ADR 000031); `Duration::ZERO` = no overall bound (only the per-try `request_timeout`
113 /// applies). The runtime applies the tighter of the two; exceeding it fails closed 504.
114 overall_timeout: Duration,
115 /// Max retries to a DIFFERENT instance after a retryable forward failure (ADR 000023); `0`
116 /// disables retry. Like `request_timeout`, not part of `health`, so a retry-only change rebuilds
117 /// the group but preserves instance health.
118 max_retries: u64,
119 /// Round-robin cursor. `Relaxed` suffices: it only needs to advance, not synchronise memory.
120 rr: AtomicUsize,
121 /// Circuit-breaker cap (ADR 000028): max concurrent in-flight requests to this upstream; `0` =
122 /// unlimited. Rebuilt from the manifest on every reconcile, like `request_timeout`/`max_retries`,
123 /// so it is not part of `health` and a breaker-only change preserves instance health.
124 max_requests: usize,
125 /// Current concurrent in-flight requests (ADR 000028) — held by a [`circuit_breaker::RequestPermit`]
126 /// from forward time until the upstream response headers arrive (or it fails). A (re)built group
127 /// starts at 0; in-flight requests of a superseded group decrement that group's own counter via
128 /// their permit, so a reload never miscounts.
129 in_flight: AtomicUsize,
130 /// Outlier-detection policy (ADR 000032), rebuilt from the manifest like the other non-health
131 /// knobs (so an outlier-config change preserves instance health): the consecutive gateway-5xx
132 /// threshold (`0` = disabled), the base ejection window (× exponential backoff), and the cap on
133 /// the fraction of the pool ejectable at once.
134 outlier_consecutive: u32,
135 outlier_base_ejection: Duration,
136 outlier_max_ejection_percent: u32,
137 /// Serializes the ejection DECISION (count already-ejected, check `max_ejection_percent`,
138 /// eject) across every instance in this group. Each instance's failure-streak bookkeeping
139 /// lives under that instance's own `counters` lock, but the cap check reads *group-wide*
140 /// state (how many peers are currently ejected) — without a group-wide lock around the
141 /// whole check-then-eject sequence, two instances crossing their threshold in the same
142 /// instant (e.g. a correlated backend blip) can each read "cap not yet reached" and both
143 /// eject, silently exceeding `max_ejection_percent`. Held only across this rare (threshold-
144 /// crossing) path, not the common per-request success/failure bookkeeping.
145 outlier_decision: std::sync::Mutex<()>,
146 /// The request attribute a `Maglev` upstream hashes for affinity (ADR 000035); `None` for the
147 /// other algorithms. The fast path reads this to project the hash key from a request.
148 hash_key: Option<HashKeySource>,
149 /// The `[upstream.tls]` section this group was reconciled from (ADR 000042), kept for the
150 /// reuse comparison on reload: an unchanged section reuses `tls_client` (stable `Arc`, so the
151 /// fast path's per-config connection pool survives the reload), a changed one rebuilds it.
152 tls_manifest: Option<crate::manifest::UpstreamTls>,
153 /// The rustls client config the fast path re-encrypts this upstream's forward leg with
154 /// (ADR 000042): roots per `ca_path` (or webpki), ALPN `[h2, http/1.1]`. `None` = plain
155 /// HTTP/1.1. Built fail-closed at reconcile, like the server-side TLS configs.
156 tls_client: Option<Arc<rustls::ClientConfig>>,
157 /// The `[upstream.tls] sni` verification-name override, parsed (ADR 000050): when set, the
158 /// fast path uses this — not the connected address — for both the SNI extension and
159 /// certificate-name verification on every TLS leg to this upstream. `None` = derive from the
160 /// address (the pre-000050 behaviour). Parsed fail-closed at reconcile alongside `tls_client`.
161 tls_sni: Option<rustls::pki_types::ServerName<'static>>,
162}
163
164impl UpstreamGroup {
165 /// The PER-TRY timeout the fast path applies to one forward attempt (ADR 000019, per-try by ADR
166 /// 000031). `Duration::ZERO` means no per-try bound (e.g. a streaming / long-poll backend);
167 /// otherwise one attempt is bounded and overrun fails closed 504.
168 pub fn request_timeout(&self) -> Duration {
169 self.request_timeout
170 }
171
172 /// The OVERALL request deadline across all attempts + backoff (ADR 000031); `Duration::ZERO`
173 /// means no overall bound (only the per-try `request_timeout` applies). Exceeding it fails
174 /// closed 504 `request-timeout` with no further retry.
175 pub fn overall_timeout(&self) -> Duration {
176 self.overall_timeout
177 }
178
179 /// The max number of retries to a different instance on a retryable forward failure (ADR
180 /// 000023); `0` disables retry.
181 pub fn max_retries(&self) -> u64 {
182 self.max_retries
183 }
184
185 /// The hash-key source for a `maglev` upstream (ADR 000035), or `None` for the other algorithms.
186 /// The fast path reads this to project a [`HashInput`] from the request.
187 pub fn hash_key_source(&self) -> Option<&HashKeySource> {
188 self.hash_key.as_ref()
189 }
190
191 /// The scheme the fast path forwards (and health-probes) this upstream with (ADR 000042):
192 /// `https` when `[upstream.tls]` is declared, else `http`.
193 pub fn scheme(&self) -> &'static str {
194 if self.tls_client.is_some() {
195 "https"
196 } else {
197 "http"
198 }
199 }
200
201 /// The rustls client config for this upstream's TLS forward leg (ADR 000042), or `None` for
202 /// plain HTTP/1.1. The `Arc` is stable across reloads while `[upstream.tls]` is unchanged, so
203 /// the fast path can key its per-config connection pool on the `Arc`'s identity.
204 pub fn tls_client_config(&self) -> Option<&Arc<rustls::ClientConfig>> {
205 self.tls_client.as_ref()
206 }
207
208 /// The `[upstream.tls] sni` verification-name override (ADR 000050), or `None` when the fast
209 /// path should derive the SNI / verification name from the connected address (the pre-000050
210 /// behaviour).
211 pub fn tls_sni(&self) -> Option<&rustls::pki_types::ServerName<'static>> {
212 self.tls_sni.as_ref()
213 }
214
215 /// A snapshot of the current endpoint set (instances + LB state). One atomic load; the
216 /// returned `Arc` stays valid across a concurrent re-resolution swap.
217 pub fn endpoints(&self) -> Arc<Endpoints> {
218 self.endpoints.load_full()
219 }
220
221 /// How often the fast path should re-resolve this group's hostname addresses, or `None` when
222 /// re-resolution is off (the default).
223 pub fn resolve_interval(&self) -> Option<Duration> {
224 (!self.resolve_interval.is_zero()).then_some(self.resolve_interval)
225 }
226
227 /// The manifest-declared `(address, weight)` list — the re-resolution input.
228 pub fn configured_addresses(&self) -> &[(String, u32)] {
229 &self.configured
230 }
231
232 /// Replace the endpoint set with `resolved` `(address, weight)` pairs — the periodic-DNS
233 /// endpoint-discovery swap. An unchanged pair keeps its instance `Arc` (health and in-flight
234 /// state survive, exactly like a reload's reconcile); a new pair starts pessimistic (ADR
235 /// 000017 — a fresh address must prove itself before entering rotation); a vanished pair is
236 /// dropped (in-flight requests finish on their cloned `Arc`). The LB state is recompiled (a
237 /// Maglev table indexes the new set). Returns `false` (and swaps nothing) when the set is
238 /// unchanged, so an idle refresh tick costs one atomic load and a compare.
239 pub fn update_endpoints(&self, resolved: &[(String, u32)]) -> bool {
240 // An empty set never replaces a serving one (API hardening — the shipped DNS supervisor
241 // never passes one, but a public method must not be a foot-gun): keep the last-known-good
242 // endpoints, exactly like a failed re-resolution does.
243 if resolved.is_empty() {
244 tracing::warn!(
245 upstream = %self.name,
246 "update_endpoints called with an empty set; keeping the current endpoints"
247 );
248 return false;
249 }
250 let current = self.endpoints.load();
251 let unchanged = current.instances.len() == resolved.len()
252 && current
253 .instances
254 .iter()
255 .zip(resolved)
256 .all(|(inst, (addr, weight))| inst.address() == addr && inst.weight() == *weight);
257 if unchanged {
258 return false;
259 }
260 let instances: Vec<Arc<UpstreamInstance>> = resolved
261 .iter()
262 .map(|(addr, weight)| {
263 current
264 .instances
265 .iter()
266 .find(|i| i.address() == addr && i.weight() == *weight)
267 .cloned()
268 .unwrap_or_else(|| {
269 Arc::new(UpstreamInstance::new(addr.clone(), *weight, &self.health))
270 })
271 })
272 .collect();
273 self.endpoints.store(Arc::new(Endpoints::build(
274 instances,
275 self.lb_algorithm,
276 self.maglev_table_size,
277 )));
278 true
279 }
280}
281
282impl crate::Control {
283 /// A snapshot of the current upstream groups (ADR 000017), for the fast-path server's
284 /// health-check supervisor to probe. Reflects the latest reconcile, so a reload's added /
285 /// removed instances are picked up on the supervisor's next tick without restarting it.
286 pub fn upstream_groups(&self) -> Vec<Arc<UpstreamGroup>> {
287 self.upstreams.groups()
288 }
289}