1use serde::{Deserialize, Serialize};
4use std::time::Duration;
5
6fn default_concurrency() -> usize {
13 1
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SchedulerConfig {
18 pub max_concurrency: usize,
20 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 pub max_attempts: u32,
39 pub initial_backoff: Duration,
40 pub backoff_multiplier: f64,
41 pub max_backoff: Duration,
42 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 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 #[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 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 #[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 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 assert_eq!(r.backoff(60), Duration::from_secs(1));
210 }
211
212 #[test]
213 fn backoff_attempt_0_treated_as_attempt_1() {
214 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 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}