1use std::sync::LazyLock;
5
6use reifydb_codec::row::{
7 pod::EncodedPodRow,
8 queue_attempt::EncodedQueueAttemptRow,
9 queue_deduplication::EncodedQueueDeduplicationRow,
10 shape::{RowFamily, RowShape, RowShapeField},
11};
12use reifydb_value::value::{datetime::DateTime, duration::Duration, row_number::RowNumber, value_type::ValueType};
13use serde::{Deserialize, Serialize};
14
15use crate::{
16 common::TimeSource,
17 interface::catalog::{
18 column::Column,
19 id::{NamespaceId, QueueId},
20 },
21};
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct Queue {
25 pub id: QueueId,
26 pub namespace: NamespaceId,
27 pub name: String,
28 pub columns: Vec<Column>,
29 pub dispatch: QueueDispatch,
30 pub deduplicate: Option<QueueDeduplicate>,
31 pub retention: QueueRetention,
32 pub retry: QueueRetry,
33 pub time: TimeSource,
34}
35
36impl Queue {
37 pub const DEFAULT_PARTITIONS: u16 = 16;
38 pub const MIN_PARTITIONS: u16 = 1;
39 pub const MAX_PARTITIONS: u16 = 1024;
40 pub const DEFAULT_RETRY_ATTEMPTS: u32 = 5;
41 pub const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_seconds_const(10);
42 pub const DEFAULT_RETRY_BACKOFF_CAP: Duration = Duration::from_hours_const(1);
43
44 pub fn name(&self) -> &str {
45 &self.name
46 }
47
48 pub fn partitions(&self) -> u16 {
49 self.dispatch.partitions()
50 }
51
52 pub fn ordered_by(&self) -> Option<&str> {
53 self.dispatch.ordered_by()
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub enum QueueDispatch {
59 Fifo {
60 partitions: u16,
61 ordered_by: Option<String>,
62 },
63}
64
65impl QueueDispatch {
66 pub const TAG_FIFO: u8 = 0;
67
68 pub fn tag(&self) -> u8 {
69 match self {
70 Self::Fifo {
71 ..
72 } => Self::TAG_FIFO,
73 }
74 }
75
76 pub fn partitions(&self) -> u16 {
77 match self {
78 Self::Fifo {
79 partitions,
80 ..
81 } => *partitions,
82 }
83 }
84
85 pub fn ordered_by(&self) -> Option<&str> {
86 match self {
87 Self::Fifo {
88 ordered_by,
89 ..
90 } => ordered_by.as_deref(),
91 }
92 }
93}
94
95impl Default for QueueDispatch {
96 fn default() -> Self {
97 Self::Fifo {
98 partitions: Queue::DEFAULT_PARTITIONS,
99 ordered_by: None,
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct QueueDeduplicate {
106 pub by: Vec<String>,
107 pub ttl: Duration,
108}
109
110impl QueueDeduplicate {
111 pub fn is_forever(&self) -> bool {
112 self.ttl == Duration::MAX
113 }
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
117pub struct QueueRetention {
118 pub done: Option<Duration>,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct QueueRetry {
123 pub attempts: u32,
124 pub backoff: Duration,
125}
126
127impl Default for QueueRetry {
128 fn default() -> Self {
129 Self {
130 attempts: Queue::DEFAULT_RETRY_ATTEMPTS,
131 backoff: Queue::DEFAULT_RETRY_BACKOFF,
132 }
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum QueueItemStatus {
138 Ready = 0,
139 Leased = 1,
140 Done = 2,
141 Dead = 3,
142 Parked = 4,
143}
144
145impl QueueItemStatus {
146 pub fn tag(&self) -> u8 {
147 *self as u8
148 }
149}
150
151impl TryFrom<u8> for QueueItemStatus {
152 type Error = u8;
153
154 fn try_from(value: u8) -> Result<Self, Self::Error> {
155 match value {
156 0 => Ok(Self::Ready),
157 1 => Ok(Self::Leased),
158 2 => Ok(Self::Done),
159 3 => Ok(Self::Dead),
160 4 => Ok(Self::Parked),
161 other => Err(other),
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq)]
167pub struct QueueItemState {
168 pub status: QueueItemStatus,
169 pub attempt: u32,
170 pub budget_base: u32,
171 pub key_hash: u64,
172 pub not_before: Option<DateTime>,
173 pub lease_deadline: Option<DateTime>,
174 pub backoff_until: Option<DateTime>,
175}
176
177impl QueueItemState {
178 pub fn ready(not_before: Option<DateTime>) -> Self {
179 Self {
180 status: QueueItemStatus::Ready,
181 attempt: 0,
182 budget_base: 0,
183 key_hash: 0,
184 not_before,
185 lease_deadline: None,
186 backoff_until: None,
187 }
188 }
189
190 pub fn due(&self) -> DateTime {
191 let not_before = self.not_before.unwrap_or_else(|| DateTime::from_nanos(0));
192 match self.backoff_until {
193 Some(backoff_until) if backoff_until > not_before => backoff_until,
194 _ => not_before,
195 }
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum QueueFailure {
201 Retry {
202 backoff_until: DateTime,
203 },
204 Dead,
205}
206
207pub fn attempts_in_life(attempt: u32, budget_base: u32) -> u32 {
208 attempt.saturating_sub(budget_base)
209}
210
211pub fn is_exhausted(attempt: u32, budget_base: u32, max_attempts: u32) -> bool {
212 attempts_in_life(attempt, budget_base) >= max_attempts
213}
214
215pub fn backoff_delay(base: Duration, cap: Duration, attempts_in_life: u32) -> Duration {
216 let exponent = attempts_in_life.saturating_sub(1).min(62);
217 base.saturating_mul(1i64 << exponent).min(cap)
218}
219
220pub fn on_failure(retry: &QueueRetry, state: &QueueItemState, now: DateTime) -> QueueFailure {
221 if is_exhausted(state.attempt, state.budget_base, retry.attempts) {
222 return QueueFailure::Dead;
223 }
224
225 let delay = backoff_delay(
226 retry.backoff,
227 Queue::DEFAULT_RETRY_BACKOFF_CAP,
228 attempts_in_life(state.attempt, state.budget_base),
229 );
230
231 QueueFailure::Retry {
232 backoff_until: now.add_duration(&delay).unwrap_or(now),
233 }
234}
235
236#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
237pub struct QueuePartitionCounters {
238 pub depth: u64,
239 pub in_flight: u64,
240 pub blocked_keys: u64,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum AttemptOutcome {
245 Ok = 0,
246 Err = 1,
247 Dead = 2,
248}
249
250impl AttemptOutcome {
251 pub fn tag(&self) -> u8 {
252 *self as u8
253 }
254}
255
256impl TryFrom<u8> for AttemptOutcome {
257 type Error = u8;
258
259 fn try_from(value: u8) -> Result<Self, u8> {
260 match value {
261 0 => Ok(Self::Ok),
262 1 => Ok(Self::Err),
263 2 => Ok(Self::Dead),
264 other => Err(other),
265 }
266 }
267}
268
269#[derive(Debug, Clone, PartialEq)]
270pub struct QueueAttemptRecord {
271 pub worker: String,
272 pub outcome: AttemptOutcome,
273 pub response: Option<String>,
274 pub finished_at: DateTime,
275 pub lost: bool,
276 pub anomaly: Option<String>,
277}
278
279mod attempt_shape {
280 use super::*;
281
282 pub(super) const WORKER: usize = 0;
283 pub(super) const RESPONSE: usize = 1;
284 pub(super) const ANOMALY: usize = 2;
285
286 pub(super) static SHAPE: LazyLock<RowShape> = LazyLock::new(|| {
287 RowShape::new(
288 RowFamily::QueueAttempt,
289 vec![
290 RowShapeField::unconstrained("worker", ValueType::Utf8),
291 RowShapeField::unconstrained("response", ValueType::Utf8),
292 RowShapeField::unconstrained("anomaly", ValueType::Utf8),
293 ],
294 )
295 });
296}
297
298pub fn encode_queue_attempt(record: &QueueAttemptRecord) -> EncodedQueueAttemptRow {
299 let shape = &attempt_shape::SHAPE;
300 let mut row = shape.allocate_queue_attempt();
301 row.set_outcome(record.outcome.tag());
302 row.set_lost(record.lost);
303 row.set_finished_at(record.finished_at);
304 shape.set_utf8(&mut row, attempt_shape::WORKER, &record.worker);
305 if let Some(response) = &record.response {
306 shape.set_utf8(&mut row, attempt_shape::RESPONSE, response);
307 }
308 if let Some(anomaly) = &record.anomaly {
309 shape.set_utf8(&mut row, attempt_shape::ANOMALY, anomaly);
310 }
311 row.freeze()
312}
313
314pub fn decode_queue_attempt(row: &EncodedQueueAttemptRow) -> Option<QueueAttemptRecord> {
315 let shape = &attempt_shape::SHAPE;
316 let outcome = row.outcome().try_into().ok()?;
317 let lost = row.lost();
318 let finished_at = row.finished_at();
319 let bytes = row.as_slice();
320 Some(QueueAttemptRecord {
321 worker: shape.get_utf8(bytes, attempt_shape::WORKER).to_string(),
322 outcome,
323 response: shape.try_get_utf8(bytes, attempt_shape::RESPONSE).map(str::to_string),
324 finished_at,
325 lost,
326 anomaly: shape.try_get_utf8(bytes, attempt_shape::ANOMALY).map(str::to_string),
327 })
328}
329
330mod deduplication_shape {
331 use super::*;
332
333 pub(super) static SHAPE: LazyLock<RowShape> =
334 LazyLock::new(|| RowShape::new(RowFamily::QueueDeduplication, vec![]));
335}
336
337pub fn encode_queue_deduplication(row_number: RowNumber, expires_at: DateTime) -> EncodedQueueDeduplicationRow {
338 let mut row = deduplication_shape::SHAPE.allocate_queue_deduplication();
339 row.set_row_number(row_number);
340 row.set_expires_at(expires_at);
341 row.freeze()
342}
343
344pub fn decode_queue_deduplication(row: &EncodedQueueDeduplicationRow) -> Option<(RowNumber, DateTime)> {
345 if row.as_slice().len() < deduplication_shape::SHAPE.header_size() {
346 return None;
347 }
348 Some((row.row_number(), row.expires_at()))
349}
350
351mod item_state_shape {
352 use super::*;
353
354 pub(super) const STATUS: usize = 0;
355 pub(super) const ATTEMPT: usize = 1;
356 pub(super) const BUDGET_BASE: usize = 2;
357 pub(super) const KEY_HASH: usize = 3;
358 pub(super) const NOT_BEFORE: usize = 4;
359 pub(super) const LEASE_DEADLINE: usize = 5;
360 pub(super) const BACKOFF_UNTIL: usize = 6;
361
362 pub(super) static SHAPE: LazyLock<RowShape> = LazyLock::new(|| {
363 RowShape::new(
364 RowFamily::Pod,
365 vec![
366 RowShapeField::unconstrained("status", ValueType::Uint1),
367 RowShapeField::unconstrained("attempt", ValueType::Uint4),
368 RowShapeField::unconstrained("budget_base", ValueType::Uint4),
369 RowShapeField::unconstrained("key_hash", ValueType::Uint8),
370 RowShapeField::unconstrained("not_before", ValueType::DateTime),
371 RowShapeField::unconstrained("lease_deadline", ValueType::DateTime),
372 RowShapeField::unconstrained("backoff_until", ValueType::DateTime),
373 ],
374 )
375 });
376}
377
378mod partition_counters_shape {
379 use super::*;
380
381 pub(super) const DEPTH: usize = 0;
382 pub(super) const IN_FLIGHT: usize = 1;
383 pub(super) const BLOCKED_KEYS: usize = 2;
384
385 pub(super) static SHAPE: LazyLock<RowShape> = LazyLock::new(|| {
386 RowShape::new(
387 RowFamily::Pod,
388 vec![
389 RowShapeField::unconstrained("depth", ValueType::Uint8),
390 RowShapeField::unconstrained("in_flight", ValueType::Uint8),
391 RowShapeField::unconstrained("blocked_keys", ValueType::Uint8),
392 ],
393 )
394 });
395}
396
397pub fn encode_queue_item_state(state: &QueueItemState) -> EncodedPodRow {
398 let shape = &item_state_shape::SHAPE;
399 let mut row = shape.allocate_pod();
400 shape.set::<u8>(&mut row, item_state_shape::STATUS, state.status.tag());
401 shape.set::<u32>(&mut row, item_state_shape::ATTEMPT, state.attempt);
402 shape.set::<u32>(&mut row, item_state_shape::BUDGET_BASE, state.budget_base);
403 shape.set::<u64>(&mut row, item_state_shape::KEY_HASH, state.key_hash);
404 if let Some(not_before) = state.not_before {
405 shape.set::<DateTime>(&mut row, item_state_shape::NOT_BEFORE, not_before);
406 }
407 if let Some(lease_deadline) = state.lease_deadline {
408 shape.set::<DateTime>(&mut row, item_state_shape::LEASE_DEADLINE, lease_deadline);
409 }
410 if let Some(backoff_until) = state.backoff_until {
411 shape.set::<DateTime>(&mut row, item_state_shape::BACKOFF_UNTIL, backoff_until);
412 }
413 row.freeze()
414}
415
416pub fn decode_queue_item_state(row: &EncodedPodRow) -> Option<QueueItemState> {
417 let shape = &item_state_shape::SHAPE;
418 let row = row.as_slice();
419 Some(QueueItemState {
420 status: shape.get::<u8>(row, item_state_shape::STATUS).try_into().ok()?,
421 attempt: shape.get::<u32>(row, item_state_shape::ATTEMPT),
422 budget_base: shape.get::<u32>(row, item_state_shape::BUDGET_BASE),
423 key_hash: shape.get::<u64>(row, item_state_shape::KEY_HASH),
424 not_before: shape.try_get::<DateTime>(row, item_state_shape::NOT_BEFORE),
425 lease_deadline: shape.try_get::<DateTime>(row, item_state_shape::LEASE_DEADLINE),
426 backoff_until: shape.try_get::<DateTime>(row, item_state_shape::BACKOFF_UNTIL),
427 })
428}
429
430pub fn encode_queue_partition_counters(counters: &QueuePartitionCounters) -> EncodedPodRow {
431 let shape = &partition_counters_shape::SHAPE;
432 let mut row = shape.allocate_pod();
433 shape.set::<u64>(&mut row, partition_counters_shape::DEPTH, counters.depth);
434 shape.set::<u64>(&mut row, partition_counters_shape::IN_FLIGHT, counters.in_flight);
435 shape.set::<u64>(&mut row, partition_counters_shape::BLOCKED_KEYS, counters.blocked_keys);
436 row.freeze()
437}
438
439pub fn decode_queue_partition_counters(row: &EncodedPodRow) -> QueuePartitionCounters {
440 let shape = &partition_counters_shape::SHAPE;
441 let row = row.as_slice();
442 QueuePartitionCounters {
443 depth: shape.get::<u64>(row, partition_counters_shape::DEPTH),
444 in_flight: shape.get::<u64>(row, partition_counters_shape::IN_FLIGHT),
445 blocked_keys: shape.get::<u64>(row, partition_counters_shape::BLOCKED_KEYS),
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use reifydb_codec::row::bytes::EncodedBytes;
452 use reifydb_value::util::cowvec::CowVec;
453
454 use super::*;
455
456 #[test]
457 fn test_item_state_roundtrips_with_no_temporals_set() {
458 let state = QueueItemState::ready(None);
463
464 let decoded = decode_queue_item_state(&encode_queue_item_state(&state)).unwrap();
465
466 assert_eq!(decoded, state);
467 assert_eq!(decoded.not_before, None);
468 assert_eq!(decoded.lease_deadline, None);
469 assert_eq!(decoded.backoff_until, None);
470 }
471
472 #[test]
473 fn test_item_state_roundtrips_with_every_field_set() {
474 let state = QueueItemState {
478 status: QueueItemStatus::Leased,
479 attempt: 3,
480 budget_base: 7,
481 key_hash: 0xDEAD_BEEF_CAFE_F00D,
482 not_before: Some(DateTime::from_nanos(1_000)),
483 lease_deadline: Some(DateTime::from_nanos(2_000)),
484 backoff_until: Some(DateTime::from_nanos(3_000)),
485 };
486
487 let decoded = decode_queue_item_state(&encode_queue_item_state(&state)).unwrap();
488
489 assert_eq!(decoded, state);
490 }
491
492 #[test]
493 fn test_every_status_survives_its_tag() {
494 for status in [
497 QueueItemStatus::Ready,
498 QueueItemStatus::Leased,
499 QueueItemStatus::Done,
500 QueueItemStatus::Dead,
501 QueueItemStatus::Parked,
502 ] {
503 assert_eq!(QueueItemStatus::try_from(status.tag()), Ok(status));
504 }
505 }
506
507 #[test]
508 fn test_an_unknown_status_tag_does_not_decode() {
509 let mut row = encode_queue_item_state(&QueueItemState::ready(None)).thaw();
512 item_state_shape::SHAPE.set::<u8>(&mut row, item_state_shape::STATUS, 99);
513
514 assert_eq!(decode_queue_item_state(&row.freeze()), None);
515 }
516
517 #[test]
518 fn test_partition_counters_roundtrip() {
519 let counters = QueuePartitionCounters {
522 depth: 42,
523 in_flight: 7,
524 blocked_keys: 3,
525 };
526
527 assert_eq!(decode_queue_partition_counters(&encode_queue_partition_counters(&counters)), counters);
528 }
529
530 #[test]
531 fn test_an_absent_counter_row_reads_as_zero() {
532 let row = partition_counters_shape::SHAPE.allocate_pod().freeze();
535
536 assert_eq!(decode_queue_partition_counters(&row), QueuePartitionCounters::default());
537 }
538
539 #[test]
540 fn test_an_attempt_record_roundtrips_with_its_optional_fields_absent() {
541 let record = QueueAttemptRecord {
545 worker: "worker-1".to_string(),
546 outcome: AttemptOutcome::Ok,
547 response: None,
548 finished_at: DateTime::from_nanos(1_234),
549 lost: false,
550 anomaly: None,
551 };
552
553 let decoded = decode_queue_attempt(&encode_queue_attempt(&record)).unwrap();
554
555 assert_eq!(decoded, record);
556 assert_eq!(decoded.response, None);
557 assert_eq!(decoded.anomaly, None);
558 }
559
560 #[test]
561 fn test_an_attempt_record_roundtrips_with_every_field_set() {
562 let record = QueueAttemptRecord {
565 worker: "10.0.0.1:8080".to_string(),
566 outcome: AttemptOutcome::Err,
567 response: Some("connection refused".to_string()),
568 finished_at: DateTime::from_nanos(9_999),
569 lost: true,
570 anomaly: Some("stale: item is no longer leased".to_string()),
571 };
572
573 assert_eq!(decode_queue_attempt(&encode_queue_attempt(&record)).unwrap(), record);
574 }
575
576 #[test]
577 fn test_every_attempt_outcome_survives_its_tag() {
578 for outcome in [AttemptOutcome::Ok, AttemptOutcome::Err, AttemptOutcome::Dead] {
581 assert_eq!(AttemptOutcome::try_from(outcome.tag()), Ok(outcome));
582 }
583 }
584
585 #[test]
586 fn test_an_unknown_attempt_outcome_tag_does_not_decode() {
587 let mut row = encode_queue_attempt(&QueueAttemptRecord {
590 worker: "w".to_string(),
591 outcome: AttemptOutcome::Ok,
592 response: None,
593 finished_at: DateTime::from_nanos(1),
594 lost: false,
595 anomaly: None,
596 })
597 .thaw();
598 row.set_outcome(99);
599
600 assert_eq!(decode_queue_attempt(&row.freeze()), None);
601 }
602
603 #[test]
604 fn test_a_deduplication_record_roundtrips_its_row_number_and_expiry() {
605 let row_number = RowNumber(9_007_199_254_740_993);
608 let expires_at = DateTime::from_nanos(1_700_000_000_000_000_000);
609
610 let decoded = decode_queue_deduplication(&encode_queue_deduplication(row_number, expires_at)).unwrap();
611
612 assert_eq!(decoded, (row_number, expires_at));
613 }
614
615 #[test]
616 fn test_a_deduplication_record_survives_boundary_row_numbers_and_instants() {
617 for row_number in [RowNumber(0), RowNumber(1), RowNumber(u64::MAX)] {
620 for expires_at in [DateTime::from_nanos(0), DateTime::from_nanos(i64::MAX as u64)] {
621 let encoded = encode_queue_deduplication(row_number, expires_at);
622
623 assert_eq!(decode_queue_deduplication(&encoded).unwrap(), (row_number, expires_at));
624 }
625 }
626 }
627
628 #[test]
629 fn test_a_truncated_deduplication_record_does_not_decode() {
630 let full = encode_queue_deduplication(RowNumber(7), DateTime::from_nanos(11)).into_bytes();
633
634 for length in 0..full.len() {
635 let truncated = EncodedQueueDeduplicationRow::from(EncodedBytes(CowVec::new(
636 full.as_slice()[..length].to_vec(),
637 )));
638
639 assert_eq!(
640 decode_queue_deduplication(&truncated),
641 None,
642 "a {length}-byte record must not decode"
643 );
644 }
645
646 assert!(
647 decode_queue_deduplication(&EncodedQueueDeduplicationRow::from(full)).is_some(),
648 "the full-width record must still decode, otherwise the guard rejects everything"
649 );
650 }
651
652 #[test]
653 fn test_an_item_with_no_not_before_is_due_at_epoch() {
654 assert_eq!(QueueItemState::ready(None).due(), DateTime::from_nanos(0));
657 assert_eq!(QueueItemState::ready(Some(DateTime::from_nanos(9))).due(), DateTime::from_nanos(9));
658 }
659
660 fn leased(attempt: u32, budget_base: u32) -> QueueItemState {
661 QueueItemState {
662 status: QueueItemStatus::Leased,
663 attempt,
664 budget_base,
665 key_hash: 0,
666 not_before: None,
667 lease_deadline: None,
668 backoff_until: None,
669 }
670 }
671
672 fn retry_policy(attempts: u32, backoff: Duration) -> QueueRetry {
673 QueueRetry {
674 attempts,
675 backoff,
676 }
677 }
678
679 #[test]
680 fn test_a_backed_off_item_is_due_at_the_backoff_not_the_original_not_before() {
681 let state = QueueItemState {
685 not_before: Some(DateTime::from_nanos(5)),
686 backoff_until: Some(DateTime::from_nanos(90)),
687 ..leased(1, 0)
688 };
689 assert_eq!(state.due(), DateTime::from_nanos(90));
690
691 let overdue_backoff = QueueItemState {
692 not_before: Some(DateTime::from_nanos(90)),
693 backoff_until: Some(DateTime::from_nanos(5)),
694 ..leased(1, 0)
695 };
696 assert_eq!(
697 overdue_backoff.due(),
698 DateTime::from_nanos(90),
699 "a user-declared not_before in the future must still hold a retried item back"
700 );
701 }
702
703 #[test]
704 fn test_the_backoff_delay_doubles_per_attempt_in_this_life() {
705 let base = Duration::from_seconds_const(10);
708 let cap = Duration::from_hours_const(1);
709
710 assert_eq!(backoff_delay(base, cap, 1), Duration::from_seconds_const(10));
711 assert_eq!(backoff_delay(base, cap, 2), Duration::from_seconds_const(20));
712 assert_eq!(backoff_delay(base, cap, 3), Duration::from_seconds_const(40));
713 assert_eq!(backoff_delay(base, cap, 4), Duration::from_seconds_const(80));
714 }
715
716 #[test]
717 fn test_the_backoff_delay_clamps_at_the_cap() {
718 let base = Duration::from_seconds_const(10);
721 let cap = Duration::from_hours_const(1);
722
723 assert_eq!(backoff_delay(base, cap, 9), Duration::from_seconds_const(2560));
724 assert_eq!(backoff_delay(base, cap, 10), cap, "10 doublings of 10s exceed 1h");
725 assert_eq!(backoff_delay(base, cap, 64), cap, "a shift wider than i64 must clamp, not wrap");
726 assert_eq!(backoff_delay(base, cap, u32::MAX), cap);
727 }
728
729 #[test]
730 fn test_the_first_attempt_of_a_life_waits_one_base_interval() {
731 let base = Duration::from_seconds_const(10);
734 let cap = Duration::from_hours_const(1);
735
736 assert_eq!(backoff_delay(base, cap, 1), base);
737 assert_eq!(backoff_delay(base, cap, 0), base, "a degenerate zero must not underflow the shift");
738 }
739
740 #[test]
741 fn test_the_budget_is_spent_when_attempts_in_this_life_reach_the_limit() {
742 assert!(!is_exhausted(1, 0, 2));
745 assert!(is_exhausted(2, 0, 2), "attempts: 2 means the second failure is terminal");
746 assert!(is_exhausted(3, 0, 2), "a budget overshoot must stay exhausted, never wrap to alive");
747 }
748
749 #[test]
750 fn test_budget_base_grants_a_fresh_budget_without_resetting_the_attempt_counter() {
751 assert_eq!(attempts_in_life(7, 5), 2);
756 assert!(is_exhausted(7, 0, 5), "without a replay the same attempt number is long spent");
757 assert!(!is_exhausted(7, 5, 5), "after replay at attempt 5 the item has a full budget again");
758 assert!(is_exhausted(10, 5, 5), "the fresh budget still ends after five more attempts");
759 }
760
761 #[test]
762 fn test_attempts_in_life_never_underflows_below_a_replay_point() {
763 assert_eq!(attempts_in_life(3, 9), 0);
767 assert!(!is_exhausted(3, 9, 1));
768 }
769
770 #[test]
771 fn test_on_failure_retries_inside_budget_and_buries_at_the_limit() {
772 let policy = retry_policy(2, Duration::from_seconds_const(10));
775 let now = DateTime::from_nanos(1_000_000_000);
776
777 assert_eq!(
778 on_failure(&policy, &leased(1, 0), now),
779 QueueFailure::Retry {
780 backoff_until: DateTime::from_nanos(11_000_000_000),
781 },
782 "the first failure waits exactly one base interval"
783 );
784 assert_eq!(on_failure(&policy, &leased(2, 0), now), QueueFailure::Dead);
785 }
786
787 #[test]
788 fn test_on_failure_of_a_replayed_item_retries_from_the_start_of_the_delay_curve() {
789 let policy = retry_policy(5, Duration::from_seconds_const(10));
792 let now = DateTime::from_nanos(0);
793
794 assert_eq!(
795 on_failure(&policy, &leased(21, 20), now),
796 QueueFailure::Retry {
797 backoff_until: DateTime::from_nanos(10_000_000_000),
798 }
799 );
800 }
801}