Skip to main content

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, 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        // The eviction above keeps us within the on-disk byte cap. Assert the invariant.
258        saluki_antithesis::always_le!(
259            self.total_on_disk_bytes,
260            self.max_on_disk_bytes,
261            "retry queue on-disk bytes within cap",
262            { "bytes": self.total_on_disk_bytes, "cap": self.max_on_disk_bytes }
263        );
264
265        debug!(entry.len = serialized.len(), "Enqueued persisted entry.");
266
267        Ok(push_result)
268    }
269
270    /// Consumes the oldest persisted entry on disk, if one exists.
271    ///
272    /// # Errors
273    ///
274    /// If there is an error reading or deserializing the entry, an error is returned.
275    pub async fn pop(&mut self) -> Result<Option<T>, GenericError> {
276        loop {
277            if self.entries.is_empty() {
278                return Ok(None);
279            }
280
281            let entry = self.entries.remove(0);
282            match try_deserialize_entry(&entry).await {
283                Ok(Some(deserialized)) => {
284                    // We got the deserialized entry, so remove it from our state and return it.
285                    self.total_on_disk_bytes -= entry.size_bytes;
286                    debug!(entry.len = entry.size_bytes, "Dequeued persisted entry.");
287
288                    return Ok(Some(deserialized));
289                }
290                Ok(None) => {
291                    // We couldn't read the entry from disk, which points to us potentially having invalid state about
292                    // what entries _are_ on disk, so we'll refresh our entry state and try again.
293                    self.refresh_entry_state().await?;
294                    continue;
295                }
296                Err(e) => {
297                    // The entry is corrupt or unreadable. Drop it permanently to avoid a poison pill scenario
298                    // where the same entry is retried indefinitely, blocking all other work.
299                    warn!(
300                        entry.path = %entry.path.display(),
301                        entry.len = entry.size_bytes,
302                        error = %e,
303                        "Permanently dropping persisted entry that could not be consumed.",
304                    );
305
306                    self.total_on_disk_bytes -= entry.size_bytes;
307                    self.entries_dropped += 1;
308
309                    // Poison-drop: a corrupt/torn on-disk entry is dropped so it can't wedge recovery forever. Anchor
310                    // that recovery continues past it rather than aborting.
311                    saluki_antithesis::sometimes!(true, "corrupt persisted retry entry dropped, recovery continues");
312
313                    continue;
314                }
315            }
316        }
317    }
318
319    async fn refresh_entry_state(&mut self) -> io::Result<()> {
320        // Scan the root path for persisted entries.
321        let mut entries = Vec::new();
322
323        let mut dir_reader = tokio::fs::read_dir(&self.root_path).await?;
324        while let Some(entry) = dir_reader.next_entry().await? {
325            let metadata = entry.metadata().await?;
326            if metadata.is_file() {
327                match PersistedEntry::try_from_path(entry.path(), metadata.len()) {
328                    Some(entry) => entries.push(entry),
329                    None => {
330                        warn!(
331                            file_size = metadata.len(),
332                            "Ignoring unrecognized file '{}' in retry directory.",
333                            entry.path().display()
334                        );
335                        continue;
336                    }
337                }
338            }
339        }
340
341        // Sort the entries by their inherent timestamp.
342        entries.sort_by_key(|entry| entry.timestamp);
343        self.total_on_disk_bytes = entries.iter().map(|entry| entry.size_bytes).sum();
344        self.entries = entries;
345
346        Ok(())
347    }
348
349    /// Removes persisted entries (oldest first) until there is at least the required number of bytes in free space
350    /// (maximum - total).
351    ///
352    /// # Errors
353    ///
354    /// If there is an error while deleting persisted entries, an error is returned.
355    async fn remove_until_available_space(&mut self, required_bytes: u64) -> Result<PushResult, GenericError> {
356        let mut push_result = PushResult::default();
357
358        let disk_usage_retriever = self.disk_usage_retriever.clone();
359        let storage_max_disk_ratio = self.storage_max_disk_ratio;
360        let max_on_disk_bytes = self.max_on_disk_bytes;
361
362        // TODO: Evaluate the possible failures scenarios a little more thoroughly, and see if we can improve
363        // how we handle them instead of just bailing out.
364        //
365        // Essentially, it's not clear to me if we would expect this to fail in a way where we could actually
366        // still write the persistent entries to disk, and if it's worth it to do something like trying to
367        // cache the last known good value we get here to use if we fail to get a new value, etc.
368        let limit = tokio::task::spawn_blocking(move || {
369            on_disk_bytes_limit(disk_usage_retriever, storage_max_disk_ratio, max_on_disk_bytes)
370        })
371        .await
372        .error_context("Failed to run disk size limit check to completion.")??;
373
374        while !self.entries.is_empty() && self.total_on_disk_bytes + required_bytes > limit {
375            let entry = self.entries.remove(0);
376
377            // Deserialize the entry, which gives us back the original event and removes the file from disk.
378            let deserialized = match try_deserialize_entry::<T>(&entry).await {
379                Ok(Some(deserialized)) => deserialized,
380                Ok(None) => {
381                    warn!(entry.path = %entry.path.display(), "Failed to find entry on disk. Persisted entry state may be inconsistent.");
382                    continue;
383                }
384                Err(e) => {
385                    // The entry is corrupt or unreadable. Drop it permanently to avoid blocking subsequent
386                    // entries from being evicted.
387                    warn!(
388                        entry.path = %entry.path.display(),
389                        entry.len = entry.size_bytes,
390                        error = %e,
391                        "Permanently dropping persisted entry that could not be consumed during eviction.",
392                    );
393
394                    self.total_on_disk_bytes -= entry.size_bytes;
395                    self.entries_dropped += 1;
396
397                    // Poison-drop: a corrupt/torn on-disk entry is dropped so it can't wedge recovery forever. Anchor
398                    // that recovery continues past it rather than aborting.
399                    saluki_antithesis::sometimes!(true, "corrupt persisted retry entry dropped, recovery continues");
400
401                    continue;
402                }
403            };
404
405            // Update our statistics.
406            self.total_on_disk_bytes -= entry.size_bytes;
407            push_result.track_dropped_item(&deserialized);
408
409            warn!(entry.path = %entry.path.display(), entry.len = entry.size_bytes, "Dropped persisted entry.");
410        }
411
412        Ok(push_result)
413    }
414}
415
416/// Determines the total number of bytes that can be written to disk without causing the underlying volume to end up
417/// with more than `storage_max_disk_ratio` in terms of used space. The minimum of `max_on_disk_bytes` and the result
418/// of this calculation is returned.
419///
420/// # Errors
421///
422/// If there is an error while retrieving the total or available space of the underlying volume, an error is returned.
423fn on_disk_bytes_limit(
424    disk_usage_retriever: DiskUsageRetrieverWrapper, storage_max_disk_ratio: f64, max_on_disk_bytes: u64,
425) -> Result<u64, GenericError> {
426    let total_space = disk_usage_retriever.inner.total_space()? as f64;
427    let available_space = disk_usage_retriever.inner.available_space()? as f64;
428    let disk_reserved = total_space * (1.0 - storage_max_disk_ratio);
429    let available_disk_usage = (available_space - disk_reserved).ceil() as u64;
430    Ok(max_on_disk_bytes.min(available_disk_usage))
431}
432
433async fn try_deserialize_entry<T: DeserializeOwned>(entry: &PersistedEntry) -> Result<Option<T>, GenericError> {
434    let serialized = match tokio::fs::read(&entry.path).await {
435        Ok(serialized) => serialized,
436        Err(e) => match e.kind() {
437            io::ErrorKind::NotFound => {
438                // We tried to delete an entry that no longer exists on disk, which means our internal entry state
439                // is corrupted for some reason.
440                //
441                // Tell the caller that we couldn't find the entry on disk, so that they need to refresh the entry state
442                // to make sure it's up-to-date before trying again.
443                return Ok(None);
444            }
445            _ => {
446                return Err(e)
447                    .with_error_context(|| format!("Failed to read persisted entry '{}'.", entry.path.display()))
448            }
449        },
450    };
451
452    let deserialized = match serde_json::from_slice(&serialized) {
453        Ok(deserialized) => deserialized,
454        Err(e) => {
455            // Deserialization failed, which means the payload is corrupt or invalid. Attempt to clean up the
456            // file from disk so it doesn't accumulate, but don't fail if we can't.
457            if let Err(remove_err) = tokio::fs::remove_file(&entry.path).await {
458                warn!(
459                    entry.path = %entry.path.display(),
460                    error = %remove_err,
461                    "Failed to remove corrupt persisted entry from disk.",
462                );
463            }
464
465            return Err(e)
466                .with_error_context(|| format!("Failed to deserialize persisted entry '{}'.", entry.path.display()));
467        }
468    };
469
470    // Delete the entry from disk before returning, so that we don't risk sending duplicates.
471    tokio::fs::remove_file(&entry.path)
472        .await
473        .with_error_context(|| format!("Failed to delete persisted entry '{}'.", entry.path.display()))?;
474
475    debug!(entry.path = %entry.path.display(), entry.len = entry.size_bytes, "Consumed persisted entry and removed from disk.");
476    Ok(Some(deserialized))
477}
478
479fn generate_timestamped_filename() -> (PathBuf, u128) {
480    let now = Utc::now();
481    let now_ts = datetime_to_timestamp(now);
482    let nonce = rand::rng().random_range(100000000..999999999);
483
484    let filename = format!("retry-{}-{}.json", now.format("%Y%m%d%H%M%S%f"), nonce).into();
485
486    (filename, now_ts)
487}
488
489fn decode_timestamped_filename(path: &Path) -> Option<u128> {
490    let filename = path.file_stem()?.to_str()?;
491    let mut filename_parts = filename.split('-');
492
493    let prefix = filename_parts.next()?;
494    let timestamp_str = filename_parts.next()?;
495    let nonce = filename_parts.next()?;
496
497    // Make sure the filename matches our expected format by first checking the prefix and nonce portions.
498    if prefix != "retry" || nonce.parse::<u64>().is_err() {
499        return None;
500    }
501
502    // Try and decode the timestamp portion.
503    NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M%S%f")
504        .map(|dt| datetime_to_timestamp(dt.and_utc()))
505        .ok()
506}
507
508fn datetime_to_timestamp(dt: DateTime<Utc>) -> u128 {
509    let secs = (dt.timestamp() as u128) * 1_000_000_000;
510    let ns = dt.timestamp_subsec_nanos() as u128;
511
512    secs + ns
513}
514
515async fn create_directory_recursive(path: PathBuf) -> Result<(), GenericError> {
516    let mut dir_builder = std::fs::DirBuilder::new();
517    dir_builder.recursive(true);
518
519    // When on Unix platforms, adjust the permissions of the directory to be RWX for the owner only, and nothing for
520    // group/world.
521    #[cfg(unix)]
522    {
523        use std::os::unix::fs::DirBuilderExt;
524        dir_builder.mode(0o700);
525    }
526
527    tokio::task::spawn_blocking(move || {
528        dir_builder
529            .create(&path)
530            .with_error_context(|| format!("Failed to create directory '{}'.", path.display()))
531    })
532    .await
533    .error_context("Failed to spawn directory creation blocking task.")?
534}
535
536/// Deletes files in `queue_path` whose filename-embedded creation timestamp is older than
537/// `max_age_days`. Does nothing if the directory does not exist.
538///
539/// Setting `max_age_days` to `0` deletes all retry files (cutoff = now), matching the behavior
540/// of the core Agent's `FileRemovalPolicy` with `outdatedFileDayCount = 0`.
541async fn remove_outdated_retry_files(queue_path: &Path, max_age_days: u32) -> Result<u32, GenericError> {
542    let mut dir = match tokio::fs::read_dir(queue_path).await {
543        Ok(d) => d,
544        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
545        Err(e) => {
546            return Err(e).with_error_context(|| {
547                format!(
548                    "Failed to open retry queue directory '{}' for age-based cleanup.",
549                    queue_path.display()
550                )
551            });
552        }
553    };
554    let now_ns = std::time::SystemTime::now()
555        .duration_since(std::time::UNIX_EPOCH)
556        .unwrap_or_default() // clock before epoch: treat cutoff as 0, skipping all deletions
557        .as_nanos();
558    let cutoff_ns = now_ns.saturating_sub(max_age_days as u128 * 24 * 3600 * 1_000_000_000);
559    let mut removed = 0u32;
560    loop {
561        let entry = match dir.next_entry().await {
562            Ok(Some(e)) => e,
563            Ok(None) => break,
564            Err(e) => {
565                return Err(e).with_error_context(|| "Error reading retry queue directory during age-based cleanup.");
566            }
567        };
568        let file_ts = match decode_timestamped_filename(&entry.path()) {
569            Some(ts) => ts,
570            None => continue,
571        };
572        if file_ts < cutoff_ns {
573            let name_str = entry.file_name();
574            let name = name_str.to_string_lossy();
575            match tokio::fs::remove_file(entry.path()).await {
576                Ok(()) => {
577                    debug!(file = %name, "Removed outdated retry file.");
578                    removed += 1;
579                }
580                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
581                    debug!(file = %name, "Retry file already removed by concurrent cleanup.");
582                }
583                Err(e) => {
584                    warn!(file = %name, error = %e, "Failed to remove outdated retry file.");
585                }
586            }
587        }
588    }
589    Ok(removed)
590}
591
592#[cfg(test)]
593mod tests {
594    use rand::RngExt as _;
595    use rand_distr::Alphanumeric;
596    use serde::Deserialize;
597
598    use super::*;
599
600    #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
601    struct FakeData {
602        name: String,
603        value: u32,
604    }
605
606    impl FakeData {
607        fn random() -> Self {
608            Self {
609                name: rand::rng().sample_iter(&Alphanumeric).take(8).map(char::from).collect(),
610                value: rand::rng().random_range(0..100),
611            }
612        }
613    }
614
615    impl EventContainer for FakeData {
616        fn event_count(&self) -> u64 {
617            1
618        }
619    }
620
621    struct MockDiskUsageRetriever {}
622
623    impl DiskUsageRetriever for MockDiskUsageRetriever {
624        fn total_space(&self) -> Result<u64, GenericError> {
625            Ok(100)
626        }
627        fn available_space(&self) -> Result<u64, GenericError> {
628            Ok(100)
629        }
630    }
631
632    async fn files_in_dir(path: &Path) -> usize {
633        let mut file_count = 0;
634        let mut dir_reader = tokio::fs::read_dir(path).await.unwrap();
635        while let Some(entry) = dir_reader.next_entry().await.unwrap() {
636            if entry.metadata().await.unwrap().is_file() {
637                file_count += 1;
638            }
639        }
640        file_count
641    }
642
643    #[tokio::test]
644    async fn basic_push_pop() {
645        let data = FakeData::random();
646
647        // Create our temporary directory and point our persisted queue at it.
648        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
649        let root_path = temp_dir.path().to_path_buf();
650
651        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
652            root_path: root_path.clone(),
653            max_on_disk_bytes: 1024,
654            storage_max_disk_ratio: 0.8,
655            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
656            max_age_days: 10,
657        })
658        .await
659        .expect("should not fail to create persisted queue");
660
661        // Ensure the directory is empty.
662        assert_eq!(0, files_in_dir(&root_path).await);
663
664        // Push our data to the queue and ensure it persisted it to disk.
665        let push_result = persisted_queue
666            .push(data.clone())
667            .await
668            .expect("should not fail to push data");
669        assert_eq!(1, files_in_dir(&root_path).await);
670        assert_eq!(0, push_result.items_dropped);
671        assert_eq!(0, push_result.events_dropped);
672
673        // Now pop the data back out and ensure it matches what we pushed, and that the file has been removed from disk.
674        let actual = persisted_queue
675            .pop()
676            .await
677            .expect("should not fail to pop data")
678            .expect("should not be empty");
679        assert_eq!(data, actual);
680        assert_eq!(0, files_in_dir(&root_path).await);
681    }
682
683    #[tokio::test]
684    async fn entry_too_large() {
685        let data = FakeData::random();
686
687        // Create our temporary directory and point our persisted queue at it.
688        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
689        let root_path = temp_dir.path().to_path_buf();
690
691        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
692            root_path: root_path.clone(),
693            max_on_disk_bytes: 1,
694            storage_max_disk_ratio: 0.8,
695            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
696            max_age_days: 10,
697        })
698        .await
699        .expect("should not fail to create persisted queue");
700
701        // Ensure the directory is empty.
702        assert_eq!(0, files_in_dir(&root_path).await);
703
704        // Attempt to push our data into the queue, which should fail because it's too large.
705        assert!(persisted_queue.push(data).await.is_err());
706
707        // Ensure the directory is (still) empty.
708        assert_eq!(0, files_in_dir(&root_path).await);
709    }
710
711    #[tokio::test]
712    async fn remove_oldest_entry_on_push() {
713        let data1 = FakeData::random();
714        let data2 = FakeData::random();
715
716        // Create our temporary directory and point our persisted queue at it.
717        //
718        // Our queue is sized such that only one entry can be persisted at a time.
719        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
720        let root_path = temp_dir.path().to_path_buf();
721
722        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
723            root_path: root_path.clone(),
724            max_on_disk_bytes: 32,
725            storage_max_disk_ratio: 0.8,
726            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
727            max_age_days: 10,
728        })
729        .await
730        .expect("should not fail to create persisted queue");
731
732        // Ensure the directory is empty.
733        assert_eq!(0, files_in_dir(&root_path).await);
734
735        // Push our data to the queue and ensure it persisted it to disk.
736        let push_result = persisted_queue.push(data1).await.expect("should not fail to push data");
737        assert_eq!(1, files_in_dir(&root_path).await);
738        assert_eq!(0, push_result.items_dropped);
739        assert_eq!(0, push_result.events_dropped);
740
741        // Push a second data entry, which should cause the first entry to be removed.
742        let push_result = persisted_queue
743            .push(data2.clone())
744            .await
745            .expect("should not fail to push data");
746        assert_eq!(1, files_in_dir(&root_path).await);
747        assert_eq!(1, push_result.items_dropped);
748        assert_eq!(1, push_result.events_dropped);
749
750        // Now pop the data back out and ensure it matches the second item we pushed -- indicating the first item was
751        // removed -- and that we've consumed it, leaving no files on disk.
752        let actual = persisted_queue
753            .pop()
754            .await
755            .expect("should not fail to pop data")
756            .expect("should not be empty");
757        assert_eq!(data2, actual);
758        assert_eq!(0, files_in_dir(&root_path).await);
759    }
760
761    #[tokio::test]
762    async fn storage_ratio_exceeded() {
763        let data1 = FakeData::random();
764        let data2 = FakeData::random();
765
766        // Create our temporary directory and point our persisted queue at it.
767        //
768        // Our queue is sized such that two entries can be persisted at a time.
769        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
770        let root_path = temp_dir.path().to_path_buf();
771
772        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
773            root_path: root_path.clone(),
774            max_on_disk_bytes: 80,
775            storage_max_disk_ratio: 0.35,
776            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
777            max_age_days: 10,
778        })
779        .await
780        .expect("should not fail to create persisted queue");
781
782        // Ensure the directory is empty.
783        assert_eq!(0, files_in_dir(&root_path).await);
784
785        // The `storage_max_disk_ratio` is 0.35, and our `MockDiskUsageRetriever` returns 100 for both `total_space` and
786        // `available_space`, so `on_disk_bytes_limit()` returns min(80, 35) = 35.
787        //
788        // First entry: total_on_disk_bytes(0) + required_bytes(30) < on_disk_bytes_limit(35)
789        let push_result = persisted_queue.push(data1).await.expect("should not fail to push data");
790
791        assert_eq!(1, files_in_dir(&root_path).await);
792        assert_eq!(0, push_result.items_dropped);
793        assert_eq!(0, push_result.events_dropped);
794
795        // Second entry: total_on_disk_bytes(30) + required_bytes(30) > on_disk_bytes_limit(35) so the first entry is dropped.
796        let push_result = persisted_queue
797            .push(data2.clone())
798            .await
799            .expect("should not fail to push data");
800        assert_eq!(1, files_in_dir(&root_path).await);
801        assert_eq!(1, push_result.items_dropped);
802        assert_eq!(1, push_result.events_dropped);
803
804        // Now pop the data back out and ensure it matches the second item we pushed -- indicating the first item was
805        // removed -- and that we've consumed it, leaving no files on disk.
806        let actual = persisted_queue
807            .pop()
808            .await
809            .expect("should not fail to pop data")
810            .expect("should not be empty");
811        assert_eq!(data2, actual);
812        assert_eq!(0, files_in_dir(&root_path).await);
813    }
814
815    /// Writes a corrupt (non-JSON) file with a valid retry filename to the given directory, using a timestamp
816    /// that sorts before any real entries (so it will be popped first).
817    async fn write_corrupt_entry(dir: &Path) -> PathBuf {
818        let filename = "retry-20000101000000000000-100000000.json";
819        let path = dir.join(filename);
820        tokio::fs::write(&path, b"this is not valid json").await.unwrap();
821        path
822    }
823
824    #[tokio::test]
825    async fn corrupt_entry_is_skipped_on_pop() {
826        let data = FakeData::random();
827
828        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
829        let root_path = temp_dir.path().to_path_buf();
830
831        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
832            root_path: root_path.clone(),
833            max_on_disk_bytes: 1024,
834            storage_max_disk_ratio: 0.8,
835            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
836            max_age_days: 10,
837        })
838        .await
839        .expect("should not fail to create persisted queue");
840
841        // Write a corrupt file before pushing valid data, so it sorts first.
842        let corrupt_path = write_corrupt_entry(&root_path).await;
843
844        // Push a valid entry.
845        let _ = persisted_queue
846            .push(data.clone())
847            .await
848            .expect("should not fail to push data");
849
850        // Refresh state so the queue picks up the corrupt file.
851        persisted_queue.refresh_entry_state().await.unwrap();
852
853        // Pop should skip the corrupt entry and return the valid one.
854        let actual = persisted_queue
855            .pop()
856            .await
857            .expect("should not fail to pop data")
858            .expect("should have a valid entry");
859        assert_eq!(data, actual);
860
861        // The corrupt file should have been cleaned up from disk.
862        assert!(!corrupt_path.exists());
863
864        // The dropped counter should reflect the corrupt entry.
865        assert_eq!(1, persisted_queue.take_entries_dropped());
866
867        // No files should remain.
868        assert_eq!(0, files_in_dir(&root_path).await);
869    }
870
871    #[tokio::test]
872    async fn corrupt_entry_does_not_block_queue() {
873        let data1 = FakeData::random();
874        let data2 = FakeData::random();
875
876        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
877        let root_path = temp_dir.path().to_path_buf();
878
879        // Use MockDiskUsageRetriever to avoid disk space ratio causing eviction during push.
880        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
881            root_path: root_path.clone(),
882            max_on_disk_bytes: 1024,
883            storage_max_disk_ratio: 0.8,
884            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
885            max_age_days: 10,
886        })
887        .await
888        .expect("should not fail to create persisted queue");
889
890        // Push two valid entries, then corrupt the first one on disk.
891        let _ = persisted_queue.push(data1).await.expect("should not fail to push data");
892        let _ = persisted_queue
893            .push(data2.clone())
894            .await
895            .expect("should not fail to push data");
896        assert_eq!(2, persisted_queue.entries.len());
897
898        // Corrupt the oldest entry file on disk.
899        let oldest_path = persisted_queue.entries[0].path.clone();
900        tokio::fs::write(&oldest_path, b"corrupted").await.unwrap();
901
902        // Pop should skip the corrupt entry and return the second valid one.
903        let actual = persisted_queue
904            .pop()
905            .await
906            .expect("should not fail to pop data")
907            .expect("should have a valid entry");
908        assert_eq!(data2, actual);
909
910        assert_eq!(1, persisted_queue.take_entries_dropped());
911        assert_eq!(0, files_in_dir(&root_path).await);
912    }
913
914    #[tokio::test]
915    async fn pop_returns_none_when_all_entries_corrupt() {
916        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
917        let root_path = temp_dir.path().to_path_buf();
918
919        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
920            root_path: root_path.clone(),
921            max_on_disk_bytes: 1024,
922            storage_max_disk_ratio: 0.8,
923            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
924            max_age_days: 10,
925        })
926        .await
927        .expect("should not fail to create persisted queue");
928
929        // Write a corrupt entry and refresh state.
930        write_corrupt_entry(&root_path).await;
931        persisted_queue.refresh_entry_state().await.unwrap();
932
933        // Pop should skip the corrupt entry and return None (no valid entries).
934        let result = persisted_queue.pop().await.expect("should not fail to pop data");
935        assert!(result.is_none());
936
937        assert_eq!(1, persisted_queue.take_entries_dropped());
938        assert_eq!(0, files_in_dir(&root_path).await);
939    }
940
941    #[tokio::test]
942    async fn corrupt_entry_dropped_during_eviction() {
943        let data = FakeData::random();
944
945        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
946        let root_path = temp_dir.path().to_path_buf();
947
948        // Queue sized to hold only one entry.
949        let mut persisted_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
950            root_path: root_path.clone(),
951            max_on_disk_bytes: 32,
952            storage_max_disk_ratio: 0.8,
953            disk_usage_retriever: Arc::new(MockDiskUsageRetriever {}),
954            max_age_days: 10,
955        })
956        .await
957        .expect("should not fail to create persisted queue");
958
959        // Push a valid entry, then corrupt it on disk.
960        let _ = persisted_queue
961            .push(FakeData::random())
962            .await
963            .expect("should not fail to push data");
964        let first_path = persisted_queue.entries[0].path.clone();
965        tokio::fs::write(&first_path, b"corrupted").await.unwrap();
966
967        // Push another entry, which needs to evict the first (corrupt) one to make space.
968        // This should succeed without error -- the corrupt entry is dropped during eviction.
969        let _ = persisted_queue
970            .push(data.clone())
971            .await
972            .expect("should not fail to push data");
973
974        // The corrupt entry was dropped during eviction, not via normal eviction tracking.
975        assert_eq!(1, persisted_queue.take_entries_dropped());
976
977        // The valid entry should be poppable.
978        let actual = persisted_queue
979            .pop()
980            .await
981            .expect("should not fail to pop data")
982            .expect("should have a valid entry");
983        assert_eq!(data, actual);
984        assert_eq!(0, files_in_dir(&root_path).await);
985    }
986
987    #[tokio::test]
988    async fn persisted_queue_removes_outdated_files_on_initialization() {
989        let data = FakeData::random();
990
991        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
992        let root_path = temp_dir.path().to_path_buf();
993
994        // Pre-seed a year-2000 retry file containing valid data that would be loaded as an entry
995        // if it were not cleaned up first.
996        let stale_content = serde_json::to_vec(&data).unwrap();
997        tokio::fs::write(
998            root_path.join("retry-20000101000000000000000-100000000.json"),
999            &stale_content,
1000        )
1001        .await
1002        .unwrap();
1003
1004        assert_eq!(1, files_in_dir(&root_path).await);
1005
1006        // Initialize the queue and remove stale files with a 10-day age limit.
1007        let mut queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
1008            root_path: root_path.clone(),
1009            max_on_disk_bytes: 1024 * 1024,
1010            storage_max_disk_ratio: 0.8,
1011            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
1012            max_age_days: 10,
1013        })
1014        .await
1015        .expect("should not fail to create persisted queue");
1016        queue
1017            .remove_stale_files()
1018            .await
1019            .expect("should not fail to remove stale files");
1020
1021        assert_eq!(0, files_in_dir(&root_path).await);
1022        assert!(queue.is_empty());
1023    }
1024
1025    #[tokio::test]
1026    async fn persisted_queue_zero_age_removes_all_retry_files_on_initialization() {
1027        // max_age_days=0 sets cutoff=now, matching the core Agent's FileRemovalPolicy behavior
1028        // with outdatedFileDayCount=0 — all retry files are removed on startup.
1029        let data = FakeData::random();
1030
1031        let temp_dir = tempfile::tempdir().expect("should not fail to create temporary directory");
1032        let root_path = temp_dir.path().to_path_buf();
1033
1034        // Seed with a freshly-written entry via a normal queue.
1035        let mut seeding_queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
1036            root_path: root_path.clone(),
1037            max_on_disk_bytes: 1024 * 1024,
1038            storage_max_disk_ratio: 0.8,
1039            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
1040            max_age_days: 10,
1041        })
1042        .await
1043        .expect("should not fail to create persisted queue");
1044        let _ = seeding_queue.push(data).await.expect("should not fail to push data");
1045        assert_eq!(1, files_in_dir(&root_path).await);
1046
1047        // Re-open and remove stale files with max_age_days=0: the just-written file must also be deleted.
1048        let mut queue = PersistedQueue::<FakeData>::from_root_path(PersistedQueueArgs {
1049            root_path: root_path.clone(),
1050            max_on_disk_bytes: 1024 * 1024,
1051            storage_max_disk_ratio: 0.8,
1052            disk_usage_retriever: Arc::new(DiskUsageRetrieverImpl::new(root_path.clone())),
1053            max_age_days: 0,
1054        })
1055        .await
1056        .expect("should not fail to create persisted queue");
1057        queue
1058            .remove_stale_files()
1059            .await
1060            .expect("should not fail to remove stale files");
1061
1062        assert_eq!(0, files_in_dir(&root_path).await);
1063        assert!(queue.is_empty());
1064    }
1065}