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