use std::collections::{HashMap, VecDeque};
use std::time::Duration;
use tokio::time::Instant;
use crate::TRACING_TARGET;
use crate::crawler::concurrency::{Feedback, HostConcurrency, Limit, Signal};
use crate::crawler::delay::HostDelay;
use crate::crawler::event::Slowdown;
use crate::frontier::seed::Seed;
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct HostPolicy {
pub(crate) concurrency: Option<HostConcurrency>,
pub(crate) delay: HostDelay,
}
pub(crate) enum Admit {
Start(Seed),
Parked,
}
struct Host {
in_flight: usize,
last_start: Option<Instant>,
paused_until: Option<Instant>,
delay: HostDelay,
gap: Duration,
limit: Limit,
slowed: Option<Instant>,
parked: VecDeque<Seed>,
}
impl Host {
fn due(&self) -> Option<Instant> {
let gap = self.last_start.filter(|_| !self.gap.is_zero());
let after_gap = gap.map(|last| last + self.gap);
after_gap.max(self.paused_until)
}
}
pub(crate) struct Hosts {
policy: HostPolicy,
tracked: bool,
by_key: HashMap<String, Host>,
parked: usize,
}
fn key(seed: &Seed) -> String {
seed.uri()
.authority()
.map(|authority| authority.as_str().to_ascii_lowercase())
.unwrap_or_default()
}
impl Hosts {
pub(crate) fn new(policy: HostPolicy, delays: bool) -> Self {
let tracked = policy.concurrency.is_some() || !policy.delay.min().is_zero() || delays;
Self {
policy,
tracked,
by_key: HashMap::new(),
parked: 0,
}
}
fn host(&mut self, key: &str) -> &mut Host {
if !self.by_key.contains_key(key) {
let host = Host {
in_flight: 0,
last_start: None,
paused_until: None,
delay: self.policy.delay,
gap: self.policy.delay.min(),
limit: Limit::new(),
slowed: None,
parked: VecDeque::new(),
};
self.by_key.insert(key.to_owned(), host);
}
self.by_key
.get_mut(key)
.unwrap_or_else(|| unreachable!("the host was just inserted"))
}
fn is_free(policy: HostPolicy, host: &Host, now: Instant) -> bool {
let capacity = policy.concurrency.is_none_or(|concurrency| {
let limit = if concurrency.is_adaptive() {
host.limit.get()
} else {
concurrency.max()
};
host.in_flight < limit
});
let due = host.due().is_none_or(|at| at <= now);
capacity && due
}
pub(crate) fn admit(&mut self, seed: Seed, now: Instant) -> Admit {
if !self.tracked {
return Admit::Start(seed);
}
let policy = self.policy;
let host = self.host(&key(&seed));
if host.parked.is_empty() && Self::is_free(policy, host, now) {
return Admit::Start(seed);
}
host.parked.push_back(seed);
self.parked += 1;
Admit::Parked
}
pub(crate) fn take_ready(&mut self, now: Instant) -> Option<Seed> {
let policy = self.policy;
let host = self
.by_key
.values_mut()
.find(|host| !host.parked.is_empty() && Self::is_free(policy, host, now))?;
self.parked -= 1;
host.parked.pop_front()
}
pub(crate) fn start(&mut self, seed: &Seed, now: Instant) -> Option<String> {
if !self.tracked {
return None;
}
let key = key(seed);
self.started(&key, now);
Some(key)
}
pub(crate) fn track(&mut self, seed: &Seed) -> String {
self.tracked = true;
key(seed)
}
pub(crate) fn started(&mut self, key: &str, now: Instant) {
let host = self.host(key);
host.in_flight += 1;
host.last_start = Some(now);
}
pub(crate) fn pause(&mut self, key: &str, until: Instant) -> bool {
let host = self.host(key);
let extends = host.paused_until.is_none_or(|at| at < until);
if extends {
tracing::info!(target: TRACING_TARGET, host = key, "pausing host, as it asked");
host.paused_until = Some(until);
}
extends
}
pub(crate) fn finished(
&mut self,
key: &str,
feedback: Option<Feedback>,
now: Instant,
) -> Option<Slowdown> {
let concurrency = self.policy.concurrency;
let host = self.host(key);
host.in_flight = host.in_flight.saturating_sub(1);
let feedback = feedback?;
let adaptive = concurrency.filter(|concurrency| concurrency.is_adaptive());
let slow = adaptive.is_some()
&& feedback.signal == Signal::Ok
&& !feedback.unchanged
&& host.limit.observe(feedback.latency);
if feedback.signal == Signal::Throttled || slow {
if host.slowed.is_some_and(|at| feedback.started < at) {
return None;
}
host.slowed = Some(now);
if adaptive.is_some() && host.limit.halve() {
tracing::debug!(target: TRACING_TARGET, host = key, limit = host.limit.get(), "host throttled, lowering its concurrency");
return Some(Slowdown::Concurrency {
limit: host.limit.get(),
});
}
let gap = host.delay.grow(host.gap);
let widened = gap > host.gap;
host.gap = gap;
tracing::debug!(target: TRACING_TARGET, host = key, gap_ms = host.gap.as_millis(), "host throttled, widening its gap");
return widened.then_some(Slowdown::Gap { gap });
} else if feedback.signal == Signal::Ok {
if host.gap > host.delay.min() {
host.gap = host.delay.shrink(host.gap);
} else if let Some(adaptive) = adaptive {
host.limit.grow(adaptive.max());
}
}
None
}
#[cfg(feature = "robots")]
pub(crate) fn raise_delay(&mut self, key: &str, delay: Duration) {
let host = self.host(key);
host.delay.raise(delay);
host.gap = host.gap.max(delay);
}
pub(crate) fn earliest(&self) -> Option<Instant> {
self.by_key
.values()
.filter(|host| !host.parked.is_empty())
.filter_map(Host::due)
.min()
}
pub(crate) const fn parked(&self) -> usize {
self.parked
}
pub(crate) fn drain(&mut self) -> Vec<Seed> {
self.parked = 0;
self.by_key
.values_mut()
.flat_map(|host| host.parked.drain(..))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seed(uri: &str) -> Seed {
Seed::get(uri).unwrap()
}
fn policy(max: usize, delay: u64) -> HostPolicy {
HostPolicy {
concurrency: (max > 0).then(|| HostConcurrency::fixed(max)),
delay: Duration::from_secs(delay).into(),
}
}
#[test]
fn limits_concurrency_per_host() {
let now = Instant::now();
let mut hosts = Hosts::new(policy(1, 0), false);
assert!(matches!(
hosts.admit(seed("https://a.com/1"), now),
Admit::Start(_)
));
hosts.started("a.com", now);
assert!(matches!(
hosts.admit(seed("http://A.com/2"), now),
Admit::Parked
));
assert!(matches!(
hosts.admit(seed("https://b.com/"), now),
Admit::Start(_)
));
assert!(hosts.take_ready(now).is_none());
hosts.finished("a.com", None, now);
assert_eq!(hosts.take_ready(now).unwrap().uri().path(), "/2");
assert_eq!(hosts.parked(), 0);
}
#[test]
fn spaces_out_requests_per_host() {
let now = Instant::now();
let mut hosts = Hosts::new(policy(0, 2), false);
hosts.started("a.com", now);
assert!(matches!(
hosts.admit(seed("https://a.com/2"), now),
Admit::Parked
));
assert_eq!(hosts.earliest(), Some(now + Duration::from_secs(2)));
assert!(hosts.take_ready(now + Duration::from_secs(1)).is_none());
assert!(hosts.take_ready(now + Duration::from_secs(2)).is_some());
}
#[test]
#[cfg(feature = "robots")]
fn raised_delay_applies_to_the_next_start() {
let now = Instant::now();
let mut hosts = Hosts::new(policy(0, 1), false);
hosts.raise_delay("a.com", Duration::from_secs(5));
hosts.started("a.com", now);
assert!(matches!(
hosts.admit(seed("https://a.com/"), now),
Admit::Parked
));
assert_eq!(hosts.earliest(), Some(now + Duration::from_secs(5)));
}
#[test]
fn untracked_hosts_start_everything_at_once() {
let now = Instant::now();
let mut hosts = Hosts::new(policy(0, 0), false);
for _ in 0..3 {
let seed = seed("https://a.com/");
assert!(hosts.start(&seed, now).is_none());
assert!(matches!(hosts.admit(seed, now), Admit::Start(_)));
}
assert_eq!((hosts.parked(), hosts.earliest()), (0, None));
let mut hosts = Hosts::new(policy(0, 0), true);
assert_eq!(
hosts.start(&seed("https://a.com/"), now).as_deref(),
Some("a.com")
);
}
#[test]
fn adaptive_limits_gate_each_host() {
use crate::crawler::concurrency::Signal;
let now = Instant::now();
let policy = HostPolicy {
concurrency: Some(HostConcurrency::adaptive(4)),
delay: Duration::ZERO.into(),
};
let mut hosts = Hosts::new(policy, false);
let first = seed("https://a.com/1");
assert!(matches!(hosts.admit(first.clone(), now), Admit::Start(_)));
hosts.start(&first, now);
assert!(matches!(
hosts.admit(seed("https://a.com/2"), now),
Admit::Parked
));
assert!(matches!(
hosts.admit(seed("https://b.com/"), now),
Admit::Start(_)
));
let fast = Feedback {
started: now,
latency: Some(Duration::from_millis(100)),
unchanged: false,
signal: Signal::Ok,
};
hosts.finished("a.com", Some(fast), now);
assert_eq!(hosts.by_key["a.com"].limit.get(), 2);
assert_eq!(hosts.take_ready(now).unwrap().uri().path(), "/2");
hosts.started("a.com", now);
assert!(matches!(
hosts.admit(seed("https://a.com/3"), now),
Admit::Start(_)
));
hosts.started("a.com", now);
assert!(matches!(
hosts.admit(seed("https://a.com/4"), now),
Admit::Parked
));
}
fn finish(hosts: &mut Hosts, signal: Signal, started: Instant, now: Instant) {
let feedback = Feedback {
started,
latency: Some(Duration::from_millis(100)),
unchanged: false,
signal,
};
hosts.finished("a.com", Some(feedback), now);
}
fn gap(hosts: &Hosts) -> Duration {
hosts.by_key["a.com"].gap
}
#[test]
fn throttled_hosts_widen_their_gap_and_recover() {
use crate::crawler::concurrency::Signal;
let ms = Duration::from_millis;
let mut hosts = Hosts::new(
HostPolicy {
concurrency: Some(HostConcurrency::fixed(1)),
delay: (ms(200)..=ms(1000)).into(),
},
false,
);
let mut now = Instant::now();
hosts.started("a.com", now);
let mut gaps = Vec::new();
for _ in 0..4 {
let started = now;
now += Duration::from_secs(1);
finish(&mut hosts, Signal::Throttled, started, now);
gaps.push(gap(&hosts));
}
assert_eq!(gaps, [ms(400), ms(800), ms(1000), ms(1000)]);
finish(&mut hosts, Signal::Throttled, now - ms(1), now);
assert_eq!(gap(&hosts), ms(1000));
finish(&mut hosts, Signal::Failed, now, now);
assert_eq!(gap(&hosts), ms(1000));
gaps.clear();
for _ in 0..3 {
finish(&mut hosts, Signal::Ok, now, now);
gaps.push(gap(&hosts));
}
assert_eq!(gaps, [ms(500), ms(250), ms(200)]);
}
#[test]
fn adaptive_hosts_lose_concurrency_before_their_gap_widens() {
use crate::crawler::concurrency::Signal;
let mut hosts = Hosts::new(
HostPolicy {
concurrency: Some(HostConcurrency::adaptive(4)),
delay: HostDelay::default(),
},
false,
);
let mut now = Instant::now();
hosts.started("a.com", now);
for _ in 0..6 {
finish(&mut hosts, Signal::Ok, now, now);
}
assert_eq!(hosts.by_key["a.com"].limit.get(), 4);
let mut steps = Vec::new();
for _ in 0..4 {
let started = now;
now += Duration::from_secs(1);
finish(&mut hosts, Signal::Throttled, started, now);
steps.push((hosts.by_key["a.com"].limit.get(), gap(&hosts).as_secs()));
}
assert_eq!(steps, [(2, 0), (1, 0), (1, 1), (1, 2)]);
steps.clear();
for _ in 0..4 {
finish(&mut hosts, Signal::Ok, now, now);
steps.push((hosts.by_key["a.com"].limit.get(), gap(&hosts).as_secs()));
}
assert_eq!(steps, [(1, 1), (1, 0), (2, 0), (2, 0)]);
}
#[test]
fn unchanged_responses_do_not_set_the_best_time() {
use crate::crawler::concurrency::Signal;
let mut hosts = Hosts::new(
HostPolicy {
concurrency: Some(HostConcurrency::adaptive(4)),
delay: HostDelay::default(),
},
false,
);
let now = Instant::now();
let response = |latency_ms, unchanged| Feedback {
started: now,
latency: Some(Duration::from_millis(latency_ms)),
unchanged,
signal: Signal::Ok,
};
hosts.finished("a.com", Some(response(100, false)), now);
for _ in 0..5 {
hosts.finished("a.com", Some(response(10, true)), now);
}
let limit = hosts.by_key["a.com"].limit.get();
hosts.finished("a.com", Some(response(150, false)), now);
assert!(hosts.by_key["a.com"].limit.get() >= limit);
}
}