Skip to main content

volli_core/
env_config.rs

1use eyre::{Report, eyre};
2use once_cell::sync::Lazy;
3use std::cell::RefCell;
4use std::{path::Path, sync::RwLock, time::Duration};
5thread_local! {
6    static TLS_CONFIG_DIR: RefCell<Option<String>> = const { RefCell::new(None) };
7}
8
9#[derive(Debug, Clone, Default)]
10pub struct EnvironmentConfig {
11    pub quic_idle_ms: Option<u64>,
12    pub backoff_ms: Option<u64>,
13    pub interval_ms: Option<u64>,
14    pub connect_timeout_ms: Option<u64>,
15    pub auth_timeout_secs: Option<u64>,
16    pub heartbeat_secs: Option<u64>,
17    pub join_timeout_secs: Option<u64>,
18    pub peer_cleanup_secs: Option<u64>,
19    pub peer_check_secs: Option<u64>,
20    /// Fractional improvement required to consider immediate rebalance (e.g. 0.05 = 5%)
21    pub rebalance_improvement: Option<f64>,
22    /// Multiplier for worker idle timeout (applied to heartbeat_secs)
23    pub worker_idle_multiplier: Option<u64>,
24    /// Worker-side rebalance: startup grace before considering rebalancing (secs)
25    pub rebalance_startup_grace_secs: Option<u64>,
26    /// Worker-side rebalance: interval between should_rebalance checks (secs)
27    pub rebalance_check_interval_secs: Option<u64>,
28    /// Worker-side rebalance controller: minimum interval between rebalances (secs)
29    pub rebalance_min_interval_secs: Option<u64>,
30    /// Worker-side migration: periodic check interval (secs)
31    pub migration_check_interval_secs: Option<u64>,
32    /// Worker-side migration: connection timeout (secs)
33    pub migration_connection_timeout_secs: Option<u64>,
34    /// Worker-side migration: handoff timeout (secs)
35    pub migration_handoff_timeout_secs: Option<u64>,
36    /// Rebalance: consider current connection "good" above this score; skip periodic rebalance
37    pub rebalance_good_score_cutoff: Option<f64>,
38    /// Rebalance (periodic): required fractional improvement over current (e.g. 0.30)
39    pub rebalance_periodic_improvement: Option<f64>,
40    /// Rebalance controller: required consecutive signals count (e.g. 3)
41    pub rebalance_consecutive_signals: Option<u32>,
42    /// Rebalance controller: max rebalances per hour
43    pub rebalance_max_per_hour: Option<u32>,
44    /// Rebalance scoring weights (health/history/breaker)
45    pub rebalance_weight_health: Option<f64>,
46    pub rebalance_weight_history: Option<f64>,
47    pub rebalance_weight_breaker: Option<f64>,
48    /// Empty-manager bonus: min delta between emptiest and next emptiest fullness
49    pub rebalance_empty_delta_threshold: Option<f64>,
50    /// Empty-manager bonus: scale factor for bonus
51    pub rebalance_empty_bonus_weight: Option<f64>,
52    /// Empty-manager bonus: minimum health score to qualify
53    pub rebalance_empty_health_cutoff: Option<f64>,
54    /// Rebalance: age threshold in seconds before health is considered stale
55    pub rebalance_health_stale_secs: Option<u64>,
56    /// Rebalance: factor applied to health score when stale (0.0-1.0)
57    pub rebalance_health_stale_factor: Option<f64>,
58}
59
60impl EnvironmentConfig {
61    pub const DEFAULT_HEARTBEAT_SECS: u64 = 2;
62    pub const DEFAULT_AUTH_TIMEOUT_SECS: u64 = 5;
63    pub const DEFAULT_JOIN_TIMEOUT_SECS: u64 = 30;
64    pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
65
66    fn from_env() -> Self {
67        let mut cfg = Self {
68            quic_idle_ms: std::env::var("VOLLI_TEST_QUIC_IDLE_MS")
69                .ok()
70                .and_then(|v| v.parse().ok()),
71            backoff_ms: std::env::var("VOLLI_TEST_BACKOFF_MS")
72                .ok()
73                .and_then(|v| v.parse().ok()),
74            interval_ms: std::env::var("VOLLI_TEST_INTERVAL_MS")
75                .ok()
76                .and_then(|v| v.parse().ok()),
77            connect_timeout_ms: std::env::var("VOLLI_CONNECT_TIMEOUT_MS")
78                .ok()
79                .and_then(|v| v.parse().ok()),
80            auth_timeout_secs: std::env::var("VOLLI_TEST_AUTH_TIMEOUT_SECS")
81                .ok()
82                .and_then(|v| v.parse().ok()),
83            heartbeat_secs: std::env::var("VOLLI_TEST_HEARTBEAT_SECS")
84                .ok()
85                .and_then(|v| v.parse().ok()),
86            join_timeout_secs: std::env::var("VOLLI_JOIN_TIMEOUT_SECS")
87                .ok()
88                .and_then(|v| v.parse().ok()),
89            peer_cleanup_secs: std::env::var("VOLLI_PEER_CLEANUP_SECS")
90                .ok()
91                .and_then(|v| v.parse().ok()),
92            peer_check_secs: std::env::var("VOLLI_PEER_CHECK_SECS")
93                .ok()
94                .and_then(|v| v.parse().ok()),
95            rebalance_improvement: std::env::var("VOLLI_REBALANCE_IMPROVEMENT")
96                .ok()
97                .and_then(|v| v.parse().ok()),
98            worker_idle_multiplier: std::env::var("VOLLI_WORKER_IDLE_MULTIPLIER")
99                .ok()
100                .and_then(|v| v.parse().ok()),
101            rebalance_startup_grace_secs: std::env::var("VOLLI_REBALANCE_STARTUP_GRACE_SECS")
102                .ok()
103                .and_then(|v| v.parse().ok()),
104            rebalance_check_interval_secs: std::env::var("VOLLI_REBALANCE_CHECK_INTERVAL_SECS")
105                .ok()
106                .and_then(|v| v.parse().ok()),
107            rebalance_min_interval_secs: std::env::var("VOLLI_REBALANCE_MIN_INTERVAL_SECS")
108                .ok()
109                .and_then(|v| v.parse().ok()),
110            migration_check_interval_secs: std::env::var("VOLLI_MIGRATION_CHECK_INTERVAL_SECS")
111                .ok()
112                .and_then(|v| v.parse().ok()),
113            migration_connection_timeout_secs: std::env::var(
114                "VOLLI_MIGRATION_CONNECTION_TIMEOUT_SECS",
115            )
116            .ok()
117            .and_then(|v| v.parse().ok()),
118            migration_handoff_timeout_secs: std::env::var("VOLLI_MIGRATION_HANDOFF_TIMEOUT_SECS")
119                .ok()
120                .and_then(|v| v.parse().ok()),
121            rebalance_good_score_cutoff: std::env::var("VOLLI_REBALANCE_GOOD_SCORE_CUTOFF")
122                .ok()
123                .and_then(|v| v.parse().ok()),
124            rebalance_periodic_improvement: std::env::var("VOLLI_REBALANCE_PERIODIC_IMPROVEMENT")
125                .ok()
126                .and_then(|v| v.parse().ok()),
127            rebalance_consecutive_signals: std::env::var("VOLLI_REBALANCE_CONSECUTIVE_SIGNALS")
128                .ok()
129                .and_then(|v| v.parse().ok()),
130            rebalance_max_per_hour: std::env::var("VOLLI_REBALANCE_MAX_PER_HOUR")
131                .ok()
132                .and_then(|v| v.parse().ok()),
133            rebalance_weight_health: std::env::var("VOLLI_REBALANCE_WEIGHT_HEALTH")
134                .ok()
135                .and_then(|v| v.parse().ok()),
136            rebalance_weight_history: std::env::var("VOLLI_REBALANCE_WEIGHT_HISTORY")
137                .ok()
138                .and_then(|v| v.parse().ok()),
139            rebalance_weight_breaker: std::env::var("VOLLI_REBALANCE_WEIGHT_BREAKER")
140                .ok()
141                .and_then(|v| v.parse().ok()),
142            rebalance_empty_delta_threshold: std::env::var("VOLLI_REBALANCE_EMPTY_DELTA")
143                .ok()
144                .and_then(|v| v.parse().ok()),
145            rebalance_empty_bonus_weight: std::env::var("VOLLI_REBALANCE_EMPTY_BONUS_WEIGHT")
146                .ok()
147                .and_then(|v| v.parse().ok()),
148            rebalance_empty_health_cutoff: std::env::var("VOLLI_REBALANCE_EMPTY_HEALTH_CUTOFF")
149                .ok()
150                .and_then(|v| v.parse().ok()),
151            rebalance_health_stale_secs: std::env::var("VOLLI_REBALANCE_HEALTH_STALE_SECS")
152                .ok()
153                .and_then(|v| v.parse().ok()),
154            rebalance_health_stale_factor: std::env::var("VOLLI_REBALANCE_HEALTH_STALE_FACTOR")
155                .ok()
156                .and_then(|v| v.parse().ok()),
157        };
158        // In test runners (nextest) or when VOLLI_FAST_TESTS is set (not "0"),
159        // default to fast, deterministic timings unless explicitly overridden.
160        let fast = std::env::var("VOLLI_FAST_TESTS")
161            .map(|v| v != "0")
162            .unwrap_or_else(|_| std::env::var("NEXTEST").is_ok());
163        if fast {
164            if cfg.connect_timeout_ms.is_none() {
165                cfg.connect_timeout_ms = Some(50);
166            }
167            if cfg.backoff_ms.is_none() {
168                cfg.backoff_ms = Some(0);
169            }
170            if cfg.interval_ms.is_none() {
171                cfg.interval_ms = Some(1);
172            }
173            if cfg.auth_timeout_secs.is_none() {
174                cfg.auth_timeout_secs = Some(1);
175            }
176            if cfg.heartbeat_secs.is_none() {
177                cfg.heartbeat_secs = Some(1);
178            }
179        }
180        cfg
181    }
182
183    pub fn heartbeat_secs(&self) -> u64 {
184        self.heartbeat_secs.unwrap_or(Self::DEFAULT_HEARTBEAT_SECS)
185    }
186
187    pub fn auth_timeout_secs(&self) -> u64 {
188        self.auth_timeout_secs
189            .unwrap_or(Self::DEFAULT_AUTH_TIMEOUT_SECS)
190    }
191
192    pub fn join_timeout_secs(&self) -> u64 {
193        self.join_timeout_secs
194            .unwrap_or(Self::DEFAULT_JOIN_TIMEOUT_SECS)
195    }
196
197    pub fn connect_timeout_ms(&self) -> u64 {
198        self.connect_timeout_ms
199            .unwrap_or(Self::DEFAULT_CONNECT_TIMEOUT_MS)
200    }
201
202    pub fn quic_idle_duration(&self) -> Result<Duration, Report> {
203        let hb = self.heartbeat_secs();
204        let ms = self.quic_idle_ms.unwrap_or(hb * 3 * 1000);
205        if ms <= hb * 1000 {
206            return Err(eyre!(
207                "quic idle timeout {ms}ms must exceed heartbeat interval {hb}s"
208            ));
209        }
210        Ok(Duration::from_millis(ms))
211    }
212
213    /// Idle multiplier for worker connections (default 5 × heartbeat)
214    pub fn worker_idle_multiplier(&self) -> u64 {
215        self.worker_idle_multiplier.unwrap_or(5)
216    }
217
218    /// Worker-side rebalance: startup grace before considering rebalancing (default 300s)
219    pub fn rebalance_startup_grace_secs(&self) -> u64 {
220        self.rebalance_startup_grace_secs.unwrap_or(300)
221    }
222
223    /// Worker-side rebalance: interval between should_rebalance checks (default 600s)
224    pub fn rebalance_check_interval_secs(&self) -> u64 {
225        self.rebalance_check_interval_secs.unwrap_or(300)
226    }
227
228    /// Worker-side rebalance controller: minimum interval between rebalances (default 300s)
229    pub fn rebalance_min_interval_secs(&self) -> u64 {
230        self.rebalance_min_interval_secs.unwrap_or(300)
231    }
232
233    /// Worker-side migration: periodic check interval (default 180s)
234    pub fn migration_check_interval_secs(&self) -> u64 {
235        self.migration_check_interval_secs.unwrap_or(180)
236    }
237
238    /// Worker-side migration: connection timeout (default 15s)
239    pub fn migration_connection_timeout_secs(&self) -> u64 {
240        self.migration_connection_timeout_secs.unwrap_or(15)
241    }
242
243    /// Worker-side migration: handoff timeout (default 30s)
244    pub fn migration_handoff_timeout_secs(&self) -> u64 {
245        self.migration_handoff_timeout_secs.unwrap_or(30)
246    }
247
248    /// Periodic rebalance: consider current score "good" above this value
249    pub fn rebalance_good_score_cutoff(&self) -> f64 {
250        self.rebalance_good_score_cutoff.unwrap_or(0.6)
251    }
252
253    /// Periodic rebalance: required fractional improvement over current
254    pub fn rebalance_periodic_improvement(&self) -> f64 {
255        self.rebalance_periodic_improvement.unwrap_or(0.30)
256    }
257
258    /// Controller: required consecutive improvement signals
259    pub fn rebalance_consecutive_signals(&self) -> u32 {
260        self.rebalance_consecutive_signals.unwrap_or(1)
261    }
262
263    /// Controller: max rebalances per hour
264    pub fn rebalance_max_per_hour(&self) -> u32 {
265        self.rebalance_max_per_hour.unwrap_or(3)
266    }
267
268    /// Rebalance scoring weights; defaults to 0.6/0.2/0.2
269    pub fn rebalance_weight_health(&self) -> f64 {
270        self.rebalance_weight_health.unwrap_or(0.6)
271    }
272    pub fn rebalance_weight_history(&self) -> f64 {
273        self.rebalance_weight_history.unwrap_or(0.2)
274    }
275    pub fn rebalance_weight_breaker(&self) -> f64 {
276        self.rebalance_weight_breaker.unwrap_or(0.2)
277    }
278
279    /// Empty-manager bonus: required fullness delta between emptiest and next emptiest
280    pub fn rebalance_empty_delta_threshold(&self) -> f64 {
281        self.rebalance_empty_delta_threshold.unwrap_or(0.20)
282    }
283
284    /// Empty-manager bonus: bonus weight applied to emptiest manager score
285    pub fn rebalance_empty_bonus_weight(&self) -> f64 {
286        self.rebalance_empty_bonus_weight.unwrap_or(0.5)
287    }
288
289    /// Empty-manager bonus: minimum health score to qualify
290    pub fn rebalance_empty_health_cutoff(&self) -> f64 {
291        self.rebalance_empty_health_cutoff.unwrap_or(0.6)
292    }
293
294    pub fn rebalance_health_stale_secs(&self) -> u64 {
295        self.rebalance_health_stale_secs.unwrap_or(60)
296    }
297
298    pub fn rebalance_health_stale_factor(&self) -> f64 {
299        self.rebalance_health_stale_factor.unwrap_or(0.75)
300    }
301}
302
303static CONFIG: Lazy<RwLock<EnvironmentConfig>> =
304    Lazy::new(|| RwLock::new(EnvironmentConfig::from_env()));
305
306static CONFIG_DIR: Lazy<RwLock<Option<String>>> =
307    Lazy::new(|| RwLock::new(std::env::var("VOLLI_CONFIG_DIR").ok()));
308
309pub fn env_config() -> EnvironmentConfig {
310    CONFIG.read().unwrap().clone()
311}
312
313pub fn config_dir_env() -> Option<String> {
314    // Prefer thread-local overrides to allow tests and scoped operations to run concurrently.
315    if let Some(dir) = TLS_CONFIG_DIR.with(|c| c.borrow().clone()) {
316        return Some(dir);
317    }
318    CONFIG_DIR.read().unwrap().clone()
319}
320
321pub fn profile_env() -> Option<String> {
322    std::env::var("VOLLI_PROFILE").ok()
323}
324
325pub fn tcp_port_env() -> Option<u16> {
326    std::env::var("VOLLI_TCP_PORT")
327        .ok()
328        .and_then(|v| v.parse().ok())
329}
330
331pub fn quic_port_env() -> Option<u16> {
332    std::env::var("VOLLI_QUIC_PORT")
333        .ok()
334        .and_then(|v| v.parse().ok())
335}
336
337pub fn editor_env() -> Option<String> {
338    std::env::var("EDITOR").ok()
339}
340
341pub struct ConfigDirGuard {
342    prev_thread: Option<String>,
343    prev_global: Option<String>,
344}
345
346pub fn override_config_dir<P: AsRef<Path>>(dir: Option<P>) -> ConfigDirGuard {
347    let new = dir.map(|p| p.as_ref().to_string_lossy().to_string());
348    let prev_thread = TLS_CONFIG_DIR.with(|c| {
349        let mut b = c.borrow_mut();
350        std::mem::replace(&mut *b, new.clone())
351    });
352    let prev_global = {
353        let mut lock = CONFIG_DIR.write().unwrap();
354        std::mem::replace(&mut *lock, new)
355    };
356    ConfigDirGuard {
357        prev_thread,
358        prev_global,
359    }
360}
361
362pub struct ConfigGuard(Option<EnvironmentConfig>);
363
364pub fn override_env_config(cfg: EnvironmentConfig) -> ConfigGuard {
365    let prev = {
366        let mut lock = CONFIG.write().unwrap();
367        std::mem::replace(&mut *lock, cfg)
368    };
369    ConfigGuard(Some(prev))
370}
371
372/// Partially override the current environment config, preserving existing values
373/// for fields not specified in the provided `patch`.
374///
375/// This is useful in tests that want to tweak a couple of timings (e.g.,
376/// `interval_ms`, `backoff_ms`) while keeping the fast defaults established by
377/// the test runner (Nextest) or a previous override.
378pub fn override_env_config_patch(patch: EnvironmentConfig) -> ConfigGuard {
379    let prev = CONFIG.read().unwrap().clone();
380    // Merge: for each Option field, prefer `patch` if Some(..), otherwise keep `prev`.
381    let merged = EnvironmentConfig {
382        quic_idle_ms: patch.quic_idle_ms.or(prev.quic_idle_ms),
383        backoff_ms: patch.backoff_ms.or(prev.backoff_ms),
384        interval_ms: patch.interval_ms.or(prev.interval_ms),
385        connect_timeout_ms: patch.connect_timeout_ms.or(prev.connect_timeout_ms),
386        auth_timeout_secs: patch.auth_timeout_secs.or(prev.auth_timeout_secs),
387        heartbeat_secs: patch.heartbeat_secs.or(prev.heartbeat_secs),
388        join_timeout_secs: patch.join_timeout_secs.or(prev.join_timeout_secs),
389        peer_cleanup_secs: patch.peer_cleanup_secs.or(prev.peer_cleanup_secs),
390        peer_check_secs: patch.peer_check_secs.or(prev.peer_check_secs),
391        rebalance_improvement: patch.rebalance_improvement.or(prev.rebalance_improvement),
392        worker_idle_multiplier: patch.worker_idle_multiplier.or(prev.worker_idle_multiplier),
393        rebalance_startup_grace_secs: patch
394            .rebalance_startup_grace_secs
395            .or(prev.rebalance_startup_grace_secs),
396        rebalance_check_interval_secs: patch
397            .rebalance_check_interval_secs
398            .or(prev.rebalance_check_interval_secs),
399        rebalance_min_interval_secs: patch
400            .rebalance_min_interval_secs
401            .or(prev.rebalance_min_interval_secs),
402        migration_check_interval_secs: patch
403            .migration_check_interval_secs
404            .or(prev.migration_check_interval_secs),
405        migration_connection_timeout_secs: patch
406            .migration_connection_timeout_secs
407            .or(prev.migration_connection_timeout_secs),
408        migration_handoff_timeout_secs: patch
409            .migration_handoff_timeout_secs
410            .or(prev.migration_handoff_timeout_secs),
411        rebalance_good_score_cutoff: patch
412            .rebalance_good_score_cutoff
413            .or(prev.rebalance_good_score_cutoff),
414        rebalance_periodic_improvement: patch
415            .rebalance_periodic_improvement
416            .or(prev.rebalance_periodic_improvement),
417        rebalance_consecutive_signals: patch
418            .rebalance_consecutive_signals
419            .or(prev.rebalance_consecutive_signals),
420        rebalance_max_per_hour: patch.rebalance_max_per_hour.or(prev.rebalance_max_per_hour),
421        rebalance_weight_health: patch
422            .rebalance_weight_health
423            .or(prev.rebalance_weight_health),
424        rebalance_weight_history: patch
425            .rebalance_weight_history
426            .or(prev.rebalance_weight_history),
427        rebalance_weight_breaker: patch
428            .rebalance_weight_breaker
429            .or(prev.rebalance_weight_breaker),
430        rebalance_empty_delta_threshold: patch
431            .rebalance_empty_delta_threshold
432            .or(prev.rebalance_empty_delta_threshold),
433        rebalance_empty_bonus_weight: patch
434            .rebalance_empty_bonus_weight
435            .or(prev.rebalance_empty_bonus_weight),
436        rebalance_empty_health_cutoff: patch
437            .rebalance_empty_health_cutoff
438            .or(prev.rebalance_empty_health_cutoff),
439        rebalance_health_stale_secs: patch
440            .rebalance_health_stale_secs
441            .or(prev.rebalance_health_stale_secs),
442        rebalance_health_stale_factor: patch
443            .rebalance_health_stale_factor
444            .or(prev.rebalance_health_stale_factor),
445    };
446    let old = {
447        let mut lock = CONFIG.write().unwrap();
448        std::mem::replace(&mut *lock, merged)
449    };
450    ConfigGuard(Some(old))
451}
452
453impl Drop for ConfigGuard {
454    fn drop(&mut self) {
455        if let Some(prev) = self.0.take() {
456            let mut lock = CONFIG.write().unwrap();
457            *lock = prev;
458        }
459    }
460}
461
462impl Drop for ConfigDirGuard {
463    fn drop(&mut self) {
464        TLS_CONFIG_DIR.with(|c| {
465            *c.borrow_mut() = self.prev_thread.take();
466        });
467        let mut lock = CONFIG_DIR.write().unwrap();
468        *lock = self.prev_global.take();
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use std::time::Duration;
476
477    #[test]
478    fn quic_idle_default() {
479        let cfg = EnvironmentConfig {
480            heartbeat_secs: Some(2),
481            ..Default::default()
482        };
483        assert_eq!(cfg.quic_idle_duration().unwrap(), Duration::from_secs(6));
484    }
485
486    #[test]
487    fn quic_idle_invalid() {
488        let cfg = EnvironmentConfig {
489            heartbeat_secs: Some(5),
490            quic_idle_ms: Some(4000),
491            ..Default::default()
492        };
493        assert!(cfg.quic_idle_duration().is_err());
494    }
495
496    #[test]
497    fn quic_idle_valid() {
498        let cfg = EnvironmentConfig {
499            heartbeat_secs: Some(5),
500            quic_idle_ms: Some(11000),
501            ..Default::default()
502        };
503        assert_eq!(
504            cfg.quic_idle_duration().unwrap(),
505            Duration::from_millis(11000)
506        );
507    }
508}