1use futures_util::stream::{self, FuturesUnordered, StreamExt};
51use serde::{Deserialize, Serialize};
52use std::collections::VecDeque;
53#[cfg(feature = "native")]
54use std::path::{Path, PathBuf};
55#[cfg(feature = "native")]
56use std::sync::atomic::{AtomicU64, Ordering};
57use std::sync::{Arc, Mutex, PoisonError};
58use std::time::Duration;
59use tracing::debug;
60#[cfg(feature = "native")]
61use tracing::warn;
62use web_time::Instant;
63
64#[cfg(feature = "native")]
68static SAVE_COUNTER: AtomicU64 = AtomicU64::new(0);
69
70const FETCH_COLD_START_CONCURRENCY: usize = 4;
74
75const HILL_PROBE_STEP_DIVISOR: usize = 4;
77
78const HILL_MIN_PROBE_STEP: usize = 1;
80
81const HILL_UP_PROBE_ACCEPT_RATIO: f64 = 1.05;
83
84const HILL_DOWN_PROBE_ACCEPT_RATIO: f64 = 0.98;
86
87const HILL_REJECT_COOLDOWN_EPOCHS: usize = 2;
89
90const HILL_STABLE_PROBE_EPOCHS: usize = 3;
93
94const HILL_STRESS_DECREASE_DIVISOR: usize = 2;
96
97const HILL_EPOCH_FULL_WAVES: usize = 2;
101
102const HILL_EPOCH_MAX_DURATION: Duration = Duration::from_secs(2);
105
106fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
109 m.lock().unwrap_or_else(PoisonError::into_inner)
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum Outcome {
115 Success,
117 Timeout,
119 NetworkError,
121 ApplicationError,
125}
126
127const FETCH_MIN_FLOOR: usize = 4;
137
138#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
142pub struct ChannelMax {
143 pub quote: usize,
144 pub store: usize,
145 pub fetch: usize,
146}
147
148impl Default for ChannelMax {
149 fn default() -> Self {
150 Self {
155 quote: 128,
156 store: 64,
157 fetch: 256,
158 }
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct AdaptiveConfig {
168 pub enabled: bool,
171 pub min_concurrency: usize,
173 pub max: ChannelMax,
175 pub window_ops: usize,
178 pub min_window_ops: usize,
180 pub success_target: f64,
183 pub timeout_ceiling: f64,
186 pub latency_inflation_factor: f64,
189 pub latency_ewma_alpha: f64,
194}
195
196impl AdaptiveConfig {
197 pub fn sanitize(&mut self) {
203 if !self.latency_ewma_alpha.is_finite() {
204 self.latency_ewma_alpha = 0.2;
205 }
206 self.latency_ewma_alpha = self.latency_ewma_alpha.clamp(0.0, 1.0);
207 if !self.success_target.is_finite() {
208 self.success_target = 0.95;
209 }
210 self.success_target = self.success_target.clamp(0.0, 1.0);
211 if !self.timeout_ceiling.is_finite() {
212 self.timeout_ceiling = 0.10;
213 }
214 self.timeout_ceiling = self.timeout_ceiling.clamp(0.0, 1.0);
215 if !self.latency_inflation_factor.is_finite() || self.latency_inflation_factor <= 0.0 {
216 self.latency_inflation_factor = 4.0;
217 }
218 self.min_concurrency = self.min_concurrency.max(1);
219 self.window_ops = self.window_ops.max(1);
220 self.min_window_ops = self.min_window_ops.max(1).min(self.window_ops);
221 self.max.quote = self.max.quote.max(self.min_concurrency);
222 self.max.store = self.max.store.max(self.min_concurrency);
223 self.max.fetch = self.max.fetch.max(self.min_concurrency);
224 }
225}
226
227impl Default for AdaptiveConfig {
228 fn default() -> Self {
229 Self {
230 enabled: true,
231 min_concurrency: 1,
232 max: ChannelMax::default(),
233 window_ops: 32,
234 min_window_ops: 8,
235 success_target: 0.95,
236 timeout_ceiling: 0.10,
237 latency_inflation_factor: 4.0,
244 latency_ewma_alpha: 0.2,
245 }
246 }
247}
248
249#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
260pub struct ChannelStart {
261 pub quote: usize,
262 pub store: usize,
263 pub fetch: usize,
264}
265
266impl Default for ChannelStart {
267 fn default() -> Self {
268 Self {
269 quote: 32,
270 store: 8,
271 fetch: FETCH_COLD_START_CONCURRENCY,
272 }
273 }
274}
275
276#[derive(Debug, Clone, Copy)]
278struct Sample {
279 outcome: Outcome,
280 latency: Duration,
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286enum LimiterAlgorithm {
287 Aimd,
288 ThroughputHillClimb,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293enum ProbeDirection {
294 Up,
295 Down,
296}
297
298#[derive(Debug)]
300struct HillClimbState {
301 epoch_started: Option<Instant>,
302 epoch_samples: usize,
303 epoch_successes: usize,
304 epoch_timeouts: usize,
305 epoch_net_errors: usize,
306 epoch_bytes: u64,
307 epoch_latencies: Vec<Duration>,
308 best_goodput_per_sec: Option<f64>,
309 best_latency_p95: Option<Duration>,
310 best_concurrency: usize,
311 stable_epochs: usize,
312 cooldown_epochs: usize,
313 next_probe: ProbeDirection,
314 active_probe: Option<ProbeDirection>,
315}
316
317impl HillClimbState {
318 fn new(start: usize, epoch_capacity: usize) -> Self {
319 Self {
320 epoch_started: None,
321 epoch_samples: 0,
322 epoch_successes: 0,
323 epoch_timeouts: 0,
324 epoch_net_errors: 0,
325 epoch_bytes: 0,
326 epoch_latencies: Vec::with_capacity(epoch_capacity),
327 best_goodput_per_sec: None,
328 best_latency_p95: None,
329 best_concurrency: start,
330 stable_epochs: 0,
331 cooldown_epochs: 0,
332 next_probe: ProbeDirection::Up,
333 active_probe: None,
334 }
335 }
336
337 fn reset_epoch(&mut self) {
338 self.epoch_started = None;
339 self.epoch_samples = 0;
340 self.epoch_successes = 0;
341 self.epoch_timeouts = 0;
342 self.epoch_net_errors = 0;
343 self.epoch_bytes = 0;
344 self.epoch_latencies.clear();
345 }
346
347 fn capacity_total(&self) -> usize {
348 self.epoch_successes + self.epoch_timeouts + self.epoch_net_errors
349 }
350}
351
352#[derive(Debug, Clone)]
358pub struct LimiterConfig {
359 pub enabled: bool,
360 pub min_concurrency: usize,
361 pub max_concurrency: usize,
362 pub window_ops: usize,
363 pub min_window_ops: usize,
364 pub success_target: f64,
365 pub timeout_ceiling: f64,
366 pub latency_inflation_factor: f64,
367 pub latency_ewma_alpha: f64,
368 pub slow_start_ramp_threshold: usize,
382 pub latency_decrease_enabled: bool,
392 pub retain_increase_credit_on_decrease: bool,
409}
410
411impl LimiterConfig {
412 fn from_adaptive(cfg: &AdaptiveConfig, max_for_channel: usize) -> Self {
413 Self {
414 enabled: cfg.enabled,
415 min_concurrency: cfg.min_concurrency,
416 max_concurrency: max_for_channel.max(cfg.min_concurrency),
417 window_ops: cfg.window_ops,
418 min_window_ops: cfg.min_window_ops,
419 success_target: cfg.success_target,
420 timeout_ceiling: cfg.timeout_ceiling,
421 latency_inflation_factor: cfg.latency_inflation_factor,
422 latency_ewma_alpha: cfg.latency_ewma_alpha,
423 slow_start_ramp_threshold: 0,
426 latency_decrease_enabled: true,
427 retain_increase_credit_on_decrease: false,
428 }
429 }
430
431 fn sanitize(&mut self) {
437 if !self.latency_ewma_alpha.is_finite() {
438 self.latency_ewma_alpha = 0.2;
439 }
440 self.latency_ewma_alpha = self.latency_ewma_alpha.clamp(0.0, 1.0);
441 if !self.success_target.is_finite() {
442 self.success_target = 0.95;
443 }
444 self.success_target = self.success_target.clamp(0.0, 1.0);
445 if !self.timeout_ceiling.is_finite() {
446 self.timeout_ceiling = 0.10;
447 }
448 self.timeout_ceiling = self.timeout_ceiling.clamp(0.0, 1.0);
449 if !self.latency_inflation_factor.is_finite() || self.latency_inflation_factor <= 0.0 {
450 self.latency_inflation_factor = 4.0;
451 }
452 self.min_concurrency = self.min_concurrency.max(1);
453 self.window_ops = self.window_ops.max(1);
454 self.min_window_ops = self.min_window_ops.max(1).min(self.window_ops);
455 self.max_concurrency = self.max_concurrency.max(self.min_concurrency);
456 }
457}
458
459#[derive(Debug, Clone)]
465pub struct Limiter {
466 inner: Arc<Mutex<LimiterInner>>,
467 config: Arc<LimiterConfig>,
468 algorithm: LimiterAlgorithm,
469}
470
471#[derive(Debug)]
472struct LimiterInner {
473 observation_epoch: u64,
474 current: usize,
476 window: VecDeque<Sample>,
478 samples_since_increase: usize,
482 samples_since_decrease: usize,
487 latency_baseline: Option<Duration>,
490 left_slow_start: bool,
493 hill: HillClimbState,
497}
498
499impl Limiter {
500 #[must_use]
505 pub fn new(start: usize, config: LimiterConfig) -> Self {
506 Self::new_with_algorithm(start, config, LimiterAlgorithm::Aimd)
507 }
508
509 fn new_with_algorithm(
510 start: usize,
511 config: LimiterConfig,
512 algorithm: LimiterAlgorithm,
513 ) -> Self {
514 let mut config = config;
515 config.sanitize();
516 let clamped = start.clamp(config.min_concurrency, config.max_concurrency.max(1));
517 let window_cap = config.window_ops;
518 Self {
519 inner: Arc::new(Mutex::new(LimiterInner {
520 observation_epoch: 0,
521 current: clamped,
522 window: VecDeque::with_capacity(window_cap),
523 samples_since_increase: 0,
524 samples_since_decrease: 0,
525 latency_baseline: None,
526 left_slow_start: false,
527 hill: HillClimbState::new(clamped, window_cap),
528 })),
529 config: Arc::new(config),
530 algorithm,
531 }
532 }
533
534 #[must_use]
538 pub fn current(&self) -> usize {
539 lock(&self.inner).current
540 }
541
542 pub fn observe(&self, outcome: Outcome, latency: Duration) {
545 self.observe_with_bytes(outcome, latency, 0);
546 }
547
548 pub fn observe_with_bytes(&self, outcome: Outcome, latency: Duration, bytes: u64) {
551 let observed_at = Instant::now();
552 let operation_started = observed_at.checked_sub(latency).unwrap_or(observed_at);
553 self.observe_with_timing(outcome, latency, bytes, operation_started);
554 }
555
556 pub(crate) fn observation_epoch(&self) -> u64 {
557 lock(&self.inner).observation_epoch
558 }
559
560 pub(crate) fn observe_fetch_in_epoch(
561 &self,
562 outcome: Outcome,
563 latency: Duration,
564 bytes: u64,
565 epoch: u64,
566 ) {
567 let now = Instant::now();
568 self.observe_with_timing_in_epoch(
569 outcome,
570 latency,
571 bytes,
572 now.checked_sub(latency).unwrap_or(now),
573 Some(epoch),
574 );
575 }
576
577 fn observe_with_timing(
578 &self,
579 outcome: Outcome,
580 latency: Duration,
581 bytes: u64,
582 operation_started: Instant,
583 ) {
584 self.observe_with_timing_in_epoch(outcome, latency, bytes, operation_started, None);
585 }
586
587 fn observe_with_timing_in_epoch(
588 &self,
589 outcome: Outcome,
590 latency: Duration,
591 bytes: u64,
592 operation_started: Instant,
593 expected_epoch: Option<u64>,
594 ) {
595 if !self.config.enabled {
596 return;
597 }
598 let mut g = lock(&self.inner);
599 if self.algorithm == LimiterAlgorithm::ThroughputHillClimb
602 && expected_epoch.is_some_and(|epoch| epoch != g.observation_epoch)
603 {
604 return;
605 }
606 if g.window.len() == self.config.window_ops {
607 g.window.pop_front();
608 }
609 g.window.push_back(Sample { outcome, latency });
610 if self.algorithm == LimiterAlgorithm::ThroughputHillClimb {
611 let previous_cap = g.current;
612 observe_hill_climb(
613 &mut g,
614 outcome,
615 latency,
616 bytes,
617 operation_started,
618 &self.config,
619 );
620 if g.current != previous_cap {
621 g.observation_epoch = g.observation_epoch.wrapping_add(1);
622 }
623 return;
624 }
625 g.samples_since_increase = g.samples_since_increase.saturating_add(1);
626 g.samples_since_decrease = g.samples_since_decrease.saturating_add(1);
627 if g.window.len() < self.config.min_window_ops {
628 return;
629 }
630 let decision = evaluate(&g.window, &self.config, g.latency_baseline);
631 apply_decision(&mut g, decision, &self.config);
632 }
633
634 pub fn warm_start(&self, start: usize) {
657 let clamped = start.clamp(
658 self.config.min_concurrency,
659 self.config.max_concurrency.max(1),
660 );
661 let mut g = lock(&self.inner);
662 g.current = clamped;
663 g.observation_epoch = g.observation_epoch.wrapping_add(1);
664 g.left_slow_start = clamped >= self.config.slow_start_ramp_threshold;
665 g.hill = HillClimbState::new(clamped, self.config.window_ops);
666 }
667
668 #[must_use]
670 pub fn snapshot(&self) -> usize {
671 let g = lock(&self.inner);
672 if self.algorithm == LimiterAlgorithm::ThroughputHillClimb {
673 g.hill.best_concurrency
674 } else {
675 g.current
676 }
677 }
678}
679
680#[derive(Debug, Clone, Copy)]
681struct HillEpochStats {
682 goodput_per_sec: f64,
683 latency_p95: Option<Duration>,
684}
685
686#[derive(Debug, Clone, Copy, PartialEq, Eq)]
688enum Decision {
689 Increase,
691 Decrease,
693 Hold,
695}
696
697fn evaluate(
698 window: &VecDeque<Sample>,
699 cfg: &LimiterConfig,
700 baseline: Option<Duration>,
701) -> Decision {
702 let mut successes = 0usize;
707 let mut timeouts = 0usize;
708 let mut net_errors = 0usize;
709 let mut latencies: Vec<Duration> = Vec::with_capacity(window.len());
710 for s in window {
711 match s.outcome {
712 Outcome::Success => {
713 successes += 1;
714 latencies.push(s.latency);
715 }
716 Outcome::Timeout => timeouts += 1,
717 Outcome::NetworkError => net_errors += 1,
718 Outcome::ApplicationError => {}
719 }
720 }
721 let capacity_total = successes + timeouts + net_errors;
722 if capacity_total < cfg.min_window_ops {
723 return Decision::Hold;
725 }
726 let total_f = capacity_total as f64;
727 let success_rate = successes as f64 / total_f;
728 let timeout_rate = timeouts as f64 / total_f;
729
730 if success_rate < cfg.success_target || timeout_rate > cfg.timeout_ceiling {
731 return Decision::Decrease;
732 }
733
734 if let Some(p95) = p95_of(&mut latencies) {
735 if cfg.latency_decrease_enabled {
736 if let Some(base) = baseline {
737 let limit = base.mul_f64(cfg.latency_inflation_factor);
738 if p95 > limit {
739 return Decision::Decrease;
740 }
741 }
742 }
743 Decision::Increase
744 } else {
745 Decision::Hold
746 }
747}
748
749fn apply_decision(inner: &mut LimiterInner, decision: Decision, cfg: &LimiterConfig) {
750 match decision {
751 Decision::Increase => {
752 if inner.samples_since_increase < cfg.window_ops {
755 return;
756 }
757 let p95 = window_p95(&inner.window);
758 inner.latency_baseline = Some(match inner.latency_baseline {
759 None => p95,
760 Some(prev) => ewma(prev, p95, cfg.latency_ewma_alpha),
761 });
762 let next = if inner.left_slow_start {
763 inner.current.saturating_add(1)
764 } else {
765 inner.current.saturating_mul(2)
766 };
767 let next = next.min(cfg.max_concurrency).max(cfg.min_concurrency);
768 if next != inner.current {
769 debug!(
770 from = inner.current,
771 to = next,
772 slow_start = !inner.left_slow_start,
773 "adaptive: increase",
774 );
775 }
776 inner.current = next;
777 inner.samples_since_increase = 0;
778 inner.samples_since_decrease = 0;
779 }
780 Decision::Decrease => {
781 if inner.samples_since_decrease < cfg.min_window_ops {
786 return;
787 }
788 if inner.current >= cfg.slow_start_ramp_threshold {
795 inner.left_slow_start = true;
796 }
797 let next = (inner.current / 2).max(cfg.min_concurrency);
798 if next != inner.current {
799 debug!(from = inner.current, to = next, "adaptive: decrease");
800 }
801 inner.current = next;
802 if !cfg.retain_increase_credit_on_decrease {
807 inner.samples_since_increase = 0;
808 }
809 inner.samples_since_decrease = 0;
810 }
811 Decision::Hold => {}
812 }
813}
814
815fn p95_of(latencies: &mut [Duration]) -> Option<Duration> {
819 if latencies.is_empty() {
820 return None;
821 }
822 latencies.sort_unstable();
823 let idx = ((latencies.len() as f64) * 0.95).ceil() as usize;
824 let idx = idx.saturating_sub(1).min(latencies.len() - 1);
825 latencies.get(idx).copied()
826}
827
828fn window_p95(window: &VecDeque<Sample>) -> Duration {
829 let mut latencies: Vec<Duration> = window
830 .iter()
831 .filter(|s| matches!(s.outcome, Outcome::Success))
832 .map(|s| s.latency)
833 .collect();
834 p95_of(&mut latencies).unwrap_or(Duration::ZERO)
835}
836
837fn ewma(prev: Duration, sample: Duration, alpha: f64) -> Duration {
838 let alpha = if alpha.is_finite() {
839 alpha.clamp(0.0, 1.0)
840 } else {
841 return prev;
842 };
843 let prev_ms = prev.as_secs_f64() * 1000.0;
844 let sample_ms = sample.as_secs_f64() * 1000.0;
845 let new_ms = (1.0 - alpha) * prev_ms + alpha * sample_ms;
846 if !new_ms.is_finite() || new_ms < 0.0 {
847 return prev;
848 }
849 Duration::from_secs_f64(new_ms / 1000.0)
850}
851
852fn observe_hill_climb(
853 inner: &mut LimiterInner,
854 outcome: Outcome,
855 latency: Duration,
856 bytes: u64,
857 operation_started: Instant,
858 cfg: &LimiterConfig,
859) {
860 match inner.hill.epoch_started {
861 Some(epoch_started) if epoch_started <= operation_started => {}
862 _ => inner.hill.epoch_started = Some(operation_started),
863 }
864 inner.hill.epoch_samples = inner.hill.epoch_samples.saturating_add(1);
865 match outcome {
866 Outcome::Success => {
867 inner.hill.epoch_successes = inner.hill.epoch_successes.saturating_add(1);
868 inner.hill.epoch_bytes = inner.hill.epoch_bytes.saturating_add(bytes);
869 inner.hill.epoch_latencies.push(latency);
870 }
871 Outcome::Timeout => {
872 inner.hill.epoch_timeouts = inner.hill.epoch_timeouts.saturating_add(1);
873 }
874 Outcome::NetworkError => {
875 inner.hill.epoch_net_errors = inner.hill.epoch_net_errors.saturating_add(1);
876 }
877 Outcome::ApplicationError => {}
878 }
879
880 if hill_epoch_stressed(&inner.hill, cfg) {
881 apply_hill_stress(inner, cfg);
882 return;
883 }
884
885 let minimum = cfg
886 .min_window_ops
887 .max(inner.current.saturating_mul(HILL_EPOCH_FULL_WAVES));
888 let timed_epoch = inner.hill.epoch_samples >= minimum
889 && inner
890 .hill
891 .epoch_started
892 .is_some_and(|started| started.elapsed() >= HILL_EPOCH_MAX_DURATION);
893 if !timed_epoch && inner.hill.epoch_samples < hill_epoch_target_samples(inner.current, cfg) {
894 return;
895 }
896
897 if let Some(stats) = hill_epoch_stats(&inner.hill, cfg) {
898 apply_hill_epoch(inner, stats, cfg);
899 }
900 inner.hill.reset_epoch();
901}
902
903fn hill_epoch_target_samples(current: usize, cfg: &LimiterConfig) -> usize {
904 cfg.window_ops
905 .max(current.saturating_mul(HILL_EPOCH_FULL_WAVES))
906 .max(cfg.min_window_ops)
907}
908
909fn hill_epoch_stressed(hill: &HillClimbState, cfg: &LimiterConfig) -> bool {
910 let capacity_total = hill.capacity_total();
911 if capacity_total < cfg.min_window_ops {
912 return false;
913 }
914 let total_f = capacity_total as f64;
915 let success_rate = hill.epoch_successes as f64 / total_f;
916 let timeout_rate = hill.epoch_timeouts as f64 / total_f;
917 success_rate < cfg.success_target || timeout_rate > cfg.timeout_ceiling
918}
919
920fn hill_epoch_stats(hill: &HillClimbState, cfg: &LimiterConfig) -> Option<HillEpochStats> {
921 let capacity_total = hill.capacity_total();
922 if capacity_total < cfg.min_window_ops || hill.epoch_successes == 0 {
923 return None;
924 }
925 let mut latencies = hill.epoch_latencies.clone();
926 let latency_p95 = p95_of(&mut latencies);
927 let max_latency = latencies.iter().copied().max().unwrap_or(Duration::ZERO);
928 let wall_elapsed = hill.epoch_started.map_or(Duration::ZERO, |s| s.elapsed());
929 let elapsed = wall_elapsed.max(max_latency);
930 let elapsed_secs = elapsed.as_secs_f64();
931 if !elapsed_secs.is_finite() || elapsed_secs <= 0.0 {
932 return None;
933 }
934
935 let units = if hill.epoch_bytes > 0 {
938 hill.epoch_bytes as f64
939 } else {
940 hill.epoch_successes as f64
941 };
942 Some(HillEpochStats {
943 goodput_per_sec: units / elapsed_secs,
944 latency_p95,
945 })
946}
947
948fn apply_hill_stress(inner: &mut LimiterInner, cfg: &LimiterConfig) {
949 let next = (inner.current / HILL_STRESS_DECREASE_DIVISOR)
950 .max(cfg.min_concurrency)
951 .min(cfg.max_concurrency);
952 if next != inner.current {
953 debug!(
954 from = inner.current,
955 to = next,
956 "adaptive: fetch hill stress decrease"
957 );
958 }
959 inner.current = next;
960 inner.hill.best_concurrency = next;
961 inner.hill.best_goodput_per_sec = None;
962 inner.hill.best_latency_p95 = None;
963 inner.hill.stable_epochs = 0;
964 inner.hill.cooldown_epochs = HILL_REJECT_COOLDOWN_EPOCHS;
965 inner.hill.active_probe = None;
966 inner.hill.next_probe = ProbeDirection::Up;
967 inner.hill.reset_epoch();
968}
969
970fn apply_hill_epoch(inner: &mut LimiterInner, stats: HillEpochStats, cfg: &LimiterConfig) {
971 let Some(best_goodput) = inner.hill.best_goodput_per_sec else {
972 inner.hill.best_goodput_per_sec = Some(stats.goodput_per_sec);
973 inner.hill.best_latency_p95 = stats.latency_p95;
974 inner.hill.best_concurrency = inner.current;
975 probe_hill_neighbor(inner, ProbeDirection::Up, cfg);
976 return;
977 };
978
979 match inner.hill.active_probe {
980 Some(ProbeDirection::Up) => {
981 let improved = stats.goodput_per_sec >= best_goodput * HILL_UP_PROBE_ACCEPT_RATIO;
982 if improved
983 && hill_latency_acceptable(stats.latency_p95, inner.hill.best_latency_p95, cfg)
984 {
985 accept_hill_probe(inner, stats, cfg);
986 probe_hill_neighbor(inner, ProbeDirection::Up, cfg);
987 } else {
988 reject_hill_probe(inner);
989 }
990 }
991 Some(ProbeDirection::Down) => {
992 let retained = stats.goodput_per_sec >= best_goodput * HILL_DOWN_PROBE_ACCEPT_RATIO;
993 if retained
994 && hill_latency_acceptable(stats.latency_p95, inner.hill.best_latency_p95, cfg)
995 {
996 accept_hill_probe(inner, stats, cfg);
997 inner.hill.next_probe = ProbeDirection::Up;
998 } else {
999 reject_hill_probe(inner);
1000 }
1001 }
1002 None => {
1003 refresh_hill_best(inner, stats, cfg);
1004 if inner.hill.cooldown_epochs > 0 {
1005 inner.hill.cooldown_epochs -= 1;
1006 return;
1007 }
1008 inner.hill.stable_epochs = inner.hill.stable_epochs.saturating_add(1);
1009 if inner.hill.stable_epochs >= HILL_STABLE_PROBE_EPOCHS {
1010 let direction = inner.hill.next_probe;
1011 inner.hill.next_probe = match direction {
1012 ProbeDirection::Up => ProbeDirection::Down,
1013 ProbeDirection::Down => ProbeDirection::Up,
1014 };
1015 probe_hill_neighbor(inner, direction, cfg);
1016 }
1017 }
1018 }
1019}
1020
1021fn refresh_hill_best(inner: &mut LimiterInner, stats: HillEpochStats, cfg: &LimiterConfig) {
1022 inner.hill.best_goodput_per_sec = Some(match inner.hill.best_goodput_per_sec {
1023 Some(prev) => ewma_f64(prev, stats.goodput_per_sec, cfg.latency_ewma_alpha),
1024 None => stats.goodput_per_sec,
1025 });
1026 if let Some(latency_p95) = stats.latency_p95 {
1027 inner.hill.best_latency_p95 = Some(match inner.hill.best_latency_p95 {
1028 Some(prev) => ewma(prev, latency_p95, cfg.latency_ewma_alpha),
1029 None => latency_p95,
1030 });
1031 }
1032}
1033
1034fn hill_latency_acceptable(
1035 candidate: Option<Duration>,
1036 best: Option<Duration>,
1037 cfg: &LimiterConfig,
1038) -> bool {
1039 match (candidate, best) {
1040 (Some(candidate), Some(best)) => candidate <= best.mul_f64(cfg.latency_inflation_factor),
1041 _ => true,
1042 }
1043}
1044
1045fn ewma_f64(prev: f64, sample: f64, alpha: f64) -> f64 {
1046 let alpha = if alpha.is_finite() {
1047 alpha.clamp(0.0, 1.0)
1048 } else {
1049 return prev;
1050 };
1051 let next = (1.0 - alpha) * prev + alpha * sample;
1052 if next.is_finite() && next >= 0.0 {
1053 next
1054 } else {
1055 prev
1056 }
1057}
1058
1059fn accept_hill_probe(inner: &mut LimiterInner, stats: HillEpochStats, cfg: &LimiterConfig) {
1060 debug!(
1061 concurrency = inner.current,
1062 goodput_per_sec = stats.goodput_per_sec,
1063 "adaptive: fetch hill accepted probe"
1064 );
1065 inner.hill.best_concurrency = inner.current;
1066 inner.hill.best_goodput_per_sec = Some(stats.goodput_per_sec);
1067 inner.hill.best_latency_p95 = stats.latency_p95;
1068 inner.hill.active_probe = None;
1069 inner.hill.cooldown_epochs = 0;
1070 inner.hill.stable_epochs = 0;
1071 inner.current = inner
1072 .hill
1073 .best_concurrency
1074 .clamp(cfg.min_concurrency, cfg.max_concurrency);
1075}
1076
1077fn reject_hill_probe(inner: &mut LimiterInner) {
1078 let from = inner.current;
1079 let to = inner.hill.best_concurrency;
1080 let rejected_direction = inner.hill.active_probe;
1081 if from != to {
1082 debug!(from, to, "adaptive: fetch hill rejected probe");
1083 }
1084 inner.current = to;
1085 inner.hill.active_probe = None;
1086 if let Some(direction) = rejected_direction {
1087 inner.hill.next_probe = match direction {
1088 ProbeDirection::Up => ProbeDirection::Down,
1089 ProbeDirection::Down => ProbeDirection::Up,
1090 };
1091 }
1092 inner.hill.cooldown_epochs = HILL_REJECT_COOLDOWN_EPOCHS;
1093 inner.hill.stable_epochs = 0;
1094}
1095
1096fn probe_hill_neighbor(inner: &mut LimiterInner, direction: ProbeDirection, cfg: &LimiterConfig) {
1097 let best = inner.hill.best_concurrency;
1098 let step = (best / HILL_PROBE_STEP_DIVISOR).max(HILL_MIN_PROBE_STEP);
1099 let candidate = match direction {
1100 ProbeDirection::Up => best.saturating_add(step).min(cfg.max_concurrency),
1101 ProbeDirection::Down => best.saturating_sub(step).max(cfg.min_concurrency),
1102 };
1103 if candidate == best {
1104 inner.current = best;
1105 inner.hill.active_probe = None;
1106 inner.hill.stable_epochs = 0;
1107 return;
1108 }
1109 debug!(
1110 from = best,
1111 to = candidate,
1112 ?direction,
1113 "adaptive: fetch hill probing"
1114 );
1115 inner.current = candidate;
1116 inner.hill.active_probe = Some(direction);
1117 inner.hill.stable_epochs = 0;
1118}
1119
1120#[derive(Debug, Clone)]
1122pub struct AdaptiveController {
1123 pub quote: Limiter,
1124 pub store: Limiter,
1125 pub fetch: Limiter,
1126 pub(crate) config: AdaptiveConfig,
1133 cold_start: ChannelStart,
1139}
1140
1141impl AdaptiveController {
1142 #[must_use]
1147 pub fn new(start: ChannelStart, config: AdaptiveConfig) -> Self {
1148 let mut config = config;
1149 config.sanitize();
1150 let quote_cfg = LimiterConfig::from_adaptive(&config, config.max.quote);
1151 let mut store_cfg = LimiterConfig::from_adaptive(&config, config.max.store);
1152 store_cfg.latency_decrease_enabled = false;
1175 store_cfg.slow_start_ramp_threshold = usize::MAX;
1176 store_cfg.retain_increase_credit_on_decrease = true;
1197 store_cfg.success_target = 0.88;
1198 let mut fetch_cfg = LimiterConfig::from_adaptive(&config, config.max.fetch);
1199 fetch_cfg.min_concurrency = fetch_cfg.min_concurrency.max(FETCH_MIN_FLOOR);
1216 fetch_cfg.max_concurrency = fetch_cfg.max_concurrency.max(fetch_cfg.min_concurrency);
1219 fetch_cfg.slow_start_ramp_threshold = usize::MAX;
1244 fetch_cfg.latency_decrease_enabled = false;
1245 Self {
1246 quote: Limiter::new(start.quote, quote_cfg),
1247 store: Limiter::new(start.store, store_cfg),
1248 fetch: Limiter::new_with_algorithm(
1249 start.fetch,
1250 fetch_cfg,
1251 LimiterAlgorithm::ThroughputHillClimb,
1252 ),
1253 config,
1254 cold_start: start,
1255 }
1256 }
1257
1258 #[must_use]
1260 pub fn snapshot(&self) -> ChannelStart {
1261 ChannelStart {
1262 quote: self.quote.snapshot(),
1263 store: self.store.snapshot(),
1264 fetch: self.fetch.snapshot(),
1265 }
1266 }
1267
1268 #[must_use]
1274 pub fn config(&self) -> &AdaptiveConfig {
1275 &self.config
1276 }
1277
1278 pub fn warm_start(&self, snapshot: ChannelStart) {
1292 if !self.config.enabled {
1293 return;
1294 }
1295 self.quote
1296 .warm_start(snapshot.quote.max(self.cold_start.quote));
1297 self.store
1298 .warm_start(snapshot.store.max(self.cold_start.store));
1299 self.fetch
1300 .warm_start(snapshot.fetch.max(self.cold_start.fetch));
1301 }
1302}
1303
1304impl Default for AdaptiveController {
1305 fn default() -> Self {
1306 Self::new(ChannelStart::default(), AdaptiveConfig::default())
1307 }
1308}
1309
1310struct ObserveGuard<'a> {
1318 epoch: u64,
1319 limiter: &'a Limiter,
1320 started: Instant,
1321 outcome: Option<(Outcome, Duration, u64)>,
1322}
1323
1324impl<'a> ObserveGuard<'a> {
1325 fn new(limiter: &'a Limiter) -> Self {
1326 Self {
1327 epoch: limiter.observation_epoch(),
1328 limiter,
1329 started: Instant::now(),
1330 outcome: None,
1331 }
1332 }
1333 fn finish(&mut self, outcome: Outcome) {
1334 self.finish_with_bytes(outcome, 0);
1335 }
1336
1337 fn finish_with_bytes(&mut self, outcome: Outcome, bytes: u64) {
1338 self.outcome = Some((outcome, self.started.elapsed(), bytes));
1339 }
1340}
1341
1342impl Drop for ObserveGuard<'_> {
1343 fn drop(&mut self) {
1344 if let Some((outcome, latency, bytes)) = self.outcome.take() {
1345 self.limiter.observe_with_timing_in_epoch(
1346 outcome,
1347 latency,
1348 bytes,
1349 self.started,
1350 Some(self.epoch),
1351 );
1352 }
1353 }
1354}
1355
1356pub async fn observe_op<T, E, F, Fut, C>(limiter: &Limiter, op: F, classify: C) -> Result<T, E>
1371where
1372 F: FnOnce() -> Fut,
1373 Fut: std::future::Future<Output = Result<T, E>>,
1374 C: FnOnce(&E) -> Outcome,
1375{
1376 let mut guard = ObserveGuard::new(limiter);
1377 let result = op().await;
1378 let outcome = match &result {
1379 Ok(_) => Outcome::Success,
1380 Err(e) => classify(e),
1381 };
1382 guard.finish(outcome);
1383 drop(guard); result
1385}
1386
1387pub async fn observe_op_with_success_bytes<T, E, F, Fut, C, B>(
1391 limiter: &Limiter,
1392 op: F,
1393 classify: C,
1394 success_bytes: B,
1395) -> Result<T, E>
1396where
1397 F: FnOnce() -> Fut,
1398 Fut: std::future::Future<Output = Result<T, E>>,
1399 C: FnOnce(&E) -> Outcome,
1400 B: FnOnce(&T) -> u64,
1401{
1402 let mut guard = ObserveGuard::new(limiter);
1403 let result = op().await;
1404 match &result {
1405 Ok(value) => guard.finish_with_bytes(Outcome::Success, success_bytes(value)),
1406 Err(e) => guard.finish_with_bytes(classify(e), 0),
1407 }
1408 drop(guard);
1409 result
1410}
1411
1412pub async fn rebucketed_unordered<I, T, E, F, Fut>(
1427 limiter: &Limiter,
1428 items: I,
1429 mut op: F,
1430) -> Result<Vec<T>, E>
1431where
1432 I: IntoIterator,
1433 F: FnMut(I::Item) -> Fut,
1434 Fut: std::future::Future<Output = Result<T, E>>,
1435{
1436 let mut iter = items.into_iter().peekable();
1437 let mut in_flight: FuturesUnordered<Fut> = FuturesUnordered::new();
1438 let mut results = Vec::new();
1439 let mut pending_err: Option<E> = None;
1440 loop {
1441 if pending_err.is_none() {
1444 let cap = limiter.current().max(1);
1445 while in_flight.len() < cap {
1446 match iter.next() {
1447 Some(item) => in_flight.push(op(item)),
1448 None => break,
1449 }
1450 }
1451 }
1452 if in_flight.is_empty() {
1453 break;
1454 }
1455 match in_flight.next().await {
1456 Some(Ok(v)) => results.push(v),
1457 Some(Err(e)) => {
1458 if pending_err.is_none() {
1459 pending_err = Some(e);
1460 }
1461 }
1462 None => break,
1463 }
1464 }
1465 match pending_err {
1466 Some(e) => Err(e),
1467 None => Ok(results),
1468 }
1469}
1470
1471pub async fn rebucketed_ordered<I, U, E, F, Fut>(
1484 limiter: &Limiter,
1485 items: I,
1486 op: F,
1487) -> Result<Vec<U>, E>
1488where
1489 I: IntoIterator,
1490 F: FnMut(I::Item) -> Fut,
1491 Fut: std::future::Future<Output = Result<(usize, U), E>>,
1492{
1493 let mut indexed = rebucketed_unordered(limiter, items, op).await?;
1494 indexed.sort_by_key(|(idx, _)| *idx);
1495 Ok(indexed.into_iter().map(|(_, v)| v).collect())
1496}
1497
1498pub async fn rebucketed<I, T, E, F, Fut>(
1504 limiter: &Limiter,
1505 items: I,
1506 ordered: bool,
1507 mut op: F,
1508) -> Result<Vec<T>, E>
1509where
1510 I: IntoIterator,
1511 F: FnMut(I::Item) -> Fut,
1512 Fut: std::future::Future<Output = Result<T, E>>,
1513{
1514 if !ordered {
1515 return rebucketed_unordered(limiter, items, op).await;
1516 }
1517 let mut iter = items.into_iter();
1518 let mut results = Vec::new();
1519 let mut pending_err: Option<E> = None;
1520 loop {
1521 if pending_err.is_some() {
1522 break;
1523 }
1524 let cap = limiter.current().max(1);
1525 let mut batch = Vec::with_capacity(cap);
1526 for item in iter.by_ref().take(cap) {
1527 batch.push(op(item));
1528 }
1529 if batch.is_empty() {
1530 break;
1531 }
1532 let mut s = stream::iter(batch).buffered(cap);
1533 while let Some(r) = s.next().await {
1534 match r {
1535 Ok(v) => results.push(v),
1536 Err(e) => {
1537 if pending_err.is_none() {
1538 pending_err = Some(e);
1539 }
1540 }
1541 }
1542 }
1543 }
1544 match pending_err {
1545 Some(e) => Err(e),
1546 None => Ok(results),
1547 }
1548}
1549
1550#[cfg(feature = "native")]
1555#[derive(Debug, Clone, Serialize, Deserialize)]
1556struct PersistedState {
1557 schema: u32,
1558 channels: ChannelStart,
1559}
1560
1561#[cfg(feature = "native")]
1562const PERSIST_SCHEMA: u32 = 2;
1563#[cfg(feature = "native")]
1564const PERSIST_SCHEMA_AIMD_FETCH: u32 = 1;
1565#[cfg(feature = "native")]
1566const PERSIST_FILENAME: &str = "client_adaptive.json";
1567
1568#[must_use]
1572#[cfg(feature = "native")]
1573pub fn default_persist_path() -> Option<PathBuf> {
1574 crate::config::data_dir()
1575 .ok()
1576 .map(|d| d.join(PERSIST_FILENAME))
1577}
1578
1579#[must_use]
1585#[cfg(feature = "native")]
1586pub fn load_snapshot(path: &Path) -> Option<ChannelStart> {
1587 let bytes = std::fs::read(path).ok()?;
1588 let state: PersistedState = match serde_json::from_slice(&bytes) {
1589 Ok(s) => s,
1590 Err(e) => {
1591 warn!(path = %path.display(), error = %e, "adaptive: corrupt snapshot, ignoring");
1592 return None;
1593 }
1594 };
1595 match state.schema {
1596 PERSIST_SCHEMA => Some(state.channels),
1597 PERSIST_SCHEMA_AIMD_FETCH => {
1598 debug!(
1599 path = %path.display(),
1600 "adaptive: migrating schema-1 snapshot, preserving quote/store and resetting fetch",
1601 );
1602 Some(ChannelStart {
1603 fetch: FETCH_COLD_START_CONCURRENCY,
1604 ..state.channels
1605 })
1606 }
1607 schema => {
1608 debug!(
1609 path = %path.display(),
1610 schema,
1611 expected = PERSIST_SCHEMA,
1612 "adaptive: snapshot schema mismatch, ignoring",
1613 );
1614 None
1615 }
1616 }
1617}
1618
1619#[cfg(feature = "native")]
1622pub fn save_snapshot(path: &Path, channels: ChannelStart) {
1623 let state = PersistedState {
1624 schema: PERSIST_SCHEMA,
1625 channels,
1626 };
1627 let bytes = match serde_json::to_vec_pretty(&state) {
1628 Ok(b) => b,
1629 Err(e) => {
1630 warn!(error = %e, "adaptive: snapshot serialize failed");
1631 return;
1632 }
1633 };
1634 if let Some(parent) = path.parent() {
1635 if let Err(e) = std::fs::create_dir_all(parent) {
1636 warn!(path = %parent.display(), error = %e, "adaptive: snapshot mkdir failed");
1637 return;
1638 }
1639 }
1640 let nanos = std::time::SystemTime::now()
1647 .duration_since(std::time::UNIX_EPOCH)
1648 .map(|d| d.subsec_nanos())
1649 .unwrap_or(0);
1650 let counter = SAVE_COUNTER.fetch_add(1, Ordering::Relaxed);
1651 let tmp = path.with_extension(format!(
1652 "json.tmp.{}.{}.{}",
1653 std::process::id(),
1654 counter,
1655 nanos
1656 ));
1657 if let Err(e) = std::fs::write(&tmp, &bytes) {
1658 warn!(path = %tmp.display(), error = %e, "adaptive: snapshot write failed");
1659 return;
1660 }
1661 if let Err(e) = std::fs::rename(&tmp, path) {
1662 warn!(
1663 from = %tmp.display(),
1664 to = %path.display(),
1665 error = %e,
1666 "adaptive: snapshot rename failed",
1667 );
1668 let _ = std::fs::remove_file(&tmp);
1671 }
1672}
1673
1674#[cfg(feature = "native")]
1684pub fn save_snapshot_with_timeout(path: PathBuf, channels: ChannelStart, timeout: Duration) {
1685 let handle = std::thread::spawn(move || {
1686 save_snapshot(&path, channels);
1687 });
1688 let started = Instant::now();
1692 let poll = Duration::from_millis(5);
1693 while started.elapsed() < timeout {
1694 if handle.is_finished() {
1695 let _ = handle.join();
1696 return;
1697 }
1698 std::thread::sleep(poll);
1699 }
1700 warn!(
1704 timeout_ms = timeout.as_millis() as u64,
1705 "adaptive: snapshot save timed out (data dir slow?); detaching writer thread"
1706 );
1707 drop(handle);
1708}
1709
1710#[cfg(test)]
1711#[allow(clippy::unwrap_used)]
1712mod tests {
1713 use super::*;
1714
1715 const HILL_TEST_START_CAP: usize = 16;
1716 const HILL_TEST_UP_PROBE_CAP: usize = 20;
1717 const HILL_TEST_NEXT_UP_PROBE_CAP: usize = 25;
1718 const HILL_TEST_DOWN_PROBE_CAP: usize = 12;
1719 const HILL_TEST_CHUNK_BYTES: u64 = 1_000;
1720 const HILL_TEST_BASE_LATENCY_MS: u64 = 100;
1721 const HILL_TEST_REJECT_LATENCY_MS: u64 = 130;
1722 const HILL_TEST_RETAINED_DOWN_LATENCY_MS: u64 = 75;
1723 const HILL_TEST_ASYNC_LATENCY_MS: u64 = 10;
1724
1725 fn cfg_for_tests() -> LimiterConfig {
1726 LimiterConfig {
1727 enabled: true,
1728 min_concurrency: 1,
1729 max_concurrency: 64,
1730 window_ops: 10,
1731 min_window_ops: 5,
1732 success_target: 0.9,
1733 timeout_ceiling: 0.2,
1734 latency_inflation_factor: 2.0,
1735 latency_ewma_alpha: 0.5,
1736 slow_start_ramp_threshold: 0,
1737 latency_decrease_enabled: true,
1738 retain_increase_credit_on_decrease: false,
1739 }
1740 }
1741
1742 fn hill_cfg_for_tests() -> LimiterConfig {
1743 LimiterConfig {
1744 window_ops: 4,
1745 min_window_ops: 2,
1746 max_concurrency: 64,
1747 success_target: 0.9,
1748 timeout_ceiling: 0.2,
1749 ..cfg_for_tests()
1750 }
1751 }
1752
1753 fn fetch_hill_for_tests(start: usize, cfg: LimiterConfig) -> Limiter {
1754 Limiter::new_with_algorithm(start, cfg, LimiterAlgorithm::ThroughputHillClimb)
1755 }
1756
1757 fn observe_hill_success_epoch_with_latency(
1758 limiter: &Limiter,
1759 cfg: &LimiterConfig,
1760 bytes: u64,
1761 latency: Duration,
1762 ) {
1763 let samples = hill_epoch_target_samples(limiter.current(), cfg);
1764 for _ in 0..samples {
1765 limiter.observe_with_bytes(Outcome::Success, latency, bytes);
1766 }
1767 }
1768
1769 fn observe_hill_success_epoch(limiter: &Limiter, cfg: &LimiterConfig, bytes: u64) {
1770 observe_hill_success_epoch_with_latency(
1771 limiter,
1772 cfg,
1773 bytes,
1774 Duration::from_millis(HILL_TEST_BASE_LATENCY_MS),
1775 );
1776 }
1777
1778 fn adaptive_cfg_for_tests() -> AdaptiveConfig {
1783 let l = cfg_for_tests();
1784 AdaptiveConfig {
1785 enabled: l.enabled,
1786 min_concurrency: l.min_concurrency,
1787 max: ChannelMax {
1788 quote: l.max_concurrency,
1789 store: l.max_concurrency,
1790 fetch: l.max_concurrency,
1791 },
1792 window_ops: l.window_ops,
1793 min_window_ops: l.min_window_ops,
1794 success_target: l.success_target,
1795 timeout_ceiling: l.timeout_ceiling,
1796 latency_inflation_factor: l.latency_inflation_factor,
1797 latency_ewma_alpha: l.latency_ewma_alpha,
1798 }
1799 }
1800
1801 #[test]
1802 fn timed_fetch_epochs_require_evidence_and_ignore_previous_caps() {
1803 let limiter = AdaptiveController::default().fetch;
1804 let initial = limiter.observation_epoch();
1805 for _ in 0..7 {
1806 limiter.observe_fetch_in_epoch(
1807 Outcome::Success,
1808 Duration::from_secs(3),
1809 1024 * 1024,
1810 initial,
1811 );
1812 }
1813 assert_eq!(limiter.current(), 4);
1814 limiter.observe_fetch_in_epoch(
1815 Outcome::Success,
1816 Duration::from_secs(3),
1817 1024 * 1024,
1818 initial,
1819 );
1820 assert_eq!(
1821 limiter.current(),
1822 5,
1823 "a slow epoch must learn before 32 completions"
1824 );
1825 let next = limiter.observation_epoch();
1826 assert_ne!(next, initial);
1827 limiter.observe_fetch_in_epoch(Outcome::Timeout, Duration::from_secs(10), 0, initial);
1828 assert_eq!(
1829 lock(&limiter.inner).hill.epoch_samples,
1830 0,
1831 "old work cannot train the new probe"
1832 );
1833 limiter.observe_fetch_in_epoch(Outcome::Success, Duration::from_secs(3), 1024 * 1024, next);
1834 assert_eq!(lock(&limiter.inner).hill.epoch_samples, 1);
1835 }
1836
1837 #[test]
1838 fn fast_fetch_epochs_keep_the_full_sample_window() {
1839 let limiter = AdaptiveController::default().fetch;
1840 for _ in 0..8 {
1841 limiter.observe_with_bytes(Outcome::Success, Duration::from_millis(1), 1024);
1842 }
1843 assert_eq!(limiter.current(), 4);
1844 }
1845
1846 #[test]
1847 fn warm_start_keeps_slow_start_armed_below_protected_threshold() {
1848 let cfg = LimiterConfig {
1857 max_concurrency: 256,
1858 slow_start_ramp_threshold: 256,
1859 latency_decrease_enabled: false,
1860 ..cfg_for_tests()
1861 };
1862 let l = Limiter::new(64, cfg.clone());
1863 l.warm_start(20);
1864 assert_eq!(l.current(), 20);
1865 for _ in 0..cfg.window_ops {
1868 l.observe(Outcome::Success, Duration::from_millis(10));
1869 }
1870 assert_eq!(
1871 l.current(),
1872 40,
1873 "protected channel must double after warm_start, not crawl +1",
1874 );
1875
1876 let default_cfg = LimiterConfig {
1879 max_concurrency: 256,
1880 ..cfg_for_tests()
1881 };
1882 let d = Limiter::new(64, default_cfg.clone());
1883 d.warm_start(20);
1884 for _ in 0..default_cfg.window_ops {
1885 d.observe(Outcome::Success, Duration::from_millis(10));
1886 }
1887 assert_eq!(
1888 d.current(),
1889 21,
1890 "default channel must stay additive after warm_start",
1891 );
1892 }
1893
1894 #[test]
1895 fn slow_start_stays_armed_at_ceiling_with_max_threshold() {
1896 let base = LimiterConfig {
1905 max_concurrency: 256,
1906 latency_decrease_enabled: false,
1907 ..cfg_for_tests()
1908 };
1909 let fixed = Limiter::new(
1910 256,
1911 LimiterConfig {
1912 slow_start_ramp_threshold: usize::MAX,
1913 ..base.clone()
1914 },
1915 );
1916 let buggy = Limiter::new(
1917 256,
1918 LimiterConfig {
1919 slow_start_ramp_threshold: 256,
1920 ..base.clone()
1921 },
1922 );
1923 for l in [&fixed, &buggy] {
1924 for _ in 0..base.window_ops {
1925 l.observe(Outcome::Timeout, Duration::from_millis(10));
1926 }
1927 for _ in 0..(base.window_ops * 10) {
1928 l.observe(Outcome::Success, Duration::from_millis(10));
1929 }
1930 }
1931 assert!(
1932 fixed.current() > buggy.current(),
1933 "MAX-threshold limiter ({}) must out-recover the ceiling-threshold one ({})",
1934 fixed.current(),
1935 buggy.current(),
1936 );
1937 }
1938
1939 #[test]
1940 fn protected_slow_start_recovers_faster_than_additive() {
1941 let base = LimiterConfig {
1946 max_concurrency: 256,
1947 latency_decrease_enabled: false,
1948 ..cfg_for_tests()
1949 };
1950 let protected = Limiter::new(
1951 64,
1952 LimiterConfig {
1953 slow_start_ramp_threshold: 256,
1954 ..base.clone()
1955 },
1956 );
1957 let unprotected = Limiter::new(
1958 64,
1959 LimiterConfig {
1960 slow_start_ramp_threshold: 0,
1961 ..base.clone()
1962 },
1963 );
1964
1965 for l in [&protected, &unprotected] {
1967 for _ in 0..base.window_ops {
1968 l.observe(Outcome::Timeout, Duration::from_millis(10));
1969 }
1970 }
1971 for l in [&protected, &unprotected] {
1975 for _ in 0..(base.window_ops * 10) {
1976 l.observe(Outcome::Success, Duration::from_millis(10));
1977 }
1978 }
1979 assert!(
1980 protected.current() > unprotected.current(),
1981 "protected slow-start ({}) should recover faster than additive ({})",
1982 protected.current(),
1983 unprotected.current(),
1984 );
1985 }
1986
1987 #[test]
1988 fn latency_decrease_disabled_ignores_p95_inflation() {
1989 let cfg = LimiterConfig {
1995 max_concurrency: 256,
1996 slow_start_ramp_threshold: 256,
1997 latency_decrease_enabled: false,
1998 ..cfg_for_tests()
1999 };
2000 let l = Limiter::new(16, cfg.clone());
2001 for _ in 0..cfg.window_ops {
2003 l.observe(Outcome::Success, Duration::from_millis(5));
2004 }
2005 let after_baseline = l.current();
2006 for _ in 0..cfg.window_ops {
2010 l.observe(Outcome::Success, Duration::from_millis(500));
2011 }
2012 assert!(
2013 l.current() >= after_baseline,
2014 "latency inflation must not shrink the cap when the check is disabled: {} < {}",
2015 l.current(),
2016 after_baseline,
2017 );
2018 }
2019
2020 #[test]
2021 fn controller_sets_fetch_channel_download_tuning() {
2022 let c = AdaptiveController::new(ChannelStart::default(), AdaptiveConfig::default());
2026 assert!(
2027 !c.fetch.config.latency_decrease_enabled,
2028 "fetch latency-decrease must be disabled",
2029 );
2030 assert_eq!(
2031 c.fetch.config.slow_start_ramp_threshold,
2032 usize::MAX,
2033 "fetch slow-start must never exit (armed at every cap incl. ceiling)",
2034 );
2035 assert!(
2036 c.quote.config.latency_decrease_enabled,
2037 "quote must keep the latency-decrease check",
2038 );
2039 assert_eq!(
2040 c.quote.config.slow_start_ramp_threshold, 0,
2041 "quote must keep classic AIMD slow-start exit",
2042 );
2043 assert!(
2044 !c.quote.config.retain_increase_credit_on_decrease,
2045 "quote must keep the classic gate (Decrease resets the increase counter)",
2046 );
2047 assert!(
2048 c.store.config.retain_increase_credit_on_decrease,
2049 "store must retain increase credit across a Decrease (V2-554)",
2050 );
2051 assert!(
2052 (c.store.config.success_target - 0.88).abs() < f64::EPSILON,
2053 "store must relax success_target to 0.88 so a few-percent shortfall still ramps (V2-554), got {}",
2054 c.store.config.success_target,
2055 );
2056 assert!(
2057 (c.quote.config.success_target - c.config().success_target).abs() < f64::EPSILON,
2058 "quote must keep the global success_target",
2059 );
2060 assert!(
2064 !c.store.config.latency_decrease_enabled,
2065 "store latency-decrease must be disabled (verification variance is not congestion)",
2066 );
2067 assert_eq!(
2068 c.store.config.slow_start_ramp_threshold,
2069 usize::MAX,
2070 "store slow-start must never exit so a transient Decrease re-doubles",
2071 );
2072 assert_eq!(
2075 c.store.current(),
2076 ChannelStart::default().store,
2077 "store cold-start floor must remain unchanged at 8",
2078 );
2079 }
2080
2081 #[test]
2082 fn store_channel_ramps_and_recovers_under_v2_468_tuning() {
2083 let mut adaptive = adaptive_cfg_for_tests();
2089 adaptive.max.store = 256;
2091 let c = AdaptiveController::new(
2092 ChannelStart {
2093 quote: 8,
2094 store: 8,
2095 fetch: 8,
2096 },
2097 adaptive,
2098 );
2099 let store = &c.store;
2100 let win = c.config().window_ops;
2101
2102 for _ in 0..win {
2105 store.observe(Outcome::Success, Duration::from_millis(5));
2106 }
2107 let after_baseline = store.current();
2108 assert!(after_baseline >= 8, "store should ramp on healthy windows");
2109 for _ in 0..win {
2110 store.observe(Outcome::Success, Duration::from_secs(30));
2111 }
2112 assert!(
2113 store.current() >= after_baseline,
2114 "verification-latency p95 must not shrink store cap: {} < {}",
2115 store.current(),
2116 after_baseline,
2117 );
2118
2119 let before_stress = store.current();
2121 for _ in 0..win {
2122 store.observe(Outcome::Timeout, Duration::from_millis(50));
2123 }
2124 let after_stress = store.current();
2125 assert!(
2126 after_stress < before_stress,
2127 "timeout-rate breach must still cut the store cap: {after_stress} !< {before_stress}",
2128 );
2129
2130 for _ in 0..(win * 8) {
2136 store.observe(Outcome::Success, Duration::from_millis(5));
2137 }
2138 assert!(
2139 store.current() >= before_stress,
2140 "store must re-double back to {before_stress} after a transient Decrease, got {}",
2141 store.current(),
2142 );
2143 }
2144
2145 #[test]
2146 fn store_application_rejections_do_not_move_cap() {
2147 let mut adaptive = adaptive_cfg_for_tests();
2151 adaptive.max.store = 256;
2152 let c = AdaptiveController::new(
2153 ChannelStart {
2154 quote: 8,
2155 store: 8,
2156 fetch: 8,
2157 },
2158 adaptive,
2159 );
2160 let store = &c.store;
2161 let start = store.current();
2162 for _ in 0..(c.config().window_ops * 5) {
2163 store.observe(Outcome::ApplicationError, Duration::from_secs(30));
2164 }
2165 assert_eq!(
2166 store.current(),
2167 start,
2168 "remote app-rejections must not move the store cap",
2169 );
2170 }
2171
2172 #[test]
2173 fn store_gate_rebalance_ramps_where_classic_gate_pins() {
2174 let build = |success_target: f64, retain: bool| {
2185 let mut cfg = cfg_for_tests();
2186 cfg.min_concurrency = 1;
2187 cfg.max_concurrency = 256;
2188 cfg.window_ops = 32;
2189 cfg.min_window_ops = 8;
2190 cfg.success_target = success_target;
2191 cfg.timeout_ceiling = 0.10;
2192 cfg.slow_start_ramp_threshold = usize::MAX;
2195 cfg.latency_decrease_enabled = false;
2196 cfg.retain_increase_credit_on_decrease = retain;
2197 Limiter::new(8, cfg)
2198 };
2199 let fixed = build(0.88, true);
2200 let classic = build(0.95, false);
2201
2202 for _ in 0..80 {
2203 for _ in 0..20 {
2204 fixed.observe(Outcome::Success, Duration::from_millis(5));
2205 classic.observe(Outcome::Success, Duration::from_millis(5));
2206 }
2207 fixed.observe(Outcome::NetworkError, Duration::from_millis(5));
2208 classic.observe(Outcome::NetworkError, Duration::from_millis(5));
2209 }
2210
2211 assert!(
2214 fixed.current() >= 32,
2215 "rebalanced store gate must ramp off the floor under ~5% shortfall, got {}",
2216 fixed.current(),
2217 );
2218 assert!(
2221 classic.current() <= 8,
2222 "classic gate should stay pinned near the floor on the same input, got {}",
2223 classic.current(),
2224 );
2225 assert!(
2226 fixed.current() > classic.current(),
2227 "rebalanced gate {} must out-grow the classic gate {}",
2228 fixed.current(),
2229 classic.current(),
2230 );
2231 }
2232
2233 #[test]
2234 fn cold_start_clamps_into_bounds() {
2235 let cfg = cfg_for_tests();
2236 let l = Limiter::new(1000, cfg.clone());
2237 assert_eq!(l.current(), cfg.max_concurrency);
2238 let l = Limiter::new(0, cfg.clone());
2239 assert_eq!(l.current(), cfg.min_concurrency);
2240 }
2241
2242 #[test]
2243 fn slow_start_doubles_then_caps() {
2244 let cfg = cfg_for_tests();
2245 let l = Limiter::new(2, cfg.clone());
2246 for _ in 0..cfg.window_ops {
2248 l.observe(Outcome::Success, Duration::from_millis(50));
2249 }
2250 assert_eq!(l.current(), 4);
2251 for _ in 0..cfg.window_ops {
2252 l.observe(Outcome::Success, Duration::from_millis(50));
2253 }
2254 assert_eq!(l.current(), 8);
2255 }
2256
2257 #[test]
2258 fn first_failure_exits_slow_start() {
2259 let cfg = cfg_for_tests();
2260 let l = Limiter::new(4, cfg.clone());
2261 for _ in 0..6 {
2265 l.observe(Outcome::Success, Duration::from_millis(50));
2266 }
2267 for _ in 0..4 {
2268 l.observe(Outcome::Timeout, Duration::from_millis(50));
2269 }
2270 let after_stress = l.current();
2271 assert!(
2272 after_stress < 4,
2273 "stress should reduce concurrency from 4, got {after_stress}",
2274 );
2275 for _ in 0..(cfg.window_ops * 5) {
2283 l.observe(Outcome::Success, Duration::from_millis(50));
2284 }
2285 assert!(
2286 l.current() > after_stress,
2287 "expected recovery above {after_stress}, got {}",
2288 l.current(),
2289 );
2290 }
2291
2292 #[test]
2293 fn floor_holds_at_one() {
2294 let cfg = cfg_for_tests();
2295 let l = Limiter::new(2, cfg);
2296 for _ in 0..30 {
2297 l.observe(Outcome::Timeout, Duration::from_millis(50));
2298 }
2299 assert_eq!(l.current(), 1);
2300 }
2301
2302 #[test]
2303 fn application_errors_do_not_punish() {
2304 let cfg = cfg_for_tests();
2305 let l = Limiter::new(4, cfg.clone());
2306 for _ in 0..cfg.window_ops * 5 {
2313 l.observe(Outcome::ApplicationError, Duration::from_millis(50));
2314 }
2315 assert_eq!(
2316 l.current(),
2317 4,
2318 "ApplicationError must not move the cap; got {}",
2319 l.current()
2320 );
2321 }
2322
2323 #[test]
2324 fn latency_inflation_triggers_decrease() {
2325 let cfg = LimiterConfig {
2326 window_ops: 20,
2327 min_window_ops: 5,
2328 ..cfg_for_tests()
2329 };
2330 let l = Limiter::new(4, cfg.clone());
2331 for _ in 0..cfg.window_ops {
2333 l.observe(Outcome::Success, Duration::from_millis(50));
2334 }
2335 let after_baseline = l.current();
2336 for _ in 0..cfg.window_ops {
2338 l.observe(Outcome::Success, Duration::from_millis(500));
2339 }
2340 assert!(
2342 l.current() < after_baseline,
2343 "expected decrease from {after_baseline}, got {}",
2344 l.current(),
2345 );
2346 }
2347
2348 #[test]
2349 fn warm_start_overrides_current() {
2350 let cfg = cfg_for_tests();
2351 let l = Limiter::new(2, cfg);
2352 l.warm_start(20);
2353 assert_eq!(l.current(), 20);
2354 }
2355
2356 #[test]
2357 fn warm_start_clamps() {
2358 let cfg = cfg_for_tests();
2359 let l = Limiter::new(2, cfg.clone());
2360 l.warm_start(1_000_000);
2361 assert_eq!(l.current(), cfg.max_concurrency);
2362 }
2363
2364 #[test]
2365 fn disabled_controller_holds_steady() {
2366 let cfg = LimiterConfig {
2367 enabled: false,
2368 ..cfg_for_tests()
2369 };
2370 let l = Limiter::new(8, cfg);
2371 for _ in 0..50 {
2372 l.observe(Outcome::Timeout, Duration::from_millis(50));
2373 }
2374 assert_eq!(l.current(), 8);
2375 }
2376
2377 #[test]
2378 fn controller_snapshot_round_trips() {
2379 let c = AdaptiveController::new(
2385 ChannelStart {
2386 quote: 64,
2387 store: 16,
2388 fetch: 64,
2389 },
2390 adaptive_cfg_for_tests(),
2391 );
2392 let snap = c.snapshot();
2393 assert_eq!(snap.quote, 64);
2394 assert_eq!(snap.store, 16);
2395 assert_eq!(snap.fetch, 64);
2396
2397 let c2 = AdaptiveController::default();
2398 c2.warm_start(snap);
2399 assert_eq!(c2.quote.current(), 64);
2400 assert_eq!(c2.store.current(), 16);
2401 assert_eq!(c2.fetch.current(), 64);
2402 }
2403
2404 #[tokio::test]
2405 async fn observe_op_records_success() {
2406 let cfg = cfg_for_tests();
2407 let l = Limiter::new(4, cfg.clone());
2408 for _ in 0..cfg.window_ops {
2409 let _: Result<(), &str> =
2410 observe_op(&l, || async { Ok(()) }, |_e: &&str| Outcome::NetworkError).await;
2411 }
2412 assert_eq!(l.current(), 8);
2414 }
2415
2416 #[test]
2417 fn snapshot_round_trips_through_disk() {
2418 let dir = tempfile::tempdir().unwrap();
2419 let path = dir.path().join("client_adaptive.json");
2420 let snap = ChannelStart {
2421 quote: 24,
2422 store: 6,
2423 fetch: 12,
2424 };
2425 save_snapshot(&path, snap);
2426 let loaded = load_snapshot(&path).unwrap();
2427 assert_eq!(loaded.quote, 24);
2428 assert_eq!(loaded.store, 6);
2429 assert_eq!(loaded.fetch, 12);
2430 }
2431
2432 #[test]
2433 fn load_missing_returns_none() {
2434 let dir = tempfile::tempdir().unwrap();
2435 let path = dir.path().join("does_not_exist.json");
2436 assert!(load_snapshot(&path).is_none());
2437 }
2438
2439 #[test]
2440 fn load_corrupt_returns_none() {
2441 let dir = tempfile::tempdir().unwrap();
2442 let path = dir.path().join("bad.json");
2443 std::fs::write(&path, b"not valid json{{{").unwrap();
2444 assert!(load_snapshot(&path).is_none());
2445 }
2446
2447 #[test]
2448 fn load_wrong_schema_returns_none() {
2449 let dir = tempfile::tempdir().unwrap();
2450 let path = dir.path().join("future.json");
2451 let payload = r#"{"schema":999,"channels":{"quote":1,"store":1,"fetch":1}}"#;
2454 std::fs::write(&path, payload).unwrap();
2455 assert!(load_snapshot(&path).is_none());
2456 }
2457
2458 #[test]
2459 fn load_schema_one_preserves_quote_store_and_resets_fetch() {
2460 const LEGACY_QUOTE_CAP: usize = 48;
2461 const LEGACY_STORE_CAP: usize = 24;
2462 const LEGACY_FETCH_CAP: usize = 96;
2463
2464 let dir = tempfile::tempdir().unwrap();
2465 let path = dir.path().join("legacy.json");
2466 let payload = format!(
2467 r#"{{"schema":{},"channels":{{"quote":{},"store":{},"fetch":{}}}}}"#,
2468 PERSIST_SCHEMA_AIMD_FETCH, LEGACY_QUOTE_CAP, LEGACY_STORE_CAP, LEGACY_FETCH_CAP,
2469 );
2470 std::fs::write(&path, payload).unwrap();
2471
2472 let loaded = load_snapshot(&path).unwrap();
2473
2474 assert_eq!(loaded.quote, LEGACY_QUOTE_CAP);
2475 assert_eq!(loaded.store, LEGACY_STORE_CAP);
2476 assert_eq!(loaded.fetch, FETCH_COLD_START_CONCURRENCY);
2477 }
2478
2479 #[tokio::test]
2480 async fn observe_op_records_classified_error() {
2481 let cfg = cfg_for_tests();
2482 let l = Limiter::new(4, cfg.clone());
2483 for _ in 0..cfg.window_ops {
2484 let _: Result<(), &str> =
2485 observe_op(&l, || async { Err("boom") }, |_e: &&str| Outcome::Timeout).await;
2486 }
2487 assert!(l.current() < 4);
2488 }
2489
2490 #[test]
2500 fn no_regression_cold_start_at_least_static_defaults() {
2501 let s = ChannelStart::default();
2502 assert!(
2503 s.quote >= 32,
2504 "quote cold-start regressed: got {}, prior static was 32",
2505 s.quote,
2506 );
2507 assert!(
2508 s.store >= 8,
2509 "store cold-start regressed: got {}, prior static was 8",
2510 s.store,
2511 );
2512 assert_eq!(
2513 s.fetch, FETCH_COLD_START_CONCURRENCY,
2514 "fetch cold-start changed unexpectedly: got {}, expected {}",
2515 s.fetch, FETCH_COLD_START_CONCURRENCY,
2516 );
2517 }
2518
2519 #[test]
2523 fn controller_default_config_is_sane() {
2524 let c = AdaptiveController::default();
2525 let starts = ChannelStart::default();
2526 assert_eq!(c.quote.current(), starts.quote);
2527 assert_eq!(c.store.current(), starts.store);
2528 assert_eq!(c.fetch.current(), starts.fetch);
2529 assert_eq!(lock(&c.quote.inner).window.len(), 0);
2531 assert_eq!(lock(&c.store.inner).window.len(), 0);
2532 assert_eq!(lock(&c.fetch.inner).window.len(), 0);
2533 }
2534
2535 #[test]
2539 fn alternating_success_failure_collapses_to_floor() {
2540 let cfg = cfg_for_tests();
2546 let l = Limiter::new(8, cfg.clone());
2547 let mut min_observed = usize::MAX;
2548 let mut max_observed = 0usize;
2549 let mut floor_visits = 0usize;
2550 for i in 0..1000 {
2551 let outcome = if i % 2 == 0 {
2552 Outcome::Success
2553 } else {
2554 Outcome::Timeout
2555 };
2556 l.observe(outcome, Duration::from_millis(50));
2557 let cur = l.current();
2558 assert!(
2559 cur >= cfg.min_concurrency,
2560 "cap underflowed floor at iter {i}: got {cur}",
2561 );
2562 min_observed = min_observed.min(cur);
2563 max_observed = max_observed.max(cur);
2564 if cur == cfg.min_concurrency {
2565 floor_visits += 1;
2566 }
2567 }
2568 assert_eq!(
2569 min_observed, cfg.min_concurrency,
2570 "cap never reached the floor under 50% timeout rate"
2571 );
2572 assert!(
2573 max_observed >= 8,
2574 "cap never visited the start value: max_observed={max_observed}"
2575 );
2576 assert!(
2580 floor_visits > 500,
2581 "cap spent only {floor_visits}/1000 ticks at floor; expected mostly at floor"
2582 );
2583 assert_eq!(
2584 l.current(),
2585 cfg.min_concurrency,
2586 "controller did not settle at floor after 1000 alternations"
2587 );
2588 }
2589
2590 #[test]
2594 fn pure_success_stream_recovers_to_max() {
2595 let cfg = cfg_for_tests();
2596 let l = Limiter::new(cfg.min_concurrency, cfg.clone());
2597 for _ in 0..10_000 {
2598 l.observe(Outcome::Success, Duration::from_millis(5));
2599 }
2600 assert_eq!(
2601 l.current(),
2602 cfg.max_concurrency,
2603 "expected recovery to max ({}), got {}",
2604 cfg.max_concurrency,
2605 l.current(),
2606 );
2607 }
2608
2609 #[test]
2613 fn stress_then_heal_drives_floor_then_recovery() {
2614 let cfg = cfg_for_tests();
2615 let l = Limiter::new(8, cfg.clone());
2616 for _ in 0..100 {
2617 l.observe(Outcome::Timeout, Duration::from_millis(50));
2618 }
2619 let after_stress = l.current();
2620 assert_eq!(
2621 after_stress, cfg.min_concurrency,
2622 "stress should drive cap to floor, got {after_stress}",
2623 );
2624 for _ in 0..1_000 {
2625 l.observe(Outcome::Success, Duration::from_millis(10));
2626 }
2627 let after_heal = l.current();
2628 assert!(
2629 after_heal >= cfg.min_concurrency.saturating_add(4),
2630 "expected substantial recovery from floor, got {after_heal}",
2631 );
2632 }
2633
2634 #[test]
2638 fn baseline_does_not_grow_unbounded_under_slow_links() {
2639 let cfg = cfg_for_tests();
2640 let l = Limiter::new(2, cfg.clone());
2641 for _ in 0..(cfg.window_ops * 10) {
2642 l.observe(Outcome::Success, Duration::from_millis(500));
2643 }
2644 let baseline = lock(&l.inner).latency_baseline;
2645 let base = baseline.expect("baseline should be set after many healthy windows");
2646 assert!(
2647 base > Duration::ZERO,
2648 "baseline must not stay at ZERO, got {base:?}",
2649 );
2650 let lo = Duration::from_millis(250);
2652 let hi = Duration::from_millis(1000);
2653 assert!(
2654 base >= lo && base <= hi,
2655 "baseline drifted out of [{lo:?}, {hi:?}]: {base:?}",
2656 );
2657 }
2658
2659 #[test]
2664 fn baseline_initialized_only_after_first_healthy_window() {
2665 let cfg = cfg_for_tests();
2666 let l = Limiter::new(8, cfg.clone());
2667 for _ in 0..50 {
2668 l.observe(Outcome::Timeout, Duration::from_millis(50));
2669 }
2670 assert!(
2672 lock(&l.inner).latency_baseline.is_none(),
2673 "baseline must be None before any healthy window",
2674 );
2675 for _ in 0..(cfg.window_ops * 5) {
2677 l.observe(Outcome::Success, Duration::from_millis(20));
2678 }
2679 let baseline = lock(&l.inner).latency_baseline;
2680 assert!(
2681 baseline.is_some(),
2682 "baseline must be Some after healthy windows",
2683 );
2684 let base = baseline.unwrap_or_default();
2685 assert!(
2686 base > Duration::ZERO,
2687 "baseline must reflect real latency, got {base:?}",
2688 );
2689 }
2690
2691 #[test]
2694 fn min_concurrency_floor_holds_under_torrent_of_errors() {
2695 let cfg = cfg_for_tests();
2696 let l = Limiter::new(8, cfg.clone());
2697 for i in 0..50_000 {
2698 l.observe(Outcome::Timeout, Duration::from_millis(50));
2699 if i == 100 || i == 1_000 || i == 49_999 {
2700 let cur = l.current();
2701 assert_eq!(
2702 cur, cfg.min_concurrency,
2703 "floor breached at iter {i}: got {cur}",
2704 );
2705 }
2706 }
2707 }
2708
2709 #[test]
2711 fn max_concurrency_ceiling_holds_under_torrent_of_successes() {
2712 let cfg = cfg_for_tests();
2713 let start = cfg
2714 .max_concurrency
2715 .saturating_sub(1)
2716 .max(cfg.min_concurrency);
2717 let l = Limiter::new(start, cfg.clone());
2718 for i in 0..50_000 {
2719 l.observe(Outcome::Success, Duration::from_millis(5));
2720 if i == 100 || i == 1_000 || i == 49_999 {
2721 let cur = l.current();
2722 assert!(
2723 cur <= cfg.max_concurrency,
2724 "ceiling breached at iter {i}: got {cur} > {}",
2725 cfg.max_concurrency,
2726 );
2727 }
2728 }
2729 assert_eq!(l.current(), cfg.max_concurrency);
2730 }
2731
2732 #[test]
2738 fn saturating_arithmetic_handles_extreme_config() {
2739 let cfg = LimiterConfig {
2740 max_concurrency: usize::MAX / 2,
2741 ..cfg_for_tests()
2742 };
2743 let start = usize::MAX / 4;
2744 let l = Limiter::new(start, cfg.clone());
2745 for _ in 0..(cfg.window_ops * 10) {
2746 l.observe(Outcome::Success, Duration::from_millis(1));
2747 }
2748 assert_eq!(
2753 l.current(),
2754 cfg.max_concurrency,
2755 "saturating math survived but cap did not grow to ceiling"
2756 );
2757 }
2758
2759 #[test]
2766 fn window_eviction_is_fifo() {
2767 let cfg = LimiterConfig {
2768 window_ops: 10,
2769 min_window_ops: 5,
2770 success_target: 0.9,
2771 timeout_ceiling: 0.1,
2772 ..cfg_for_tests()
2773 };
2774 let l = Limiter::new(8, cfg.clone());
2775 for _ in 0..cfg.window_ops {
2780 l.observe(Outcome::Timeout, Duration::from_millis(50));
2781 }
2782 let after_stress = l.current();
2783 assert!(
2784 after_stress < 8,
2785 "expected cap to drop from 8 after pure-timeout window, got {after_stress}"
2786 );
2787 for _ in 0..(cfg.window_ops * 3) {
2792 l.observe(Outcome::Success, Duration::from_millis(20));
2793 }
2794 let after_recovery = l.current();
2795 assert!(
2798 after_recovery > after_stress,
2799 "FIFO eviction broken: cap stayed at {after_stress} after recovery successes (expected > {after_stress}, got {after_recovery})"
2800 );
2801 }
2802
2803 #[test]
2806 fn disabled_controller_returns_initial_value_invariantly() {
2807 let cfg = LimiterConfig {
2808 enabled: false,
2809 ..cfg_for_tests()
2810 };
2811 let initial = 8;
2812 let l = Limiter::new(initial, cfg);
2813 for i in 0..1_000 {
2814 let outcome = match i % 4 {
2815 0 => Outcome::Success,
2816 1 => Outcome::Timeout,
2817 2 => Outcome::NetworkError,
2818 _ => Outcome::ApplicationError,
2819 };
2820 l.observe(outcome, Duration::from_millis(50));
2821 assert_eq!(
2822 l.current(),
2823 initial,
2824 "disabled controller moved at iter {i}",
2825 );
2826 }
2827 }
2828
2829 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2832 async fn concurrent_observations_do_not_corrupt_window() {
2833 let cfg = cfg_for_tests();
2834 let l = Limiter::new(4, cfg.clone());
2835 let mut handles = Vec::with_capacity(100);
2836 for _ in 0..100 {
2837 let l_clone = l.clone();
2838 handles.push(tokio::spawn(async move {
2839 for _ in 0..100 {
2840 l_clone.observe(Outcome::Success, Duration::from_millis(5));
2841 }
2842 }));
2843 }
2844 for h in handles {
2845 h.await.unwrap();
2846 }
2847 let cur = l.current();
2848 assert!(
2849 cur >= cfg.min_concurrency && cur <= cfg.max_concurrency,
2850 "cap out of bounds after concurrent observations: {cur}",
2851 );
2852 }
2853
2854 #[test]
2859 fn persisted_snapshot_warm_starts_above_cold_floor() {
2860 let dir = tempfile::tempdir().unwrap();
2861 let path = dir.path().join("client_adaptive.json");
2862 let saved = ChannelStart {
2865 quote: 64,
2866 store: 32,
2867 fetch: 128,
2868 };
2869 save_snapshot(&path, saved);
2870 let loaded = load_snapshot(&path).unwrap();
2871
2872 let low = ChannelStart {
2875 quote: 2,
2876 store: 2,
2877 fetch: 2,
2878 };
2879 let c = AdaptiveController::new(low, AdaptiveConfig::default());
2880 c.warm_start(loaded);
2881 assert_eq!(c.quote.current(), 64);
2882 assert_eq!(c.store.current(), 32);
2883 assert_eq!(c.fetch.current(), 128);
2884 }
2885
2886 #[test]
2890 fn save_load_round_trip_with_concurrent_writes() {
2891 use std::thread;
2892 let dir = tempfile::tempdir().unwrap();
2893 let path = dir.path().join("client_adaptive.json");
2894 let path_a = path.clone();
2895 let path_b = path.clone();
2896 let snap_a = ChannelStart {
2897 quote: 10,
2898 store: 10,
2899 fetch: 10,
2900 };
2901 let snap_b = ChannelStart {
2902 quote: 99,
2903 store: 99,
2904 fetch: 99,
2905 };
2906 let h_a = thread::spawn(move || {
2907 for _ in 0..50 {
2908 save_snapshot(&path_a, snap_a);
2909 }
2910 });
2911 let h_b = thread::spawn(move || {
2912 for _ in 0..50 {
2913 save_snapshot(&path_b, snap_b);
2914 }
2915 });
2916 h_a.join().unwrap();
2917 h_b.join().unwrap();
2918 let loaded = load_snapshot(&path).expect("file must be a valid snapshot, not torn");
2919 let valid = (loaded.quote == snap_a.quote
2920 && loaded.store == snap_a.store
2921 && loaded.fetch == snap_a.fetch)
2922 || (loaded.quote == snap_b.quote
2923 && loaded.store == snap_b.store
2924 && loaded.fetch == snap_b.fetch);
2925 assert!(valid, "loaded snapshot is neither A nor B: {loaded:?}",);
2926 }
2927
2928 #[test]
2931 fn save_snapshot_to_unwritable_dir_does_not_panic() {
2932 let blocker = tempfile::NamedTempFile::new().unwrap();
2938 let path = blocker.path().join("sub").join("client_adaptive.json");
2939 let snap = ChannelStart {
2940 quote: 1,
2941 store: 1,
2942 fetch: 1,
2943 };
2944 save_snapshot(&path, snap);
2946 assert!(!path.exists());
2948 }
2949
2950 #[test]
2953 fn load_snapshot_from_truncated_file_returns_none() {
2954 let dir = tempfile::tempdir().unwrap();
2955 let path = dir.path().join("truncated.json");
2956 std::fs::write(&path, br#"{"schema":1,"channels":{"quote":"#).unwrap();
2957 assert!(load_snapshot(&path).is_none());
2958 }
2959
2960 #[test]
2964 fn controller_perf_overhead_is_bounded() {
2965 let cfg = cfg_for_tests();
2966 let l = Limiter::new(8, cfg);
2967 let started = Instant::now();
2968 for _ in 0..100_000 {
2969 let _ = l.current();
2970 l.observe(Outcome::Success, Duration::from_micros(1));
2971 }
2972 let elapsed = started.elapsed();
2973 assert!(
2976 elapsed < Duration::from_millis(500),
2977 "100k observe+current pairs took {elapsed:?}, expected <500ms",
2978 );
2979 }
2980
2981 #[test]
2989 fn nan_and_out_of_range_config_does_not_panic() {
2990 let cfg = AdaptiveConfig {
2991 enabled: true,
2992 min_concurrency: 0, max: ChannelMax {
2994 quote: 0, store: 0,
2996 fetch: 0,
2997 },
2998 window_ops: 10,
2999 min_window_ops: 50, success_target: f64::NAN,
3001 timeout_ceiling: f64::INFINITY,
3002 latency_inflation_factor: f64::NEG_INFINITY,
3003 latency_ewma_alpha: f64::NAN,
3004 };
3005 let c = AdaptiveController::new(ChannelStart::default(), cfg);
3006 let post = &c.config;
3010 assert_eq!(
3011 post.min_concurrency, 1,
3012 "sanitize did not raise min_concurrency from 0"
3013 );
3014 assert!(
3015 post.success_target.is_finite() && (0.0..=1.0).contains(&post.success_target),
3016 "sanitize did not clamp success_target from NaN: {}",
3017 post.success_target
3018 );
3019 assert!(
3020 post.timeout_ceiling.is_finite() && (0.0..=1.0).contains(&post.timeout_ceiling),
3021 "sanitize did not clamp timeout_ceiling from Inf: {}",
3022 post.timeout_ceiling
3023 );
3024 assert!(
3025 post.latency_inflation_factor.is_finite() && post.latency_inflation_factor > 0.0,
3026 "sanitize did not fix latency_inflation_factor from -Inf: {}",
3027 post.latency_inflation_factor
3028 );
3029 assert!(
3030 post.latency_ewma_alpha.is_finite() && (0.0..=1.0).contains(&post.latency_ewma_alpha),
3031 "sanitize did not fix latency_ewma_alpha from NaN: {}",
3032 post.latency_ewma_alpha
3033 );
3034 assert!(
3035 post.min_window_ops <= post.window_ops,
3036 "sanitize did not clamp min_window_ops <= window_ops: min={} window={}",
3037 post.min_window_ops,
3038 post.window_ops
3039 );
3040 assert!(
3041 post.max.quote >= post.min_concurrency,
3042 "max.quote below min_concurrency"
3043 );
3044 for _ in 0..200 {
3047 c.store
3048 .observe(Outcome::Success, Duration::from_secs(99_999));
3049 c.store.observe(Outcome::Timeout, Duration::ZERO);
3050 }
3051 let cur = c.store.current();
3052 assert!(cur >= 1, "cap below floor: {cur}");
3053 }
3054
3055 #[test]
3062 fn transient_burst_does_not_pile_drive_to_floor() {
3063 let cfg = LimiterConfig {
3064 window_ops: 32,
3065 min_window_ops: 8,
3066 success_target: 0.95,
3067 timeout_ceiling: 0.10,
3068 ..cfg_for_tests()
3069 };
3070 let l = Limiter::new(32, cfg);
3071 for _ in 0..8 {
3075 l.observe(Outcome::Timeout, Duration::from_millis(10));
3076 }
3077 let after_burst = l.current();
3080 assert!(
3081 after_burst >= 16,
3082 "transient burst pile-drove cap from 32 to {after_burst}; expected >= 16",
3083 );
3084 }
3085
3086 #[tokio::test]
3091 async fn transport_errors_classify_as_capacity_signal() {
3092 use crate::data::client::classify_error;
3093 use crate::data::error::Error;
3094 let make_cfg = || LimiterConfig {
3095 window_ops: 16,
3096 min_window_ops: 5,
3097 success_target: 0.5,
3098 timeout_ceiling: 0.5,
3099 ..cfg_for_tests()
3100 };
3101 type ErrFactory = Box<dyn Fn() -> Error>;
3103 let cases: Vec<(&str, ErrFactory)> = vec![
3104 ("Network", Box::new(|| Error::Network("net".to_string()))),
3105 (
3106 "InsufficientPeers",
3107 Box::new(|| Error::InsufficientPeers("ip".to_string())),
3108 ),
3109 ("Io", Box::new(|| Error::Io(std::io::Error::other("io")))),
3110 ("Protocol", Box::new(|| Error::Protocol("p".to_string()))),
3111 ("Storage", Box::new(|| Error::Storage("s".to_string()))),
3112 (
3113 "PartialUpload",
3114 Box::new(|| Error::PartialUpload {
3115 stored: vec![],
3116 stored_count: 0,
3117 failed: vec![],
3118 failed_count: 0,
3119 total_chunks: 0,
3120 spend: Box::new(crate::data::error::PartialUploadSpend {
3121 storage_cost_atto: "0".to_string(),
3122 gas_cost_wei: 0,
3123 }),
3124 reason: "r".to_string(),
3125 }),
3126 ),
3127 ];
3128 for (name, mk) in &cases {
3129 let l = Limiter::new(8, make_cfg());
3130 for _ in 0..16 {
3131 let _: std::result::Result<(), Error> =
3132 observe_op(&l, || async { Err(mk()) }, classify_error).await;
3133 }
3134 let cur = l.current();
3138 assert!(
3139 cur < 8,
3140 "{name} not classified as capacity signal: cap stayed at {cur}",
3141 );
3142 }
3143 }
3144
3145 #[test]
3149 fn per_channel_ceilings_are_independent() {
3150 let cfg = AdaptiveConfig {
3151 max: ChannelMax {
3152 quote: 4, store: 8, fetch: 1024, },
3156 ..AdaptiveConfig::default()
3157 };
3158 let c = AdaptiveController::new(
3159 ChannelStart {
3160 quote: 4,
3161 store: 8,
3162 fetch: 64,
3163 },
3164 cfg,
3165 );
3166 for _ in 0..1000 {
3169 c.quote.observe(Outcome::Success, Duration::from_micros(10));
3170 c.store.observe(Outcome::Success, Duration::from_micros(10));
3171 c.fetch.observe(Outcome::Success, Duration::from_micros(10));
3172 }
3173 assert_eq!(c.quote.current(), 4, "quote should cap at 4");
3174 assert_eq!(c.store.current(), 8, "store should cap at 8");
3175 assert!(
3179 c.fetch.current() > 8 && c.fetch.current() <= 1024,
3180 "fetch did not use its independent ceiling; got {}",
3181 c.fetch.current()
3182 );
3183 }
3184
3185 #[test]
3186 fn fetch_hill_rejects_upward_probe_without_goodput_gain() {
3187 let cfg = hill_cfg_for_tests();
3188 let l = fetch_hill_for_tests(HILL_TEST_START_CAP, cfg.clone());
3189
3190 observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3191 assert_eq!(
3192 l.current(),
3193 HILL_TEST_UP_PROBE_CAP,
3194 "first healthy epoch should probe upward"
3195 );
3196
3197 observe_hill_success_epoch_with_latency(
3198 &l,
3199 &cfg,
3200 HILL_TEST_CHUNK_BYTES,
3201 Duration::from_millis(HILL_TEST_REJECT_LATENCY_MS),
3202 );
3203 assert_eq!(
3204 l.current(),
3205 HILL_TEST_START_CAP,
3206 "slower higher-cap wave should reject the upward probe"
3207 );
3208 assert_eq!(l.snapshot(), HILL_TEST_START_CAP);
3209 }
3210
3211 #[test]
3212 fn fetch_hill_accepts_upward_probe_with_goodput_gain() {
3213 let cfg = hill_cfg_for_tests();
3214 let l = fetch_hill_for_tests(HILL_TEST_START_CAP, cfg.clone());
3215
3216 observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3217 assert_eq!(l.current(), HILL_TEST_UP_PROBE_CAP);
3218
3219 observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3220 assert_eq!(
3221 l.snapshot(),
3222 HILL_TEST_UP_PROBE_CAP,
3223 "same-size chunks at same latency should promote the higher cap"
3224 );
3225 assert_eq!(
3226 l.current(),
3227 HILL_TEST_NEXT_UP_PROBE_CAP,
3228 "after accepting an upward probe, hill climber should probe higher"
3229 );
3230 }
3231
3232 #[test]
3233 fn fetch_hill_accepts_lower_probe_when_goodput_is_retained() {
3234 let cfg = hill_cfg_for_tests();
3235 let l = fetch_hill_for_tests(HILL_TEST_START_CAP, cfg.clone());
3236
3237 observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3238 observe_hill_success_epoch_with_latency(
3239 &l,
3240 &cfg,
3241 HILL_TEST_CHUNK_BYTES,
3242 Duration::from_millis(HILL_TEST_REJECT_LATENCY_MS),
3243 );
3244 assert_eq!(l.current(), HILL_TEST_START_CAP);
3245
3246 for _ in 0..(HILL_REJECT_COOLDOWN_EPOCHS + HILL_STABLE_PROBE_EPOCHS) {
3247 observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3248 }
3249 assert_eq!(
3250 l.current(),
3251 HILL_TEST_DOWN_PROBE_CAP,
3252 "stable best should eventually probe a lower cap"
3253 );
3254
3255 observe_hill_success_epoch_with_latency(
3256 &l,
3257 &cfg,
3258 HILL_TEST_CHUNK_BYTES,
3259 Duration::from_millis(HILL_TEST_RETAINED_DOWN_LATENCY_MS),
3260 );
3261 assert_eq!(
3262 l.snapshot(),
3263 HILL_TEST_DOWN_PROBE_CAP,
3264 "retained goodput at lower concurrency should become the new best"
3265 );
3266 }
3267
3268 #[tokio::test]
3269 async fn fetch_hill_records_constant_size_timed_ops_without_stress() {
3270 let cfg = hill_cfg_for_tests();
3271 let l = fetch_hill_for_tests(HILL_TEST_START_CAP, cfg.clone());
3272 let total_ops = hill_epoch_target_samples(HILL_TEST_START_CAP, &cfg)
3273 + hill_epoch_target_samples(HILL_TEST_UP_PROBE_CAP, &cfg);
3274 let limiter_for_ops = l.clone();
3275
3276 let result: std::result::Result<Vec<()>, ()> =
3277 rebucketed_unordered(&l, 0..total_ops, move |_| {
3278 let limiter = limiter_for_ops.clone();
3279 async move {
3280 observe_op_with_success_bytes(
3281 &limiter,
3282 || async {
3283 crate::runtime::sleep(Duration::from_millis(
3284 HILL_TEST_ASYNC_LATENCY_MS,
3285 ))
3286 .await;
3287 Ok::<(), ()>(())
3288 },
3289 |_| Outcome::NetworkError,
3290 |_| HILL_TEST_CHUNK_BYTES,
3291 )
3292 .await
3293 }
3294 })
3295 .await;
3296 result.unwrap();
3297
3298 let snapshot = l.snapshot();
3303 assert!(
3304 matches!(snapshot, HILL_TEST_START_CAP | HILL_TEST_UP_PROBE_CAP),
3305 "timed successes should finish at the existing or accepted best cap, got {snapshot}"
3306 );
3307 let current = l.current();
3308 assert!(
3309 matches!(current, HILL_TEST_START_CAP | HILL_TEST_NEXT_UP_PROBE_CAP),
3310 "timed successes should leave the controller unstressed, got {current}"
3311 );
3312 }
3313
3314 #[test]
3315 fn fetch_hill_stress_cuts_before_full_epoch() {
3316 let cfg = LimiterConfig {
3317 window_ops: 8,
3318 min_window_ops: 4,
3319 ..hill_cfg_for_tests()
3320 };
3321 let l = fetch_hill_for_tests(16, cfg.clone());
3322
3323 for _ in 0..cfg.min_window_ops {
3324 l.observe(Outcome::Timeout, Duration::from_millis(10));
3325 }
3326
3327 assert_eq!(
3328 l.current(),
3329 8,
3330 "fetch hill climber should halve on early stress"
3331 );
3332 }
3333
3334 #[test]
3338 fn cold_start_at_least_prior_static_defaults() {
3339 let cs = ChannelStart::default();
3340 assert!(cs.quote >= 32, "quote cold-start regressed: {}", cs.quote);
3341 assert!(cs.store >= 8, "store cold-start regressed: {}", cs.store);
3342 assert_eq!(
3343 cs.fetch, FETCH_COLD_START_CONCURRENCY,
3344 "fetch cold-start changed unexpectedly"
3345 );
3346 }
3347
3348 #[test]
3361 fn sustained_stress_reaches_floor_within_bounded_ops() {
3362 let cfg = LimiterConfig {
3363 window_ops: 32,
3364 min_window_ops: 8,
3365 success_target: 0.95,
3366 timeout_ceiling: 0.10,
3367 max_concurrency: 64,
3368 ..cfg_for_tests()
3369 };
3370 let l = Limiter::new(64, cfg);
3371 let mut ops = 0usize;
3372 while l.current() > 1 && ops < 200 {
3373 l.observe(Outcome::Timeout, Duration::from_millis(10));
3374 ops += 1;
3375 }
3376 assert_eq!(
3377 l.current(),
3378 1,
3379 "controller did not reach floor within 200 observations under \
3380 sustained timeout stress; took {ops} ops, ended at cap {}",
3381 l.current()
3382 );
3383 }
3384
3385 #[test]
3390 fn default_controller_has_growth_headroom() {
3391 let c = AdaptiveController::default();
3392 let cs = ChannelStart::default();
3393 let max = ChannelMax::default();
3394 assert_eq!(c.quote.current(), cs.quote);
3395 assert_eq!(c.store.current(), cs.store);
3396 assert_eq!(c.fetch.current(), cs.fetch);
3397 assert!(
3398 max.quote > cs.quote,
3399 "no growth headroom for quote: max={} start={}",
3400 max.quote,
3401 cs.quote
3402 );
3403 assert!(
3404 max.store > cs.store,
3405 "no growth headroom for store: max={} start={}",
3406 max.store,
3407 cs.store
3408 );
3409 assert!(
3410 max.fetch > cs.fetch,
3411 "no growth headroom for fetch: max={} start={}",
3412 max.fetch,
3413 cs.fetch
3414 );
3415 }
3416
3417 #[test]
3424 fn warm_start_floors_at_cold_defaults() {
3425 let c = AdaptiveController::default();
3426 let cold = ChannelStart::default();
3427 let bad_snap = ChannelStart {
3429 quote: 1,
3430 store: 1,
3431 fetch: 1,
3432 };
3433 c.warm_start(bad_snap);
3434 assert_eq!(
3437 c.quote.current(),
3438 cold.quote,
3439 "quote warm_start did not floor at cold default"
3440 );
3441 assert_eq!(
3442 c.store.current(),
3443 cold.store,
3444 "store warm_start did not floor at cold default"
3445 );
3446 assert_eq!(
3447 c.fetch.current(),
3448 cold.fetch,
3449 "fetch warm_start did not floor at cold default"
3450 );
3451 }
3452
3453 #[test]
3456 fn warm_start_honors_values_above_cold_floor() {
3457 let c = AdaptiveController::default();
3458 let cold = ChannelStart::default();
3459 let snap = ChannelStart {
3460 quote: cold.quote * 2,
3461 store: cold.store * 4,
3462 fetch: cold.fetch * 2,
3463 };
3464 c.warm_start(snap);
3465 assert_eq!(c.quote.current(), snap.quote);
3466 assert_eq!(c.store.current(), snap.store);
3467 assert_eq!(c.fetch.current(), snap.fetch);
3468 }
3469
3470 #[tokio::test]
3477 async fn rebucketed_picks_up_cap_changes_mid_stream() {
3478 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
3479 use std::sync::Arc as StdArc;
3480 let cfg = LimiterConfig {
3481 min_concurrency: 1,
3482 max_concurrency: 32,
3483 ..cfg_for_tests()
3484 };
3485 let l = Limiter::new(4, cfg);
3486 let max_seen = StdArc::new(AtomicUsize::new(0));
3487 let in_flight = StdArc::new(AtomicUsize::new(0));
3488 let processed = StdArc::new(AtomicUsize::new(0));
3489 let l_for_bump = l.clone();
3490 let processed_for_bump = processed.clone();
3491 let bump_handle = tokio::spawn(async move {
3494 loop {
3495 crate::runtime::sleep(Duration::from_millis(2)).await;
3496 if processed_for_bump.load(AtomicOrdering::Relaxed) >= 16 {
3497 l_for_bump.warm_start(16);
3498 return;
3499 }
3500 }
3501 });
3502 let _: Vec<()> = rebucketed(&l, 0..200usize, false, |_i| {
3503 let max_seen = max_seen.clone();
3504 let in_flight = in_flight.clone();
3505 let processed = processed.clone();
3506 async move {
3507 let cur = in_flight.fetch_add(1, AtomicOrdering::Relaxed) + 1;
3508 max_seen.fetch_max(cur, AtomicOrdering::Relaxed);
3509 crate::runtime::sleep(Duration::from_millis(1)).await;
3510 in_flight.fetch_sub(1, AtomicOrdering::Relaxed);
3511 processed.fetch_add(1, AtomicOrdering::Relaxed);
3512 Ok::<(), &'static str>(())
3513 }
3514 })
3515 .await
3516 .unwrap();
3517 bump_handle.await.unwrap();
3518 let peak = max_seen.load(AtomicOrdering::Relaxed);
3522 assert!(
3523 peak > 4,
3524 "rebucketed did not pick up the mid-stream cap bump (peak in-flight = {peak})"
3525 );
3526 }
3527
3528 #[tokio::test]
3537 async fn observe_op_cancellation_drops_silently() {
3538 let cfg = LimiterConfig {
3539 window_ops: 16,
3540 min_window_ops: 4,
3541 ..cfg_for_tests()
3542 };
3543 let l = Limiter::new(4, cfg);
3544 let l_clone = l.clone();
3548 let fut = observe_op(
3549 &l_clone,
3550 || async {
3551 std::future::pending::<()>().await;
3552 Ok::<(), &'static str>(())
3553 },
3554 |_| Outcome::Timeout,
3555 );
3556 drop(fut);
3557 assert_eq!(l.current(), 4, "cancelled op moved the cap");
3559 for _ in 0..16 {
3564 let _: Result<(), &'static str> = observe_op(
3565 &l,
3566 || async { Ok(()) },
3567 |_| Outcome::NetworkError,
3569 )
3570 .await;
3571 }
3572 assert!(
3575 l.current() > 4,
3576 "cap did not grow after 16 successes; controller corrupted by cancellation? cap={}",
3577 l.current(),
3578 );
3579 }
3580
3581 #[test]
3588 fn save_snapshot_is_synchronous_and_durable() {
3589 let dir = tempfile::tempdir().unwrap();
3590 let path = dir.path().join("client_adaptive.json");
3591 let snap = ChannelStart {
3592 quote: 100,
3593 store: 50,
3594 fetch: 200,
3595 };
3596 save_snapshot(&path, snap);
3597 assert!(
3600 path.exists(),
3601 "save_snapshot did not write file synchronously"
3602 );
3603 let loaded = load_snapshot(&path).unwrap();
3604 assert_eq!(loaded.quote, 100);
3605 assert_eq!(loaded.store, 50);
3606 assert_eq!(loaded.fetch, 200);
3607 }
3608
3609 #[tokio::test]
3616 async fn warm_start_disables_slow_start_doubling() {
3617 let cfg = LimiterConfig {
3618 window_ops: 8,
3619 min_window_ops: 4,
3620 success_target: 0.9,
3621 ..cfg_for_tests()
3622 };
3623 let l = Limiter::new(2, cfg.clone());
3624 l.warm_start(16);
3627 assert_eq!(l.current(), 16);
3628 for _ in 0..cfg.window_ops {
3631 l.observe(Outcome::Success, Duration::from_millis(10));
3632 }
3633 assert_eq!(
3634 l.current(),
3635 17,
3636 "warm-start triggered slow-start doubling instead of additive +1"
3637 );
3638 }
3639
3640 #[test]
3645 fn controller_warm_start_floors_at_per_instance_cold_start() {
3646 let custom_cold = ChannelStart {
3647 quote: 2,
3648 store: 1,
3649 fetch: 4,
3650 };
3651 let c = AdaptiveController::new(custom_cold, AdaptiveConfig::default());
3652 c.warm_start(ChannelStart {
3654 quote: 1,
3655 store: 1,
3656 fetch: 1,
3657 });
3658 assert_eq!(c.quote.current(), 2);
3659 assert_eq!(c.store.current(), 1);
3660 assert_eq!(c.fetch.current(), 4);
3661 c.warm_start(ChannelStart {
3663 quote: 10,
3664 store: 10,
3665 fetch: 10,
3666 });
3667 assert_eq!(c.quote.current(), 10);
3668 assert_eq!(c.store.current(), 10);
3669 assert_eq!(c.fetch.current(), 10);
3670 }
3671
3672 #[test]
3676 fn warm_start_is_noop_when_adaptive_disabled() {
3677 let cfg = AdaptiveConfig {
3678 enabled: false,
3679 ..AdaptiveConfig::default()
3680 };
3681 let custom_cold = ChannelStart {
3682 quote: 5,
3683 store: 5,
3684 fetch: 5,
3685 };
3686 let c = AdaptiveController::new(custom_cold, cfg);
3687 c.warm_start(ChannelStart {
3688 quote: 100,
3689 store: 100,
3690 fetch: 100,
3691 });
3692 assert_eq!(c.quote.current(), 5, "warm_start moved cap when disabled");
3693 assert_eq!(c.store.current(), 5, "warm_start moved cap when disabled");
3694 assert_eq!(c.fetch.current(), 5, "warm_start moved cap when disabled");
3695 }
3696
3697 #[tokio::test]
3701 async fn rebucketed_unordered_is_rolling_not_fenced() {
3702 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
3703 use std::sync::Arc as StdArc;
3704 let cfg = LimiterConfig {
3705 min_concurrency: 1,
3706 max_concurrency: 8,
3707 window_ops: 100,
3708 min_window_ops: 50,
3709 ..cfg_for_tests()
3710 };
3711 let l = Limiter::new(4, cfg);
3712 let in_flight = StdArc::new(AtomicUsize::new(0));
3713 let max_in_flight = StdArc::new(AtomicUsize::new(0));
3714 let started = StdArc::new(AtomicUsize::new(0));
3715 let _: Vec<()> = rebucketed_unordered(&l, 0..20usize, |i| {
3716 let in_flight = in_flight.clone();
3717 let max_in_flight = max_in_flight.clone();
3718 let started = started.clone();
3719 async move {
3720 let cur = in_flight.fetch_add(1, AtomicOrdering::Relaxed) + 1;
3721 max_in_flight.fetch_max(cur, AtomicOrdering::Relaxed);
3722 started.fetch_add(1, AtomicOrdering::Relaxed);
3723 if i == 0 {
3729 crate::runtime::sleep(Duration::from_millis(50)).await;
3730 } else {
3731 crate::runtime::sleep(Duration::from_millis(1)).await;
3732 }
3733 in_flight.fetch_sub(1, AtomicOrdering::Relaxed);
3734 Ok::<(), &'static str>(())
3735 }
3736 })
3737 .await
3738 .unwrap();
3739 assert_eq!(started.load(AtomicOrdering::Relaxed), 20);
3742 let peak = max_in_flight.load(AtomicOrdering::Relaxed);
3743 assert!(
3744 peak >= 4,
3745 "rolling scheduler did not fill cap; peak in-flight = {peak}"
3746 );
3747 }
3748
3749 #[tokio::test]
3751 async fn rebucketed_ordered_preserves_input_order() {
3752 let cfg = LimiterConfig {
3753 min_concurrency: 1,
3754 max_concurrency: 4,
3755 ..cfg_for_tests()
3756 };
3757 let l = Limiter::new(4, cfg);
3758 let items: Vec<usize> = (0..50).collect();
3759 let result: Vec<usize> = rebucketed_ordered(
3760 &l,
3761 items.iter().copied().enumerate(),
3762 |(idx, v)| async move {
3763 let delay = (50 - v) as u64;
3765 crate::runtime::sleep(Duration::from_micros(delay)).await;
3766 Ok::<_, &'static str>((idx, v * 10))
3767 },
3768 )
3769 .await
3770 .unwrap();
3771 assert_eq!(result.len(), 50);
3772 for (i, v) in result.iter().enumerate() {
3773 assert_eq!(*v, i * 10, "out of order at index {i}: got {v}");
3774 }
3775 }
3776
3777 #[tokio::test]
3782 async fn rebucketed_ordered_pairs_idx_with_payload_correctly() {
3783 let cfg = LimiterConfig {
3784 min_concurrency: 1,
3785 max_concurrency: 8,
3786 ..cfg_for_tests()
3787 };
3788 let l = Limiter::new(8, cfg);
3789 let items: Vec<(usize, u64)> = (0..40).map(|i| (i, 1000u64 + i as u64)).collect();
3794 let result: Vec<u64> = rebucketed_ordered(&l, items, |(idx, hash)| async move {
3795 let delay = (40 - idx) as u64; crate::runtime::sleep(Duration::from_micros(delay)).await;
3797 Ok::<_, &'static str>((idx, hash * 7))
3799 })
3800 .await
3801 .unwrap();
3802 for (i, v) in result.iter().enumerate() {
3803 let expected = (1000 + i as u64) * 7;
3804 assert_eq!(
3805 *v, expected,
3806 "idx {i} paired with wrong content: {v}, expected {expected}"
3807 );
3808 }
3809 }
3810
3811 #[test]
3815 fn save_snapshot_temp_file_is_unique_per_call() {
3816 let dir = tempfile::tempdir().unwrap();
3817 let path = dir.path().join("client_adaptive.json");
3818 for i in 0..100 {
3825 save_snapshot(
3826 &path,
3827 ChannelStart {
3828 quote: i + 1,
3829 store: i + 1,
3830 fetch: i + 1,
3831 },
3832 );
3833 }
3834 let loaded = load_snapshot(&path).unwrap();
3835 assert_eq!(loaded.quote, 100);
3836 assert_eq!(loaded.store, 100);
3837 assert_eq!(loaded.fetch, 100);
3838 let leftover: Vec<_> = std::fs::read_dir(dir.path())
3840 .unwrap()
3841 .filter_map(|e| e.ok())
3842 .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
3843 .collect();
3844 assert!(
3845 leftover.is_empty(),
3846 "temp files leaked: {:?}",
3847 leftover.iter().map(|e| e.file_name()).collect::<Vec<_>>()
3848 );
3849 }
3850
3851 #[tokio::test]
3856 async fn rebucketed_empty_input_returns_empty() {
3857 let cfg = cfg_for_tests();
3858 let l = Limiter::new(4, cfg);
3859 let v: Vec<usize> = rebucketed_unordered(&l, std::iter::empty::<usize>(), |_| async {
3860 Ok::<_, &'static str>(42usize)
3861 })
3862 .await
3863 .unwrap();
3864 assert!(v.is_empty());
3865 let v: Vec<usize> = rebucketed_ordered(
3866 &l,
3867 std::iter::empty::<(usize, ())>(),
3868 |(idx, _)| async move { Ok::<_, &'static str>((idx, 42usize)) },
3869 )
3870 .await
3871 .unwrap();
3872 assert!(v.is_empty());
3873 }
3874
3875 #[tokio::test]
3877 async fn rebucketed_exactly_cap_items() {
3878 let cfg = LimiterConfig {
3879 min_concurrency: 1,
3880 max_concurrency: 4,
3881 ..cfg_for_tests()
3882 };
3883 let l = Limiter::new(4, cfg);
3884 let v: Vec<usize> =
3885 rebucketed_unordered(
3886 &l,
3887 0..4usize,
3888 |i| async move { Ok::<_, &'static str>(i * 2) },
3889 )
3890 .await
3891 .unwrap();
3892 assert_eq!(v.len(), 4);
3893 }
3894
3895 #[tokio::test]
3898 async fn rebucketed_preserves_first_error() {
3899 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
3900 use std::sync::Arc as StdArc;
3901 let cfg = LimiterConfig {
3902 min_concurrency: 1,
3903 max_concurrency: 4,
3904 ..cfg_for_tests()
3905 };
3906 let l = Limiter::new(4, cfg);
3907 let started = StdArc::new(AtomicUsize::new(0));
3908 let started_clone = started.clone();
3909 let result: Result<Vec<()>, &'static str> = rebucketed_unordered(&l, 0..20usize, |i| {
3910 let started = started_clone.clone();
3911 async move {
3912 started.fetch_add(1, AtomicOrdering::Relaxed);
3913 if i == 5 {
3914 crate::runtime::sleep(Duration::from_micros(100)).await;
3917 return Err("first error");
3918 }
3919 if i == 10 {
3920 return Err("second error - should be ignored");
3921 }
3922 crate::runtime::sleep(Duration::from_micros(50)).await;
3923 Ok(())
3924 }
3925 })
3926 .await;
3927 match result {
3928 Err(e) => assert_eq!(e, "first error", "wrong error preserved"),
3929 Ok(_) => panic!("expected error, got ok"),
3930 }
3931 let total = started.load(AtomicOrdering::Relaxed);
3937 assert!(
3938 (5..20).contains(&total),
3939 "started count out of range: {total}"
3940 );
3941 }
3942
3943 #[test]
3946 fn limiter_with_min_equal_max_is_pinned() {
3947 let cfg = LimiterConfig {
3948 min_concurrency: 5,
3949 max_concurrency: 5,
3950 ..cfg_for_tests()
3951 };
3952 let l = Limiter::new(5, cfg);
3953 for _ in 0..1000 {
3954 l.observe(Outcome::Success, Duration::from_millis(1));
3955 }
3956 assert_eq!(l.current(), 5, "cap moved despite min==max");
3957 for _ in 0..1000 {
3958 l.observe(Outcome::Timeout, Duration::from_millis(50));
3959 }
3960 assert_eq!(l.current(), 5, "cap moved despite min==max");
3961 }
3962
3963 #[test]
3966 fn ewma_alpha_zero_returns_prev() {
3967 let prev = Duration::from_millis(100);
3968 let sample = Duration::from_millis(500);
3969 let result = ewma(prev, sample, 0.0);
3970 assert_eq!(result, prev, "alpha=0 must return prev unchanged");
3971 }
3972
3973 #[test]
3976 fn ewma_alpha_one_returns_sample() {
3977 let prev = Duration::from_millis(100);
3978 let sample = Duration::from_millis(500);
3979 let result = ewma(prev, sample, 1.0);
3980 let diff = result.abs_diff(sample);
3982 assert!(
3983 diff <= Duration::from_millis(1),
3984 "alpha=1 should return sample; got {result:?}, expected ~{sample:?}"
3985 );
3986 }
3987
3988 #[test]
3990 fn ewma_alpha_half_returns_midpoint() {
3991 let prev = Duration::from_millis(200);
3992 let sample = Duration::from_millis(400);
3993 let result = ewma(prev, sample, 0.5);
3994 let expected = Duration::from_millis(300);
3995 let diff = result.abs_diff(expected);
3996 assert!(
3997 diff <= Duration::from_millis(1),
3998 "alpha=0.5 midpoint: got {result:?}, expected ~{expected:?}"
3999 );
4000 }
4001
4002 #[test]
4006 fn ewma_nan_alpha_returns_prev() {
4007 let prev = Duration::from_millis(100);
4008 let sample = Duration::from_millis(500);
4009 let result = ewma(prev, sample, f64::NAN);
4010 assert_eq!(result, prev);
4011 let result = ewma(prev, sample, f64::INFINITY);
4012 assert_eq!(result, prev);
4013 let result = ewma(prev, sample, f64::NEG_INFINITY);
4014 assert_eq!(result, prev);
4015 }
4016
4017 #[test]
4020 fn ewma_clamps_alpha_above_one() {
4021 let prev = Duration::from_millis(100);
4022 let sample = Duration::from_millis(500);
4023 let result = ewma(prev, sample, 2.5);
4024 assert!(result >= Duration::from_millis(499));
4026 assert!(result <= Duration::from_millis(501));
4027 }
4028
4029 #[test]
4033 fn window_full_of_application_errors_does_not_move_cap() {
4034 let cfg = cfg_for_tests();
4035 let l = Limiter::new(8, cfg.clone());
4036 for _ in 0..(cfg.window_ops * 5) {
4037 l.observe(Outcome::ApplicationError, Duration::from_millis(50));
4038 }
4039 assert_eq!(
4040 l.current(),
4041 8,
4042 "cap moved on pure-app-error window; should hold"
4043 );
4044 }
4045
4046 #[test]
4050 fn disabled_adaptive_controller_truly_inert() {
4051 let cfg = AdaptiveConfig {
4052 enabled: false,
4053 ..AdaptiveConfig::default()
4054 };
4055 let c = AdaptiveController::new(ChannelStart::default(), cfg);
4056 let baseline_quote = c.quote.current();
4057 let baseline_store = c.store.current();
4058 let baseline_fetch = c.fetch.current();
4059 for _ in 0..10000 {
4060 c.quote.observe(Outcome::Timeout, Duration::from_millis(1));
4061 c.store.observe(Outcome::Timeout, Duration::from_millis(1));
4062 c.fetch.observe(Outcome::Timeout, Duration::from_millis(1));
4063 }
4064 assert_eq!(c.quote.current(), baseline_quote);
4065 assert_eq!(c.store.current(), baseline_store);
4066 assert_eq!(c.fetch.current(), baseline_fetch);
4067 }
4068
4069 #[test]
4074 fn channel_state_is_independent() {
4075 let c = AdaptiveController::default();
4076 let q0 = c.quote.current();
4077 let f0 = c.fetch.current();
4078 let s0 = c.store.current();
4079 for _ in 0..1000 {
4080 c.store.observe(Outcome::Timeout, Duration::from_millis(1));
4081 }
4082 assert_eq!(
4084 c.store.current(),
4085 c.config.min_concurrency,
4086 "store did not reach floor after 1000 timeouts; cap={}",
4087 c.store.current()
4088 );
4089 assert!(c.store.current() < s0, "store cap did not move at all");
4090 assert_eq!(c.quote.current(), q0, "quote leaked from store stress");
4092 assert_eq!(c.fetch.current(), f0, "fetch leaked from store stress");
4093 }
4094
4095 #[test]
4101 fn sanitize_corrects_pathological_floats() {
4102 let mut cfg = AdaptiveConfig {
4103 success_target: f64::NAN,
4104 timeout_ceiling: 5.0,
4105 latency_inflation_factor: f64::NEG_INFINITY,
4106 latency_ewma_alpha: 2.5,
4107 window_ops: 4,
4108 min_window_ops: 10,
4109 ..AdaptiveConfig::default()
4110 };
4111 cfg.sanitize();
4112 assert!(cfg.success_target.is_finite());
4113 assert!((0.0..=1.0).contains(&cfg.success_target));
4114 assert!((0.0..=1.0).contains(&cfg.timeout_ceiling));
4115 assert!(cfg.latency_inflation_factor.is_finite());
4116 assert!(cfg.latency_inflation_factor > 0.0);
4117 assert!((0.0..=1.0).contains(&cfg.latency_ewma_alpha));
4118 assert!(
4119 cfg.min_window_ops <= cfg.window_ops,
4120 "min_window_ops {} > window_ops {}",
4121 cfg.min_window_ops,
4122 cfg.window_ops
4123 );
4124 }
4125
4126 #[test]
4131 fn channel_max_serde_round_trips() {
4132 let m = ChannelMax {
4133 quote: 7,
4134 store: 13,
4135 fetch: 200,
4136 };
4137 let json = serde_json::to_string(&m).unwrap();
4138 let back: ChannelMax = serde_json::from_str(&json).unwrap();
4139 assert_eq!(back.quote, 7);
4140 assert_eq!(back.store, 13);
4141 assert_eq!(back.fetch, 200);
4142 }
4143
4144 #[test]
4145 fn channel_start_serde_round_trips() {
4146 let s = ChannelStart {
4147 quote: 11,
4148 store: 22,
4149 fetch: 33,
4150 };
4151 let json = serde_json::to_string(&s).unwrap();
4152 let back: ChannelStart = serde_json::from_str(&json).unwrap();
4153 assert_eq!(back.quote, 11);
4154 assert_eq!(back.store, 22);
4155 assert_eq!(back.fetch, 33);
4156 }
4157
4158 #[tokio::test]
4163 async fn rebucketed_honors_cap_shrinkage_mid_stream() {
4164 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
4165 use std::sync::Arc as StdArc;
4166 let cfg = LimiterConfig {
4167 min_concurrency: 1,
4168 max_concurrency: 16,
4169 ..cfg_for_tests()
4170 };
4171 let l = Limiter::new(16, cfg);
4172 let in_flight = StdArc::new(AtomicUsize::new(0));
4173 let max_after_shrink = StdArc::new(AtomicUsize::new(0));
4174 let processed = StdArc::new(AtomicUsize::new(0));
4175 let shrunk = StdArc::new(std::sync::atomic::AtomicBool::new(false));
4176 let l_for_shrink = l.clone();
4177 let p_for_shrink = processed.clone();
4178 let shrunk_for_shrink = shrunk.clone();
4179 let shrink_handle = tokio::spawn(async move {
4180 loop {
4182 crate::runtime::sleep(Duration::from_millis(2)).await;
4183 if p_for_shrink.load(AtomicOrdering::Relaxed) >= 50 {
4184 l_for_shrink.warm_start(2);
4185 shrunk_for_shrink.store(true, AtomicOrdering::Relaxed);
4186 return;
4187 }
4188 }
4189 });
4190 let _: Vec<()> = rebucketed_unordered(&l, 0..400usize, |_i| {
4191 let in_flight = in_flight.clone();
4192 let max_after_shrink = max_after_shrink.clone();
4193 let processed = processed.clone();
4194 let shrunk = shrunk.clone();
4195 async move {
4196 let cur = in_flight.fetch_add(1, AtomicOrdering::Relaxed) + 1;
4197 if shrunk.load(AtomicOrdering::Relaxed) {
4198 max_after_shrink.fetch_max(cur, AtomicOrdering::Relaxed);
4199 }
4200 crate::runtime::sleep(Duration::from_millis(1)).await;
4201 in_flight.fetch_sub(1, AtomicOrdering::Relaxed);
4202 processed.fetch_add(1, AtomicOrdering::Relaxed);
4203 Ok::<(), &'static str>(())
4204 }
4205 })
4206 .await
4207 .unwrap();
4208 shrink_handle.await.unwrap();
4209 let peak = max_after_shrink.load(AtomicOrdering::Relaxed);
4210 assert!(
4215 peak <= 4,
4216 "rebucketed exceeded shrunk cap of 2: peak post-shrink in-flight = {peak}"
4217 );
4218 }
4219
4220 #[test]
4226 fn mixed_window_app_errors_with_capacity_signal() {
4227 let cfg = LimiterConfig {
4228 window_ops: 10,
4229 min_window_ops: 5,
4230 timeout_ceiling: 0.2,
4231 success_target: 0.9,
4232 ..cfg_for_tests()
4233 };
4234 let l = Limiter::new(8, cfg.clone());
4239 for _ in 0..5 {
4240 l.observe(Outcome::ApplicationError, Duration::from_millis(50));
4241 }
4242 for _ in 0..5 {
4243 l.observe(Outcome::Success, Duration::from_millis(50));
4244 }
4245 assert!(
4246 l.current() >= 8,
4247 "AppErrors falsely depressed the success rate; cap dropped from 8 to {}",
4248 l.current()
4249 );
4250 let l2 = Limiter::new(8, cfg);
4253 for _ in 0..5 {
4254 l2.observe(Outcome::ApplicationError, Duration::from_millis(50));
4255 }
4256 for _ in 0..5 {
4257 l2.observe(Outcome::Timeout, Duration::from_millis(50));
4258 }
4259 assert!(
4260 l2.current() < 8,
4261 "all-timeouts (with AppError padding) did not decrease cap; got {}",
4262 l2.current()
4263 );
4264 }
4265
4266 #[test]
4272 fn concurrent_save_load_no_torn_reads() {
4273 use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
4274 use std::thread;
4275 let dir = tempfile::tempdir().unwrap();
4276 let path = dir.path().join("snap.json");
4277 save_snapshot(
4279 &path,
4280 ChannelStart {
4281 quote: 1,
4282 store: 1,
4283 fetch: 1,
4284 },
4285 );
4286 let stop = std::sync::Arc::new(AtomicBool::new(false));
4287 let p_w = path.clone();
4288 let s_w = stop.clone();
4289 let writer = thread::spawn(move || {
4290 let mut i = 1usize;
4291 while !s_w.load(AtomicOrdering::Relaxed) {
4292 save_snapshot(
4293 &p_w,
4294 ChannelStart {
4295 quote: i,
4296 store: i,
4297 fetch: i,
4298 },
4299 );
4300 i = i.wrapping_add(1).max(1);
4301 }
4302 });
4303 let p_r = path.clone();
4304 let reader = thread::spawn(move || {
4305 let mut torn = 0usize;
4306 for _ in 0..2_000 {
4307 if let Some(snap) = load_snapshot(&p_r) {
4308 if snap.quote != snap.store || snap.store != snap.fetch {
4311 torn += 1;
4312 }
4313 }
4314 }
4315 torn
4316 });
4317 let torn = reader.join().unwrap();
4318 stop.store(true, AtomicOrdering::Relaxed);
4319 writer.join().unwrap();
4320 assert_eq!(
4321 torn, 0,
4322 "observed {torn} torn reads under concurrent writes"
4323 );
4324 }
4325
4326 #[test]
4335 fn save_with_timeout_returns_promptly_on_fast_failure() {
4336 let blocker = tempfile::NamedTempFile::new().unwrap();
4337 let path = blocker.path().join("snap.json");
4338 let snap = ChannelStart {
4339 quote: 1,
4340 store: 1,
4341 fetch: 1,
4342 };
4343 let started = Instant::now();
4344 save_snapshot_with_timeout(path, snap, Duration::from_secs(5));
4345 let elapsed = started.elapsed();
4346 assert!(
4349 elapsed < Duration::from_secs(1),
4350 "save_snapshot_with_timeout took {elapsed:?} on fast-failing path"
4351 );
4352 }
4353
4354 #[test]
4359 fn save_with_timeout_bounds_wall_time_on_hang() {
4360 let dir = tempfile::tempdir().unwrap();
4372 let path = dir.path().join("snap.json");
4373 let snap = ChannelStart {
4374 quote: 1,
4375 store: 1,
4376 fetch: 1,
4377 };
4378 let started = Instant::now();
4379 save_snapshot_with_timeout(path, snap, Duration::from_micros(1));
4382 let elapsed = started.elapsed();
4383 assert!(
4384 elapsed < Duration::from_millis(200),
4385 "timeout wrapper did not bound wall time: {elapsed:?}"
4386 );
4387 }
4388}