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}