saluki_io/net/util/retry/queue/
mod.rs1use std::collections::VecDeque;
2
3use saluki_error::{generic_error, GenericError};
4use serde::{de::DeserializeOwned, Serialize};
5use tracing::{debug, info, warn};
6
7mod persisted;
8use self::persisted::PersistedQueue;
9pub use self::persisted::{DiskUsageRetriever, DiskUsageRetrieverImpl, PersistedQueueArgs};
10
11const DEFAULT_FLUSH_TO_DISK_MEM_RATIO: f64 = 0.5;
12
13pub trait EventContainer {
19 fn event_count(&self) -> u64;
21
22 fn data_point_count(&self) -> u64 {
24 0
25 }
26}
27
28pub trait Retryable: EventContainer + DeserializeOwned + Serialize {
30 fn size_bytes(&self) -> u64;
32}
33
34impl EventContainer for String {
35 fn event_count(&self) -> u64 {
36 1
37 }
38}
39
40impl Retryable for String {
41 fn size_bytes(&self) -> u64 {
42 self.len() as u64
43 }
44}
45
46#[derive(Default)]
51#[must_use = "`PushResult` carries information about potentially dropped items/events and should not be ignored"]
52pub struct PushResult {
53 pub items_dropped: u64,
55
56 pub events_dropped: u64,
58
59 pub data_points_dropped: u64,
61}
62
63impl PushResult {
64 pub fn had_drops(&self) -> bool {
66 self.items_dropped > 0
67 }
68
69 pub fn merge(&mut self, other: Self) {
71 self.items_dropped += other.items_dropped;
72 self.events_dropped += other.events_dropped;
73 self.data_points_dropped += other.data_points_dropped;
74 }
75
76 pub fn track_dropped_item(&mut self, item: &dyn EventContainer) {
78 self.items_dropped += 1;
79 self.events_dropped += item.event_count();
80 self.data_points_dropped += item.data_point_count();
81 }
82}
83
84pub struct RetryQueue<T> {
86 queue_name: String,
87 pending: VecDeque<T>,
88 persisted_pending: Option<PersistedQueue<T>>,
89 total_in_memory_bytes: u64,
90 max_in_memory_bytes: u64,
91 flush_to_disk_mem_ratio: f64,
92}
93
94impl<T> RetryQueue<T>
95where
96 T: Retryable,
97{
98 pub fn new(queue_name: String, max_in_memory_bytes: u64) -> Self {
104 Self {
105 queue_name,
106 pending: VecDeque::new(),
107 persisted_pending: None,
108 total_in_memory_bytes: 0,
109 max_in_memory_bytes,
110 flush_to_disk_mem_ratio: DEFAULT_FLUSH_TO_DISK_MEM_RATIO,
111 }
112 }
113
114 pub fn with_flush_to_disk_mem_ratio(mut self, flush_to_disk_mem_ratio: f64) -> Self {
121 self.flush_to_disk_mem_ratio = flush_to_disk_mem_ratio;
122 self
123 }
124
125 pub async fn with_disk_persistence(mut self, mut args: PersistedQueueArgs) -> Result<Self, GenericError> {
141 if args.root_path.as_os_str().is_empty() {
144 return Err(generic_error!("Storage path cannot be empty."));
145 }
146
147 args.root_path = args.root_path.join(&self.queue_name);
148 let mut persisted_pending = PersistedQueue::from_root_path(args).await?;
149 match persisted_pending.remove_stale_files().await {
150 Ok(removed) if removed > 0 => {
151 info!(count = removed, "Removed outdated retry files from disk.");
152 }
153 Ok(_) => {}
154 Err(e) => warn!(error = %e, "Failed to remove stale retry files."),
155 }
156 self.persisted_pending = Some(persisted_pending);
157 Ok(self)
158 }
159
160 pub fn is_empty(&self) -> bool {
164 self.pending.is_empty() && self.persisted_pending.as_ref().is_none_or(|p| p.is_empty())
165 }
166
167 pub fn len(&self) -> usize {
171 self.pending.len() + self.persisted_pending.as_ref().map_or(0, |p| p.len())
172 }
173
174 pub const fn max_in_memory_bytes(&self) -> u64 {
176 self.max_in_memory_bytes
177 }
178
179 pub const fn available_in_memory_capacity_bytes(&self) -> u64 {
181 self.max_in_memory_bytes.saturating_sub(self.total_in_memory_bytes)
182 }
183
184 pub async fn available_on_disk_capacity_bytes(&self) -> Result<u64, GenericError> {
193 match &self.persisted_pending {
194 Some(persisted_pending) => persisted_pending.available_capacity_bytes().await,
195 None => Ok(0),
196 }
197 }
198
199 pub fn take_persisted_entries_dropped(&mut self) -> u64 {
204 self.persisted_pending.as_mut().map_or(0, |p| p.take_entries_dropped())
205 }
206
207 pub async fn push(&mut self, entry: T) -> Result<PushResult, GenericError> {
220 let mut push_result = PushResult::default();
221
222 let current_entry_size = entry.size_bytes();
224 if current_entry_size > self.max_in_memory_bytes {
225 return Err(generic_error!(
226 "Entry too large to fit into retry queue. ({} > {})",
227 current_entry_size,
228 self.max_in_memory_bytes
229 ));
230 }
231
232 let required_bytes = self
235 .total_in_memory_bytes
236 .saturating_add(current_entry_size)
237 .saturating_sub(self.max_in_memory_bytes);
238 let using_disk = self.persisted_pending.is_some();
239 let bytes_to_remove = if using_disk && required_bytes > 0 {
240 required_bytes.max(flush_to_disk_bytes(
241 self.max_in_memory_bytes,
242 self.flush_to_disk_mem_ratio,
243 ))
244 } else {
245 required_bytes
246 };
247 let mut bytes_removed = 0;
248
249 while !self.pending.is_empty() && bytes_removed < bytes_to_remove {
250 let oldest_entry = self.pending.pop_front().expect("queue is not empty");
251 let oldest_entry_size = oldest_entry.size_bytes();
252
253 if using_disk {
254 let oldest_entry_events = oldest_entry.event_count();
257 let oldest_entry_data_points = oldest_entry.data_point_count();
258 let persisted_pending = self.persisted_pending.as_mut().expect("disk persistence is enabled");
259 match persisted_pending.push(oldest_entry).await {
260 Ok(persist_result) => {
261 push_result.merge(persist_result);
262 debug!(entry.len = oldest_entry_size, "Moved in-memory entry to disk.");
263 }
264 Err(e) => {
265 warn!(
269 error = %e,
270 entry.len = oldest_entry_size,
271 "Failed to persist in-memory entry to disk; dropping entry to make room."
272 );
273 push_result.items_dropped += 1;
274 push_result.events_dropped += oldest_entry_events;
275 push_result.data_points_dropped += oldest_entry_data_points;
276 }
277 }
278 } else {
279 debug!(
280 entry.len = oldest_entry_size,
281 "Dropped in-memory entry to increase available capacity."
282 );
283
284 push_result.track_dropped_item(&oldest_entry);
285
286 saluki_antithesis::sometimes!(true, "retry queue dropped oldest in-memory entry on overflow");
289 }
290
291 self.total_in_memory_bytes -= oldest_entry_size;
292 bytes_removed += oldest_entry_size;
293 }
294
295 self.pending.push_back(entry);
296 self.total_in_memory_bytes += current_entry_size;
297
298 saluki_antithesis::always_le!(
301 self.total_in_memory_bytes,
302 self.max_in_memory_bytes,
303 "retry queue in-memory bytes within cap",
304 { "bytes": self.total_in_memory_bytes, "cap": self.max_in_memory_bytes }
305 );
306
307 debug!(entry.len = current_entry_size, "Enqueued in-memory entry.");
308
309 Ok(push_result)
310 }
311
312 pub async fn pop(&mut self) -> Result<Option<T>, GenericError> {
323 if let Some(entry) = self.pending.pop_front() {
325 self.total_in_memory_bytes -= entry.size_bytes();
326 debug!(entry.len = entry.size_bytes(), "Dequeued in-memory entry.");
327
328 return Ok(Some(entry));
329 }
330
331 if let Some(persisted_pending) = &mut self.persisted_pending {
333 if let Some(entry) = persisted_pending.pop().await? {
334 return Ok(Some(entry));
335 }
336 }
337
338 Ok(None)
339 }
340
341 pub async fn flush(mut self) -> Result<PushResult, GenericError> {
351 let mut push_result = PushResult::default();
352
353 while let Some(entry) = self.pending.pop_front() {
354 let entry_size = entry.size_bytes();
355
356 if let Some(persisted_pending) = &mut self.persisted_pending {
357 let persist_result = persisted_pending.push(entry).await?;
358 push_result.merge(persist_result);
359
360 debug!(entry.len = entry_size, "Flushed in-memory entry to disk.");
361 } else {
362 debug!(entry.len = entry_size, "Dropped in-memory entry during flush.");
363
364 push_result.track_dropped_item(&entry);
365 }
366 }
367
368 Ok(push_result)
369 }
370}
371
372fn flush_to_disk_bytes(max_in_memory_bytes: u64, flush_to_disk_mem_ratio: f64) -> u64 {
373 if flush_to_disk_mem_ratio <= 0.0 || flush_to_disk_mem_ratio.is_nan() {
374 0
375 } else if flush_to_disk_mem_ratio.is_infinite() {
376 u64::MAX
377 } else {
378 ((max_in_memory_bytes as f64) * flush_to_disk_mem_ratio) as u64
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use std::{path::Path, sync::Arc};
386
387 use rand::RngExt as _;
388 use rand_distr::Alphanumeric;
389 use serde::Deserialize;
390
391 use super::*;
392
393 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
394 struct FakeData {
395 name: String,
396 value: u32,
397 }
398
399 impl FakeData {
400 fn random() -> Self {
401 Self {
402 name: rand::rng().sample_iter(&Alphanumeric).take(8).map(char::from).collect(),
403 value: rand::rng().random_range(0..100),
404 }
405 }
406 }
407
408 impl EventContainer for FakeData {
409 fn event_count(&self) -> u64 {
410 1
411 }
412 }
413
414 impl Retryable for FakeData {
415 fn size_bytes(&self) -> u64 {
416 (self.name.len() + std::mem::size_of::<String>() + 4) as u64
417 }
418 }
419
420 fn file_count_recursive<P: AsRef<Path>>(path: P) -> u64 {
421 let mut count = 0;
422 let entries = std::fs::read_dir(path).expect("should not fail to read directory");
423 for maybe_entry in entries {
424 let entry = maybe_entry.expect("should not fail to read directory entry");
425 if entry.file_type().expect("should not fail to get file type").is_file() {
426 count += 1;
427 } else if entry.file_type().expect("should not fail to get file type").is_dir() {
428 count += file_count_recursive(entry.path());
429 }
430 }
431 count
432 }
433
434 #[tokio::test]
435 async fn basic_push_pop() {
436 let data = FakeData::random();
437
438 let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), 1024);
439
440 let push_result = retry_queue
442 .push(data.clone())
443 .await
444 .expect("should not fail to push data");
445 assert_eq!(0, push_result.items_dropped);
446 assert_eq!(0, push_result.events_dropped);
447
448 let actual = retry_queue
450 .pop()
451 .await
452 .expect("should not fail to pop data")
453 .expect("should not be empty");
454 assert_eq!(data, actual);
455 }
456
457 #[tokio::test]
458 async fn capacity_accessors_report_memory_and_disk_capacity() {
459 let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
460 let root_path = temp_dir.path().to_path_buf();
461 let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), 36)
462 .with_disk_persistence(PersistedQueueArgs {
463 root_path: root_path.clone(),
464 max_on_disk_bytes: 1024,
465 storage_max_disk_ratio: 1.0,
466 disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path)),
467 max_age_days: 10,
468 })
469 .await
470 .expect("should not fail to create retry queue with disk persistence");
471
472 assert_eq!(retry_queue.max_in_memory_bytes(), 36);
473 assert_eq!(retry_queue.available_in_memory_capacity_bytes(), 36);
474 assert_eq!(
475 retry_queue
476 .available_on_disk_capacity_bytes()
477 .await
478 .expect("should not fail to calculate disk capacity"),
479 1024
480 );
481
482 let push_result = retry_queue
483 .push(FakeData::random())
484 .await
485 .expect("first push should succeed");
486 assert!(!push_result.had_drops());
487 assert_eq!(retry_queue.available_in_memory_capacity_bytes(), 0);
488 let push_result = retry_queue
489 .push(FakeData::random())
490 .await
491 .expect("second push should persist the oldest entry");
492 assert!(!push_result.had_drops());
493 assert_eq!(retry_queue.available_in_memory_capacity_bytes(), 0);
494
495 assert!(
496 retry_queue
497 .available_on_disk_capacity_bytes()
498 .await
499 .expect("should not fail to calculate disk capacity")
500 < 1024
501 );
502
503 let _ = retry_queue.pop().await.expect("pop should succeed");
504 assert_eq!(retry_queue.available_in_memory_capacity_bytes(), 36);
505 }
506
507 #[tokio::test]
508 async fn entry_too_large() {
509 let data = FakeData::random();
510
511 let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), 1);
512
513 assert!(retry_queue.push(data).await.is_err());
515 }
516
517 #[tokio::test]
518 async fn remove_oldest_entry_on_push() {
519 let data1 = FakeData::random();
520 let data2 = FakeData::random();
521
522 let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), 36);
524
525 let push_result = retry_queue.push(data1).await.expect("should not fail to push data");
527 assert_eq!(0, push_result.items_dropped);
528 assert_eq!(0, push_result.events_dropped);
529
530 let push_result = retry_queue
532 .push(data2.clone())
533 .await
534 .expect("should not fail to push data");
535 assert_eq!(1, push_result.items_dropped);
536 assert_eq!(1, push_result.events_dropped);
537
538 let actual = retry_queue
541 .pop()
542 .await
543 .expect("should not fail to pop data")
544 .expect("should not be empty");
545 assert_eq!(data2, actual);
546 }
547
548 #[tokio::test]
549 async fn flush_no_disk() {
550 let data1 = FakeData::random();
551 let data2 = FakeData::random();
552
553 let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), u64::MAX);
555
556 let push_result1 = retry_queue.push(data1).await.expect("should not fail to push data");
558 assert_eq!(0, push_result1.items_dropped);
559 assert_eq!(0, push_result1.events_dropped);
560 let push_result2 = retry_queue.push(data2).await.expect("should not fail to push data");
561 assert_eq!(0, push_result2.items_dropped);
562 assert_eq!(0, push_result2.events_dropped);
563
564 let flush_result = retry_queue.flush().await.expect("should not fail to flush");
566 assert_eq!(2, flush_result.items_dropped);
567 assert_eq!(2, flush_result.events_dropped);
568 }
569
570 #[tokio::test]
571 async fn flush_disk() {
572 let data1 = FakeData::random();
573 let data2 = FakeData::random();
574
575 let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
577 let root_path = temp_dir.path().to_path_buf();
578
579 assert_eq!(0, file_count_recursive(&root_path));
581
582 let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), u64::MAX)
583 .with_disk_persistence(PersistedQueueArgs {
584 root_path: root_path.clone(),
585 max_on_disk_bytes: u64::MAX,
586 storage_max_disk_ratio: 1.0,
587 disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
588 max_age_days: 10,
589 })
590 .await
591 .expect("should not fail to create retry queue with disk persistence");
592
593 let push_result1 = retry_queue.push(data1).await.expect("should not fail to push data");
595 assert_eq!(0, push_result1.items_dropped);
596 assert_eq!(0, push_result1.events_dropped);
597 let push_result2 = retry_queue.push(data2).await.expect("should not fail to push data");
598 assert_eq!(0, push_result2.items_dropped);
599 assert_eq!(0, push_result2.events_dropped);
600
601 let flush_result = retry_queue.flush().await.expect("should not fail to flush");
603 assert_eq!(0, flush_result.items_dropped);
604 assert_eq!(0, flush_result.events_dropped);
605
606 assert_eq!(2, file_count_recursive(&root_path));
608 }
609
610 #[tokio::test]
611 async fn disk_overflow_flushes_configured_memory_ratio() {
612 let data1 = FakeData::random();
613 let data2 = FakeData::random();
614 let data3 = FakeData::random();
615 let data4 = FakeData::random();
616
617 let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
618 let root_path = temp_dir.path().to_path_buf();
619
620 let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), 120)
621 .with_flush_to_disk_mem_ratio(0.5)
622 .with_disk_persistence(PersistedQueueArgs {
623 root_path: root_path.clone(),
624 max_on_disk_bytes: u64::MAX,
625 storage_max_disk_ratio: 1.0,
626 disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
627 max_age_days: 10,
628 })
629 .await
630 .expect("should not fail to create retry queue with disk persistence");
631
632 let push_result = retry_queue
633 .push(data1.clone())
634 .await
635 .expect("should not fail to push data");
636 assert_eq!(0, push_result.items_dropped);
637 assert_eq!(0, push_result.events_dropped);
638 let push_result = retry_queue
639 .push(data2.clone())
640 .await
641 .expect("should not fail to push data");
642 assert_eq!(0, push_result.items_dropped);
643 assert_eq!(0, push_result.events_dropped);
644 let push_result = retry_queue
645 .push(data3.clone())
646 .await
647 .expect("should not fail to push data");
648 assert_eq!(0, push_result.items_dropped);
649 assert_eq!(0, push_result.events_dropped);
650
651 let push_result = retry_queue
652 .push(data4.clone())
653 .await
654 .expect("should not fail to push data");
655 assert_eq!(0, push_result.items_dropped);
656 assert_eq!(0, push_result.events_dropped);
657 assert!(file_count_recursive(&root_path) >= 2);
658
659 let actual = retry_queue
662 .pop()
663 .await
664 .expect("should not fail to pop data")
665 .expect("should not be empty");
666 assert_eq!(data3, actual);
667
668 let actual = retry_queue
669 .pop()
670 .await
671 .expect("should not fail to pop data")
672 .expect("should not be empty");
673 assert_eq!(data4, actual);
674
675 let actual = retry_queue
676 .pop()
677 .await
678 .expect("should not fail to pop data")
679 .expect("should not be empty");
680 assert_eq!(data1, actual);
681
682 let actual = retry_queue
683 .pop()
684 .await
685 .expect("should not fail to pop data")
686 .expect("should not be empty");
687 assert_eq!(data2, actual);
688 }
689
690 #[tokio::test]
691 async fn zero_disk_flush_ratio_persists_required_entries() {
692 let data1 = FakeData::random();
693 let data2 = FakeData::random();
694 let data3 = FakeData::random();
695
696 let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
697 let root_path = temp_dir.path().to_path_buf();
698
699 let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), 72)
700 .with_flush_to_disk_mem_ratio(0.0)
701 .with_disk_persistence(PersistedQueueArgs {
702 root_path: root_path.clone(),
703 max_on_disk_bytes: u64::MAX,
704 storage_max_disk_ratio: 1.0,
705 disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
706 max_age_days: 10,
707 })
708 .await
709 .expect("should not fail to create retry queue with disk persistence");
710
711 let push_result = retry_queue
712 .push(data1.clone())
713 .await
714 .expect("should not fail to push data");
715 assert_eq!(0, push_result.items_dropped);
716 assert_eq!(0, push_result.events_dropped);
717 let push_result = retry_queue
718 .push(data2.clone())
719 .await
720 .expect("should not fail to push data");
721 assert_eq!(0, push_result.items_dropped);
722 assert_eq!(0, push_result.events_dropped);
723
724 let push_result = retry_queue
725 .push(data3.clone())
726 .await
727 .expect("should not fail to push data");
728 assert_eq!(0, push_result.items_dropped);
729 assert_eq!(0, push_result.events_dropped);
730 assert_eq!(1, file_count_recursive(&root_path));
731
732 let actual = retry_queue
733 .pop()
734 .await
735 .expect("should not fail to pop data")
736 .expect("should not be empty");
737 assert_eq!(data2, actual);
738
739 let actual = retry_queue
740 .pop()
741 .await
742 .expect("should not fail to pop data")
743 .expect("should not be empty");
744 assert_eq!(data3, actual);
745
746 let actual = retry_queue
747 .pop()
748 .await
749 .expect("should not fail to pop data")
750 .expect("should not be empty");
751 assert_eq!(data1, actual);
752 }
753}