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