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