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}