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}