Skip to main content

pipedash_core/application/
fetch_scheduler.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::sync::atomic::{
4    AtomicU64,
5    Ordering,
6};
7use std::sync::{
8    Arc,
9    Mutex,
10    MutexGuard,
11    PoisonError,
12};
13use std::time::Duration;
14
15use chrono::{
16    DateTime,
17    Utc,
18};
19use tokio::sync::watch;
20use tokio::time::Instant;
21
22use crate::domain::{
23    DomainError,
24    FailureKind,
25};
26
27const FAR_FUTURE: Duration = Duration::from_secs(10 * 365 * 24 * 60 * 60);
28const MAX_INTERVAL: Duration = Duration::from_secs(365 * 24 * 60 * 60);
29
30static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub struct Generation(u64);
34
35impl Generation {
36    pub fn next() -> Self {
37        Self(NEXT_GENERATION.fetch_add(1, Ordering::Relaxed))
38    }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ClaimKind {
43    Scheduled,
44    Forced,
45}
46
47#[derive(Debug, Clone)]
48pub struct BackoffPolicy {
49    pub max_backoff: Duration,
50    pub max_rate_limit: Duration,
51    pub outage_floor: Duration,
52    pub forced_min_gap: Duration,
53}
54
55impl Default for BackoffPolicy {
56    fn default() -> Self {
57        Self {
58            max_backoff: Duration::from_secs(10 * 60),
59            max_rate_limit: Duration::from_secs(60 * 60),
60            outage_floor: Duration::from_secs(2 * 60),
61            forced_min_gap: Duration::from_secs(10),
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum Outcome {
68    Success,
69    Failed {
70        kind: FailureKind,
71        retry_after: Option<Duration>,
72    },
73    Cancelled,
74}
75
76#[derive(Debug, Clone)]
77pub enum FetchResult {
78    Committed(Result<usize, DomainError>),
79    Superseded,
80    Cancelled,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ScheduleView {
85    pub failures: u32,
86    pub last_failure: Option<FailureKind>,
87    pub next_attempt_in: Duration,
88    pub in_flight: bool,
89}
90
91type AttemptSender = watch::Sender<Option<FetchResult>>;
92type AttemptReceiver = watch::Receiver<Option<FetchResult>>;
93
94struct QuotaPause {
95    until: DateTime<Utc>,
96    resume: Instant,
97}
98
99struct Entry {
100    generation: Generation,
101    interval: Duration,
102    next_attempt: Instant,
103    last_attempt: Option<Instant>,
104    last_outcome: Option<Outcome>,
105    failures: u32,
106    last_failure: Option<FailureKind>,
107    attempt: Option<AttemptReceiver>,
108    quota: Option<QuotaPause>,
109    forced_waiters: usize,
110}
111
112impl Entry {
113    fn new(generation: Generation, interval: Duration, now: Instant) -> Self {
114        Self {
115            generation,
116            interval: interval.min(MAX_INTERVAL),
117            next_attempt: now,
118            last_attempt: None,
119            last_outcome: None,
120            failures: 0,
121            last_failure: None,
122            attempt: None,
123            quota: None,
124            forced_waiters: 0,
125        }
126    }
127
128    fn view(&self, now: Instant) -> ScheduleView {
129        ScheduleView {
130            failures: self.failures,
131            last_failure: self.last_failure,
132            next_attempt_in: self.next_attempt.saturating_duration_since(now),
133            in_flight: self.attempt.is_some(),
134        }
135    }
136
137    fn due_at(&self, floor: Option<Duration>) -> Instant {
138        match (floor, self.last_attempt) {
139            (Some(floor), Some(last)) => self.next_attempt.max(after(last, floor)),
140            _ => self.next_attempt,
141        }
142    }
143
144    fn forced_delay(&self, policy: &BackoffPolicy, now: Instant) -> Duration {
145        let mut resume = self
146            .quota
147            .as_ref()
148            .map_or(now, |quota| quota.resume.max(now));
149        if matches!(
150            self.last_outcome,
151            Some(Outcome::Failed {
152                kind: FailureKind::RateLimited,
153                ..
154            })
155        ) {
156            resume = resume.max(self.next_attempt);
157        }
158        if let Some(last) = self.last_attempt {
159            resume = resume.max(after(last, policy.forced_min_gap));
160        }
161        resume.saturating_duration_since(now)
162    }
163
164    fn start_attempt(&mut self, now: Instant) -> (AttemptSender, Option<Instant>) {
165        let (sender, receiver) = watch::channel(None);
166        self.attempt = Some(receiver);
167        (sender, self.last_attempt.replace(now))
168    }
169
170    fn release(&mut self, previous_attempt: Option<Instant>) {
171        self.attempt = None;
172        self.last_attempt = previous_attempt;
173    }
174
175    fn finish(&mut self, policy: &BackoffPolicy, now: Instant, outcome: Outcome) {
176        let interval = self.interval;
177        let wait = match &outcome {
178            Outcome::Success => {
179                self.failures = 0;
180                self.last_failure = None;
181                interval
182            }
183            Outcome::Cancelled => interval,
184            Outcome::Failed { kind, retry_after } => {
185                self.last_failure = Some(*kind);
186                if *kind == FailureKind::RateLimited {
187                    self.failures = self.failures.saturating_add(1);
188                    let wait = (*retry_after).unwrap_or_else(|| doubled(interval, self.failures));
189                    interval.max(wait.min(policy.max_rate_limit))
190                } else if kind.is_transient() {
191                    self.failures = self.failures.saturating_add(1);
192                    let hint = (*retry_after)
193                        .unwrap_or_default()
194                        .min(policy.max_rate_limit);
195                    interval
196                        .max(doubled(interval, self.failures).min(policy.max_backoff))
197                        .max(hint)
198                } else {
199                    interval
200                }
201            }
202        };
203        self.attempt = None;
204        self.last_outcome = Some(outcome);
205        self.next_attempt = after(now, wait);
206        match self.quota.as_ref().map(|quota| quota.resume) {
207            Some(resume) if resume > now => self.next_attempt = self.next_attempt.max(resume),
208            Some(_) => self.quota = None,
209            None => {}
210        }
211    }
212}
213
214fn doubled(interval: Duration, failures: u32) -> Duration {
215    interval.saturating_mul(1u32.checked_shl(failures).unwrap_or(u32::MAX))
216}
217
218fn after(now: Instant, wait: Duration) -> Instant {
219    now.checked_add(wait).unwrap_or_else(|| now + FAR_FUTURE)
220}
221
222pub struct FetchScheduler {
223    entries: Mutex<HashMap<i64, Entry>>,
224    policy: BackoffPolicy,
225}
226
227impl Default for FetchScheduler {
228    fn default() -> Self {
229        Self::new(BackoffPolicy::default())
230    }
231}
232
233impl FetchScheduler {
234    pub fn new(policy: BackoffPolicy) -> Self {
235        Self {
236            entries: Mutex::new(HashMap::new()),
237            policy,
238        }
239    }
240
241    pub fn policy(&self) -> &BackoffPolicy {
242        &self.policy
243    }
244
245    fn entries(&self) -> MutexGuard<'_, HashMap<i64, Entry>> {
246        self.entries.lock().unwrap_or_else(PoisonError::into_inner)
247    }
248
249    pub fn register(&self, id: i64, generation: Generation, interval: Duration) {
250        let mut entries = self.entries();
251        if entries
252            .get(&id)
253            .is_some_and(|entry| entry.generation == generation)
254        {
255            return;
256        }
257        entries.insert(id, Entry::new(generation, interval, Instant::now()));
258    }
259
260    pub fn set_interval(&self, id: i64, generation: Generation, interval: Duration) {
261        let mut entries = self.entries();
262        let Some(entry) = entries
263            .get_mut(&id)
264            .filter(|entry| entry.generation == generation)
265        else {
266            return;
267        };
268        entry.interval = interval.min(MAX_INTERVAL);
269        if entry.failures == 0 && entry.attempt.is_none() {
270            if let Some(last) = entry.last_attempt {
271                entry.next_attempt = after(last, entry.interval);
272            }
273        }
274    }
275
276    pub fn reset(&self, id: i64, generation: Generation, interval: Duration) {
277        self.entries()
278            .insert(id, Entry::new(generation, interval, Instant::now()));
279    }
280
281    pub fn remove(&self, id: i64) {
282        self.entries().remove(&id);
283    }
284
285    pub fn claim_due(
286        self: &Arc<Self>, now: Instant, floors: &HashMap<i64, Duration>,
287    ) -> Vec<FetchClaim> {
288        let mut entries = self.entries();
289        let mut claims: Vec<FetchClaim> = entries
290            .iter_mut()
291            .filter(|(id, entry)| {
292                !entry.interval.is_zero()
293                    && entry.forced_waiters == 0
294                    && entry.attempt.is_none()
295                    && now >= entry.due_at(floors.get(*id).copied())
296            })
297            .map(|(id, entry)| {
298                let (result, previous_attempt) = entry.start_attempt(now);
299                FetchClaim {
300                    scheduler: Arc::clone(self),
301                    id: *id,
302                    generation: entry.generation,
303                    result,
304                    previous_attempt,
305                    done: false,
306                }
307            })
308            .collect();
309        claims.sort_by_key(FetchClaim::id);
310
311        claims
312    }
313
314    pub(crate) fn prioritize_forced(
315        self: &Arc<Self>, id: i64, generation: Generation,
316    ) -> Option<ForcedWait> {
317        let mut entries = self.entries();
318        let entry = entries
319            .get_mut(&id)
320            .filter(|entry| entry.generation == generation)?;
321        entry.forced_waiters += 1;
322        Some(ForcedWait {
323            scheduler: Arc::clone(self),
324            id,
325            generation,
326        })
327    }
328
329    pub fn claim_forced(
330        self: &Arc<Self>, id: i64, generation: Generation, now: Instant,
331    ) -> ForcedClaim {
332        let mut entries = self.entries();
333        let Some(entry) = entries
334            .get_mut(&id)
335            .filter(|entry| entry.generation == generation)
336        else {
337            return ForcedClaim::Refused;
338        };
339        if let Some(attempt) = &entry.attempt {
340            return ForcedClaim::Joined(AttemptHandle(attempt.clone()));
341        }
342        if let Some(resume) = entry
343            .quota
344            .as_ref()
345            .map(|quota| quota.resume)
346            .filter(|resume| *resume > now)
347        {
348            return ForcedClaim::Recent(Outcome::Failed {
349                kind: FailureKind::RateLimited,
350                retry_after: Some(resume.saturating_duration_since(now)),
351            });
352        }
353        if !entry.forced_delay(&self.policy, now).is_zero() {
354            return ForcedClaim::Recent(entry.last_outcome.clone().unwrap_or(Outcome::Cancelled));
355        }
356
357        let (result, previous_attempt) = entry.start_attempt(now);
358        ForcedClaim::Claimed(FetchClaim {
359            scheduler: Arc::clone(self),
360            id,
361            generation,
362            result,
363            previous_attempt,
364            done: false,
365        })
366    }
367
368    pub(crate) fn forced_retry_in(
369        &self, id: i64, generation: Generation, now: Instant,
370    ) -> Option<Duration> {
371        self.entries()
372            .get(&id)
373            .filter(|entry| entry.generation == generation)
374            .map(|entry| entry.forced_delay(&self.policy, now))
375    }
376
377    pub fn view(&self, id: i64, now: Instant) -> Option<ScheduleView> {
378        self.entries().get(&id).map(|entry| entry.view(now))
379    }
380
381    pub fn next_due_in(&self, id: i64, now: Instant, floor: Option<Duration>) -> Option<Duration> {
382        self.entries()
383            .get(&id)
384            .map(|entry| entry.due_at(floor).saturating_duration_since(now))
385    }
386
387    pub fn set_quota_pause(
388        &self, id: i64, generation: Generation, until: Option<DateTime<Utc>>, now: Instant,
389        wall: DateTime<Utc>,
390    ) -> bool {
391        let mut entries = self.entries();
392        let Some(entry) = entries
393            .get_mut(&id)
394            .filter(|entry| entry.generation == generation)
395        else {
396            return false;
397        };
398        let previous = entry.quota.as_ref().map(|quota| quota.until);
399        let pause = until.and_then(|until| {
400            (until - wall)
401                .to_std()
402                .ok()
403                .filter(|wait| !wait.is_zero())
404                .map(|wait| QuotaPause {
405                    until,
406                    resume: after(now, wait),
407                })
408        });
409        if let Some(pause) = &pause {
410            entry.next_attempt = entry.next_attempt.max(pause.resume);
411        }
412        let current = pause.as_ref().map(|pause| pause.until);
413        entry.quota = pause;
414
415        previous != current
416    }
417
418    pub fn paused_until(&self, id: i64, wall: DateTime<Utc>) -> Option<DateTime<Utc>> {
419        self.entries()
420            .get(&id)
421            .and_then(|entry| entry.quota.as_ref())
422            .map(|quota| quota.until)
423            .filter(|until| *until > wall)
424    }
425
426    fn finish(
427        &self, id: i64, generation: Generation, now: Instant, outcome: Outcome,
428    ) -> Option<ScheduleView> {
429        let mut entries = self.entries();
430        let entry = entries
431            .get_mut(&id)
432            .filter(|entry| entry.generation == generation)?;
433        entry.finish(&self.policy, now, outcome);
434
435        Some(entry.view(now))
436    }
437
438    fn release(&self, id: i64, generation: Generation, previous_attempt: Option<Instant>) {
439        if let Some(entry) = self
440            .entries()
441            .get_mut(&id)
442            .filter(|entry| entry.generation == generation)
443        {
444            entry.release(previous_attempt);
445        }
446    }
447}
448
449#[derive(Debug)]
450pub enum ForcedClaim {
451    Claimed(FetchClaim),
452    Joined(AttemptHandle),
453    Recent(Outcome),
454    Refused,
455}
456
457pub(crate) struct ForcedWait {
458    scheduler: Arc<FetchScheduler>,
459    id: i64,
460    generation: Generation,
461}
462
463impl Drop for ForcedWait {
464    fn drop(&mut self) {
465        if let Some(entry) = self
466            .scheduler
467            .entries()
468            .get_mut(&self.id)
469            .filter(|entry| entry.generation == self.generation)
470        {
471            entry.forced_waiters = entry.forced_waiters.saturating_sub(1);
472        }
473    }
474}
475
476pub struct FetchClaim {
477    scheduler: Arc<FetchScheduler>,
478    id: i64,
479    generation: Generation,
480    result: AttemptSender,
481    previous_attempt: Option<Instant>,
482    done: bool,
483}
484
485impl fmt::Debug for FetchClaim {
486    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487        f.debug_struct("FetchClaim")
488            .field("id", &self.id)
489            .field("generation", &self.generation)
490            .finish()
491    }
492}
493
494impl FetchClaim {
495    pub fn id(&self) -> i64 {
496        self.id
497    }
498
499    pub fn generation(&self) -> Generation {
500        self.generation
501    }
502
503    pub fn handle(&self) -> AttemptHandle {
504        AttemptHandle(self.result.subscribe())
505    }
506
507    pub fn complete(
508        mut self, now: Instant, outcome: Outcome, result: FetchResult,
509    ) -> Option<ScheduleView> {
510        self.done = true;
511        let view = self
512            .scheduler
513            .finish(self.id, self.generation, now, outcome);
514        self.result.send_replace(Some(result));
515        view
516    }
517
518    pub fn release(mut self) {
519        self.done = true;
520        self.scheduler
521            .release(self.id, self.generation, self.previous_attempt);
522        self.result.send_replace(Some(FetchResult::Cancelled));
523    }
524}
525
526impl Drop for FetchClaim {
527    fn drop(&mut self) {
528        if !self.done {
529            self.scheduler
530                .finish(self.id, self.generation, Instant::now(), Outcome::Cancelled);
531        }
532    }
533}
534
535pub struct AttemptHandle(AttemptReceiver);
536
537impl fmt::Debug for AttemptHandle {
538    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539        f.write_str("AttemptHandle")
540    }
541}
542
543impl AttemptHandle {
544    pub async fn wait(mut self) -> FetchResult {
545        match self.0.wait_for(Option::is_some).await {
546            Ok(result) => result.clone().unwrap_or(FetchResult::Cancelled),
547            Err(_) => FetchResult::Cancelled,
548        }
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::application::test_support::{
556        pipeline,
557        Harness,
558        StubProvider,
559    };
560    use crate::application::RefreshMode;
561    use crate::domain::{
562        PipelineStatus,
563        RefreshState,
564    };
565
566    async fn wait_for_joiners(claim: &FetchClaim, receivers: usize) {
567        tokio::time::timeout(Duration::from_secs(10), async {
568            while claim.result.receiver_count() < receivers {
569                tokio::task::yield_now().await;
570            }
571        })
572        .await
573        .expect("refresh did not join the scheduler claim");
574    }
575
576    #[tokio::test]
577    async fn joined_refreshes_fetch_real_pipeline_data_after_a_run_claim_is_released() {
578        let h = Harness::new().await;
579        let (id, stub) = h.add_stub(StubProvider::new).await;
580        *stub.pipelines.lock().unwrap() = Ok(vec![pipeline(id, "fresh", PipelineStatus::Success)]);
581        h.repository
582            .update_pipelines_cache(id, &[pipeline(id, "stale", PipelineStatus::Running)])
583            .await
584            .unwrap();
585        let lease = h.provider_service.acquire(id).await.unwrap();
586        let ForcedClaim::Claimed(claim) =
587            h.scheduler
588                .claim_forced(id, lease.generation, Instant::now())
589        else {
590            panic!("expected a run-detail claim");
591        };
592        let receivers = claim.result.receiver_count();
593        h.refresh_manager.set_mode(RefreshMode::Idle);
594        h.start().await;
595        let service = Arc::clone(&h.pipeline_service);
596        let first = tokio::spawn(async move { service.refresh_provider(id).await });
597        let service = Arc::clone(&h.pipeline_service);
598        let second = tokio::spawn(async move { service.refresh_provider(id).await });
599        wait_for_joiners(&claim, receivers + 2).await;
600        claim.release();
601
602        let states = [first.await.unwrap().state, second.await.unwrap().state];
603        assert!(states.contains(&RefreshState::Ok));
604        assert!(states
605            .iter()
606            .all(|state| matches!(state, RefreshState::Ok | RefreshState::Recent)));
607        assert_eq!(stub.fetch_count(), 1);
608        assert_eq!(h.cached_names(id).await, ["fresh"]);
609    }
610
611    #[tokio::test]
612    async fn joined_refresh_does_not_retry_through_a_new_quota_pause() {
613        let h = Harness::new().await;
614        let (id, stub) = h.add_stub(StubProvider::new).await;
615        let lease = h.provider_service.acquire(id).await.unwrap();
616        let ForcedClaim::Claimed(claim) =
617            h.scheduler
618                .claim_forced(id, lease.generation, Instant::now())
619        else {
620            panic!("expected a run-detail claim");
621        };
622        let receivers = claim.result.receiver_count();
623        h.refresh_manager.set_mode(RefreshMode::Idle);
624        h.start().await;
625        let service = Arc::clone(&h.pipeline_service);
626        let refresh = tokio::spawn(async move { service.refresh_provider(id).await });
627        wait_for_joiners(&claim, receivers + 1).await;
628        h.scheduler.set_quota_pause(
629            id,
630            lease.generation,
631            Some(Utc::now() + chrono::Duration::minutes(10)),
632            Instant::now(),
633            Utc::now(),
634        );
635        claim.release();
636
637        let result = refresh.await.unwrap();
638        assert_eq!(result.state, RefreshState::Recent);
639        assert_eq!(result.kind, Some(FailureKind::RateLimited));
640        assert_eq!(stub.fetch_count(), 0);
641    }
642
643    fn secs(seconds: u64) -> Duration {
644        Duration::from_secs(seconds)
645    }
646
647    fn scheduler_with(entries: &[(i64, u64)]) -> (Arc<FetchScheduler>, HashMap<i64, Generation>) {
648        let scheduler = Arc::new(FetchScheduler::default());
649        let generations = entries
650            .iter()
651            .map(|(id, interval)| {
652                let generation = Generation::next();
653                scheduler.register(*id, generation, secs(*interval));
654                (*id, generation)
655            })
656            .collect();
657
658        (scheduler, generations)
659    }
660
661    fn no_floors() -> HashMap<i64, Duration> {
662        HashMap::new()
663    }
664
665    fn due_ids(scheduler: &Arc<FetchScheduler>, now: Instant) -> Vec<i64> {
666        scheduler
667            .claim_due(now, &no_floors())
668            .iter()
669            .map(FetchClaim::id)
670            .collect()
671    }
672
673    fn only_claim(scheduler: &Arc<FetchScheduler>, now: Instant) -> FetchClaim {
674        let mut claims = scheduler.claim_due(now, &no_floors());
675        assert_eq!(claims.len(), 1);
676        claims.remove(0)
677    }
678
679    fn failed(kind: FailureKind, retry_after: Option<Duration>) -> Outcome {
680        Outcome::Failed { kind, retry_after }
681    }
682
683    fn finish_with(claim: FetchClaim, now: Instant, outcome: Outcome) -> ScheduleView {
684        let result = match &outcome {
685            Outcome::Failed { .. } => {
686                FetchResult::Committed(Err(DomainError::NetworkError("unreachable".to_string())))
687            }
688            _ => FetchResult::Committed(Ok(0)),
689        };
690        claim.complete(now, outcome, result).unwrap()
691    }
692
693    fn due_after(seconds: u64) -> Vec<i64> {
694        let (scheduler, generations) = scheduler_with(&[(1, 10), (2, 600), (3, 0), (4, 30)]);
695        let start = Instant::now();
696        for id in [1, 2] {
697            let ForcedClaim::Claimed(claim) = scheduler.claim_forced(id, generations[&id], start)
698            else {
699                panic!("expected a claim for {id}");
700            };
701            finish_with(claim, start, Outcome::Success);
702        }
703
704        due_ids(&scheduler, start + secs(seconds))
705    }
706
707    #[test]
708    fn each_provider_waits_for_its_own_interval() {
709        assert_eq!(due_after(5), vec![4]);
710        assert_eq!(due_after(60), vec![1, 4]);
711        assert_eq!(due_after(599), vec![1, 4]);
712        assert_eq!(due_after(600), vec![1, 2, 4]);
713    }
714
715    #[test]
716    fn counters_do_not_change_from_view_or_from_claiming() {
717        let (scheduler, _) = scheduler_with(&[(1, 30)]);
718        let now = Instant::now();
719
720        let before = scheduler.view(1, now).unwrap();
721        assert_eq!(scheduler.view(1, now).unwrap(), before);
722        let claim = only_claim(&scheduler, now);
723        let claimed = scheduler.view(1, now).unwrap();
724
725        assert_eq!(claimed.failures, before.failures);
726        assert_eq!(claimed.next_attempt_in, before.next_attempt_in);
727        assert!(claimed.in_flight);
728        drop(claim);
729    }
730
731    #[test]
732    fn transient_failures_double_up_to_the_cap() {
733        let (scheduler, _) = scheduler_with(&[(1, 30)]);
734        let mut now = Instant::now();
735        let mut waits = Vec::new();
736        for _ in 0..7 {
737            let view = finish_with(
738                only_claim(&scheduler, now),
739                now,
740                failed(FailureKind::Network, None),
741            );
742            waits.push(view.next_attempt_in.as_secs());
743            now += view.next_attempt_in;
744        }
745
746        assert_eq!(waits, vec![60, 120, 240, 480, 600, 600, 600]);
747        assert_eq!(scheduler.view(1, now).unwrap().failures, 7);
748    }
749
750    #[test]
751    fn timeouts_and_unavailable_count_like_network_failures() {
752        for kind in [FailureKind::Timeout, FailureKind::Unavailable] {
753            let (scheduler, _) = scheduler_with(&[(1, 30)]);
754            let now = Instant::now();
755
756            let view = finish_with(only_claim(&scheduler, now), now, failed(kind, None));
757
758            assert_eq!(view.failures, 1, "{kind:?}");
759            assert_eq!(view.next_attempt_in, secs(60), "{kind:?}");
760        }
761    }
762
763    #[test]
764    fn a_server_hint_extends_the_wait_but_never_shortens_it() {
765        let (scheduler, _) = scheduler_with(&[(1, 30)]);
766        let now = Instant::now();
767
768        let long = finish_with(
769            only_claim(&scheduler, now),
770            now,
771            failed(FailureKind::Unavailable, Some(secs(300))),
772        );
773        let later = now + long.next_attempt_in;
774        let short = finish_with(
775            only_claim(&scheduler, later),
776            later,
777            failed(FailureKind::Unavailable, Some(secs(5))),
778        );
779
780        assert_eq!(long.next_attempt_in, secs(300));
781        assert_eq!(short.next_attempt_in, secs(120));
782    }
783
784    #[test]
785    fn rate_limits_use_retry_after_capped_at_an_hour() {
786        let (scheduler, _) = scheduler_with(&[(1, 30)]);
787        let mut now = Instant::now();
788        let mut waits = Vec::new();
789        for retry_after in [Some(secs(120)), Some(secs(2 * 3600)), None, Some(secs(5))] {
790            let view = finish_with(
791                only_claim(&scheduler, now),
792                now,
793                failed(FailureKind::RateLimited, retry_after),
794            );
795            waits.push(view.next_attempt_in.as_secs());
796            now += view.next_attempt_in;
797        }
798
799        assert_eq!(waits, vec![120, 3600, 30 * 8, 30]);
800    }
801
802    #[test]
803    fn an_interval_longer_than_the_wait_caps_is_kept() {
804        let (scheduler, _) = scheduler_with(&[(1, 2 * 3600), (2, 2 * 3600)]);
805        let now = Instant::now();
806        let claims = scheduler.claim_due(now, &no_floors());
807        let mut views = HashMap::new();
808        for claim in claims {
809            let id = claim.id();
810            let outcome = if id == 1 {
811                failed(FailureKind::RateLimited, Some(secs(3600)))
812            } else {
813                failed(FailureKind::Network, None)
814            };
815            views.insert(id, finish_with(claim, now, outcome));
816        }
817
818        assert_eq!(views[&1].next_attempt_in, secs(2 * 3600));
819        assert_eq!(views[&2].next_attempt_in, secs(2 * 3600));
820    }
821
822    #[test]
823    fn auth_failures_do_not_count_and_success_resets() {
824        let (scheduler, _) = scheduler_with(&[(1, 30)]);
825        let mut now = Instant::now();
826
827        let network = finish_with(
828            only_claim(&scheduler, now),
829            now,
830            failed(FailureKind::Network, None),
831        );
832        now += network.next_attempt_in;
833        let auth = finish_with(
834            only_claim(&scheduler, now),
835            now,
836            failed(FailureKind::Auth, None),
837        );
838        now += auth.next_attempt_in;
839        let success = finish_with(only_claim(&scheduler, now), now, Outcome::Success);
840
841        assert_eq!(network.failures, 1);
842        assert_eq!(auth.failures, 1);
843        assert_eq!(auth.last_failure, Some(FailureKind::Auth));
844        assert_eq!(auth.next_attempt_in, secs(30));
845        assert_eq!(success.failures, 0);
846        assert_eq!(success.last_failure, None);
847        assert_eq!(success.next_attempt_in, secs(30));
848    }
849
850    #[test]
851    fn the_outage_floor_delays_but_still_lets_a_probe_through() {
852        let (scheduler, _) = scheduler_with(&[(1, 10)]);
853        let floors = HashMap::from([(1, secs(120))]);
854        let start = Instant::now();
855        finish_with(only_claim(&scheduler, start), start, Outcome::Success);
856
857        assert!(scheduler.claim_due(start + secs(10), &floors).is_empty());
858        assert!(scheduler.claim_due(start + secs(119), &floors).is_empty());
859        assert_eq!(
860            scheduler.next_due_in(1, start + secs(10), Some(secs(120))),
861            Some(secs(110))
862        );
863        assert_eq!(
864            scheduler.next_due_in(1, start + secs(10), None),
865            Some(Duration::ZERO)
866        );
867        let probe = scheduler.claim_due(start + secs(120), &floors);
868
869        assert_eq!(probe.len(), 1);
870        let view = finish_with(
871            probe.into_iter().next().unwrap(),
872            start + secs(120),
873            Outcome::Success,
874        );
875        assert_eq!(view.failures, 0);
876        assert_eq!(view.next_attempt_in, secs(10));
877    }
878
879    #[test]
880    fn interval_zero_is_never_scheduled_but_can_be_forced() {
881        let (scheduler, generations) = scheduler_with(&[(1, 0)]);
882        let now = Instant::now();
883
884        assert!(scheduler
885            .claim_due(now + secs(86_400), &no_floors())
886            .is_empty());
887        assert!(matches!(
888            scheduler.claim_forced(1, generations[&1], now),
889            ForcedClaim::Claimed(_)
890        ));
891    }
892
893    #[test]
894    fn forced_claims_bypass_next_attempt_and_the_outage_floor() {
895        let (scheduler, generations) = scheduler_with(&[(1, 600)]);
896        let start = Instant::now();
897        finish_with(only_claim(&scheduler, start), start, Outcome::Success);
898        let later = start + secs(20);
899
900        assert!(scheduler.claim_due(later, &no_floors()).is_empty());
901        assert!(matches!(
902            scheduler.claim_forced(1, generations[&1], later),
903            ForcedClaim::Claimed(_)
904        ));
905    }
906
907    #[test]
908    fn forced_claims_wait_out_a_rate_limit_and_the_minimum_gap() {
909        let (scheduler, generations) = scheduler_with(&[(1, 30), (2, 30)]);
910        let start = Instant::now();
911        let mut claims = scheduler.claim_due(start, &no_floors());
912        let second = claims.pop().unwrap();
913        let first = claims.pop().unwrap();
914        finish_with(
915            first,
916            start,
917            failed(FailureKind::RateLimited, Some(secs(120))),
918        );
919        finish_with(second, start, Outcome::Success);
920
921        let during_rate_limit = scheduler.claim_forced(1, generations[&1], start + secs(60));
922        let within_gap = scheduler.claim_forced(2, generations[&2], start + secs(9));
923        let after_gap = scheduler.claim_forced(2, generations[&2], start + secs(10));
924        let after_rate_limit = scheduler.claim_forced(1, generations[&1], start + secs(120));
925
926        assert!(matches!(
927            during_rate_limit,
928            ForcedClaim::Recent(Outcome::Failed {
929                kind: FailureKind::RateLimited,
930                ..
931            })
932        ));
933        assert!(matches!(within_gap, ForcedClaim::Recent(Outcome::Success)));
934        assert!(matches!(after_gap, ForcedClaim::Claimed(_)));
935        assert!(matches!(after_rate_limit, ForcedClaim::Claimed(_)));
936    }
937
938    #[test]
939    fn waiting_watch_gets_a_turn_without_bypassing_the_forced_gap() {
940        let (scheduler, generations) = scheduler_with(&[(1, 5)]);
941        let start = Instant::now();
942        finish_with(only_claim(&scheduler, start), start, Outcome::Success);
943        let waiting = scheduler.prioritize_forced(1, generations[&1]).unwrap();
944        assert!(scheduler
945            .claim_due(start + secs(5), &no_floors())
946            .is_empty());
947        assert!(matches!(
948            scheduler.claim_forced(1, generations[&1], start + secs(9)),
949            ForcedClaim::Recent(Outcome::Success)
950        ));
951        let ForcedClaim::Claimed(claim) =
952            scheduler.claim_forced(1, generations[&1], start + secs(10))
953        else {
954            panic!("a waiting watch must get the next forced read");
955        };
956        finish_with(claim, start + secs(10), Outcome::Success);
957        drop(waiting);
958        assert_eq!(due_ids(&scheduler, start + secs(15)), vec![1]);
959    }
960
961    #[test]
962    fn stale_waiter_cancellation_cannot_release_a_replacement_provider() {
963        let (scheduler, generations) = scheduler_with(&[(1, 5)]);
964        let old = scheduler.prioritize_forced(1, generations[&1]).unwrap();
965        let replacement = Generation::next();
966        scheduler.register(1, replacement, secs(5));
967        let current = scheduler.prioritize_forced(1, replacement).unwrap();
968        drop(old);
969        assert!(scheduler.claim_due(Instant::now(), &no_floors()).is_empty());
970        drop(current);
971        assert_eq!(due_ids(&scheduler, Instant::now()), vec![1]);
972    }
973
974    #[tokio::test]
975    async fn forced_claims_join_an_attempt_from_claim_time_until_it_completes() {
976        let (scheduler, generations) = scheduler_with(&[(1, 30)]);
977        let now = Instant::now();
978        let claim = only_claim(&scheduler, now);
979
980        let ForcedClaim::Joined(joiner) = scheduler.claim_forced(1, generations[&1], now) else {
981            panic!("expected to join the in-flight attempt");
982        };
983        let waiting = tokio::spawn(joiner.wait());
984        tokio::task::yield_now().await;
985        assert!(!waiting.is_finished());
986        let ForcedClaim::Joined(late) = scheduler.claim_forced(1, generations[&1], now) else {
987            panic!("expected to join the in-flight attempt");
988        };
989        claim.complete(now, Outcome::Success, FetchResult::Committed(Ok(3)));
990
991        assert!(matches!(
992            waiting.await.unwrap(),
993            FetchResult::Committed(Ok(3))
994        ));
995        assert!(matches!(late.wait().await, FetchResult::Committed(Ok(3))));
996        assert!(!scheduler.view(1, now).unwrap().in_flight);
997    }
998
999    #[tokio::test]
1000    async fn a_dropped_claim_cancels_its_joiners_without_counting_a_failure() {
1001        let (scheduler, generations) = scheduler_with(&[(1, 30)]);
1002        let now = Instant::now();
1003        let claim = only_claim(&scheduler, now);
1004        let ForcedClaim::Joined(joiner) = scheduler.claim_forced(1, generations[&1], now) else {
1005            panic!("expected to join the in-flight attempt");
1006        };
1007
1008        drop(claim);
1009
1010        assert!(matches!(joiner.wait().await, FetchResult::Cancelled));
1011        let view = scheduler.view(1, now).unwrap();
1012        assert_eq!(view.failures, 0);
1013        assert_eq!(view.last_failure, None);
1014        assert!(!view.in_flight);
1015        let remaining = scheduler.view(1, Instant::now()).unwrap().next_attempt_in;
1016        assert!(remaining <= secs(30));
1017        assert!(remaining > Duration::ZERO);
1018    }
1019
1020    #[test]
1021    fn an_old_claim_changes_nothing_after_reset() {
1022        let (scheduler, generations) = scheduler_with(&[(1, 30)]);
1023        let now = Instant::now();
1024        let old = only_claim(&scheduler, now);
1025        let fresh = Generation::next();
1026        scheduler.reset(1, fresh, secs(30));
1027        let current = only_claim(&scheduler, Instant::now());
1028        let before = scheduler.view(1, now).unwrap();
1029
1030        let stale = old.complete(
1031            now,
1032            failed(FailureKind::Network, None),
1033            FetchResult::Superseded,
1034        );
1035        let after = scheduler.view(1, now).unwrap();
1036
1037        assert!(stale.is_none());
1038        assert_eq!(after, before);
1039        assert!(after.in_flight);
1040        assert_eq!(after.failures, 0);
1041        assert_ne!(generations[&1], fresh);
1042        assert_eq!(current.generation(), fresh);
1043    }
1044
1045    #[test]
1046    fn an_old_claim_dropped_after_reset_leaves_the_new_attempt_alone() {
1047        let (scheduler, _) = scheduler_with(&[(1, 30)]);
1048        let now = Instant::now();
1049        let old = only_claim(&scheduler, now);
1050        scheduler.reset(1, Generation::next(), secs(30));
1051        let current = only_claim(&scheduler, Instant::now());
1052
1053        drop(old);
1054
1055        assert!(scheduler.view(1, now).unwrap().in_flight);
1056        drop(current);
1057        assert!(!scheduler.view(1, now).unwrap().in_flight);
1058    }
1059
1060    #[test]
1061    fn claims_after_remove_are_refused_and_the_entry_is_not_recreated() {
1062        let (scheduler, generations) = scheduler_with(&[(1, 30)]);
1063        let now = Instant::now();
1064        let old = only_claim(&scheduler, now);
1065        scheduler.remove(1);
1066
1067        assert!(matches!(
1068            scheduler.claim_forced(1, generations[&1], now),
1069            ForcedClaim::Refused
1070        ));
1071        assert!(scheduler.claim_due(now, &no_floors()).is_empty());
1072        old.complete(now, Outcome::Success, FetchResult::Superseded);
1073        assert!(scheduler.view(1, now).is_none());
1074        assert!(scheduler.claim_due(now, &no_floors()).is_empty());
1075        scheduler.set_interval(1, generations[&1], secs(5));
1076        assert!(scheduler.view(1, now).is_none());
1077    }
1078
1079    #[test]
1080    fn a_reused_id_with_a_new_generation_ignores_old_claims() {
1081        let (scheduler, generations) = scheduler_with(&[(1, 30)]);
1082        let now = Instant::now();
1083        let old = only_claim(&scheduler, now);
1084        scheduler.remove(1);
1085        let reused = Generation::next();
1086        scheduler.register(1, reused, secs(30));
1087
1088        assert!(matches!(
1089            scheduler.claim_forced(1, generations[&1], now),
1090            ForcedClaim::Refused
1091        ));
1092        old.complete(
1093            now,
1094            failed(FailureKind::Network, None),
1095            FetchResult::Superseded,
1096        );
1097        let view = scheduler.view(1, now).unwrap();
1098
1099        assert_eq!(view.failures, 0);
1100        assert!(!view.in_flight);
1101        assert!(matches!(
1102            scheduler.claim_forced(1, reused, now),
1103            ForcedClaim::Claimed(_)
1104        ));
1105    }
1106
1107    #[test]
1108    fn register_keeps_an_existing_entry_of_the_same_generation() {
1109        let (scheduler, generations) = scheduler_with(&[(1, 30)]);
1110        let now = Instant::now();
1111        finish_with(
1112            only_claim(&scheduler, now),
1113            now,
1114            failed(FailureKind::Network, None),
1115        );
1116
1117        scheduler.register(1, generations[&1], secs(30));
1118
1119        assert_eq!(scheduler.view(1, now).unwrap().failures, 1);
1120    }
1121
1122    #[test]
1123    fn a_shorter_interval_brings_the_next_attempt_forward() {
1124        let (scheduler, generations) = scheduler_with(&[(1, 3600)]);
1125        let now = Instant::now();
1126        finish_with(only_claim(&scheduler, now), now, Outcome::Success);
1127
1128        scheduler.set_interval(1, generations[&1], secs(30));
1129
1130        assert!(scheduler.view(1, Instant::now()).unwrap().next_attempt_in <= secs(30));
1131        assert_eq!(due_ids(&scheduler, now + secs(30)), vec![1]);
1132    }
1133
1134    #[test]
1135    fn a_quota_pause_postpones_every_outcome_until_the_reset() {
1136        let (scheduler, generations) = scheduler_with(&[(1, 30)]);
1137        let now = Instant::now();
1138        let wall = Utc::now();
1139        let reset = wall + chrono::Duration::minutes(20);
1140        let claim = only_claim(&scheduler, now);
1141
1142        let changed = scheduler.set_quota_pause(1, generations[&1], Some(reset), now, wall);
1143        let repeated = scheduler.set_quota_pause(1, generations[&1], Some(reset), now, wall);
1144        let view = claim.complete(now, Outcome::Cancelled, FetchResult::Cancelled);
1145
1146        assert!(changed);
1147        assert!(!repeated);
1148        assert_eq!(view.unwrap().next_attempt_in, secs(20 * 60));
1149        assert_eq!(scheduler.paused_until(1, wall), Some(reset));
1150        assert!(scheduler
1151            .claim_due(now + secs(20 * 60 - 1), &no_floors())
1152            .is_empty());
1153        assert_eq!(due_ids(&scheduler, now + secs(20 * 60)), vec![1]);
1154    }
1155
1156    #[test]
1157    fn a_quota_pause_turns_manual_refreshes_into_a_rate_limit_answer() {
1158        let (scheduler, generations) = scheduler_with(&[(1, 30)]);
1159        let now = Instant::now();
1160        let wall = Utc::now();
1161        scheduler.set_quota_pause(
1162            1,
1163            generations[&1],
1164            Some(wall + chrono::Duration::minutes(5)),
1165            now,
1166            wall,
1167        );
1168
1169        let forced = scheduler.claim_forced(1, generations[&1], now + secs(60));
1170
1171        let ForcedClaim::Recent(Outcome::Failed { kind, retry_after }) = forced else {
1172            panic!("expected a recent rate-limit answer, got {forced:?}");
1173        };
1174        assert_eq!(kind, FailureKind::RateLimited);
1175        assert_eq!(retry_after, Some(secs(240)));
1176    }
1177
1178    #[test]
1179    fn a_quota_pause_clears_when_the_quota_is_back_and_ignores_stale_generations() {
1180        let (scheduler, generations) = scheduler_with(&[(1, 30)]);
1181        let now = Instant::now();
1182        let wall = Utc::now();
1183        let reset = wall + chrono::Duration::minutes(5);
1184        let stale = Generation::next();
1185
1186        let ignored = scheduler.set_quota_pause(1, stale, Some(reset), now, wall);
1187        scheduler.set_quota_pause(1, generations[&1], Some(reset), now, wall);
1188        let cleared = scheduler.set_quota_pause(1, generations[&1], None, now, wall);
1189        let already_clear = scheduler.set_quota_pause(1, generations[&1], None, now, wall);
1190
1191        assert!(!ignored);
1192        assert!(cleared);
1193        assert!(!already_clear);
1194        assert_eq!(scheduler.paused_until(1, wall), None);
1195        let past = wall - chrono::Duration::seconds(1);
1196        assert!(!scheduler.set_quota_pause(1, generations[&1], Some(past), now, wall));
1197        assert_eq!(scheduler.paused_until(1, wall), None);
1198    }
1199
1200    #[test]
1201    fn generations_are_never_reused() {
1202        let first = Generation::next();
1203        let second = Generation::next();
1204
1205        assert_ne!(first, second);
1206        assert!(second > first);
1207    }
1208}