use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::pin::pin;
use std::sync::Arc;
use std::time::Duration;
use ::http::StatusCode;
use tokio::task::{Id, JoinError, JoinSet};
use tokio::time::{Instant, sleep_until, timeout};
use tracing::Instrument;
use crate::backend::Backend;
use crate::context::{Context, FETCH, Fetch};
use crate::crawler::concurrency::{Feedback, Signal};
use crate::crawler::connector::Connector;
use crate::crawler::hosts::{Admit, Hosts};
#[cfg(feature = "robots")]
use crate::crawler::robots::{BackendSource, RobotsCache, RobotsSource, Verdict};
use crate::crawler::{Crawler, Live, Stats, Tag};
use crate::frontier::queue::Queue;
use crate::frontier::seed::Seed;
use crate::frontier::{Ack, Pop, Report};
use crate::identity::{Health, Identity};
use crate::sync::lock;
use crate::worker::{Outcome, Scope, Worker};
use crate::{Error, ErrorKind, Result, TRACING_TARGET};
const MAX_PARKED: usize = 4096;
#[cfg(feature = "robots")]
struct RobotsRun {
cache: RobotsCache,
source: Arc<dyn RobotsSource>,
agent: Arc<str>,
tasks: JoinSet<RobotsEvent>,
}
#[cfg(feature = "robots")]
type RobotsEvent = (String, Result<robotxt::Robots>);
#[cfg(not(feature = "robots"))]
enum RobotsRun {}
#[cfg(not(feature = "robots"))]
type RobotsEvent = std::convert::Infallible;
async fn next_robots(robots: &mut Option<RobotsRun>) -> Option<RobotsEvent> {
#[cfg(feature = "robots")]
if let Some(robots) = robots
&& let Some(joined) = robots.tasks.join_next().await
{
return joined.ok();
}
#[cfg(not(feature = "robots"))]
let _ = robots;
std::future::pending().await
}
#[cfg_attr(
not(feature = "robots"),
expect(dead_code, reason = "robots.txt only disallows with `robots`")
)]
enum Access {
Allow(Seed),
Disallow(Seed),
Parked,
}
struct Run<B> {
tasks: JoinSet<(Outcome, Fetch)>,
in_flight: HashMap<Id, InFlight>,
robots: Option<RobotsRun>,
hosts: Hosts,
connector: Arc<Connector<B>>,
aborted: HashSet<Option<Tag>>,
started: usize,
stopping: bool,
grace: Option<Instant>,
error: Option<Error>,
}
struct InFlight {
seed: Seed,
identity: Option<Identity>,
host: Option<String>,
started: Instant,
}
pub(super) fn signal(outcome: &Outcome) -> Signal {
match outcome {
Outcome::Fail(error)
if error.kind() == ErrorKind::Timeout
|| matches!(
error.status().map(|status| status.as_u16()),
Some(429 | 503)
) =>
{
Signal::Throttled
}
Outcome::Fail(_) | Outcome::Fatal(_) | Outcome::Retry { .. } | Outcome::Backoff { .. } => {
Signal::Failed
}
Outcome::Continue | Outcome::Abort(_) | Outcome::Stop => Signal::Ok,
}
}
impl<B: Backend> Run<B> {
fn finish_host(
&mut self,
host: Option<String>,
seed: &Seed,
started: Instant,
fetch: Fetch,
outcome: &Outcome,
) {
let now = Instant::now();
let pause = match outcome {
Outcome::Fail(error) if error.is_retryable() => error.retry_after(),
_ => None,
};
let signal = if pause.is_some() {
Signal::Failed
} else {
signal(outcome)
};
let throttled = signal == Signal::Throttled || pause.is_some();
let Some(host) = host.or_else(|| throttled.then(|| self.hosts.track(seed))) else {
return;
};
let feedback = Feedback {
started,
latency: fetch.latency,
unchanged: fetch.status == Some(StatusCode::NOT_MODIFIED),
signal,
};
self.hosts.finished(&host, Some(feedback), now);
if let Some(pause) = pause {
self.hosts.pause(&host, now + pause);
}
}
fn stop(&mut self, reason: &'static str, grace: Option<Duration>) {
if self.stopping {
return;
}
tracing::info!(target: TRACING_TARGET, in_flight = self.tasks.len(), reason, "stopping crawl");
self.stopping = true;
self.grace = grace.map(|grace| Instant::now() + grace);
}
fn fail(&mut self, error: Error, grace: Option<Duration>) {
tracing::error!(target: TRACING_TARGET, %error, "crawl failed");
self.error.get_or_insert(error);
self.stop("crawl failed", grace);
}
fn is_aborted(&self, seed: &Seed) -> bool {
self.aborted.contains(&seed.tag().cloned())
}
#[cfg_attr(
not(feature = "robots"),
expect(clippy::unused_self, reason = "no robots.txt state")
)]
fn robots_busy(&self) -> bool {
#[cfg(feature = "robots")]
return self
.robots
.as_ref()
.is_some_and(|robots| !robots.tasks.is_empty());
#[cfg(not(feature = "robots"))]
false
}
#[cfg_attr(
not(feature = "robots"),
expect(clippy::unused_self, reason = "no robots.txt state")
)]
fn robots_access(&mut self, seed: Seed) -> Access {
#[cfg(feature = "robots")]
if let Some(robots) = &mut self.robots {
return match robots.cache.admit(seed, Instant::now()) {
Verdict::Allow(seed) => Access::Allow(seed),
Verdict::Disallow(seed) => Access::Disallow(seed),
Verdict::Parked => Access::Parked,
};
}
Access::Allow(seed)
}
#[cfg_attr(
not(feature = "robots"),
expect(clippy::unused_self, reason = "no robots.txt state")
)]
fn robots_tick(&mut self) -> Option<Instant> {
#[cfg(feature = "robots")]
if let Some(robots) = &mut self.robots {
robots.cache.retry_due(Instant::now());
for (origin, url) in robots.cache.take_fetches() {
let fetch = robots.source.fetch(url, robots.agent.clone());
robots.tasks.spawn(async move { (origin, fetch.await) });
}
return robots.cache.earliest();
}
None
}
fn parked(&self) -> usize {
#[cfg(feature = "robots")]
let robots = self
.robots
.as_ref()
.map_or(0, |robots| robots.cache.parked());
#[cfg(not(feature = "robots"))]
let robots = 0;
self.hosts.parked() + robots
}
fn drain(&mut self) -> Vec<Seed> {
let seeds = self.hosts.drain();
#[cfg(feature = "robots")]
let seeds = match &mut self.robots {
Some(robots) => {
robots.tasks.abort_all();
seeds.into_iter().chain(robots.cache.drain()).collect()
}
None => seeds,
};
seeds
}
}
impl<B, W> Crawler<B, W>
where
B: Backend,
W: Worker<B::Session>,
{
pub async fn run(&self) -> Result<Stats> {
let seeds = std::mem::take(&mut *lock(&self.seeds));
for seed in seeds {
self.frontier.push(seed).await?;
}
let started = Instant::now();
*lock(&self.live) = Live {
stats: Stats::default(),
started: Some(started),
running: true,
};
let settings = &self.settings;
let deadline = settings.time_limit.map(|limit| started + limit);
let mut run = self.new_run();
tracing::info!(target: TRACING_TARGET, concurrency = settings.concurrency.get(), "crawl started");
let mut wait_timer = pin!(sleep_until(started));
let mut armed_wait = None;
let mut deadline_timer = pin!(sleep_until(deadline.unwrap_or(started)));
let mut grace_timer = pin!(sleep_until(started));
let mut armed_grace = None;
let mut shutdown = pin!(self.shutdown.cancelled());
loop {
let wait = if run.stopping {
None
} else {
self.fill(&mut run).await
};
if run.tasks.is_empty() && !run.robots_busy() && (run.stopping || wait.is_none()) {
break;
}
if let Some(at) = wait
&& armed_wait != wait
{
wait_timer.as_mut().reset(at);
armed_wait = wait;
}
if let Some(at) = run.grace
&& armed_grace != run.grace
{
grace_timer.as_mut().reset(at);
armed_grace = run.grace;
}
tokio::select! {
biased;
() = &mut shutdown, if !run.stopping => {
run.stop("shutdown requested", settings.shutdown_grace);
}
() = &mut deadline_timer, if deadline.is_some() && !run.stopping => {
run.stop("time limit reached", settings.shutdown_grace);
}
() = &mut grace_timer, if run.grace.is_some() => {
tracing::warn!(target: TRACING_TARGET, in_flight = run.tasks.len(), "shutdown grace period over, cancelling requests");
run.grace = None;
run.tasks.abort_all();
}
Some(joined) = run.tasks.join_next_with_id() => self.complete(&mut run, joined).await,
Some(fetched) = next_robots(&mut run.robots) => self.robots_fetched(&mut run, fetched).await,
() = &mut wait_timer, if wait.is_some() => {
armed_wait = None;
}
}
}
for seed in run.drain() {
self.report(&mut run, Report::Cancelled(seed)).await;
}
let stats = {
let mut live = lock(&self.live);
live.running = false;
live.stats.elapsed = started.elapsed();
live.stats
};
tracing::info!(
target: TRACING_TARGET,
requests = stats.requests,
succeeded = stats.succeeded,
failed = stats.failed,
retried = stats.retried,
skipped = stats.skipped,
cancelled = stats.cancelled,
elapsed_ms = stats.elapsed.as_millis(),
"crawl finished",
);
match run.error {
Some(error) => Err(error),
None => Ok(stats),
}
}
fn new_run(&self) -> Run<B> {
let backend = self.backend.clone();
let connector = Arc::new(match &self.identities {
Some(identities) => Connector::with_identities(backend, identities.clone()),
None => Connector::new(backend),
});
let robots = self.robots_run(&connector);
let hosts = Hosts::new(self.settings.hosts, robots.is_some());
Run {
tasks: JoinSet::new(),
in_flight: HashMap::new(),
robots,
hosts,
connector,
aborted: HashSet::new(),
started: 0,
stopping: false,
grace: None,
error: None,
}
}
async fn fill(&self, run: &mut Run<B>) -> Option<Instant> {
let settings = &self.settings;
let mut wait = run.robots_tick();
while run.tasks.len() < settings.concurrency.get() {
if settings.max_requests.is_some_and(|max| run.started >= max) {
run.stop("request budget spent", settings.shutdown_grace);
return None;
}
if let Some(seed) = run.hosts.take_ready(Instant::now()) {
self.start(run, seed).await;
continue;
}
if run.parked() >= MAX_PARKED {
break;
}
match self.frontier.pop().await {
Ok(Pop::Ready(seed)) => self.admit(run, seed).await,
Ok(Pop::Wait(at)) => {
wait = wait.min(Some(at)).or(Some(at));
break;
}
Ok(Pop::Empty) => break,
Err(error) => {
run.fail(error, settings.shutdown_grace);
return None;
}
}
}
let robots = run.robots_tick();
[wait, robots, run.hosts.earliest()]
.into_iter()
.flatten()
.min()
}
async fn admit(&self, run: &mut Run<B>, seed: Seed) {
match run.robots_access(seed) {
Access::Allow(seed) => match run.hosts.admit(seed, Instant::now()) {
Admit::Start(seed) => self.start(run, seed).await,
Admit::Parked => {}
},
Access::Disallow(seed) => self.skip(run, seed, "disallowed by robots.txt").await,
Access::Parked => {}
}
}
#[cfg(feature = "robots")]
fn robots_run(&self, connector: &Arc<Connector<B>>) -> Option<RobotsRun> {
let config = self.settings.robots.clone()?;
let source = config
.source
.clone()
.unwrap_or_else(|| Arc::new(BackendSource::new(connector.clone())));
Some(RobotsRun {
agent: config.agent.clone(),
cache: RobotsCache::new(config),
source,
tasks: JoinSet::new(),
})
}
#[cfg(not(feature = "robots"))]
#[expect(clippy::unused_self, reason = "mirrors the signature with `robots`")]
const fn robots_run(&self, _connector: &Arc<Connector<B>>) -> Option<RobotsRun> {
None
}
#[cfg(feature = "robots")]
async fn robots_fetched(&self, run: &mut Run<B>, (origin, result): RobotsEvent) {
let Some(robots) = &mut run.robots else {
return;
};
let fetched = robots.cache.fetched(&origin, result, Instant::now());
if let Some(delay) = fetched.delay {
let host = origin
.split_once("://")
.map_or(origin.as_str(), |(_, host)| host);
run.hosts.raise_delay(host, delay);
}
for seed in fetched.sitemaps {
let uri = seed.uri().clone();
if let Err(error) = self.queue().push(seed).await {
tracing::warn!(target: TRACING_TARGET, %uri, %error, "failed to queue a sitemap");
}
}
for seed in fetched.released {
self.admit(run, seed).await;
}
}
#[cfg(not(feature = "robots"))]
#[expect(clippy::unused_self, reason = "mirrors the signature with `robots`")]
fn robots_fetched(&self, _run: &mut Run<B>, never: RobotsEvent) -> std::future::Ready<()> {
match never {}
}
async fn skip(&self, run: &mut Run<B>, seed: Seed, reason: &'static str) {
tracing::debug!(target: TRACING_TARGET, uri = %seed.uri(), reason, "skipping request");
self.tally(|stats| stats.skipped += 1);
self.report(run, Report::Skipped(seed)).await;
}
async fn start(&self, run: &mut Run<B>, seed: Seed) {
if run.is_aborted(&seed) {
self.skip(run, seed, "tag aborted").await;
return;
}
run.started += 1;
let now = Instant::now();
let host = run.hosts.start(&seed, now);
let worker = self.worker.clone();
let queue = self.queue();
let datasets = self.datasets.clone();
let deadline = self.settings.request_timeout;
let span = tracing::debug_span!(
target: TRACING_TARGET,
"request",
method = %seed.method(),
uri = %seed.uri(),
tag = seed.tag().map(tracing::field::display),
depth = %seed.depth(),
attempt = %seed.attempt(),
);
let connector = run.connector.clone();
let mut request = seed.clone().into_request();
let identity = connector.identify(&seed, &mut request);
let used = identity.as_ref().ok().cloned().flatten();
let task = async move {
let started = Instant::now();
let handle = async {
let connected = match identity {
Ok(identity) => connector.connect(identity.as_ref()).await,
Err(error) => Err(error),
};
let client = match connected {
Ok(client) => client,
Err(error) => return Outcome::Fail(error),
};
let queue: Queue = queue.child_of(&request);
let cx = Context::from_parts(request, client, queue, datasets);
worker.handle(cx).await
};
let (outcome, fetch) = FETCH
.scope(Cell::default(), async {
let outcome = match deadline {
Some(deadline) => timeout(deadline, handle).await.unwrap_or_else(|_| {
let error =
Error::new(ErrorKind::Timeout).with_message("request timed out");
Outcome::Fail(error)
}),
None => handle.await,
};
(outcome, FETCH.with(Cell::get))
})
.await;
tracing::debug!(
target: TRACING_TARGET,
outcome = %label(&outcome),
elapsed_ms = started.elapsed().as_millis(),
"request handled",
);
(outcome, fetch)
};
let id = run.tasks.spawn(Box::pin(task.instrument(span))).id();
let in_flight = InFlight {
seed,
identity: used,
host,
started: now,
};
run.in_flight.insert(id, in_flight);
}
async fn complete(&self, run: &mut Run<B>, joined: Result<(Id, (Outcome, Fetch)), JoinError>) {
let (id, (outcome, fetch)) = match joined {
Ok(joined) => joined,
Err(error) if error.is_cancelled() => {
let Some(InFlight { seed, host, .. }) = run.in_flight.remove(&error.id()) else {
return;
};
if let Some(host) = host {
run.hosts.finished(&host, None, Instant::now());
}
self.tally(|stats| stats.cancelled += 1);
self.report(run, Report::Cancelled(seed)).await;
return;
}
Err(error) => {
let failure = Error::worker("worker panicked").with_source(error.to_string());
(error.id(), (Outcome::Fail(failure), Fetch::default()))
}
};
let Some(InFlight {
seed,
identity,
host,
started,
}) = run.in_flight.remove(&id)
else {
return;
};
run.finish_host(host, &seed, started, fetch, &outcome);
if let Some(identity) = &identity {
run.connector
.finished(identity, Health::of(&outcome, fetch.status));
}
self.tally(|stats| {
stats.requests += 1;
if fetch.status == Some(StatusCode::NOT_MODIFIED) {
stats.unchanged += 1;
}
});
let uri = seed.uri().clone();
let mut failure = None;
let report = match outcome {
Outcome::Continue => {
self.tally(|stats| stats.succeeded += 1);
Report::Done(seed)
}
Outcome::Stop => {
self.tally(|stats| stats.succeeded += 1);
run.stop("worker requested stop", self.settings.shutdown_grace);
Report::Done(seed)
}
Outcome::Abort(scope) => {
let tag = match scope {
Scope::SameTag => seed.tag().cloned(),
Scope::Tag(tag) => Some(tag),
};
tracing::info!(target: TRACING_TARGET, tag = tag.as_ref().map(tracing::field::display), "aborting tag");
run.aborted.insert(tag);
self.tally(|stats| stats.succeeded += 1);
Report::Done(seed)
}
Outcome::Retry { after } => Report::Retry { seed, after },
Outcome::Backoff { scope, duration } => self.backoff(run, seed, scope, duration).await,
Outcome::Fatal(error) => {
self.tally(|stats| stats.failed += 1);
run.fail(error.with_uri(uri.clone()), self.settings.shutdown_grace);
Report::Failed(seed)
}
Outcome::Fail(error) => {
let error = match error.uri() {
Some(_) => error,
None => error.with_uri(uri.clone()),
};
if error.is_retryable() {
tracing::debug!(target: TRACING_TARGET, %uri, %error, "request failed, retrying");
let after = error.retry_after();
failure = Some(error);
Report::Retry { seed, after }
} else {
tracing::warn!(target: TRACING_TARGET, %uri, %error, "request failed");
self.tally(|stats| stats.failed += 1);
Report::Failed(seed)
}
}
};
let retry = matches!(report, Report::Retry { .. });
match self.report(run, report).await {
Some(Ack::Applied) if retry => self.tally(|stats| stats.retried += 1),
Some(Ack::RetryRefused) => {
self.tally(|stats| stats.failed += 1);
tracing::warn!(
target: TRACING_TARGET,
%uri,
error = failure.as_ref().map(tracing::field::display),
"request failed, out of retries",
);
}
_ => {}
}
}
async fn backoff(
&self,
run: &mut Run<B>,
seed: Seed,
scope: Scope,
duration: Duration,
) -> Report {
let tag = match scope {
Scope::SameTag => seed.tag().cloned(),
Scope::Tag(tag) => Some(tag),
};
tracing::debug!(
target: TRACING_TARGET,
tag = tag.as_ref().map(tracing::field::display),
duration_ms = duration.as_millis(),
"backing off",
);
let until = Instant::now() + duration;
self.report(run, Report::Backoff { tag, until }).await;
Report::Retry {
seed,
after: Some(duration),
}
}
async fn report(&self, run: &mut Run<B>, report: Report) -> Option<Ack> {
match self.frontier.report(report).await {
Ok(ack) => Some(ack),
Err(error) => {
run.fail(error, self.settings.shutdown_grace);
None
}
}
}
}
const fn label(outcome: &Outcome) -> &'static str {
match outcome {
Outcome::Continue => "continue",
Outcome::Retry { .. } => "retry",
Outcome::Backoff { .. } => "backoff",
Outcome::Abort(_) => "abort",
Outcome::Stop => "stop",
Outcome::Fail(_) => "fail",
Outcome::Fatal(_) => "fatal",
}
}