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