Skip to main content

spire_core/crawler/
run.rs

1//! The run loop of a [`Crawler`].
2
3use 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
30/// Seeds parked for busy hosts at most, before the frontier stops being
31/// read; bounds memory when one host dominates the frontier.
32const MAX_PARKED: usize = 4096;
33
34/// The robots.txt state of a run.
35#[cfg(feature = "robots")]
36struct RobotsRun {
37    cache: RobotsCache,
38    source: Arc<dyn RobotsSource>,
39    agent: Arc<str>,
40    tasks: JoinSet<RobotsEvent>,
41}
42
43/// A fetched robots.txt: the origin and its rules, or why it is unreachable.
44#[cfg(feature = "robots")]
45type RobotsEvent = (String, Result<robotxt::Robots>);
46
47/// Without the `robots` feature, no robots.txt state exists.
48#[cfg(not(feature = "robots"))]
49enum RobotsRun {}
50
51#[cfg(not(feature = "robots"))]
52type RobotsEvent = std::convert::Infallible;
53
54/// Waits for the next fetched robots.txt; never resolves without fetches.
55async 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/// Whether robots.txt lets a seed be crawled.
69#[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
79/// The state of a single run.
80struct Run<B> {
81    tasks: JoinSet<(Outcome, Fetch)>,
82    /// The running requests.
83    in_flight: HashMap<Id, InFlight>,
84    robots: Option<RobotsRun>,
85    hosts: Hosts,
86    /// Connects for requests and robots.txt, as an identity or plainly.
87    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
95/// A running request.
96struct InFlight {
97    seed: Seed,
98    /// The identity it went out as, if any.
99    identity: Option<Identity>,
100    /// Its host's key, if hosts are tracked.
101    host: Option<String>,
102    /// When it started.
103    started: Instant,
104}
105
106/// How a request went, as far as its host is concerned: a host answering
107/// `429` or `503`, or timing out, is throttling the crawl. A handler's
108/// backoff pauses its tag, not the host, so it is neither a success nor
109/// throttling.
110pub(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    /// Records that a request for `seed` to `host` finished with `outcome`:
128    /// a throttled host is slowed down, and one with `Retry-After` paused.
129    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        // A retryable error with `Retry-After` pauses the host it came from
139        // for exactly as long, without slowing it down further: the host
140        // said how long to wait, so there is nothing to guess.
141        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        // Hosts are tracked as soon as one is throttled, so it slows down
151        // even in a crawl without limits or gaps.
152        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    /// Stops the run because the frontier failed; the error is returned once
178    /// in-flight requests are drained.
179    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    /// Whether robots.txt fetches are in flight.
190    #[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    /// Asks robots.txt whether `seed` may be crawled.
205    #[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    /// Starts the robots.txt fetches due, returning when the next blocked
222    /// origin is retried.
223    #[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    /// The number of seeds parked for hosts or robots.txt.
241    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    /// Takes every parked seed, e.g. when the run stops.
253    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    /// Crawls until the frontier is empty and no request is in flight, a
273    /// worker returns [`Outcome::Stop`], the budget is spent, or the run is
274    /// shut down.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if a seed cannot be pushed, or if the frontier fails
279    /// mid-run; requests in flight are drained first.
280    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        // Timers and the shutdown signal are created once and re-armed as
299        // needed: `select!` would otherwise build them again for every
300        // finished request.
301        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            // Biased: the rare control branches are checked first, so a steady
332            // stream of finished requests cannot delay a shutdown.
333            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    /// Sets up the state of a run: its identities, robots.txt and hosts.
383    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        // A robots.txt `Crawl-delay` may slow a host down, so hosts are
391        // tracked whenever robots.txt is obeyed.
392        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    /// Starts requests until the concurrency limit or budget is reached or
408    /// no seed is ready, returning when the next one will be.
409    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    /// Starts a popped seed now, parks it until robots.txt and its host
449    /// allow it, or skips it if robots.txt disallows it.
450    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    /// Builds the robots.txt state, fetching through the run's connector,
462    /// as one of its identities if it has any, unless
463    /// [`Robots`](crate::crawler::Robots) sets another backend.
464    #[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    /// Applies a fetched robots.txt and admits the seeds that waited for it.
487    #[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    /// Skips `seed` without crawling it.
517    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    /// Skips `seed` if its tag was aborted, and otherwise spawns the request.
524    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        // The identity is picked now, as the request starts, so a failure to
549        // pick one fails the request like any other.
550        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            // The context records the fetch into this task's scope, which
571            // outlives a timeout: a fetch that finished before the handlers
572            // ran out of time still counts.
573            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        // Boxed so spawning moves a pointer, not the whole request future.
597        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    /// Records a finished request and reports its outcome to the frontier.
608    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                    // A `Retry-After` paused the host above; the request
680                    // itself waits as long.
681                    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    /// Pauses `scope` for `duration`, returning the report that retries
709    /// `seed` once the pause ends.
710    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    /// Reports to the frontier; a failure stops the run.
737    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
748/// Names an outcome for logs.
749const 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}