Skip to main content

rustfs_targets/
store.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::error::StoreError;
16use rustfs_config::notify::{COMPRESS_EXT, DEFAULT_EXT};
17use rustfs_config::{DEFAULT_LIMIT, DEFAULT_TARGET_STORE_COMPRESS, ENV_TARGET_STORE_COMPRESS, EnableState};
18use serde::{Serialize, de::DeserializeOwned};
19use snap::raw::{Decoder, Encoder};
20use std::{
21    collections::HashMap,
22    fs::File,
23    io::Write,
24    marker::PhantomData,
25    path::{Path, PathBuf},
26    sync::{
27        Arc, Mutex, RwLock,
28        atomic::{AtomicU64, Ordering},
29    },
30    time::{Duration, SystemTime, UNIX_EPOCH},
31};
32use tracing::{debug, warn};
33use uuid::Uuid;
34
35const LOG_COMPONENT_TARGETS: &str = "targets";
36const LOG_SUBSYSTEM_STORE: &str = "store";
37const EVENT_TARGET_STORE_STATE: &str = "target_store_state";
38
39/// Suffix used for the temporary file of an in-progress atomic write. A crash
40/// between `File::create` and `rename` leaves one of these behind; `open()`
41/// removes them so they are never mistaken for committed queue entries.
42const TMP_SUFFIX: &str = ".tmp";
43
44/// Upper bound applied to the initial `HashMap`/`Vec` capacities derived from
45/// untrusted inputs (`entry_limit`, batch `item_count`). Growth is still lazy,
46/// so a huge configured limit or a malicious/corrupt filename can no longer
47/// trigger a giant up-front allocation or a capacity-overflow panic.
48const MAX_PREALLOC_CAPACITY: usize = 4096;
49
50/// Returns true if `file_name` looks like a committed queue entry for `file_ext`.
51///
52/// A committed entry is `<...><file_ext>` optionally followed by [`COMPRESS_EXT`]
53/// (e.g. `<uuid>.event` or `3:<uuid>.event.snappy`). Any other file in the queue
54/// directory (foreign files, leftover temp files) is ignored so it can never be
55/// indexed and replayed as if it were a real event.
56fn is_queue_file_name(file_name: &str, file_ext: &str) -> bool {
57    if file_ext.is_empty() {
58        return false;
59    }
60    let base = file_name.strip_suffix(COMPRESS_EXT).unwrap_or(file_name);
61    base.ends_with(file_ext)
62}
63
64/// Separator between the batch item count and the entry name in an on-disk batch filename. It must
65/// stay outside the Windows reserved filename set so File::create succeeds on NTFS, and outside the
66/// UUID alphabet so the count splits unambiguously when the name is parsed back.
67const BATCH_COUNT_SEPARATOR: char = '_';
68
69/// Legacy separator for the batch item count. Entries written with it are read back for
70/// compatibility, but it is reserved on NTFS so it is never written.
71const LEGACY_BATCH_COUNT_SEPARATOR: char = ':';
72
73/// Name of the child directory inside a target queue directory that holds events which failed
74/// terminally. It is created lazily on the first failed write, so a target that never fails
75/// terminally creates no such directory.
76const FAILED_STORE_SUBDIR: &str = "failed";
77
78/// Maximum number of entries retained in the failed store per target, bounded independently of the
79/// live queue so an accumulation of terminal failures cannot starve live events. At the bound the
80/// oldest failed entry is dropped to admit the newer one, so a newer failure is never lost in favour
81/// of an older one.
82const FAILED_STORE_MAX_ENTRIES: usize = 10_000;
83
84/// Maximum age of a failed-store entry before it is removed as expired, measured from the file write
85/// time, which is the instant the entry entered the failed store.
86const FAILED_STORE_TTL: Duration = Duration::from_secs(72 * 60 * 60);
87
88/// Writes payload to a temp file in the same directory, flushes the file to disk, then atomically
89/// renames it onto final_path.
90///
91/// rename within a single directory is atomic on the supported filesystems, so a crash leaves
92/// either final_path absent, the prior final_path, or the complete payload, never a partial file.
93/// On failure the temp file is removed so an interrupted write leaves nothing behind.
94fn write_temp_then_rename(temp_path: &Path, final_path: &Path, payload: &[u8]) -> Result<(), StoreError> {
95    if let Err(err) = write_and_sync_temp(temp_path, payload) {
96        let _ = std::fs::remove_file(temp_path);
97        return Err(err);
98    }
99
100    if let Err(err) = std::fs::rename(temp_path, final_path) {
101        let _ = std::fs::remove_file(temp_path);
102        return Err(StoreError::Io(err));
103    }
104
105    Ok(())
106}
107
108/// Writes payload to path and flushes the file to disk before returning, so the bytes are durable
109/// before the caller renames the file into place.
110fn write_and_sync_temp(path: &Path, payload: &[u8]) -> Result<(), StoreError> {
111    let mut file = std::fs::File::create(path).map_err(StoreError::Io)?;
112    file.write_all(payload).map_err(StoreError::Io)?;
113    file.sync_all().map_err(StoreError::Io)?;
114    Ok(())
115}
116
117fn resolve_queue_store_compression_from_env_value(value: Option<&str>) -> bool {
118    value
119        .and_then(|value| value.parse::<EnableState>().ok().map(|state| state.is_enabled()))
120        .unwrap_or(DEFAULT_TARGET_STORE_COMPRESS)
121}
122
123fn queue_store_compression_enabled() -> bool {
124    let value = std::env::var(ENV_TARGET_STORE_COMPRESS).ok();
125    resolve_queue_store_compression_from_env_value(value.as_deref())
126}
127
128/// Represents a key for an entry in the store
129#[derive(Debug, Clone)]
130pub struct Key {
131    /// The name of the key (UUID)
132    pub name: String,
133    /// The file extension for the entry
134    pub extension: String,
135    /// The number of items in the entry (for batch storage)
136    pub item_count: usize,
137    /// Whether the entry is compressed
138    pub compress: bool,
139}
140
141impl Key {
142    /// Converts the key to a string (filename)
143    pub fn to_key_string(&self) -> String {
144        self.to_key_string_with(BATCH_COUNT_SEPARATOR)
145    }
146
147    /// Builds the filename using the given batch count separator.
148    fn to_key_string_with(&self, separator: char) -> String {
149        let name_part = if self.item_count > 1 {
150            format!("{}{separator}{}", self.item_count, self.name)
151        } else {
152            self.name.clone()
153        };
154
155        let mut file_name = name_part;
156        if !self.extension.is_empty() {
157            file_name.push_str(&self.extension);
158        }
159
160        if self.compress {
161            file_name.push_str(COMPRESS_EXT);
162        }
163        file_name
164    }
165}
166
167impl std::fmt::Display for Key {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.write_str(&self.to_key_string())
170    }
171}
172
173/// Parses a string into a Key
174pub fn parse_key(s: &str) -> Key {
175    debug!(
176        event = EVENT_TARGET_STORE_STATE,
177        component = LOG_COMPONENT_TARGETS,
178        subsystem = LOG_SUBSYSTEM_STORE,
179        action = "parse_key",
180        key = %s,
181        "target store state"
182    );
183
184    let mut name = s.to_string();
185    let mut extension = String::new();
186    let mut item_count = 1;
187    let mut compress = false;
188
189    // Check for compressed suffixes
190    if name.ends_with(COMPRESS_EXT) {
191        compress = true;
192        name = name[..name.len() - COMPRESS_EXT.len()].to_string();
193    }
194
195    // Number of batch items parsed. The current separator and the legacy one are both accepted so an
196    // entry written before the switch to the Windows-safe separator still reads back. The count is
197    // stripped only when it exceeds one, mirroring the render side which prefixes the count only for a
198    // real batch, so a hostile 1_ or 0_ prefix on a single-item name stays part of the name.
199    if let Some(separator_pos) = name.find([BATCH_COUNT_SEPARATOR, LEGACY_BATCH_COUNT_SEPARATOR])
200        && let Ok(count) = name[..separator_pos].parse::<usize>()
201        && count > 1
202    {
203        item_count = count;
204        name = name[separator_pos + 1..].to_string();
205    }
206
207    // Resolve extension
208    if let Some(dot_pos) = name.rfind('.') {
209        extension = name[dot_pos..].to_string();
210        name = name[..dot_pos].to_string();
211    }
212
213    debug!(
214        event = EVENT_TARGET_STORE_STATE,
215        component = LOG_COMPONENT_TARGETS,
216        subsystem = LOG_SUBSYSTEM_STORE,
217        action = "parse_key",
218        result = "parsed",
219        key_name = %name,
220        extension = %extension,
221        item_count,
222        compressed = compress,
223        "target store state"
224    );
225
226    Key {
227        name,
228        extension,
229        item_count,
230        compress,
231    }
232}
233
234pub fn ensure_store_entry_raw_readable<T>(
235    store: &(dyn Store<T, Error = StoreError, Key = Key> + Send),
236    key: &Key,
237) -> Result<bool, StoreError>
238where
239    T: Send + Sync + 'static + Clone + Serialize,
240{
241    match store.get_raw(key) {
242        Ok(_) => Ok(true),
243        Err(StoreError::NotFound) => Ok(false),
244        Err(err) => {
245            match store.del(key) {
246                Ok(()) | Err(StoreError::NotFound) => {}
247                Err(del_err) => {
248                    return Err(StoreError::Internal(format!("Failed to remove unreadable store entry {key}: {del_err}")));
249                }
250            }
251            Err(err)
252        }
253    }
254}
255
256/// Trait for a store that can store and retrieve items of type T
257pub trait Store<T>: Send + Sync
258where
259    T: Send + Sync + 'static + Clone + Serialize,
260{
261    /// The error type for the store
262    type Error;
263    /// The key type for the store
264    type Key;
265
266    /// Opens the store
267    fn open(&self) -> Result<(), Self::Error>;
268
269    /// Stores a single item
270    fn put(&self, item: Arc<T>) -> Result<Self::Key, Self::Error>;
271
272    /// Stores multiple items in a single batch
273    fn put_multiple(&self, items: Vec<T>) -> Result<Self::Key, Self::Error>;
274
275    /// Stores raw bytes in a single entry.
276    fn put_raw(&self, data: &[u8]) -> Result<Self::Key, Self::Error>;
277
278    /// Retrieves a single item by key
279    fn get(&self, key: &Self::Key) -> Result<T, Self::Error>;
280
281    /// Retrieves multiple items by key
282    fn get_multiple(&self, key: &Self::Key) -> Result<Vec<T>, Self::Error>;
283
284    /// Retrieves the raw bytes stored for a key.
285    fn get_raw(&self, key: &Self::Key) -> Result<Vec<u8>, Self::Error>;
286
287    /// Deletes an item by key
288    fn del(&self, key: &Self::Key) -> Result<(), Self::Error>;
289
290    /// Deletes the underlying store directory and clears all in-memory state.
291    fn delete(&self) -> Result<(), Self::Error>;
292
293    /// Lists all keys in the store
294    fn list(&self) -> Vec<Self::Key>;
295
296    /// Returns the number of items in the store
297    fn len(&self) -> usize;
298
299    /// Returns true if the store is empty
300    fn is_empty(&self) -> bool;
301
302    /// Clones the store into a boxed trait object
303    fn boxed_clone(&self) -> Box<dyn Store<T, Error = Self::Error, Key = Self::Key> + Send + Sync>;
304}
305
306/// A failed-events store nested inside the queue directory for terminal entries, kept separate from the generic queue interface
307/// so only the paths that record terminal failures carry the surface.
308///
309/// The failed store holds events that could not be handed off and is bounded independently of the
310/// live queue. At the count bound the oldest failed entry is dropped before a new entry is written, so
311/// a newer failure is never lost in favour of an older one.
312pub trait FailedEventStore: Send + Sync {
313    /// Writes pre-encoded bytes to the failed-events store under the given entry name and returns the
314    /// written entry id.
315    ///
316    /// The write is atomic. The entry name derives from the live entry, so a repeated move of the same
317    /// entry replaces its earlier failed file instead of accumulating duplicates.
318    fn put_failed_raw(&self, entry_name: &str, data: &[u8]) -> Result<String, StoreError>;
319
320    /// Removes failed-store entries older than the retention bound and returns how many were removed.
321    ///
322    /// Runs on the replay maintenance tick, not a separate timer.
323    fn prune_failed_store(&self) -> Result<usize, StoreError>;
324
325    /// Returns the number of entries currently in the failed store.
326    fn failed_len(&self) -> usize;
327
328    /// Clones the capability into an owned boxed handle sharing the same backing state, so the
329    /// maintenance scan can run inside a blocking task without borrowing the target.
330    fn boxed_clone_failed(&self) -> Box<dyn FailedEventStore>;
331}
332
333/// A store that uses the filesystem to persist events in a queue
334pub struct QueueStore<T> {
335    entry_limit: u64,
336    directory: PathBuf,
337    file_ext: String,
338    compress: bool,
339    entries: Arc<RwLock<HashMap<String, i64>>>, // key -> modtime as unix nano
340    pending_entries: Arc<AtomicU64>,
341    /// Cached count of complete failed-store entries, shared across clones. Initialized by one
342    /// directory read in open() and kept current by the failed-store write, the capacity trim, and
343    /// the expired-entry removal, so failed_len() is a plain atomic load rather than a directory scan.
344    failed_count: Arc<AtomicU64>,
345    /// Serializes the failed-store writers and the maintenance scan among themselves. put_failed_raw,
346    /// the capacity trim it runs, and the expiry-plus-reconcile scan take this guard, so the at-bound
347    /// check and the write stay atomic and the scan reconcile cannot overwrite a concurrent write's
348    /// count update. The live queue path never takes it. Shared across clones through the Arc.
349    failed_store_guard: Arc<Mutex<()>>,
350    fs_guard: Arc<RwLock<()>>,
351    _phantom: PhantomData<T>,
352}
353
354impl<T> Clone for QueueStore<T> {
355    fn clone(&self) -> Self {
356        QueueStore {
357            entry_limit: self.entry_limit,
358            directory: self.directory.clone(),
359            file_ext: self.file_ext.clone(),
360            compress: self.compress,
361            entries: Arc::clone(&self.entries),
362            pending_entries: Arc::clone(&self.pending_entries),
363            failed_count: Arc::clone(&self.failed_count),
364            failed_store_guard: Arc::clone(&self.failed_store_guard),
365            fs_guard: Arc::clone(&self.fs_guard),
366            _phantom: PhantomData,
367        }
368    }
369}
370
371struct EntryReservation<'a> {
372    pending_entries: &'a AtomicU64,
373}
374
375impl Drop for EntryReservation<'_> {
376    fn drop(&mut self) {
377        self.pending_entries.fetch_sub(1, Ordering::SeqCst);
378    }
379}
380
381impl<T: Serialize + DeserializeOwned + Send + Sync> QueueStore<T> {
382    /// Creates a new QueueStore
383    pub fn new(directory: impl Into<PathBuf>, limit: u64, ext: &str) -> Self {
384        Self::new_with_compression(directory, limit, ext, queue_store_compression_enabled())
385    }
386
387    /// Creates a new QueueStore with an explicit compression setting.
388    pub fn new_with_compression(directory: impl Into<PathBuf>, limit: u64, ext: &str, compress: bool) -> Self {
389        let file_ext = if ext.is_empty() { DEFAULT_EXT } else { ext };
390        let entry_limit = if limit == 0 { DEFAULT_LIMIT } else { limit };
391
392        QueueStore {
393            directory: directory.into(),
394            entry_limit,
395            file_ext: file_ext.to_string(),
396            compress,
397            entries: Arc::new(RwLock::new(HashMap::with_capacity((entry_limit as usize).min(MAX_PREALLOC_CAPACITY)))),
398            pending_entries: Arc::new(AtomicU64::new(0)),
399            failed_count: Arc::new(AtomicU64::new(0)),
400            failed_store_guard: Arc::new(Mutex::new(())),
401            fs_guard: Arc::new(RwLock::new(())),
402            _phantom: PhantomData,
403        }
404    }
405
406    /// Returns the full path for a key. A batch entry (item_count > 1) may exist on disk under the
407    /// legacy separator, so when the current-separator path is absent and the legacy-separator path
408    /// exists, the legacy path is returned. Reads and deletes then resolve a legacy file while fresh
409    /// writes keep the current separator.
410    fn file_path(&self, key: &Key) -> PathBuf {
411        let path = self.directory.join(key.to_key_string());
412        if key.item_count > 1 && !path.exists() {
413            let legacy = self.directory.join(key.to_key_string_with(LEGACY_BATCH_COUNT_SEPARATOR));
414            if legacy.exists() {
415                return legacy;
416            }
417        }
418        path
419    }
420
421    fn build_key(&self, item_count: usize) -> Key {
422        Key {
423            // UUIDv7 is time-ordered: sorting entries by name reproduces FIFO
424            // enqueue order deterministically and, crucially, identically after
425            // a restart — the ordering is intrinsic to the persisted filename
426            // rather than derived from coarse, clock-dependent file mtimes.
427            name: Uuid::now_v7().to_string(),
428            extension: self.file_ext.clone(),
429            item_count,
430            compress: self.compress,
431        }
432    }
433
434    /// Best-effort `fsync` of the queue directory so a freshly `rename`d entry
435    /// (and its containing directory entry) survives a power loss. Failure is
436    /// logged at debug and not propagated: the data file itself is already
437    /// durably `fsync`ed, and not every filesystem/platform supports directory
438    /// fsync.
439    fn fsync_dir(dir: &Path) {
440        match File::open(dir) {
441            Ok(dir_file) => {
442                if let Err(err) = dir_file.sync_all() {
443                    debug!(
444                        event = EVENT_TARGET_STORE_STATE,
445                        component = LOG_COMPONENT_TARGETS,
446                        subsystem = LOG_SUBSYSTEM_STORE,
447                        action = "fsync_dir",
448                        dir = %dir.display(),
449                        error = %err,
450                        "target store state"
451                    );
452                }
453            }
454            Err(err) => {
455                debug!(
456                    event = EVENT_TARGET_STORE_STATE,
457                    component = LOG_COMPONENT_TARGETS,
458                    subsystem = LOG_SUBSYSTEM_STORE,
459                    action = "fsync_dir_open",
460                    dir = %dir.display(),
461                    error = %err,
462                    "target store state"
463                );
464            }
465        }
466    }
467
468    /// Reads a file for the given key
469    fn read_file(&self, key: &Key) -> Result<Vec<u8>, StoreError> {
470        let _fs_guard = self
471            .fs_guard
472            .read()
473            .map_err(|_| StoreError::Internal("Failed to acquire read lock on store filesystem".to_string()))?;
474        let path = self.file_path(key);
475        debug!(
476            event = EVENT_TARGET_STORE_STATE,
477            component = LOG_COMPONENT_TARGETS,
478            subsystem = LOG_SUBSYSTEM_STORE,
479            action = "read_file",
480            key = %key,
481            path = %path.display(),
482            "target store state"
483        );
484        let data = std::fs::read(&path).map_err(|e| {
485            if e.kind() == std::io::ErrorKind::NotFound {
486                StoreError::NotFound
487            } else {
488                StoreError::Io(e)
489            }
490        })?;
491
492        if data.is_empty() {
493            return Err(StoreError::NotFound);
494        }
495
496        if !key.compress {
497            return Ok(data);
498        }
499
500        let mut decoder = Decoder::new();
501        decoder
502            .decompress_vec(&data)
503            .map_err(|e| StoreError::Compression(e.to_string()))
504    }
505
506    fn reserve_entry_slot(&self) -> Result<EntryReservation<'_>, StoreError> {
507        loop {
508            let entries = self
509                .entries
510                .read()
511                .map_err(|_| StoreError::Internal("Failed to acquire read lock on entries".to_string()))?;
512            let entries_len = entries.len() as u64;
513            let pending = self.pending_entries.load(Ordering::SeqCst);
514
515            if entries_len + pending >= self.entry_limit {
516                return Err(StoreError::LimitExceeded);
517            }
518
519            if self
520                .pending_entries
521                .compare_exchange(pending, pending + 1, Ordering::SeqCst, Ordering::SeqCst)
522                .is_ok()
523            {
524                return Ok(EntryReservation {
525                    pending_entries: self.pending_entries.as_ref(),
526                });
527            }
528        }
529    }
530
531    /// Durably and atomically writes data to the file for the given key.
532    ///
533    /// The write is crash-safe: bytes are written to a per-key temporary file,
534    /// `fsync`ed (`sync_all`), and only then `rename`d onto the final path. A
535    /// same-directory `rename` is atomic, so a reader (including `open()` after a
536    /// restart) observes either the complete previous file or the complete new
537    /// one — never a half-written payload. On a crash mid-write the temp file is
538    /// left behind and cleaned up by `open()`, so an acknowledged event is never
539    /// lost and no ghost/truncated entry is ever indexed.
540    fn write_file(&self, key: &Key, data: &[u8]) -> Result<i64, StoreError> {
541        let path = self.file_path(key);
542        let parent = path
543            .parent()
544            .ok_or_else(|| StoreError::Internal(format!("store entry path {} has no parent directory", path.display())))?;
545        std::fs::create_dir_all(parent).map_err(StoreError::Io)?;
546
547        let payload: std::borrow::Cow<'_, [u8]> = if key.compress {
548            let mut encoder = Encoder::new();
549            let compressed = encoder
550                .compress_vec(data)
551                .map_err(|e| StoreError::Compression(e.to_string()))?;
552            std::borrow::Cow::Owned(compressed)
553        } else {
554            std::borrow::Cow::Borrowed(data)
555        };
556
557        let tmp_path = {
558            let mut file_name = key.to_key_string();
559            file_name.push_str(TMP_SUFFIX);
560            self.directory.join(file_name)
561        };
562
563        // One implementation of the write-fsync-rename invariant, shared with the failed-store write.
564        // The temp file is removed on any error path so a failed write leaves no residue.
565        write_temp_then_rename(&tmp_path, &path, &payload)?;
566
567        // Best-effort: persist the new directory entry created by rename.
568        Self::fsync_dir(&self.directory);
569
570        let modified = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as i64;
571        debug!(
572            event = EVENT_TARGET_STORE_STATE,
573            component = LOG_COMPONENT_TARGETS,
574            subsystem = LOG_SUBSYSTEM_STORE,
575            action = "write_file",
576            key = %key,
577            "target store state"
578        );
579        Ok(modified)
580    }
581
582    fn insert_entry(&self, key: &Key, modified: i64) -> Result<(), StoreError> {
583        let mut entries = self
584            .entries
585            .write()
586            .map_err(|_| StoreError::Internal("Failed to acquire write lock on entries".to_string()))?;
587        entries.insert(key.to_key_string(), modified);
588        Ok(())
589    }
590
591    fn remove_file_if_present(&self, key: &Key) -> Result<(), StoreError> {
592        let path = self.file_path(key);
593        match std::fs::remove_file(&path) {
594            Ok(()) => Ok(()),
595            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
596            Err(err) => Err(StoreError::Io(err)),
597        }
598    }
599
600    fn write_and_index(&self, key: &Key, data: &[u8]) -> Result<(), StoreError> {
601        let modified = self.write_file(key, data)?;
602        if let Err(err) = self.insert_entry(key, modified) {
603            self.remove_file_if_present(key).map_err(|cleanup_err| {
604                StoreError::Internal(format!("Failed to index store entry {key}: {err}; cleanup failed: {cleanup_err}"))
605            })?;
606            return Err(err);
607        }
608        Ok(())
609    }
610
611    /// Path of the failed-events child directory inside the live queue directory.
612    fn failed_dir(&self) -> PathBuf {
613        self.directory.join(FAILED_STORE_SUBDIR)
614    }
615
616    /// Reads the failed directory once and counts complete entries, skipping non-file entries and
617    /// residual temp files from an interrupted write. Off the hot path, so it seeds the cached count
618    /// at open and reconciles it on the maintenance scan rather than serving failed_len.
619    fn count_failed_entries_on_disk(&self) -> u64 {
620        let read_dir = match std::fs::read_dir(self.failed_dir()) {
621            Ok(read_dir) => read_dir,
622            Err(_) => return 0,
623        };
624
625        let mut count = 0u64;
626        for entry in read_dir.flatten() {
627            if entry.file_name().to_string_lossy().ends_with(TMP_SUFFIX) {
628                continue;
629            }
630            // file_type reads the directory entry kind without a full stat where the platform records it.
631            if matches!(entry.file_type(), Ok(file_type) if file_type.is_file()) {
632                count += 1;
633            }
634        }
635        count
636    }
637
638    /// Lowers the cached failed count by one removed entry, clamped at zero so a decrement can never
639    /// wrap. The maintenance scan reconciles any residual drift each interval.
640    fn decrement_failed_count(&self) {
641        // The closure always returns Some, so the update never fails and the Result is discarded.
642        let _ = self
643            .failed_count
644            .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| Some(current.saturating_sub(1)));
645    }
646
647    /// Maps a per-entry stat outcome inside the ordered failed scan. A NotFound error means the file
648    /// was removed between the directory listing and the stat, by the capacity trim or the
649    /// expired-entry removal on another handle, so the scan skips that entry (None) instead of
650    /// failing as a whole. Any other error still fails the scan.
651    fn failed_scan_entry_metadata(outcome: std::io::Result<std::fs::Metadata>) -> Result<Option<std::fs::Metadata>, StoreError> {
652        match outcome {
653            Ok(metadata) => Ok(Some(metadata)),
654            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
655            Err(err) => Err(StoreError::Io(err)),
656        }
657    }
658
659    /// Filenames of current failed-store entries paired with their write time, oldest first.
660    ///
661    /// The directory is read fresh each call rather than indexed in memory, because the failed store
662    /// is a low-traffic operator surface and a fresh scan keeps it free of the live-queue bookkeeping.
663    /// A missing directory yields an empty list, so a target that never failed reports nothing. An
664    /// entry removed concurrently mid-scan is skipped.
665    fn failed_entries_oldest_first(&self) -> Result<Vec<(PathBuf, SystemTime)>, StoreError> {
666        let dir = self.failed_dir();
667        let read_dir = match std::fs::read_dir(&dir) {
668            Ok(read_dir) => read_dir,
669            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
670            Err(err) => return Err(StoreError::Io(err)),
671        };
672
673        let mut entries = Vec::new();
674        for entry in read_dir {
675            let entry = entry.map_err(StoreError::Io)?;
676            let Some(metadata) = Self::failed_scan_entry_metadata(entry.metadata())? else {
677                continue;
678            };
679            if !metadata.is_file() {
680                continue;
681            }
682            let name = entry.file_name().to_string_lossy().to_string();
683            // A residual temp file from an interrupted failed-store write is not a complete entry.
684            if name.ends_with(TMP_SUFFIX) {
685                let _ = std::fs::remove_file(entry.path());
686                continue;
687            }
688            let written_at = metadata.modified().unwrap_or(UNIX_EPOCH);
689            entries.push((entry.path(), written_at));
690        }
691
692        entries.sort_by_key(|(_, written_at)| *written_at);
693        Ok(entries)
694    }
695
696    /// Drops the oldest failed entry to admit a new one when the count bound is reached. The warn log
697    /// naming the trimmed entry fires after its unlink succeeds, so the log names exactly the entries
698    /// the capacity trim removed.
699    fn evict_oldest_failed_if_full(&self, current: &[(PathBuf, SystemTime)]) -> Result<(), StoreError> {
700        if current.len() < FAILED_STORE_MAX_ENTRIES {
701            return Ok(());
702        }
703
704        let drop_count = current.len() - FAILED_STORE_MAX_ENTRIES + 1;
705        for (path, _) in current.iter().take(drop_count) {
706            let evicted_id = path
707                .file_name()
708                .map(|name| name.to_string_lossy().to_string())
709                .unwrap_or_default();
710            match std::fs::remove_file(path) {
711                Ok(()) => {
712                    self.decrement_failed_count();
713                    warn!(
714                        event = EVENT_TARGET_STORE_STATE,
715                        component = LOG_COMPONENT_TARGETS,
716                        subsystem = LOG_SUBSYSTEM_STORE,
717                        action = "failed_store_evict",
718                        evicted_entry = %evicted_id,
719                        reason = "capacity",
720                        "target store state"
721                    );
722                }
723                // Already removed by another handle, so nothing was trimmed here and no log fires.
724                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
725                Err(err) => return Err(StoreError::Io(err)),
726            }
727        }
728        Ok(())
729    }
730}
731
732impl<T> Store<T> for QueueStore<T>
733where
734    T: Serialize + DeserializeOwned + Clone + Send + Sync + 'static,
735{
736    type Error = StoreError;
737    type Key = Key;
738
739    fn open(&self) -> Result<(), Self::Error> {
740        let _fs_guard = self
741            .fs_guard
742            .write()
743            .map_err(|_| StoreError::Internal("Failed to acquire write lock on store filesystem".to_string()))?;
744        std::fs::create_dir_all(&self.directory).map_err(StoreError::Io)?;
745
746        let dir_entries = std::fs::read_dir(&self.directory).map_err(StoreError::Io)?;
747        let mut entries_map = self
748            .entries
749            .write()
750            .map_err(|_| StoreError::Internal("Failed to acquire write lock on entries".to_string()))?;
751        self.pending_entries.store(0, Ordering::SeqCst);
752        entries_map.clear();
753        for entry in dir_entries {
754            let entry = entry.map_err(StoreError::Io)?;
755            let metadata = entry.metadata().map_err(StoreError::Io)?;
756            if !metadata.is_file() {
757                continue;
758            }
759
760            let file_name = entry.file_name().to_string_lossy().to_string();
761
762            // Remove leftover temp files from an interrupted atomic write; they
763            // are never valid committed entries.
764            if file_name.ends_with(TMP_SUFFIX) {
765                let _ = std::fs::remove_file(entry.path());
766                continue;
767            }
768
769            // Ignore foreign files that do not match the queue file extension so
770            // externally-dropped files cannot pollute the queue.
771            if !is_queue_file_name(&file_name, &self.file_ext) {
772                continue;
773            }
774
775            // Drop zero-byte files (truncated/empty writes from older code paths
776            // or crashes) instead of indexing a ghost entry that read_file would
777            // report as NotFound forever.
778            if metadata.len() == 0 {
779                let _ = std::fs::remove_file(entry.path());
780                continue;
781            }
782
783            let modified = metadata.modified().map_err(StoreError::Io)?;
784            let unix_nano = modified.duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as i64;
785            entries_map.insert(file_name, unix_nano);
786        }
787
788        // Seed the cached failed count from one read of the failed directory, under the write guard
789        // already held, so failed_len() serves an atomic load rather than a directory scan.
790        self.failed_count.store(self.count_failed_entries_on_disk(), Ordering::SeqCst);
791
792        debug!(
793            event = EVENT_TARGET_STORE_STATE,
794            component = LOG_COMPONENT_TARGETS,
795            subsystem = LOG_SUBSYSTEM_STORE,
796            state = "opened",
797            store_dir = ?self.directory,
798            entry_count = entries_map.len(),
799            "target store state"
800        );
801        Ok(())
802    }
803
804    fn put(&self, item: Arc<T>) -> Result<Self::Key, Self::Error> {
805        let _fs_guard = self
806            .fs_guard
807            .read()
808            .map_err(|_| StoreError::Internal("Failed to acquire read lock on store filesystem".to_string()))?;
809        let _reservation = self.reserve_entry_slot()?;
810        let key = self.build_key(1);
811        let data = serde_json::to_vec(&*item).map_err(|e| StoreError::Serialization(e.to_string()))?;
812        self.write_and_index(&key, &data)?;
813
814        Ok(key)
815    }
816
817    fn put_multiple(&self, items: Vec<T>) -> Result<Self::Key, Self::Error> {
818        if items.is_empty() {
819            return Err(StoreError::Internal("Cannot put_multiple with empty items list".to_string()));
820        }
821        let _fs_guard = self
822            .fs_guard
823            .read()
824            .map_err(|_| StoreError::Internal("Failed to acquire read lock on store filesystem".to_string()))?;
825        let _reservation = self.reserve_entry_slot()?;
826        let key = self.build_key(items.len());
827
828        let mut buffer = Vec::new();
829        for item in items {
830            serde_json::to_writer(&mut buffer, &item).map_err(|e| StoreError::Serialization(e.to_string()))?;
831        }
832
833        self.write_and_index(&key, &buffer)?;
834
835        Ok(key)
836    }
837
838    fn put_raw(&self, data: &[u8]) -> Result<Self::Key, Self::Error> {
839        let _fs_guard = self
840            .fs_guard
841            .read()
842            .map_err(|_| StoreError::Internal("Failed to acquire read lock on store filesystem".to_string()))?;
843        let _reservation = self.reserve_entry_slot()?;
844        let key = self.build_key(1);
845        self.write_and_index(&key, data)?;
846
847        Ok(key)
848    }
849
850    fn get(&self, key: &Self::Key) -> Result<T, Self::Error> {
851        if key.item_count != 1 {
852            return Err(StoreError::Internal(format!(
853                "get() called on a batch key ({} items), use get_multiple()",
854                key.item_count
855            )));
856        }
857        let items = self.get_multiple(key)?;
858        items.into_iter().next().ok_or(StoreError::NotFound)
859    }
860
861    fn get_multiple(&self, key: &Self::Key) -> Result<Vec<T>, Self::Error> {
862        debug!(
863            event = EVENT_TARGET_STORE_STATE,
864            component = LOG_COMPONENT_TARGETS,
865            subsystem = LOG_SUBSYSTEM_STORE,
866            action = "read_batch",
867            key = %key,
868            "target store state"
869        );
870        let data = self.get_raw(key)?;
871        if data.is_empty() {
872            return Err(StoreError::Deserialization("Cannot deserialize empty data".to_string()));
873        }
874        // `item_count` is parsed from the (untrusted) filename; clamp the
875        // up-front allocation so a corrupt/malicious name cannot drive a huge
876        // reservation. The loop below still reads exactly `item_count` items.
877        let mut items = Vec::with_capacity(key.item_count.min(MAX_PREALLOC_CAPACITY));
878
879        // let mut deserializer = serde_json::Deserializer::from_slice(&data);
880        // while let Ok(item) = serde::Deserialize::deserialize(&mut deserializer) {
881        //     items.push(item);
882        // }
883
884        // This deserialization logic assumes multiple JSON objects are simply concatenated in the file.
885        // This is fragile. It's better to store a JSON array `[item1, item2, ...]`
886        // or use a streaming deserializer that can handle multiple top-level objects if that's the format.
887        // For now, assuming serde_json::Deserializer::from_slice can handle this if input is well-formed.
888        let mut deserializer = serde_json::Deserializer::from_slice(&data).into_iter::<T>();
889
890        for _ in 0..key.item_count {
891            match deserializer.next() {
892                Some(Ok(item)) => items.push(item),
893                Some(Err(e)) => {
894                    return Err(StoreError::Deserialization(format!("Failed to deserialize item in batch: {e}")));
895                }
896                None => {
897                    // Reached end of stream before deserializing item_count items: the
898                    // batch file was truncated or corrupted. This MUST be surfaced as an
899                    // error rather than silently returning the partial set. Returning
900                    // Ok(partial) would let the caller treat the batch as fully delivered,
901                    // delete the store entry, and permanently lose the missing events.
902                    warn!(
903                        event = EVENT_TARGET_STORE_STATE,
904                        component = LOG_COMPONENT_TARGETS,
905                        subsystem = LOG_SUBSYSTEM_STORE,
906                        action = "read_batch",
907                        key = %key,
908                        expected_items = key.item_count,
909                        actual_items = items.len(),
910                        reason = "truncated_batch_read",
911                        "target store state"
912                    );
913                    return Err(StoreError::Deserialization(format!(
914                        "Truncated batch for key {key}: expected {} items but only deserialized {}",
915                        key.item_count,
916                        items.len()
917                    )));
918                }
919            }
920        }
921
922        // The filename's item count is untrusted. Reject a payload that contains
923        // more items than advertised instead of returning success and allowing the
924        // caller to delete the entry with trailing events still in the file.
925        match deserializer.next() {
926            None => {}
927            Some(Ok(_)) => {
928                return Err(StoreError::Deserialization(format!(
929                    "Batch for key {key} contains more than {} items",
930                    key.item_count
931                )));
932            }
933            Some(Err(e)) => {
934                return Err(StoreError::Deserialization(format!("Failed to deserialize trailing batch item: {e}")));
935            }
936        }
937
938        if items.is_empty() && key.item_count > 0 {
939            return Err(StoreError::Deserialization("No items found".to_string()));
940        }
941
942        Ok(items)
943    }
944
945    fn get_raw(&self, key: &Self::Key) -> Result<Vec<u8>, Self::Error> {
946        self.read_file(key)
947    }
948
949    fn del(&self, key: &Self::Key) -> Result<(), Self::Error> {
950        let _fs_guard = self
951            .fs_guard
952            .read()
953            .map_err(|_| StoreError::Internal("Failed to acquire read lock on store filesystem".to_string()))?;
954        let path = self.file_path(key);
955        match std::fs::remove_file(&path) {
956            Ok(()) => {}
957            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
958                // File already gone — still clean up the entries map to avoid stale keys.
959                warn!(
960                    event = EVENT_TARGET_STORE_STATE,
961                    component = LOG_COMPONENT_TARGETS,
962                    subsystem = LOG_SUBSYSTEM_STORE,
963                    action = "delete",
964                    key = %key,
965                    result = "file_missing",
966                    "target store state"
967                );
968            }
969            Err(e) => return Err(StoreError::Io(e)),
970        }
971
972        // Get the write lock to update the internal state
973        let mut entries = self
974            .entries
975            .write()
976            .map_err(|_| StoreError::Internal("Failed to acquire write lock on entries".to_string()))?;
977
978        // A batch entry indexed under the legacy separator is removed under both renderings so its slot is reclaimed.
979        let removed_current = entries.remove(&key.to_key_string()).is_some();
980        let removed_legacy = key.item_count > 1
981            && entries
982                .remove(&key.to_key_string_with(LEGACY_BATCH_COUNT_SEPARATOR))
983                .is_some();
984        if !removed_current && !removed_legacy {
985            debug!(
986                event = EVENT_TARGET_STORE_STATE,
987                component = LOG_COMPONENT_TARGETS,
988                subsystem = LOG_SUBSYSTEM_STORE,
989                action = "delete",
990                key = %key,
991                result = "entry_missing",
992                "target store state"
993            );
994        }
995        debug!(
996            event = EVENT_TARGET_STORE_STATE,
997            component = LOG_COMPONENT_TARGETS,
998            subsystem = LOG_SUBSYSTEM_STORE,
999            action = "delete",
1000            key = %key,
1001            result = "deleted",
1002            "target store state"
1003        );
1004        Ok(())
1005    }
1006
1007    fn delete(&self) -> Result<(), Self::Error> {
1008        let _fs_guard = self
1009            .fs_guard
1010            .write()
1011            .map_err(|_| StoreError::Internal("Failed to acquire write lock on store filesystem".to_string()))?;
1012        let mut entries = self
1013            .entries
1014            .write()
1015            .map_err(|_| StoreError::Internal("Failed to acquire write lock on entries".to_string()))?;
1016        entries.clear();
1017        self.pending_entries.store(0, Ordering::SeqCst);
1018        self.failed_count.store(0, Ordering::SeqCst);
1019
1020        match std::fs::remove_dir_all(&self.directory) {
1021            Ok(()) => Ok(()),
1022            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1023            Err(err) => Err(StoreError::Io(err)),
1024        }
1025    }
1026
1027    fn list(&self) -> Vec<Self::Key> {
1028        // Get the read lock to read the internal state
1029        let entries = match self.entries.read() {
1030            Ok(entries) => entries,
1031            Err(_) => {
1032                debug!(
1033                    event = EVENT_TARGET_STORE_STATE,
1034                    component = LOG_COMPONENT_TARGETS,
1035                    subsystem = LOG_SUBSYSTEM_STORE,
1036                    action = "list",
1037                    result = "lock_unavailable",
1038                    "target store state"
1039                );
1040                return Vec::new();
1041            }
1042        };
1043
1044        // Order by the entry name (UUIDv7), which is time-ordered by
1045        // construction. This yields a stable FIFO order that is identical in a
1046        // single run and after a restart, because it derives purely from the
1047        // persisted filename rather than from coarse, clock-dependent file
1048        // mtimes. The full filename is used as a deterministic tie-breaker.
1049        let mut entries_vec: Vec<(String, String)> = entries
1050            .keys()
1051            .map(|file_name| (parse_key(file_name).name, file_name.clone()))
1052            .collect();
1053        entries_vec.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
1054
1055        entries_vec.into_iter().map(|(_, file_name)| parse_key(&file_name)).collect()
1056    }
1057
1058    fn len(&self) -> usize {
1059        // Get the read lock to read the internal state
1060        match self.entries.read() {
1061            Ok(entries) => entries.len(),
1062            Err(_) => {
1063                debug!(
1064                    event = EVENT_TARGET_STORE_STATE,
1065                    component = LOG_COMPONENT_TARGETS,
1066                    subsystem = LOG_SUBSYSTEM_STORE,
1067                    action = "len",
1068                    result = "lock_unavailable",
1069                    "target store state"
1070                );
1071                0
1072            }
1073        }
1074    }
1075
1076    fn is_empty(&self) -> bool {
1077        self.len() == 0
1078    }
1079
1080    fn boxed_clone(&self) -> Box<dyn Store<T, Error = Self::Error, Key = Self::Key> + Send + Sync> {
1081        Box::new(self.clone()) as Box<dyn Store<T, Error = Self::Error, Key = Self::Key> + Send + Sync>
1082    }
1083}
1084
1085impl<T> FailedEventStore for QueueStore<T>
1086where
1087    T: Serialize + DeserializeOwned + Clone + Send + Sync + 'static,
1088{
1089    fn put_failed_raw(&self, entry_name: &str, data: &[u8]) -> Result<String, StoreError> {
1090        // The name becomes a filename inside the failed directory, so a separator or a dot-only name
1091        // is rejected before it can address a path outside it.
1092        if entry_name.is_empty() || entry_name.contains(['/', '\\']) || entry_name == "." || entry_name == ".." {
1093            return Err(StoreError::Internal(format!("invalid failed-store entry name: {entry_name}")));
1094        }
1095
1096        // The seam holds across the at-bound check, the capacity trim, and the count update, so a
1097        // second writer or the scan cannot interleave. It nests outside fs_guard, the order the scan
1098        // uses too, so the two never invert.
1099        let _failed_guard = self
1100            .failed_store_guard
1101            .lock()
1102            .map_err(|_| StoreError::Internal("Failed to acquire the failed-store guard".to_string()))?;
1103        let _fs_guard = self
1104            .fs_guard
1105            .read()
1106            .map_err(|_| StoreError::Internal("Failed to acquire read lock on store filesystem".to_string()))?;
1107
1108        let failed_dir = self.failed_dir();
1109        if !failed_dir.exists() {
1110            std::fs::create_dir_all(&failed_dir).map_err(StoreError::Io)?;
1111            // Best-effort: persist the lazily created directory entry in the queue directory, the
1112            // same durability discipline the live write path applies after its rename.
1113            Self::fsync_dir(&self.directory);
1114        }
1115
1116        let entry_id = entry_name.to_string();
1117        let final_path = failed_dir.join(&entry_id);
1118        let is_new_entry = !final_path.exists();
1119
1120        // A repeated move of the same live entry lands on the same filename and replaces the earlier
1121        // file. The count bound is enforced only when the write creates a new entry, since an
1122        // overwrite does not grow the count and a capacity trim for it would drop a genuine entry. The
1123        // cached count gates the ordered capacity scan with a plain atomic load, so a write below the
1124        // bound skips the stat-and-sort entirely. The ordered scan runs only at the bound, oldest
1125        // first, so the new failure is recorded even at capacity.
1126        if is_new_entry && self.failed_len() >= FAILED_STORE_MAX_ENTRIES {
1127            let existing = self.failed_entries_oldest_first()?;
1128            self.evict_oldest_failed_if_full(&existing)?;
1129        }
1130
1131        let temp_path = failed_dir.join(format!("{entry_id}.{}{}", Uuid::new_v4(), TMP_SUFFIX));
1132        // rename replaces an existing destination file on the supported platforms, which the
1133        // idempotent overwrite relies on.
1134        write_temp_then_rename(&temp_path, &final_path, data)?;
1135
1136        // A new failed file grows the cached count. An overwrite of an existing failed file leaves it
1137        // unchanged.
1138        if is_new_entry {
1139            self.failed_count.fetch_add(1, Ordering::SeqCst);
1140        }
1141
1142        // Best-effort: persist the directory entry created by the rename, matching the live write
1143        // path.
1144        Self::fsync_dir(&failed_dir);
1145
1146        debug!(
1147            event = EVENT_TARGET_STORE_STATE,
1148            component = LOG_COMPONENT_TARGETS,
1149            subsystem = LOG_SUBSYSTEM_STORE,
1150            action = "failed_store_write",
1151            failed_entry = %entry_id,
1152            "target store state"
1153        );
1154        Ok(entry_id)
1155    }
1156
1157    fn prune_failed_store(&self) -> Result<usize, StoreError> {
1158        // Same seam and nesting order as put_failed_raw, so the reconcile store below rests on a
1159        // listing no concurrent writer can shift, and the count it writes cannot overwrite a write's
1160        // increment.
1161        let _failed_guard = self
1162            .failed_store_guard
1163            .lock()
1164            .map_err(|_| StoreError::Internal("Failed to acquire the failed-store guard".to_string()))?;
1165        let _fs_guard = self
1166            .fs_guard
1167            .read()
1168            .map_err(|_| StoreError::Internal("Failed to acquire read lock on store filesystem".to_string()))?;
1169
1170        let entries = self.failed_entries_oldest_first()?;
1171        let materialized_len = entries.len();
1172        let now = SystemTime::now();
1173        let mut pruned = 0usize;
1174        for (path, written_at) in entries {
1175            let age = now.duration_since(written_at).unwrap_or_default();
1176            if age < FAILED_STORE_TTL {
1177                // Entries are oldest first, so the first within the retention bound ends the scan.
1178                break;
1179            }
1180            let pruned_id = path
1181                .file_name()
1182                .map(|name| name.to_string_lossy().to_string())
1183                .unwrap_or_default();
1184            warn!(
1185                event = EVENT_TARGET_STORE_STATE,
1186                component = LOG_COMPONENT_TARGETS,
1187                subsystem = LOG_SUBSYSTEM_STORE,
1188                action = "failed_store_prune",
1189                pruned_entry = %pruned_id,
1190                reason = "ttl",
1191                "target store state"
1192            );
1193            match std::fs::remove_file(&path) {
1194                Ok(()) => {
1195                    pruned += 1;
1196                    self.decrement_failed_count();
1197                }
1198                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1199                Err(err) => return Err(StoreError::Io(err)),
1200            }
1201        }
1202        // Reconcile the cached count to this scan's materialized listing under the seam, so a change
1203        // made outside the store drifts the value by at most one maintenance interval and no concurrent
1204        // write is overwritten.
1205        self.failed_count.store((materialized_len - pruned) as u64, Ordering::SeqCst);
1206        Ok(pruned)
1207    }
1208
1209    fn failed_len(&self) -> usize {
1210        // A plain load of the cached count seeded at open and kept current by the write, the capacity
1211        // trim, and the expired-entry removal. The maintenance scan reconciles it to the directory
1212        // each interval, so external drift is corrected within one interval.
1213        self.failed_count.load(Ordering::SeqCst) as usize
1214    }
1215
1216    fn boxed_clone_failed(&self) -> Box<dyn FailedEventStore> {
1217        Box::new(self.clone())
1218    }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224    use std::{
1225        sync::{Arc, Barrier},
1226        thread,
1227    };
1228
1229    fn temp_store_dir(name: &str) -> PathBuf {
1230        std::env::temp_dir().join(format!("rustfs-targets-{name}-{}", Uuid::new_v4()))
1231    }
1232
1233    #[test]
1234    fn resolve_queue_store_compression_defaults_to_true() {
1235        assert!(resolve_queue_store_compression_from_env_value(None));
1236    }
1237
1238    #[test]
1239    fn resolve_queue_store_compression_respects_disabled_env_value() {
1240        assert!(!resolve_queue_store_compression_from_env_value(Some("off")));
1241        assert!(!resolve_queue_store_compression_from_env_value(Some("false")));
1242    }
1243
1244    #[test]
1245    fn put_uses_store_compression_setting_in_key() {
1246        let dir = temp_store_dir("put-key");
1247        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1248        store.open().unwrap();
1249
1250        let key = store.put(Arc::new("payload".to_string())).unwrap();
1251
1252        assert!(!key.compress);
1253        assert!(store.file_path(&key).exists());
1254
1255        let _ = std::fs::remove_dir_all(dir);
1256    }
1257
1258    #[test]
1259    fn parse_key_round_trips_batch_and_compression_suffixes() {
1260        let key = Key {
1261            name: "event-id".to_string(),
1262            extension: ".json".to_string(),
1263            item_count: 3,
1264            compress: true,
1265        };
1266
1267        let parsed = parse_key(&key.to_key_string());
1268
1269        assert_eq!(parsed.name, key.name);
1270        assert_eq!(parsed.extension, key.extension);
1271        assert_eq!(parsed.item_count, key.item_count);
1272        assert_eq!(parsed.compress, key.compress);
1273    }
1274
1275    // A batch filename must stay outside the Windows reserved character set so File::create succeeds
1276    // on NTFS. The name carries a UUID, matching what build_key produces for a real entry.
1277    #[test]
1278    fn batch_key_filename_has_no_windows_reserved_characters() {
1279        let key = Key {
1280            name: Uuid::new_v4().to_string(),
1281            extension: ".json".to_string(),
1282            item_count: 7,
1283            compress: true,
1284        };
1285
1286        let file_name = key.to_key_string();
1287
1288        // The characters NTFS rejects in a filename, plus the path separators.
1289        const WINDOWS_RESERVED: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
1290        for reserved in WINDOWS_RESERVED {
1291            assert!(
1292                !file_name.contains(*reserved),
1293                "batch filename {file_name} must not contain the reserved character {reserved}"
1294            );
1295        }
1296        assert!(file_name.contains(BATCH_COUNT_SEPARATOR), "the batch count uses the safe separator");
1297    }
1298
1299    // An entry written with the legacy separator still reads back, so a queue populated before the
1300    // switch to the Windows-safe separator is not misparsed after an upgrade.
1301    #[test]
1302    fn parse_key_reads_legacy_batch_separator() {
1303        let legacy = format!("5{LEGACY_BATCH_COUNT_SEPARATOR}{}.json", "event-id");
1304
1305        let parsed = parse_key(&legacy);
1306
1307        assert_eq!(parsed.item_count, 5);
1308        assert_eq!(parsed.name, "event-id");
1309        assert_eq!(parsed.extension, ".json");
1310    }
1311
1312    // The render side prefixes the count only when it exceeds one, so the parse side strips a count
1313    // prefix only for the same range. A hostile 1_ or 0_ prefix stays part of the name and round-trips.
1314    #[test]
1315    fn parse_key_treats_a_count_of_one_or_zero_as_a_plain_name() {
1316        for hostile in ["1_name.event", "0_name.event"] {
1317            let parsed = parse_key(hostile);
1318            assert_eq!(parsed.item_count, 1, "{hostile} is a single-item entry");
1319            assert_eq!(parsed.to_key_string(), hostile, "{hostile} round-trips as a plain name");
1320        }
1321    }
1322
1323    #[test]
1324    fn put_raw_and_get_raw_round_trip_bytes() {
1325        let dir = temp_store_dir("raw-roundtrip");
1326        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", true);
1327        store.open().unwrap();
1328
1329        let payload = br#"{"kind":"notify","bucket":"demo","key":"alpha.txt"}"#;
1330        let key = store.put_raw(payload).unwrap();
1331        let raw = store.get_raw(&key).unwrap();
1332
1333        assert_eq!(raw, payload);
1334
1335        let _ = store.delete();
1336    }
1337
1338    #[test]
1339    fn delete_removes_directory_and_clears_entries() {
1340        let dir = temp_store_dir("delete-store");
1341        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1342        store.open().unwrap();
1343        let _ = store.put(Arc::new("payload".to_string())).unwrap();
1344
1345        store.delete().unwrap();
1346
1347        assert!(store.list().is_empty());
1348        assert!(!dir.exists());
1349    }
1350
1351    #[test]
1352    fn put_enforces_entry_limit() {
1353        let dir = temp_store_dir("limit");
1354        let store = QueueStore::<String>::new_with_compression(&dir, 1, ".test", false);
1355        store.open().unwrap();
1356
1357        let _ = store.put(Arc::new("first".to_string())).unwrap();
1358        let err = store.put(Arc::new("second".to_string())).unwrap_err();
1359
1360        assert!(matches!(err, StoreError::LimitExceeded));
1361
1362        let _ = store.delete();
1363    }
1364
1365    #[test]
1366    fn get_multiple_errors_on_truncated_batch_instead_of_partial_success() {
1367        let dir = temp_store_dir("truncated-batch");
1368        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1369        store.open().unwrap();
1370
1371        let items = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
1372        let key = store.put_multiple(items).unwrap();
1373        assert_eq!(key.item_count, 3);
1374
1375        // Truncate the batch file at a clean boundary after the first two serialized
1376        // items, so the read of the third item hits end-of-stream. This is the
1377        // partial-read condition the batch reader must surface as an error.
1378        let prefix_len =
1379            serde_json::to_vec(&"aa".to_string()).unwrap().len() + serde_json::to_vec(&"bb".to_string()).unwrap().len();
1380        let path = store.file_path(&key);
1381        let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1382        file.set_len(prefix_len as u64).unwrap();
1383        drop(file);
1384
1385        // A truncated batch must be reported as an error, not silently returned as a
1386        // partial-but-successful result that would drop the missing "cc" event.
1387        let err = store.get_multiple(&key).unwrap_err();
1388        assert!(
1389            matches!(err, StoreError::Deserialization(_)),
1390            "expected Deserialization error, got {err:?}"
1391        );
1392
1393        // Because get_multiple failed, the batch entry is still on disk for the caller
1394        // to retry — the missing events are not silently discarded.
1395        assert!(store.file_path(&key).exists());
1396
1397        let _ = store.delete();
1398    }
1399
1400    #[test]
1401    fn get_multiple_errors_on_batch_with_trailing_items_instead_of_partial_success() {
1402        let dir = temp_store_dir("trailing-batch-items");
1403        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1404        store.open().unwrap();
1405
1406        let items = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
1407        let original_key = store.put_multiple(items).unwrap();
1408        assert_eq!(original_key.item_count, 3);
1409
1410        // Keep the three-item payload but make its filename claim that it contains
1411        // only two items, simulating a corrupt or otherwise untrusted queue key.
1412        let original_path = store.file_path(&original_key);
1413        let advertised_key = Key {
1414            item_count: 2,
1415            ..original_key
1416        };
1417        let advertised_path = store.file_path(&advertised_key);
1418        std::fs::rename(&original_path, &advertised_path).unwrap();
1419
1420        let err = store.get_multiple(&advertised_key).unwrap_err();
1421        assert!(
1422            matches!(err, StoreError::Deserialization(_)),
1423            "expected Deserialization error, got {err:?}"
1424        );
1425
1426        // Because get_multiple failed, the batch entry remains available for
1427        // inspection or recovery instead of being silently discarded.
1428        assert!(advertised_path.exists());
1429
1430        let _ = store.delete();
1431    }
1432
1433    #[test]
1434    fn concurrent_put_raw_respects_entry_limit() {
1435        let dir = temp_store_dir("concurrent-limit");
1436        let store = Arc::new(QueueStore::<String>::new_with_compression(&dir, 1, ".test", true));
1437        store.open().unwrap();
1438
1439        let start = Arc::new(Barrier::new(4));
1440        let mut handles = Vec::new();
1441
1442        for idx in 0..4 {
1443            let store = Arc::clone(&store);
1444            let start = Arc::clone(&start);
1445            handles.push(thread::spawn(move || {
1446                let payload = vec![b'x'; 32 * 1024 + idx];
1447                start.wait();
1448                store.put_raw(&payload)
1449            }));
1450        }
1451
1452        let mut successes = 0;
1453        let mut limit_errors = 0;
1454        for handle in handles {
1455            match handle.join().unwrap() {
1456                Ok(_) => successes += 1,
1457                Err(StoreError::LimitExceeded) => limit_errors += 1,
1458                Err(err) => panic!("unexpected error: {err}"),
1459            }
1460        }
1461
1462        assert_eq!(successes, 1);
1463        assert_eq!(limit_errors, 3);
1464        assert_eq!(store.len(), 1);
1465
1466        let _ = store.delete();
1467    }
1468
1469    #[test]
1470    fn open_cleans_leftover_tmp_and_zero_byte_files() {
1471        let dir = temp_store_dir("open-cleanup");
1472        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1473        store.open().unwrap();
1474
1475        // A committed entry that must survive open().
1476        let key = store.put(Arc::new("payload".to_string())).unwrap();
1477        let good_name = key.to_key_string();
1478
1479        // Simulate an interrupted atomic write: a temp file that was fsynced but
1480        // never renamed into place. It must be removed and never indexed.
1481        let tmp_path = dir.join(format!("{good_name}{TMP_SUFFIX}"));
1482        std::fs::write(&tmp_path, b"half-written").unwrap();
1483
1484        // Simulate a zero-byte ghost file with a valid queue extension.
1485        let zero_name = format!("{}.test", Uuid::now_v7());
1486        let zero_path = dir.join(&zero_name);
1487        std::fs::write(&zero_path, b"").unwrap();
1488
1489        store.open().unwrap();
1490
1491        // The queue is clean: only the committed entry remains indexed, and both
1492        // the temp and zero-byte residues are gone from disk.
1493        assert_eq!(store.len(), 1);
1494        let listed: Vec<String> = store.list().iter().map(|k| k.to_key_string()).collect();
1495        assert_eq!(listed, vec![good_name]);
1496        assert!(!tmp_path.exists(), "leftover temp file should be removed");
1497        assert!(!zero_path.exists(), "zero-byte file should be removed");
1498
1499        let _ = store.delete();
1500    }
1501
1502    #[test]
1503    fn open_ignores_foreign_extension_files() {
1504        let dir = temp_store_dir("open-foreign");
1505        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1506        store.open().unwrap();
1507        let key = store.put(Arc::new("payload".to_string())).unwrap();
1508
1509        // A non-empty file with an unrelated extension must not be indexed and
1510        // must be left untouched. Only residue from this store's own writes is cleaned up.
1511        let foreign_path = dir.join("intruder.txt");
1512        std::fs::write(&foreign_path, b"not ours").unwrap();
1513
1514        store.open().unwrap();
1515
1516        assert_eq!(store.len(), 1);
1517        let listed: Vec<String> = store.list().iter().map(|k| k.to_key_string()).collect();
1518        assert_eq!(listed, vec![key.to_key_string()]);
1519        assert!(foreign_path.exists(), "foreign file should be left in place, just not indexed");
1520
1521        let _ = store.delete();
1522    }
1523
1524    #[test]
1525    fn list_order_is_stable_across_reopen() {
1526        let dir = temp_store_dir("list-order");
1527        let store = QueueStore::<String>::new_with_compression(&dir, 32, ".test", false);
1528        store.open().unwrap();
1529
1530        for idx in 0..8 {
1531            store.put(Arc::new(format!("event-{idx}"))).unwrap();
1532        }
1533
1534        let order_before: Vec<String> = store.list().iter().map(|k| k.to_key_string()).collect();
1535        assert_eq!(order_before.len(), 8);
1536
1537        // Re-open from disk (simulating a restart) and assert the FIFO order is
1538        // reproduced exactly — it is derived from the persisted UUIDv7 names, not
1539        // from coarse file mtimes.
1540        let reopened = QueueStore::<String>::new_with_compression(&dir, 32, ".test", false);
1541        reopened.open().unwrap();
1542        let order_after: Vec<String> = reopened.list().iter().map(|k| k.to_key_string()).collect();
1543
1544        assert_eq!(order_before, order_after);
1545
1546        let _ = store.delete();
1547    }
1548
1549    #[test]
1550    fn new_with_huge_limit_does_not_panic() {
1551        // An absurd configured entry_limit must not drive a giant up-front HashMap
1552        // allocation or a capacity-overflow panic.
1553        let dir = temp_store_dir("huge-limit");
1554        let store = QueueStore::<String>::new_with_compression(&dir, u64::MAX, ".test", false);
1555        store.open().unwrap();
1556        assert!(store.is_empty());
1557        let _ = store.delete();
1558    }
1559
1560    #[test]
1561    fn get_multiple_does_not_overallocate_on_huge_item_count() {
1562        let dir = temp_store_dir("huge-item-count");
1563        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1564        store.open().unwrap();
1565        std::fs::create_dir_all(&dir).unwrap();
1566
1567        // Craft a batch file whose name claims a colossal item_count (far beyond
1568        // MAX_PREALLOC_CAPACITY) but whose body holds only two items — the shape a
1569        // corrupt/malicious filename would take. The read must fail cleanly with a
1570        // Deserialization error and must not attempt a giant pre-allocation
1571        // (Vec::with_capacity is clamped to MAX_PREALLOC_CAPACITY).
1572        let claimed_count = 1_000_000usize;
1573        let file_name = format!("{claimed_count}:{}.test", Uuid::now_v7());
1574        let mut body = Vec::new();
1575        body.extend_from_slice(&serde_json::to_vec(&"aa".to_string()).unwrap());
1576        body.extend_from_slice(&serde_json::to_vec(&"bb".to_string()).unwrap());
1577        std::fs::write(dir.join(&file_name), &body).unwrap();
1578
1579        let key = parse_key(&file_name);
1580        assert_eq!(key.item_count, claimed_count);
1581
1582        let err = store.get_multiple(&key).unwrap_err();
1583        assert!(
1584            matches!(err, StoreError::Deserialization(_)),
1585            "expected Deserialization error, got {err:?}"
1586        );
1587
1588        let _ = store.delete();
1589    }
1590
1591    fn count_temp_files(dir: &Path) -> usize {
1592        match std::fs::read_dir(dir) {
1593            Ok(read_dir) => read_dir
1594                .filter_map(|entry| entry.ok())
1595                .filter(|entry| entry.file_name().to_string_lossy().ends_with(TMP_SUFFIX))
1596                .count(),
1597            Err(_) => 0,
1598        }
1599    }
1600
1601    /// Directory truth for the failed store: the number of complete failed files on disk, skipping
1602    /// residual temp files, so a test can assert the cached failed_len matches the filesystem.
1603    fn count_failed_files_on_disk(dir: &Path) -> usize {
1604        match std::fs::read_dir(dir.join(FAILED_STORE_SUBDIR)) {
1605            Ok(read_dir) => read_dir
1606                .filter_map(|entry| entry.ok())
1607                .filter(|entry| {
1608                    let name = entry.file_name().to_string_lossy().into_owned();
1609                    !name.ends_with(TMP_SUFFIX) && entry.file_type().map(|file_type| file_type.is_file()).unwrap_or(false)
1610                })
1611                .count(),
1612            Err(_) => 0,
1613        }
1614    }
1615
1616    // A completed write leaves the full payload at the final path, readable, and no temp file. The
1617    // atomic path renames the temp into place rather than writing the final path directly.
1618    #[test]
1619    fn write_leaves_complete_file_and_no_temp_residue() {
1620        let dir = temp_store_dir("atomic-complete");
1621        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1622        store.open().unwrap();
1623
1624        let payload = br#"{"kind":"notify","bucket":"demo","key":"complete.txt"}"#;
1625        let key = store.put_raw(payload).unwrap();
1626
1627        let final_path = store.file_path(&key);
1628        assert!(final_path.exists(), "final entry must exist after a complete write");
1629        assert_eq!(std::fs::read(&final_path).unwrap(), payload);
1630        assert_eq!(store.get_raw(&key).unwrap(), payload);
1631        assert_eq!(count_temp_files(&dir), 0, "a complete write leaves no temp file");
1632
1633        let _ = store.delete();
1634    }
1635
1636    // A temp file from an interrupted write (one that never reached its rename) is not a valid
1637    // entry. The open-time rescan removes it and never indexes it, so a partial write is absent
1638    // rather than read as a corrupt entry.
1639    #[test]
1640    fn open_discards_residual_temp_file() {
1641        let dir = temp_store_dir("atomic-residue");
1642        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1643        store.open().unwrap();
1644
1645        let key = store.put_raw(br#"{"complete":true}"#).unwrap();
1646
1647        // Simulate a write interrupted before its rename. A half-written temp file is left in the
1648        // store directory under the same naming scheme write_file uses.
1649        let residual_temp = dir.join(format!("{}.{}{}", "2_orphan.test", Uuid::new_v4(), TMP_SUFFIX));
1650        std::fs::write(&residual_temp, b"partial payload, never renamed").unwrap();
1651        assert!(residual_temp.exists());
1652
1653        // Reopen to drive the rescan.
1654        store.open().unwrap();
1655
1656        assert!(!residual_temp.exists(), "open must remove a residual temp file");
1657        assert_eq!(count_temp_files(&dir), 0);
1658        assert_eq!(store.len(), 1, "only the complete entry is indexed");
1659        assert_eq!(store.get_raw(&key).unwrap(), br#"{"complete":true}"#);
1660
1661        let _ = store.delete();
1662    }
1663
1664    // Flag-independent. The queue round-trip (write, read, delete) is unchanged by the atomic write,
1665    // for both the uncompressed and the snappy-compressed path. The atomic write changes how bytes
1666    // land, not what round-trips.
1667    #[test]
1668    fn queue_round_trip_unchanged_uncompressed_and_compressed() {
1669        for compress in [false, true] {
1670            let dir = temp_store_dir("round-trip");
1671            let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", compress);
1672            store.open().unwrap();
1673
1674            let payload = br#"{"kind":"notify","bucket":"demo","key":"round-trip.txt"}"#;
1675            let key = store.put_raw(payload).unwrap();
1676            assert_eq!(key.compress, compress);
1677
1678            assert_eq!(store.get_raw(&key).unwrap(), payload, "read returns the written bytes");
1679            assert_eq!(store.len(), 1);
1680
1681            store.del(&key).unwrap();
1682            assert!(matches!(store.get_raw(&key), Err(StoreError::NotFound)));
1683            assert_eq!(store.len(), 0);
1684
1685            let _ = store.delete();
1686        }
1687    }
1688
1689    // The temp file lives in the same directory as the final path, so the rename stays within one
1690    // filesystem and is atomic. A cross-directory temp would break that guarantee.
1691    #[test]
1692    fn temp_file_shares_directory_with_final_path() {
1693        let dir = temp_store_dir("same-dir");
1694        let final_path = dir.join("entry.test");
1695        let temp_path = dir.join(format!("entry.test.{}{}", Uuid::new_v4(), TMP_SUFFIX));
1696
1697        assert_eq!(temp_path.parent(), final_path.parent());
1698    }
1699
1700    // The failed-events store is a child directory inside the live queue directory and is created only on the
1701    // first failed write, so a target that never fails terminally leaves no failed directory.
1702    #[test]
1703    fn failed_store_directory_is_lazy_and_sibling_of_the_queue() {
1704        let dir = temp_store_dir("failed-lazy");
1705        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1706        store.open().unwrap();
1707
1708        let failed_dir = dir.join("failed");
1709        assert!(!failed_dir.exists(), "no failed directory before any failed write");
1710        assert_eq!(store.failed_len(), 0);
1711
1712        let first_id = store.put_failed_raw("failed-entry-1", b"failed-entry-1").unwrap();
1713        assert!(failed_dir.exists(), "the failed directory is created on the first failed write");
1714        assert_eq!(store.failed_len(), 1);
1715        assert_eq!(
1716            store.failed_len(),
1717            count_failed_files_on_disk(&dir),
1718            "the cached count matches the directory truth"
1719        );
1720        assert_eq!(
1721            std::fs::read(failed_dir.join(&first_id)).unwrap(),
1722            b"failed-entry-1",
1723            "the lazy-create write lands the full payload"
1724        );
1725
1726        // A later write finds the directory already present and takes the non-create branch.
1727        let second_id = store.put_failed_raw("failed-entry-2", b"failed-entry-2").unwrap();
1728        assert_eq!(store.failed_len(), 2);
1729        assert_eq!(
1730            store.failed_len(),
1731            count_failed_files_on_disk(&dir),
1732            "the cached count matches the directory truth"
1733        );
1734        assert_eq!(
1735            std::fs::read(failed_dir.join(&second_id)).unwrap(),
1736            b"failed-entry-2",
1737            "a write into the existing directory lands the full payload"
1738        );
1739
1740        let _ = store.delete();
1741    }
1742
1743    // A repeated move of the same live entry lands on the same failed filename, so a persistent
1744    // delete failure, or a crash that left the entry in both stores followed by another terminal
1745    // failure, replaces the earlier failed file instead of accumulating duplicates.
1746    #[test]
1747    fn failed_store_write_is_idempotent_per_entry() {
1748        let dir = temp_store_dir("failed-idempotent");
1749        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1750        store.open().unwrap();
1751
1752        let first = store.put_failed_raw("entry-a", b"first-write").unwrap();
1753        let second = store.put_failed_raw("entry-a", b"second-write").unwrap();
1754        assert_eq!(first, second, "the same entry keeps the same failed filename");
1755        assert_eq!(store.failed_len(), 1, "a repeated move yields exactly one failed file");
1756        assert_eq!(
1757            store.failed_len(),
1758            count_failed_files_on_disk(&dir),
1759            "an overwrite leaves the cached count matching the directory truth"
1760        );
1761        assert_eq!(
1762            std::fs::read(dir.join("failed").join(&second)).unwrap(),
1763            b"second-write",
1764            "the re-move replaces the earlier file"
1765        );
1766
1767        let _ = store.delete();
1768    }
1769
1770    // The failed filename is used inside the failed directory, so a name that could address a path
1771    // outside it is rejected before any write.
1772    #[test]
1773    fn failed_store_write_rejects_a_path_escaping_entry_name() {
1774        let dir = temp_store_dir("failed-name-guard");
1775        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1776        store.open().unwrap();
1777
1778        for name in ["", ".", "..", "a/b", "a\\b"] {
1779            assert!(store.put_failed_raw(name, b"payload").is_err(), "name {name:?} is rejected");
1780        }
1781        assert_eq!(store.failed_len(), 0, "no rejected name produced a file");
1782
1783        let _ = store.delete();
1784    }
1785
1786    // A per-entry NotFound stat inside the ordered failed scan means the file was removed between
1787    // the directory listing and the stat, so that entry is skipped and the scan completes. Any other
1788    // stat error still fails the scan.
1789    #[test]
1790    fn failed_scan_skips_an_entry_removed_mid_scan() {
1791        let dir = temp_store_dir("failed-scan-skip");
1792        std::fs::create_dir_all(&dir).unwrap();
1793        let probe = dir.join("probe");
1794        std::fs::write(&probe, b"probe").unwrap();
1795        let metadata = std::fs::metadata(&probe).unwrap();
1796
1797        let present = QueueStore::<String>::failed_scan_entry_metadata(Ok(metadata)).unwrap();
1798        assert!(present.is_some(), "a present entry passes its metadata through");
1799
1800        let removed =
1801            QueueStore::<String>::failed_scan_entry_metadata(Err(std::io::Error::from(std::io::ErrorKind::NotFound))).unwrap();
1802        assert!(removed.is_none(), "a concurrently removed entry is skipped, not an error");
1803
1804        let denied =
1805            QueueStore::<String>::failed_scan_entry_metadata(Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)));
1806        assert!(denied.is_err(), "a non-NotFound stat error still fails the scan");
1807
1808        let _ = std::fs::remove_dir_all(&dir);
1809    }
1810
1811    // The failed store is bounded independently of the live queue. A live queue at its limit does not
1812    // consume failed-store capacity and the reverse holds.
1813    #[test]
1814    fn failed_store_is_separate_from_the_live_queue_limit() {
1815        let dir = temp_store_dir("failed-separate");
1816        let store = QueueStore::<String>::new_with_compression(&dir, 2, ".test", false);
1817        store.open().unwrap();
1818
1819        store.put_raw(b"live-1").unwrap();
1820        store.put_raw(b"live-2").unwrap();
1821        assert!(matches!(store.put_raw(b"live-3"), Err(StoreError::LimitExceeded)));
1822
1823        // The live queue is full, yet failed writes still succeed and grow the separate failed store.
1824        store.put_failed_raw("failed-1", b"failed-1").unwrap();
1825        store.put_failed_raw("failed-2", b"failed-2").unwrap();
1826        store.put_failed_raw("failed-3", b"failed-3").unwrap();
1827        assert_eq!(store.len(), 2, "the live queue stays at its own limit");
1828        assert_eq!(store.failed_len(), 3, "the failed store grows past the live limit");
1829
1830        let _ = store.delete();
1831    }
1832
1833    // At the count bound the oldest failed entry is dropped to admit the newer one, so a newer
1834    // terminal failure is never lost in preference to an older one. The directory is filled to the
1835    // bound with plain files at distinct write times, then one real failed write triggers the
1836    // capacity trim of the unambiguous oldest.
1837    #[test]
1838    fn failed_store_drops_oldest_at_the_count_bound() {
1839        let dir = temp_store_dir("failed-evict");
1840        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1841        store.open().unwrap();
1842
1843        let failed_dir = dir.join("failed");
1844        std::fs::create_dir_all(&failed_dir).unwrap();
1845
1846        let oldest_name = "0000-oldest";
1847        for index in 0..FAILED_STORE_MAX_ENTRIES {
1848            let name = if index == 0 {
1849                oldest_name.to_string()
1850            } else {
1851                format!("{index:06}-{}", Uuid::new_v4())
1852            };
1853            let path = failed_dir.join(&name);
1854            std::fs::write(&path, b"prefilled").unwrap();
1855            let written_at = UNIX_EPOCH + Duration::from_secs(index as u64);
1856            std::fs::OpenOptions::new()
1857                .write(true)
1858                .open(&path)
1859                .unwrap()
1860                .set_modified(written_at)
1861                .unwrap();
1862        }
1863        // The prefill lands entries directly on disk, so re-open to seed the cached count from the
1864        // failed directory, the same path a restart with existing failed entries takes.
1865        store.open().unwrap();
1866        assert_eq!(store.failed_len(), FAILED_STORE_MAX_ENTRIES);
1867        assert_eq!(
1868            store.failed_len(),
1869            count_failed_files_on_disk(&dir),
1870            "the seeded count matches the directory truth"
1871        );
1872
1873        store.put_failed_raw("failed-newest", b"failed-newest").unwrap();
1874        assert_eq!(store.failed_len(), FAILED_STORE_MAX_ENTRIES, "the bound holds after the capacity trim");
1875        assert_eq!(
1876            store.failed_len(),
1877            count_failed_files_on_disk(&dir),
1878            "the cached count matches the directory truth after the trim"
1879        );
1880        assert!(
1881            !failed_dir.join(oldest_name).exists(),
1882            "the oldest failed entry is trimmed to admit the newer one"
1883        );
1884
1885        let _ = store.delete();
1886    }
1887
1888    // Entries older than the retention bound are removed as expired on the maintenance tick, while
1889    // younger entries survive. The age is measured from the file write time.
1890    #[test]
1891    fn failed_store_prunes_entries_past_the_ttl() {
1892        let dir = temp_store_dir("failed-ttl");
1893        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1894        store.open().unwrap();
1895
1896        let failed_dir = dir.join("failed");
1897
1898        let stale_id = store.put_failed_raw("stale", b"stale").unwrap();
1899        let fresh_id = store.put_failed_raw("fresh", b"fresh").unwrap();
1900
1901        // Age the stale entry past the bound, keep the fresh entry recent.
1902        let stale_time = SystemTime::now() - (FAILED_STORE_TTL + Duration::from_secs(60));
1903        std::fs::OpenOptions::new()
1904            .write(true)
1905            .open(failed_dir.join(&stale_id))
1906            .unwrap()
1907            .set_modified(stale_time)
1908            .unwrap();
1909
1910        let pruned = store.prune_failed_store().unwrap();
1911        assert_eq!(pruned, 1, "one entry is past the retention bound");
1912        assert!(!failed_dir.join(&stale_id).exists(), "the stale entry is removed as expired");
1913        assert!(failed_dir.join(&fresh_id).exists(), "the fresh entry survives");
1914        assert_eq!(store.failed_len(), 1, "the cached count drops with the expired removal");
1915        assert_eq!(
1916            store.failed_len(),
1917            count_failed_files_on_disk(&dir),
1918            "the cached count matches the directory truth after expiry"
1919        );
1920
1921        let _ = store.delete();
1922    }
1923
1924    // Expired-entry removal on a store with no failed directory is a no-op, so a target that never
1925    // failed terminally is not charged a maintenance error on the tick.
1926    #[test]
1927    fn failed_store_prune_is_a_noop_without_a_failed_directory() {
1928        let dir = temp_store_dir("failed-prune-noop");
1929        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1930        store.open().unwrap();
1931
1932        assert_eq!(store.prune_failed_store().unwrap(), 0);
1933        assert!(!dir.join("failed").exists());
1934
1935        let _ = store.delete();
1936    }
1937
1938    // failed_len counts complete failed entries and skips a residual temp file from an interrupted
1939    // write. The count does not remove the temp file. Removal stays on the ordered scan run by the
1940    // expired-entry removal.
1941    #[test]
1942    fn failed_len_counts_real_entries_and_ignores_temp_files() {
1943        let dir = temp_store_dir("failed-len");
1944        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1945        store.open().unwrap();
1946
1947        store.put_failed_raw("complete-one", b"complete-one").unwrap();
1948        store.put_failed_raw("complete-two", b"complete-two").unwrap();
1949        let failed_dir = dir.join("failed");
1950        let residual_temp = failed_dir.join(format!("orphan.{}{}", Uuid::new_v4(), TMP_SUFFIX));
1951        std::fs::write(&residual_temp, b"partial, never renamed").unwrap();
1952
1953        assert_eq!(store.failed_len(), 2, "only complete entries are counted");
1954        assert!(residual_temp.exists(), "the count does not remove the residual temp file");
1955
1956        // The ordered scan run by the expired-entry removal drops the residual temp file.
1957        store.prune_failed_store().unwrap();
1958        assert!(!residual_temp.exists(), "the ordered scan removes the residual temp file");
1959
1960        let _ = store.delete();
1961    }
1962
1963    // The maintenance scan reconciles the cached count to the directory truth, so a hand-planted
1964    // drift is corrected on the next expired-entry removal, bounding external-mutation drift to one
1965    // interval.
1966    #[test]
1967    fn maintenance_reconciles_a_hand_planted_count_drift() {
1968        let dir = temp_store_dir("failed-reconcile");
1969        let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
1970        store.open().unwrap();
1971
1972        store.put_failed_raw("entry-a", b"entry-a").unwrap();
1973        store.put_failed_raw("entry-b", b"entry-b").unwrap();
1974        assert_eq!(store.failed_len(), 2);
1975
1976        // Plant a drift so the cached count disagrees with the directory truth.
1977        store.failed_count.store(99, Ordering::SeqCst);
1978        assert_eq!(store.failed_len(), 99, "the planted drift is observed before the reconcile");
1979
1980        // Both entries are within the retention bound, so the scan removes nothing and reconciles the
1981        // count to the materialized listing.
1982        let pruned = store.prune_failed_store().unwrap();
1983        assert_eq!(pruned, 0, "no entry is past the retention bound");
1984        assert_eq!(store.failed_len(), 2, "the reconcile restores the directory truth");
1985        assert_eq!(store.failed_len(), count_failed_files_on_disk(&dir));
1986
1987        let _ = store.delete();
1988    }
1989
1990    // The maintenance scan holds the seam across its listing and reconcile, so a racing writer waits
1991    // and its increment lands after the reconcile rather than being overwritten by it. The scan
1992    // thread is parked inside the seam by the filesystem write guard, the writer is released against
1993    // the held seam, and the final count must include the write. A scan that does not take the seam
1994    // fails the held-seam wait below.
1995    #[test]
1996    fn maintenance_reconcile_does_not_overwrite_a_concurrent_failed_write() {
1997        let dir = temp_store_dir("failed-reconcile-race");
1998        let store = Arc::new(QueueStore::<String>::new_with_compression(&dir, 8, ".test", false));
1999        store.open().unwrap();
2000
2001        store.put_failed_raw("existing", b"existing").unwrap();
2002        // Plant a drift so the final value proves the reconcile ran and the write survived it. A
2003        // reconcile that overwrites the write ends at 1, a write with no reconcile ends at 100, the
2004        // serialized pair ends at 2.
2005        store.failed_count.store(99, Ordering::SeqCst);
2006
2007        // Hold the filesystem guard so the scan thread stops inside the seam, before its listing.
2008        let fs_block = store.fs_guard.write().unwrap();
2009
2010        let scan_store = Arc::clone(&store);
2011        let scan = thread::spawn(move || scan_store.prune_failed_store());
2012
2013        // Wait for the scan to take the seam. The deadline turns a scan that never takes the seam
2014        // into a failure rather than a hang.
2015        let deadline = std::time::Instant::now() + Duration::from_secs(5);
2016        while store.failed_store_guard.try_lock().is_ok() {
2017            assert!(
2018                std::time::Instant::now() < deadline,
2019                "the maintenance scan never took the failed-store seam"
2020            );
2021            thread::yield_now();
2022        }
2023
2024        // The writer is released while the scan holds the seam, so its write and increment wait for
2025        // the reconcile.
2026        let release = Arc::new(Barrier::new(2));
2027        let writer_store = Arc::clone(&store);
2028        let writer_release = Arc::clone(&release);
2029        let writer = thread::spawn(move || {
2030            writer_release.wait();
2031            writer_store.put_failed_raw("racing", b"racing").unwrap();
2032        });
2033        release.wait();
2034
2035        drop(fs_block);
2036        assert_eq!(scan.join().unwrap().unwrap(), 0, "both entries are within the retention bound");
2037        writer.join().unwrap();
2038
2039        assert_eq!(store.failed_len(), 2, "the reconcile does not overwrite the racing write");
2040        assert_eq!(
2041            store.failed_len(),
2042            count_failed_files_on_disk(&dir),
2043            "the cached count matches the directory truth after the race"
2044        );
2045
2046        let _ = store.delete();
2047    }
2048
2049    // Two writers racing new failed entries at the count bound stay atomic through the seam, so the
2050    // bound holds and the cached count matches the directory truth. Without the seam the at-bound
2051    // check and the write interleave and the count overshoots the bound.
2052    #[test]
2053    fn concurrent_failed_writes_at_the_bound_hold_the_count() {
2054        let dir = temp_store_dir("failed-race-bound");
2055        let store = Arc::new(QueueStore::<String>::new_with_compression(&dir, 8, ".test", false));
2056        store.open().unwrap();
2057
2058        let failed_dir = dir.join("failed");
2059        std::fs::create_dir_all(&failed_dir).unwrap();
2060        for index in 0..FAILED_STORE_MAX_ENTRIES {
2061            let path = failed_dir.join(format!("{index:06}-{}", Uuid::new_v4()));
2062            std::fs::write(&path, b"prefilled").unwrap();
2063            let written_at = UNIX_EPOCH + Duration::from_secs(index as u64);
2064            std::fs::OpenOptions::new()
2065                .write(true)
2066                .open(&path)
2067                .unwrap()
2068                .set_modified(written_at)
2069                .unwrap();
2070        }
2071        // Seed the cached count from the prefilled directory, the path a restart with existing entries takes.
2072        store.open().unwrap();
2073        assert_eq!(store.failed_len(), FAILED_STORE_MAX_ENTRIES);
2074
2075        let start = Arc::new(Barrier::new(2));
2076        let mut handles = Vec::new();
2077        for writer in 0..2 {
2078            let store = Arc::clone(&store);
2079            let start = Arc::clone(&start);
2080            handles.push(thread::spawn(move || {
2081                start.wait();
2082                store.put_failed_raw(&format!("racing-newest-{writer}"), b"newest").unwrap();
2083            }));
2084        }
2085        for handle in handles {
2086            handle.join().unwrap();
2087        }
2088
2089        assert_eq!(store.failed_len(), FAILED_STORE_MAX_ENTRIES, "the bound holds after two racing writes");
2090        assert_eq!(
2091            store.failed_len(),
2092            count_failed_files_on_disk(&dir),
2093            "the cached count matches the directory truth after the race"
2094        );
2095
2096        let _ = store.delete();
2097    }
2098
2099    // Two writers racing the same failed entry name produce one failed file and one count increment,
2100    // so a concurrent same-key move cannot double-count.
2101    #[test]
2102    fn concurrent_same_key_failed_writes_count_once() {
2103        let dir = temp_store_dir("failed-race-same-key");
2104        let store = Arc::new(QueueStore::<String>::new_with_compression(&dir, 8, ".test", false));
2105        store.open().unwrap();
2106
2107        let start = Arc::new(Barrier::new(2));
2108        let mut handles = Vec::new();
2109        for _ in 0..2 {
2110            let store = Arc::clone(&store);
2111            let start = Arc::clone(&start);
2112            handles.push(thread::spawn(move || {
2113                start.wait();
2114                store.put_failed_raw("same-entry", b"payload").unwrap();
2115            }));
2116        }
2117        for handle in handles {
2118            handle.join().unwrap();
2119        }
2120
2121        assert_eq!(store.failed_len(), 1, "two racing writes of one name count once");
2122        assert_eq!(
2123            store.failed_len(),
2124            count_failed_files_on_disk(&dir),
2125            "the cached count matches the directory truth"
2126        );
2127
2128        let _ = store.delete();
2129    }
2130}