laminar_connectors/files/
manifest.rs1use std::collections::BTreeSet;
8use std::fmt;
9use std::sync::{Arc, RwLock};
10
11use crate::checkpoint::{PersistentOffset, SourceCheckpoint};
12
13#[derive(Clone)]
18pub(super) struct FileInventorySnapshot {
19 paths: Arc<RwLock<BTreeSet<String>>>,
20}
21
22impl FileInventorySnapshot {
23 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
47pub struct FileIngestionManifest {
49 paths: Arc<RwLock<BTreeSet<String>>>,
50 serialized_paths: PersistentOffset,
51}
52
53impl FileIngestionManifest {
54 #[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 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 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 #[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 #[must_use]
95 pub(super) fn snapshot_for_dedup(&self) -> FileInventorySnapshot {
96 FileInventorySnapshot {
97 paths: Arc::clone(&self.paths),
98 }
99 }
100
101 pub fn to_checkpoint(&self, checkpoint: &mut SourceCheckpoint) {
104 checkpoint.set_persistent_offset("manifest", self.serialized_paths.clone());
105 }
106
107 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}