Skip to main content

spire_core/
context.rs

1//! The [`Context`] a worker handles.
2
3use std::cell::Cell;
4use std::fmt;
5use std::sync::Arc;
6use std::time::Duration;
7
8use ::http::StatusCode;
9use bytes::Bytes;
10use tokio::sync::OnceCell;
11use tokio::time::Instant;
12
13use crate::backend::Session;
14use crate::crawler::event::Notify;
15use crate::dataset::{Data, Dataset, Datasets};
16use crate::frontier::InMemFrontier;
17use crate::frontier::queue::Queue;
18use crate::http::{Request, Response, ResponseExt};
19use crate::{Error, Result};
20
21/// A request being crawled, with everything needed to handle it.
22///
23/// It holds the [`Request`], a session to fetch it with, a [`Queue`] to add
24/// the links it leads to, and the crawl's datasets. Handlers borrow it, so
25/// several of them can handle one request: the request is fetched once, by
26/// the first [`response`](Self::response) or [`body`](Self::body), and the
27/// complete response is kept. Its body is a fixed snapshot, including for
28/// browser sessions; live document operations go through the session.
29///
30/// The crawler learns how the fetch went, its time and status, from the task
31/// the request runs on. Fetching after moving the context to another task,
32/// e.g. with `tokio::spawn`, works, but adaptive host limits and
33/// [`Stats::unchanged`](crate::crawler::Stats::unchanged) do not see it.
34pub struct Context<C> {
35    request: Request,
36    session: C,
37    queue: Queue,
38    datasets: Arc<Datasets>,
39    response: OnceCell<Response>,
40}
41
42/// How a request's fetch went: how long it took, and the status the server
43/// answered with, also when the session failed the fetch for it, e.g. a
44/// `404` its status policy rejects.
45///
46/// The crawler runs each request on a task of its own, with a [`FETCH`]
47/// scope the context records the fetch into, so the crawler reads it back
48/// without allocating or locking per request.
49#[derive(Debug, Default, Clone, Copy)]
50pub(crate) struct Fetch {
51    pub(crate) latency: Option<Duration>,
52    /// `None` if no response came, e.g. the connection failed.
53    pub(crate) status: Option<StatusCode>,
54}
55
56/// Where the current task's request records how it was fetched.
57#[derive(Default)]
58pub(crate) struct FetchScope {
59    pub(crate) fetch: Cell<Fetch>,
60    /// Reports the fetch as it happens, if anyone subscribed to events.
61    pub(crate) notify: Option<Notify>,
62}
63
64impl FetchScope {
65    fn record(&self, fetch: Fetch) {
66        self.fetch.set(fetch);
67        if let Some(notify) = &self.notify {
68            notify.fetched(fetch);
69        }
70    }
71}
72
73tokio::task_local! {
74    /// How the current task's request was fetched, see [`Fetch`].
75    pub(crate) static FETCH: FetchScope;
76}
77
78impl<C> Context<C> {
79    /// Creates a standalone context, e.g. to test a handler.
80    ///
81    /// It gets an empty queue of its own and no datasets; see
82    /// [`with_queue`](Self::with_queue) and
83    /// [`with_dataset`](Self::with_dataset).
84    #[must_use]
85    pub fn new(request: Request, session: C) -> Self {
86        let queue = Queue::new(InMemFrontier::fifo()).child_of(&request);
87        Self::from_parts(request, session, queue, Arc::default())
88    }
89
90    pub(crate) fn from_parts(
91        request: Request,
92        session: C,
93        queue: Queue,
94        datasets: Arc<Datasets>,
95    ) -> Self {
96        Self {
97            request,
98            session,
99            queue,
100            datasets,
101            response: OnceCell::new(),
102        }
103    }
104
105    /// Replaces the queue, binding it to the request.
106    #[must_use]
107    pub fn with_queue(mut self, queue: &Queue) -> Self {
108        self.queue = queue.child_of(&self.request);
109        self
110    }
111
112    /// Registers `dataset` for items of type `T`.
113    #[must_use]
114    pub fn with_dataset<T, D>(mut self, dataset: D) -> Self
115    where
116        T: Send + 'static,
117        D: Dataset<T>,
118    {
119        Arc::make_mut(&mut self.datasets).insert(dataset);
120        self
121    }
122
123    /// Adds a service to this request, replacing any value of the same type.
124    #[must_use]
125    pub fn with_extension<T: Clone + Send + Sync + 'static>(mut self, value: T) -> Self {
126        self.request.extensions_mut().insert(value);
127        self
128    }
129
130    /// Returns the request.
131    #[must_use]
132    pub const fn request(&self) -> &Request {
133        &self.request
134    }
135
136    /// Returns the request mutably, e.g. to add headers before fetching.
137    pub const fn request_mut(&mut self) -> &mut Request {
138        &mut self.request
139    }
140
141    /// Returns the session, including any live document capability.
142    #[must_use]
143    pub const fn session(&self) -> &C {
144        &self.session
145    }
146
147    /// Returns the queue for the links this request leads to.
148    #[must_use]
149    pub const fn queue(&self) -> &Queue {
150        &self.queue
151    }
152
153    /// Returns the dataset for items of type `T`.
154    ///
155    /// # Errors
156    ///
157    /// Returns a [`Dataset`](crate::ErrorKind::Dataset) error if no dataset
158    /// is registered for `T`.
159    pub fn dataset<T: Send + 'static>(&self) -> Result<Data<T>> {
160        self.datasets.get()
161    }
162
163    /// Returns the complete response, fetching the request on the first call.
164    ///
165    /// Later calls return the same response without fetching again. A failed
166    /// fetch is not kept: the next call tries again. Once fetched, links
167    /// followed through the queue, including clones taken earlier, resolve
168    /// against the URI the response was served from.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if the session fails to fetch the request.
173    pub async fn response(&self) -> Result<&Response>
174    where
175        C: Session,
176    {
177        self.response
178            .get_or_try_init(|| async {
179                let started = Instant::now();
180                let response = self.session.fetch(&self.request).await;
181                // Outside a crawl, e.g. in a test, there is no scope to
182                // record into.
183                let fetch = Fetch {
184                    latency: Some(started.elapsed()),
185                    status: response
186                        .as_ref()
187                        .map_or_else(Error::status, |response| Some(response.status())),
188                };
189                let _ = FETCH.try_with(|scope| scope.record(fetch));
190                let response = response?;
191                if let Some(uri) = response.final_uri() {
192                    self.queue.rebase(uri.clone());
193                }
194                Ok(response)
195            })
196            .await
197    }
198
199    /// Returns a cheap clone of the cached response body, fetching first
200    /// if needed. Browser sessions also return a fixed HTML snapshot;
201    /// use the session's document capability for live observations.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if the request cannot be fetched.
206    pub async fn body(&self) -> Result<Bytes>
207    where
208        C: Session,
209    {
210        Ok(self.response().await?.body().clone())
211    }
212}
213
214impl<C> fmt::Debug for Context<C> {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        f.debug_struct("Context")
217            .field("method", self.request.method())
218            .field("uri", self.request.uri())
219            .field("queue", &self.queue)
220            .field("fetched", &self.response.initialized())
221            .finish_non_exhaustive()
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use std::future::Future;
228    use std::sync::atomic::{AtomicUsize, Ordering};
229
230    use super::*;
231    use crate::backend::Noop;
232    use crate::crawler::Depth;
233    use crate::dataset::InMemDataset;
234    use crate::frontier::seed::Seed;
235    use crate::http::RequestExt;
236
237    #[tokio::test]
238    async fn standalone_context() {
239        let request = Seed::get("https://example.com/").unwrap().into_request();
240        let cx = Context::new(request, Noop).with_dataset(InMemDataset::<u32>::new());
241
242        cx.dataset::<u32>().unwrap().write(1).await.unwrap();
243        assert!(cx.dataset::<String>().is_err());
244
245        cx.queue().follow("https://example.com/next").await.unwrap();
246        assert_eq!(cx.queue().size_hint(), Some(1));
247        assert_eq!(cx.request().depth(), Depth::ROOT);
248
249        let response = cx.response().await.unwrap();
250        assert!(response.status().is_success());
251    }
252
253    /// Counts its fetches, failing the first `fail` of them, and serves a
254    /// body that changes after the fetch, as a browser page can.
255    struct Counting {
256        fetches: AtomicUsize,
257        fail: usize,
258        body: std::sync::Mutex<&'static str>,
259    }
260
261    impl Session for Counting {
262        fn fetch(&self, _request: &Request) -> impl Future<Output = Result<Response>> + Send {
263            let fetch = self.fetches.fetch_add(1, Ordering::SeqCst);
264            std::future::ready(if fetch < self.fail {
265                Err(crate::Error::new(crate::ErrorKind::Http))
266            } else {
267                Ok(Response::new(Bytes::from_static(
268                    self.body.lock().unwrap().as_bytes(),
269                )))
270            })
271        }
272    }
273
274    #[tokio::test]
275    async fn fetches_once_and_keeps_a_snapshot_of_the_body() {
276        let request = Seed::get("https://example.com/").unwrap().into_request();
277        let client = Counting {
278            fetches: AtomicUsize::new(0),
279            fail: 1,
280            body: std::sync::Mutex::new("loaded"),
281        };
282        let cx = Context::new(request, client);
283
284        // A failed fetch is tried again; a successful one is kept.
285        assert!(cx.response().await.is_err());
286        assert_eq!(cx.body().await.unwrap(), "loaded");
287        *cx.session().body.lock().unwrap() = "clicked";
288        assert_eq!(cx.body().await.unwrap(), "loaded");
289        assert_eq!(cx.response().await.unwrap().body(), "loaded");
290        assert!(cx.response().await.unwrap().status().is_success());
291        assert_eq!(cx.session().fetches.load(Ordering::SeqCst), 2);
292    }
293}