Skip to main content

hyperi_rustlib/scaling/
config.rs

1// Project:   hyperi-rustlib
2// File:      src/scaling/config.rs
3// Purpose:   Scaling pressure configuration types
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Configuration for the scaling pressure calculator.
10//!
11//! [`ScalingPressureConfig`] provides the base gate thresholds shared across
12//! all apps. Per-component weights and saturation points are defined in each
13//! app's own config struct and passed as [`ScalingComponent`] at construction.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19/// Base configuration for scaling pressure calculation.
20///
21/// Lives in the app's config cascade so thresholds are env-var overridable
22/// (e.g., `DFE_LOADER__SCALING__MEMORY_GATE_THRESHOLD=0.9`).
23///
24/// Component weights and saturation points are app-specific -- defined in
25/// each app's config and passed to [`super::ScalingPressure::new`] via
26/// [`ScalingComponent`].
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(default)]
29pub struct ScalingPressureConfig {
30    /// Enable scaling pressure calculation.
31    /// When disabled, `calculate()` always returns 0.0.
32    pub enabled: bool,
33
34    /// Memory usage ratio that triggers the memory gate (0.0-1.0).
35    ///
36    /// When `memory_used / memory_limit >= threshold`, scaling pressure
37    /// is forced to 100.0 to trigger immediate scale-up before OOM.
38    pub memory_gate_threshold: f64,
39}
40
41impl Default for ScalingPressureConfig {
42    fn default() -> Self {
43        Self {
44            enabled: true,
45            memory_gate_threshold: 0.8,
46        }
47    }
48}
49
50impl ScalingPressureConfig {
51    /// Load from the config cascade under the `scaling` key.
52    ///
53    /// Falls back to [`ScalingPressureConfig::default()`] if config is not
54    /// initialised or the key is absent.
55    #[must_use]
56    pub fn from_cascade() -> Self {
57        #[cfg(feature = "config")]
58        {
59            // `or_warn`: absent `scaling` key -> default (silent); present-but-
60            // malformed -> WARN + default (was silently swallowed pre-2.8.11).
61            if let Some(cfg) = crate::config::try_get()
62                && let Some(scaling) = cfg.unmarshal_key_registered_or_warn::<Self>("scaling")
63            {
64                return scaling;
65            }
66        }
67        Self::default()
68    }
69}
70
71/// Named scaling component with weight and saturation point.
72///
73/// Apps define their components with service-specific signals:
74///
75/// ```rust
76/// use hyperi_rustlib::scaling::ScalingComponent;
77///
78/// let components = vec![
79///     ScalingComponent::new("kafka_lag", 0.35, 100_000.0),
80///     ScalingComponent::new("buffer_depth", 0.25, 10_000.0),
81///     ScalingComponent::new("insert_latency", 0.15, 5.0),
82///     ScalingComponent::new("memory", 0.15, 1.0),
83///     ScalingComponent::new("errors", 0.10, 100.0),
84/// ];
85/// ```
86#[derive(Debug, Clone)]
87pub struct ScalingComponent {
88    /// Component name (e.g., "kafka_lag", "buffer_depth").
89    pub name: String,
90    /// Relative weight (0.0-1.0). All weights should sum to ~1.0.
91    pub weight: f64,
92    /// Value at which this component contributes its full weight.
93    /// Score = `(value / saturation).min(1.0) * weight * 100.0`.
94    pub saturation: f64,
95}
96
97impl ScalingComponent {
98    /// Create a new scaling component.
99    #[must_use]
100    pub fn new(name: impl Into<String>, weight: f64, saturation: f64) -> Self {
101        Self {
102            name: name.into(),
103            weight,
104            saturation,
105        }
106    }
107}
108
109/// serde default for `PressureExpr::enabled`.
110fn default_true() -> bool {
111    true
112}
113
114/// Configuration for the horizontal scaling-pressure ENGINE (CEL over local
115/// metrics).
116///
117/// Lives under the `scaling` cascade key alongside [`ScalingPressureConfig`];
118/// serde ignores each other's extra fields, so both can read the same section.
119/// Precedence for the produced pressure: config `pressures` (here) > app-plumbed
120/// default > rustlib's context-aware smart default (when `pressures` is empty).
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(default)]
123pub struct ScalingEngineConfig {
124    /// Master switch for the CEL pressure engine.
125    pub enabled: bool,
126    /// Evaluation period in seconds (periodic, off the data hot-path).
127    pub interval_secs: u64,
128    /// Tunable targets/constants referenced by expressions as `params.<key>`.
129    /// Transport-term defaults are filled by `PressureTargets::from_params`;
130    /// `cpu_target` defaults to 0.70 (see [`Self::cpu_target`]).
131    pub params: BTreeMap<String, f64>,
132    /// Named pressure expressions. EMPTY => rustlib composes the context-aware
133    /// smart default from the inbound transport kind.
134    pub pressures: Vec<PressureExpr>,
135    /// Optional explicit inbound/outbound transport kinds (else the runtime
136    /// auto-derives them from the transports it builds).
137    pub transport: ScalingTransportConfig,
138}
139
140impl Default for ScalingEngineConfig {
141    fn default() -> Self {
142        let mut params = BTreeMap::new();
143        params.insert("cpu_target".to_string(), 0.70);
144        Self {
145            enabled: true,
146            interval_secs: 15,
147            params,
148            pressures: Vec::new(),
149            transport: ScalingTransportConfig::default(),
150        }
151    }
152}
153
154impl ScalingEngineConfig {
155    /// Load from the config cascade under the `scaling` key.
156    ///
157    /// Non-registered read (the section is already registered by
158    /// [`ScalingPressureConfig::from_cascade`]); falls back to defaults when
159    /// config is absent.
160    #[must_use]
161    pub fn from_cascade() -> Self {
162        #[cfg(feature = "config")]
163        {
164            if let Some(cfg) = crate::config::try_get()
165                && let Ok(engine) = cfg.unmarshal_key::<Self>("scaling")
166            {
167                return engine;
168            }
169        }
170        Self::default()
171    }
172
173    /// CPU utilisation target (0-1), defaulting to 0.70 when unset/invalid.
174    #[must_use]
175    pub fn cpu_target(&self) -> f64 {
176        self.params
177            .get("cpu_target")
178            .copied()
179            .filter(|v| *v > 0.0)
180            .unwrap_or(0.70)
181    }
182}
183
184/// A single named pressure expression -> one `{ns}_scaling_pressure{name=...}`
185/// gauge. The autoscaler scales to the MAX across all enabled pressures.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct PressureExpr {
188    /// Output label (`name=...`) on the emitted gauge; must be unique.
189    pub name: String,
190    /// CEL expression evaluated over the metric/derived/params context.
191    pub expression: String,
192    /// Whether this pressure is evaluated/emitted.
193    #[serde(default = "default_true")]
194    pub enabled: bool,
195}
196
197/// Optional explicit transport kinds for the compound pressure (`kafka`,
198/// `redis`, `http`, `grpc`, ...). `None` => auto-derived by the runtime.
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200#[serde(default)]
201pub struct ScalingTransportConfig {
202    /// Inbound transport kind label, or `None` to auto-derive.
203    pub inbound: Option<String>,
204    /// Outbound transport kind label, or `None` to auto-derive.
205    pub outbound: Option<String>,
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_config_defaults() {
214        let config = ScalingPressureConfig::default();
215        assert!(config.enabled);
216        assert!((config.memory_gate_threshold - 0.8).abs() < f64::EPSILON);
217    }
218
219    #[test]
220    fn test_config_serde_roundtrip() {
221        let config = ScalingPressureConfig {
222            enabled: false,
223            memory_gate_threshold: 0.9,
224        };
225        let json = serde_json::to_string(&config).unwrap();
226        let parsed: ScalingPressureConfig = serde_json::from_str(&json).unwrap();
227        assert!(!parsed.enabled);
228        assert!((parsed.memory_gate_threshold - 0.9).abs() < f64::EPSILON);
229    }
230
231    #[test]
232    fn test_component_new() {
233        let c = ScalingComponent::new("kafka_lag", 0.35, 100_000.0);
234        assert_eq!(c.name, "kafka_lag");
235        assert!((c.weight - 0.35).abs() < f64::EPSILON);
236        assert!((c.saturation - 100_000.0).abs() < f64::EPSILON);
237    }
238}