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::{self, File, OpenOptions};
9use std::io::{ErrorKind, Write};
10use std::os::unix::ffi::OsStrExt;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::thread;
14use std::time::Duration;
15use std::time::SystemTime;
16use std::{collections::HashMap, pin::Pin};
17
18use anyhow::Context as _;
19use async_trait::async_trait;
20use fs4::fs_std::FileExt;
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;
39const MUTATION_LOCK_ATTEMPTS: usize = 100;
40const MUTATION_LOCK_RETRY_DELAY: Duration = Duration::from_millis(10);
41
42/// Treat as a singleton
43#[derive(Clone)]
44pub struct FileStore {
45    cancel_token: CancellationToken,
46    root: PathBuf,
47    connection_id: u64,
48    /// Directories we may have created files in, for shutdown cleanup and keep-alive.
49    /// Arc so that we only ever have one map here after clone.
50    active_dirs: Arc<Mutex<HashMap<PathBuf, Directory>>>,
51}
52
53impl FileStore {
54    pub(super) fn new<P: Into<PathBuf>>(cancel_token: CancellationToken, root_dir: P) -> Self {
55        let fs = FileStore {
56            cancel_token,
57            root: root_dir.into(),
58            connection_id: rand::random::<u64>(),
59            active_dirs: Arc::new(Mutex::new(HashMap::new())),
60        };
61        let c = fs.clone();
62        thread::spawn(move || c.expiry_thread());
63        fs
64    }
65
66    /// Keep our files alive and delete expired keys.
67    ///
68    /// Does not return until cancellation token cancelled. On shutdown the process will
69    /// often exit before we detect cancellation. That's fine.
70    /// We run this in a real thread so it doesn't get delayed by tokio runtime under heavy load.
71    fn expiry_thread(&self) {
72        loop {
73            let ttl = self.shortest_ttl();
74            let keep_alive_interval = cmp::max(ttl / 3, MIN_KEEP_ALIVE);
75
76            // Check before and after the sleep
77            if self.cancel_token.is_cancelled() {
78                break;
79            }
80
81            thread::sleep(keep_alive_interval);
82
83            if self.cancel_token.is_cancelled() {
84                break;
85            }
86
87            self.keep_alive();
88            if let Err(err) = self.delete_expired_files() {
89                tracing::error!(error = %err, "FileStore delete_expired_files");
90            }
91        }
92    }
93
94    /// The shortest TTL of any directory we are using.
95    fn shortest_ttl(&self) -> Duration {
96        let mut ttl = DEFAULT_TTL;
97        let active_dirs = self.active_dirs.lock().clone();
98        for (_, dir) in active_dirs {
99            ttl = cmp::min(ttl, dir.ttl);
100        }
101        tracing::trace!("FileStore expiry shortest ttl {ttl:?}");
102        ttl
103    }
104
105    fn keep_alive(&self) {
106        let active_dirs = self.active_dirs.lock().clone();
107        for (_, dir) in active_dirs {
108            dir.keep_alive();
109        }
110    }
111
112    fn delete_expired_files(&self) -> anyhow::Result<()> {
113        let active_dirs = self.active_dirs.lock().clone();
114        for (path, dir) in active_dirs {
115            dir.delete_expired_files()
116                .with_context(|| path.display().to_string())?;
117        }
118        Ok(())
119    }
120}
121
122#[async_trait]
123impl Store for FileStore {
124    type Bucket = Directory;
125
126    /// A "bucket" is a directory
127    async fn get_or_create_bucket(
128        &self,
129        bucket_name: &str,
130        ttl: Option<Duration>,
131    ) -> Result<Self::Bucket, StoreError> {
132        let p = self.root.join(bucket_name);
133        if let Some(dir) = self.active_dirs.lock().get(&p) {
134            return Ok(dir.clone());
135        };
136
137        if p.exists() {
138            // Get
139            if !p.is_dir() {
140                return Err(StoreError::FilesystemError(
141                    "Bucket name is not a directory".to_string(),
142                ));
143            }
144        } else {
145            // Create
146            fs::create_dir_all(&p).map_err(to_fs_err)?;
147        }
148        let candidate = Directory::new(self.root.clone(), p.clone(), ttl.unwrap_or(DEFAULT_TTL));
149        let dir = self
150            .active_dirs
151            .lock()
152            .entry(p)
153            .or_insert(candidate)
154            .clone();
155        Ok(dir)
156    }
157
158    /// A "bucket" is a directory
159    async fn get_bucket(&self, bucket_name: &str) -> Result<Option<Self::Bucket>, StoreError> {
160        let p = self.root.join(bucket_name);
161        if let Some(dir) = self.active_dirs.lock().get(&p) {
162            return Ok(Some(dir.clone()));
163        };
164
165        if !p.exists() {
166            return Ok(None);
167        }
168        if !p.is_dir() {
169            return Err(StoreError::FilesystemError(
170                "Bucket name is not a directory".to_string(),
171            ));
172        }
173        // The filesystem itself doesn't store the TTL so for now default it
174        let candidate = Directory::new(self.root.clone(), p.clone(), DEFAULT_TTL);
175        let dir = self
176            .active_dirs
177            .lock()
178            .entry(p)
179            .or_insert(candidate)
180            .clone();
181        Ok(Some(dir))
182    }
183
184    fn connection_id(&self) -> u64 {
185        self.connection_id
186    }
187
188    // This cannot be a Drop imp because DistributedRuntime is cloned various places including
189    // Python. Drop doesn't get called.
190    fn shutdown(&self) {
191        for (_, mut dir) in self.active_dirs.lock().drain() {
192            if let Err(err) = dir.delete_owned_files() {
193                tracing::error!(error = %err, %dir, "Failed shutdown delete of owned files");
194            }
195        }
196    }
197}
198
199#[derive(Clone)]
200pub struct Directory {
201    root: PathBuf,
202    p: PathBuf,
203    ttl: Duration,
204    /// These are the files we created and hence must delete on shutdown
205    owned_files: Arc<Mutex<HashSet<PathBuf>>>,
206}
207
208struct DirectoryMutationLock {
209    _file: File,
210}
211
212impl DirectoryMutationLock {
213    fn acquire(path: &Path) -> Result<Self, StoreError> {
214        let file = File::open(path).map_err(to_fs_err)?;
215        FileExt::lock_exclusive(&file).map_err(to_fs_err)?;
216        Ok(Self { _file: file })
217    }
218
219    fn try_acquire(path: &Path) -> Result<Option<Self>, StoreError> {
220        let file = File::open(path).map_err(to_fs_err)?;
221        match FileExt::try_lock_exclusive(&file).map_err(to_fs_err)? {
222            true => Ok(Some(Self { _file: file })),
223            false => Ok(None),
224        }
225    }
226}
227
228impl Directory {
229    fn new(root: PathBuf, p: PathBuf, ttl: Duration) -> Self {
230        // Keep watched paths and event paths in the same form across symlinked roots.
231        let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
232        let canonical_path = p.canonicalize().unwrap_or_else(|_| p.clone());
233        if ttl < MIN_KEEP_ALIVE {
234            let h_ttl = humantime::format_duration(ttl);
235            tracing::warn!(path = %p.display(), ttl = %h_ttl, "ttl is too short, increasing to {}", humantime::format_duration(MIN_KEEP_ALIVE));
236        }
237        let ttl = cmp::max(ttl, MIN_KEEP_ALIVE);
238        Directory {
239            root: canonical_root,
240            p: canonical_path,
241            ttl,
242            owned_files: Arc::new(Mutex::new(HashSet::new())),
243        }
244    }
245
246    /// touch the files we own so they don't get deleted by a different FileStore
247    fn keep_alive(&self) {
248        let owned_files = self.owned_files.lock().clone();
249        for path in owned_files {
250            let file = match OpenOptions::new().write(true).open(&path) {
251                Ok(f) => f,
252                Err(err) => {
253                    tracing::error!(path = %path.display(), error = %err, "FileStore::keep_alive failed opening owned file");
254                    continue;
255                }
256            };
257            if let Err(err) = file.set_modified(SystemTime::now()) {
258                tracing::error!(path = %path.display(), error = %err, "FileStore::keep_alive failed set_modified on owned file");
259                continue;
260            }
261            tracing::trace!("FileStore keep_alive set {}", path.display());
262        }
263    }
264
265    /// Remove any files not touched for longer than TTL.
266    /// This looks at all files in the directory to catch orphaned files from processes that didn't stop cleanly.
267    /// Returns an error if we cannot open the directory. Errors inside the directory are logged
268    /// but non-fatal.
269    fn delete_expired_files(&self) -> anyhow::Result<()> {
270        let _mutation_lock = self
271            .lock_mutations_blocking()
272            .map_err(anyhow::Error::from)?;
273        let deadline = SystemTime::now() - self.ttl;
274        let dirname = self.p.display().to_string();
275        for entry in fs::read_dir(&self.p).with_context(|| dirname.clone())? {
276            let entry = match entry {
277                Ok(p) => p,
278                Err(err) => {
279                    tracing::warn!(dir = dirname, error = %err, "File store could read directory contents");
280                    continue;
281                }
282            };
283            if !entry.file_type().map(|f| f.is_file()).unwrap_or(false) {
284                tracing::warn!(dir = dirname, entry = %entry.path().display(), "File store directory should only contain files");
285                continue;
286            }
287            let ctx = entry.path().display().to_string();
288            let metadata = match entry.metadata() {
289                Ok(m) => m,
290                Err(err) => {
291                    tracing::warn!(path = %ctx, error = %err, "Failed fetching metadata");
292                    continue;
293                }
294            };
295            let last_modified = match metadata.modified() {
296                Ok(lm) => lm,
297                Err(err) => {
298                    // We should only get an error on platforms with no mtime, which we don't
299                    // support anyway.
300                    tracing::warn!(path = %ctx, error = %err, "Failed reading mtime");
301                    continue;
302                }
303            };
304            if last_modified < deadline {
305                tracing::info!(path = ctx, ?last_modified, "Expired");
306                if let Err(err) = fs::remove_file(entry.path()) {
307                    tracing::warn!(path = %ctx, error = %err, "Failed removing");
308                }
309            }
310        }
311        Ok(())
312    }
313
314    fn delete_owned_files(&mut self) -> anyhow::Result<()> {
315        let _mutation_lock = self
316            .lock_mutations_blocking()
317            .map_err(anyhow::Error::from)?;
318        let mut errs = Vec::new();
319        for p in self.owned_files.lock().drain() {
320            if let Err(err) = fs::remove_file(&p) {
321                errs.push(format!("{}: {err}", p.display()));
322            }
323        }
324        if !errs.is_empty() {
325            anyhow::bail!(errs.join(", "));
326        }
327        Ok(())
328    }
329
330    fn lock_mutations_blocking(&self) -> Result<DirectoryMutationLock, StoreError> {
331        DirectoryMutationLock::acquire(&self.p)
332    }
333
334    async fn lock_mutations(&self) -> Result<DirectoryMutationLock, StoreError> {
335        for attempt in 0..MUTATION_LOCK_ATTEMPTS {
336            if let Some(lock) = DirectoryMutationLock::try_acquire(&self.p)? {
337                return Ok(lock);
338            }
339            if attempt + 1 < MUTATION_LOCK_ATTEMPTS {
340                tokio::time::sleep(MUTATION_LOCK_RETRY_DELAY).await;
341            }
342        }
343
344        Err(StoreError::FilesystemError(format!(
345            "timed out acquiring FileStore mutation lock for {} after {MUTATION_LOCK_ATTEMPTS} attempts",
346            self.p.display()
347        )))
348    }
349
350    fn write_temp_file(&self, value: &[u8]) -> Result<PathBuf, StoreError> {
351        for _ in 0..TEMP_FILE_CREATE_ATTEMPTS {
352            let temp_name = format!("{TEMP_FILE_PREFIX}{:016x}", rand::random::<u64>());
353            let temp_path = self.p.join(&temp_name);
354            if write_temp_file_at(&temp_path, value)? {
355                return Ok(temp_path);
356            }
357        }
358
359        Err(StoreError::FilesystemError(format!(
360            "failed to create unique FileStore temp file in {} after {TEMP_FILE_CREATE_ATTEMPTS} attempts",
361            self.p.display()
362        )))
363    }
364}
365
366impl fmt::Display for Directory {
367    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
368        write!(f, "{}", self.p.display())
369    }
370}
371
372#[async_trait]
373impl Bucket for Directory {
374    /// Write a file to the directory by publishing a completed temp file.
375    /// This ensures watchers never see a partially written file.
376    /// Revision-zero inserts provide create-if-absent publication for this
377    /// FileStore path, but not leases, fencing, crash durability, or strict
378    /// runtime-wide cardinality guarantees.
379    async fn insert(
380        &self,
381        key: &Key,
382        value: bytes::Bytes,
383        revision: u64,
384    ) -> Result<StoreOutcome, StoreError> {
385        let safe_key = key.url_safe();
386        let full_path = self.p.join(safe_key.as_ref());
387        let str_path = full_path.display().to_string();
388
389        let _mutation_lock = self.lock_mutations().await?;
390        let temp_path = self.write_temp_file(&value)?;
391
392        if revision == 0 {
393            // No-clobber publish for revision-zero inserts: the link fails if another
394            // writer already created the key, and readers never see a partial target file.
395            match fs::hard_link(&temp_path, &full_path) {
396                Ok(()) => {
397                    if let Err(err) = fs::remove_file(&temp_path) {
398                        tracing::warn!(
399                            path = %temp_path.display(),
400                            error = %err,
401                            "Failed to remove FileStore temp file after create-if-absent publish"
402                        );
403                    }
404                    self.owned_files.lock().insert(full_path.clone());
405                    return Ok(StoreOutcome::Created(0));
406                }
407                Err(err) if err.kind() == ErrorKind::AlreadyExists => {
408                    if let Err(remove_err) = fs::remove_file(&temp_path) {
409                        tracing::warn!(
410                            path = %temp_path.display(),
411                            error = %remove_err,
412                            "Failed to remove unused FileStore temp file after create-if-absent conflict"
413                        );
414                    }
415                    return Ok(StoreOutcome::Exists(0));
416                }
417                Err(err) => {
418                    if let Err(remove_err) = fs::remove_file(&temp_path) {
419                        tracing::warn!(
420                            path = %temp_path.display(),
421                            error = %remove_err,
422                            "Failed to remove unused FileStore temp file after create-if-absent error"
423                        );
424                    }
425                    return Err(to_fs_err(err));
426                }
427            }
428        }
429
430        // Atomic rename to target path
431        fs::rename(&temp_path, &full_path)
432            .with_context(|| format!("renaming {} to {}", temp_path.display(), str_path))
433            .map_err(a_to_fs_err)?;
434
435        self.owned_files.lock().insert(full_path.clone());
436        Ok(StoreOutcome::Created(revision))
437    }
438
439    async fn compare_and_replace(
440        &self,
441        key: &Key,
442        expected: bytes::Bytes,
443        value: bytes::Bytes,
444    ) -> Result<StoreOutcome, StoreError> {
445        let full_path = self.p.join(key.url_safe().as_ref());
446        let str_path = full_path.display().to_string();
447        let _mutation_lock = self.lock_mutations().await?;
448
449        let current = match fs::read(&full_path) {
450            Ok(current) => current,
451            Err(error) if error.kind() == ErrorKind::NotFound => {
452                return Err(StoreError::MissingKey(key.to_string()));
453            }
454            Err(error) => return Err(to_fs_err(error)),
455        };
456        if current.as_slice() != expected.as_ref() {
457            return Err(StoreError::Retry);
458        }
459
460        let temp_path = self.write_temp_file(&value)?;
461        if let Err(error) = fs::rename(&temp_path, &full_path) {
462            if let Err(remove_error) = fs::remove_file(&temp_path) {
463                tracing::warn!(path = %temp_path.display(), %remove_error, "Failed to remove FileStore temp file after replace error");
464            }
465            return Err(a_to_fs_err(anyhow::Error::new(error).context(format!(
466                "renaming {} to {}",
467                temp_path.display(),
468                str_path
469            ))));
470        }
471
472        self.owned_files.lock().insert(full_path);
473        Ok(StoreOutcome::Created(1))
474    }
475
476    /// Read a file from the directory
477    async fn get(&self, key: &Key) -> Result<Option<bytes::Bytes>, StoreError> {
478        let safe_key = key.url_safe();
479        let full_path = self.p.join(safe_key.as_ref());
480        if !full_path.exists() {
481            return Ok(None);
482        }
483        let str_path = full_path.display().to_string();
484        let data: bytes::Bytes = fs::read(&full_path)
485            .context(str_path)
486            .map_err(a_to_fs_err)?
487            .into();
488        Ok(Some(data))
489    }
490
491    /// Delete a file from the directory
492    async fn delete(&self, key: &Key) -> Result<(), StoreError> {
493        let safe_key = key.url_safe();
494        let full_path = self.p.join(safe_key.as_ref());
495        let str_path = full_path.display().to_string();
496        let _mutation_lock = self.lock_mutations().await?;
497        if !full_path.exists() {
498            return Err(StoreError::MissingKey(str_path));
499        }
500
501        self.owned_files.lock().remove(&full_path);
502
503        fs::remove_file(&full_path)
504            .context(str_path)
505            .map_err(a_to_fs_err)
506    }
507
508    async fn watch(
509        &self,
510    ) -> Result<Pin<Box<dyn futures::Stream<Item = WatchEvent> + Send + 'life0>>, StoreError> {
511        let (tx, mut rx) = tokio::sync::mpsc::channel(128);
512
513        let mut watcher = RecommendedWatcher::new(
514            move |res: Result<Event, notify::Error>| {
515                if let Err(err) = tx.blocking_send(res) {
516                    tracing::error!(error = %err, "Failed to send file watch event");
517                }
518            },
519            Config::default(),
520        )
521        .map_err(to_fs_err)?;
522
523        watcher
524            .watch(&self.p, RecursiveMode::NonRecursive)
525            .map_err(to_fs_err)?;
526
527        // Establish the filesystem watcher before reading the snapshot. Mutations that race
528        // with the snapshot are buffered by notify; event values are read when consumed, so
529        // replaying a buffered notification cannot regress an emitted snapshot value.
530        let initial_entries = self.entries().await?;
531        let dir = self.p.clone();
532        let root = self.root.clone();
533
534        Ok(Box::pin(async_stream::stream! {
535            // Keep watcher alive for the duration of the stream
536            let _watcher = watcher;
537
538            for (key, value) in initial_entries {
539                yield WatchEvent::Put(KeyValue::new(key, value));
540            }
541
542            while let Some(event_result) = rx.recv().await {
543                let event = match event_result {
544                    Ok(event) => event,
545                    Err(err) => {
546                        tracing::error!(error = %err, "Failed receiving file watch event");
547                        continue;
548                    }
549                };
550                for item_path in event.paths {
551                    // Skip if the event is for the directory itself
552                    if item_path == dir {
553                        tracing::warn!("Unexpected event on the directory itself");
554                        continue;
555                    }
556
557                    let canonical_item_path = canonicalize_event_path(&item_path);
558
559                    let key = match canonical_item_path.strip_prefix(&root) {
560                        Ok(stripped) => Key::from_url_safe(&stripped.display().to_string()),
561                        Err(err) => {
562                            // Possibly this should be a panic.
563                            // A key cannot be outside the file store root.
564                            tracing::error!(
565                                error = %err,
566                                item_path = %canonical_item_path.display(),
567                                root = %root.display(),
568                                "Item in file store is not prefixed with file store root. Should be impossible. Ignoring invalid key.");
569                            continue;
570                        }
571                    };
572
573                    // Skip temp files used for atomic writes
574                    if item_path.file_name()
575                        .map(|n| n.to_string_lossy().starts_with(TEMP_FILE_PREFIX))
576                        .unwrap_or(false)
577                    {
578                        continue;
579                    }
580
581                    match event.kind {
582                        // Handle file creation, modification, and rename-to (from atomic writes)
583                        EventKind::Create(event::CreateKind::File)
584                        | EventKind::Modify(event::ModifyKind::Data(event::DataChange::Content))
585                        | EventKind::Modify(event::ModifyKind::Name(event::RenameMode::To)) => {
586                            let data: bytes::Bytes = match fs::read(&item_path) {
587                                Ok(data) => data.into(),
588                                Err(err) => {
589                                    tracing::warn!(error = %err, item = %item_path.display(), "Failed reading event item. Skipping.");
590                                    continue;
591                                }
592                            };
593                            let item = KeyValue::new(key, data);
594                            yield WatchEvent::Put(item);
595                        }
596                        EventKind::Remove(_) => {
597                            if should_emit_delete(&item_path) {
598                                yield WatchEvent::Delete(key);
599                            } else {
600                                tracing::debug!(
601                                    item = %item_path.display(),
602                                    "Suppressing stale FileStore remove event for a recreated key"
603                                );
604                            }
605                        }
606                        _ => {
607                            // These happen every time the keep-alive updates last modified time
608                            continue;
609                        }
610                    }
611                }
612            }
613        }))
614    }
615
616    async fn entries(&self) -> Result<HashMap<Key, bytes::Bytes>, StoreError> {
617        let _mutation_lock = self.lock_mutations().await?;
618        let contents = fs::read_dir(&self.p)
619            .with_context(|| self.p.display().to_string())
620            .map_err(a_to_fs_err)?;
621        let mut out = HashMap::new();
622        for entry in contents {
623            let entry = entry.map_err(to_fs_err)?;
624            if !entry.path().is_file() {
625                tracing::warn!(
626                    path = %entry.path().display(),
627                    "Unexpected entry, directory should only contain files."
628                );
629                continue;
630            }
631
632            // Skip temp files used for atomic writes
633            if entry
634                .file_name()
635                .to_string_lossy()
636                .starts_with(TEMP_FILE_PREFIX)
637            {
638                continue;
639            }
640
641            // Canonicalize paths to handle symlinks (e.g., /var -> /private/var on macOS)
642            let canonical_entry_path = match entry.path().canonicalize() {
643                Ok(p) => p,
644                Err(err) => {
645                    tracing::warn!(error = %err, path = %entry.path().display(), "Failed to canonicalize path. Using original path.");
646                    entry.path()
647                }
648            };
649
650            let key = match canonical_entry_path.strip_prefix(&self.root) {
651                Ok(p) => Key::from_url_safe(&p.to_string_lossy()),
652                Err(err) => {
653                    tracing::error!(
654                        error = %err,
655                        path = %canonical_entry_path.display(),
656                        root = %self.root.display(),
657                        "FileStore path not in root. Should be impossible. Skipping entry."
658                    );
659                    continue;
660                }
661            };
662            let data: bytes::Bytes = fs::read(entry.path())
663                .with_context(|| self.p.display().to_string())
664                .map_err(a_to_fs_err)?
665                .into();
666            out.insert(key, data);
667        }
668        Ok(out)
669    }
670}
671
672fn write_temp_file_at(temp_path: &Path, value: &[u8]) -> Result<bool, StoreError> {
673    let mut file = match OpenOptions::new()
674        .write(true)
675        .create_new(true)
676        .open(temp_path)
677    {
678        Ok(file) => file,
679        Err(err) if err.kind() == ErrorKind::AlreadyExists => return Ok(false),
680        Err(err) => {
681            let err = anyhow::Error::new(err)
682                .context(format!("creating temp file {}", temp_path.display()));
683            return Err(a_to_fs_err(err));
684        }
685    };
686
687    if let Err(err) = file.write_all(value) {
688        if let Err(remove_err) = fs::remove_file(temp_path) {
689            tracing::warn!(
690                path = %temp_path.display(),
691                error = %remove_err,
692                "Failed to remove FileStore temp file after write error"
693            );
694        }
695        let err =
696            anyhow::Error::new(err).context(format!("writing temp file {}", temp_path.display()));
697        return Err(a_to_fs_err(err));
698    }
699
700    Ok(true)
701}
702
703fn canonicalize_event_path(path: &Path) -> PathBuf {
704    if let Ok(canonical_path) = path.canonicalize() {
705        return canonical_path;
706    }
707    let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) else {
708        return path.to_path_buf();
709    };
710    let Ok(canonical_parent) = parent.canonicalize() else {
711        return path.to_path_buf();
712    };
713    canonical_parent.join(file_name)
714}
715
716fn should_emit_delete(path: &Path) -> bool {
717    !path.exists()
718}
719
720// For anyhow preserve the context
721fn a_to_fs_err(err: anyhow::Error) -> StoreError {
722    StoreError::FilesystemError(format!("{err:#}"))
723}
724
725fn to_fs_err<E: std::error::Error>(err: E) -> StoreError {
726    StoreError::FilesystemError(err.to_string())
727}
728
729#[cfg(test)]
730mod tests {
731    use std::collections::HashSet;
732    use std::fs;
733    use std::os::unix::fs::symlink;
734    use std::sync::Arc;
735    use std::time::Duration;
736
737    use futures::StreamExt;
738    use tokio::sync::Barrier;
739    use tokio_util::sync::CancellationToken;
740
741    use crate::storage::kv::{Bucket as _, FileStore, Key, Store as _, StoreError, StoreOutcome};
742
743    #[test]
744    fn stale_remove_event_for_recreated_file_is_suppressed() {
745        let temp_dir = tempfile::tempdir().unwrap();
746        let path = temp_dir.path().join("recreated");
747
748        assert!(super::should_emit_delete(&path));
749        fs::write(&path, b"new").unwrap();
750        assert!(!super::should_emit_delete(&path));
751    }
752
753    #[tokio::test(flavor = "current_thread")]
754    async fn contended_mutation_lock_does_not_block_runtime_worker() {
755        let temp_dir = tempfile::tempdir().unwrap();
756        let cancel_token = CancellationToken::new();
757        let store = FileStore::new(cancel_token.clone(), temp_dir.path());
758        let bucket = store.get_or_create_bucket("v1/tests", None).await.unwrap();
759        let held_lock = bucket.lock_mutations_blocking().unwrap();
760
761        let release_thread = std::thread::spawn(move || {
762            std::thread::sleep(Duration::from_millis(200));
763            drop(held_lock);
764        });
765
766        tokio::select! {
767            _ = tokio::time::sleep(Duration::from_millis(50)) => {}
768            _ = bucket.lock_mutations() => {
769                panic!("contended mutation lock blocked the current-thread runtime");
770            }
771        }
772
773        release_thread.join().unwrap();
774        cancel_token.cancel();
775    }
776
777    #[tokio::test]
778    async fn delete_wins_race_with_compare_and_replace() {
779        let temp_dir = tempfile::tempdir().unwrap();
780        let update_cancel = CancellationToken::new();
781        let delete_cancel = CancellationToken::new();
782        let update_store = FileStore::new(update_cancel.clone(), temp_dir.path());
783        let delete_store = FileStore::new(delete_cancel.clone(), temp_dir.path());
784        let update_bucket = Arc::new(
785            update_store
786                .get_or_create_bucket("v1/tests", None)
787                .await
788                .unwrap(),
789        );
790        let delete_bucket = Arc::new(
791            delete_store
792                .get_or_create_bucket("v1/tests", None)
793                .await
794                .unwrap(),
795        );
796        let key = Key::new("model".to_string());
797        update_bucket.insert(&key, "old".into(), 0).await.unwrap();
798
799        let barrier = Arc::new(Barrier::new(3));
800        let task_bucket = update_bucket.clone();
801        let task_key = key.clone();
802        let task_barrier = barrier.clone();
803        let update = tokio::spawn(async move {
804            task_barrier.wait().await;
805            task_bucket
806                .compare_and_replace(&task_key, "old".into(), "new".into())
807                .await
808        });
809        let task_bucket = delete_bucket.clone();
810        let task_key = key.clone();
811        let task_barrier = barrier.clone();
812        let delete = tokio::spawn(async move {
813            task_barrier.wait().await;
814            task_bucket.delete(&task_key).await
815        });
816
817        barrier.wait().await;
818        let update_result = update.await.unwrap();
819        delete.await.unwrap().unwrap();
820        assert!(update_result.is_ok() || matches!(update_result, Err(StoreError::MissingKey(_))));
821        assert_eq!(update_bucket.get(&key).await.unwrap(), None);
822
823        update_cancel.cancel();
824        delete_cancel.cancel();
825    }
826
827    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
828    async fn concurrent_bucket_open_shares_ownership_registry() {
829        let t = tempfile::tempdir().unwrap();
830        let cancel_token = CancellationToken::new();
831        let store = FileStore::new(cancel_token.clone(), t.path());
832        let barrier = Arc::new(Barrier::new(32));
833
834        let mut tasks = Vec::new();
835        for _ in 0..32 {
836            let store = store.clone();
837            let barrier = barrier.clone();
838            tasks.push(tokio::spawn(async move {
839                barrier.wait().await;
840                store
841                    .get_or_create_bucket("v1/concurrent", None)
842                    .await
843                    .unwrap()
844            }));
845        }
846
847        let mut directories = Vec::new();
848        for task in tasks {
849            directories.push(task.await.unwrap());
850        }
851        cancel_token.cancel();
852
853        let ownership = &directories[0].owned_files;
854        assert!(
855            directories
856                .iter()
857                .all(|directory| Arc::ptr_eq(ownership, &directory.owned_files))
858        );
859    }
860
861    #[test]
862    fn deleted_event_path_canonicalizes_existing_parent() {
863        let t = tempfile::tempdir().unwrap();
864        let canonical_root = t.path().join("canonical");
865        let bucket = canonical_root.join("v1/tests");
866        fs::create_dir_all(&bucket).unwrap();
867        let linked_root = t.path().join("linked");
868        symlink(&canonical_root, &linked_root).unwrap();
869
870        assert_eq!(
871            super::canonicalize_event_path(&linked_root.join("v1/tests/deleted")),
872            canonical_root
873                .canonicalize()
874                .unwrap()
875                .join("v1/tests/deleted")
876        );
877    }
878
879    #[tokio::test]
880    async fn external_delete_is_observed_under_noncanonical_root() {
881        let t = tempfile::tempdir().unwrap();
882        let canonical_root = t.path().join("canonical");
883        fs::create_dir_all(&canonical_root).unwrap();
884        let linked_root = t.path().join("linked");
885        symlink(&canonical_root, &linked_root).unwrap();
886        let watcher_cancel = CancellationToken::new();
887        let creator_cancel = CancellationToken::new();
888        let watcher_store = FileStore::new(watcher_cancel.clone(), &linked_root);
889        let creator_store = FileStore::new(creator_cancel.clone(), &canonical_root);
890        let watcher_bucket = watcher_store
891            .get_or_create_bucket("v1/tests", None)
892            .await
893            .unwrap();
894        let creator_bucket = creator_store
895            .get_or_create_bucket("v1/tests", None)
896            .await
897            .unwrap();
898        let mut events = watcher_bucket.watch().await.unwrap();
899        let key = Key::new("scope/item".to_string());
900
901        creator_bucket
902            .insert(&key, "value".into(), 0)
903            .await
904            .unwrap();
905        loop {
906            let event = tokio::time::timeout(Duration::from_secs(2), events.next())
907                .await
908                .expect("FileStore watcher did not observe value creation")
909                .expect("FileStore watcher ended after value creation");
910            if matches!(event, super::WatchEvent::Put(ref item) if item.key_str() == "v1/tests/scope/item")
911            {
912                break;
913            }
914        }
915
916        creator_bucket.delete(&key).await.unwrap();
917        let event = tokio::time::timeout(Duration::from_secs(2), events.next())
918            .await
919            .expect("FileStore watcher did not observe value deletion")
920            .expect("FileStore watcher ended after value deletion");
921        assert!(
922            matches!(event, super::WatchEvent::Delete(ref deleted) if deleted == &Key::new("v1/tests/scope/item".to_string()))
923        );
924
925        watcher_cancel.cancel();
926        creator_cancel.cancel();
927    }
928
929    #[tokio::test]
930    async fn test_entries_full_path() {
931        let t = tempfile::tempdir().unwrap();
932
933        let cancel_token = CancellationToken::new();
934        let m = FileStore::new(cancel_token.clone(), t.path());
935        let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
936        let _ = bucket
937            .insert(&Key::new("key1/multi/part".to_string()), "value1".into(), 0)
938            .await
939            .unwrap();
940        let _ = bucket
941            .insert(&Key::new("key2".to_string()), "value2".into(), 0)
942            .await
943            .unwrap();
944        let entries = bucket.entries().await.unwrap();
945        let keys: HashSet<Key> = entries.into_keys().collect();
946        cancel_token.cancel(); // stop the background thread
947
948        assert!(keys.contains(&Key::new("v1/tests/key1/multi/part".to_string())));
949        assert!(keys.contains(&Key::new("v1/tests/key2".to_string())));
950    }
951
952    #[test]
953    fn test_temp_file_creation_does_not_overwrite_existing_path() {
954        let t = tempfile::tempdir().unwrap();
955        let temp_path = t.path().join(".tmp_existing");
956
957        fs::write(&temp_path, b"sentinel").unwrap();
958        let created = super::write_temp_file_at(&temp_path, b"new").unwrap();
959
960        assert!(!created);
961        assert_eq!(fs::read(&temp_path).unwrap(), b"sentinel");
962    }
963
964    #[tokio::test]
965    async fn test_insert_revision_zero_is_create_if_absent() {
966        let t = tempfile::tempdir().unwrap();
967
968        let cancel_token = CancellationToken::new();
969        let m = FileStore::new(cancel_token.clone(), t.path());
970        let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
971        let key = Key::new("singleton".to_string());
972
973        let first = bucket.insert(&key, "winner".into(), 0).await.unwrap();
974        let second = bucket.insert(&key, "loser".into(), 0).await.unwrap();
975        let value = bucket.get(&key).await.unwrap().unwrap();
976        cancel_token.cancel();
977
978        assert_eq!(first, StoreOutcome::Created(0));
979        assert_eq!(second, StoreOutcome::Exists(0));
980        assert_eq!(value.as_ref(), b"winner");
981    }
982
983    #[tokio::test]
984    async fn test_insert_nonzero_revision_overwrites() {
985        let t = tempfile::tempdir().unwrap();
986
987        let cancel_token = CancellationToken::new();
988        let m = FileStore::new(cancel_token.clone(), t.path());
989        let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
990        let key = Key::new("existing".to_string());
991
992        bucket.insert(&key, "old".into(), 0).await.unwrap();
993        let outcome = bucket.insert(&key, "new".into(), 1).await.unwrap();
994        let value = bucket.get(&key).await.unwrap().unwrap();
995        cancel_token.cancel();
996
997        assert_eq!(outcome, StoreOutcome::Created(1));
998        assert_eq!(value.as_ref(), b"new");
999    }
1000
1001    #[tokio::test]
1002    async fn test_concurrent_insert_revision_zero_has_one_winner() {
1003        let t = tempfile::tempdir().unwrap();
1004        let root = t.path().to_path_buf();
1005        let key = Key::new("singleton".to_string());
1006
1007        let mut tasks = Vec::new();
1008        for index in 0..16 {
1009            let root = root.clone();
1010            let key = key.clone();
1011            tasks.push(tokio::spawn(async move {
1012                let cancel_token = CancellationToken::new();
1013                let store = FileStore::new(cancel_token.clone(), root);
1014                let bucket = store.get_or_create_bucket("v1/claims", None).await.unwrap();
1015                let value = format!("value-{index}");
1016                let outcome = bucket.insert(&key, value.clone().into(), 0).await.unwrap();
1017                let stored = bucket.get(&key).await.unwrap().unwrap();
1018                cancel_token.cancel();
1019                (outcome, String::from_utf8(stored.to_vec()).unwrap(), value)
1020            }));
1021        }
1022
1023        let mut created_values = Vec::new();
1024        let mut observed_values = HashSet::new();
1025        for task in tasks {
1026            let (outcome, stored, attempted) = task.await.unwrap();
1027            observed_values.insert(stored);
1028            if outcome == StoreOutcome::Created(0) {
1029                created_values.push(attempted);
1030            } else {
1031                assert_eq!(outcome, StoreOutcome::Exists(0));
1032            }
1033        }
1034
1035        assert_eq!(created_values.len(), 1);
1036        assert_eq!(observed_values.len(), 1);
1037        assert_eq!(
1038            observed_values.into_iter().next().unwrap(),
1039            created_values.pop().unwrap()
1040        );
1041    }
1042}