1use std::cmp;
5use std::collections::HashSet;
6use std::ffi::OsString;
7use std::fmt;
8use std::fs;
9use std::fs::OpenOptions;
10use std::io::{ErrorKind, Write};
11use std::os::unix::ffi::OsStrExt;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::thread;
15use std::time::Duration;
16use std::time::SystemTime;
17use std::{collections::HashMap, pin::Pin};
18
19use anyhow::Context as _;
20use async_trait::async_trait;
21use futures::StreamExt;
22use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, event};
23use parking_lot::Mutex;
24use tokio_util::sync::CancellationToken;
25
26use super::{Bucket, Key, KeyValue, Store, StoreError, StoreOutcome, WatchEvent};
27
28const DEFAULT_TTL: Duration = Duration::from_secs(10);
31
32const MIN_KEEP_ALIVE: Duration = Duration::from_secs(1);
34
35const TEMP_FILE_PREFIX: &str = ".tmp_";
38const TEMP_FILE_CREATE_ATTEMPTS: usize = 16;
39
40#[derive(Clone)]
42pub struct FileStore {
43 cancel_token: CancellationToken,
44 root: PathBuf,
45 connection_id: u64,
46 active_dirs: Arc<Mutex<HashMap<PathBuf, Directory>>>,
49}
50
51impl FileStore {
52 pub(super) fn new<P: Into<PathBuf>>(cancel_token: CancellationToken, root_dir: P) -> Self {
53 let fs = FileStore {
54 cancel_token,
55 root: root_dir.into(),
56 connection_id: rand::random::<u64>(),
57 active_dirs: Arc::new(Mutex::new(HashMap::new())),
58 };
59 let c = fs.clone();
60 thread::spawn(move || c.expiry_thread());
61 fs
62 }
63
64 fn expiry_thread(&self) {
70 loop {
71 let ttl = self.shortest_ttl();
72 let keep_alive_interval = cmp::max(ttl / 3, MIN_KEEP_ALIVE);
73
74 if self.cancel_token.is_cancelled() {
76 break;
77 }
78
79 thread::sleep(keep_alive_interval);
80
81 if self.cancel_token.is_cancelled() {
82 break;
83 }
84
85 self.keep_alive();
86 if let Err(err) = self.delete_expired_files() {
87 tracing::error!(error = %err, "FileStore delete_expired_files");
88 }
89 }
90 }
91
92 fn shortest_ttl(&self) -> Duration {
94 let mut ttl = DEFAULT_TTL;
95 let active_dirs = self.active_dirs.lock().clone();
96 for (_, dir) in active_dirs {
97 ttl = cmp::min(ttl, dir.ttl);
98 }
99 tracing::trace!("FileStore expiry shortest ttl {ttl:?}");
100 ttl
101 }
102
103 fn keep_alive(&self) {
104 let active_dirs = self.active_dirs.lock().clone();
105 for (_, dir) in active_dirs {
106 dir.keep_alive();
107 }
108 }
109
110 fn delete_expired_files(&self) -> anyhow::Result<()> {
111 let active_dirs = self.active_dirs.lock().clone();
112 for (path, dir) in active_dirs {
113 dir.delete_expired_files()
114 .with_context(|| path.display().to_string())?;
115 }
116 Ok(())
117 }
118}
119
120#[async_trait]
121impl Store for FileStore {
122 type Bucket = Directory;
123
124 async fn get_or_create_bucket(
126 &self,
127 bucket_name: &str,
128 ttl: Option<Duration>,
129 ) -> Result<Self::Bucket, StoreError> {
130 let p = self.root.join(bucket_name);
131 if let Some(dir) = self.active_dirs.lock().get(&p) {
132 return Ok(dir.clone());
133 };
134
135 if p.exists() {
136 if !p.is_dir() {
138 return Err(StoreError::FilesystemError(
139 "Bucket name is not a directory".to_string(),
140 ));
141 }
142 } else {
143 fs::create_dir_all(&p).map_err(to_fs_err)?;
145 }
146 let dir = Directory::new(self.root.clone(), p.clone(), ttl.unwrap_or(DEFAULT_TTL));
147 self.active_dirs.lock().insert(p, dir.clone());
148 Ok(dir)
149 }
150
151 async fn get_bucket(&self, bucket_name: &str) -> Result<Option<Self::Bucket>, StoreError> {
153 let p = self.root.join(bucket_name);
154 if let Some(dir) = self.active_dirs.lock().get(&p) {
155 return Ok(Some(dir.clone()));
156 };
157
158 if !p.exists() {
159 return Ok(None);
160 }
161 if !p.is_dir() {
162 return Err(StoreError::FilesystemError(
163 "Bucket name is not a directory".to_string(),
164 ));
165 }
166 let dir = Directory::new(self.root.clone(), p.clone(), DEFAULT_TTL);
168 self.active_dirs.lock().insert(p, dir.clone());
169 Ok(Some(dir))
170 }
171
172 fn connection_id(&self) -> u64 {
173 self.connection_id
174 }
175
176 fn shutdown(&self) {
179 for (_, mut dir) in self.active_dirs.lock().drain() {
180 if let Err(err) = dir.delete_owned_files() {
181 tracing::error!(error = %err, %dir, "Failed shutdown delete of owned files");
182 }
183 }
184 }
185}
186
187#[derive(Clone)]
188pub struct Directory {
189 root: PathBuf,
190 p: PathBuf,
191 ttl: Duration,
192 owned_files: Arc<Mutex<HashSet<PathBuf>>>,
194}
195
196impl Directory {
197 fn new(root: PathBuf, p: PathBuf, ttl: Duration) -> Self {
198 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
200 if ttl < MIN_KEEP_ALIVE {
201 let h_ttl = humantime::format_duration(ttl);
202 tracing::warn!(path = %p.display(), ttl = %h_ttl, "ttl is too short, increasing to {}", humantime::format_duration(MIN_KEEP_ALIVE));
203 }
204 let ttl = cmp::max(ttl, MIN_KEEP_ALIVE);
205 Directory {
206 root: canonical_root,
207 p,
208 ttl,
209 owned_files: Arc::new(Mutex::new(HashSet::new())),
210 }
211 }
212
213 fn keep_alive(&self) {
215 let owned_files = self.owned_files.lock().clone();
216 for path in owned_files {
217 let file = match OpenOptions::new().write(true).open(&path) {
218 Ok(f) => f,
219 Err(err) => {
220 tracing::error!(path = %path.display(), error = %err, "FileStore::keep_alive failed opening owned file");
221 continue;
222 }
223 };
224 if let Err(err) = file.set_modified(SystemTime::now()) {
225 tracing::error!(path = %path.display(), error = %err, "FileStore::keep_alive failed set_modified on owned file");
226 continue;
227 }
228 tracing::trace!("FileStore keep_alive set {}", path.display());
229 }
230 }
231
232 fn delete_expired_files(&self) -> anyhow::Result<()> {
237 let deadline = SystemTime::now() - self.ttl;
238 let dirname = self.p.display().to_string();
239 for entry in fs::read_dir(&self.p).with_context(|| dirname.clone())? {
240 let entry = match entry {
241 Ok(p) => p,
242 Err(err) => {
243 tracing::warn!(dir = dirname, error = %err, "File store could read directory contents");
244 continue;
245 }
246 };
247 if !entry.file_type().map(|f| f.is_file()).unwrap_or(false) {
248 tracing::warn!(dir = dirname, entry = %entry.path().display(), "File store directory should only contain files");
249 continue;
250 }
251 let ctx = entry.path().display().to_string();
252 let metadata = match entry.metadata() {
253 Ok(m) => m,
254 Err(err) => {
255 tracing::warn!(path = %ctx, error = %err, "Failed fetching metadata");
256 continue;
257 }
258 };
259 let last_modified = match metadata.modified() {
260 Ok(lm) => lm,
261 Err(err) => {
262 tracing::warn!(path = %ctx, error = %err, "Failed reading mtime");
265 continue;
266 }
267 };
268 if last_modified < deadline {
269 tracing::info!(path = ctx, ?last_modified, "Expired");
270 if let Err(err) = fs::remove_file(entry.path()) {
271 tracing::warn!(path = %ctx, error = %err, "Failed removing");
272 }
273 }
274 }
275 Ok(())
276 }
277
278 fn delete_owned_files(&mut self) -> anyhow::Result<()> {
279 let mut errs = Vec::new();
280 for p in self.owned_files.lock().drain() {
281 if let Err(err) = fs::remove_file(&p) {
282 errs.push(format!("{}: {err}", p.display()));
283 }
284 }
285 if !errs.is_empty() {
286 anyhow::bail!(errs.join(", "));
287 }
288 Ok(())
289 }
290
291 fn write_temp_file(&self, value: &[u8]) -> Result<PathBuf, StoreError> {
292 for _ in 0..TEMP_FILE_CREATE_ATTEMPTS {
293 let temp_name = format!("{TEMP_FILE_PREFIX}{:016x}", rand::random::<u64>());
294 let temp_path = self.p.join(&temp_name);
295 if write_temp_file_at(&temp_path, value)? {
296 return Ok(temp_path);
297 }
298 }
299
300 Err(StoreError::FilesystemError(format!(
301 "failed to create unique FileStore temp file in {} after {TEMP_FILE_CREATE_ATTEMPTS} attempts",
302 self.p.display()
303 )))
304 }
305}
306
307impl fmt::Display for Directory {
308 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
309 write!(f, "{}", self.p.display())
310 }
311}
312
313#[async_trait]
314impl Bucket for Directory {
315 async fn insert(
321 &self,
322 key: &Key,
323 value: bytes::Bytes,
324 revision: u64,
325 ) -> Result<StoreOutcome, StoreError> {
326 let safe_key = key.url_safe();
327 let full_path = self.p.join(safe_key.as_ref());
328 let str_path = full_path.display().to_string();
329
330 let temp_path = self.write_temp_file(&value)?;
331
332 if revision == 0 {
333 match fs::hard_link(&temp_path, &full_path) {
336 Ok(()) => {
337 if let Err(err) = fs::remove_file(&temp_path) {
338 tracing::warn!(
339 path = %temp_path.display(),
340 error = %err,
341 "Failed to remove FileStore temp file after create-if-absent publish"
342 );
343 }
344 self.owned_files.lock().insert(full_path.clone());
345 return Ok(StoreOutcome::Created(0));
346 }
347 Err(err) if err.kind() == ErrorKind::AlreadyExists => {
348 if let Err(remove_err) = fs::remove_file(&temp_path) {
349 tracing::warn!(
350 path = %temp_path.display(),
351 error = %remove_err,
352 "Failed to remove unused FileStore temp file after create-if-absent conflict"
353 );
354 }
355 return Ok(StoreOutcome::Exists(0));
356 }
357 Err(err) => {
358 if let Err(remove_err) = fs::remove_file(&temp_path) {
359 tracing::warn!(
360 path = %temp_path.display(),
361 error = %remove_err,
362 "Failed to remove unused FileStore temp file after create-if-absent error"
363 );
364 }
365 return Err(to_fs_err(err));
366 }
367 }
368 }
369
370 fs::rename(&temp_path, &full_path)
372 .with_context(|| format!("renaming {} to {}", temp_path.display(), str_path))
373 .map_err(a_to_fs_err)?;
374
375 self.owned_files.lock().insert(full_path.clone());
376 Ok(StoreOutcome::Created(revision))
377 }
378
379 async fn get(&self, key: &Key) -> Result<Option<bytes::Bytes>, StoreError> {
381 let safe_key = key.url_safe();
382 let full_path = self.p.join(safe_key.as_ref());
383 if !full_path.exists() {
384 return Ok(None);
385 }
386 let str_path = full_path.display().to_string();
387 let data: bytes::Bytes = fs::read(&full_path)
388 .context(str_path)
389 .map_err(a_to_fs_err)?
390 .into();
391 Ok(Some(data))
392 }
393
394 async fn delete(&self, key: &Key) -> Result<(), StoreError> {
396 let safe_key = key.url_safe();
397 let full_path = self.p.join(safe_key.as_ref());
398 let str_path = full_path.display().to_string();
399 if !full_path.exists() {
400 return Err(StoreError::MissingKey(str_path));
401 }
402
403 self.owned_files.lock().remove(&full_path);
404
405 fs::remove_file(&full_path)
406 .context(str_path)
407 .map_err(a_to_fs_err)
408 }
409
410 async fn watch(
411 &self,
412 ) -> Result<Pin<Box<dyn futures::Stream<Item = WatchEvent> + Send + 'life0>>, StoreError> {
413 let (tx, mut rx) = tokio::sync::mpsc::channel(128);
414
415 let mut watcher = RecommendedWatcher::new(
416 move |res: Result<Event, notify::Error>| {
417 if let Err(err) = tx.blocking_send(res) {
418 tracing::error!(error = %err, "Failed to send file watch event");
419 }
420 },
421 Config::default(),
422 )
423 .map_err(to_fs_err)?;
424
425 watcher
426 .watch(&self.p, RecursiveMode::NonRecursive)
427 .map_err(to_fs_err)?;
428
429 let dir = self.p.clone();
430 let root = self.root.clone();
431
432 Ok(Box::pin(async_stream::stream! {
433 let _watcher = watcher;
435
436 while let Some(event_result) = rx.recv().await {
437 let event = match event_result {
438 Ok(event) => event,
439 Err(err) => {
440 tracing::error!(error = %err, "Failed receiving file watch event");
441 continue;
442 }
443 };
444
445 for item_path in event.paths {
446 if item_path == dir {
448 tracing::warn!("Unexpected event on the directory itself");
449 continue;
450 }
451
452 let canonical_item_path = item_path.canonicalize().unwrap_or_else(|_| item_path.clone());
455
456 let key = match canonical_item_path.strip_prefix(&root) {
457 Ok(stripped) => Key::from_url_safe(&stripped.display().to_string()),
458 Err(err) => {
459 tracing::error!(
462 error = %err,
463 item_path = %canonical_item_path.display(),
464 root = %root.display(),
465 "Item in file store is not prefixed with file store root. Should be impossible. Ignoring invalid key.");
466 continue;
467 }
468 };
469
470 if item_path.file_name()
472 .map(|n| n.to_string_lossy().starts_with(TEMP_FILE_PREFIX))
473 .unwrap_or(false)
474 {
475 continue;
476 }
477
478 match event.kind {
479 EventKind::Create(event::CreateKind::File)
481 | EventKind::Modify(event::ModifyKind::Data(event::DataChange::Content))
482 | EventKind::Modify(event::ModifyKind::Name(event::RenameMode::To)) => {
483 let data: bytes::Bytes = match fs::read(&item_path) {
484 Ok(data) => data.into(),
485 Err(err) => {
486 tracing::warn!(error = %err, item = %item_path.display(), "Failed reading event item. Skipping.");
487 continue;
488 }
489 };
490 let item = KeyValue::new(key, data);
491 yield WatchEvent::Put(item);
492 }
493 EventKind::Remove(event::RemoveKind::File) => {
494 yield WatchEvent::Delete(key);
495 }
496 _ => {
497 continue;
499 }
500 }
501 }
502 }
503 }))
504 }
505
506 async fn entries(&self) -> Result<HashMap<Key, bytes::Bytes>, StoreError> {
507 let contents = fs::read_dir(&self.p)
508 .with_context(|| self.p.display().to_string())
509 .map_err(a_to_fs_err)?;
510 let mut out = HashMap::new();
511 for entry in contents {
512 let entry = entry.map_err(to_fs_err)?;
513 if !entry.path().is_file() {
514 tracing::warn!(
515 path = %entry.path().display(),
516 "Unexpected entry, directory should only contain files."
517 );
518 continue;
519 }
520
521 if entry
523 .file_name()
524 .to_string_lossy()
525 .starts_with(TEMP_FILE_PREFIX)
526 {
527 continue;
528 }
529
530 let canonical_entry_path = match entry.path().canonicalize() {
532 Ok(p) => p,
533 Err(err) => {
534 tracing::warn!(error = %err, path = %entry.path().display(), "Failed to canonicalize path. Using original path.");
535 entry.path()
536 }
537 };
538
539 let key = match canonical_entry_path.strip_prefix(&self.root) {
540 Ok(p) => Key::from_url_safe(&p.to_string_lossy()),
541 Err(err) => {
542 tracing::error!(
543 error = %err,
544 path = %canonical_entry_path.display(),
545 root = %self.root.display(),
546 "FileStore path not in root. Should be impossible. Skipping entry."
547 );
548 continue;
549 }
550 };
551 let data: bytes::Bytes = fs::read(entry.path())
552 .with_context(|| self.p.display().to_string())
553 .map_err(a_to_fs_err)?
554 .into();
555 out.insert(key, data);
556 }
557 Ok(out)
558 }
559}
560
561fn write_temp_file_at(temp_path: &Path, value: &[u8]) -> Result<bool, StoreError> {
562 let mut file = match OpenOptions::new()
563 .write(true)
564 .create_new(true)
565 .open(temp_path)
566 {
567 Ok(file) => file,
568 Err(err) if err.kind() == ErrorKind::AlreadyExists => return Ok(false),
569 Err(err) => {
570 let err = anyhow::Error::new(err)
571 .context(format!("creating temp file {}", temp_path.display()));
572 return Err(a_to_fs_err(err));
573 }
574 };
575
576 if let Err(err) = file.write_all(value) {
577 if let Err(remove_err) = fs::remove_file(temp_path) {
578 tracing::warn!(
579 path = %temp_path.display(),
580 error = %remove_err,
581 "Failed to remove FileStore temp file after write error"
582 );
583 }
584 let err =
585 anyhow::Error::new(err).context(format!("writing temp file {}", temp_path.display()));
586 return Err(a_to_fs_err(err));
587 }
588
589 Ok(true)
590}
591
592fn a_to_fs_err(err: anyhow::Error) -> StoreError {
594 StoreError::FilesystemError(format!("{err:#}"))
595}
596
597fn to_fs_err<E: std::error::Error>(err: E) -> StoreError {
598 StoreError::FilesystemError(err.to_string())
599}
600
601#[cfg(test)]
602mod tests {
603 use std::collections::HashSet;
604 use std::fs;
605
606 use tokio_util::sync::CancellationToken;
607
608 use crate::storage::kv::{Bucket as _, FileStore, Key, Store as _, StoreOutcome};
609
610 #[tokio::test]
611 async fn test_entries_full_path() {
612 let t = tempfile::tempdir().unwrap();
613
614 let cancel_token = CancellationToken::new();
615 let m = FileStore::new(cancel_token.clone(), t.path());
616 let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
617 let _ = bucket
618 .insert(&Key::new("key1/multi/part".to_string()), "value1".into(), 0)
619 .await
620 .unwrap();
621 let _ = bucket
622 .insert(&Key::new("key2".to_string()), "value2".into(), 0)
623 .await
624 .unwrap();
625 let entries = bucket.entries().await.unwrap();
626 let keys: HashSet<Key> = entries.into_keys().collect();
627 cancel_token.cancel(); assert!(keys.contains(&Key::new("v1/tests/key1/multi/part".to_string())));
630 assert!(keys.contains(&Key::new("v1/tests/key2".to_string())));
631 }
632
633 #[test]
634 fn test_temp_file_creation_does_not_overwrite_existing_path() {
635 let t = tempfile::tempdir().unwrap();
636 let temp_path = t.path().join(".tmp_existing");
637
638 fs::write(&temp_path, b"sentinel").unwrap();
639 let created = super::write_temp_file_at(&temp_path, b"new").unwrap();
640
641 assert!(!created);
642 assert_eq!(fs::read(&temp_path).unwrap(), b"sentinel");
643 }
644
645 #[tokio::test]
646 async fn test_insert_revision_zero_is_create_if_absent() {
647 let t = tempfile::tempdir().unwrap();
648
649 let cancel_token = CancellationToken::new();
650 let m = FileStore::new(cancel_token.clone(), t.path());
651 let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
652 let key = Key::new("singleton".to_string());
653
654 let first = bucket.insert(&key, "winner".into(), 0).await.unwrap();
655 let second = bucket.insert(&key, "loser".into(), 0).await.unwrap();
656 let value = bucket.get(&key).await.unwrap().unwrap();
657 cancel_token.cancel();
658
659 assert_eq!(first, StoreOutcome::Created(0));
660 assert_eq!(second, StoreOutcome::Exists(0));
661 assert_eq!(value.as_ref(), b"winner");
662 }
663
664 #[tokio::test]
665 async fn test_insert_nonzero_revision_overwrites() {
666 let t = tempfile::tempdir().unwrap();
667
668 let cancel_token = CancellationToken::new();
669 let m = FileStore::new(cancel_token.clone(), t.path());
670 let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
671 let key = Key::new("existing".to_string());
672
673 bucket.insert(&key, "old".into(), 0).await.unwrap();
674 let outcome = bucket.insert(&key, "new".into(), 1).await.unwrap();
675 let value = bucket.get(&key).await.unwrap().unwrap();
676 cancel_token.cancel();
677
678 assert_eq!(outcome, StoreOutcome::Created(1));
679 assert_eq!(value.as_ref(), b"new");
680 }
681
682 #[tokio::test]
683 async fn test_concurrent_insert_revision_zero_has_one_winner() {
684 let t = tempfile::tempdir().unwrap();
685 let root = t.path().to_path_buf();
686 let key = Key::new("singleton".to_string());
687
688 let mut tasks = Vec::new();
689 for index in 0..16 {
690 let root = root.clone();
691 let key = key.clone();
692 tasks.push(tokio::spawn(async move {
693 let cancel_token = CancellationToken::new();
694 let store = FileStore::new(cancel_token.clone(), root);
695 let bucket = store.get_or_create_bucket("v1/claims", None).await.unwrap();
696 let value = format!("value-{index}");
697 let outcome = bucket.insert(&key, value.clone().into(), 0).await.unwrap();
698 let stored = bucket.get(&key).await.unwrap().unwrap();
699 cancel_token.cancel();
700 (outcome, String::from_utf8(stored.to_vec()).unwrap(), value)
701 }));
702 }
703
704 let mut created_values = Vec::new();
705 let mut observed_values = HashSet::new();
706 for task in tasks {
707 let (outcome, stored, attempted) = task.await.unwrap();
708 observed_values.insert(stored);
709 if outcome == StoreOutcome::Created(0) {
710 created_values.push(attempted);
711 } else {
712 assert_eq!(outcome, StoreOutcome::Exists(0));
713 }
714 }
715
716 assert_eq!(created_values.len(), 1);
717 assert_eq!(observed_values.len(), 1);
718 assert_eq!(
719 observed_values.into_iter().next().unwrap(),
720 created_values.pop().unwrap()
721 );
722 }
723}