1use 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
39const TMP_SUFFIX: &str = ".tmp";
43
44const MAX_PREALLOC_CAPACITY: usize = 4096;
49
50fn 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
64const BATCH_COUNT_SEPARATOR: char = '_';
68
69const LEGACY_BATCH_COUNT_SEPARATOR: char = ':';
72
73const FAILED_STORE_SUBDIR: &str = "failed";
77
78const FAILED_STORE_MAX_ENTRIES: usize = 10_000;
83
84const FAILED_STORE_TTL: Duration = Duration::from_secs(72 * 60 * 60);
87
88fn 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
108fn 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#[derive(Debug, Clone)]
130pub struct Key {
131 pub name: String,
133 pub extension: String,
135 pub item_count: usize,
137 pub compress: bool,
139}
140
141impl Key {
142 pub fn to_key_string(&self) -> String {
144 self.to_key_string_with(BATCH_COUNT_SEPARATOR)
145 }
146
147 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
173pub 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 if name.ends_with(COMPRESS_EXT) {
191 compress = true;
192 name = name[..name.len() - COMPRESS_EXT.len()].to_string();
193 }
194
195 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 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
256pub trait Store<T>: Send + Sync
258where
259 T: Send + Sync + 'static + Clone + Serialize,
260{
261 type Error;
263 type Key;
265
266 fn open(&self) -> Result<(), Self::Error>;
268
269 fn put(&self, item: Arc<T>) -> Result<Self::Key, Self::Error>;
271
272 fn put_multiple(&self, items: Vec<T>) -> Result<Self::Key, Self::Error>;
274
275 fn put_raw(&self, data: &[u8]) -> Result<Self::Key, Self::Error>;
277
278 fn get(&self, key: &Self::Key) -> Result<T, Self::Error>;
280
281 fn get_multiple(&self, key: &Self::Key) -> Result<Vec<T>, Self::Error>;
283
284 fn get_raw(&self, key: &Self::Key) -> Result<Vec<u8>, Self::Error>;
286
287 fn del(&self, key: &Self::Key) -> Result<(), Self::Error>;
289
290 fn delete(&self) -> Result<(), Self::Error>;
292
293 fn list(&self) -> Vec<Self::Key>;
295
296 fn len(&self) -> usize;
298
299 fn is_empty(&self) -> bool;
301
302 fn boxed_clone(&self) -> Box<dyn Store<T, Error = Self::Error, Key = Self::Key> + Send + Sync>;
304}
305
306pub trait FailedEventStore: Send + Sync {
313 fn put_failed_raw(&self, entry_name: &str, data: &[u8]) -> Result<String, StoreError>;
319
320 fn prune_failed_store(&self) -> Result<usize, StoreError>;
324
325 fn failed_len(&self) -> usize;
327
328 fn boxed_clone_failed(&self) -> Box<dyn FailedEventStore>;
331}
332
333pub struct QueueStore<T> {
335 entry_limit: u64,
336 directory: PathBuf,
337 file_ext: String,
338 compress: bool,
339 entries: Arc<RwLock<HashMap<String, i64>>>, pending_entries: Arc<AtomicU64>,
341 failed_count: Arc<AtomicU64>,
345 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 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 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 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 name: Uuid::now_v7().to_string(),
428 extension: self.file_ext.clone(),
429 item_count,
430 compress: self.compress,
431 }
432 }
433
434 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 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 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 write_temp_then_rename(&tmp_path, &path, &payload)?;
566
567 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 fn failed_dir(&self) -> PathBuf {
613 self.directory.join(FAILED_STORE_SUBDIR)
614 }
615
616 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 if matches!(entry.file_type(), Ok(file_type) if file_type.is_file()) {
632 count += 1;
633 }
634 }
635 count
636 }
637
638 fn decrement_failed_count(&self) {
641 let _ = self
643 .failed_count
644 .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| Some(current.saturating_sub(1)));
645 }
646
647 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 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 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 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 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 if file_name.ends_with(TMP_SUFFIX) {
765 let _ = std::fs::remove_file(entry.path());
766 continue;
767 }
768
769 if !is_queue_file_name(&file_name, &self.file_ext) {
772 continue;
773 }
774
775 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 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 let mut items = Vec::with_capacity(key.item_count.min(MAX_PREALLOC_CAPACITY));
878
879 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 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 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 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 let mut entries = self
974 .entries
975 .write()
976 .map_err(|_| StoreError::Internal("Failed to acquire write lock on entries".to_string()))?;
977
978 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 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 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 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 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 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 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 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 write_temp_then_rename(&temp_path, &final_path, data)?;
1135
1136 if is_new_entry {
1139 self.failed_count.fetch_add(1, Ordering::SeqCst);
1140 }
1141
1142 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 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 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 self.failed_count.store((materialized_len - pruned) as u64, Ordering::SeqCst);
1206 Ok(pruned)
1207 }
1208
1209 fn failed_len(&self) -> usize {
1210 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 #[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 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 #[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 #[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 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 let err = store.get_multiple(&key).unwrap_err();
1388 assert!(
1389 matches!(err, StoreError::Deserialization(_)),
1390 "expected Deserialization error, got {err:?}"
1391 );
1392
1393 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 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 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 let key = store.put(Arc::new("payload".to_string())).unwrap();
1477 let good_name = key.to_key_string();
1478
1479 let tmp_path = dir.join(format!("{good_name}{TMP_SUFFIX}"));
1482 std::fs::write(&tmp_path, b"half-written").unwrap();
1483
1484 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 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 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 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 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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 store.failed_count.store(99, Ordering::SeqCst);
1978 assert_eq!(store.failed_len(), 99, "the planted drift is observed before the reconcile");
1979
1980 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 #[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 store.failed_count.store(99, Ordering::SeqCst);
2006
2007 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 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 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 #[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 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 #[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}