Skip to main content

spire_core/crawler/
run.rs

1//! The run loop of a [`Crawler`].
2
3use std::collections::{HashMap, HashSet};
4use std::pin::pin;
5use std::time::Duration;
6
7use tokio::task::{Id, JoinError, JoinSet};
8use tokio::time::{Instant, sleep_until, timeout};
9use tracing::Instrument;
10
11use crate::backend::Backend;
12use crate::context::{Context, FetchTime};
13use crate::crawler::delay::{Feedback, Signal};
14use crate::crawler::hosts::{Admit, Hosts};
15#[cfg(feature = "robots")]
16use crate::crawler::robots::{BackendSource, RobotsCache, RobotsSource, Verdict};
17use crate::crawler::{Crawler, Live, Stats};
18use crate::frontier::queue::Queue;
19use crate::frontier::seed::Seed;
20use crate::frontier::{Ack, Next, Report};
21use crate::request::Tag;
22use crate::sync::lock;
23use crate::worker::{Outcome, Scope, Worker};
24use crate::{Error, ErrorKind, Result, TRACING_TARGET};
25
26/// Seeds parked for busy hosts at most, before the frontier stops being
27/// read; bounds memory when one host dominates the frontier.
28const MAX_PARKED: usize = 4096;
29
30/// The robots.txt state of a run.
31#[cfg(feature = "robots")]
32struct RobotsRun {
33    cache: RobotsCache,
34    source: std::sync::Arc<dyn RobotsSource>,
35    agent: std::sync::Arc<str>,
36    tasks: JoinSet<RobotsEvent>,
37}
38
39/// A fetched robots.txt: the origin and its rules, or why it is unreachable.
40#[cfg(feature = "robots")]
41type RobotsEvent = (String, Result<robotxt::Robots>);
42
43/// Without the `robots` feature, no robots.txt state exists.
44#[cfg(not(feature = "robots"))]
45enum RobotsRun {}
46
47#[cfg(not(feature = "robots"))]
48type RobotsEvent = std::convert::Infallible;
49
50/// Waits for the next fetched robots.txt; never resolves without fetches.
51async fn next_robots(robots: &mut Option<RobotsRun>) -> Option<RobotsEvent> {
52    #[cfg(feature = "robots")]
53    if let Some(robots) = robots
54        && let Some(joined) = robots.tasks.join_next().await
55    {
56        return joined.ok();
57    }
58
59    #[cfg(not(feature = "robots"))]
60    let _ = robots;
61    std::future::pending().await
62}
63
64/// Whether robots.txt lets a seed be crawled.
65#[cfg_attr(
66    not(feature = "robots"),
67    expect(dead_code, reason = "robots.txt only disallows with `robots`")
68)]
69enum Access {
70    Allow(Seed),
71    Disallow(Seed),
72    Parked,
73}
74
75/// The state of a single run.
76struct Run {
77    tasks: JoinSet<Outcome>,
78    /// The running requests.
79    in_flight: HashMap<Id, InFlight>,
80    robots: Option<RobotsRun>,
81    hosts: Hosts,
82    aborted: HashSet<Option<Tag>>,
83    started: usize,
84    stopping: bool,
85    grace: Option<Instant>,
86    error: Option<Error>,
87}
88
89/// A running request.
90struct InFlight {
91    seed: Seed,
92    /// Its host's key, if hosts are tracked.
93    host: Option<String>,
94    /// How long its fetch took, if host delays adapt to it.
95    fetch_time: Option<FetchTime>,
96}
97
98/// How a request went, as far as its host's delay is concerned: a host
99/// asking to slow down, or timing out, is throttling the crawl.
100fn signal(outcome: &Outcome) -> Signal {
101    match outcome {
102        Outcome::Backoff { .. } => Signal::Throttled,
103        Outcome::Fail(error)
104            if error.kind() == ErrorKind::Timeout
105                || matches!(
106                    error.status().map(|status| status.as_u16()),
107                    Some(429 | 503)
108                ) =>
109        {
110            Signal::Throttled
111        }
112        Outcome::Fail(_) | Outcome::Retry { .. } => Signal::Failed,
113        Outcome::Continue | Outcome::Abort(_) | Outcome::Stop => Signal::Ok,
114    }
115}
116
117impl Run {
118    fn stop(&mut self, reason: &'static str, grace: Option<Duration>) {
119        if self.stopping {
120            return;
121        }
122        tracing::info!(target: TRACING_TARGET, in_flight = self.tasks.len(), reason, "stopping crawl");
123        self.stopping = true;
124        self.grace = grace.map(|grace| Instant::now() + grace);
125    }
126
127    /// Stops the run because the frontier failed; the error is returned once
128    /// in-flight requests are drained.
129    fn fail(&mut self, error: Error, grace: Option<Duration>) {
130        tracing::error!(target: TRACING_TARGET, %error, "frontier failed");
131        self.error.get_or_insert(error);
132        self.stop("frontier failed", grace);
133    }
134
135    fn is_aborted(&self, seed: &Seed) -> bool {
136        self.aborted.contains(&seed.tag().cloned())
137    }
138
139    /// Whether robots.txt fetches are in flight.
140    #[cfg_attr(
141        not(feature = "robots"),
142        expect(clippy::unused_self, reason = "no robots.txt state")
143    )]
144    fn robots_busy(&self) -> bool {
145        #[cfg(feature = "robots")]
146        return self
147            .robots
148            .as_ref()
149            .is_some_and(|robots| !robots.tasks.is_empty());
150        #[cfg(not(feature = "robots"))]
151        false
152    }
153
154    /// Asks robots.txt whether `seed` may be crawled.
155    #[cfg_attr(
156        not(feature = "robots"),
157        expect(clippy::unused_self, reason = "no robots.txt state")
158    )]
159    fn robots_access(&mut self, seed: Seed) -> Access {
160        #[cfg(feature = "robots")]
161        if let Some(robots) = &mut self.robots {
162            return match robots.cache.admit(seed, Instant::now()) {
163                Verdict::Allow(seed) => Access::Allow(seed),
164                Verdict::Disallow(seed) => Access::Disallow(seed),
165                Verdict::Parked => Access::Parked,
166            };
167        }
168        Access::Allow(seed)
169    }
170
171    /// Starts the robots.txt fetches due, returning when the next blocked
172    /// origin is retried.
173    #[cfg_attr(
174        not(feature = "robots"),
175        expect(clippy::unused_self, reason = "no robots.txt state")
176    )]
177    fn robots_tick(&mut self) -> Option<Instant> {
178        #[cfg(feature = "robots")]
179        if let Some(robots) = &mut self.robots {
180            robots.cache.retry_due(Instant::now());
181            for (origin, url) in robots.cache.take_fetches() {
182                let fetch = robots.source.fetch(url, robots.agent.clone());
183                robots.tasks.spawn(async move { (origin, fetch.await) });
184            }
185            return robots.cache.earliest();
186        }
187        None
188    }
189
190    /// The number of seeds parked for hosts or robots.txt.
191    fn parked(&self) -> usize {
192        #[cfg(feature = "robots")]
193        let robots = self
194            .robots
195            .as_ref()
196            .map_or(0, |robots| robots.cache.parked());
197        #[cfg(not(feature = "robots"))]
198        let robots = 0;
199        self.hosts.parked() + robots
200    }
201
202    /// Takes every parked seed, e.g. when the run stops.
203    fn drain(&mut self) -> Vec<Seed> {
204        let seeds = self.hosts.drain();
205        #[cfg(feature = "robots")]
206        let seeds = match &mut self.robots {
207            Some(robots) => {
208                robots.tasks.abort_all();
209                seeds.into_iter().chain(robots.cache.drain()).collect()
210            }
211            None => seeds,
212        };
213        seeds
214    }
215}
216
217impl<B, W> Crawler<B, W>
218where
219    B: Backend,
220    W: Worker<B::Client>,
221{
222    /// Crawls until the frontier is empty and no request is in flight, a
223    /// worker returns [`Outcome::Stop`], the budget is spent, or the run is
224    /// shut down.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if a seed cannot be pushed, or if the frontier fails
229    /// mid-run; requests in flight are drained first.
230    pub async fn run(&self) -> Result<Stats> {
231        let seeds = std::mem::take(&mut *lock(&self.seeds));
232        for seed in seeds {
233            self.frontier.push(seed).await?;
234        }
235
236        let started = Instant::now();
237        *lock(&self.live) = Live {
238            stats: Stats::default(),
239            started: Some(started),
240            running: true,
241        };
242
243        let settings = &self.settings;
244        let deadline = settings.time_limit.map(|limit| started + limit);
245        // A robots.txt `Crawl-delay` may slow a host down, so hosts are
246        // tracked whenever robots.txt is obeyed.
247        let robots = self.robots_run();
248        let hosts = Hosts::new(settings.hosts, robots.is_some());
249        let mut run = Run {
250            tasks: JoinSet::new(),
251            in_flight: HashMap::new(),
252            robots,
253            hosts,
254            aborted: HashSet::new(),
255            started: 0,
256            stopping: false,
257            grace: None,
258            error: None,
259        };
260        tracing::info!(target: TRACING_TARGET, concurrency = settings.concurrency.get(), "crawl started");
261
262        // Timers and the shutdown signal are created once and re-armed as
263        // needed: `select!` would otherwise build them again for every
264        // finished request.
265        let mut wait_timer = pin!(sleep_until(started));
266        let mut armed_wait = None;
267        let mut deadline_timer = pin!(sleep_until(deadline.unwrap_or(started)));
268        let mut grace_timer = pin!(sleep_until(started));
269        let mut armed_grace = None;
270        let mut shutdown = pin!(self.shutdown.cancelled());
271
272        loop {
273            let wait = if run.stopping {
274                None
275            } else {
276                self.fill(&mut run).await
277            };
278
279            if run.tasks.is_empty() && !run.robots_busy() && (run.stopping || wait.is_none()) {
280                break;
281            }
282            if let Some(at) = wait
283                && armed_wait != wait
284            {
285                wait_timer.as_mut().reset(at);
286                armed_wait = wait;
287            }
288            if let Some(at) = run.grace
289                && armed_grace != run.grace
290            {
291                grace_timer.as_mut().reset(at);
292                armed_grace = run.grace;
293            }
294
295            // Biased: the rare control branches are checked first, so a steady
296            // stream of finished requests cannot delay a shutdown.
297            tokio::select! {
298                biased;
299                () = &mut shutdown, if !run.stopping => {
300                    run.stop("shutdown requested", settings.shutdown_grace);
301                }
302                () = &mut deadline_timer, if deadline.is_some() && !run.stopping => {
303                    run.stop("time limit reached", settings.shutdown_grace);
304                }
305                () = &mut grace_timer, if run.grace.is_some() => {
306                    tracing::warn!(target: TRACING_TARGET, in_flight = run.tasks.len(), "shutdown grace period over, cancelling requests");
307                    run.grace = None;
308                    run.tasks.abort_all();
309                }
310                Some(joined) = run.tasks.join_next_with_id() => self.complete(&mut run, joined).await,
311                Some(fetched) = next_robots(&mut run.robots) => self.robots_fetched(&mut run, fetched).await,
312                () = &mut wait_timer, if wait.is_some() => {
313                    armed_wait = None;
314                }
315            }
316        }
317
318        for seed in run.drain() {
319            self.report(&mut run, Report::Cancelled(seed)).await;
320        }
321
322        let stats = {
323            let mut live = lock(&self.live);
324            live.running = false;
325            live.stats.elapsed = started.elapsed();
326            live.stats
327        };
328        tracing::info!(
329            target: TRACING_TARGET,
330            requests = stats.requests,
331            succeeded = stats.succeeded,
332            failed = stats.failed,
333            retried = stats.retried,
334            skipped = stats.skipped,
335            cancelled = stats.cancelled,
336            elapsed_ms = stats.elapsed.as_millis(),
337            "crawl finished",
338        );
339
340        match run.error {
341            Some(error) => Err(error),
342            None => Ok(stats),
343        }
344    }
345
346    /// Starts requests until the concurrency limit or budget is reached or
347    /// no seed is ready, returning when the next one will be.
348    async fn fill(&self, run: &mut Run) -> Option<Instant> {
349        let settings = &self.settings;
350        let mut wait = run.robots_tick();
351
352        while run.tasks.len() < settings.concurrency.get() {
353            if settings.max_requests.is_some_and(|max| run.started >= max) {
354                run.stop("request budget spent", settings.shutdown_grace);
355                return None;
356            }
357
358            if let Some(seed) = run.hosts.take_ready(Instant::now()) {
359                self.start(run, seed).await;
360                continue;
361            }
362            if run.parked() >= MAX_PARKED {
363                break;
364            }
365
366            match self.frontier.pop().await {
367                Ok(Next::Ready(seed)) => self.admit(run, seed).await,
368                Ok(Next::Wait(at)) => {
369                    wait = wait.min(Some(at)).or(Some(at));
370                    break;
371                }
372                Ok(Next::Empty) => break,
373                Err(error) => {
374                    run.fail(error, settings.shutdown_grace);
375                    return None;
376                }
377            }
378        }
379
380        let robots = run.robots_tick();
381        [wait, robots, run.hosts.earliest()]
382            .into_iter()
383            .flatten()
384            .min()
385    }
386
387    /// Starts a popped seed now, parks it until robots.txt and its host
388    /// allow it, or skips it if robots.txt disallows it.
389    async fn admit(&self, run: &mut Run, seed: Seed) {
390        match run.robots_access(seed) {
391            Access::Allow(seed) => match run.hosts.admit(seed, Instant::now()) {
392                Admit::Start(seed) => self.start(run, seed).await,
393                Admit::Parked => {}
394            },
395            Access::Disallow(seed) => self.skip(run, seed, "disallowed by robots.txt").await,
396            Access::Parked => {}
397        }
398    }
399
400    /// Builds the robots.txt state, fetching with the crawl's backend unless
401    /// [`Robots`](crate::crawler::Robots) sets another.
402    #[cfg(feature = "robots")]
403    fn robots_run(&self) -> Option<RobotsRun> {
404        let config = self.settings.robots.clone()?;
405        let source = config
406            .source
407            .clone()
408            .unwrap_or_else(|| std::sync::Arc::new(BackendSource::new(self.backend.clone())));
409
410        Some(RobotsRun {
411            agent: config.agent.clone(),
412            cache: RobotsCache::new(config),
413            source,
414            tasks: JoinSet::new(),
415        })
416    }
417
418    #[cfg(not(feature = "robots"))]
419    #[expect(clippy::unused_self, reason = "mirrors the signature with `robots`")]
420    const fn robots_run(&self) -> Option<RobotsRun> {
421        None
422    }
423
424    /// Applies a fetched robots.txt and admits the seeds that waited for it.
425    #[cfg(feature = "robots")]
426    async fn robots_fetched(&self, run: &mut Run, (origin, result): RobotsEvent) {
427        let Some(robots) = &mut run.robots else {
428            return;
429        };
430        let fetched = robots.cache.fetched(&origin, result, Instant::now());
431        if let Some(delay) = fetched.delay {
432            let host = origin
433                .split_once("://")
434                .map_or(origin.as_str(), |(_, host)| host);
435            run.hosts.raise_delay(host, delay);
436        }
437        for seed in fetched.sitemaps {
438            let uri = seed.uri().clone();
439            if let Err(error) = self.queue().push(seed).await {
440                tracing::warn!(target: TRACING_TARGET, %uri, %error, "failed to queue a sitemap");
441            }
442        }
443        for seed in fetched.released {
444            self.admit(run, seed).await;
445        }
446    }
447
448    #[cfg(not(feature = "robots"))]
449    #[expect(clippy::unused_self, reason = "mirrors the signature with `robots`")]
450    fn robots_fetched(&self, _run: &mut Run, never: RobotsEvent) -> std::future::Ready<()> {
451        match never {}
452    }
453
454    /// Skips `seed` without crawling it.
455    async fn skip(&self, run: &mut Run, seed: Seed, reason: &'static str) {
456        tracing::debug!(target: TRACING_TARGET, uri = %seed.uri(), reason, "skipping request");
457        self.tally(|stats| stats.skipped += 1);
458        self.report(run, Report::Skipped(seed)).await;
459    }
460
461    /// Skips `seed` if its tag was aborted, and otherwise spawns the request.
462    async fn start(&self, run: &mut Run, seed: Seed) {
463        if run.is_aborted(&seed) {
464            self.skip(run, seed, "tag aborted").await;
465            return;
466        }
467
468        run.started += 1;
469        let host = run.hosts.start(&seed, Instant::now());
470        let fetch_time = run.hosts.adaptive().then(FetchTime::default);
471
472        let backend = self.backend.clone();
473        let worker = self.worker.clone();
474        let queue = self.queue();
475        let datasets = self.datasets.clone();
476        let deadline = self.settings.request_timeout;
477        let span = tracing::debug_span!(
478            target: TRACING_TARGET,
479            "request",
480            method = %seed.method(),
481            uri = %seed.uri(),
482            tag = seed.tag().map(tracing::field::display),
483            depth = %seed.depth(),
484            attempt = %seed.attempt(),
485        );
486
487        let request = seed.clone().into_request();
488        let fetch_time_cx = fetch_time.clone();
489        let task = async move {
490            let started = Instant::now();
491            let handle = async {
492                let client = match backend.connect().await {
493                    Ok(client) => client,
494                    Err(error) => return Outcome::Fail(error),
495                };
496                let queue: Queue = queue.child_of(&request);
497                let mut cx = Context::from_parts(request, client, queue, datasets);
498                if let Some(fetch_time) = fetch_time_cx {
499                    cx = cx.with_fetch_time(fetch_time);
500                }
501                worker.handle(cx).await
502            };
503
504            let outcome = match deadline {
505                Some(deadline) => timeout(deadline, handle).await.unwrap_or_else(|_| {
506                    let error = Error::new(ErrorKind::Timeout).with_message("request timed out");
507                    Outcome::Fail(error)
508                }),
509                None => handle.await,
510            };
511
512            tracing::debug!(
513                target: TRACING_TARGET,
514                outcome = %label(&outcome),
515                elapsed_ms = started.elapsed().as_millis(),
516                "request handled",
517            );
518            outcome
519        };
520
521        // Boxed so spawning moves a pointer, not the whole request future.
522        let id = run.tasks.spawn(Box::pin(task.instrument(span))).id();
523        let in_flight = InFlight {
524            seed,
525            host,
526            fetch_time,
527        };
528        run.in_flight.insert(id, in_flight);
529    }
530
531    /// Records a finished request and reports its outcome to the frontier.
532    async fn complete(&self, run: &mut Run, joined: Result<(Id, Outcome), JoinError>) {
533        let (id, outcome) = match joined {
534            Ok(joined) => joined,
535            Err(error) if error.is_cancelled() => {
536                let Some(InFlight { seed, host, .. }) = run.in_flight.remove(&error.id()) else {
537                    return;
538                };
539                if let Some(host) = host {
540                    run.hosts.finished(&host, None);
541                }
542                self.tally(|stats| stats.cancelled += 1);
543                self.report(run, Report::Cancelled(seed)).await;
544                return;
545            }
546            Err(error) => {
547                let failure = Error::worker("worker panicked").with_source(error.to_string());
548                (error.id(), Outcome::Fail(failure))
549            }
550        };
551        let Some(InFlight {
552            seed,
553            host,
554            fetch_time,
555        }) = run.in_flight.remove(&id)
556        else {
557            return;
558        };
559        if let Some(host) = host {
560            let feedback = fetch_time.map(|fetch_time| Feedback {
561                latency: *lock(&fetch_time),
562                signal: signal(&outcome),
563            });
564            run.hosts.finished(&host, feedback);
565        }
566        self.tally(|stats| stats.requests += 1);
567
568        let uri = seed.uri().clone();
569        let mut failure = None;
570        let report = match outcome {
571            Outcome::Continue => {
572                self.tally(|stats| stats.succeeded += 1);
573                Report::Done(seed)
574            }
575            Outcome::Stop => {
576                self.tally(|stats| stats.succeeded += 1);
577                run.stop("worker requested stop", self.settings.shutdown_grace);
578                Report::Done(seed)
579            }
580            Outcome::Abort(scope) => {
581                let tag = match scope {
582                    Scope::Owner => seed.tag().cloned(),
583                    Scope::Tag(tag) => Some(tag),
584                };
585                tracing::info!(target: TRACING_TARGET, tag = tag.as_ref().map(tracing::field::display), "aborting tag");
586                run.aborted.insert(tag);
587                self.tally(|stats| stats.succeeded += 1);
588                Report::Done(seed)
589            }
590            Outcome::Retry { after } => Report::Retry { seed, after },
591            Outcome::Backoff { scope, duration } => self.backoff(run, seed, scope, duration).await,
592            Outcome::Fail(error) => {
593                let error = match error.uri() {
594                    Some(_) => error,
595                    None => error.with_uri(uri.clone()),
596                };
597                if error.is_retryable() {
598                    tracing::debug!(target: TRACING_TARGET, %uri, %error, "request failed, retrying");
599                    let report = match error.retry_after() {
600                        Some(duration) => self.backoff(run, seed, Scope::Owner, duration).await,
601                        None => Report::Retry { seed, after: None },
602                    };
603                    failure = Some(error);
604                    report
605                } else {
606                    tracing::warn!(target: TRACING_TARGET, %uri, %error, "request failed");
607                    self.tally(|stats| stats.failed += 1);
608                    Report::Failed(seed)
609                }
610            }
611        };
612
613        let retry = matches!(report, Report::Retry { .. });
614        match self.report(run, report).await {
615            Some(Ack::Applied) if retry => self.tally(|stats| stats.retried += 1),
616            Some(Ack::RetryRefused) => {
617                self.tally(|stats| stats.failed += 1);
618                tracing::warn!(
619                    target: TRACING_TARGET,
620                    %uri,
621                    error = failure.as_ref().map(tracing::field::display),
622                    "request failed, out of retries",
623                );
624            }
625            _ => {}
626        }
627    }
628
629    /// Pauses `scope` for `duration`, returning the report that retries
630    /// `seed` once the pause ends.
631    async fn backoff(&self, run: &mut Run, seed: Seed, scope: Scope, duration: Duration) -> Report {
632        let tag = match scope {
633            Scope::Owner => seed.tag().cloned(),
634            Scope::Tag(tag) => Some(tag),
635        };
636        tracing::debug!(
637            target: TRACING_TARGET,
638            tag = tag.as_ref().map(tracing::field::display),
639            duration_ms = duration.as_millis(),
640            "backing off",
641        );
642
643        let until = Instant::now() + duration;
644        self.report(run, Report::Backoff { tag, until }).await;
645        Report::Retry {
646            seed,
647            after: Some(duration),
648        }
649    }
650
651    /// Reports to the frontier; a failure stops the run.
652    async fn report(&self, run: &mut Run, report: Report) -> Option<Ack> {
653        match self.frontier.report(report).await {
654            Ok(ack) => Some(ack),
655            Err(error) => {
656                run.fail(error, self.settings.shutdown_grace);
657                None
658            }
659        }
660    }
661}
662
663/// Names an outcome for logs.
664const fn label(outcome: &Outcome) -> &'static str {
665    match outcome {
666        Outcome::Continue => "continue",
667        Outcome::Retry { .. } => "retry",
668        Outcome::Backoff { .. } => "backoff",
669        Outcome::Abort(_) => "abort",
670        Outcome::Stop => "stop",
671        Outcome::Fail(_) => "fail",
672    }
673}