Skip to main content

dynamo_runtime/storage/kv/
file.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
28/// How long until a key expires. We keep the keys alive by touching the files.
29/// 10s is the same as our etcd lease expiry.
30const DEFAULT_TTL: Duration = Duration::from_secs(10);
31
32/// Don't do keep-alive any more often than this. Limits the disk write load.
33const MIN_KEEP_ALIVE: Duration = Duration::from_secs(1);
34
35/// Prefix for temporary files used in atomic writes.
36/// Files with this prefix are ignored by the watcher.
37const TEMP_FILE_PREFIX: &str = ".tmp_";
38const TEMP_FILE_CREATE_ATTEMPTS: usize = 16;
39
40/// Treat as a singleton
41#[derive(Clone)]
42pub struct FileStore {
43    cancel_token: CancellationToken,
44    root: PathBuf,
45    connection_id: u64,
46    /// Directories we may have created files in, for shutdown cleanup and keep-alive.
47    /// Arc so that we only ever have one map here after clone.
48    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    /// Keep our files alive and delete expired keys.
65    ///
66    /// Does not return until cancellation token cancelled. On shutdown the process will
67    /// often exit before we detect cancellation. That's fine.
68    /// We run this in a real thread so it doesn't get delayed by tokio runtime under heavy load.
69    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            // Check before and after the sleep
75            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    /// The shortest TTL of any directory we are using.
93    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    /// A "bucket" is a directory
125    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            // Get
137            if !p.is_dir() {
138                return Err(StoreError::FilesystemError(
139                    "Bucket name is not a directory".to_string(),
140                ));
141            }
142        } else {
143            // Create
144            fs::create_dir_all(&p).map_err(to_fs_err)?;
145        }
146        let candidate = Directory::new(self.root.clone(), p.clone(), ttl.unwrap_or(DEFAULT_TTL));
147        let dir = self
148            .active_dirs
149            .lock()
150            .entry(p)
151            .or_insert(candidate)
152            .clone();
153        Ok(dir)
154    }
155
156    /// A "bucket" is a directory
157    async fn get_bucket(&self, bucket_name: &str) -> Result<Option<Self::Bucket>, StoreError> {
158        let p = self.root.join(bucket_name);
159        if let Some(dir) = self.active_dirs.lock().get(&p) {
160            return Ok(Some(dir.clone()));
161        };
162
163        if !p.exists() {
164            return Ok(None);
165        }
166        if !p.is_dir() {
167            return Err(StoreError::FilesystemError(
168                "Bucket name is not a directory".to_string(),
169            ));
170        }
171        // The filesystem itself doesn't store the TTL so for now default it
172        let candidate = Directory::new(self.root.clone(), p.clone(), DEFAULT_TTL);
173        let dir = self
174            .active_dirs
175            .lock()
176            .entry(p)
177            .or_insert(candidate)
178            .clone();
179        Ok(Some(dir))
180    }
181
182    fn connection_id(&self) -> u64 {
183        self.connection_id
184    }
185
186    // This cannot be a Drop imp because DistributedRuntime is cloned various places including
187    // Python. Drop doesn't get called.
188    fn shutdown(&self) {
189        for (_, mut dir) in self.active_dirs.lock().drain() {
190            if let Err(err) = dir.delete_owned_files() {
191                tracing::error!(error = %err, %dir, "Failed shutdown delete of owned files");
192            }
193        }
194    }
195}
196
197#[derive(Clone)]
198pub struct Directory {
199    root: PathBuf,
200    p: PathBuf,
201    ttl: Duration,
202    /// These are the files we created and hence must delete on shutdown
203    owned_files: Arc<Mutex<HashSet<PathBuf>>>,
204}
205
206impl Directory {
207    fn new(root: PathBuf, p: PathBuf, ttl: Duration) -> Self {
208        // Keep watched paths and event paths in the same form across symlinked roots.
209        let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
210        let canonical_path = p.canonicalize().unwrap_or_else(|_| p.clone());
211        if ttl < MIN_KEEP_ALIVE {
212            let h_ttl = humantime::format_duration(ttl);
213            tracing::warn!(path = %p.display(), ttl = %h_ttl, "ttl is too short, increasing to {}", humantime::format_duration(MIN_KEEP_ALIVE));
214        }
215        let ttl = cmp::max(ttl, MIN_KEEP_ALIVE);
216        Directory {
217            root: canonical_root,
218            p: canonical_path,
219            ttl,
220            owned_files: Arc::new(Mutex::new(HashSet::new())),
221        }
222    }
223
224    /// touch the files we own so they don't get deleted by a different FileStore
225    fn keep_alive(&self) {
226        let owned_files = self.owned_files.lock().clone();
227        for path in owned_files {
228            let file = match OpenOptions::new().write(true).open(&path) {
229                Ok(f) => f,
230                Err(err) => {
231                    tracing::error!(path = %path.display(), error = %err, "FileStore::keep_alive failed opening owned file");
232                    continue;
233                }
234            };
235            if let Err(err) = file.set_modified(SystemTime::now()) {
236                tracing::error!(path = %path.display(), error = %err, "FileStore::keep_alive failed set_modified on owned file");
237                continue;
238            }
239            tracing::trace!("FileStore keep_alive set {}", path.display());
240        }
241    }
242
243    /// Remove any files not touched for longer than TTL.
244    /// This looks at all files in the directory to catch orphaned files from processes that didn't stop cleanly.
245    /// Returns an error if we cannot open the directory. Errors inside the directory are logged
246    /// but non-fatal.
247    fn delete_expired_files(&self) -> anyhow::Result<()> {
248        let deadline = SystemTime::now() - self.ttl;
249        let dirname = self.p.display().to_string();
250        for entry in fs::read_dir(&self.p).with_context(|| dirname.clone())? {
251            let entry = match entry {
252                Ok(p) => p,
253                Err(err) => {
254                    tracing::warn!(dir = dirname, error = %err, "File store could read directory contents");
255                    continue;
256                }
257            };
258            if !entry.file_type().map(|f| f.is_file()).unwrap_or(false) {
259                tracing::warn!(dir = dirname, entry = %entry.path().display(), "File store directory should only contain files");
260                continue;
261            }
262            let ctx = entry.path().display().to_string();
263            let metadata = match entry.metadata() {
264                Ok(m) => m,
265                Err(err) => {
266                    tracing::warn!(path = %ctx, error = %err, "Failed fetching metadata");
267                    continue;
268                }
269            };
270            let last_modified = match metadata.modified() {
271                Ok(lm) => lm,
272                Err(err) => {
273                    // We should only get an error on platforms with no mtime, which we don't
274                    // support anyway.
275                    tracing::warn!(path = %ctx, error = %err, "Failed reading mtime");
276                    continue;
277                }
278            };
279            if last_modified < deadline {
280                tracing::info!(path = ctx, ?last_modified, "Expired");
281                if let Err(err) = fs::remove_file(entry.path()) {
282                    tracing::warn!(path = %ctx, error = %err, "Failed removing");
283                }
284            }
285        }
286        Ok(())
287    }
288
289    fn delete_owned_files(&mut self) -> anyhow::Result<()> {
290        let mut errs = Vec::new();
291        for p in self.owned_files.lock().drain() {
292            if let Err(err) = fs::remove_file(&p) {
293                errs.push(format!("{}: {err}", p.display()));
294            }
295        }
296        if !errs.is_empty() {
297            anyhow::bail!(errs.join(", "));
298        }
299        Ok(())
300    }
301
302    fn write_temp_file(&self, value: &[u8]) -> Result<PathBuf, StoreError> {
303        for _ in 0..TEMP_FILE_CREATE_ATTEMPTS {
304            let temp_name = format!("{TEMP_FILE_PREFIX}{:016x}", rand::random::<u64>());
305            let temp_path = self.p.join(&temp_name);
306            if write_temp_file_at(&temp_path, value)? {
307                return Ok(temp_path);
308            }
309        }
310
311        Err(StoreError::FilesystemError(format!(
312            "failed to create unique FileStore temp file in {} after {TEMP_FILE_CREATE_ATTEMPTS} attempts",
313            self.p.display()
314        )))
315    }
316}
317
318impl fmt::Display for Directory {
319    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
320        write!(f, "{}", self.p.display())
321    }
322}
323
324#[async_trait]
325impl Bucket for Directory {
326    /// Write a file to the directory by publishing a completed temp file.
327    /// This ensures watchers never see a partially written file.
328    /// Revision-zero inserts provide create-if-absent publication for this
329    /// FileStore path, but not leases, fencing, crash durability, or strict
330    /// runtime-wide cardinality guarantees.
331    async fn insert(
332        &self,
333        key: &Key,
334        value: bytes::Bytes,
335        revision: u64,
336    ) -> Result<StoreOutcome, StoreError> {
337        let safe_key = key.url_safe();
338        let full_path = self.p.join(safe_key.as_ref());
339        let str_path = full_path.display().to_string();
340
341        let temp_path = self.write_temp_file(&value)?;
342
343        if revision == 0 {
344            // No-clobber publish for revision-zero inserts: the link fails if another
345            // writer already created the key, and readers never see a partial target file.
346            match fs::hard_link(&temp_path, &full_path) {
347                Ok(()) => {
348                    if let Err(err) = fs::remove_file(&temp_path) {
349                        tracing::warn!(
350                            path = %temp_path.display(),
351                            error = %err,
352                            "Failed to remove FileStore temp file after create-if-absent publish"
353                        );
354                    }
355                    self.owned_files.lock().insert(full_path.clone());
356                    return Ok(StoreOutcome::Created(0));
357                }
358                Err(err) if err.kind() == ErrorKind::AlreadyExists => {
359                    if let Err(remove_err) = fs::remove_file(&temp_path) {
360                        tracing::warn!(
361                            path = %temp_path.display(),
362                            error = %remove_err,
363                            "Failed to remove unused FileStore temp file after create-if-absent conflict"
364                        );
365                    }
366                    return Ok(StoreOutcome::Exists(0));
367                }
368                Err(err) => {
369                    if let Err(remove_err) = fs::remove_file(&temp_path) {
370                        tracing::warn!(
371                            path = %temp_path.display(),
372                            error = %remove_err,
373                            "Failed to remove unused FileStore temp file after create-if-absent error"
374                        );
375                    }
376                    return Err(to_fs_err(err));
377                }
378            }
379        }
380
381        // Atomic rename to target path
382        fs::rename(&temp_path, &full_path)
383            .with_context(|| format!("renaming {} to {}", temp_path.display(), str_path))
384            .map_err(a_to_fs_err)?;
385
386        self.owned_files.lock().insert(full_path.clone());
387        Ok(StoreOutcome::Created(revision))
388    }
389
390    /// Read a file from the directory
391    async fn get(&self, key: &Key) -> Result<Option<bytes::Bytes>, StoreError> {
392        let safe_key = key.url_safe();
393        let full_path = self.p.join(safe_key.as_ref());
394        if !full_path.exists() {
395            return Ok(None);
396        }
397        let str_path = full_path.display().to_string();
398        let data: bytes::Bytes = fs::read(&full_path)
399            .context(str_path)
400            .map_err(a_to_fs_err)?
401            .into();
402        Ok(Some(data))
403    }
404
405    /// Delete a file from the directory
406    async fn delete(&self, key: &Key) -> Result<(), StoreError> {
407        let safe_key = key.url_safe();
408        let full_path = self.p.join(safe_key.as_ref());
409        let str_path = full_path.display().to_string();
410        if !full_path.exists() {
411            return Err(StoreError::MissingKey(str_path));
412        }
413
414        self.owned_files.lock().remove(&full_path);
415
416        fs::remove_file(&full_path)
417            .context(str_path)
418            .map_err(a_to_fs_err)
419    }
420
421    async fn watch(
422        &self,
423    ) -> Result<Pin<Box<dyn futures::Stream<Item = WatchEvent> + Send + 'life0>>, StoreError> {
424        let (tx, mut rx) = tokio::sync::mpsc::channel(128);
425
426        let mut watcher = RecommendedWatcher::new(
427            move |res: Result<Event, notify::Error>| {
428                if let Err(err) = tx.blocking_send(res) {
429                    tracing::error!(error = %err, "Failed to send file watch event");
430                }
431            },
432            Config::default(),
433        )
434        .map_err(to_fs_err)?;
435
436        watcher
437            .watch(&self.p, RecursiveMode::NonRecursive)
438            .map_err(to_fs_err)?;
439
440        let dir = self.p.clone();
441        let root = self.root.clone();
442
443        Ok(Box::pin(async_stream::stream! {
444            // Keep watcher alive for the duration of the stream
445            let _watcher = watcher;
446
447            while let Some(event_result) = rx.recv().await {
448                let event = match event_result {
449                    Ok(event) => event,
450                    Err(err) => {
451                        tracing::error!(error = %err, "Failed receiving file watch event");
452                        continue;
453                    }
454                };
455                for item_path in event.paths {
456                    // Skip if the event is for the directory itself
457                    if item_path == dir {
458                        tracing::warn!("Unexpected event on the directory itself");
459                        continue;
460                    }
461
462                    let canonical_item_path = canonicalize_event_path(&item_path);
463
464                    let key = match canonical_item_path.strip_prefix(&root) {
465                        Ok(stripped) => Key::from_url_safe(&stripped.display().to_string()),
466                        Err(err) => {
467                            // Possibly this should be a panic.
468                            // A key cannot be outside the file store root.
469                            tracing::error!(
470                                error = %err,
471                                item_path = %canonical_item_path.display(),
472                                root = %root.display(),
473                                "Item in file store is not prefixed with file store root. Should be impossible. Ignoring invalid key.");
474                            continue;
475                        }
476                    };
477
478                    // Skip temp files used for atomic writes
479                    if item_path.file_name()
480                        .map(|n| n.to_string_lossy().starts_with(TEMP_FILE_PREFIX))
481                        .unwrap_or(false)
482                    {
483                        continue;
484                    }
485
486                    match event.kind {
487                        // Handle file creation, modification, and rename-to (from atomic writes)
488                        EventKind::Create(event::CreateKind::File)
489                        | EventKind::Modify(event::ModifyKind::Data(event::DataChange::Content))
490                        | EventKind::Modify(event::ModifyKind::Name(event::RenameMode::To)) => {
491                            let data: bytes::Bytes = match fs::read(&item_path) {
492                                Ok(data) => data.into(),
493                                Err(err) => {
494                                    tracing::warn!(error = %err, item = %item_path.display(), "Failed reading event item. Skipping.");
495                                    continue;
496                                }
497                            };
498                            let item = KeyValue::new(key, data);
499                            yield WatchEvent::Put(item);
500                        }
501                        EventKind::Remove(_) => {
502                            yield WatchEvent::Delete(key);
503                        }
504                        _ => {
505                            // These happen every time the keep-alive updates last modified time
506                            continue;
507                        }
508                    }
509                }
510            }
511        }))
512    }
513
514    async fn entries(&self) -> Result<HashMap<Key, bytes::Bytes>, StoreError> {
515        let contents = fs::read_dir(&self.p)
516            .with_context(|| self.p.display().to_string())
517            .map_err(a_to_fs_err)?;
518        let mut out = HashMap::new();
519        for entry in contents {
520            let entry = entry.map_err(to_fs_err)?;
521            if !entry.path().is_file() {
522                tracing::warn!(
523                    path = %entry.path().display(),
524                    "Unexpected entry, directory should only contain files."
525                );
526                continue;
527            }
528
529            // Skip temp files used for atomic writes
530            if entry
531                .file_name()
532                .to_string_lossy()
533                .starts_with(TEMP_FILE_PREFIX)
534            {
535                continue;
536            }
537
538            // Canonicalize paths to handle symlinks (e.g., /var -> /private/var on macOS)
539            let canonical_entry_path = match entry.path().canonicalize() {
540                Ok(p) => p,
541                Err(err) => {
542                    tracing::warn!(error = %err, path = %entry.path().display(), "Failed to canonicalize path. Using original path.");
543                    entry.path()
544                }
545            };
546
547            let key = match canonical_entry_path.strip_prefix(&self.root) {
548                Ok(p) => Key::from_url_safe(&p.to_string_lossy()),
549                Err(err) => {
550                    tracing::error!(
551                        error = %err,
552                        path = %canonical_entry_path.display(),
553                        root = %self.root.display(),
554                        "FileStore path not in root. Should be impossible. Skipping entry."
555                    );
556                    continue;
557                }
558            };
559            let data: bytes::Bytes = fs::read(entry.path())
560                .with_context(|| self.p.display().to_string())
561                .map_err(a_to_fs_err)?
562                .into();
563            out.insert(key, data);
564        }
565        Ok(out)
566    }
567}
568
569fn write_temp_file_at(temp_path: &Path, value: &[u8]) -> Result<bool, StoreError> {
570    let mut file = match OpenOptions::new()
571        .write(true)
572        .create_new(true)
573        .open(temp_path)
574    {
575        Ok(file) => file,
576        Err(err) if err.kind() == ErrorKind::AlreadyExists => return Ok(false),
577        Err(err) => {
578            let err = anyhow::Error::new(err)
579                .context(format!("creating temp file {}", temp_path.display()));
580            return Err(a_to_fs_err(err));
581        }
582    };
583
584    if let Err(err) = file.write_all(value) {
585        if let Err(remove_err) = fs::remove_file(temp_path) {
586            tracing::warn!(
587                path = %temp_path.display(),
588                error = %remove_err,
589                "Failed to remove FileStore temp file after write error"
590            );
591        }
592        let err =
593            anyhow::Error::new(err).context(format!("writing temp file {}", temp_path.display()));
594        return Err(a_to_fs_err(err));
595    }
596
597    Ok(true)
598}
599
600fn canonicalize_event_path(path: &Path) -> PathBuf {
601    if let Ok(canonical_path) = path.canonicalize() {
602        return canonical_path;
603    }
604    let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) else {
605        return path.to_path_buf();
606    };
607    let Ok(canonical_parent) = parent.canonicalize() else {
608        return path.to_path_buf();
609    };
610    canonical_parent.join(file_name)
611}
612
613// For anyhow preserve the context
614fn a_to_fs_err(err: anyhow::Error) -> StoreError {
615    StoreError::FilesystemError(format!("{err:#}"))
616}
617
618fn to_fs_err<E: std::error::Error>(err: E) -> StoreError {
619    StoreError::FilesystemError(err.to_string())
620}
621
622#[cfg(test)]
623mod tests {
624    use std::collections::HashSet;
625    use std::fs;
626    use std::os::unix::fs::symlink;
627    use std::sync::Arc;
628    use std::time::Duration;
629
630    use futures::StreamExt;
631    use tokio::sync::Barrier;
632    use tokio_util::sync::CancellationToken;
633
634    use crate::storage::kv::{Bucket as _, FileStore, Key, Store as _, StoreOutcome};
635
636    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
637    async fn concurrent_bucket_open_shares_ownership_registry() {
638        let t = tempfile::tempdir().unwrap();
639        let cancel_token = CancellationToken::new();
640        let store = FileStore::new(cancel_token.clone(), t.path());
641        let barrier = Arc::new(Barrier::new(32));
642
643        let mut tasks = Vec::new();
644        for _ in 0..32 {
645            let store = store.clone();
646            let barrier = barrier.clone();
647            tasks.push(tokio::spawn(async move {
648                barrier.wait().await;
649                store
650                    .get_or_create_bucket("v1/concurrent", None)
651                    .await
652                    .unwrap()
653            }));
654        }
655
656        let mut directories = Vec::new();
657        for task in tasks {
658            directories.push(task.await.unwrap());
659        }
660        cancel_token.cancel();
661
662        let ownership = &directories[0].owned_files;
663        assert!(
664            directories
665                .iter()
666                .all(|directory| Arc::ptr_eq(ownership, &directory.owned_files))
667        );
668    }
669
670    #[test]
671    fn deleted_event_path_canonicalizes_existing_parent() {
672        let t = tempfile::tempdir().unwrap();
673        let canonical_root = t.path().join("canonical");
674        let bucket = canonical_root.join("v1/tests");
675        fs::create_dir_all(&bucket).unwrap();
676        let linked_root = t.path().join("linked");
677        symlink(&canonical_root, &linked_root).unwrap();
678
679        assert_eq!(
680            super::canonicalize_event_path(&linked_root.join("v1/tests/deleted")),
681            canonical_root
682                .canonicalize()
683                .unwrap()
684                .join("v1/tests/deleted")
685        );
686    }
687
688    #[tokio::test]
689    async fn external_delete_is_observed_under_noncanonical_root() {
690        let t = tempfile::tempdir().unwrap();
691        let canonical_root = t.path().join("canonical");
692        fs::create_dir_all(&canonical_root).unwrap();
693        let linked_root = t.path().join("linked");
694        symlink(&canonical_root, &linked_root).unwrap();
695        let watcher_cancel = CancellationToken::new();
696        let creator_cancel = CancellationToken::new();
697        let watcher_store = FileStore::new(watcher_cancel.clone(), &linked_root);
698        let creator_store = FileStore::new(creator_cancel.clone(), &canonical_root);
699        let watcher_bucket = watcher_store
700            .get_or_create_bucket("v1/tests", None)
701            .await
702            .unwrap();
703        let creator_bucket = creator_store
704            .get_or_create_bucket("v1/tests", None)
705            .await
706            .unwrap();
707        let mut events = watcher_bucket.watch().await.unwrap();
708        let key = Key::new("scope/item".to_string());
709
710        creator_bucket
711            .insert(&key, "value".into(), 0)
712            .await
713            .unwrap();
714        loop {
715            let event = tokio::time::timeout(Duration::from_secs(2), events.next())
716                .await
717                .expect("FileStore watcher did not observe value creation")
718                .expect("FileStore watcher ended after value creation");
719            if matches!(event, super::WatchEvent::Put(ref item) if item.key_str() == "v1/tests/scope/item")
720            {
721                break;
722            }
723        }
724
725        creator_bucket.delete(&key).await.unwrap();
726        let event = tokio::time::timeout(Duration::from_secs(2), events.next())
727            .await
728            .expect("FileStore watcher did not observe value deletion")
729            .expect("FileStore watcher ended after value deletion");
730        assert!(
731            matches!(event, super::WatchEvent::Delete(ref deleted) if deleted == &Key::new("v1/tests/scope/item".to_string()))
732        );
733
734        watcher_cancel.cancel();
735        creator_cancel.cancel();
736    }
737
738    #[tokio::test]
739    async fn test_entries_full_path() {
740        let t = tempfile::tempdir().unwrap();
741
742        let cancel_token = CancellationToken::new();
743        let m = FileStore::new(cancel_token.clone(), t.path());
744        let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
745        let _ = bucket
746            .insert(&Key::new("key1/multi/part".to_string()), "value1".into(), 0)
747            .await
748            .unwrap();
749        let _ = bucket
750            .insert(&Key::new("key2".to_string()), "value2".into(), 0)
751            .await
752            .unwrap();
753        let entries = bucket.entries().await.unwrap();
754        let keys: HashSet<Key> = entries.into_keys().collect();
755        cancel_token.cancel(); // stop the background thread
756
757        assert!(keys.contains(&Key::new("v1/tests/key1/multi/part".to_string())));
758        assert!(keys.contains(&Key::new("v1/tests/key2".to_string())));
759    }
760
761    #[test]
762    fn test_temp_file_creation_does_not_overwrite_existing_path() {
763        let t = tempfile::tempdir().unwrap();
764        let temp_path = t.path().join(".tmp_existing");
765
766        fs::write(&temp_path, b"sentinel").unwrap();
767        let created = super::write_temp_file_at(&temp_path, b"new").unwrap();
768
769        assert!(!created);
770        assert_eq!(fs::read(&temp_path).unwrap(), b"sentinel");
771    }
772
773    #[tokio::test]
774    async fn test_insert_revision_zero_is_create_if_absent() {
775        let t = tempfile::tempdir().unwrap();
776
777        let cancel_token = CancellationToken::new();
778        let m = FileStore::new(cancel_token.clone(), t.path());
779        let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
780        let key = Key::new("singleton".to_string());
781
782        let first = bucket.insert(&key, "winner".into(), 0).await.unwrap();
783        let second = bucket.insert(&key, "loser".into(), 0).await.unwrap();
784        let value = bucket.get(&key).await.unwrap().unwrap();
785        cancel_token.cancel();
786
787        assert_eq!(first, StoreOutcome::Created(0));
788        assert_eq!(second, StoreOutcome::Exists(0));
789        assert_eq!(value.as_ref(), b"winner");
790    }
791
792    #[tokio::test]
793    async fn test_insert_nonzero_revision_overwrites() {
794        let t = tempfile::tempdir().unwrap();
795
796        let cancel_token = CancellationToken::new();
797        let m = FileStore::new(cancel_token.clone(), t.path());
798        let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
799        let key = Key::new("existing".to_string());
800
801        bucket.insert(&key, "old".into(), 0).await.unwrap();
802        let outcome = bucket.insert(&key, "new".into(), 1).await.unwrap();
803        let value = bucket.get(&key).await.unwrap().unwrap();
804        cancel_token.cancel();
805
806        assert_eq!(outcome, StoreOutcome::Created(1));
807        assert_eq!(value.as_ref(), b"new");
808    }
809
810    #[tokio::test]
811    async fn test_concurrent_insert_revision_zero_has_one_winner() {
812        let t = tempfile::tempdir().unwrap();
813        let root = t.path().to_path_buf();
814        let key = Key::new("singleton".to_string());
815
816        let mut tasks = Vec::new();
817        for index in 0..16 {
818            let root = root.clone();
819            let key = key.clone();
820            tasks.push(tokio::spawn(async move {
821                let cancel_token = CancellationToken::new();
822                let store = FileStore::new(cancel_token.clone(), root);
823                let bucket = store.get_or_create_bucket("v1/claims", None).await.unwrap();
824                let value = format!("value-{index}");
825                let outcome = bucket.insert(&key, value.clone().into(), 0).await.unwrap();
826                let stored = bucket.get(&key).await.unwrap().unwrap();
827                cancel_token.cancel();
828                (outcome, String::from_utf8(stored.to_vec()).unwrap(), value)
829            }));
830        }
831
832        let mut created_values = Vec::new();
833        let mut observed_values = HashSet::new();
834        for task in tasks {
835            let (outcome, stored, attempted) = task.await.unwrap();
836            observed_values.insert(stored);
837            if outcome == StoreOutcome::Created(0) {
838                created_values.push(attempted);
839            } else {
840                assert_eq!(outcome, StoreOutcome::Exists(0));
841            }
842        }
843
844        assert_eq!(created_values.len(), 1);
845        assert_eq!(observed_values.len(), 1);
846        assert_eq!(
847            observed_values.into_iter().next().unwrap(),
848            created_values.pop().unwrap()
849        );
850    }
851}