Skip to main content

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}