Skip to main content

laminar_connectors/files/
manifest.rs

1//! Exact file ingestion inventory.
2//!
3//! Only immutable file identities (paths) are correctness state. The exact set
4//! supports logarithmic membership/insertion, while an immutable serialized
5//! fragment log makes source-checkpoint snapshots constant-time.
6
7use std::collections::BTreeSet;
8use std::fmt;
9use std::sync::{Arc, RwLock};
10
11use crate::checkpoint::{PersistentOffset, SourceCheckpoint};
12
13/// Read-only exact membership view used by the discovery task.
14///
15/// The view shares the live path index, so newly completed files become known
16/// without rebuilding or copying the full inventory.
17#[derive(Clone)]
18pub(super) struct FileInventorySnapshot {
19    paths: Arc<RwLock<BTreeSet<String>>>,
20}
21
22impl FileInventorySnapshot {
23    /// Returns `true` only when the exact path has been ingested.
24    pub(super) fn contains(&self, path: &str) -> bool {
25        self.paths
26            .read()
27            .unwrap_or_else(std::sync::PoisonError::into_inner)
28            .contains(path)
29    }
30}
31
32impl fmt::Debug for FileInventorySnapshot {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.debug_struct("FileInventorySnapshot")
35            .field(
36                "path_count",
37                &self
38                    .paths
39                    .read()
40                    .unwrap_or_else(std::sync::PoisonError::into_inner)
41                    .len(),
42            )
43            .finish()
44    }
45}
46
47/// Tracks every ingested file with exact membership.
48pub struct FileIngestionManifest {
49    paths: Arc<RwLock<BTreeSet<String>>>,
50    serialized_paths: PersistentOffset,
51}
52
53impl FileIngestionManifest {
54    /// Creates an empty manifest.
55    #[must_use]
56    pub fn new() -> Self {
57        Self {
58            paths: Arc::new(RwLock::new(BTreeSet::new())),
59            serialized_paths: PersistentOffset::new("[", ",", "]"),
60        }
61    }
62
63    /// Returns `true` only when the exact path has been ingested.
64    pub fn contains(&self, path: &str) -> bool {
65        self.paths
66            .read()
67            .unwrap_or_else(std::sync::PoisonError::into_inner)
68            .contains(path)
69    }
70
71    /// Inserts an immutable file identity in `O(log N)`.
72    pub fn insert(&mut self, path: String) {
73        let fragment = serde_json::Value::String(path.clone()).to_string();
74        let inserted = self
75            .paths
76            .write()
77            .unwrap_or_else(std::sync::PoisonError::into_inner)
78            .insert(path);
79        if inserted {
80            self.serialized_paths.push_fragment(fragment);
81        }
82    }
83
84    /// Returns the number of processed paths.
85    #[must_use]
86    pub fn processed_count(&self) -> usize {
87        self.paths
88            .read()
89            .unwrap_or_else(std::sync::PoisonError::into_inner)
90            .len()
91    }
92
93    /// Creates an `O(1)` exact membership view for the discovery engine.
94    #[must_use]
95    pub(super) fn snapshot_for_dedup(&self) -> FileInventorySnapshot {
96        FileInventorySnapshot {
97            paths: Arc::clone(&self.paths),
98        }
99    }
100
101    /// Adds the immutable serialized inventory snapshot to a source checkpoint
102    /// without materializing or copying the full path set.
103    pub fn to_checkpoint(&self, checkpoint: &mut SourceCheckpoint) {
104        checkpoint.set_persistent_offset("manifest", self.serialized_paths.clone());
105    }
106
107    /// Restores the exact inventory from a durable checkpoint.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if deserialization fails.
112    pub fn from_checkpoint(checkpoint: &SourceCheckpoint) -> Result<Self, serde_json::Error> {
113        let paths: BTreeSet<String> = match checkpoint.get_offset("manifest") {
114            Some(json) => serde_json::from_str(json)?,
115            None => BTreeSet::new(),
116        };
117        let mut manifest = Self::new();
118        for path in paths {
119            manifest.insert(path);
120        }
121        Ok(manifest)
122    }
123}
124
125impl Default for FileIngestionManifest {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl fmt::Debug for FileIngestionManifest {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.debug_struct("FileIngestionManifest")
134            .field("path_count", &self.processed_count())
135            .field(
136                "serialized_fragments",
137                &self.serialized_paths.fragment_count(),
138            )
139            .finish()
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn insert_and_exact_membership() {
149        let mut manifest = FileIngestionManifest::new();
150        assert!(!manifest.contains("a.csv"));
151        manifest.insert("a.csv".into());
152        assert!(manifest.contains("a.csv"));
153        assert!(!manifest.contains("b.csv"));
154    }
155
156    #[test]
157    fn inventory_never_evicts_or_false_positives() {
158        let mut manifest = FileIngestionManifest::new();
159        for i in 0..10_000 {
160            manifest.insert(format!("file_{i}.csv"));
161        }
162        assert_eq!(manifest.processed_count(), 10_000);
163        for i in 10_000..20_000 {
164            assert!(!manifest.contains(&format!("file_{i}.csv")));
165        }
166    }
167
168    #[test]
169    fn checkpoint_snapshot_is_immutable_and_roundtrips() {
170        let mut manifest = FileIngestionManifest::new();
171        manifest.insert("a.csv".into());
172        let mut old_checkpoint = SourceCheckpoint::new();
173        manifest.to_checkpoint(&mut old_checkpoint);
174
175        manifest.insert("b.csv".into());
176        let mut new_checkpoint = SourceCheckpoint::new();
177        manifest.to_checkpoint(&mut new_checkpoint);
178
179        let old = FileIngestionManifest::from_checkpoint(&old_checkpoint).unwrap();
180        assert!(old.contains("a.csv"));
181        assert!(!old.contains("b.csv"));
182        let new = FileIngestionManifest::from_checkpoint(&new_checkpoint).unwrap();
183        assert!(new.contains("a.csv"));
184        assert!(new.contains("b.csv"));
185    }
186
187    #[test]
188    fn discovery_snapshot_is_constant_time_live_exact_view() {
189        let mut manifest = FileIngestionManifest::new();
190        manifest.insert("a.csv".into());
191        let snapshot = manifest.snapshot_for_dedup();
192        manifest.insert("b.csv".into());
193
194        assert!(snapshot.contains("a.csv"));
195        assert!(snapshot.contains("b.csv"));
196        assert!(!snapshot.contains("unknown.csv"));
197    }
198
199    #[test]
200    fn duplicate_insert_does_not_grow_serialized_inventory() {
201        let mut manifest = FileIngestionManifest::new();
202        manifest.insert("a.csv".into());
203        manifest.insert("a.csv".into());
204        assert_eq!(manifest.processed_count(), 1);
205        assert_eq!(manifest.serialized_paths.fragment_count(), 1);
206    }
207
208    #[test]
209    fn empty_manifest_checkpoint_roundtrip() {
210        let manifest = FileIngestionManifest::new();
211        let mut checkpoint = SourceCheckpoint::new();
212        manifest.to_checkpoint(&mut checkpoint);
213        let restored = FileIngestionManifest::from_checkpoint(&checkpoint).unwrap();
214        assert_eq!(restored.processed_count(), 0);
215    }
216}