Skip to main content

reifydb_core/interface/catalog/
queue.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use 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		// A freshly admitted item has neither a lease nor a backoff, and an immediately due
459		// item has no not_before either. All three must decode back as none rather than as
460		// epoch: an epoch lease_deadline would read as a lease that expired in 1970, so the
461		// reaper would treat every ready item as abandoned work.
462		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		// The layout is fixed for the whole queue series: later steps write attempt,
475		// budget_base, key_hash and the two deadlines into the same record. A field that
476		// does not survive the round trip would silently reset a retry budget or a lease.
477		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		// Status drives every transition guard. A tag that decodes to the wrong variant would
495		// let a claim take an item that is already done.
496		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		// A record written by a newer version must fail loudly rather than being read as
510		// Ready, which would re-deliver work that is already finished.
511		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		// Counters are maintained transitionally and never recomputed, so a lossy codec makes
520		// the queue's reported depth drift away from reality permanently.
521		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		// Counter rows are created lazily on first admit, so every reader has to treat an
533		// unset row as zeros rather than as garbage depth.
534		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		// A live ack carries neither a response nor an anomaly. Those two must decode back as
542		// none rather than as empty strings, because the ack classifier distinguishes "no
543		// anomaly" from "anomaly recorded" to decide whether an outcome ever transitioned.
544		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		// The stale and conflicting-ack paths write exactly this shape. Losing the anomaly would
563		// erase the only evidence that a late ack was seen and deliberately not applied.
564		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		// The outcome drives the transition decision. A tag that decodes to the wrong variant
579		// would retry work that was reported successful, or bury work that merely failed once.
580		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		// A record written by a newer version must fail loudly rather than read as Ok, which
588		// would report someone else's failure as a success.
589		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		// This record is the only link from a suppressed key back to its claimant, so a lossy codec points the
606		// duplicate at someone else's row.
607		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		// Both facts are fixed-width header slots, so a sign or width bug surfaces only at the extremes and
618		// would expire every live claim at once.
619		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		// The insert path turns a none here into an internal error, so without the guard a short record reads
631		// trailing memory as a live suppression.
632		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		// Due time is the due-index component: epoch means "immediately claimable" and is
655		// computed without a clock so hydration reproduces exactly the same key it lost.
656		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		// due() is the single definition shared by the writer that places a due-index entry and
682		// the claimer that removes it. If the two disagreed by one field the claim would remove a
683		// key that does not exist, stranding a live entry that redelivers the item forever.
684		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		// Linear or constant retry against a struggling endpoint is what turns one failure into a
706		// thundering herd; the doubling is the load-shedding property, so pin the exact sequence.
707		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		// Without the clamp the doubling reaches years, which is indistinguishable from losing the
719		// item. The cap is what keeps a long-lived retry loop bounded and observable.
720		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		// attempts_in_life is 1 on the first failure, so the exponent is 0. An off-by-one here
732		// would either skip the wait entirely or double every delay for the item's whole life.
733		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		// This is the boundary that decides retry-forever versus dead. It must fire exactly at the
743		// declared count: one too early loses recoverable work, one too late doubles the load.
744		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		// queue::replay resets the budget by moving budget_base up to the current attempt, because
752		// attempt doubles as the QueueAttempt key component: rewinding it would overwrite the
753		// previous life's audit records. A budget check that ignored budget_base would bury a
754		// replayed item on its very first new attempt.
755		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		// budget_base above attempt is only reachable through corruption or a future repartition
764		// bug; saturating to zero keeps the policy total instead of panicking in a post-commit
765		// interceptor where the error would be swallowed.
766		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		// The two branches of the whole failure policy, asserted through the one entry point that
773		// the ack path and the reaper both call, so they can never drift apart.
774		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		// A replayed item must not inherit the previous life's exponent, or an operator's manual
790		// retry would sit behind an hour-long wait it never asked for.
791		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}