Skip to main content

reddb_server/server/
http_limits.rs

1//! Resolution of the HTTP admission and execution knobs.
2//!
3//! The values are configurable through the standard precedence chain
4//! used elsewhere in the boot path:
5//!
6//!   flag > red_config > env > built-in default
7//!
8//! Built-in defaults preserve the existing request envelope and add a
9//! CPU-sized synchronous execution bound:
10//!   - max_handlers      = (2 * num_cpus).clamp(8, 256)
11//!   - execution_workers = num_cpus, clamped to max_handlers
12//!   - handler_timeout   = 30_000 ms
13//!   - retry_after_secs  = 5
14//!   - max_inflight_per_principal = min(64, max_handlers - 1)
15//!     (issue #934; 0 disables; one slot is reserved when possible)
16//!
17//! Each knob is validated at parse time and at resolution time so a
18//! stale red_config value cannot corrupt the running server.
19
20/// Lower bound for `handler_timeout_ms`. Anything below this is so
21/// short the deadline trips on healthy requests; we reject the value.
22pub const MIN_HANDLER_TIMEOUT_MS: u64 = 100;
23/// Inclusive bounds for `retry_after_secs`. Below 1s means clients
24/// hammer the server; above 30s means a transient overload looks like
25/// a permanent outage to load balancers.
26pub const MIN_RETRY_AFTER_SECS: u64 = 1;
27pub const MAX_RETRY_AFTER_SECS: u64 = 30;
28
29/// Built-in default for `max_handlers`. Matches
30/// `HttpConnectionLimiter::with_default_cap`.
31pub fn default_max_handlers() -> usize {
32    let cores = std::thread::available_parallelism()
33        .map(|n| n.get())
34        .unwrap_or(1);
35    (2 * cores).clamp(8, 256)
36}
37
38/// Synchronous engine calls are CPU/storage work, not connection work. Keep
39/// their default parallelism tied to the CPU budget while the larger
40/// `max_handlers` allowance becomes the bounded wait envelope.
41pub fn default_execution_workers() -> usize {
42    std::thread::available_parallelism()
43        .map(|parallelism| parallelism.get())
44        .unwrap_or(1)
45}
46
47pub const DEFAULT_HANDLER_TIMEOUT_MS: u64 = 30_000;
48pub const DEFAULT_RETRY_AFTER_SECS: u64 = 5;
49
50/// Built-in default for `max_inflight_per_principal` (issue #934). Bounds
51/// any single principal's concurrent in-flight requests at the async edge so
52/// one caller can't drain the whole global handler cap and starve the rest.
53/// `0` disables the per-principal cap entirely; a single-tenant deployment
54/// can set it there to pay nothing. The effective default is clamped below
55/// the global handler envelope when possible so even a one-CPU server keeps
56/// one request slot available for another principal.
57pub const DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL: usize = 64;
58
59fn default_max_inflight_per_principal(max_handlers: usize) -> usize {
60    DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL.min(max_handlers.saturating_sub(1).max(1))
61}
62
63/// Validate a `max_handlers` candidate from any source. Returns the
64/// value unchanged on success.
65pub fn validate_max_handlers(value: usize) -> Result<usize, String> {
66    if value == 0 {
67        return Err("http max_handlers must be >= 1".to_string());
68    }
69    Ok(value)
70}
71
72pub fn validate_execution_workers(value: usize) -> Result<usize, String> {
73    if value == 0 {
74        return Err("http execution_workers must be >= 1".to_string());
75    }
76    Ok(value)
77}
78
79/// Validate a `max_inflight_per_principal` candidate (issue #934). Every
80/// `usize` is acceptable: a positive value caps each principal's concurrent
81/// in-flight requests, and `0` disables the per-principal cap. Present for
82/// symmetry with the other knobs so the CLI parser can run all four through
83/// the same validated-parse helper.
84pub fn validate_max_inflight_per_principal(value: usize) -> Result<usize, String> {
85    Ok(value)
86}
87
88pub fn validate_handler_timeout_ms(value: u64) -> Result<u64, String> {
89    if value < MIN_HANDLER_TIMEOUT_MS {
90        return Err(format!(
91            "http handler_timeout_ms must be >= {MIN_HANDLER_TIMEOUT_MS}"
92        ));
93    }
94    Ok(value)
95}
96
97pub fn validate_retry_after_secs(value: u64) -> Result<u64, String> {
98    if !(MIN_RETRY_AFTER_SECS..=MAX_RETRY_AFTER_SECS).contains(&value) {
99        return Err(format!(
100            "http retry_after_secs must be in [{MIN_RETRY_AFTER_SECS}, {MAX_RETRY_AFTER_SECS}]"
101        ));
102    }
103    Ok(value)
104}
105
106/// CLI-layer input. Each pair holds the already-validated value coming
107/// from a flag and from an env var, respectively. The resolver applies
108/// the `flag > red_config > env > default` precedence using these
109/// inputs plus a config-store lookup.
110#[derive(Debug, Default, Clone)]
111pub struct HttpLimitsCliInput {
112    pub max_handlers_flag: Option<usize>,
113    pub max_handlers_env: Option<usize>,
114    pub execution_workers_flag: Option<usize>,
115    pub execution_workers_env: Option<usize>,
116    pub handler_timeout_ms_flag: Option<u64>,
117    pub handler_timeout_ms_env: Option<u64>,
118    pub retry_after_secs_flag: Option<u64>,
119    pub retry_after_secs_env: Option<u64>,
120    pub max_inflight_per_principal_flag: Option<usize>,
121    pub max_inflight_per_principal_env: Option<usize>,
122}
123
124/// Resolved values after applying the full precedence chain. Stamped
125/// into both the `RedDBServer` and the startup log line.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct HttpLimitsResolved {
128    pub max_handlers: usize,
129    /// Maximum synchronous engine calls running concurrently. Values from
130    /// configuration are clamped to `max_handlers`, which is the outer
131    /// admitted-request envelope.
132    pub execution_workers: usize,
133    pub handler_timeout_ms: u64,
134    pub retry_after_secs: u64,
135    /// Per-principal concurrent in-flight cap (issue #934). `0` disables.
136    pub max_inflight_per_principal: usize,
137}
138
139impl HttpLimitsResolved {
140    pub fn builtin_defaults() -> Self {
141        let max_handlers = default_max_handlers();
142        Self {
143            max_handlers,
144            execution_workers: default_execution_workers().min(max_handlers),
145            handler_timeout_ms: DEFAULT_HANDLER_TIMEOUT_MS,
146            retry_after_secs: DEFAULT_RETRY_AFTER_SECS,
147            max_inflight_per_principal: default_max_inflight_per_principal(max_handlers),
148        }
149    }
150}
151
152/// Apply the `flag > red_config > env > default` chain.
153///
154/// `config_lookup` is a closure so this function is independent of the
155/// runtime/config-store type — keeps the resolver pure and testable.
156/// Each lookup returns the raw text value stored under the given key,
157/// matching how `set_config_tree` persists scalars.
158pub fn resolve_http_limits<F>(input: &HttpLimitsCliInput, config_lookup: F) -> HttpLimitsResolved
159where
160    F: Fn(&str) -> Option<String>,
161{
162    let defaults = HttpLimitsResolved::builtin_defaults();
163
164    let max_handlers = input
165        .max_handlers_flag
166        .or_else(|| {
167            config_lookup("red.http.max_handlers")
168                .and_then(|raw| raw.parse::<usize>().ok())
169                .and_then(|v| validate_max_handlers(v).ok())
170        })
171        .or(input.max_handlers_env)
172        .unwrap_or(defaults.max_handlers);
173
174    let execution_workers = input
175        .execution_workers_flag
176        .or_else(|| {
177            config_lookup("red.http.execution_workers")
178                .and_then(|raw| raw.parse::<usize>().ok())
179                .and_then(|value| validate_execution_workers(value).ok())
180        })
181        .or(input.execution_workers_env)
182        .unwrap_or(defaults.execution_workers)
183        .min(max_handlers);
184
185    let handler_timeout_ms = input
186        .handler_timeout_ms_flag
187        .or_else(|| {
188            config_lookup("red.http.handler_timeout_ms")
189                .and_then(|raw| raw.parse::<u64>().ok())
190                .and_then(|v| validate_handler_timeout_ms(v).ok())
191        })
192        .or(input.handler_timeout_ms_env)
193        .unwrap_or(defaults.handler_timeout_ms);
194
195    let retry_after_secs = input
196        .retry_after_secs_flag
197        .or_else(|| {
198            config_lookup("red.http.retry_after_secs")
199                .and_then(|raw| raw.parse::<u64>().ok())
200                .and_then(|v| validate_retry_after_secs(v).ok())
201        })
202        .or(input.retry_after_secs_env)
203        .unwrap_or(defaults.retry_after_secs);
204
205    let max_inflight_per_principal = input
206        .max_inflight_per_principal_flag
207        .or_else(|| {
208            config_lookup("red.http.max_inflight_per_principal")
209                .and_then(|raw| raw.parse::<usize>().ok())
210                .and_then(|v| validate_max_inflight_per_principal(v).ok())
211        })
212        .or(input.max_inflight_per_principal_env)
213        .unwrap_or_else(|| default_max_inflight_per_principal(max_handlers));
214
215    HttpLimitsResolved {
216        max_handlers,
217        execution_workers,
218        handler_timeout_ms,
219        retry_after_secs,
220        max_inflight_per_principal,
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use std::collections::HashMap;
228
229    fn no_config() -> impl Fn(&str) -> Option<String> {
230        |_| None
231    }
232
233    fn map_lookup(map: HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
234        move |key| map.get(key).map(|v| v.to_string())
235    }
236
237    #[test]
238    fn defaults_when_nothing_set() {
239        let resolved = resolve_http_limits(&HttpLimitsCliInput::default(), no_config());
240        assert_eq!(resolved, HttpLimitsResolved::builtin_defaults());
241    }
242
243    #[test]
244    fn flag_wins_over_env_and_default() {
245        let input = HttpLimitsCliInput {
246            max_handlers_flag: Some(16),
247            max_handlers_env: Some(99),
248            execution_workers_flag: Some(4),
249            execution_workers_env: Some(12),
250            handler_timeout_ms_flag: Some(5_000),
251            handler_timeout_ms_env: Some(7_000),
252            retry_after_secs_flag: Some(3),
253            retry_after_secs_env: Some(7),
254            ..Default::default()
255        };
256        let resolved = resolve_http_limits(&input, no_config());
257        assert_eq!(resolved.max_handlers, 16);
258        assert_eq!(resolved.execution_workers, 4);
259        assert_eq!(resolved.handler_timeout_ms, 5_000);
260        assert_eq!(resolved.retry_after_secs, 3);
261    }
262
263    #[test]
264    fn flag_wins_over_red_config() {
265        let input = HttpLimitsCliInput {
266            max_handlers_flag: Some(16),
267            execution_workers_flag: Some(4),
268            handler_timeout_ms_flag: Some(5_000),
269            retry_after_secs_flag: Some(3),
270            ..Default::default()
271        };
272        let lookup = map_lookup(HashMap::from([
273            ("red.http.max_handlers", "64"),
274            ("red.http.execution_workers", "32"),
275            ("red.http.handler_timeout_ms", "9000"),
276            ("red.http.retry_after_secs", "9"),
277        ]));
278        let resolved = resolve_http_limits(&input, lookup);
279        assert_eq!(resolved.max_handlers, 16);
280        assert_eq!(resolved.execution_workers, 4);
281        assert_eq!(resolved.handler_timeout_ms, 5_000);
282        assert_eq!(resolved.retry_after_secs, 3);
283    }
284
285    #[test]
286    fn red_config_wins_over_env() {
287        let input = HttpLimitsCliInput {
288            max_handlers_env: Some(99),
289            execution_workers_env: Some(12),
290            handler_timeout_ms_env: Some(7_000),
291            retry_after_secs_env: Some(7),
292            ..Default::default()
293        };
294        let lookup = map_lookup(HashMap::from([
295            ("red.http.max_handlers", "64"),
296            ("red.http.execution_workers", "8"),
297            ("red.http.handler_timeout_ms", "9000"),
298            ("red.http.retry_after_secs", "9"),
299        ]));
300        let resolved = resolve_http_limits(&input, lookup);
301        assert_eq!(resolved.max_handlers, 64);
302        assert_eq!(resolved.execution_workers, 8);
303        assert_eq!(resolved.handler_timeout_ms, 9_000);
304        assert_eq!(resolved.retry_after_secs, 9);
305    }
306
307    #[test]
308    fn env_wins_over_default() {
309        let input = HttpLimitsCliInput {
310            max_handlers_env: Some(11),
311            execution_workers_env: Some(3),
312            handler_timeout_ms_env: Some(1_500),
313            retry_after_secs_env: Some(2),
314            ..Default::default()
315        };
316        let resolved = resolve_http_limits(&input, no_config());
317        assert_eq!(resolved.max_handlers, 11);
318        assert_eq!(resolved.execution_workers, 3);
319        assert_eq!(resolved.handler_timeout_ms, 1_500);
320        assert_eq!(resolved.retry_after_secs, 2);
321    }
322
323    #[test]
324    fn invalid_red_config_is_ignored_in_favor_of_lower_layers() {
325        // Garbage in red_config — must not break boot. Env value wins;
326        // if env is absent, default wins.
327        let input = HttpLimitsCliInput {
328            max_handlers_env: Some(11),
329            ..Default::default()
330        };
331        let lookup = map_lookup(HashMap::from([
332            ("red.http.max_handlers", "0"),        // rejected by validate
333            ("red.http.execution_workers", "0"),   // rejected by validate
334            ("red.http.handler_timeout_ms", "5"),  // rejected by validate
335            ("red.http.retry_after_secs", "9999"), // rejected by validate
336        ]));
337        let resolved = resolve_http_limits(&input, lookup);
338        // max_handlers: red_config invalid -> env (11)
339        assert_eq!(resolved.max_handlers, 11);
340        assert_eq!(
341            resolved.execution_workers,
342            HttpLimitsResolved::builtin_defaults()
343                .execution_workers
344                .min(11)
345        );
346        // handler_timeout_ms: red_config invalid, no env -> default
347        assert_eq!(resolved.handler_timeout_ms, DEFAULT_HANDLER_TIMEOUT_MS);
348        // retry_after_secs: red_config invalid, no env -> default
349        assert_eq!(resolved.retry_after_secs, DEFAULT_RETRY_AFTER_SECS);
350    }
351
352    #[test]
353    fn validators_reject_zero_equivalent_values() {
354        assert!(validate_max_handlers(0).is_err());
355        assert!(validate_max_handlers(1).is_ok());
356
357        assert!(validate_execution_workers(0).is_err());
358        assert!(validate_execution_workers(1).is_ok());
359
360        assert!(validate_handler_timeout_ms(0).is_err());
361        assert!(validate_handler_timeout_ms(MIN_HANDLER_TIMEOUT_MS - 1).is_err());
362        assert!(validate_handler_timeout_ms(MIN_HANDLER_TIMEOUT_MS).is_ok());
363
364        assert!(validate_retry_after_secs(0).is_err());
365        assert!(validate_retry_after_secs(MIN_RETRY_AFTER_SECS).is_ok());
366        assert!(validate_retry_after_secs(MAX_RETRY_AFTER_SECS).is_ok());
367        assert!(validate_retry_after_secs(MAX_RETRY_AFTER_SECS + 1).is_err());
368    }
369
370    #[test]
371    fn default_max_handlers_in_bounds() {
372        let cap = default_max_handlers();
373        assert!((8..=256).contains(&cap));
374    }
375
376    #[test]
377    fn execution_workers_are_positive_and_never_exceed_handler_envelope() {
378        let defaults = HttpLimitsResolved::builtin_defaults();
379        assert!(defaults.execution_workers >= 1);
380        assert!(defaults.execution_workers <= defaults.max_handlers);
381
382        let input = HttpLimitsCliInput {
383            max_handlers_flag: Some(2),
384            execution_workers_flag: Some(16),
385            ..Default::default()
386        };
387        assert_eq!(
388            resolve_http_limits(&input, no_config()).execution_workers,
389            2
390        );
391    }
392
393    #[test]
394    fn max_inflight_per_principal_follows_precedence_chain() {
395        // default
396        let resolved = resolve_http_limits(&HttpLimitsCliInput::default(), no_config());
397        assert_eq!(
398            resolved.max_inflight_per_principal,
399            default_max_inflight_per_principal(resolved.max_handlers)
400        );
401
402        let input = HttpLimitsCliInput {
403            max_handlers_flag: Some(2),
404            ..Default::default()
405        };
406        assert_eq!(
407            resolve_http_limits(&input, no_config()).max_inflight_per_principal,
408            1
409        );
410
411        // env over default
412        let input = HttpLimitsCliInput {
413            max_inflight_per_principal_env: Some(17),
414            ..Default::default()
415        };
416        assert_eq!(
417            resolve_http_limits(&input, no_config()).max_inflight_per_principal,
418            17
419        );
420
421        // red_config over env
422        let input = HttpLimitsCliInput {
423            max_inflight_per_principal_env: Some(17),
424            ..Default::default()
425        };
426        let lookup = map_lookup(HashMap::from([(
427            "red.http.max_inflight_per_principal",
428            "9",
429        )]));
430        assert_eq!(
431            resolve_http_limits(&input, lookup).max_inflight_per_principal,
432            9
433        );
434
435        // flag over everything
436        let input = HttpLimitsCliInput {
437            max_inflight_per_principal_flag: Some(3),
438            max_inflight_per_principal_env: Some(17),
439            ..Default::default()
440        };
441        let lookup = map_lookup(HashMap::from([(
442            "red.http.max_inflight_per_principal",
443            "9",
444        )]));
445        assert_eq!(
446            resolve_http_limits(&input, lookup).max_inflight_per_principal,
447            3
448        );
449    }
450
451    #[test]
452    fn max_inflight_per_principal_zero_disables_and_is_honored() {
453        // 0 is a legal value (disables the per-principal cap) and must
454        // survive the resolve chain rather than being treated as unset.
455        let lookup = map_lookup(HashMap::from([(
456            "red.http.max_inflight_per_principal",
457            "0",
458        )]));
459        assert_eq!(
460            resolve_http_limits(&HttpLimitsCliInput::default(), lookup).max_inflight_per_principal,
461            0
462        );
463        assert!(validate_max_inflight_per_principal(0).is_ok());
464    }
465}