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            if let Some(cfg) = crate::config::try_get()
60                && let Ok(scaling) = cfg.unmarshal_key_registered::<Self>("scaling")
61            {
62                return scaling;
63            }
64        }
65        Self::default()
66    }
67}
68
69/// Named scaling component with weight and saturation point.
70///
71/// Apps define their components with service-specific signals:
72///
73/// ```rust
74/// use hyperi_rustlib::scaling::ScalingComponent;
75///
76/// let components = vec![
77///     ScalingComponent::new("kafka_lag", 0.35, 100_000.0),
78///     ScalingComponent::new("buffer_depth", 0.25, 10_000.0),
79///     ScalingComponent::new("insert_latency", 0.15, 5.0),
80///     ScalingComponent::new("memory", 0.15, 1.0),
81///     ScalingComponent::new("errors", 0.10, 100.0),
82/// ];
83/// ```
84#[derive(Debug, Clone)]
85pub struct ScalingComponent {
86    /// Component name (e.g., "kafka_lag", "buffer_depth").
87    pub name: String,
88    /// Relative weight (0.0-1.0). All weights should sum to ~1.0.
89    pub weight: f64,
90    /// Value at which this component contributes its full weight.
91    /// Score = `(value / saturation).min(1.0) * weight * 100.0`.
92    pub saturation: f64,
93}
94
95impl ScalingComponent {
96    /// Create a new scaling component.
97    #[must_use]
98    pub fn new(name: impl Into<String>, weight: f64, saturation: f64) -> Self {
99        Self {
100            name: name.into(),
101            weight,
102            saturation,
103        }
104    }
105}
106
107/// serde default for `PressureExpr::enabled`.
108fn default_true() -> bool {
109    true
110}
111
112/// Configuration for the horizontal scaling-pressure ENGINE (CEL over local
113/// metrics).
114///
115/// Lives under the `scaling` cascade key alongside [`ScalingPressureConfig`];
116/// serde ignores each other's extra fields, so both can read the same section.
117/// Precedence for the produced pressure: config `pressures` (here) > app-plumbed
118/// default > rustlib's context-aware smart default (when `pressures` is empty).
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(default)]
121pub struct ScalingEngineConfig {
122    /// Master switch for the CEL pressure engine.
123    pub enabled: bool,
124    /// Evaluation period in seconds (periodic, off the data hot-path).
125    pub interval_secs: u64,
126    /// Tunable targets/constants referenced by expressions as `params.<key>`.
127    /// Transport-term defaults are filled by `PressureTargets::from_params`;
128    /// `cpu_target` defaults to 0.70 (see [`Self::cpu_target`]).
129    pub params: BTreeMap<String, f64>,
130    /// Named pressure expressions. EMPTY => rustlib composes the context-aware
131    /// smart default from the inbound transport kind.
132    pub pressures: Vec<PressureExpr>,
133    /// Optional explicit inbound/outbound transport kinds (else the runtime
134    /// auto-derives them from the transports it builds).
135    pub transport: ScalingTransportConfig,
136}
137
138impl Default for ScalingEngineConfig {
139    fn default() -> Self {
140        let mut params = BTreeMap::new();
141        params.insert("cpu_target".to_string(), 0.70);
142        Self {
143            enabled: true,
144            interval_secs: 15,
145            params,
146            pressures: Vec::new(),
147            transport: ScalingTransportConfig::default(),
148        }
149    }
150}
151
152impl ScalingEngineConfig {
153    /// Load from the config cascade under the `scaling` key.
154    ///
155    /// Non-registered read (the section is already registered by
156    /// [`ScalingPressureConfig::from_cascade`]); falls back to defaults when
157    /// config is absent.
158    #[must_use]
159    pub fn from_cascade() -> Self {
160        #[cfg(feature = "config")]
161        {
162            if let Some(cfg) = crate::config::try_get()
163                && let Ok(engine) = cfg.unmarshal_key::<Self>("scaling")
164            {
165                return engine;
166            }
167        }
168        Self::default()
169    }
170
171    /// CPU utilisation target (0-1), defaulting to 0.70 when unset/invalid.
172    #[must_use]
173    pub fn cpu_target(&self) -> f64 {
174        self.params
175            .get("cpu_target")
176            .copied()
177            .filter(|v| *v > 0.0)
178            .unwrap_or(0.70)
179    }
180}
181
182/// A single named pressure expression -> one `{ns}_scaling_pressure{name=...}`
183/// gauge. The autoscaler scales to the MAX across all enabled pressures.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct PressureExpr {
186    /// Output label (`name=...`) on the emitted gauge; must be unique.
187    pub name: String,
188    /// CEL expression evaluated over the metric/derived/params context.
189    pub expression: String,
190    /// Whether this pressure is evaluated/emitted.
191    #[serde(default = "default_true")]
192    pub enabled: bool,
193}
194
195/// Optional explicit transport kinds for the compound pressure (`kafka`,
196/// `redis`, `http`, `grpc`, ...). `None` => auto-derived by the runtime.
197#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198#[serde(default)]
199pub struct ScalingTransportConfig {
200    /// Inbound transport kind label, or `None` to auto-derive.
201    pub inbound: Option<String>,
202    /// Outbound transport kind label, or `None` to auto-derive.
203    pub outbound: Option<String>,
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_config_defaults() {
212        let config = ScalingPressureConfig::default();
213        assert!(config.enabled);
214        assert!((config.memory_gate_threshold - 0.8).abs() < f64::EPSILON);
215    }
216
217    #[test]
218    fn test_config_serde_roundtrip() {
219        let config = ScalingPressureConfig {
220            enabled: false,
221            memory_gate_threshold: 0.9,
222        };
223        let json = serde_json::to_string(&config).unwrap();
224        let parsed: ScalingPressureConfig = serde_json::from_str(&json).unwrap();
225        assert!(!parsed.enabled);
226        assert!((parsed.memory_gate_threshold - 0.9).abs() < f64::EPSILON);
227    }
228
229    #[test]
230    fn test_component_new() {
231        let c = ScalingComponent::new("kafka_lag", 0.35, 100_000.0);
232        assert_eq!(c.name, "kafka_lag");
233        assert!((c.weight - 0.35).abs() < f64::EPSILON);
234        assert!((c.saturation - 100_000.0).abs() < f64::EPSILON);
235    }
236}