Skip to main content

spire_core/frontier/
mod.rs

1//! The frontier: requests waiting to be crawled.
2//!
3//! A [`Frontier`] stores [`Seed`]s and decides which one is crawled next.
4//! [`InMemFrontier`](memory::InMemFrontier) provides the storage; facades wrap any frontier to add
5//! behavior and are chained with [`FrontierExt`]:
6//!
7//! ```
8//! use spire_core::frontier::FrontierExt;
9//! use spire_core::frontier::memory::InMemFrontier;
10//!
11//! let frontier = InMemFrontier::fifo()
12//!     .dedup()
13//!     .max_depth(5)
14//!     .retry(3)
15//!     .tag_backoff();
16//! ```
17//!
18//! Pushes flow from the outermost facade inwards, so above, a seed that is
19//! too deep is refused before it is marked as seen.
20//!
21//! - [`Filter`] refuses seeds outside the crawl's scope, e.g. other hosts.
22//! - [`Dedup`] refuses seeds whose [`Fingerprint`](seed::Fingerprint) was seen before.
23//! - [`MaxDepth`] refuses seeds deeper than a limit.
24//! - [`Retry`] re-queues seeds after [`Report::Retry`], with a delay.
25//! - [`TagBackoff`] holds back a tag's seeds after [`Report::Backoff`].
26//!
27//! Without a [`Retry`] facade, retries are refused and the seeds fail;
28//! without a [`TagBackoff`] facade, backoffs only delay the retried request.
29
30pub(crate) mod dynamic;
31pub mod facade;
32pub mod memory;
33pub mod queue;
34pub mod seed;
35pub mod seen;
36#[cfg(test)]
37pub(crate) mod test_util;
38
39use std::future::{Future, ready};
40use std::time::Duration;
41
42use tokio::time::Instant;
43
44use self::facade::{AllowedHosts, Dedup, Filter, MaxDepth, Retry, RetryPolicy, TagBackoff};
45use self::seed::Seed;
46use self::seen::{InMemSeen, SeenSet};
47use crate::Result;
48use crate::request::Tag;
49
50/// Stores [`Seed`]s and decides which one is crawled next.
51pub trait Frontier: Send + Sync + 'static {
52    /// Offers a seed, which the frontier may refuse.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if the underlying storage fails.
57    fn push(&self, seed: Seed) -> impl Future<Output = Result<Admission>> + Send;
58
59    /// Takes the next seed that is ready to be crawled.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the underlying storage fails.
64    fn pop(&self) -> impl Future<Output = Result<Next>> + Send;
65
66    /// Tells the frontier how a popped seed ended.
67    ///
68    /// Facades react to the reports they handle and pass the rest inwards.
69    /// The default implementation ignores the report, refusing retries.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if the underlying storage fails.
74    fn report(&self, report: Report) -> impl Future<Output = Result<Ack>> + Send {
75        let ack = match report {
76            Report::Retry { .. } => Ack::RetryRefused,
77            _ => Ack::Applied,
78        };
79        ready(Ok(ack))
80    }
81
82    /// Returns the number of queued seeds, if known.
83    fn size_hint(&self) -> Option<usize> {
84        None
85    }
86
87    /// Returns a copy of every seed held, in no particular order, to save
88    /// the crawl and resume it later; `None` if the frontier cannot, e.g.
89    /// because it persists seeds itself.
90    ///
91    /// Facades add the seeds they hold, such as seeds waiting for a retry.
92    fn snapshot(&self) -> Option<Vec<Seed>> {
93        None
94    }
95}
96
97impl<F: Frontier> Frontier for std::sync::Arc<F> {
98    fn push(&self, seed: Seed) -> impl Future<Output = Result<Admission>> + Send {
99        F::push(self, seed)
100    }
101
102    fn pop(&self) -> impl Future<Output = Result<Next>> + Send {
103        F::pop(self)
104    }
105
106    fn report(&self, report: Report) -> impl Future<Output = Result<Ack>> + Send {
107        F::report(self, report)
108    }
109
110    fn size_hint(&self) -> Option<usize> {
111        F::size_hint(self)
112    }
113
114    fn snapshot(&self) -> Option<Vec<Seed>> {
115        F::snapshot(self)
116    }
117}
118
119/// Whether [`Frontier::push`] queued a seed.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121#[non_exhaustive]
122pub enum Admission {
123    /// The seed was queued.
124    Queued,
125    /// The seed was refused because an identical one was seen before.
126    Duplicate,
127    /// The seed was refused because it is deeper than allowed.
128    TooDeep,
129    /// The link was skipped because it is not an `http(s)` URL.
130    Unsupported,
131    /// The seed was refused because it is outside the crawl's scope.
132    OutOfScope,
133}
134
135impl Admission {
136    /// Whether the seed was queued.
137    #[must_use]
138    pub const fn is_queued(self) -> bool {
139        matches!(self, Self::Queued)
140    }
141}
142
143/// The result of [`Frontier::pop`].
144#[expect(
145    clippy::large_enum_variant,
146    reason = "moved once per pop, never stored"
147)]
148#[derive(Debug)]
149pub enum Next {
150    /// A seed is ready to be crawled.
151    Ready(Seed),
152    /// No seed is ready now, but one will be at the given instant.
153    Wait(Instant),
154    /// No seed is queued.
155    Empty,
156}
157
158/// How a popped seed ended, see [`Frontier::report`].
159#[derive(Debug)]
160#[non_exhaustive]
161pub enum Report {
162    /// The seed was crawled.
163    Done(Seed),
164    /// The seed failed and will not be tried again.
165    Failed(Seed),
166    /// The seed was not crawled on purpose, e.g. robots.txt disallows it or
167    /// its tag was aborted.
168    Skipped(Seed),
169    /// The crawl stopped before the seed finished; a persistent frontier
170    /// should keep it for the next run.
171    Cancelled(Seed),
172    /// The seed should be tried again.
173    Retry {
174        /// The seed that failed.
175        seed: Seed,
176        /// Delay before the next attempt; `None` defers to the frontier.
177        after: Option<Duration>,
178    },
179    /// Seeds with `tag` should not be crawled before `until`.
180    Backoff {
181        /// The tag to pause; `None` pauses untagged seeds.
182        tag: Option<Tag>,
183        /// The earliest instant at which the tag may be crawled again.
184        until: Instant,
185    },
186}
187
188/// What a frontier did with a [`Report`].
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190#[non_exhaustive]
191pub enum Ack {
192    /// The report was applied.
193    Applied,
194    /// A [`Report::Retry`] was refused, because the seed is out of retries or
195    /// the frontier cannot retry; the seed has failed.
196    RetryRefused,
197}
198
199/// Wraps a [`Frontier`] in another, see [`FrontierExt::layer`].
200pub trait FrontierLayer<F> {
201    /// The wrapping frontier.
202    type Frontier: Frontier;
203
204    /// Wraps `inner`.
205    fn layer(self, inner: F) -> Self::Frontier;
206}
207
208/// Chains facades onto a [`Frontier`].
209pub trait FrontierExt: Frontier + Sized {
210    /// Refuses seeds seen before, remembering them in memory.
211    fn dedup(self) -> Dedup<Self> {
212        Dedup::new(self, InMemSeen::default())
213    }
214
215    /// Refuses seeds seen before, remembering them in `seen`.
216    fn dedup_with<S: SeenSet>(self, seen: S) -> Dedup<Self, S> {
217        Dedup::new(self, seen)
218    }
219
220    /// Refuses seeds `predicate` rejects.
221    fn filter<P>(self, predicate: P) -> Filter<Self, P>
222    where
223        P: Fn(&Seed) -> bool + Send + Sync + 'static,
224    {
225        Filter::new(self, predicate)
226    }
227
228    /// Refuses seeds outside `hosts` and their subdomains, e.g. to keep a
229    /// crawl from following links off-site.
230    fn allow_hosts<I>(
231        self,
232        hosts: I,
233    ) -> Filter<Self, impl Fn(&Seed) -> bool + Send + Sync + 'static>
234    where
235        I: IntoIterator<Item: AsRef<str>>,
236    {
237        let hosts = AllowedHosts::new(hosts);
238        Filter::new(self, move |seed: &Seed| hosts.allows(seed))
239    }
240
241    /// Refuses seeds deeper than `depth`.
242    fn max_depth(self, depth: u32) -> MaxDepth<Self> {
243        MaxDepth::new(self, depth)
244    }
245
246    /// Retries failed seeds up to `max_retries` times with exponential
247    /// backoff.
248    fn retry(self, max_retries: u32) -> Retry<Self> {
249        Retry::new(self, RetryPolicy::new(max_retries))
250    }
251
252    /// Retries failed seeds according to `policy`.
253    fn retry_with(self, policy: RetryPolicy) -> Retry<Self> {
254        Retry::new(self, policy)
255    }
256
257    /// Holds back seeds of paused tags.
258    fn tag_backoff(self) -> TagBackoff<Self> {
259        TagBackoff::new(self)
260    }
261
262    /// Wraps the frontier with a custom facade.
263    fn layer<L: FrontierLayer<Self>>(self, layer: L) -> L::Frontier {
264        layer.layer(self)
265    }
266}
267
268impl<F: Frontier> FrontierExt for F {}