Skip to main content

spire_core/crawler/
run.rs

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