1use serde::{Deserialize, Serialize};
4use std::time::Duration;
5
6fn 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 pub max_concurrency: usize,
18 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 pub max_attempts: u32,
37 pub initial_backoff: Duration,
38 pub backoff_multiplier: f64,
39 pub max_backoff: Duration,
40 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 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 #[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 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 #[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 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 assert_eq!(r.backoff(60), Duration::from_secs(1));
215 }
216
217 #[test]
218 fn backoff_attempt_0_treated_as_attempt_1() {
219 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 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}