Skip to main content

praxis_core/config/
runtime.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Runtime tuning: worker thread count, work-stealing toggle, logging overrides, and upstream CA.
5
6use std::collections::HashMap;
7
8use serde::Deserialize;
9
10// -----------------------------------------------------------------------------
11// RuntimeConfig
12// -----------------------------------------------------------------------------
13
14/// Configuration for the runtime of the proxy server.
15///
16/// ```
17/// use praxis_core::config::RuntimeConfig;
18///
19/// let cfg = RuntimeConfig::default();
20/// assert_eq!(cfg.threads, 0);
21/// assert!(cfg.work_stealing);
22/// assert_eq!(cfg.global_queue_interval, Some(61));
23/// assert!(cfg.log_overrides.is_empty());
24/// assert_eq!(cfg.upstream_keepalive_pool_size, Some(64));
25/// assert!(cfg.upstream_ca_file.is_none());
26///
27/// let cfg: RuntimeConfig = serde_yaml::from_str("threads: 4\nwork_stealing: true").unwrap();
28/// assert_eq!(cfg.threads, 4);
29/// assert!(cfg.work_stealing);
30/// ```
31#[derive(Debug, Clone, Deserialize, serde::Serialize)]
32#[serde(deny_unknown_fields)]
33pub struct RuntimeConfig {
34    /// Tokio scheduler global queue check interval, in ticks.
35    ///
36    /// Controls how often worker threads check the global task
37    /// queue. The default of 61 (a prime) reduces contention
38    /// under proxy workloads where most tasks are I/O-bound.
39    /// Set to `null` to use the tokio default. Valid range is
40    /// any positive `u32`.
41    ///
42    /// ```
43    /// use praxis_core::config::RuntimeConfig;
44    ///
45    /// let cfg = RuntimeConfig::default();
46    /// assert_eq!(cfg.global_queue_interval, Some(61));
47    ///
48    /// let cfg: RuntimeConfig = serde_yaml::from_str("global_queue_interval: 128").unwrap();
49    /// assert_eq!(cfg.global_queue_interval, Some(128));
50    /// ```
51    #[serde(default = "default_global_queue_interval")]
52    pub global_queue_interval: Option<u32>,
53
54    /// Per-module log level overrides.
55    ///
56    /// ```
57    /// use praxis_core::config::RuntimeConfig;
58    ///
59    /// let yaml = r#"
60    /// log_overrides:
61    ///   praxis_filter::pipeline: trace
62    ///   praxis_protocol: debug
63    /// "#;
64    /// let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
65    /// assert_eq!(cfg.log_overrides.len(), 2);
66    /// assert_eq!(cfg.log_overrides["praxis_filter::pipeline"], "trace");
67    /// ```
68    #[serde(default)]
69    pub log_overrides: HashMap<String, String>,
70
71    /// Process-wide maximum concurrent connections across all
72    /// listeners (both HTTP and TCP).
73    ///
74    /// When set, new connections beyond this limit are rejected
75    /// with HTTP 503 (or TCP close for non-HTTP listeners),
76    /// regardless of per-listener limits. Connections are shed
77    /// before filter pipeline execution. `None` (the default)
78    /// means no global limit.
79    ///
80    /// ```
81    /// use praxis_core::config::RuntimeConfig;
82    ///
83    /// let cfg: RuntimeConfig = serde_yaml::from_str("max_connections: 10000").unwrap();
84    /// assert_eq!(cfg.max_connections, Some(10_000));
85    ///
86    /// let cfg = RuntimeConfig::default();
87    /// assert!(cfg.max_connections.is_none());
88    /// ```
89    #[serde(default)]
90    pub max_connections: Option<u32>,
91
92    /// Maximum resident memory (RSS) in bytes before shedding load.
93    ///
94    /// When set, Praxis monitors process RSS and rejects new
95    /// requests with 503 when the threshold is exceeded. `None`
96    /// (the default) disables memory pressure monitoring.
97    ///
98    /// ```
99    /// use praxis_core::config::RuntimeConfig;
100    ///
101    /// let cfg: RuntimeConfig = serde_yaml::from_str("max_memory_bytes: 1073741824").unwrap();
102    /// assert_eq!(cfg.max_memory_bytes, Some(1_073_741_824));
103    ///
104    /// let cfg = RuntimeConfig::default();
105    /// assert!(cfg.max_memory_bytes.is_none());
106    /// ```
107    #[serde(default)]
108    pub max_memory_bytes: Option<usize>,
109
110    /// Number of worker threads per service.
111    ///
112    /// `0` (the default) auto-detects based on available CPU
113    /// cores. Values above the CPU count are valid but yield
114    /// diminishing returns for I/O-bound workloads.
115    #[serde(default)]
116    pub threads: usize,
117
118    /// Path to a PEM CA file used as the root certificate store for all upstream TLS connections.
119    ///
120    /// When set, this **replaces** the system trust store (not additive). If backends
121    /// use both a private CA and public CAs, create a combined PEM bundle containing
122    /// all required root certificates.
123    ///
124    /// ```
125    /// use praxis_core::config::RuntimeConfig;
126    ///
127    /// let cfg: RuntimeConfig =
128    ///     serde_yaml::from_str("upstream_ca_file: /etc/praxis/ca-bundle.pem").unwrap();
129    /// assert_eq!(
130    ///     cfg.upstream_ca_file.as_deref(),
131    ///     Some("/etc/praxis/ca-bundle.pem")
132    /// );
133    ///
134    /// let cfg = RuntimeConfig::default();
135    /// assert!(cfg.upstream_ca_file.is_none());
136    /// ```
137    #[serde(default)]
138    pub upstream_ca_file: Option<String>,
139
140    /// Maximum number of idle upstream connections kept per worker
141    /// thread, shared across all clusters.
142    ///
143    /// When a worker's pool is full, the oldest idle connection
144    /// is evicted. Set to `null` to use Pingora's built-in
145    /// default. This is a per-thread limit, not per-cluster.
146    ///
147    /// ```
148    /// use praxis_core::config::RuntimeConfig;
149    ///
150    /// let cfg = RuntimeConfig::default();
151    /// assert_eq!(cfg.upstream_keepalive_pool_size, Some(64));
152    ///
153    /// let cfg: RuntimeConfig = serde_yaml::from_str("upstream_keepalive_pool_size: 32").unwrap();
154    /// assert_eq!(cfg.upstream_keepalive_pool_size, Some(32));
155    /// ```
156    #[serde(default = "default_upstream_keepalive_pool_size")]
157    pub upstream_keepalive_pool_size: Option<usize>,
158
159    /// Allow work-stealing between worker threads of the same service.
160    #[serde(default = "default_work_stealing")]
161    pub work_stealing: bool,
162}
163
164impl Default for RuntimeConfig {
165    fn default() -> Self {
166        Self {
167            max_connections: None,
168            max_memory_bytes: None,
169            threads: 0,
170            work_stealing: default_work_stealing(),
171            global_queue_interval: default_global_queue_interval(),
172            log_overrides: HashMap::new(),
173            upstream_ca_file: None,
174            upstream_keepalive_pool_size: default_upstream_keepalive_pool_size(),
175        }
176    }
177}
178
179/// Serde default for [`RuntimeConfig::work_stealing`].
180fn default_work_stealing() -> bool {
181    true
182}
183
184/// Serde default for [`RuntimeConfig::upstream_keepalive_pool_size`].
185#[expect(clippy::unnecessary_wraps, reason = "serde default")]
186fn default_upstream_keepalive_pool_size() -> Option<usize> {
187    Some(64)
188}
189
190/// Serde default for [`RuntimeConfig::global_queue_interval`].
191#[expect(clippy::unnecessary_wraps, reason = "serde default")]
192fn default_global_queue_interval() -> Option<u32> {
193    Some(61)
194}
195
196// -----------------------------------------------------------------------------
197// Tests
198// -----------------------------------------------------------------------------
199
200#[cfg(test)]
201#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
202#[allow(
203    clippy::unwrap_used,
204    clippy::expect_used,
205    clippy::indexing_slicing,
206    clippy::needless_raw_strings,
207    clippy::needless_raw_string_hashes,
208    reason = "tests use unwrap/expect/indexing/raw strings for brevity"
209)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn default_has_zero_threads_and_work_stealing_true() {
215        let cfg = RuntimeConfig::default();
216        assert_eq!(cfg.threads, 0, "default threads should be 0");
217        assert!(cfg.work_stealing, "default work_stealing should be true");
218    }
219
220    #[test]
221    fn deserialise_empty_yaml_gives_defaults() {
222        let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
223        assert_eq!(cfg.threads, 0, "empty yaml should give 0 threads");
224        assert!(cfg.work_stealing, "empty yaml should give work_stealing=true");
225    }
226
227    #[test]
228    fn deserialise_explicit_threads() {
229        let cfg: RuntimeConfig = serde_yaml::from_str("threads: 4").unwrap();
230        assert_eq!(cfg.threads, 4, "explicit threads should be preserved");
231        assert!(cfg.work_stealing, "unset work_stealing should default to true");
232    }
233
234    #[test]
235    fn deserialise_work_stealing_disabled() {
236        let cfg: RuntimeConfig = serde_yaml::from_str("work_stealing: false").unwrap();
237        assert_eq!(cfg.threads, 0, "unset threads should default to 0");
238        assert!(!cfg.work_stealing, "explicit work_stealing=false should be preserved");
239    }
240
241    #[test]
242    fn deserialise_all_fields() {
243        let yaml = "threads: 8\nwork_stealing: true";
244        let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
245        assert_eq!(cfg.threads, 8, "threads should be 8");
246        assert!(cfg.work_stealing, "work_stealing should be true");
247    }
248
249    #[test]
250    fn deserialise_log_overrides() {
251        let yaml = r#"
252log_overrides:
253  praxis_filter::pipeline: trace
254  praxis_protocol: debug
255"#;
256        let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
257        assert_eq!(cfg.log_overrides.len(), 2, "should have 2 log overrides");
258        assert_eq!(
259            cfg.log_overrides["praxis_filter::pipeline"], "trace",
260            "pipeline override mismatch"
261        );
262        assert_eq!(
263            cfg.log_overrides["praxis_protocol"], "debug",
264            "protocol override mismatch"
265        );
266    }
267
268    #[test]
269    fn default_log_overrides_is_empty() {
270        let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
271        assert!(cfg.log_overrides.is_empty(), "log_overrides should default to empty");
272    }
273
274    #[test]
275    fn global_queue_interval_defaults_to_61() {
276        let cfg = RuntimeConfig::default();
277        assert_eq!(cfg.global_queue_interval, Some(61), "default interval should be 61");
278    }
279
280    #[test]
281    fn deserialise_global_queue_interval() {
282        let cfg: RuntimeConfig = serde_yaml::from_str("global_queue_interval: 128").unwrap();
283        assert_eq!(cfg.global_queue_interval, Some(128), "explicit interval should be 128");
284    }
285
286    #[test]
287    fn deserialise_global_queue_interval_null() {
288        let cfg: RuntimeConfig = serde_yaml::from_str("global_queue_interval: null").unwrap();
289        assert!(cfg.global_queue_interval.is_none(), "null interval should be None");
290    }
291
292    #[test]
293    fn upstream_keepalive_pool_size_defaults_to_64() {
294        let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
295        assert_eq!(
296            cfg.upstream_keepalive_pool_size,
297            Some(64),
298            "default pool size should be 64"
299        );
300    }
301
302    #[test]
303    fn deserialise_upstream_keepalive_pool_size() {
304        let cfg: RuntimeConfig = serde_yaml::from_str("upstream_keepalive_pool_size: 64").unwrap();
305        assert_eq!(
306            cfg.upstream_keepalive_pool_size,
307            Some(64),
308            "explicit pool size should be 64"
309        );
310    }
311
312    #[test]
313    fn upstream_ca_file_defaults_to_none() {
314        let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
315        assert!(
316            cfg.upstream_ca_file.is_none(),
317            "upstream_ca_file should default to None"
318        );
319    }
320
321    #[test]
322    fn deserialise_upstream_ca_file() {
323        let cfg: RuntimeConfig = serde_yaml::from_str("upstream_ca_file: /etc/ssl/ca.pem").unwrap();
324        assert_eq!(
325            cfg.upstream_ca_file.as_deref(),
326            Some("/etc/ssl/ca.pem"),
327            "explicit upstream_ca_file should be preserved"
328        );
329    }
330}