1use std::cell::Cell;
4use std::collections::{HashMap, HashSet};
5use std::pin::pin;
6use std::sync::Arc;
7use std::time::Duration;
8
9use ::http::StatusCode;
10use tokio::task::{Id, JoinError, JoinSet};
11use tokio::time::{Instant, sleep_until, timeout};
12use tracing::Instrument;
13
14use crate::backend::Backend;
15use crate::context::{Context, FETCH, Fetch};
16use crate::crawler::concurrency::{Feedback, Signal};
17use crate::crawler::connector::Connector;
18use crate::crawler::hosts::{Admit, Hosts};
19#[cfg(feature = "robots")]
20use crate::crawler::robots::{BackendSource, RobotsCache, RobotsSource, Verdict};
21use crate::crawler::{Crawler, Live, Stats, Tag};
22use crate::frontier::queue::Queue;
23use crate::frontier::seed::Seed;
24use crate::frontier::{Ack, Pop, Report};
25use crate::identity::{Health, Identity};
26use crate::sync::lock;
27use crate::worker::{Outcome, Scope, Worker};
28use crate::{Error, ErrorKind, Result, TRACING_TARGET};
29
30const MAX_PARKED: usize = 4096;
33
34#[cfg(feature = "robots")]
36struct RobotsRun {
37 cache: RobotsCache,
38 source: Arc<dyn RobotsSource>,
39 agent: Arc<str>,
40 tasks: JoinSet<RobotsEvent>,
41}
42
43#[cfg(feature = "robots")]
45type RobotsEvent = (String, Result<robotxt::Robots>);
46
47#[cfg(not(feature = "robots"))]
49enum RobotsRun {}
50
51#[cfg(not(feature = "robots"))]
52type RobotsEvent = std::convert::Infallible;
53
54async fn next_robots(robots: &mut Option<RobotsRun>) -> Option<RobotsEvent> {
56 #[cfg(feature = "robots")]
57 if let Some(robots) = robots
58 && let Some(joined) = robots.tasks.join_next().await
59 {
60 return joined.ok();
61 }
62
63 #[cfg(not(feature = "robots"))]
64 let _ = robots;
65 std::future::pending().await
66}
67
68#[cfg_attr(
70 not(feature = "robots"),
71 expect(dead_code, reason = "robots.txt only disallows with `robots`")
72)]
73enum Access {
74 Allow(Seed),
75 Disallow(Seed),
76 Parked,
77}
78
79struct Run<B> {
81 tasks: JoinSet<(Outcome, Fetch)>,
82 in_flight: HashMap<Id, InFlight>,
84 robots: Option<RobotsRun>,
85 hosts: Hosts,
86 connector: Arc<Connector<B>>,
88 aborted: HashSet<Option<Tag>>,
89 started: usize,
90 stopping: bool,
91 grace: Option<Instant>,
92 error: Option<Error>,
93}
94
95struct InFlight {
97 seed: Seed,
98 identity: Option<Identity>,
100 host: Option<String>,
102 started: Instant,
104}
105
106pub(super) fn signal(outcome: &Outcome) -> Signal {
111 match outcome {
112 Outcome::Fail(error)
113 if error.kind() == ErrorKind::Timeout
114 || matches!(
115 error.status().map(|status| status.as_u16()),
116 Some(429 | 503)
117 ) =>
118 {
119 Signal::Throttled
120 }
121 Outcome::Fail(_) | Outcome::Retry { .. } | Outcome::Backoff { .. } => Signal::Failed,
122 Outcome::Continue | Outcome::Abort(_) | Outcome::Stop => Signal::Ok,
123 }
124}
125
126impl<B: Backend> Run<B> {
127 fn finish_host(
130 &mut self,
131 host: Option<String>,
132 seed: &Seed,
133 started: Instant,
134 fetch: Fetch,
135 outcome: &Outcome,
136 ) {
137 let now = Instant::now();
138 let pause = match outcome {
142 Outcome::Fail(error) if error.is_retryable() => error.retry_after(),
143 _ => None,
144 };
145 let signal = if pause.is_some() {
146 Signal::Failed
147 } else {
148 signal(outcome)
149 };
150 let throttled = signal == Signal::Throttled || pause.is_some();
153 let Some(host) = host.or_else(|| throttled.then(|| self.hosts.track(seed))) else {
154 return;
155 };
156 let feedback = Feedback {
157 started,
158 latency: fetch.latency,
159 unchanged: fetch.status == Some(StatusCode::NOT_MODIFIED),
160 signal,
161 };
162 self.hosts.finished(&host, Some(feedback), now);
163 if let Some(pause) = pause {
164 self.hosts.pause(&host, now + pause);
165 }
166 }
167
168 fn stop(&mut self, reason: &'static str, grace: Option<Duration>) {
169 if self.stopping {
170 return;
171 }
172 tracing::info!(target: TRACING_TARGET, in_flight = self.tasks.len(), reason, "stopping crawl");
173 self.stopping = true;
174 self.grace = grace.map(|grace| Instant::now() + grace);
175 }
176
177 fn fail(&mut self, error: Error, grace: Option<Duration>) {
180 tracing::error!(target: TRACING_TARGET, %error, "frontier failed");
181 self.error.get_or_insert(error);
182 self.stop("frontier failed", grace);
183 }
184
185 fn is_aborted(&self, seed: &Seed) -> bool {
186 self.aborted.contains(&seed.tag().cloned())
187 }
188
189 #[cfg_attr(
191 not(feature = "robots"),
192 expect(clippy::unused_self, reason = "no robots.txt state")
193 )]
194 fn robots_busy(&self) -> bool {
195 #[cfg(feature = "robots")]
196 return self
197 .robots
198 .as_ref()
199 .is_some_and(|robots| !robots.tasks.is_empty());
200 #[cfg(not(feature = "robots"))]
201 false
202 }
203
204 #[cfg_attr(
206 not(feature = "robots"),
207 expect(clippy::unused_self, reason = "no robots.txt state")
208 )]
209 fn robots_access(&mut self, seed: Seed) -> Access {
210 #[cfg(feature = "robots")]
211 if let Some(robots) = &mut self.robots {
212 return match robots.cache.admit(seed, Instant::now()) {
213 Verdict::Allow(seed) => Access::Allow(seed),
214 Verdict::Disallow(seed) => Access::Disallow(seed),
215 Verdict::Parked => Access::Parked,
216 };
217 }
218 Access::Allow(seed)
219 }
220
221 #[cfg_attr(
224 not(feature = "robots"),
225 expect(clippy::unused_self, reason = "no robots.txt state")
226 )]
227 fn robots_tick(&mut self) -> Option<Instant> {
228 #[cfg(feature = "robots")]
229 if let Some(robots) = &mut self.robots {
230 robots.cache.retry_due(Instant::now());
231 for (origin, url) in robots.cache.take_fetches() {
232 let fetch = robots.source.fetch(url, robots.agent.clone());
233 robots.tasks.spawn(async move { (origin, fetch.await) });
234 }
235 return robots.cache.earliest();
236 }
237 None
238 }
239
240 fn parked(&self) -> usize {
242 #[cfg(feature = "robots")]
243 let robots = self
244 .robots
245 .as_ref()
246 .map_or(0, |robots| robots.cache.parked());
247 #[cfg(not(feature = "robots"))]
248 let robots = 0;
249 self.hosts.parked() + robots
250 }
251
252 fn drain(&mut self) -> Vec<Seed> {
254 let seeds = self.hosts.drain();
255 #[cfg(feature = "robots")]
256 let seeds = match &mut self.robots {
257 Some(robots) => {
258 robots.tasks.abort_all();
259 seeds.into_iter().chain(robots.cache.drain()).collect()
260 }
261 None => seeds,
262 };
263 seeds
264 }
265}
266
267impl<B, W> Crawler<B, W>
268where
269 B: Backend,
270 W: Worker<B::Session>,
271{
272 pub async fn run(&self) -> Result<Stats> {
281 let seeds = std::mem::take(&mut *lock(&self.seeds));
282 for seed in seeds {
283 self.frontier.push(seed).await?;
284 }
285
286 let started = Instant::now();
287 *lock(&self.live) = Live {
288 stats: Stats::default(),
289 started: Some(started),
290 running: true,
291 };
292
293 let settings = &self.settings;
294 let deadline = settings.time_limit.map(|limit| started + limit);
295 let mut run = self.new_run();
296 tracing::info!(target: TRACING_TARGET, concurrency = settings.concurrency.get(), "crawl started");
297
298 let mut wait_timer = pin!(sleep_until(started));
302 let mut armed_wait = None;
303 let mut deadline_timer = pin!(sleep_until(deadline.unwrap_or(started)));
304 let mut grace_timer = pin!(sleep_until(started));
305 let mut armed_grace = None;
306 let mut shutdown = pin!(self.shutdown.cancelled());
307
308 loop {
309 let wait = if run.stopping {
310 None
311 } else {
312 self.fill(&mut run).await
313 };
314
315 if run.tasks.is_empty() && !run.robots_busy() && (run.stopping || wait.is_none()) {
316 break;
317 }
318 if let Some(at) = wait
319 && armed_wait != wait
320 {
321 wait_timer.as_mut().reset(at);
322 armed_wait = wait;
323 }
324 if let Some(at) = run.grace
325 && armed_grace != run.grace
326 {
327 grace_timer.as_mut().reset(at);
328 armed_grace = run.grace;
329 }
330
331 tokio::select! {
334 biased;
335 () = &mut shutdown, if !run.stopping => {
336 run.stop("shutdown requested", settings.shutdown_grace);
337 }
338 () = &mut deadline_timer, if deadline.is_some() && !run.stopping => {
339 run.stop("time limit reached", settings.shutdown_grace);
340 }
341 () = &mut grace_timer, if run.grace.is_some() => {
342 tracing::warn!(target: TRACING_TARGET, in_flight = run.tasks.len(), "shutdown grace period over, cancelling requests");
343 run.grace = None;
344 run.tasks.abort_all();
345 }
346 Some(joined) = run.tasks.join_next_with_id() => self.complete(&mut run, joined).await,
347 Some(fetched) = next_robots(&mut run.robots) => self.robots_fetched(&mut run, fetched).await,
348 () = &mut wait_timer, if wait.is_some() => {
349 armed_wait = None;
350 }
351 }
352 }
353
354 for seed in run.drain() {
355 self.report(&mut run, Report::Cancelled(seed)).await;
356 }
357
358 let stats = {
359 let mut live = lock(&self.live);
360 live.running = false;
361 live.stats.elapsed = started.elapsed();
362 live.stats
363 };
364 tracing::info!(
365 target: TRACING_TARGET,
366 requests = stats.requests,
367 succeeded = stats.succeeded,
368 failed = stats.failed,
369 retried = stats.retried,
370 skipped = stats.skipped,
371 cancelled = stats.cancelled,
372 elapsed_ms = stats.elapsed.as_millis(),
373 "crawl finished",
374 );
375
376 match run.error {
377 Some(error) => Err(error),
378 None => Ok(stats),
379 }
380 }
381
382 fn new_run(&self) -> Run<B> {
384 let backend = self.backend.clone();
385 let connector = Arc::new(match &self.identities {
386 Some(identities) => Connector::with_identities(backend, identities.clone()),
387 None => Connector::new(backend),
388 });
389 let robots = self.robots_run(&connector);
390 let hosts = Hosts::new(self.settings.hosts, robots.is_some());
393 Run {
394 tasks: JoinSet::new(),
395 in_flight: HashMap::new(),
396 robots,
397 hosts,
398 connector,
399 aborted: HashSet::new(),
400 started: 0,
401 stopping: false,
402 grace: None,
403 error: None,
404 }
405 }
406
407 async fn fill(&self, run: &mut Run<B>) -> Option<Instant> {
410 let settings = &self.settings;
411 let mut wait = run.robots_tick();
412
413 while run.tasks.len() < settings.concurrency.get() {
414 if settings.max_requests.is_some_and(|max| run.started >= max) {
415 run.stop("request budget spent", settings.shutdown_grace);
416 return None;
417 }
418
419 if let Some(seed) = run.hosts.take_ready(Instant::now()) {
420 self.start(run, seed).await;
421 continue;
422 }
423 if run.parked() >= MAX_PARKED {
424 break;
425 }
426
427 match self.frontier.pop().await {
428 Ok(Pop::Ready(seed)) => self.admit(run, seed).await,
429 Ok(Pop::Wait(at)) => {
430 wait = wait.min(Some(at)).or(Some(at));
431 break;
432 }
433 Ok(Pop::Empty) => break,
434 Err(error) => {
435 run.fail(error, settings.shutdown_grace);
436 return None;
437 }
438 }
439 }
440
441 let robots = run.robots_tick();
442 [wait, robots, run.hosts.earliest()]
443 .into_iter()
444 .flatten()
445 .min()
446 }
447
448 async fn admit(&self, run: &mut Run<B>, seed: Seed) {
451 match run.robots_access(seed) {
452 Access::Allow(seed) => match run.hosts.admit(seed, Instant::now()) {
453 Admit::Start(seed) => self.start(run, seed).await,
454 Admit::Parked => {}
455 },
456 Access::Disallow(seed) => self.skip(run, seed, "disallowed by robots.txt").await,
457 Access::Parked => {}
458 }
459 }
460
461 #[cfg(feature = "robots")]
465 fn robots_run(&self, connector: &Arc<Connector<B>>) -> Option<RobotsRun> {
466 let config = self.settings.robots.clone()?;
467 let source = config
468 .source
469 .clone()
470 .unwrap_or_else(|| Arc::new(BackendSource::new(connector.clone())));
471
472 Some(RobotsRun {
473 agent: config.agent.clone(),
474 cache: RobotsCache::new(config),
475 source,
476 tasks: JoinSet::new(),
477 })
478 }
479
480 #[cfg(not(feature = "robots"))]
481 #[expect(clippy::unused_self, reason = "mirrors the signature with `robots`")]
482 const fn robots_run(&self, _connector: &Arc<Connector<B>>) -> Option<RobotsRun> {
483 None
484 }
485
486 #[cfg(feature = "robots")]
488 async fn robots_fetched(&self, run: &mut Run<B>, (origin, result): RobotsEvent) {
489 let Some(robots) = &mut run.robots else {
490 return;
491 };
492 let fetched = robots.cache.fetched(&origin, result, Instant::now());
493 if let Some(delay) = fetched.delay {
494 let host = origin
495 .split_once("://")
496 .map_or(origin.as_str(), |(_, host)| host);
497 run.hosts.raise_delay(host, delay);
498 }
499 for seed in fetched.sitemaps {
500 let uri = seed.uri().clone();
501 if let Err(error) = self.queue().push(seed).await {
502 tracing::warn!(target: TRACING_TARGET, %uri, %error, "failed to queue a sitemap");
503 }
504 }
505 for seed in fetched.released {
506 self.admit(run, seed).await;
507 }
508 }
509
510 #[cfg(not(feature = "robots"))]
511 #[expect(clippy::unused_self, reason = "mirrors the signature with `robots`")]
512 fn robots_fetched(&self, _run: &mut Run<B>, never: RobotsEvent) -> std::future::Ready<()> {
513 match never {}
514 }
515
516 async fn skip(&self, run: &mut Run<B>, seed: Seed, reason: &'static str) {
518 tracing::debug!(target: TRACING_TARGET, uri = %seed.uri(), reason, "skipping request");
519 self.tally(|stats| stats.skipped += 1);
520 self.report(run, Report::Skipped(seed)).await;
521 }
522
523 async fn start(&self, run: &mut Run<B>, seed: Seed) {
525 if run.is_aborted(&seed) {
526 self.skip(run, seed, "tag aborted").await;
527 return;
528 }
529
530 run.started += 1;
531 let now = Instant::now();
532 let host = run.hosts.start(&seed, now);
533
534 let worker = self.worker.clone();
535 let queue = self.queue();
536 let datasets = self.datasets.clone();
537 let deadline = self.settings.request_timeout;
538 let span = tracing::debug_span!(
539 target: TRACING_TARGET,
540 "request",
541 method = %seed.method(),
542 uri = %seed.uri(),
543 tag = seed.tag().map(tracing::field::display),
544 depth = %seed.depth(),
545 attempt = %seed.attempt(),
546 );
547
548 let connector = run.connector.clone();
551 let mut request = seed.clone().into_request();
552 let identity = connector.identify(&seed, &mut request);
553 let used = identity.as_ref().ok().cloned().flatten();
554
555 let task = async move {
556 let started = Instant::now();
557 let handle = async {
558 let connected = match identity {
559 Ok(identity) => connector.connect(identity.as_ref()).await,
560 Err(error) => Err(error),
561 };
562 let client = match connected {
563 Ok(client) => client,
564 Err(error) => return Outcome::Fail(error),
565 };
566 let queue: Queue = queue.child_of(&request);
567 let cx = Context::from_parts(request, client, queue, datasets);
568 worker.handle(cx).await
569 };
570 let (outcome, fetch) = FETCH
574 .scope(Cell::default(), async {
575 let outcome = match deadline {
576 Some(deadline) => timeout(deadline, handle).await.unwrap_or_else(|_| {
577 let error =
578 Error::new(ErrorKind::Timeout).with_message("request timed out");
579 Outcome::Fail(error)
580 }),
581 None => handle.await,
582 };
583 (outcome, FETCH.with(Cell::get))
584 })
585 .await;
586
587 tracing::debug!(
588 target: TRACING_TARGET,
589 outcome = %label(&outcome),
590 elapsed_ms = started.elapsed().as_millis(),
591 "request handled",
592 );
593 (outcome, fetch)
594 };
595
596 let id = run.tasks.spawn(Box::pin(task.instrument(span))).id();
598 let in_flight = InFlight {
599 seed,
600 identity: used,
601 host,
602 started: now,
603 };
604 run.in_flight.insert(id, in_flight);
605 }
606
607 async fn complete(&self, run: &mut Run<B>, joined: Result<(Id, (Outcome, Fetch)), JoinError>) {
609 let (id, (outcome, fetch)) = match joined {
610 Ok(joined) => joined,
611 Err(error) if error.is_cancelled() => {
612 let Some(InFlight { seed, host, .. }) = run.in_flight.remove(&error.id()) else {
613 return;
614 };
615 if let Some(host) = host {
616 run.hosts.finished(&host, None, Instant::now());
617 }
618 self.tally(|stats| stats.cancelled += 1);
619 self.report(run, Report::Cancelled(seed)).await;
620 return;
621 }
622 Err(error) => {
623 let failure = Error::worker("worker panicked").with_source(error.to_string());
624 (error.id(), (Outcome::Fail(failure), Fetch::default()))
625 }
626 };
627 let Some(InFlight {
628 seed,
629 identity,
630 host,
631 started,
632 }) = run.in_flight.remove(&id)
633 else {
634 return;
635 };
636 run.finish_host(host, &seed, started, fetch, &outcome);
637 if let Some(identity) = &identity {
638 run.connector
639 .finished(identity, Health::of(&outcome, fetch.status));
640 }
641 self.tally(|stats| {
642 stats.requests += 1;
643 if fetch.status == Some(StatusCode::NOT_MODIFIED) {
644 stats.unchanged += 1;
645 }
646 });
647
648 let uri = seed.uri().clone();
649 let mut failure = None;
650 let report = match outcome {
651 Outcome::Continue => {
652 self.tally(|stats| stats.succeeded += 1);
653 Report::Done(seed)
654 }
655 Outcome::Stop => {
656 self.tally(|stats| stats.succeeded += 1);
657 run.stop("worker requested stop", self.settings.shutdown_grace);
658 Report::Done(seed)
659 }
660 Outcome::Abort(scope) => {
661 let tag = match scope {
662 Scope::SameTag => seed.tag().cloned(),
663 Scope::Tag(tag) => Some(tag),
664 };
665 tracing::info!(target: TRACING_TARGET, tag = tag.as_ref().map(tracing::field::display), "aborting tag");
666 run.aborted.insert(tag);
667 self.tally(|stats| stats.succeeded += 1);
668 Report::Done(seed)
669 }
670 Outcome::Retry { after } => Report::Retry { seed, after },
671 Outcome::Backoff { scope, duration } => self.backoff(run, seed, scope, duration).await,
672 Outcome::Fail(error) => {
673 let error = match error.uri() {
674 Some(_) => error,
675 None => error.with_uri(uri.clone()),
676 };
677 if error.is_retryable() {
678 tracing::debug!(target: TRACING_TARGET, %uri, %error, "request failed, retrying");
679 let after = error.retry_after();
682 failure = Some(error);
683 Report::Retry { seed, after }
684 } else {
685 tracing::warn!(target: TRACING_TARGET, %uri, %error, "request failed");
686 self.tally(|stats| stats.failed += 1);
687 Report::Failed(seed)
688 }
689 }
690 };
691
692 let retry = matches!(report, Report::Retry { .. });
693 match self.report(run, report).await {
694 Some(Ack::Applied) if retry => self.tally(|stats| stats.retried += 1),
695 Some(Ack::RetryRefused) => {
696 self.tally(|stats| stats.failed += 1);
697 tracing::warn!(
698 target: TRACING_TARGET,
699 %uri,
700 error = failure.as_ref().map(tracing::field::display),
701 "request failed, out of retries",
702 );
703 }
704 _ => {}
705 }
706 }
707
708 async fn backoff(
711 &self,
712 run: &mut Run<B>,
713 seed: Seed,
714 scope: Scope,
715 duration: Duration,
716 ) -> Report {
717 let tag = match scope {
718 Scope::SameTag => seed.tag().cloned(),
719 Scope::Tag(tag) => Some(tag),
720 };
721 tracing::debug!(
722 target: TRACING_TARGET,
723 tag = tag.as_ref().map(tracing::field::display),
724 duration_ms = duration.as_millis(),
725 "backing off",
726 );
727
728 let until = Instant::now() + duration;
729 self.report(run, Report::Backoff { tag, until }).await;
730 Report::Retry {
731 seed,
732 after: Some(duration),
733 }
734 }
735
736 async fn report(&self, run: &mut Run<B>, report: Report) -> Option<Ack> {
738 match self.frontier.report(report).await {
739 Ok(ack) => Some(ack),
740 Err(error) => {
741 run.fail(error, self.settings.shutdown_grace);
742 None
743 }
744 }
745 }
746}
747
748const fn label(outcome: &Outcome) -> &'static str {
750 match outcome {
751 Outcome::Continue => "continue",
752 Outcome::Retry { .. } => "retry",
753 Outcome::Backoff { .. } => "backoff",
754 Outcome::Abort(_) => "abort",
755 Outcome::Stop => "stop",
756 Outcome::Fail(_) => "fail",
757 }
758}