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}