graceful_worker/backoff.rs
1//! How long a failing operation waits before trying again — and how a
2//! stop cuts the wait short.
3//!
4//! # What this adds over the other backoff crates
5//!
6//! `backoff`, `backon` and `exponential-backoff` compute a schedule and
7//! sleep it. None of them takes a shutdown signal, so a worker that must
8//! stop promptly has to wrap every wait in its own `select!` against
9//! whatever it uses for cancellation, and get that right at each call site.
10//!
11//! [`Backoff::wait`] takes a [`Watcher`] and returns `false` when a stop
12//! arrived mid-wait, so the loop reads:
13//!
14//! ```
15//! # #[tokio::main(flavor = "current_thread")]
16//! # async fn main() {
17//! use graceful_worker::{Backoff, Shutdown};
18//!
19//! let shutdown = Shutdown::new();
20//! let watcher = shutdown.watcher();
21//! let mut backoff = Backoff::new();
22//!
23//! shutdown.stop();
24//!
25//! // A fifteen-minute delay, abandoned at once rather than in fifteen
26//! // minutes.
27//! for _ in 0..20 {
28//! backoff.failed();
29//! }
30//! assert!(!backoff.wait(&watcher).await);
31//! # }
32//! ```
33//!
34//! # On slicing, honestly
35//!
36//! [`Backoff::wait`] sleeps the delay in bounded slices ([`with_slice`]).
37//! **That is not what makes the wait interruptible.** Each slice is a
38//! [`Watcher::sleep`], which is a `select!` against the cancellation token
39//! and so returns the moment a stop arrives — a single unsliced sleep would
40//! be exactly as responsive.
41//!
42//! Slicing therefore buys nothing for shutdown latency here, and an earlier
43//! version of this documentation claimed otherwise. It is kept because it
44//! bounds the granularity of the wait for anyone who wants that — a
45//! progress tick, a slice-boundary check of some other condition — and
46//! because [`Duration::ZERO`] disables it. If you have no such need, leave
47//! it alone: the default costs one extra timer per minute of waiting.
48//!
49//! The integration with [`Watcher`] is the reason to use this crate. The
50//! slicing is a knob.
51//!
52//! [`with_slice`]: Backoff::with_slice
53//! [`Watcher::sleep`]: crate::Watcher::sleep
54//!
55//! # Why any of this is configurable
56//!
57//! The defaults are a working set, not a recommendation: five seconds,
58//! doubling to a fifteen-minute ceiling, sliced at a minute. What matters
59//! for a given system is the ceiling and the platform's grace period, and
60//! only the caller knows either.
61
62use std::time::Duration;
63
64use crate::shutdown::Watcher;
65
66/// The wait before the first retry, by default.
67pub const DEFAULT_INITIAL_DELAY: Duration = Duration::from_secs(5);
68
69/// The longest wait between attempts, by default.
70pub const DEFAULT_MAX_DELAY: Duration = Duration::from_secs(900);
71
72/// The longest single sleep, by default.
73///
74/// Also the worst case for how long a stop goes unnoticed during a wait.
75pub const DEFAULT_SLICE: Duration = Duration::from_secs(60);
76
77/// What the delay is multiplied by after each failure, by default.
78pub const DEFAULT_FACTOR: u32 = 2;
79
80/// The retry state of one loop.
81///
82/// A loop holds one of these, tells it when an attempt succeeded or failed,
83/// and asks it to wait. Nothing else in the loop needs to know about
84/// multiplying, ceilings or slicing.
85///
86/// # Examples
87///
88/// ```
89/// use std::time::Duration;
90/// use graceful_worker::Backoff;
91///
92/// let mut backoff = Backoff::new();
93/// assert_eq!(backoff.delay(), Duration::from_secs(5));
94///
95/// backoff.failed();
96/// assert_eq!(backoff.delay(), Duration::from_secs(10));
97/// backoff.failed();
98/// assert_eq!(backoff.delay(), Duration::from_secs(20));
99///
100/// // Any success puts it straight back to the start.
101/// backoff.succeeded();
102/// assert_eq!(backoff.delay(), Duration::from_secs(5));
103/// assert_eq!(backoff.attempt(), 0);
104/// ```
105///
106/// A schedule of your own:
107///
108/// ```
109/// use std::time::Duration;
110/// use graceful_worker::Backoff;
111///
112/// let backoff = Backoff::new()
113/// .with_initial_delay(Duration::from_millis(100))
114/// .with_max_delay(Duration::from_secs(30))
115/// .with_slice(Duration::from_secs(5))
116/// .with_factor(3);
117///
118/// assert_eq!(backoff.delay(), Duration::from_millis(100));
119/// ```
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Backoff {
122 /// What the next wait will be.
123 delay: Duration,
124 /// How many consecutive failures there have been.
125 attempt: u32,
126 /// Where a reset returns to.
127 initial: Duration,
128 /// The ceiling.
129 max: Duration,
130 /// The longest single sleep. Zero means do not slice.
131 slice: Duration,
132 /// The multiplier applied after each failure.
133 factor: u32,
134}
135
136impl Backoff {
137 /// A fresh backoff on the default schedule.
138 #[must_use]
139 pub const fn new() -> Self {
140 Self {
141 delay: DEFAULT_INITIAL_DELAY,
142 attempt: 0,
143 initial: DEFAULT_INITIAL_DELAY,
144 max: DEFAULT_MAX_DELAY,
145 slice: DEFAULT_SLICE,
146 factor: DEFAULT_FACTOR,
147 }
148 }
149
150 /// Set the first delay, and reset to it.
151 ///
152 /// Clamped to the ceiling, so an initial delay longer than the maximum
153 /// is the maximum rather than a schedule that counts downwards.
154 #[must_use]
155 pub const fn with_initial_delay(mut self, initial: Duration) -> Self {
156 self.initial = initial;
157 self.delay = initial;
158 self.clamp()
159 }
160
161 /// Set the ceiling.
162 #[must_use]
163 pub const fn with_max_delay(mut self, max: Duration) -> Self {
164 self.max = max;
165 self.clamp()
166 }
167
168 /// Set the longest single sleep [`Backoff::wait`] performs.
169 ///
170 /// This is the worst case for how long a stop goes unnoticed during a
171 /// wait, so it should be comfortably shorter than the platform's grace
172 /// period. [`Duration::ZERO`] disables slicing, which restores the
173 /// behaviour of every other backoff crate — including its drawback.
174 #[must_use]
175 pub const fn with_slice(mut self, slice: Duration) -> Self {
176 self.slice = slice;
177 self
178 }
179
180 /// Set the multiplier applied after each failure.
181 ///
182 /// A factor of 1 is a constant delay. Zero is treated as 1, because a
183 /// backoff that multiplies by zero would retry instantly forever.
184 #[must_use]
185 pub const fn with_factor(mut self, factor: u32) -> Self {
186 self.factor = if factor == 0 { 1 } else { factor };
187 self
188 }
189
190 /// Keep the current and initial delays within the ceiling.
191 const fn clamp(mut self) -> Self {
192 if self.initial.as_nanos() > self.max.as_nanos() {
193 self.initial = self.max;
194 }
195 if self.delay.as_nanos() > self.max.as_nanos() {
196 self.delay = self.max;
197 }
198 self
199 }
200
201 /// How long to wait before the next attempt.
202 #[must_use]
203 pub const fn delay(&self) -> Duration {
204 self.delay
205 }
206
207 /// How many consecutive failures there have been.
208 ///
209 /// Useful as a dimension on a retry metric.
210 #[must_use]
211 pub const fn attempt(&self) -> u32 {
212 self.attempt
213 }
214
215 /// Whether anything has failed since the last success.
216 ///
217 /// A loop uses this to decide whether an empty queue is unremarkable or
218 /// worth a log line.
219 #[must_use]
220 pub const fn is_retrying(&self) -> bool {
221 self.attempt > 0
222 }
223
224 /// The ceiling this backoff will not exceed.
225 #[must_use]
226 pub const fn max_delay(&self) -> Duration {
227 self.max
228 }
229
230 /// Record a failure: multiply the delay, up to the ceiling.
231 ///
232 /// Returns the delay that was in force *for this failure*, which is
233 /// what a metric should record — not the multiplied value the next one
234 /// will use.
235 pub const fn failed(&mut self) -> Duration {
236 let current = self.delay;
237
238 // Saturating rather than wrapping: at the ceiling this is a no-op,
239 // and no arithmetic here can overflow into a tiny delay.
240 let raised = self.delay.saturating_mul(self.factor);
241 self.delay = if raised.as_nanos() > self.max.as_nanos() {
242 self.max
243 } else {
244 raised
245 };
246 self.attempt = self.attempt.saturating_add(1);
247
248 current
249 }
250
251 /// Record a success: back to the initial delay, attempt count zero.
252 ///
253 /// Any success resets it, not just a run of them.
254 pub const fn succeeded(&mut self) {
255 self.delay = self.initial;
256 self.attempt = 0;
257 }
258
259 /// The slice to sleep for, given how much of a delay is left.
260 ///
261 /// Never zero while `remaining` is non-zero, which is what stops
262 /// [`Backoff::wait`] spinning.
263 ///
264 /// # Examples
265 ///
266 /// ```
267 /// use std::time::Duration;
268 /// use graceful_worker::Backoff;
269 ///
270 /// let backoff = Backoff::new();
271 ///
272 /// // A short remainder is slept in one go.
273 /// assert_eq!(backoff.sleep_slice(Duration::from_secs(3)), Duration::from_secs(3));
274 /// // A long one is sliced, so shutdown stays responsive.
275 /// assert_eq!(backoff.sleep_slice(Duration::from_secs(900)), Duration::from_secs(60));
276 /// ```
277 #[must_use]
278 pub const fn sleep_slice(&self, remaining: Duration) -> Duration {
279 if self.slice.is_zero() || remaining.as_nanos() <= self.slice.as_nanos() {
280 remaining
281 } else {
282 self.slice
283 }
284 }
285
286 /// Wait out the current delay, in slices, unless a stop arrives first.
287 ///
288 /// Returns `true` if the whole delay elapsed, `false` if it was cut
289 /// short — in which case the loop should stop rather than retry.
290 ///
291 /// This is the method the crate exists for. See the module
292 /// documentation.
293 ///
294 /// # Examples
295 ///
296 /// The usual loop shape. Marked `no_run` because running it would do
297 /// exactly what it says: wait five real seconds.
298 ///
299 /// ```no_run
300 /// # #[tokio::main(flavor = "current_thread")]
301 /// # async fn main() {
302 /// use graceful_worker::{Backoff, Shutdown};
303 ///
304 /// let shutdown = Shutdown::new();
305 /// let watcher = shutdown.watcher();
306 /// let mut backoff = Backoff::new();
307 ///
308 /// while watcher.is_running() {
309 /// let worked = false; // ... do a unit of work ...
310 /// if worked {
311 /// backoff.succeeded();
312 /// } else {
313 /// backoff.failed();
314 /// if !backoff.wait(&watcher).await {
315 /// break; // asked to stop mid-wait
316 /// }
317 /// }
318 /// # break;
319 /// }
320 /// # }
321 /// ```
322 pub async fn wait(&self, watcher: &Watcher) -> bool {
323 let mut remaining = self.delay;
324
325 while !remaining.is_zero() {
326 let slice = self.sleep_slice(remaining);
327 if !watcher.sleep(slice).await {
328 return false;
329 }
330 remaining = remaining.saturating_sub(slice);
331 }
332
333 true
334 }
335}
336
337impl Default for Backoff {
338 fn default() -> Self {
339 Self::new()
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use crate::shutdown::Shutdown;
346
347 use super::*;
348
349 #[test]
350 fn the_default_schedule_starts_at_five_seconds() {
351 assert_eq!(Backoff::new().delay(), DEFAULT_INITIAL_DELAY);
352 assert_eq!(Backoff::new().max_delay(), DEFAULT_MAX_DELAY);
353 }
354
355 #[test]
356 fn the_delay_multiplies_on_each_failure() {
357 let mut backoff = Backoff::new();
358 let seen: Vec<u64> = (0..6).map(|_| backoff.failed().as_secs()).collect();
359 assert_eq!(seen, [5, 10, 20, 40, 80, 160]);
360 }
361
362 #[test]
363 fn the_delay_stops_at_the_ceiling() {
364 let mut backoff = Backoff::new();
365 for _ in 0..50 {
366 backoff.failed();
367 }
368 assert_eq!(backoff.delay(), DEFAULT_MAX_DELAY);
369 }
370
371 #[test]
372 fn the_ceiling_is_not_overshot_on_the_way_up() {
373 // 10, 20, 40, 80, 160, 320, 640, and then a doubling that would
374 // reach 1280.
375 let mut backoff = Backoff::new();
376 for _ in 0..7 {
377 backoff.failed();
378 }
379 assert_eq!(backoff.delay(), Duration::from_secs(640));
380 backoff.failed();
381 assert_eq!(backoff.delay(), DEFAULT_MAX_DELAY, "clamped rather than 1280");
382 }
383
384 #[test]
385 fn any_success_resets_it_completely() {
386 let mut backoff = Backoff::new();
387 for _ in 0..10 {
388 backoff.failed();
389 }
390 assert!(backoff.is_retrying());
391
392 backoff.succeeded();
393
394 assert_eq!(backoff.delay(), DEFAULT_INITIAL_DELAY);
395 assert_eq!(backoff.attempt(), 0);
396 assert!(!backoff.is_retrying());
397 }
398
399 #[test]
400 fn failed_reports_the_delay_that_applied_not_the_next_one() {
401 // A metric should chart the actual schedule.
402 let mut backoff = Backoff::new();
403 assert_eq!(backoff.failed(), Duration::from_secs(5));
404 assert_eq!(backoff.delay(), Duration::from_secs(10));
405 }
406
407 #[test]
408 fn the_schedule_is_configurable() {
409 let mut backoff = Backoff::new()
410 .with_initial_delay(Duration::from_millis(100))
411 .with_max_delay(Duration::from_secs(1))
412 .with_factor(3);
413
414 assert_eq!(backoff.delay(), Duration::from_millis(100));
415 assert_eq!(backoff.failed(), Duration::from_millis(100));
416 assert_eq!(backoff.delay(), Duration::from_millis(300));
417 backoff.failed();
418 assert_eq!(backoff.delay(), Duration::from_millis(900));
419 backoff.failed();
420 assert_eq!(backoff.delay(), Duration::from_secs(1), "clamped");
421
422 backoff.succeeded();
423 assert_eq!(backoff.delay(), Duration::from_millis(100));
424 }
425
426 #[test]
427 fn an_initial_longer_than_the_maximum_is_clamped() {
428 // Otherwise the schedule would count downwards.
429 let backoff = Backoff::new()
430 .with_max_delay(Duration::from_secs(2))
431 .with_initial_delay(Duration::from_secs(30));
432
433 assert_eq!(backoff.delay(), Duration::from_secs(2));
434 }
435
436 #[test]
437 fn a_factor_of_one_is_a_constant_delay() {
438 let mut backoff = Backoff::new().with_factor(1);
439 for _ in 0..5 {
440 assert_eq!(backoff.failed(), DEFAULT_INITIAL_DELAY);
441 }
442 }
443
444 #[test]
445 fn a_factor_of_zero_is_treated_as_one() {
446 // A backoff that multiplied by zero would retry instantly forever.
447 let mut backoff = Backoff::new().with_factor(0);
448 backoff.failed();
449 assert_eq!(backoff.delay(), DEFAULT_INITIAL_DELAY);
450 }
451
452 #[test]
453 fn a_long_wait_is_slept_in_slices() {
454 let backoff = Backoff::new();
455 assert_eq!(backoff.sleep_slice(DEFAULT_MAX_DELAY), DEFAULT_SLICE);
456 assert_eq!(backoff.sleep_slice(Duration::from_secs(61)), DEFAULT_SLICE);
457 assert_eq!(backoff.sleep_slice(Duration::from_secs(60)), DEFAULT_SLICE);
458 assert_eq!(
459 backoff.sleep_slice(Duration::from_secs(1)),
460 Duration::from_secs(1)
461 );
462 assert_eq!(backoff.sleep_slice(Duration::ZERO), Duration::ZERO);
463 }
464
465 #[test]
466 fn a_zero_slice_disables_slicing() {
467 let backoff = Backoff::new().with_slice(Duration::ZERO);
468 assert_eq!(backoff.sleep_slice(DEFAULT_MAX_DELAY), DEFAULT_MAX_DELAY);
469 }
470
471 #[test]
472 fn a_slice_is_never_zero_while_time_remains() {
473 // What stops `wait` spinning.
474 for slice in [Duration::ZERO, Duration::from_secs(1), Duration::from_secs(90)] {
475 let backoff = Backoff::new().with_slice(slice);
476 assert!(!backoff.sleep_slice(Duration::from_secs(30)).is_zero());
477 }
478 }
479
480 #[tokio::test(start_paused = true)]
481 async fn waiting_serves_the_whole_delay_when_nothing_stops_it() {
482 let shutdown = Shutdown::new();
483 let watcher = shutdown.watcher();
484 let mut backoff = Backoff::new();
485 for _ in 0..20 {
486 backoff.failed();
487 }
488 assert_eq!(backoff.delay(), DEFAULT_MAX_DELAY);
489
490 assert!(backoff.wait(&watcher).await, "fifteen minutes, in slices");
491 }
492
493 #[tokio::test(start_paused = true)]
494 async fn waiting_is_abandoned_when_a_stop_arrives() {
495 // The reason this crate exists: a fifteen-minute backoff must not
496 // outlast a thirty-second grace period.
497 let shutdown = Shutdown::new();
498 let watcher = shutdown.watcher();
499 let mut backoff = Backoff::new();
500 for _ in 0..20 {
501 backoff.failed();
502 }
503
504 let waiting = tokio::spawn(async move { backoff.wait(&watcher).await });
505 tokio::task::yield_now().await;
506 shutdown.stop();
507
508 assert!(!waiting.await.expect("the waiting task"));
509 }
510
511 #[tokio::test(start_paused = true)]
512 async fn a_stop_is_noticed_within_one_slice() {
513 // The property, stated as an elapsed-time assertion rather than as
514 // a description of the loop.
515 let shutdown = Shutdown::new();
516 let watcher = shutdown.watcher();
517 let backoff = Backoff::new().with_slice(Duration::from_secs(10));
518 let mut backoff = backoff;
519 for _ in 0..20 {
520 backoff.failed();
521 }
522
523 let started = tokio::time::Instant::now();
524 let waiting = tokio::spawn(async move { backoff.wait(&watcher).await });
525
526 // Let one slice pass, then ask to stop partway through the next.
527 tokio::time::sleep(Duration::from_secs(15)).await;
528 shutdown.stop();
529 assert!(!waiting.await.expect("the waiting task"));
530
531 let elapsed = started.elapsed();
532 assert!(
533 elapsed < Duration::from_secs(30),
534 "a stop must be noticed within a slice, not after the whole \
535 delay; took {elapsed:?}"
536 );
537 }
538
539 #[tokio::test(start_paused = true)]
540 async fn slicing_is_not_what_makes_the_wait_interruptible() {
541 // The corrected claim, as a test. With slicing switched off
542 // entirely the wait is a single 15-minute sleep, and a stop still
543 // ends it at once -- because `Watcher::sleep` is a `select!`
544 // against the token, not because the sleep was chopped up.
545 let shutdown = Shutdown::new();
546 let watcher = shutdown.watcher();
547 let mut backoff = Backoff::new().with_slice(Duration::ZERO);
548 for _ in 0..20 {
549 backoff.failed();
550 }
551 assert_eq!(backoff.delay(), DEFAULT_MAX_DELAY);
552 assert_eq!(
553 backoff.sleep_slice(DEFAULT_MAX_DELAY),
554 DEFAULT_MAX_DELAY,
555 "one unsliced sleep"
556 );
557
558 let started = tokio::time::Instant::now();
559 let waiting = tokio::spawn(async move { backoff.wait(&watcher).await });
560 tokio::time::sleep(Duration::from_secs(5)).await;
561 shutdown.stop();
562
563 assert!(!waiting.await.expect("the waiting task"));
564 assert!(
565 started.elapsed() < Duration::from_secs(30),
566 "an unsliced wait is abandoned just as promptly; took {:?}",
567 started.elapsed()
568 );
569 }
570
571 #[tokio::test(start_paused = true)]
572 async fn a_zero_delay_waits_for_nothing() {
573 let shutdown = Shutdown::new();
574 let watcher = shutdown.watcher();
575 let backoff = Backoff::new().with_initial_delay(Duration::ZERO);
576 assert!(backoff.wait(&watcher).await);
577 }
578
579 #[test]
580 fn a_default_backoff_is_a_new_one() {
581 assert_eq!(Backoff::default(), Backoff::new());
582 }
583}