1use std::collections::VecDeque;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use pamoja_core::{Error, Result, Store};
9
10const RECORD_EXTENSION: &str = "rec";
12
13const PARTIAL_EXTENSION: &str = "rec.tmp";
15
16pub struct FileStore {
49 dir: PathBuf,
50 pending: VecDeque<u64>,
51 next: u64,
52 capacity: Option<usize>,
53}
54
55impl FileStore {
56 pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
76 let dir = dir.as_ref().to_path_buf();
77 fs::create_dir_all(&dir).map_err(io)?;
78
79 let mut sequences = Vec::new();
80 for entry in fs::read_dir(&dir).map_err(io)? {
81 let path = entry.map_err(io)?.path();
82 let name = path.file_name().and_then(|name| name.to_str());
83 if name.is_some_and(|name| name.ends_with(PARTIAL_EXTENSION)) {
84 fs::remove_file(&path).map_err(io)?;
85 continue;
86 }
87 if path.extension().and_then(|ext| ext.to_str()) != Some(RECORD_EXTENSION) {
88 continue;
89 }
90 if let Some(sequence) = path
91 .file_stem()
92 .and_then(|stem| stem.to_str())
93 .and_then(|stem| stem.parse::<u64>().ok())
94 {
95 sequences.push(sequence);
96 }
97 }
98 sequences.sort_unstable();
99 let next = sequences.last().map_or(0, |last| last + 1);
100
101 Ok(Self {
102 dir,
103 pending: sequences.into(),
104 next,
105 capacity: None,
106 })
107 }
108
109 pub fn open_with_capacity(dir: impl AsRef<Path>, capacity: usize) -> Result<Self> {
129 let mut store = Self::open(dir)?;
130 store.capacity = Some(capacity);
131 Ok(store)
132 }
133
134 fn record_path(&self, sequence: u64) -> PathBuf {
136 self.dir.join(format!("{sequence:020}.{RECORD_EXTENSION}"))
137 }
138
139 fn sync_dir(&self) -> Result<()> {
141 #[cfg(unix)]
142 fs::File::open(&self.dir)
143 .and_then(|dir| dir.sync_all())
144 .map_err(io)?;
145 Ok(())
146 }
147}
148
149impl Store for FileStore {
150 async fn append(&mut self, record: &[u8]) -> Result<()> {
151 if self
152 .capacity
153 .is_some_and(|capacity| self.pending.len() >= capacity)
154 {
155 return Err(Error::Io("store is at capacity".to_owned()));
156 }
157 let sequence = self.next;
158 let final_path = self.record_path(sequence);
159 let temp_path = final_path.with_extension(PARTIAL_EXTENSION);
160
161 let mut file = fs::File::create(&temp_path).map_err(io)?;
162 file.write_all(record).map_err(io)?;
163 file.sync_all().map_err(io)?;
164 drop(file);
165 fs::rename(&temp_path, &final_path).map_err(io)?;
166 self.sync_dir()?;
167
168 self.next += 1;
169 self.pending.push_back(sequence);
170 Ok(())
171 }
172
173 async fn peek(&self) -> Result<Option<Vec<u8>>> {
174 let Some(&sequence) = self.pending.front() else {
175 return Ok(None);
176 };
177 let record = fs::read(self.record_path(sequence)).map_err(io)?;
178 Ok(Some(record))
179 }
180
181 async fn pop(&mut self) -> Result<Option<Vec<u8>>> {
182 let Some(sequence) = self.pending.pop_front() else {
183 return Ok(None);
184 };
185 let path = self.record_path(sequence);
186 let record = fs::read(&path).map_err(io)?;
187 fs::remove_file(&path).map_err(io)?;
188 Ok(Some(record))
189 }
190
191 async fn len(&self) -> Result<usize> {
192 Ok(self.pending.len())
193 }
194}
195
196fn io(error: std::io::Error) -> Error {
198 Error::Io(error.to_string())
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[tokio::test]
206 async fn drains_in_first_in_first_out_order() {
207 let dir = tempfile::tempdir().expect("tempdir");
208 let mut store = FileStore::open(dir.path()).expect("open");
209
210 store.append(b"a").await.expect("append");
211 store.append(b"b").await.expect("append");
212 assert_eq!(store.len().await.expect("len"), 2);
213
214 assert_eq!(store.pop().await.expect("pop"), Some(b"a".to_vec()));
215 assert_eq!(store.pop().await.expect("pop"), Some(b"b".to_vec()));
216 assert_eq!(store.pop().await.expect("pop"), None);
217 }
218
219 #[tokio::test]
220 async fn records_survive_reopening() {
221 let dir = tempfile::tempdir().expect("tempdir");
222 {
223 let mut store = FileStore::open(dir.path()).expect("open");
224 store.append(b"durable").await.expect("append");
225 store.append(b"records").await.expect("append");
226 }
227
228 let mut reopened = FileStore::open(dir.path()).expect("reopen");
229 assert_eq!(reopened.len().await.expect("len"), 2);
230 assert_eq!(
231 reopened.pop().await.expect("pop"),
232 Some(b"durable".to_vec())
233 );
234 assert_eq!(
235 reopened.pop().await.expect("pop"),
236 Some(b"records".to_vec())
237 );
238 }
239
240 #[tokio::test]
241 async fn peek_reads_the_oldest_without_removing_it() {
242 let dir = tempfile::tempdir().expect("tempdir");
243 let mut store = FileStore::open(dir.path()).expect("open");
244 store.append(b"oldest").await.expect("append");
245 store.append(b"newer").await.expect("append");
246
247 assert_eq!(store.peek().await.expect("peek"), Some(b"oldest".to_vec()));
248 assert_eq!(store.len().await.expect("len"), 2);
249 assert_eq!(store.pop().await.expect("pop"), Some(b"oldest".to_vec()));
250 }
251
252 #[tokio::test]
253 async fn popping_removes_the_record_file() {
254 let dir = tempfile::tempdir().expect("tempdir");
255 let mut store = FileStore::open(dir.path()).expect("open");
256 store.append(b"once").await.expect("append");
257 let _ = store.pop().await.expect("pop");
258
259 let reopened = FileStore::open(dir.path()).expect("reopen");
260 assert!(reopened.is_empty().await.expect("is_empty"));
261 }
262
263 #[tokio::test]
264 async fn a_record_interrupted_mid_write_is_never_seen_and_is_cleared() {
265 let dir = tempfile::tempdir().expect("tempdir");
266 {
267 let mut store = FileStore::open(dir.path()).expect("open");
268 store.append(b"whole").await.expect("append");
269 }
270 let partial = dir.path().join(format!("{:020}.{PARTIAL_EXTENSION}", 1));
271 fs::write(&partial, b"hal").expect("a half-written record");
272
273 let mut reopened = FileStore::open(dir.path()).expect("reopen");
274 assert_eq!(reopened.len().await.expect("len"), 1);
275 assert!(!partial.exists(), "the interrupted write was cleared");
276 assert_eq!(reopened.pop().await.expect("pop"), Some(b"whole".to_vec()));
277 reopened.append(b"next").await.expect("append");
278 assert_eq!(reopened.pop().await.expect("pop"), Some(b"next".to_vec()));
279 }
280
281 #[tokio::test]
282 async fn a_bounded_store_refuses_the_append_that_would_overflow_it() {
283 let dir = tempfile::tempdir().expect("tempdir");
284 let mut store = FileStore::open_with_capacity(dir.path(), 2).expect("open");
285 store.append(b"1").await.expect("room");
286 store.append(b"2").await.expect("room");
287
288 match store.append(b"3").await {
289 Err(Error::Io(reason)) => assert_eq!(reason, "store is at capacity"),
290 other => panic!("a full store took a record: {other:?}"),
291 }
292 assert_eq!(store.len().await.expect("len"), 2);
293
294 let mut smaller = FileStore::open_with_capacity(dir.path(), 1).expect("reopen");
295 assert_eq!(
296 smaller.len().await.expect("len"),
297 2,
298 "a smaller bound keeps what is there"
299 );
300 assert!(smaller.append(b"3").await.is_err());
301 smaller.pop().await.expect("pop");
302 smaller.pop().await.expect("pop");
303 smaller.append(b"3").await.expect("room once drained");
304 }
305}