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!(back.retry.backoff_multiplier, cfg.retry.backoff_multiplier);
128        assert_eq!(back.retry.max_backoff, cfg.retry.max_backoff);
129        assert_eq!(back.retry.schema_retry_max, cfg.retry.schema_retry_max);
130    }
131
132    #[test]
133    fn scheduler_config_debug_format_includes_field_names() {
134        let cfg = SchedulerConfig::default();
135        let dbg = format!("{:?}", cfg);
136        assert!(dbg.contains("max_concurrency"));
137        assert!(dbg.contains("quota_per_run"));
138        assert!(dbg.contains("retry"));
139    }
140
141    // ── RetryPolicy ──────────────────────────────────────────────
142
143    #[test]
144    fn retry_policy_default_values() {
145        let r = RetryPolicy::default();
146        assert_eq!(r.max_attempts, 2);
147        assert_eq!(r.initial_backoff, Duration::from_millis(500));
148        assert_eq!(r.backoff_multiplier, 2.0);
149        assert_eq!(r.max_backoff, Duration::from_secs(10));
150        assert_eq!(r.schema_retry_max, 3);
151    }
152
153    #[test]
154    fn retry_policy_serde_roundtrip() {
155        let r = RetryPolicy {
156            max_attempts: 4,
157            initial_backoff: Duration::from_millis(123),
158            backoff_multiplier: 3.5,
159            max_backoff: Duration::from_secs(30),
160            schema_retry_max: 0,
161        };
162        let json = serde_json::to_string(&r).unwrap();
163        let back: RetryPolicy = serde_json::from_str(&json).unwrap();
164        assert_eq!(back.max_attempts, r.max_attempts);
165        assert_eq!(back.initial_backoff, r.initial_backoff);
166        assert_eq!(back.backoff_multiplier, r.backoff_multiplier);
167        assert_eq!(back.max_backoff, r.max_backoff);
168        assert_eq!(back.schema_retry_max, r.schema_retry_max);
169    }
170
171    #[test]
172    fn backoff_attempt_1_returns_initial_backoff() {
173        let r = RetryPolicy {
174            initial_backoff: Duration::from_millis(500),
175            backoff_multiplier: 2.0,
176            max_backoff: Duration::from_secs(60),
177            ..RetryPolicy::default()
178        };
179        assert_eq!(r.backoff(1), Duration::from_millis(500));
180    }
181
182    #[test]
183    fn backoff_doubles_each_attempt() {
184        let r = RetryPolicy {
185            initial_backoff: Duration::from_millis(100),
186            backoff_multiplier: 2.0,
187            max_backoff: Duration::from_secs(60),
188            ..RetryPolicy::default()
189        };
190        assert_eq!(r.backoff(1), Duration::from_millis(100));
191        assert_eq!(r.backoff(2), Duration::from_millis(200));
192        assert_eq!(r.backoff(3), Duration::from_millis(400));
193        assert_eq!(r.backoff(4), Duration::from_millis(800));
194        assert_eq!(r.backoff(5), Duration::from_millis(1600));
195    }
196
197    #[test]
198    fn backoff_capped_at_max_backoff() {
199        let r = RetryPolicy {
200            initial_backoff: Duration::from_millis(500),
201            backoff_multiplier: 2.0,
202            max_backoff: Duration::from_secs(1),
203            ..RetryPolicy::default()
204        };
205        // 500ms, 1s, 2s, 4s — but the cap is 1s, so the 2nd attempt is the cap.
206        assert_eq!(r.backoff(1), Duration::from_millis(500));
207        assert_eq!(r.backoff(2), Duration::from_secs(1));
208        assert_eq!(r.backoff(10), Duration::from_secs(1));
209        // u32::MAX would overflow the i32 exponent inside backoff(), so we
210        // don't drive it that high. The cap is still exercised by attempt=10.
211        assert_eq!(r.backoff(60), Duration::from_secs(1));
212    }
213
214    #[test]
215    fn backoff_attempt_0_treated_as_attempt_1() {
216        // 1-based attempt: attempt 0 saturates to attempt 1 (no underflow panic).
217        let r = RetryPolicy {
218            initial_backoff: Duration::from_millis(750),
219            backoff_multiplier: 2.0,
220            max_backoff: Duration::from_secs(60),
221            ..RetryPolicy::default()
222        };
223        assert_eq!(r.backoff(0), r.backoff(1));
224        assert_eq!(r.backoff(0), Duration::from_millis(750));
225    }
226
227    #[test]
228    fn backoff_fractional_multiplier() {
229        let r = RetryPolicy {
230            initial_backoff: Duration::from_millis(1000),
231            backoff_multiplier: 1.5,
232            max_backoff: Duration::from_secs(60),
233            ..RetryPolicy::default()
234        };
235        let b1 = r.backoff(1);
236        let b2 = r.backoff(2);
237        let b3 = r.backoff(3);
238        // 1000ms, 1500ms, 2250ms — strictly increasing, monotonically.
239        assert_eq!(b1, Duration::from_millis(1000));
240        assert_eq!(b2, Duration::from_millis(1500));
241        assert_eq!(b3, Duration::from_millis(2250));
242        assert!(b1 < b2);
243        assert!(b2 < b3);
244    }
245
246    #[test]
247    fn backoff_never_exceeds_max_backoff_even_with_large_multiplier() {
248        let r = RetryPolicy {
249            initial_backoff: Duration::from_millis(10),
250            backoff_multiplier: 100.0,
251            max_backoff: Duration::from_secs(2),
252            ..RetryPolicy::default()
253        };
254        for attempt in 1..20 {
255            let b = r.backoff(attempt);
256            assert!(
257                b <= r.max_backoff,
258                "backoff for attempt {} ({:?}) exceeded cap {:?}",
259                attempt,
260                b,
261                r.max_backoff
262            );
263        }
264    }
265
266    #[test]
267    fn backoff_zero_initial_returns_zero() {
268        let r = RetryPolicy {
269            initial_backoff: Duration::ZERO,
270            backoff_multiplier: 2.0,
271            max_backoff: Duration::from_secs(60),
272            ..RetryPolicy::default()
273        };
274        assert_eq!(r.backoff(1), Duration::ZERO);
275        assert_eq!(r.backoff(5), Duration::ZERO);
276    }
277
278    #[test]
279    fn backoff_multiplier_one_keeps_constant() {
280        let r = RetryPolicy {
281            initial_backoff: Duration::from_millis(250),
282            backoff_multiplier: 1.0,
283            max_backoff: Duration::from_secs(60),
284            ..RetryPolicy::default()
285        };
286        let b1 = r.backoff(1);
287        for attempt in 2..=10 {
288            assert_eq!(r.backoff(attempt), b1);
289        }
290    }
291
292    #[test]
293    fn retry_policy_clone_preserves_fields() {
294        let r = RetryPolicy {
295            max_attempts: 7,
296            initial_backoff: Duration::from_millis(123),
297            backoff_multiplier: 1.25,
298            max_backoff: Duration::from_secs(45),
299            schema_retry_max: 0,
300        };
301        let cloned = r.clone();
302        assert_eq!(cloned.max_attempts, r.max_attempts);
303        assert_eq!(cloned.initial_backoff, r.initial_backoff);
304        assert_eq!(cloned.backoff_multiplier, r.backoff_multiplier);
305        assert_eq!(cloned.max_backoff, r.max_backoff);
306        assert_eq!(cloned.schema_retry_max, r.schema_retry_max);
307    }
308
309    #[test]
310    fn retry_policy_debug_format() {
311        let r = RetryPolicy::default();
312        let dbg = format!("{:?}", r);
313        assert!(dbg.contains("max_attempts"));
314        assert!(dbg.contains("initial_backoff"));
315        assert!(dbg.contains("backoff_multiplier"));
316        assert!(dbg.contains("max_backoff"));
317        assert!(dbg.contains("schema_retry_max"));
318    }
319}