saluki_io/net/util/retry/queue/
mod.rs

1use 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
13/// A container that holds events.
14///
15/// This trait is used as an incredibly generic way to expose the number of events within a "container," which we
16/// loosely define to be anything that's holding events in some form. This is primarily used to track the number of
17/// events dropped by `RetryQueue` (and `PersistedQueue`) when entries have to be dropped due to size limits.
18pub trait EventContainer {
19    /// Returns the number of events represented by this container.
20    fn event_count(&self) -> u64;
21
22    /// Returns the number of metric data points represented by this container.
23    fn data_point_count(&self) -> u64 {
24        0
25    }
26}
27
28/// A value that can be retried.
29pub trait Retryable: EventContainer + DeserializeOwned + Serialize {
30    /// Returns the in-memory size of this value, in bytes.
31    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/// Result of a push operation.
47///
48/// As pushing items to `RetryQueue` may result in dropping older items to make room for new ones, this struct tracks
49/// the total number of items dropped, and the number of events represented by those items.
50#[derive(Default)]
51#[must_use = "`PushResult` carries information about potentially dropped items/events and should not be ignored"]
52pub struct PushResult {
53    /// Total number of items dropped.
54    pub items_dropped: u64,
55
56    /// Total number of events represented by the dropped items.
57    pub events_dropped: u64,
58
59    /// Total number of metric data points represented by the dropped items.
60    pub data_points_dropped: u64,
61}
62
63impl PushResult {
64    /// Returns `true` if any items were dropped.
65    pub fn had_drops(&self) -> bool {
66        self.items_dropped > 0
67    }
68
69    /// Merges `other` into `Self`.
70    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    /// Tracks a single dropped item.
77    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
84/// A queue for storing requests to be retried.
85pub 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    /// Creates a new `RetryQueue` instance with the given name and maximum size.
99    ///
100    /// The queue will only hold as many entries as can fit within the given maximum size. If the queue is full, the
101    /// oldest entries will be removed (or potentially persisted to disk, see
102    /// [`with_disk_persistence`][Self::with_disk_persistence]) to make room for new entries.
103    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    /// Configures the ratio of in-memory queue bytes to flush to disk when the queue is full.
115    ///
116    /// When disk persistence is enabled and the queue does not have enough room for a new entry, this ratio controls how
117    /// much in-memory data is moved to disk. For example, a value of `0.5` moves at least half of
118    /// `max_in_memory_bytes` to disk when the queue overflows. Values less than or equal to zero disable extra batch
119    /// flushing, but entries evicted to make room are still persisted when disk persistence is enabled.
120    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    /// Configures the queue to persist pending entries to disk.
126    ///
127    /// Disk persistence is used as a fallback to in-memory storage when the queue is full. When attempting to add a new
128    /// entry to the queue, and the queue can't fit the entry in-memory, in-memory entries will be persisted to disk,
129    /// oldest first.
130    ///
131    /// When reading entries from the queue, in-memory entries are read first, followed by persisted entries. This
132    /// provides priority to the most recent entries added to the queue, but allows for bursting over the configured
133    /// in-memory size limit without having to immediately discard entries.
134    ///
135    /// Files are stored in a subdirectory, with the same name as the given queue name, within `args.root_path`.
136    ///
137    /// # Errors
138    ///
139    /// If there is an error initializing the disk persistence layer, an error is returned.
140    pub async fn with_disk_persistence(mut self, mut args: PersistedQueueArgs) -> Result<Self, GenericError> {
141        // Make sure the root storage path is non-empty, as otherwise we can't generate a valid path
142        // for the persisted entries in this retry queue.
143        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    /// Returns `true` if the queue is empty.
161    ///
162    /// This includes both in-memory and persisted entries.
163    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    /// Returns the number of entries in the queue
168    ///
169    /// This includes both in-memory and persisted entries.
170    pub fn len(&self) -> usize {
171        self.pending.len() + self.persisted_pending.as_ref().map_or(0, |p| p.len())
172    }
173
174    /// Returns the maximum in-memory capacity, in bytes.
175    pub const fn max_in_memory_bytes(&self) -> u64 {
176        self.max_in_memory_bytes
177    }
178
179    /// Returns the available in-memory capacity, in bytes.
180    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    /// Returns the available on-disk capacity, in bytes.
185    ///
186    /// Returns `0` when disk persistence is not enabled.
187    ///
188    /// # Errors
189    ///
190    /// If disk persistence is enabled and there is an error while retrieving the underlying disk capacity, an error is
191    /// returned.
192    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    /// Returns the number of persisted entries that have been permanently dropped due to errors since the last call
200    /// to this method, resetting the counter.
201    ///
202    /// Always returns 0 if disk persistence isn't enabled.
203    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    /// Enqueues an entry.
208    ///
209    /// If the queue is full and the entry can't be enqueued in-memory, in-memory entries (oldest first) are evicted
210    /// until there is room for the new entry. When disk persistence is enabled, evicted entries are moved to disk. If the
211    /// flush-to-disk ratio is greater than zero, eviction moves at least
212    /// `max_in_memory_bytes * flush_to_disk_mem_ratio` bytes of in-memory data to disk before admitting the new entry. If
213    /// disk persistence is disabled, evicted entries are dropped instead. If an in-memory entry can't be persisted due to
214    /// a disk error, that entry is dropped and counted in the returned `PushResult`; the new entry is still enqueued.
215    ///
216    /// # Errors
217    ///
218    /// If the entry is too large to fit into the queue, an error is returned.
219    pub async fn push(&mut self, entry: T) -> Result<PushResult, GenericError> {
220        let mut push_result = PushResult::default();
221
222        // Make sure the entry, by itself, isn't too big to ever fit into the queue.
223        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        // Make sure we have enough room for this incoming entry, either by persisting older entries to disk or by
233        // simply dropping them.
234        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                // Capture the dropped-event counts before moving `oldest_entry` into the persist call, so we can still
255                // record drop telemetry if the disk write fails.
256                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                        // Match the upstream Agent: on disk persistence failure, drop this entry and continue evicting
266                        // so the new entry can still be admitted to the queue. Propagating the error here would
267                        // permanently lose the incoming transaction at the caller, which the Agent does not do.
268                        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                // Anchor the overflow-drop path: a prolonged outage saturates the queue and sheds the oldest entry
287                // (bounded memory at the cost of counted data loss).
288                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        // The eviction loop above guarantees we stay within the in-memory byte cap. Assert the invariant; numeric form
299        // hands the search the headroom to the cap as a gradient.
300        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    /// Consumes an entry.
313    ///
314    /// In-memory entries are consumed first, followed by persisted entries if disk persistence is enabled.
315    ///
316    /// If no entries are available, `None` is returned.
317    ///
318    /// # Errors
319    ///
320    /// If there is an error when consuming an entry from disk, whether due to reading or deserializing the entry, an
321    /// error is returned.
322    pub async fn pop(&mut self) -> Result<Option<T>, GenericError> {
323        // Pull from in-memory first to prioritize the most recent entries.
324        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 we have disk persistence enabled, pull from disk next.
332        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    /// Flushes all entries, potentially persisting them to disk.
342    ///
343    /// When disk persistence is configured, this will flush all in-memory entries to disk. Flushing to disk still obeys
344    /// the normal limiting behavior in terms of maximum on-disk size. When disk persistence isn't enabled, all
345    /// in-memory entries will be dropped.
346    ///
347    /// # Errors
348    ///
349    /// If an error occurs while persisting an entry to disk, an error is returned.
350    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        // Truncate toward zero to match the upstream Agent's `int(maxMemSizeInBytes * flushToStorageRatio)` semantics.
379        ((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        // Push our data to the queue.
441        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        // Now pop the data back out and ensure it matches what we pushed, and that the file has been removed from disk.
449        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        // Attempt to push our data into the queue, which should fail because it's too large.
514        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        // Create our retry queue such that it is sized to only fit one entry at a time.
523        let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), 36);
524
525        // Push our data to the queue.
526        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        // Push a second data entry, which should cause the first entry to be removed.
531        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        // Now pop the data back out and ensure it matches the second item we pushed, indicating the first item was
539        // removed from the queue to make room.
540        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        // Create our retry queue such that it can hold both items.
554        let mut retry_queue = RetryQueue::<FakeData>::new("test".to_string(), u64::MAX);
555
556        // Push our data to the queue.
557        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        // Flush the queue, which should drop all entries as we have no disk persistence layer configured.
565        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        // Create our retry queue such that it can hold both items, and enable disk persistence.
576        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        // Just a sanity check to ensure our temp directory is empty.
580        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        // Push our data to the queue.
594        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        // Flush the queue, which should push all entries to disk.
602        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        // We should now have two files on disk after flushing.
607        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        // In-memory entries are popped first (data3, data4), followed by the entries that were flushed to disk
660        // (data1, data2 in FIFO order).
661        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}