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

1use std::{
2    io,
3    marker::PhantomData,
4    path::{Path, PathBuf},
5    sync::Arc,
6};
7
8use chrono::{DateTime, NaiveDateTime, Utc};
9use fs4::{available_space, total_space};
10use rand::RngExt as _;
11use saluki_error::{generic_error, ErrorContext as _, GenericError};
12use serde::{de::DeserializeOwned, Serialize};
13use tracing::{debug, error, info, warn};
14
15use super::{EventContainer, PushResult};
16
17/// A persisted entry.
18///
19/// Represents the high-level metadata of a persisted entry, including the path to and size of the entry.
20struct PersistedEntry {
21    path: PathBuf,
22    timestamp: u128,
23    size_bytes: u64,
24}
25
26impl PersistedEntry {
27    /// Attempts to create a `PersistedEntry` from the given path.
28    ///
29    /// If the given path isn't recognized as the path to a valid persisted entry, `None` is returned.
30    fn try_from_path(path: PathBuf, size_bytes: u64) -> Option<Self> {
31        let timestamp = decode_timestamped_filename(&path)?;
32        Some(Self {
33            path,
34            timestamp,
35            size_bytes,
36        })
37    }
38
39    fn from_parts(path: PathBuf, timestamp: u128, size_bytes: u64) -> Self {
40        Self {
41            path,
42            timestamp,
43            size_bytes,
44        }
45    }
46}
47
48pub trait DiskUsageRetriever {
49    fn total_space(&self) -> Result<u64, GenericError>;
50    fn available_space(&self) -> Result<u64, GenericError>;
51}
52
53pub struct DiskUsageRetrieverImpl {
54    root_path: PathBuf,
55}
56
57impl DiskUsageRetrieverImpl {
58    pub fn new(root_path: PathBuf) -> Self {
59        Self { root_path }
60    }
61}
62
63impl DiskUsageRetriever for DiskUsageRetrieverImpl {
64    fn total_space(&self) -> Result<u64, GenericError> {
65        total_space(&self.root_path)
66            .with_error_context(|| format!("Failed to get total space for '{}'.", self.root_path.display()))
67    }
68
69    fn available_space(&self) -> Result<u64, GenericError> {
70        available_space(&self.root_path)
71            .with_error_context(|| format!("Failed to get available space for '{}'.", self.root_path.display()))
72    }
73}
74
75#[derive(Clone)]
76pub struct DiskUsageRetrieverWrapper {
77    inner: Arc<dyn DiskUsageRetriever + Send + Sync>,
78}
79
80impl DiskUsageRetrieverWrapper {
81    pub fn new(disk_usage_retriever: Arc<dyn DiskUsageRetriever + Send + Sync>) -> Self {
82        Self {
83            inner: disk_usage_retriever,
84        }
85    }
86}
87
88/// Arguments for constructing a persisted retry queue.
89pub struct PersistedQueueArgs {
90    /// Root path under which the queue directory is created.
91    pub root_path: PathBuf,
92    /// Maximum total bytes the queue may occupy on disk.
93    pub max_on_disk_bytes: u64,
94    /// Maximum fraction of the disk that may be used before writes stop.
95    pub storage_max_disk_ratio: f64,
96    /// Provider for total- and available-disk-space queries.
97    pub disk_usage_retriever: Arc<dyn DiskUsageRetriever + Send + Sync>,
98    /// Maximum age of retry files in days; files older than this are removed on startup.
99    ///
100    /// Setting this to `0` removes all retry files (cutoff = now), matching the behavior of the
101    /// core Agent's `FileRemovalPolicy` with `outdatedFileDayCount = 0`.
102    pub max_age_days: u32,
103}
104
105pub struct PersistedQueue<T> {
106    root_path: PathBuf,
107    entries: Vec<PersistedEntry>,
108    total_on_disk_bytes: u64,
109    max_on_disk_bytes: u64,
110    storage_max_disk_ratio: f64,
111    disk_usage_retriever: DiskUsageRetrieverWrapper,
112    max_age_days: u32,
113    entries_dropped: u64,
114    _entry: PhantomData<T>,
115}
116
117impl<T> PersistedQueue<T>
118where
119    T: EventContainer + DeserializeOwned + Serialize,
120{
121    /// Creates a new `PersistedQueue` instance from the given arguments.
122    ///
123    /// The root path is created if it doesn't already exist, and is scanned for existing persisted entries. Entries
124    /// are removed (oldest first) until the total size of all scanned entries is within the given maximum size.
125    ///
126    /// To remove stale retry files on startup, call [`remove_stale_files`][Self::remove_stale_files] after construction.
127    ///
128    /// # Errors
129    ///
130    /// If there is an error creating the root directory, or scanning it for existing entries, or deleting entries to
131    /// shrink the directory to fit the given maximum size, an error is returned.
132    pub async fn from_root_path(args: PersistedQueueArgs) -> Result<Self, GenericError> {
133        let PersistedQueueArgs {
134            root_path,
135            max_on_disk_bytes,
136            storage_max_disk_ratio,
137            disk_usage_retriever,
138            max_age_days,
139        } = args;
140
141        // Make sure the directory exists first.
142        create_directory_recursive(root_path.clone())
143            .await
144            .with_error_context(|| format!("Failed to create retry directory '{}'.", root_path.display()))?;
145
146        let mut persisted_requests = Self {
147            root_path: root_path.clone(),
148            entries: Vec::new(),
149            total_on_disk_bytes: 0,
150            max_on_disk_bytes,
151            storage_max_disk_ratio,
152            disk_usage_retriever: DiskUsageRetrieverWrapper::new(disk_usage_retriever),
153            max_age_days,
154            entries_dropped: 0,
155            _entry: PhantomData,
156        };
157
158        persisted_requests.refresh_entry_state().await?;
159
160        info!(
161            "Persisted retry queue initialized. Transactions will be stored in '{}'.",
162            root_path.display()
163        );
164
165        Ok(persisted_requests)
166    }
167
168    /// Returns `true` if the queue is empty.
169    pub fn is_empty(&self) -> bool {
170        self.entries.is_empty()
171    }
172
173    /// Returns the number of entries in the queue.
174    pub fn len(&self) -> usize {
175        self.entries.len()
176    }
177
178    /// Returns the available on-disk capacity, in bytes.
179    ///
180    /// This reflects the lower of the configured queue limit and disk-usage-ratio limit, minus the bytes currently used
181    /// by persisted entries.
182    ///
183    /// # Errors
184    ///
185    /// If there is an error while retrieving the total or available space of the underlying volume, an error is returned.
186    pub async fn available_capacity_bytes(&self) -> Result<u64, GenericError> {
187        let disk_usage_retriever = self.disk_usage_retriever.clone();
188        let storage_max_disk_ratio = self.storage_max_disk_ratio;
189        let max_on_disk_bytes = self.max_on_disk_bytes;
190
191        let limit = tokio::task::spawn_blocking(move || {
192            on_disk_bytes_limit(disk_usage_retriever, storage_max_disk_ratio, max_on_disk_bytes)
193        })
194        .await
195        .error_context("Failed to run disk size limit check to completion.")??;
196
197        Ok(limit.saturating_sub(self.total_on_disk_bytes))
198    }
199
200    /// Returns the number of entries that have been permanently dropped due to errors since the last call to this
201    /// method, resetting the counter.
202    pub fn take_entries_dropped(&mut self) -> u64 {
203        std::mem::take(&mut self.entries_dropped)
204    }
205
206    /// Removes retry files older than `max_age_days` (from [`PersistedQueueArgs`]) from the queue directory and
207    /// reloads entry state.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the queue directory cannot be opened or scanned. Individual file removal failures are
212    /// logged as warnings and do not stop the cleanup.
213    pub async fn remove_stale_files(&mut self) -> Result<u32, GenericError> {
214        let removed = remove_outdated_retry_files(&self.root_path, self.max_age_days).await?;
215        self.refresh_entry_state().await.map_err(GenericError::from)?;
216        Ok(removed)
217    }
218
219    /// Enqueues an entry and persists it to disk.
220    ///
221    /// # Errors
222    ///
223    /// If there is an error serializing the entry, or writing it to disk, or removing older entries to make space for
224    /// the new entry, an error is returned.
225    pub async fn push(&mut self, entry: T) -> Result<PushResult, GenericError> {
226        // Serialize the entry to a temporary file.
227        let (filename, timestamp) = generate_timestamped_filename();
228        let entry_path = self.root_path.join(filename);
229        let serialized = serde_json::to_vec(&entry)
230            .with_error_context(|| format!("Failed to serialize entry for '{}'.", entry_path.display()))?;
231
232        if serialized.len() as u64 > self.max_on_disk_bytes {
233            return Err(generic_error!("Entry is too large to persist."));
234        }
235
236        // Make sure we have enough space to persist the entry.
237        let push_result = self
238            .remove_until_available_space(serialized.len() as u64)
239            .await
240            .error_context(
241                "Failed to remove older persisted entries to make space for the incoming persisted entry.",
242            )?;
243
244        // Actually persist it.
245        tokio::fs::write(&entry_path, &serialized)
246            .await
247            .with_error_context(|| format!("Failed to write entry to '{}'.", entry_path.display()))?;
248
249        // Add a new persisted entry to our state.
250        self.entries.push(PersistedEntry::from_parts(
251            entry_path,
252            timestamp,
253            serialized.len() as u64,
254        ));
255        self.total_on_disk_bytes += serialized.len() as u64;
256
257        debug!(entry.len = serialized.len(), "Enqueued persisted entry.");
258
259        Ok(push_result)
260    }
261
262    /// Consumes the oldest persisted entry on disk, if one exists.
263    ///
264    /// # Errors
265    ///
266    /// If there is an error reading or deserializing the entry, an error is returned.
267    pub async fn pop(&mut self) -> Result<Option<T>, GenericError> {
268        loop {
269            if self.entries.is_empty() {
270                return Ok(None);
271            }
272
273            let entry = self.entries.remove(0);
274            match try_deserialize_entry(&entry).await {
275                Ok(Some(deserialized)) => {
276                    // We got the deserialized entry, so remove it from our state and return it.
277                    self.total_on_disk_bytes -= entry.size_bytes;
278                    debug!(entry.len = entry.size_bytes, "Dequeued persisted entry.");
279
280                    return Ok(Some(deserialized));
281                }
282                Ok(None) => {
283                    // We couldn't read the entry from disk, which points to us potentially having invalid state about
284                    // what entries _are_ on disk, so we'll refresh our entry state and try again.
285                    self.refresh_entry_state().await?;
286                    continue;
287                }
288                Err(e) => {
289                    // The entry is corrupt or unreadable. Drop it permanently to avoid a poison pill scenario
290                    // where the same entry is retried indefinitely, blocking all other work.
291                    warn!(
292                        entry.path = %entry.path.display(),
293                        entry.len = entry.size_bytes,
294                        error = %e,
295                        "Permanently dropping persisted entry that could not be consumed.",
296                    );
297
298                    self.total_on_disk_bytes -= entry.size_bytes;
299                    self.entries_dropped += 1;
300
301                    continue;
302                }
303            }
304        }
305    }
306
307    async fn refresh_entry_state(&mut self) -> io::Result<()> {
308        // Scan the root path for persisted entries.
309        let mut entries = Vec::new();
310
311        let mut dir_reader = tokio::fs::read_dir(&self.root_path).await?;
312        while let Some(entry) = dir_reader.next_entry().await? {
313            let metadata = entry.metadata().await?;
314            if metadata.is_file() {
315                match PersistedEntry::try_from_path(entry.path(), metadata.len()) {
316                    Some(entry) => entries.push(entry),
317                    None => {
318                        warn!(
319                            file_size = metadata.len(),
320                            "Ignoring unrecognized file '{}' in retry directory.",
321                            entry.path().display()
322                        );
323                        continue;
324                    }
325                }
326            }
327        }
328
329        // Sort the entries by their inherent timestamp.
330        entries.sort_by_key(|entry| entry.timestamp);
331        self.total_on_disk_bytes = entries.iter().map(|entry| entry.size_bytes).sum();
332        self.entries = entries;
333
334        Ok(())
335    }
336
337    /// Removes persisted entries (oldest first) until there is at least the required number of bytes in free space
338    /// (maximum - total).
339    ///
340    /// # Errors
341    ///
342    /// If there is an error while deleting persisted entries, an error is returned.
343    async fn remove_until_available_space(&mut self, required_bytes: u64) -> Result<PushResult, GenericError> {
344        let mut push_result = PushResult::default();
345
346        let disk_usage_retriever = self.disk_usage_retriever.clone();
347        let storage_max_disk_ratio = self.storage_max_disk_ratio;
348        let max_on_disk_bytes = self.max_on_disk_bytes;
349
350        // TODO: Evaluate the possible failures scenarios a little more thoroughly, and see if we can improve
351        // how we handle them instead of just bailing out.
352        //
353        // Essentially, it's not clear to me if we would expect this to fail in a way where we could actually
354        // still write the persistent entries to disk, and if it's worth it to do something like trying to
355        // cache the last known good value we get here to use if we fail to get a new value, etc.
356        let limit = tokio::task::spawn_blocking(move || {
357            on_disk_bytes_limit(disk_usage_retriever, storage_max_disk_ratio, max_on_disk_bytes)
358        })
359        .await
360        .error_context("Failed to run disk size limit check to completion.")??;
361
362        while !self.entries.is_empty() && self.total_on_disk_bytes + required_bytes > limit {
363            let entry = self.entries.remove(0);
364
365            // Deserialize the entry, which gives us back the original event and removes the file from disk.
366            let deserialized = match try_deserialize_entry::<T>(&entry).await {
367                Ok(Some(deserialized)) => deserialized,
368                Ok(None) => {
369                    warn!(entry.path = %entry.path.display(), "Failed to find entry on disk. Persisted entry state may be inconsistent.");
370                    continue;
371                }
372                Err(e) => {
373                    // The entry is corrupt or unreadable. Drop it permanently to avoid blocking subsequent
374                    // entries from being evicted.
375                    warn!(
376                        entry.path = %entry.path.display(),
377                        entry.len = entry.size_bytes,
378                        error = %e,
379                        "Permanently dropping persisted entry that could not be consumed during eviction.",
380                    );
381
382                    self.total_on_disk_bytes -= entry.size_bytes;
383                    self.entries_dropped += 1;
384
385                    continue;
386                }
387            };
388
389            // Update our statistics.
390            self.total_on_disk_bytes -= entry.size_bytes;
391            push_result.track_dropped_item(&deserialized);
392
393            // The message text here is kept in sync with an equivalent log emitted by another implementation of
394            // this queue, so that log-based alerting on this condition matches regardless of which implementation
395            // is deployed.
396            error!(
397                entry.path = %entry.path.display(),
398                entry.len = entry.size_bytes,
399                "Maximum disk space for retry transactions is reached. Removing {}.",
400                entry.path.display()
401            );
402        }
403
404        Ok(push_result)
405    }
406}
407
408/// Determines the total number of bytes that can be written to disk without causing the underlying volume to end up
409/// with more than `storage_max_disk_ratio` in terms of used space. The minimum of `max_on_disk_bytes` and the result
410/// of this calculation is returned.
411///
412/// # Errors
413///
414/// If there is an error while retrieving the total or available space of the underlying volume, an error is returned.
415fn on_disk_bytes_limit(
416    disk_usage_retriever: DiskUsageRetrieverWrapper, storage_max_disk_ratio: f64, max_on_disk_bytes: u64,
417) -> Result<u64, GenericError> {
418    let total_space = disk_usage_retriever.inner.total_space()? as f64;
419    let available_space = disk_usage_retriever.inner.available_space()? as f64;
420    let disk_reserved = total_space * (1.0 - storage_max_disk_ratio);
421    let available_disk_usage = (available_space - disk_reserved).ceil() as u64;
422    Ok(max_on_disk_bytes.min(available_disk_usage))
423}
424
425async fn try_deserialize_entry<T: DeserializeOwned>(entry: &PersistedEntry) -> Result<Option<T>, GenericError> {
426    let serialized = match tokio::fs::read(&entry.path).await {
427        Ok(serialized) => serialized,
428        Err(e) => match e.kind() {
429            io::ErrorKind::NotFound => {
430                // We tried to delete an entry that no longer exists on disk, which means our internal entry state
431                // is corrupted for some reason.
432                //
433                // Tell the caller that we couldn't find the entry on disk, so that they need to refresh the entry state
434                // to make sure it's up-to-date before trying again.
435                return Ok(None);
436            }
437            _ => {
438                return Err(e)
439                    .with_error_context(|| format!("Failed to read persisted entry '{}'.", entry.path.display()))
440            }
441        },
442    };
443
444    let deserialized = match serde_json::from_slice(&serialized) {
445        Ok(deserialized) => deserialized,
446        Err(e) => {
447            // Deserialization failed, which means the payload is corrupt or invalid. Attempt to clean up the
448            // file from disk so it doesn't accumulate, but don't fail if we can't.
449            if let Err(remove_err) = tokio::fs::remove_file(&entry.path).await {
450                warn!(
451                    entry.path = %entry.path.display(),
452                    error = %remove_err,
453                    "Failed to remove corrupt persisted entry from disk.",
454                );
455            }
456
457            return Err(e)
458                .with_error_context(|| format!("Failed to deserialize persisted entry '{}'.", entry.path.display()));
459        }
460    };
461
462    // Delete the entry from disk before returning, so that we don't risk sending duplicates.
463    tokio::fs::remove_file(&entry.path)
464        .await
465        .with_error_context(|| format!("Failed to delete persisted entry '{}'.", entry.path.display()))?;
466
467    debug!(entry.path = %entry.path.display(), entry.len = entry.size_bytes, "Consumed persisted entry and removed from disk.");
468    Ok(Some(deserialized))
469}
470
471fn generate_timestamped_filename() -> (PathBuf, u128) {
472    let now = Utc::now();
473    let now_ts = datetime_to_timestamp(now);
474    let nonce = rand::rng().random_range(100000000..999999999);
475
476    let filename = format!("retry-{}-{}.json", now.format("%Y%m%d%H%M%S%f"), nonce).into();
477
478    (filename, now_ts)
479}
480
481fn decode_timestamped_filename(path: &Path) -> Option<u128> {
482    let filename = path.file_stem()?.to_str()?;
483    let mut filename_parts = filename.split('-');
484
485    let prefix = filename_parts.next()?;
486    let timestamp_str = filename_parts.next()?;
487    let nonce = filename_parts.next()?;
488
489    // Make sure the filename matches our expected format by first checking the prefix and nonce portions.
490    if prefix != "retry" || nonce.parse::<u64>().is_err() {
491        return None;
492    }
493
494    // Try and decode the timestamp portion.
495    NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M%S%f")
496        .map(|dt| datetime_to_timestamp(dt.and_utc()))
497        .ok()
498}
499
500fn datetime_to_timestamp(dt: DateTime<Utc>) -> u128 {
501    let secs = (dt.timestamp() as u128) * 1_000_000_000;
502    let ns = dt.timestamp_subsec_nanos() as u128;
503
504    secs + ns
505}
506
507async fn create_directory_recursive(path: PathBuf) -> Result<(), GenericError> {
508    let mut dir_builder = std::fs::DirBuilder::new();
509    dir_builder.recursive(true);
510
511    // When on Unix platforms, adjust the permissions of the directory to be RWX for the owner only, and nothing for
512    // group/world.
513    #[cfg(unix)]
514    {
515        use std::os::unix::fs::DirBuilderExt;
516        dir_builder.mode(0o700);
517    }
518
519    tokio::task::spawn_blocking(move || {
520        dir_builder
521            .create(&path)
522            .with_error_context(|| format!("Failed to create directory '{}'.", path.display()))
523    })
524    .await
525    .error_context("Failed to spawn directory creation blocking task.")?
526}
527
528/// Deletes files in `queue_path` whose filename-embedded creation timestamp is older than
529/// `max_age_days`. Does nothing if the directory does not exist.
530///
531/// Setting `max_age_days` to `0` deletes all retry files (cutoff = now), matching the behavior
532/// of the core Agent's `FileRemovalPolicy` with `outdatedFileDayCount = 0`.
533async fn remove_outdated_retry_files(queue_path: &Path, max_age_days: u32) -> Result<u32, GenericError> {
534    let mut dir = match tokio::fs::read_dir(queue_path).await {
535        Ok(d) => d,
536        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
537        Err(e) => {
538            return Err(e).with_error_context(|| {
539                format!(
540                    "Failed to open retry queue directory '{}' for age-based cleanup.",
541                    queue_path.display()
542                )
543            });
544        }
545    };
546    let now_ns = std::time::SystemTime::now()
547        .duration_since(std::time::UNIX_EPOCH)
548        .unwrap_or_default() // clock before epoch: treat cutoff as 0, skipping all deletions
549        .as_nanos();
550    let cutoff_ns = now_ns.saturating_sub(max_age_days as u128 * 24 * 3600 * 1_000_000_000);
551    let mut removed = 0u32;
552    loop {
553        let entry = match dir.next_entry().await {
554            Ok(Some(e)) => e,
555            Ok(None) => break,
556            Err(e) => {
557                return Err(e).with_error_context(|| "Error reading retry queue directory during age-based cleanup.");
558            }
559        };
560        let file_ts = match decode_timestamped_filename(&entry.path()) {
561            Some(ts) => ts,
562            None => continue,
563        };
564        if file_ts < cutoff_ns {
565            let name_str = entry.file_name();
566            let name = name_str.to_string_lossy();
567            match tokio::fs::remove_file(entry.path()).await {
568                Ok(()) => {
569                    debug!(file = %name, "Removed outdated retry file.");
570                    removed += 1;
571                }
572                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
573                    debug!(file = %name, "Retry file already removed by concurrent cleanup.");
574                }
575                Err(e) => {
576                    warn!(file = %name, error = %e, "Failed to remove outdated retry file.");
577                }
578            }
579        }
580    }
581    Ok(removed)
582}
583
584#[cfg(test)]
585mod tests {
586    use rand::RngExt as _;
587    use rand_distr::Alphanumeric;
588    use serde::Deserialize;
589
590    use super::*;
591
592    #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
593    struct FakeData {
594        name: String,
595        value: u32,
596    }
597
598    impl FakeData {
599        fn random() -> Self {
600            Self {
601                name: rand::rng().sample_iter(&Alphanumeric).take(8).map(char::from).collect(),
602                value: rand::rng().random_range(0..100),
603            }
604        }
605    }
606
607    impl EventContainer for FakeData {
608        fn event_count(&self) -> u64 {
609            1
610        }
611    }
612
613    struct MockDiskUsageRetriever {}
614
615    impl DiskUsageRetriever for MockDiskUsageRetriever {
616        fn total_space(&self) -> Result<u64, GenericError> {
617            Ok(100)
618        }
619        fn available_space(&self) -> Result<u64, GenericError> {
620            Ok(100)
621        }
622    }
623
624    async fn files_in_dir(path: &Path) -> usize {
625        let mut file_count = 0;
626        let mut dir_reader = tokio::fs::read_dir(path).await.unwrap();
627        while let Some(entry) = dir_reader.next_entry().await.unwrap() {
628            if entry.metadata().await.unwrap().is_file() {
629                file_count += 1;
630            }
631        }
632        file_count
633    }
634
635    #[tokio::test]
636    async fn basic_push_pop() {
637        let data = FakeData::random();
638
639        // Create our temporary directory and point our persisted queue at it.
640        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
641        let root_path = temp_dir.path().to_path_buf();
642
643        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
644            root_path: root_path.clone(),
645            max_on_disk_bytes: 1024,
646            storage_max_disk_ratio: 0.8,
647            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
648            max_age_days: 10,
649        })
650        .await
651        .expect("should not fail to create persisted queue");
652
653        // Ensure the directory is empty.
654        assert_eq!(0, files_in_dir(&root_path).await);
655
656        // Push our data to the queue and ensure it persisted it to disk.
657        let push_result = persisted_queue
658            .push(data.clone())
659            .await
660            .expect("should not fail to push data");
661        assert_eq!(1, files_in_dir(&root_path).await);
662        assert_eq!(0, push_result.items_dropped);
663        assert_eq!(0, push_result.events_dropped);
664
665        // Now pop the data back out and ensure it matches what we pushed, and that the file has been removed from disk.
666        let actual = persisted_queue
667            .pop()
668            .await
669            .expect("should not fail to pop data")
670            .expect("should not be empty");
671        assert_eq!(data, actual);
672        assert_eq!(0, files_in_dir(&root_path).await);
673    }
674
675    #[tokio::test]
676    async fn entry_too_large() {
677        let data = FakeData::random();
678
679        // Create our temporary directory and point our persisted queue at it.
680        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
681        let root_path = temp_dir.path().to_path_buf();
682
683        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
684            root_path: root_path.clone(),
685            max_on_disk_bytes: 1,
686            storage_max_disk_ratio: 0.8,
687            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
688            max_age_days: 10,
689        })
690        .await
691        .expect("should not fail to create persisted queue");
692
693        // Ensure the directory is empty.
694        assert_eq!(0, files_in_dir(&root_path).await);
695
696        // Attempt to push our data into the queue, which should fail because it's too large.
697        assert!(persisted_queue.push(data).await.is_err());
698
699        // Ensure the directory is (still) empty.
700        assert_eq!(0, files_in_dir(&root_path).await);
701    }
702
703    #[tokio::test]
704    async fn remove_oldest_entry_on_push() {
705        let data1 = FakeData::random();
706        let data2 = FakeData::random();
707
708        // Create our temporary directory and point our persisted queue at it.
709        //
710        // Our queue is sized such that only one entry can be persisted at a time.
711        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
712        let root_path = temp_dir.path().to_path_buf();
713
714        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
715            root_path: root_path.clone(),
716            max_on_disk_bytes: 32,
717            storage_max_disk_ratio: 0.8,
718            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
719            max_age_days: 10,
720        })
721        .await
722        .expect("should not fail to create persisted queue");
723
724        // Ensure the directory is empty.
725        assert_eq!(0, files_in_dir(&root_path).await);
726
727        // Push our data to the queue and ensure it persisted it to disk.
728        let push_result = persisted_queue.push(data1).await.expect("should not fail to push data");
729        assert_eq!(1, files_in_dir(&root_path).await);
730        assert_eq!(0, push_result.items_dropped);
731        assert_eq!(0, push_result.events_dropped);
732
733        // Push a second data entry, which should cause the first entry to be removed.
734        let push_result = persisted_queue
735            .push(data2.clone())
736            .await
737            .expect("should not fail to push data");
738        assert_eq!(1, files_in_dir(&root_path).await);
739        assert_eq!(1, push_result.items_dropped);
740        assert_eq!(1, push_result.events_dropped);
741
742        // Now pop the data back out and ensure it matches the second item we pushed -- indicating the first item was
743        // removed -- and that we've consumed it, leaving no files on disk.
744        let actual = persisted_queue
745            .pop()
746            .await
747            .expect("should not fail to pop data")
748            .expect("should not be empty");
749        assert_eq!(data2, actual);
750        assert_eq!(0, files_in_dir(&root_path).await);
751    }
752
753    #[tokio::test]
754    async fn storage_ratio_exceeded() {
755        let data1 = FakeData::random();
756        let data2 = FakeData::random();
757
758        // Create our temporary directory and point our persisted queue at it.
759        //
760        // Our queue is sized such that two entries can be persisted at a time.
761        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
762        let root_path = temp_dir.path().to_path_buf();
763
764        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
765            root_path: root_path.clone(),
766            max_on_disk_bytes: 80,
767            storage_max_disk_ratio: 0.35,
768            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
769            max_age_days: 10,
770        })
771        .await
772        .expect("should not fail to create persisted queue");
773
774        // Ensure the directory is empty.
775        assert_eq!(0, files_in_dir(&root_path).await);
776
777        // The `storage_max_disk_ratio` is 0.35, and our `MockDiskUsageRetriever` returns 100 for both `total_space` and
778        // `available_space`, so `on_disk_bytes_limit()` returns min(80, 35) = 35.
779        //
780        // First entry: total_on_disk_bytes(0) + required_bytes(30) < on_disk_bytes_limit(35)
781        let push_result = persisted_queue.push(data1).await.expect("should not fail to push data");
782
783        assert_eq!(1, files_in_dir(&root_path).await);
784        assert_eq!(0, push_result.items_dropped);
785        assert_eq!(0, push_result.events_dropped);
786
787        // Second entry: total_on_disk_bytes(30) + required_bytes(30) > on_disk_bytes_limit(35) so the first entry is dropped.
788        let push_result = persisted_queue
789            .push(data2.clone())
790            .await
791            .expect("should not fail to push data");
792        assert_eq!(1, files_in_dir(&root_path).await);
793        assert_eq!(1, push_result.items_dropped);
794        assert_eq!(1, push_result.events_dropped);
795
796        // Now pop the data back out and ensure it matches the second item we pushed -- indicating the first item was
797        // removed -- and that we've consumed it, leaving no files on disk.
798        let actual = persisted_queue
799            .pop()
800            .await
801            .expect("should not fail to pop data")
802            .expect("should not be empty");
803        assert_eq!(data2, actual);
804        assert_eq!(0, files_in_dir(&root_path).await);
805    }
806
807    /// Writes a corrupt (non-JSON) file with a valid retry filename to the given directory, using a timestamp
808    /// that sorts before any real entries (so it will be popped first).
809    async fn write_corrupt_entry(dir: &Path) -> PathBuf {
810        let filename = "retry-20000101000000000000-100000000.json";
811        let path = dir.join(filename);
812        tokio::fs::write(&path, b"this is not valid json").await.unwrap();
813        path
814    }
815
816    #[tokio::test]
817    async fn corrupt_entry_is_skipped_on_pop() {
818        let data = FakeData::random();
819
820        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
821        let root_path = temp_dir.path().to_path_buf();
822
823        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
824            root_path: root_path.clone(),
825            max_on_disk_bytes: 1024,
826            storage_max_disk_ratio: 0.8,
827            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
828            max_age_days: 10,
829        })
830        .await
831        .expect("should not fail to create persisted queue");
832
833        // Write a corrupt file before pushing valid data, so it sorts first.
834        let corrupt_path = write_corrupt_entry(&root_path).await;
835
836        // Push a valid entry.
837        let _ = persisted_queue
838            .push(data.clone())
839            .await
840            .expect("should not fail to push data");
841
842        // Refresh state so the queue picks up the corrupt file.
843        persisted_queue.refresh_entry_state().await.unwrap();
844
845        // Pop should skip the corrupt entry and return the valid one.
846        let actual = persisted_queue
847            .pop()
848            .await
849            .expect("should not fail to pop data")
850            .expect("should have a valid entry");
851        assert_eq!(data, actual);
852
853        // The corrupt file should have been cleaned up from disk.
854        assert!(!corrupt_path.exists());
855
856        // The dropped counter should reflect the corrupt entry.
857        assert_eq!(1, persisted_queue.take_entries_dropped());
858
859        // No files should remain.
860        assert_eq!(0, files_in_dir(&root_path).await);
861    }
862
863    #[tokio::test]
864    async fn corrupt_entry_does_not_block_queue() {
865        let data1 = FakeData::random();
866        let data2 = FakeData::random();
867
868        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
869        let root_path = temp_dir.path().to_path_buf();
870
871        // Use MockDiskUsageRetriever to avoid disk space ratio causing eviction during push.
872        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
873            root_path: root_path.clone(),
874            max_on_disk_bytes: 1024,
875            storage_max_disk_ratio: 0.8,
876            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
877            max_age_days: 10,
878        })
879        .await
880        .expect("should not fail to create persisted queue");
881
882        // Push two valid entries, then corrupt the first one on disk.
883        let _ = persisted_queue.push(data1).await.expect("should not fail to push data");
884        let _ = persisted_queue
885            .push(data2.clone())
886            .await
887            .expect("should not fail to push data");
888        assert_eq!(2, persisted_queue.entries.len());
889
890        // Corrupt the oldest entry file on disk.
891        let oldest_path = persisted_queue.entries[0].path.clone();
892        tokio::fs::write(&oldest_path, b"corrupted").await.unwrap();
893
894        // Pop should skip the corrupt entry and return the second valid one.
895        let actual = persisted_queue
896            .pop()
897            .await
898            .expect("should not fail to pop data")
899            .expect("should have a valid entry");
900        assert_eq!(data2, actual);
901
902        assert_eq!(1, persisted_queue.take_entries_dropped());
903        assert_eq!(0, files_in_dir(&root_path).await);
904    }
905
906    #[tokio::test]
907    async fn pop_returns_none_when_all_entries_corrupt() {
908        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
909        let root_path = temp_dir.path().to_path_buf();
910
911        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
912            root_path: root_path.clone(),
913            max_on_disk_bytes: 1024,
914            storage_max_disk_ratio: 0.8,
915            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
916            max_age_days: 10,
917        })
918        .await
919        .expect("should not fail to create persisted queue");
920
921        // Write a corrupt entry and refresh state.
922        write_corrupt_entry(&root_path).await;
923        persisted_queue.refresh_entry_state().await.unwrap();
924
925        // Pop should skip the corrupt entry and return None (no valid entries).
926        let result = persisted_queue.pop().await.expect("should not fail to pop data");
927        assert!(result.is_none());
928
929        assert_eq!(1, persisted_queue.take_entries_dropped());
930        assert_eq!(0, files_in_dir(&root_path).await);
931    }
932
933    #[tokio::test]
934    async fn corrupt_entry_dropped_during_eviction() {
935        let data = FakeData::random();
936
937        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
938        let root_path = temp_dir.path().to_path_buf();
939
940        // Queue sized to hold only one entry.
941        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
942            root_path: root_path.clone(),
943            max_on_disk_bytes: 32,
944            storage_max_disk_ratio: 0.8,
945            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
946            max_age_days: 10,
947        })
948        .await
949        .expect("should not fail to create persisted queue");
950
951        // Push a valid entry, then corrupt it on disk.
952        let _ = persisted_queue
953            .push(FakeData::random())
954            .await
955            .expect("should not fail to push data");
956        let first_path = persisted_queue.entries[0].path.clone();
957        tokio::fs::write(&first_path, b"corrupted").await.unwrap();
958
959        // Push another entry, which needs to evict the first (corrupt) one to make space.
960        // This should succeed without error -- the corrupt entry is dropped during eviction.
961        let _ = persisted_queue
962            .push(data.clone())
963            .await
964            .expect("should not fail to push data");
965
966        // The corrupt entry was dropped during eviction, not via normal eviction tracking.
967        assert_eq!(1, persisted_queue.take_entries_dropped());
968
969        // The valid entry should be poppable.
970        let actual = persisted_queue
971            .pop()
972            .await
973            .expect("should not fail to pop data")
974            .expect("should have a valid entry");
975        assert_eq!(data, actual);
976        assert_eq!(0, files_in_dir(&root_path).await);
977    }
978
979    #[tokio::test]
980    async fn persisted_queue_removes_outdated_files_on_initialization() {
981        let data = FakeData::random();
982
983        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
984        let root_path = temp_dir.path().to_path_buf();
985
986        // Pre-seed a year-2000 retry file containing valid data that would be loaded as an entry
987        // if it were not cleaned up first.
988        let stale_content = serde_json::to_vec(&data).unwrap();
989        tokio::fs::write(
990            root_path.join("retry-20000101000000000000000-100000000.json"),
991            &stale_content,
992        )
993        .await
994        .unwrap();
995
996        assert_eq!(1, files_in_dir(&root_path).await);
997
998        // Initialize the queue and remove stale files with a 10-day age limit.
999        let mut queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
1000            root_path: root_path.clone(),
1001            max_on_disk_bytes: 1024 * 1024,
1002            storage_max_disk_ratio: 0.8,
1003            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
1004            max_age_days: 10,
1005        })
1006        .await
1007        .expect("should not fail to create persisted queue");
1008        queue
1009            .remove_stale_files()
1010            .await
1011            .expect("should not fail to remove stale files");
1012
1013        assert_eq!(0, files_in_dir(&root_path).await);
1014        assert!(queue.is_empty());
1015    }
1016
1017    #[tokio::test]
1018    async fn persisted_queue_zero_age_removes_all_retry_files_on_initialization() {
1019        // max_age_days=0 sets cutoff=now, matching the core Agent's FileRemovalPolicy behavior
1020        // with outdatedFileDayCount=0 — all retry files are removed on startup.
1021        let data = FakeData::random();
1022
1023        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
1024        let root_path = temp_dir.path().to_path_buf();
1025
1026        // Seed with a freshly-written entry via a normal queue.
1027        let mut seeding_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
1028            root_path: root_path.clone(),
1029            max_on_disk_bytes: 1024 * 1024,
1030            storage_max_disk_ratio: 0.8,
1031            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
1032            max_age_days: 10,
1033        })
1034        .await
1035        .expect("should not fail to create persisted queue");
1036        let _ = seeding_queue.push(data).await.expect("should not fail to push data");
1037        assert_eq!(1, files_in_dir(&root_path).await);
1038
1039        // Re-open and remove stale files with max_age_days=0: the just-written file must also be deleted.
1040        let mut queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
1041            root_path: root_path.clone(),
1042            max_on_disk_bytes: 1024 * 1024,
1043            storage_max_disk_ratio: 0.8,
1044            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
1045            max_age_days: 0,
1046        })
1047        .await
1048        .expect("should not fail to create persisted queue");
1049        queue
1050            .remove_stale_files()
1051            .await
1052            .expect("should not fail to remove stale files");
1053
1054        assert_eq!(0, files_in_dir(&root_path).await);
1055        assert!(queue.is_empty());
1056    }
1057}