use std::collections::{HashMap, VecDeque};
use std::num::NonZeroUsize;
use std::time::Duration;
use tokio::time::Instant;
use crate::crawler::delay::{Feedback, HostDelay};
use crate::frontier::seed::Seed;
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct HostPolicy {
pub(crate) max_concurrency: Option<NonZeroUsize>,
pub(crate) delay: HostDelay,
}
pub(crate) enum Admit {
Start(Seed),
Parked,
}
#[derive(Default)]
struct Host {
in_flight: usize,
next_start: Option<Instant>,
delay: Duration,
floor: Duration,
parked: VecDeque<Seed>,
}
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.max_concurrency.is_some() || !policy.delay.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 {
delay: self.policy.delay.initial(),
..Host::default()
};
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
.max_concurrency
.is_none_or(|max| host.in_flight < max.get());
let due = host.next_start.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 started(&mut self, key: &str, now: Instant) {
let host = self.host(key);
host.in_flight += 1;
if !host.delay.is_zero() {
host.next_start = Some(now + host.delay);
}
}
pub(crate) const fn adaptive(&self) -> bool {
matches!(self.policy.delay, HostDelay::Adaptive(_))
}
pub(crate) fn finished(&mut self, key: &str, feedback: Option<Feedback>) {
let policy = self.policy;
let host = self.host(key);
host.in_flight = host.in_flight.saturating_sub(1);
if let (HostDelay::Adaptive(adaptive), Some(feedback)) = (policy.delay, feedback) {
host.delay = adaptive.next(host.delay, feedback).max(host.floor);
}
}
#[cfg(feature = "robots")]
pub(crate) fn raise_delay(&mut self, key: &str, delay: Duration) {
let host = self.host(key);
host.floor = host.floor.max(delay);
host.delay = host.delay.max(delay);
}
pub(crate) fn earliest(&self) -> Option<Instant> {
self.by_key
.values()
.filter(|host| !host.parked.is_empty())
.filter_map(|host| host.next_start)
.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 {
max_concurrency: NonZeroUsize::new(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);
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_delays_follow_each_host_above_its_floor() {
use crate::crawler::delay::{AdaptiveDelay, Signal};
let now = Instant::now();
let policy = HostPolicy {
max_concurrency: None,
delay: AdaptiveDelay::new().into(),
};
let mut hosts = Hosts::new(policy, false);
let fast = Feedback {
latency: Some(Duration::ZERO),
signal: Signal::Ok,
};
assert!(hosts.adaptive());
assert_eq!(
hosts.start(&seed("https://a.com/"), now).as_deref(),
Some("a.com")
);
hosts.finished("a.com", Some(fast));
hosts.started("a.com", now);
assert_eq!(
hosts.by_key["a.com"].next_start,
Some(now + Duration::from_millis(500))
);
#[cfg(feature = "robots")]
{
hosts.raise_delay("b.com", Duration::from_secs(2));
hosts.started("b.com", now);
hosts.finished("b.com", Some(fast));
assert_eq!(hosts.by_key["b.com"].delay, Duration::from_secs(2));
}
assert_eq!(hosts.by_key["a.com"].delay, Duration::from_millis(500));
}
}