Skip to content

Commit 42345ab

Browse files
committed
Harden load balancer health and hash state
1 parent a0e3eb7 commit 42345ab

6 files changed

Lines changed: 86 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ behavior when the change improves security or project direction.
3535
- Replace Pingora's FNV weighted-hash selector for source, URI, header, and
3636
cookie hash modes with Fluxheim-owned weighted-first FNV selection over the
3737
current backend container.
38+
- Seed Fluxheim-owned FNV and consistent-hash selectors with per-boot routing
39+
secrets so clients cannot precompute keys that target a chosen backend.
3840
- Replace Pingora's random selector dependency for power-of-two choices with a
3941
Fluxheim-owned weighted random first pick and unique backend fallback scan.
4042
- Replace Pingora's consistent-hash selector dependency with Fluxheim-owned
4143
rendezvous candidate ordering for consistent and bounded-load consistent
4244
hash modes, while preserving dynamic discovery support through the current
43-
backend container.
45+
backend container. This is a valid consistent-hash algorithm change and can
46+
remap existing consistent-hash affinity keys once during the 1.5.7 upgrade.
4447
- Collapse the load-balancer factory, stats, and priority-check helpers onto a
4548
concrete readiness container now that Fluxheim owns all shipped selection
4649
algorithms, removing the remaining generic Pingora selector trait plumbing.
@@ -76,6 +79,14 @@ behavior when the change improves security or project direction.
7679
- Hide the remaining runtime backend value type behind the load-balancer
7780
backend adapter so selector and health-check modules import Fluxheim's
7881
boundary type instead of `pingora::lb::Backend` directly.
82+
- Serialize per-backend load-balancer health state updates so enable/disable
83+
changes and active health observations cannot overwrite each other under
84+
concurrent health checks.
85+
- Store refreshed backend sets before refreshed health maps and use checked
86+
wake-time arithmetic in the load-balancer background loop.
87+
- Clarify stream upstream TLS warnings for mixed hostname and IP upstream
88+
routes where only IP connections skip hostname verification without
89+
`upstream_sni`.
7990

8091
## 1.5.6 - 2026-06-06
8192

docs/config-reference.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,12 +1070,15 @@ and at least one upstream must remain a normal primary.
10701070
`maglev-header-hash`, and `maglev-cookie-hash`. Header-hash modes require
10711071
`proxy.load_balance.hash_header = "x-session"` or another valid HTTP header
10721072
name. Cookie-hash modes require `proxy.load_balance.hash_cookie = "session"` or
1073-
another valid cookie name. Hash modes use weighted FNV selection; consistent
1074-
modes use Pingora's weighted Ketama ring for lower remapping when upstream
1075-
membership changes. Bounded-load consistent modes use the same ring and skip a
1076-
hash target whose weighted in-flight pressure is above the configured soft
1077-
bound when another eligible candidate is available inside `max_iterations`;
1078-
they fall back to normal consistent selection if no bounded candidate is found.
1073+
another valid cookie name. Hash modes use weighted FNV selection seeded with a
1074+
per-boot secret. Consistent modes use Fluxheim-owned weighted rendezvous
1075+
hashing, also seeded with a per-boot secret, for low remapping when upstream
1076+
membership changes. Upgrading from the pre-1.5.7 Pingora ring-backed
1077+
consistent selector can remap existing affinity keys once. Bounded-load
1078+
consistent modes use the same rendezvous candidate ordering and skip a hash
1079+
target whose weighted in-flight pressure is above the configured soft bound
1080+
when another eligible candidate is available inside `max_iterations`; they
1081+
fall back to normal consistent selection if no bounded candidate is found.
10791082
`bounded_load_factor_per_mille` defaults to `1250`, meaning roughly 125% of
10801083
current weighted average load, and is valid only with bounded-load consistent
10811084
selectors. Maglev modes use a fixed 65,537-slot bounded lookup table for static

release-notes/RELEASE_NOTES_1.5.7.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,15 @@ results as far as possible.
2828
- Replace Pingora's FNV weighted-hash selector for source, URI, header, and
2929
cookie hash modes with Fluxheim-owned weighted-first FNV selection over the
3030
current backend container.
31+
- Seed Fluxheim-owned FNV and consistent-hash selectors with per-boot routing
32+
secrets so clients cannot precompute keys that target a chosen backend.
3133
- Replace Pingora's random selector dependency for power-of-two choices with a
3234
Fluxheim-owned weighted random first pick and unique backend fallback scan.
3335
- Replace Pingora's consistent-hash selector dependency with Fluxheim-owned
3436
rendezvous candidate ordering for consistent and bounded-load consistent
3537
hash modes. Dynamic file/DNS discovery remains supported through the current
36-
backend container.
38+
backend container. This is a valid consistent-hash algorithm change and can
39+
remap existing consistent-hash affinity keys once during the 1.5.7 upgrade.
3740
- Collapse load-balancer factory, stats, and priority-check helpers onto a
3841
concrete readiness container now that Fluxheim owns all shipped selection
3942
algorithms.
@@ -68,6 +71,14 @@ results as far as possible.
6871
- Hide the remaining runtime backend value type behind the load-balancer
6972
backend adapter so selector and health-check modules use Fluxheim's boundary
7073
type while the final backend-type replacement remains isolated.
74+
- Serialize per-backend load-balancer health state updates so enable/disable
75+
changes and active health observations cannot overwrite each other under
76+
concurrent health checks.
77+
- Store refreshed backend sets before refreshed health maps and use checked
78+
wake-time arithmetic in the load-balancer background loop.
79+
- Clarify stream upstream TLS warnings for mixed hostname and IP upstream
80+
routes where only IP connections skip hostname verification without
81+
`upstream_sni`.
7182

7283
## Boundaries
7384

src/load_balancer/backend.rs

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use std::collections::{BTreeSet, HashMap};
22
use std::io;
33
use std::net::SocketAddr;
4+
use std::process;
45
use std::sync::Arc;
6+
use std::sync::Mutex;
57
use std::time::Duration;
68
use std::time::Instant;
79

@@ -34,64 +36,52 @@ pub(super) trait FluxHealthCheck: Send + Sync + 'static {
3436
}
3537
}
3638

37-
#[derive(Clone)]
3839
struct FluxBackendHealthInner {
3940
healthy: bool,
4041
enabled: bool,
4142
consecutive_counter: usize,
4243
}
4344

44-
struct FluxBackendHealth(ArcSwap<FluxBackendHealthInner>);
45+
#[derive(Clone)]
46+
struct FluxBackendHealth(Arc<Mutex<FluxBackendHealthInner>>);
4547

4648
impl Default for FluxBackendHealth {
4749
fn default() -> Self {
48-
Self(ArcSwap::new(Arc::new(FluxBackendHealthInner {
50+
Self(Arc::new(Mutex::new(FluxBackendHealthInner {
4951
healthy: true,
5052
enabled: true,
5153
consecutive_counter: 0,
5254
})))
5355
}
5456
}
5557

56-
impl Clone for FluxBackendHealth {
57-
fn clone(&self) -> Self {
58-
Self(ArcSwap::new(self.0.load_full()))
58+
impl FluxBackendHealth {
59+
fn lock(&self) -> std::sync::MutexGuard<'_, FluxBackendHealthInner> {
60+
self.0.lock().unwrap_or_else(|_| process::abort())
5961
}
60-
}
6162

62-
impl FluxBackendHealth {
6363
fn ready(&self) -> bool {
64-
let health = self.0.load();
64+
let health = self.lock();
6565
health.healthy && health.enabled
6666
}
6767

6868
fn enable(&self, enabled: bool) {
69-
let health = self.0.load();
70-
if health.enabled != enabled {
71-
let mut next = (**health).clone();
72-
next.enabled = enabled;
73-
self.0.store(Arc::new(next));
74-
}
69+
self.lock().enabled = enabled;
7570
}
7671

7772
fn observe(&self, healthy: bool, flip_threshold: usize) -> bool {
78-
let health = self.0.load();
79-
let mut flipped = false;
73+
let mut health = self.lock();
8074
if health.healthy != healthy {
81-
let mut next = (**health).clone();
82-
next.consecutive_counter = next.consecutive_counter.saturating_add(1);
83-
if next.consecutive_counter >= flip_threshold {
84-
next.healthy = healthy;
85-
next.consecutive_counter = 0;
86-
flipped = true;
75+
health.consecutive_counter = health.consecutive_counter.saturating_add(1);
76+
if health.consecutive_counter >= flip_threshold {
77+
health.healthy = healthy;
78+
health.consecutive_counter = 0;
79+
return true;
8780
}
88-
self.0.store(Arc::new(next));
89-
} else if health.consecutive_counter > 0 {
90-
let mut next = (**health).clone();
91-
next.consecutive_counter = 0;
92-
self.0.store(Arc::new(next));
81+
} else {
82+
health.consecutive_counter = 0;
9383
}
94-
flipped
84+
false
9585
}
9686
}
9787

@@ -149,8 +139,8 @@ impl FluxLoadBalancerRuntime {
149139
}
150140
next_health.insert(key, backend_health);
151141
}
152-
self.health.store(Arc::new(next_health));
153142
self.backends.store(Arc::new(new_backends));
143+
self.health.store(Arc::new(next_health));
154144
} else {
155145
let health = self.health.load();
156146
for (key, enabled) in enablement {
@@ -187,7 +177,7 @@ impl FluxLoadBalancerRuntime {
187177
if let Err(error) = self.update().await {
188178
log::warn!("load-balancer discovery update failed: {error}");
189179
}
190-
next_update = now + self.update_frequency.unwrap_or(NEVER);
180+
next_update = checked_next_wake(now, self.update_frequency.unwrap_or(NEVER));
191181
}
192182

193183
if let Some(ready) = ready.take() {
@@ -196,7 +186,8 @@ impl FluxLoadBalancerRuntime {
196186

197187
if next_health_check <= now {
198188
self.run_health_check(self.parallel_health_check).await;
199-
next_health_check = now + self.health_check_frequency.unwrap_or(NEVER);
189+
next_health_check =
190+
checked_next_wake(now, self.health_check_frequency.unwrap_or(NEVER));
200191
}
201192

202193
if self.update_frequency.is_none() && self.health_check_frequency.is_none() {
@@ -259,6 +250,11 @@ impl FluxLoadBalancerRuntime {
259250
}
260251
}
261252

253+
fn checked_next_wake(now: Instant, delay: Duration) -> Instant {
254+
now.checked_add(delay)
255+
.unwrap_or_else(|| now + Duration::from_secs(3600))
256+
}
257+
262258
pub(crate) trait BackendIdentity {
263259
fn authority(&self) -> String;
264260

src/load_balancer/selection.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ use super::state::{
1212
};
1313
use crate::flux_error::{FluxError, FluxResult};
1414

15+
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
1516
const MAGLEV_TABLE_SIZE: usize = 65_537;
1617

1718
pub(super) fn fnv1a64(bytes: &[u8]) -> u64 {
18-
fnv1a64_with_seed(bytes, 0xcbf2_9ce4_8422_2325)
19+
fnv1a64_with_seed(bytes, FNV_OFFSET_BASIS)
1920
}
2021

2122
pub(super) fn fnv1a64_with_seed(bytes: &[u8], seed: u64) -> u64 {
@@ -596,12 +597,12 @@ fn select_fnv_hash_with_backup_policy(
596597
}
597598

598599
let mut seen = std::collections::HashSet::new();
599-
let mut index = fnv1a64(key);
600+
let mut index = fnv1a64_with_seed(key, fnv_route_secret());
600601
for step in 0..max_iterations.max(1) {
601602
let candidate_index = if step == 0 {
602603
weighted[index as usize % weighted.len()]
603604
} else {
604-
index = fnv1a64(&index.to_le_bytes());
605+
index = fnv1a64_with_seed(&index.to_le_bytes(), fnv_route_secret());
605606
index as usize % backends.len()
606607
};
607608
let candidate = &backends[candidate_index];
@@ -834,7 +835,7 @@ fn consistent_hash_candidates(
834835
fn consistent_backend_score(key: &[u8], backend_key: u64, weight: usize) -> u64 {
835836
let mut best = 0u64;
836837
for replica in 0..weight.max(1) {
837-
let mut hash = fnv1a64_with_seed(key, 0x9e37_79b9_7f4a_7c15);
838+
let mut hash = fnv1a64_with_seed(key, consistent_route_secret());
838839
hash = fnv1a64_with_seed(&backend_key.to_le_bytes(), hash);
839840
hash = fnv1a64_with_seed(&(replica as u64).to_le_bytes(), hash);
840841
best = best.max(hash);
@@ -870,8 +871,7 @@ impl MaglevTable {
870871
.iter()
871872
.map(|backend_key| {
872873
let key = backend_key.to_le_bytes();
873-
let offset =
874-
fnv1a64_with_seed(&key, 0xcbf2_9ce4_8422_2325) as usize % MAGLEV_TABLE_SIZE;
874+
let offset = fnv1a64_with_seed(&key, FNV_OFFSET_BASIS) as usize % MAGLEV_TABLE_SIZE;
875875
let skip = (fnv1a64_with_seed(&key, 0x8422_2325_cbf2_9ce4) as usize
876876
% (MAGLEV_TABLE_SIZE - 1))
877877
+ 1;
@@ -961,13 +961,28 @@ pub(super) fn select_maglev(
961961
}
962962

963963
fn maglev_route_secret() -> u64 {
964-
static SECRET: OnceLock<u64> = OnceLock::new();
965-
*SECRET.get_or_init(|| {
964+
route_secret(&MAGLEV_ROUTE_SECRET, "Maglev routing hash")
965+
}
966+
967+
fn consistent_route_secret() -> u64 {
968+
route_secret(&CONSISTENT_ROUTE_SECRET, "consistent-hash routing")
969+
}
970+
971+
fn fnv_route_secret() -> u64 {
972+
route_secret(&FNV_ROUTE_SECRET, "FNV routing hash")
973+
}
974+
975+
static MAGLEV_ROUTE_SECRET: OnceLock<u64> = OnceLock::new();
976+
static CONSISTENT_ROUTE_SECRET: OnceLock<u64> = OnceLock::new();
977+
static FNV_ROUTE_SECRET: OnceLock<u64> = OnceLock::new();
978+
979+
fn route_secret(secret: &'static OnceLock<u64>, label: &'static str) -> u64 {
980+
*secret.get_or_init(|| {
966981
let mut bytes = [0u8; 8];
967982
if let Err(error) = getrandom::fill(&mut bytes) {
968983
log::error!(
969984
target: "fluxheim::security",
970-
"failed to seed Maglev routing hash secret: {error}"
985+
"failed to seed {label} secret: {error}"
971986
);
972987
process::abort();
973988
}

src/stream_tls.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ fn warn_if_ip_upstream_tls_verification_skips_hostname(route: &StreamRouteConfig
161161
}
162162
log::warn!(
163163
target: "fluxheim::security",
164-
"stream route '{}' enables upstream TLS certificate verification for IP-only upstreams without upstream_sni; hostname verification is skipped for those connections",
164+
"stream route '{}' enables upstream TLS certificate verification for one or more IP upstreams without upstream_sni; hostname verification is skipped for those IP connections",
165165
route.name
166166
);
167167
}

0 commit comments

Comments
 (0)