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