1use crate::error::FaucetError;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::VecDeque;
10use std::time::Duration;
11
12fn default_controller() -> String {
13 "aimd".to_string()
14}
15fn default_min() -> usize {
16 100
17}
18fn default_max() -> usize {
19 50_000
20}
21fn default_increase_step() -> usize {
22 250
23}
24fn default_decrease_factor() -> f64 {
25 0.5
26}
27fn default_cooldown_batches() -> usize {
28 5
29}
30fn default_latency_window() -> usize {
31 10
32}
33fn default_error_threshold() -> f64 {
34 0.01
35}
36fn default_true() -> bool {
37 true
38}
39fn default_log_every() -> usize {
40 50
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
47pub struct AdaptiveBatchConfig {
48 #[serde(default)]
50 pub enabled: bool,
51 #[serde(default = "default_controller")]
53 pub controller: String,
54 #[serde(default = "default_min")]
56 pub min: usize,
57 #[serde(default = "default_max")]
60 pub max: usize,
61 #[serde(default = "default_increase_step")]
63 pub increase_step: usize,
64 #[serde(default = "default_decrease_factor")]
66 pub decrease_factor: f64,
67 #[serde(default = "default_cooldown_batches")]
69 pub cooldown_batches: usize,
70 #[serde(default)]
72 pub target_latency_ms: Option<u64>,
73 #[serde(default = "default_latency_window")]
75 pub latency_window: usize,
76 #[serde(default = "default_error_threshold")]
78 pub error_threshold: f64,
79 #[serde(default = "default_true")]
86 pub respect_source_max: bool,
87 #[serde(default = "default_log_every")]
89 pub log_every: usize,
90}
91
92impl AdaptiveBatchConfig {
93 pub fn validate(&self) -> Result<(), FaucetError> {
95 if self.controller != "aimd" {
96 return Err(FaucetError::Config(format!(
97 "adaptive_batch_size.controller '{}' is not supported (only 'aimd')",
98 self.controller
99 )));
100 }
101 if self.min < 1 {
102 return Err(FaucetError::Config(
103 "adaptive_batch_size.min must be >= 1".into(),
104 ));
105 }
106 if self.min > self.max {
107 return Err(FaucetError::Config(format!(
108 "adaptive_batch_size.min ({}) must be <= max ({})",
109 self.min, self.max
110 )));
111 }
112 if self.max > crate::MAX_BATCH_SIZE {
113 return Err(FaucetError::Config(format!(
114 "adaptive_batch_size.max ({}) must be <= {} (MAX_BATCH_SIZE)",
115 self.max,
116 crate::MAX_BATCH_SIZE
117 )));
118 }
119 if self.increase_step > crate::MAX_BATCH_SIZE {
120 return Err(FaucetError::Config(format!(
121 "adaptive_batch_size.increase_step ({}) must be <= {} (MAX_BATCH_SIZE)",
122 self.increase_step,
123 crate::MAX_BATCH_SIZE
124 )));
125 }
126 if !(self.decrease_factor > 0.0 && self.decrease_factor < 1.0) {
127 return Err(FaucetError::Config(
128 "adaptive_batch_size.decrease_factor must be in (0, 1)".into(),
129 ));
130 }
131 if self.increase_step < 1 {
132 return Err(FaucetError::Config(
133 "adaptive_batch_size.increase_step must be >= 1".into(),
134 ));
135 }
136 if !(0.0..=1.0).contains(&self.error_threshold) {
137 return Err(FaucetError::Config(
138 "adaptive_batch_size.error_threshold must be in [0, 1]".into(),
139 ));
140 }
141 if self.latency_window < 1 {
142 return Err(FaucetError::Config(
143 "adaptive_batch_size.latency_window must be >= 1".into(),
144 ));
145 }
146 if let Some(t) = self.target_latency_ms
147 && t == 0
148 {
149 return Err(FaucetError::Config(
150 "adaptive_batch_size.target_latency_ms must be > 0 when set".into(),
151 ));
152 }
153 if !self.respect_source_max {
154 return Err(FaucetError::Config(
155 "adaptive_batch_size.respect_source_max=false is not supported \
156 (cross-page buffering would violate the O(batch_size) memory \
157 guarantee); remove the field or set it to true"
158 .into(),
159 ));
160 }
161 Ok(())
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum AdjustDirection {
168 Up,
169 Down,
170}
171impl AdjustDirection {
172 pub fn as_str(&self) -> &'static str {
173 match self {
174 AdjustDirection::Up => "up",
175 AdjustDirection::Down => "down",
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum AdjustReason {
183 Success,
184 Error,
185 Latency,
186}
187impl AdjustReason {
188 pub fn as_str(&self) -> &'static str {
189 match self {
190 AdjustReason::Success => "success",
191 AdjustReason::Error => "error",
192 AdjustReason::Latency => "latency",
193 }
194 }
195}
196
197#[derive(Debug, Clone, Copy)]
199pub struct Observation {
200 pub batch_len: usize,
201 pub errors: usize,
202 pub latency: Duration,
203}
204
205#[derive(Debug, Clone, Copy)]
207pub struct Adjustment {
208 pub new_size: usize,
209 pub direction: AdjustDirection,
210 pub reason: AdjustReason,
211}
212
213pub struct AimdController {
215 min: usize,
216 max: usize,
217 increase_step: usize,
218 decrease_factor: f64,
219 cooldown_batches: usize,
220 target_latency_ms: Option<u64>,
221 latency_window: usize,
222 error_threshold: f64,
223 log_every: usize,
224
225 current: usize,
226 cooldown: usize,
227 latencies: VecDeque<u64>,
228 floor_warned: bool,
229 adjustments: u64,
230}
231
232impl AimdController {
233 pub fn new(cfg: &AdaptiveBatchConfig, initial: usize) -> Self {
236 Self {
237 min: cfg.min,
238 max: cfg.max,
239 increase_step: cfg.increase_step,
240 decrease_factor: cfg.decrease_factor,
241 cooldown_batches: cfg.cooldown_batches,
242 target_latency_ms: cfg.target_latency_ms,
243 latency_window: cfg.latency_window.max(1),
244 error_threshold: cfg.error_threshold,
245 log_every: cfg.log_every,
246 current: initial.clamp(cfg.min, cfg.max),
247 cooldown: 0,
248 latencies: VecDeque::new(),
249 floor_warned: false,
250 adjustments: 0,
251 }
252 }
253
254 pub fn current(&self) -> usize {
255 self.current
256 }
257 pub fn cooldown_active(&self) -> bool {
258 self.cooldown > 0
259 }
260
261 pub fn p50_latency_ms(&self) -> Option<u64> {
263 if self.latencies.is_empty() {
264 return None;
265 }
266 let mut v: Vec<u64> = self.latencies.iter().copied().collect();
267 v.sort_unstable();
268 Some(v[v.len() / 2])
269 }
270
271 pub fn observe(&mut self, obs: Observation) -> Option<Adjustment> {
275 if obs.batch_len > 0 {
277 let rate = obs.errors as f64 / obs.batch_len as f64;
278 if rate > self.error_threshold {
279 return self.shrink(AdjustReason::Error);
280 }
281 }
282 if self.cooldown > 0 {
284 self.cooldown -= 1;
285 return None;
286 }
287 if let Some(target) = self.target_latency_ms {
289 self.latencies.push_back(obs.latency.as_millis() as u64);
290 while self.latencies.len() > self.latency_window {
291 self.latencies.pop_front();
292 }
293 let p50 = self.p50_latency_ms().unwrap_or(0) as f64;
294 let t = target as f64;
295 if p50 > t * 1.2 {
296 return self.shrink(AdjustReason::Latency);
297 } else if p50 < t * 0.5 {
298 return self.grow(AdjustReason::Latency);
299 }
300 return None;
301 }
302 self.grow(AdjustReason::Success)
304 }
305
306 fn shrink(&mut self, reason: AdjustReason) -> Option<Adjustment> {
307 let new = ((self.current as f64 * self.decrease_factor).floor() as usize).max(self.min);
308 self.cooldown = self.cooldown_batches;
309 if new == self.current {
310 if reason == AdjustReason::Error && self.current == self.min && !self.floor_warned {
311 tracing::warn!(
312 batch_size = self.current,
313 "adaptive batch size at floor (min) and still seeing errors; \
314 consider lowering `min` or investigating the sink"
315 );
316 self.floor_warned = true;
317 }
318 return None;
319 }
320 self.current = new;
321 self.latencies.clear();
322 self.bump_log(AdjustDirection::Down, reason);
323 Some(Adjustment {
324 new_size: new,
325 direction: AdjustDirection::Down,
326 reason,
327 })
328 }
329
330 fn grow(&mut self, reason: AdjustReason) -> Option<Adjustment> {
331 let new = self
334 .current
335 .saturating_add(self.increase_step)
336 .min(self.max);
337 if new == self.current {
338 return None;
339 }
340 self.current = new;
341 self.latencies.clear();
342 self.bump_log(AdjustDirection::Up, reason);
343 Some(Adjustment {
344 new_size: new,
345 direction: AdjustDirection::Up,
346 reason,
347 })
348 }
349
350 fn bump_log(&mut self, direction: AdjustDirection, reason: AdjustReason) {
351 self.adjustments += 1;
352 if self.log_every > 0 && self.adjustments.is_multiple_of(self.log_every as u64) {
353 tracing::info!(
354 current = self.current,
355 direction = direction.as_str(),
356 reason = reason.as_str(),
357 adjustments = self.adjustments,
358 "adaptive batch size adjusted"
359 );
360 }
361 }
362}
363
364#[cfg(test)]
365mod config_tests {
366 use super::*;
367
368 fn valid() -> AdaptiveBatchConfig {
369 serde_json::from_value(serde_json::json!({"enabled": true})).unwrap()
370 }
371
372 #[test]
373 fn defaults_are_sane_and_valid() {
374 let c = valid();
375 assert_eq!(c.controller, "aimd");
376 assert_eq!(c.min, 100);
377 assert_eq!(c.max, 50_000);
378 assert!(c.respect_source_max);
379 assert!(c.target_latency_ms.is_none());
380 c.validate().unwrap();
381 }
382
383 #[test]
384 fn rejects_respect_source_max_false() {
385 let mut c = valid();
389 c.respect_source_max = false;
390 assert!(c.validate().is_err());
391 }
392
393 #[test]
394 fn rejects_unknown_controller() {
395 let mut c = valid();
396 c.controller = "pid".into();
397 assert!(c.validate().is_err());
398 }
399
400 #[test]
401 fn rejects_min_gt_max_and_zero_min() {
402 let mut c = valid();
403 c.min = 10;
404 c.max = 5;
405 assert!(c.validate().is_err());
406 let mut c = valid();
407 c.min = 0;
408 assert!(c.validate().is_err());
409 }
410
411 #[test]
412 fn rejects_max_and_increase_step_above_max_batch_size() {
413 let mut c = valid();
414 c.max = crate::MAX_BATCH_SIZE + 1;
415 assert!(c.validate().is_err());
416 let mut c = valid();
417 c.increase_step = crate::MAX_BATCH_SIZE + 1;
418 assert!(c.validate().is_err());
419 let mut c = valid();
421 c.max = crate::MAX_BATCH_SIZE;
422 c.validate().unwrap();
423 }
424
425 #[test]
426 fn rejects_out_of_range_factors() {
427 let mut c = valid();
428 c.decrease_factor = 1.5;
429 assert!(c.validate().is_err());
430 let mut c = valid();
431 c.error_threshold = 2.0;
432 assert!(c.validate().is_err());
433 let mut c = valid();
434 c.increase_step = 0;
435 assert!(c.validate().is_err());
436 let mut c = valid();
437 c.target_latency_ms = Some(0);
438 assert!(c.validate().is_err());
439 let mut c = valid();
441 c.decrease_factor = 0.0;
442 assert!(c.validate().is_err());
443 let mut c = valid();
444 c.decrease_factor = 1.0;
445 assert!(c.validate().is_err());
446 let mut c = valid();
448 c.latency_window = 0;
449 assert!(c.validate().is_err());
450 }
451}
452
453#[cfg(test)]
454mod controller_tests {
455 use super::*;
456 use std::time::Duration;
457
458 fn cfg() -> AdaptiveBatchConfig {
459 serde_json::from_value(serde_json::json!({
460 "enabled": true, "min": 100, "max": 1000,
461 "increase_step": 100, "decrease_factor": 0.5,
462 "cooldown_batches": 2, "error_threshold": 0.1
463 }))
464 .unwrap()
465 }
466
467 fn ok(len: usize) -> Observation {
468 Observation {
469 batch_len: len,
470 errors: 0,
471 latency: Duration::from_millis(1),
472 }
473 }
474
475 #[test]
476 fn cold_start_clamps_initial_to_bounds() {
477 let c = AimdController::new(&cfg(), 50); assert_eq!(c.current(), 100);
479 let c = AimdController::new(&cfg(), 99_999); assert_eq!(c.current(), 1000);
481 let c = AimdController::new(&cfg(), 500);
482 assert_eq!(c.current(), 500);
483 }
484
485 #[test]
486 fn grow_saturates_instead_of_overflowing_usize() {
487 let cfg: AdaptiveBatchConfig = serde_json::from_value(serde_json::json!({
491 "enabled": true, "min": 1, "max": usize::MAX,
492 "increase_step": usize::MAX, "decrease_factor": 0.5
493 }))
494 .unwrap();
495 let mut c = AimdController::new(&cfg, 1);
496 let adj = c.observe(ok(1)).expect("growth should occur");
497 assert_eq!(adj.new_size, usize::MAX);
498 assert_eq!(c.current(), usize::MAX);
499 }
500
501 #[test]
502 fn grows_additively_on_success_up_to_max() {
503 let mut c = AimdController::new(&cfg(), 800);
504 let a = c.observe(ok(800)).unwrap();
505 assert_eq!(a.new_size, 900);
506 assert_eq!(a.direction, AdjustDirection::Up);
507 assert_eq!(a.reason, AdjustReason::Success);
508 c.observe(ok(900)); assert_eq!(c.current(), 1000);
510 assert!(c.observe(ok(1000)).is_none());
512 assert_eq!(c.current(), 1000);
513 }
514
515 #[test]
516 fn shrinks_multiplicatively_on_error_and_arms_cooldown() {
517 let mut c = AimdController::new(&cfg(), 800);
518 let a = c
519 .observe(Observation {
520 batch_len: 100,
521 errors: 20,
522 latency: Duration::from_millis(1),
523 })
524 .unwrap();
525 assert_eq!(a.new_size, 400); assert_eq!(a.direction, AdjustDirection::Down);
527 assert_eq!(a.reason, AdjustReason::Error);
528 assert!(c.cooldown_active());
529 assert!(c.observe(ok(400)).is_none());
531 assert!(c.observe(ok(400)).is_none());
532 let a = c.observe(ok(400)).unwrap();
534 assert_eq!(a.new_size, 500);
535 }
536
537 #[test]
538 fn does_not_shrink_below_min_and_warns_once() {
539 let mut c = AimdController::new(&cfg(), 100); let bad = Observation {
541 batch_len: 100,
542 errors: 100,
543 latency: Duration::from_millis(1),
544 };
545 assert!(c.observe(bad).is_none());
547 assert_eq!(c.current(), 100);
548 }
549
550 #[test]
551 fn latency_target_shrinks_when_slow_grows_when_fast() {
552 let mut c: AimdController = AimdController::new(
553 &serde_json::from_value(serde_json::json!({
554 "enabled": true, "min": 100, "max": 1000, "increase_step": 100,
555 "decrease_factor": 0.5, "cooldown_batches": 0,
556 "target_latency_ms": 500, "latency_window": 1
557 }))
558 .unwrap(),
559 800,
560 );
561 let a = c
563 .observe(Observation {
564 batch_len: 800,
565 errors: 0,
566 latency: Duration::from_millis(700),
567 })
568 .unwrap();
569 assert_eq!(a.reason, AdjustReason::Latency);
570 assert_eq!(a.direction, AdjustDirection::Down);
571 assert_eq!(c.current(), 400);
572 let a = c
574 .observe(Observation {
575 batch_len: 400,
576 errors: 0,
577 latency: Duration::from_millis(100),
578 })
579 .unwrap();
580 assert_eq!(a.direction, AdjustDirection::Up);
581 assert_eq!(a.reason, AdjustReason::Latency);
582 assert_eq!(c.current(), 500);
583 assert!(
585 c.observe(Observation {
586 batch_len: 500,
587 errors: 0,
588 latency: Duration::from_millis(500)
589 })
590 .is_none()
591 );
592 }
593
594 #[test]
595 fn error_during_cooldown_reshrinks_and_rearms() {
596 let mut c = AimdController::new(&cfg(), 800);
599 let bad = Observation {
600 batch_len: 100,
601 errors: 50,
602 latency: Duration::from_millis(1),
603 };
604 let a = c.observe(bad).unwrap(); assert_eq!(a.new_size, 400);
606 assert!(c.cooldown_active());
607 let a = c.observe(bad).unwrap();
609 assert_eq!(a.new_size, 200);
610 assert_eq!(a.reason, AdjustReason::Error);
611 assert!(c.cooldown_active());
612 }
613
614 #[test]
615 fn p50_uses_median_of_multi_sample_window() {
616 let mut c = AimdController::new(
617 &serde_json::from_value(serde_json::json!({
618 "enabled": true, "min": 100, "max": 1000, "increase_step": 100,
619 "decrease_factor": 0.5, "cooldown_batches": 0,
620 "target_latency_ms": 500, "latency_window": 5
621 }))
622 .unwrap(),
623 800,
624 );
625 for _ in 0..5 {
627 c.observe(Observation {
628 batch_len: 800,
629 errors: 0,
630 latency: Duration::from_millis(10),
631 });
632 }
633 assert_eq!(c.p50_latency_ms(), Some(10));
634 c.observe(Observation {
637 batch_len: 800,
638 errors: 0,
639 latency: Duration::from_millis(900),
640 });
641 assert_eq!(c.p50_latency_ms(), Some(10));
642 }
643
644 #[test]
645 fn an_adjustment_starts_a_fresh_latency_window() {
646 let cfg: AdaptiveBatchConfig = serde_json::from_value(serde_json::json!({
647 "enabled": true, "min": 1, "max": 1000, "target_latency_ms": 100,
648 "latency_window": 5, "cooldown_batches": 0
649 }))
650 .unwrap();
651 let mut c = AimdController::new(&cfg, 1000);
652 let obs = |ms| Observation {
653 batch_len: 10,
654 errors: 0,
655 latency: std::time::Duration::from_millis(ms),
656 };
657 assert!(c.observe(obs(500)).is_some());
658 assert_eq!(c.current(), 500);
659 assert!(
660 c.observe(obs(100)).is_none(),
661 "the stale slow sample is gone"
662 );
663 assert_eq!(c.current(), 500);
664 }
665}