Skip to main content

millipede_core/autoscale/
pool.rs

1use super::{
2    AimdController, ScaleDecision, Snapshotter, SnapshotterOptions, SystemStatus,
3    SystemStatusOptions,
4    rate_limit::{DomainLimiter, TaskRateLimiter},
5};
6use std::{
7    sync::{
8        Arc,
9        atomic::{AtomicUsize, Ordering},
10    },
11    time::Duration,
12};
13use tokio::time::{Instant, MissedTickBehavior};
14use tokio_util::sync::CancellationToken;
15
16/// Configuration for concurrency scaling and request politeness limits.
17#[derive(Debug, Clone)]
18#[non_exhaustive]
19#[must_use = "autoscaled pool options do nothing unless passed to AutoscaledPool::new"]
20pub struct AutoscaledPoolOptions {
21    /// Pins concurrency and disables all autoscaling when set.
22    pub fixed_concurrency: Option<usize>,
23    /// Minimum dynamic concurrency.
24    pub min_concurrency: usize,
25    /// Maximum dynamic concurrency.
26    pub max_concurrency: usize,
27    /// Initial desired concurrency, or the minimum when omitted.
28    pub desired_concurrency: Option<usize>,
29    /// Proportional increase applied by load-signal scaling.
30    pub scale_up_step_ratio: f32,
31    /// Proportional decrease applied by load-signal scaling.
32    pub scale_down_step_ratio: f32,
33    /// Mean healthy-history ratio required to scale up.
34    pub desired_utilization_ratio: f32,
35    /// Optional deadline applied to each dispatched request attempt.
36    pub task_timeout: Option<Duration>,
37    /// Optional global task-start budget per minute.
38    pub max_tasks_per_minute: Option<u32>,
39    /// Minimum delay between reservations for the same host.
40    pub same_domain_delay: Duration,
41    /// Dispatcher fallback tick for reconsidering whether more work can run.
42    pub maybe_run_interval: Duration,
43    /// Interval between load-signal scaling decisions.
44    pub autoscale_interval: Duration,
45    /// Concurrency scaling strategy.
46    pub mode: AutoscaleMode,
47    /// Load-signal collection configuration.
48    pub snapshotter: SnapshotterOptions,
49    /// Load-history evaluation configuration.
50    pub system_status: SystemStatusOptions,
51}
52
53impl Default for AutoscaledPoolOptions {
54    fn default() -> Self {
55        Self {
56            fixed_concurrency: None,
57            min_concurrency: 1,
58            max_concurrency: 200,
59            desired_concurrency: None,
60            scale_up_step_ratio: 0.05,
61            scale_down_step_ratio: 0.05,
62            desired_utilization_ratio: 0.9,
63            task_timeout: None,
64            max_tasks_per_minute: None,
65            same_domain_delay: Duration::ZERO,
66            maybe_run_interval: Duration::from_millis(500),
67            autoscale_interval: Duration::from_secs(10),
68            mode: AutoscaleMode::Aimd {
69                increase_after_successes: 10,
70                decrease_factor: 0.5,
71            },
72            snapshotter: SnapshotterOptions::default(),
73            system_status: SystemStatusOptions::default(),
74        }
75    }
76}
77
78/// Strategy used to adjust desired concurrency.
79#[derive(Debug, Clone)]
80#[non_exhaustive]
81pub enum AutoscaleMode {
82    /// Deterministic additive-increase, multiplicative-decrease scaling.
83    Aimd {
84        /// Number of consecutive successes required for one additive increase.
85        increase_after_successes: usize,
86        /// Multiplicative factor applied after a setback.
87        decrease_factor: f32,
88    },
89    /// Periodic scaling based on registered load-signal histories.
90    LoadSignals,
91}
92
93/// Coarse attempt result consumed by AIMD scaling.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub(crate) enum AttemptOutcomeKind {
96    /// A successful attempt.
97    Success,
98    /// A retryable or failed attempt.
99    Setback,
100}
101
102enum Mode {
103    Fixed(usize),
104    Aimd(AimdController),
105    LoadSignals {
106        desired: AtomicUsize,
107        snapshotter: Snapshotter,
108        system_status: SystemStatus,
109    },
110}
111
112/// Concurrency decision and politeness facade consulted by a crawler dispatch loop.
113pub struct AutoscaledPool {
114    mode: Mode,
115    min: usize,
116    max: usize,
117    scale_up_step_ratio: f32,
118    scale_down_step_ratio: f32,
119    desired_utilization_ratio: f32,
120    autoscale_interval: Duration,
121    task_rate_limiter: Option<TaskRateLimiter>,
122    domain_limiter: DomainLimiter,
123}
124
125impl AutoscaledPool {
126    /// Creates a pool, normalizing concurrency bounds and selecting its scaling mode.
127    pub fn new(options: AutoscaledPoolOptions) -> Self {
128        let min = options.min_concurrency.max(1);
129        let max = options.max_concurrency.max(min);
130        let initial = options.desired_concurrency.unwrap_or(min);
131        let mode = if let Some(fixed) = options.fixed_concurrency {
132            Mode::Fixed(fixed.max(1))
133        } else {
134            match options.mode {
135                AutoscaleMode::Aimd {
136                    increase_after_successes,
137                    decrease_factor,
138                } => Mode::Aimd(AimdController::new(
139                    min,
140                    max,
141                    initial,
142                    increase_after_successes,
143                    decrease_factor,
144                )),
145                AutoscaleMode::LoadSignals => {
146                    if options.snapshotter.signals.is_empty() {
147                        tracing::warn!(
148                            "AutoscaleMode::LoadSignals configured with no registered signals; falling back to AIMD defaults"
149                        );
150                        Mode::Aimd(AimdController::new(min, max, initial, 10, 0.5))
151                    } else {
152                        Mode::LoadSignals {
153                            desired: AtomicUsize::new(initial.clamp(min, max)),
154                            snapshotter: Snapshotter::new(options.snapshotter),
155                            system_status: SystemStatus::new(options.system_status),
156                        }
157                    }
158                }
159            }
160        };
161
162        Self {
163            mode,
164            min,
165            max,
166            scale_up_step_ratio: options.scale_up_step_ratio,
167            scale_down_step_ratio: options.scale_down_step_ratio,
168            desired_utilization_ratio: options.desired_utilization_ratio,
169            autoscale_interval: options.autoscale_interval,
170            task_rate_limiter: options.max_tasks_per_minute.map(TaskRateLimiter::new),
171            domain_limiter: DomainLimiter::new(options.same_domain_delay),
172        }
173    }
174
175    /// Returns whether concurrency is explicitly fixed.
176    pub fn is_fixed(&self) -> bool {
177        matches!(self.mode, Mode::Fixed(_))
178    }
179
180    /// Returns the concurrency currently desired by the selected mode.
181    pub fn desired_concurrency(&self) -> usize {
182        match &self.mode {
183            Mode::Fixed(value) => *value,
184            Mode::Aimd(controller) => controller.desired_concurrency(),
185            Mode::LoadSignals { desired, .. } => desired.load(Ordering::Acquire),
186        }
187    }
188
189    /// Returns the effective minimum concurrency.
190    pub fn min_concurrency(&self) -> usize {
191        match self.mode {
192            Mode::Fixed(value) => value,
193            _ => self.min,
194        }
195    }
196
197    /// Returns the effective maximum concurrency.
198    pub fn max_concurrency(&self) -> usize {
199        match self.mode {
200            Mode::Fixed(value) => value,
201            _ => self.max,
202        }
203    }
204
205    /// Sets a persistent minimum delay between reservations for one host.
206    pub fn set_domain_delay_floor(&self, host: &str, floor: Duration) {
207        self.domain_limiter.set_delay_floor(host, floor);
208    }
209
210    /// Records an attempt result when using AIMD mode.
211    pub(crate) fn record_outcome(&self, outcome: AttemptOutcomeKind) {
212        if let Mode::Aimd(controller) = &self.mode {
213            match outcome {
214                AttemptOutcomeKind::Success => controller.record_success(),
215                AttemptOutcomeKind::Setback => controller.record_setback(),
216            }
217        }
218    }
219
220    /// Acquires a global task token or returns the required wait.
221    pub(crate) fn task_token_wait(&self, now: Instant) -> Option<Duration> {
222        self.task_rate_limiter
223            .as_ref()
224            .and_then(|limiter| limiter.try_acquire(now).err())
225    }
226
227    /// Reserves a host slot and returns the required wait.
228    pub(crate) fn domain_slot_wait(&self, host: &str, now: Instant) -> Duration {
229        self.domain_limiter.reserve_slot(host, now)
230    }
231
232    /// Updates host politeness state from a response.
233    pub(crate) fn note_response(
234        &self,
235        host: &str,
236        status: Option<http::StatusCode>,
237        retry_after: Option<Duration>,
238        now: Instant,
239    ) {
240        self.domain_limiter
241            .note_response(host, status, retry_after, now);
242    }
243
244    /// Spawns periodic scaling for load-signal mode only.
245    pub(crate) fn spawn_background(
246        self: &Arc<Self>,
247        cancel: CancellationToken,
248        on_scale_change: Box<dyn Fn() + Send + Sync>,
249    ) -> Option<tokio::task::JoinHandle<()>> {
250        if !matches!(self.mode, Mode::LoadSignals { .. }) {
251            return None;
252        }
253
254        let pool = Arc::clone(self);
255        Some(tokio::spawn(async move {
256            let Mode::LoadSignals {
257                desired,
258                snapshotter,
259                system_status,
260            } = &pool.mode
261            else {
262                return;
263            };
264
265            if let Err(error) = snapshotter.start().await {
266                tracing::warn!(%error, "autoscale snapshotter start failed");
267            }
268
269            let mut ticker = tokio::time::interval(pool.autoscale_interval);
270            ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
271            loop {
272                tokio::select! {
273                    _ = cancel.cancelled() => break,
274                    _ = ticker.tick() => {
275                        let decision = system_status.evaluate(
276                            snapshotter,
277                            pool.desired_utilization_ratio,
278                            Instant::now(),
279                        );
280                        let previous = desired.fetch_update(
281                            Ordering::AcqRel,
282                            Ordering::Acquire,
283                            |current| Some(apply_scale_decision(
284                                current,
285                                decision,
286                                pool.min,
287                                pool.max,
288                                pool.scale_up_step_ratio,
289                                pool.scale_down_step_ratio,
290                            )),
291                        );
292                        if let Ok(previous) = previous {
293                            if desired.load(Ordering::Acquire) != previous {
294                                on_scale_change();
295                            }
296                        }
297                    }
298                }
299            }
300
301            if let Err(error) = snapshotter.stop().await {
302                tracing::warn!(%error, "autoscale snapshotter stop failed");
303            }
304        }))
305    }
306}
307
308/// Applies one proportional load-signal scaling decision within normalized bounds.
309pub(crate) fn apply_scale_decision(
310    current: usize,
311    decision: ScaleDecision,
312    min: usize,
313    max: usize,
314    up_ratio: f32,
315    down_ratio: f32,
316) -> usize {
317    match decision {
318        ScaleDecision::Hold => current,
319        ScaleDecision::ScaleUp => current
320            .saturating_add(((current as f32 * up_ratio).ceil() as usize).max(1))
321            .min(max.max(min)),
322        ScaleDecision::ScaleDown => current
323            .saturating_sub(((current as f32 * down_ratio).ceil() as usize).max(1))
324            .max(min),
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::autoscale::{LoadSignal, LoadSnapshot};
332    use proptest::prelude::*;
333    use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
334
335    struct StartTrackingSignal {
336        started: Arc<AtomicBool>,
337    }
338
339    #[async_trait::async_trait]
340    impl LoadSignal for StartTrackingSignal {
341        fn name(&self) -> &str {
342            "start-tracking"
343        }
344
345        fn overload_threshold(&self) -> f32 {
346            1.0
347        }
348
349        async fn start(&self) -> Result<(), crate::errors::CrawlError> {
350            self.started.store(true, AtomicOrdering::SeqCst);
351            Ok(())
352        }
353
354        fn sample(&self, _window: Duration) -> Vec<LoadSnapshot> {
355            Vec::new()
356        }
357    }
358
359    proptest! {
360        #[test]
361        fn apply_scale_decision_stays_in_bounds(
362            min in 1_usize..100,
363            width in 0_usize..100,
364            offset in 0_usize..100,
365            decision_index in 0_u8..3,
366            up_ratio in 0.0001_f32..=1.0,
367            down_ratio in 0.0001_f32..=1.0,
368        ) {
369            let max = min + width;
370            let current = min + offset.min(width);
371            let decision = match decision_index {
372                0 => ScaleDecision::ScaleDown,
373                1 => ScaleDecision::Hold,
374                _ => ScaleDecision::ScaleUp,
375            };
376            let result = apply_scale_decision(
377                current,
378                decision,
379                min,
380                max,
381                up_ratio,
382                down_ratio,
383            );
384
385            prop_assert!((min..=max).contains(&result));
386            match decision {
387                ScaleDecision::ScaleUp => prop_assert!(result >= current),
388                ScaleDecision::ScaleDown => prop_assert!(result <= current),
389                ScaleDecision::Hold => prop_assert_eq!(result, current),
390            }
391        }
392
393        #[test]
394        fn desired_concurrency_tracks_signal_direction_and_stays_bounded(
395            readings in proptest::collection::vec(any::<bool>(), 1..200),
396            min in 1_usize..20,
397            width in 0_usize..50,
398            up in 0.01_f32..=1.0,
399            down in 0.01_f32..=1.0,
400        ) {
401            struct FakeSignal {
402                samples: Vec<LoadSnapshot>,
403            }
404
405            #[async_trait::async_trait]
406            impl LoadSignal for FakeSignal {
407                fn name(&self) -> &str {
408                    "property"
409                }
410
411                fn overload_threshold(&self) -> f32 {
412                    1.0
413                }
414
415                fn sample(&self, _window: Duration) -> Vec<LoadSnapshot> {
416                    self.samples.clone()
417                }
418            }
419
420            let runtime = tokio::runtime::Builder::new_current_thread()
421                .enable_time()
422                .build()
423                .unwrap();
424            let transitions = runtime.block_on(async {
425                let now = Instant::now();
426                let max = min + width;
427                let mut desired = min + width / 2;
428                let mut transitions = Vec::with_capacity(readings.len());
429
430                for index in 0..readings.len() {
431                    let prefix = &readings[..=index];
432                    let samples = prefix
433                        .iter()
434                        .enumerate()
435                        .map(|(sample_index, overloaded)| LoadSnapshot {
436                            at: now
437                                - Duration::from_millis(
438                                    (prefix.len() - sample_index) as u64,
439                                ),
440                            overloaded: *overloaded,
441                        })
442                        .collect();
443                    let snapshotter = Snapshotter::new(SnapshotterOptions {
444                        signals: vec![Arc::new(FakeSignal { samples })],
445                        window: Duration::from_secs(1),
446                    });
447                    let decision = SystemStatus::new(SystemStatusOptions { min_samples: 1 })
448                        .evaluate(&snapshotter, 0.9, now);
449                    let next = apply_scale_decision(
450                        desired,
451                        decision,
452                        min,
453                        max,
454                        up,
455                        down,
456                    );
457                    transitions.push((readings[index], desired, next));
458                    desired = next;
459                }
460
461                transitions
462            });
463
464            for (overloaded, desired, next) in transitions {
465                prop_assert!((min..=min + width).contains(&next));
466                if overloaded {
467                    prop_assert!(next <= desired);
468                } else {
469                    prop_assert!(next >= desired);
470                }
471            }
472        }
473    }
474
475    #[tokio::test]
476    async fn fixed_mode_ignores_signals_and_never_spawns_background() {
477        let started = Arc::new(AtomicBool::new(false));
478        let signal = Arc::new(StartTrackingSignal {
479            started: Arc::clone(&started),
480        });
481        let pool = Arc::new(AutoscaledPool::new(AutoscaledPoolOptions {
482            fixed_concurrency: Some(4),
483            mode: AutoscaleMode::LoadSignals,
484            snapshotter: SnapshotterOptions {
485                signals: vec![signal],
486                ..SnapshotterOptions::default()
487            },
488            ..AutoscaledPoolOptions::default()
489        }));
490
491        assert!(pool.is_fixed());
492        assert_eq!(pool.desired_concurrency(), 4);
493        assert_eq!(pool.min_concurrency(), 4);
494        assert_eq!(pool.max_concurrency(), 4);
495        assert!(
496            pool.spawn_background(CancellationToken::new(), Box::new(|| {}))
497                .is_none()
498        );
499        assert!(!started.load(AtomicOrdering::SeqCst));
500    }
501
502    #[tokio::test]
503    async fn aimd_mode_spawn_background_returns_none() {
504        let pool = Arc::new(AutoscaledPool::new(AutoscaledPoolOptions::default()));
505        assert!(
506            pool.spawn_background(CancellationToken::new(), Box::new(|| {}))
507                .is_none()
508        );
509    }
510
511    #[test]
512    fn autoscale_interval_is_copied_verbatim() {
513        let pool = AutoscaledPool::new(AutoscaledPoolOptions {
514            autoscale_interval: Duration::ZERO,
515            ..AutoscaledPoolOptions::default()
516        });
517
518        assert_eq!(pool.autoscale_interval, Duration::ZERO);
519    }
520}