Skip to main content

luft_core/scheduler/
config.rs

1//! Scheduler configuration (§2.2).
2
3use serde::{Deserialize, Serialize};
4use std::time::Duration;
5
6/// Adaptive default concurrency: 2× available cores, clamped to [4, 16].
7fn default_concurrency() -> usize {
8    std::thread::available_parallelism()
9        .map(|n| n.get() * 2)
10        .unwrap_or(8)
11        .clamp(4, 16)
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SchedulerConfig {
16    /// Semaphore permits (global concurrency ceiling).
17    pub max_concurrency: usize,
18    /// Per-run agent total ceiling (guards against runaway fan-out).
19    pub quota_per_run: u32,
20    pub retry: RetryPolicy,
21}
22
23impl Default for SchedulerConfig {
24    fn default() -> Self {
25        Self {
26            max_concurrency: default_concurrency(),
27            quota_per_run: 1000,
28            retry: RetryPolicy::default(),
29        }
30    }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RetryPolicy {
35    /// Max retries (0 = no retry).
36    pub max_attempts: u32,
37    pub initial_backoff: Duration,
38    pub backoff_multiplier: f64,
39    pub max_backoff: Duration,
40    /// Max schema validation retries before giving up (0 = no retry).
41    pub schema_retry_max: u32,
42}
43
44impl Default for RetryPolicy {
45    fn default() -> Self {
46        Self {
47            max_attempts: 2,
48            initial_backoff: Duration::from_millis(500),
49            backoff_multiplier: 2.0,
50            max_backoff: Duration::from_secs(10),
51            schema_retry_max: 3,
52        }
53    }
54}
55
56impl RetryPolicy {
57    /// Exponential backoff for the given retry attempt (1-based), capped.
58    /// Cancellation is checked by the caller while sleeping.
59    pub fn backoff(&self, attempt: u32) -> Duration {
60        let exp = attempt.saturating_sub(1) as i32;
61        let secs = self.initial_backoff.as_secs_f64() * self.backoff_multiplier.powi(exp);
62        Duration::from_secs_f64(secs.min(self.max_backoff.as_secs_f64()))
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    // ── SchedulerConfig ──────────────────────────────────────────
71
72    #[test]
73    fn scheduler_config_default_is_in_adaptive_range() {
74        let cfg = SchedulerConfig::default();
75        assert!(
76            (4..=16).contains(&cfg.max_concurrency),
77            "default max_concurrency {} outside adaptive [4,16]",
78            cfg.max_concurrency
79        );
80        assert_eq!(cfg.quota_per_run, 1000);
81        // Retry policy defaults are the canonical "2 tries, half a second".
82        assert_eq!(cfg.retry.max_attempts, 2);
83        assert_eq!(cfg.retry.initial_backoff, Duration::from_millis(500));
84        assert_eq!(cfg.retry.schema_retry_max, 3);
85    }
86
87    #[test]
88    fn scheduler_config_clone_preserves_fields() {
89        let cfg = SchedulerConfig {
90            max_concurrency: 32,
91            quota_per_run: 250,
92            retry: RetryPolicy {
93                max_attempts: 5,
94                initial_backoff: Duration::from_millis(100),
95                backoff_multiplier: 1.5,
96                max_backoff: Duration::from_secs(20),
97                schema_retry_max: 1,
98            },
99        };
100        let cloned = cfg.clone();
101        assert_eq!(cloned.max_concurrency, 32);
102        assert_eq!(cloned.quota_per_run, 250);
103        assert_eq!(cloned.retry.max_attempts, 5);
104        assert_eq!(cloned.retry.initial_backoff, Duration::from_millis(100));
105        assert_eq!(cloned.retry.schema_retry_max, 1);
106    }
107
108    #[test]
109    fn scheduler_config_serde_roundtrip() {
110        let cfg = SchedulerConfig {
111            max_concurrency: 8,
112            quota_per_run: 100,
113            retry: RetryPolicy {
114                max_attempts: 3,
115                initial_backoff: Duration::from_millis(250),
116                backoff_multiplier: 1.7,
117                max_backoff: Duration::from_secs(5),
118                schema_retry_max: 2,
119            },
120        };
121        let json = serde_json::to_string(&cfg).unwrap();
122        let back: SchedulerConfig = serde_json::from_str(&json).unwrap();
123        assert_eq!(back.max_concurrency, cfg.max_concurrency);
124        assert_eq!(back.quota_per_run, cfg.quota_per_run);
125        assert_eq!(back.retry.max_attempts, cfg.retry.max_attempts);
126        assert_eq!(back.retry.initial_backoff, cfg.retry.initial_backoff);
127        assert_eq!(
128            back.retry.backoff_multiplier,
129            cfg.retry.backoff_multiplier
130        );
131        assert_eq!(back.retry.max_backoff, cfg.retry.max_backoff);
132        assert_eq!(back.retry.schema_retry_max, cfg.retry.schema_retry_max);
133    }
134
135    #[test]
136    fn scheduler_config_debug_format_includes_field_names() {
137        let cfg = SchedulerConfig::default();
138        let dbg = format!("{:?}", cfg);
139        assert!(dbg.contains("max_concurrency"));
140        assert!(dbg.contains("quota_per_run"));
141        assert!(dbg.contains("retry"));
142    }
143
144    // ── RetryPolicy ──────────────────────────────────────────────
145
146    #[test]
147    fn retry_policy_default_values() {
148        let r = RetryPolicy::default();
149        assert_eq!(r.max_attempts, 2);
150        assert_eq!(r.initial_backoff, Duration::from_millis(500));
151        assert_eq!(r.backoff_multiplier, 2.0);
152        assert_eq!(r.max_backoff, Duration::from_secs(10));
153        assert_eq!(r.schema_retry_max, 3);
154    }
155
156    #[test]
157    fn retry_policy_serde_roundtrip() {
158        let r = RetryPolicy {
159            max_attempts: 4,
160            initial_backoff: Duration::from_millis(123),
161            backoff_multiplier: 3.5,
162            max_backoff: Duration::from_secs(30),
163            schema_retry_max: 0,
164        };
165        let json = serde_json::to_string(&r).unwrap();
166        let back: RetryPolicy = serde_json::from_str(&json).unwrap();
167        assert_eq!(back.max_attempts, r.max_attempts);
168        assert_eq!(back.initial_backoff, r.initial_backoff);
169        assert_eq!(back.backoff_multiplier, r.backoff_multiplier);
170        assert_eq!(back.max_backoff, r.max_backoff);
171        assert_eq!(back.schema_retry_max, r.schema_retry_max);
172    }
173
174    #[test]
175    fn backoff_attempt_1_returns_initial_backoff() {
176        let r = RetryPolicy {
177            initial_backoff: Duration::from_millis(500),
178            backoff_multiplier: 2.0,
179            max_backoff: Duration::from_secs(60),
180            ..RetryPolicy::default()
181        };
182        assert_eq!(r.backoff(1), Duration::from_millis(500));
183    }
184
185    #[test]
186    fn backoff_doubles_each_attempt() {
187        let r = RetryPolicy {
188            initial_backoff: Duration::from_millis(100),
189            backoff_multiplier: 2.0,
190            max_backoff: Duration::from_secs(60),
191            ..RetryPolicy::default()
192        };
193        assert_eq!(r.backoff(1), Duration::from_millis(100));
194        assert_eq!(r.backoff(2), Duration::from_millis(200));
195        assert_eq!(r.backoff(3), Duration::from_millis(400));
196        assert_eq!(r.backoff(4), Duration::from_millis(800));
197        assert_eq!(r.backoff(5), Duration::from_millis(1600));
198    }
199
200    #[test]
201    fn backoff_capped_at_max_backoff() {
202        let r = RetryPolicy {
203            initial_backoff: Duration::from_millis(500),
204            backoff_multiplier: 2.0,
205            max_backoff: Duration::from_secs(1),
206            ..RetryPolicy::default()
207        };
208        // 500ms, 1s, 2s, 4s — but the cap is 1s, so the 2nd attempt is the cap.
209        assert_eq!(r.backoff(1), Duration::from_millis(500));
210        assert_eq!(r.backoff(2), Duration::from_secs(1));
211        assert_eq!(r.backoff(10), Duration::from_secs(1));
212        // u32::MAX would overflow the i32 exponent inside backoff(), so we
213        // don't drive it that high. The cap is still exercised by attempt=10.
214        assert_eq!(r.backoff(60), Duration::from_secs(1));
215    }
216
217    #[test]
218    fn backoff_attempt_0_treated_as_attempt_1() {
219        // 1-based attempt: attempt 0 saturates to attempt 1 (no underflow panic).
220        let r = RetryPolicy {
221            initial_backoff: Duration::from_millis(750),
222            backoff_multiplier: 2.0,
223            max_backoff: Duration::from_secs(60),
224            ..RetryPolicy::default()
225        };
226        assert_eq!(r.backoff(0), r.backoff(1));
227        assert_eq!(r.backoff(0), Duration::from_millis(750));
228    }
229
230    #[test]
231    fn backoff_fractional_multiplier() {
232        let r = RetryPolicy {
233            initial_backoff: Duration::from_millis(1000),
234            backoff_multiplier: 1.5,
235            max_backoff: Duration::from_secs(60),
236            ..RetryPolicy::default()
237        };
238        let b1 = r.backoff(1);
239        let b2 = r.backoff(2);
240        let b3 = r.backoff(3);
241        // 1000ms, 1500ms, 2250ms — strictly increasing, monotonically.
242        assert_eq!(b1, Duration::from_millis(1000));
243        assert_eq!(b2, Duration::from_millis(1500));
244        assert_eq!(b3, Duration::from_millis(2250));
245        assert!(b1 < b2);
246        assert!(b2 < b3);
247    }
248
249    #[test]
250    fn backoff_never_exceeds_max_backoff_even_with_large_multiplier() {
251        let r = RetryPolicy {
252            initial_backoff: Duration::from_millis(10),
253            backoff_multiplier: 100.0,
254            max_backoff: Duration::from_secs(2),
255            ..RetryPolicy::default()
256        };
257        for attempt in 1..20 {
258            let b = r.backoff(attempt);
259            assert!(
260                b <= r.max_backoff,
261                "backoff for attempt {} ({:?}) exceeded cap {:?}",
262                attempt,
263                b,
264                r.max_backoff
265            );
266        }
267    }
268
269    #[test]
270    fn backoff_zero_initial_returns_zero() {
271        let r = RetryPolicy {
272            initial_backoff: Duration::ZERO,
273            backoff_multiplier: 2.0,
274            max_backoff: Duration::from_secs(60),
275            ..RetryPolicy::default()
276        };
277        assert_eq!(r.backoff(1), Duration::ZERO);
278        assert_eq!(r.backoff(5), Duration::ZERO);
279    }
280
281    #[test]
282    fn backoff_multiplier_one_keeps_constant() {
283        let r = RetryPolicy {
284            initial_backoff: Duration::from_millis(250),
285            backoff_multiplier: 1.0,
286            max_backoff: Duration::from_secs(60),
287            ..RetryPolicy::default()
288        };
289        let b1 = r.backoff(1);
290        for attempt in 2..=10 {
291            assert_eq!(r.backoff(attempt), b1);
292        }
293    }
294
295    #[test]
296    fn retry_policy_clone_preserves_fields() {
297        let r = RetryPolicy {
298            max_attempts: 7,
299            initial_backoff: Duration::from_millis(123),
300            backoff_multiplier: 1.25,
301            max_backoff: Duration::from_secs(45),
302            schema_retry_max: 0,
303        };
304        let cloned = r.clone();
305        assert_eq!(cloned.max_attempts, r.max_attempts);
306        assert_eq!(cloned.initial_backoff, r.initial_backoff);
307        assert_eq!(cloned.backoff_multiplier, r.backoff_multiplier);
308        assert_eq!(cloned.max_backoff, r.max_backoff);
309        assert_eq!(cloned.schema_retry_max, r.schema_retry_max);
310    }
311
312    #[test]
313    fn retry_policy_debug_format() {
314        let r = RetryPolicy::default();
315        let dbg = format!("{:?}", r);
316        assert!(dbg.contains("max_attempts"));
317        assert!(dbg.contains("initial_backoff"));
318        assert!(dbg.contains("backoff_multiplier"));
319        assert!(dbg.contains("max_backoff"));
320        assert!(dbg.contains("schema_retry_max"));
321    }
322}