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