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#[derive(Debug, Clone)]
18#[non_exhaustive]
19#[must_use = "autoscaled pool options do nothing unless passed to AutoscaledPool::new"]
20pub struct AutoscaledPoolOptions {
21 pub fixed_concurrency: Option<usize>,
23 pub min_concurrency: usize,
25 pub max_concurrency: usize,
27 pub desired_concurrency: Option<usize>,
29 pub scale_up_step_ratio: f32,
31 pub scale_down_step_ratio: f32,
33 pub desired_utilization_ratio: f32,
35 pub task_timeout: Option<Duration>,
37 pub max_tasks_per_minute: Option<u32>,
39 pub same_domain_delay: Duration,
41 pub maybe_run_interval: Duration,
43 pub autoscale_interval: Duration,
45 pub mode: AutoscaleMode,
47 pub snapshotter: SnapshotterOptions,
49 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#[derive(Debug, Clone)]
80#[non_exhaustive]
81pub enum AutoscaleMode {
82 Aimd {
84 increase_after_successes: usize,
86 decrease_factor: f32,
88 },
89 LoadSignals,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub(crate) enum AttemptOutcomeKind {
96 Success,
98 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
112pub 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 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 pub fn is_fixed(&self) -> bool {
177 matches!(self.mode, Mode::Fixed(_))
178 }
179
180 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 pub fn min_concurrency(&self) -> usize {
191 match self.mode {
192 Mode::Fixed(value) => value,
193 _ => self.min,
194 }
195 }
196
197 pub fn max_concurrency(&self) -> usize {
199 match self.mode {
200 Mode::Fixed(value) => value,
201 _ => self.max,
202 }
203 }
204
205 pub fn set_domain_delay_floor(&self, host: &str, floor: Duration) {
207 self.domain_limiter.set_delay_floor(host, floor);
208 }
209
210 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 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 pub(crate) fn domain_slot_wait(&self, host: &str, now: Instant) -> Duration {
229 self.domain_limiter.reserve_slot(host, now)
230 }
231
232 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 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
308pub(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}