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            yield WatchEvent::Resync(initial_entries);
539
540            while let Some(event_result) = rx.recv().await {
541                let event = match event_result {
542                    Ok(event) => event,
543                    Err(err) => {
544                        tracing::error!(error = %err, "Failed receiving file watch event");
545                        continue;
546                    }
547                };
548                for item_path in event.paths {
549                    // Skip if the event is for the directory itself
550                    if item_path == dir {
551                        tracing::warn!("Unexpected event on the directory itself");
552                        continue;
553                    }
554
555                    let canonical_item_path = canonicalize_event_path(&item_path);
556
557                    let key = match canonical_item_path.strip_prefix(&root) {
558                        Ok(stripped) => Key::from_url_safe(&stripped.display().to_string()),
559                        Err(err) => {
560                            // Possibly this should be a panic.
561                            // A key cannot be outside the file store root.
562                            tracing::error!(
563                                error = %err,
564                                item_path = %canonical_item_path.display(),
565                                root = %root.display(),
566                                "Item in file store is not prefixed with file store root. Should be impossible. Ignoring invalid key.");
567                            continue;
568                        }
569                    };
570
571                    // Skip temp files used for atomic writes
572                    if item_path.file_name()
573                        .map(|n| n.to_string_lossy().starts_with(TEMP_FILE_PREFIX))
574                        .unwrap_or(false)
575                    {
576                        continue;
577                    }
578
579                    match event.kind {
580                        // Handle file creation, modification, and rename-to (from atomic writes)
581                        EventKind::Create(event::CreateKind::File)
582                        | EventKind::Modify(event::ModifyKind::Data(event::DataChange::Content))
583                        | EventKind::Modify(event::ModifyKind::Name(event::RenameMode::To)) => {
584                            let data: bytes::Bytes = match fs::read(&item_path) {
585                                Ok(data) => data.into(),
586                                Err(err) => {
587                                    tracing::warn!(error = %err, item = %item_path.display(), "Failed reading event item. Skipping.");
588                                    continue;
589                                }
590                            };
591                            let item = KeyValue::new(key, data);
592                            yield WatchEvent::Put(item);
593                        }
594                        EventKind::Remove(_) => {
595                            if should_emit_delete(&item_path) {
596                                yield WatchEvent::Delete(key);
597                            } else {
598                                tracing::debug!(
599                                    item = %item_path.display(),
600                                    "Suppressing stale FileStore remove event for a recreated key"
601                                );
602                            }
603                        }
604                        _ => {
605                            // These happen every time the keep-alive updates last modified time
606                            continue;
607                        }
608                    }
609                }
610            }
611        }))
612    }
613
614    async fn entries(&self) -> Result<HashMap<Key, bytes::Bytes>, StoreError> {
615        let _mutation_lock = self.lock_mutations().await?;
616        let contents = fs::read_dir(&self.p)
617            .with_context(|| self.p.display().to_string())
618            .map_err(a_to_fs_err)?;
619        let mut out = HashMap::new();
620        for entry in contents {
621            let entry = entry.map_err(to_fs_err)?;
622            if !entry.path().is_file() {
623                tracing::warn!(
624                    path = %entry.path().display(),
625                    "Unexpected entry, directory should only contain files."
626                );
627                continue;
628            }
629
630            // Skip temp files used for atomic writes
631            if entry
632                .file_name()
633                .to_string_lossy()
634                .starts_with(TEMP_FILE_PREFIX)
635            {
636                continue;
637            }
638
639            // Canonicalize paths to handle symlinks (e.g., /var -> /private/var on macOS)
640            let canonical_entry_path = match entry.path().canonicalize() {
641                Ok(p) => p,
642                Err(err) => {
643                    tracing::warn!(error = %err, path = %entry.path().display(), "Failed to canonicalize path. Using original path.");
644                    entry.path()
645                }
646            };
647
648            let key = match canonical_entry_path.strip_prefix(&self.root) {
649                Ok(p) => Key::from_url_safe(&p.to_string_lossy()),
650                Err(err) => {
651                    tracing::error!(
652                        error = %err,
653                        path = %canonical_entry_path.display(),
654                        root = %self.root.display(),
655                        "FileStore path not in root. Should be impossible. Skipping entry."
656                    );
657                    continue;
658                }
659            };
660            let data: bytes::Bytes = fs::read(entry.path())
661                .with_context(|| self.p.display().to_string())
662                .map_err(a_to_fs_err)?
663                .into();
664            out.insert(key, data);
665        }
666        Ok(out)
667    }
668}
669
670fn write_temp_file_at(temp_path: &Path, value: &[u8]) -> Result<bool, StoreError> {
671    let mut file = match OpenOptions::new()
672        .write(true)
673        .create_new(true)
674        .open(temp_path)
675    {
676        Ok(file) => file,
677        Err(err) if err.kind() == ErrorKind::AlreadyExists => return Ok(false),
678        Err(err) => {
679            let err = anyhow::Error::new(err)
680                .context(format!("creating temp file {}", temp_path.display()));
681            return Err(a_to_fs_err(err));
682        }
683    };
684
685    if let Err(err) = file.write_all(value) {
686        if let Err(remove_err) = fs::remove_file(temp_path) {
687            tracing::warn!(
688                path = %temp_path.display(),
689                error = %remove_err,
690                "Failed to remove FileStore temp file after write error"
691            );
692        }
693        let err =
694            anyhow::Error::new(err).context(format!("writing temp file {}", temp_path.display()));
695        return Err(a_to_fs_err(err));
696    }
697
698    Ok(true)
699}
700
701fn canonicalize_event_path(path: &Path) -> PathBuf {
702    if let Ok(canonical_path) = path.canonicalize() {
703        return canonical_path;
704    }
705    let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) else {
706        return path.to_path_buf();
707    };
708    let Ok(canonical_parent) = parent.canonicalize() else {
709        return path.to_path_buf();
710    };
711    canonical_parent.join(file_name)
712}
713
714fn should_emit_delete(path: &Path) -> bool {
715    !path.exists()
716}
717
718// For anyhow preserve the context
719fn a_to_fs_err(err: anyhow::Error) -> StoreError {
720    StoreError::FilesystemError(format!("{err:#}"))
721}
722
723fn to_fs_err<E: std::error::Error>(err: E) -> StoreError {
724    StoreError::FilesystemError(err.to_string())
725}
726
727#[cfg(test)]
728mod tests {
729    use std::collections::HashSet;
730    use std::fs;
731    use std::os::unix::fs::symlink;
732    use std::sync::Arc;
733    use std::time::Duration;
734
735    use futures::StreamExt;
736    use tokio::sync::Barrier;
737    use tokio_util::sync::CancellationToken;
738
739    use crate::storage::kv::{Bucket as _, FileStore, Key, Store as _, StoreError, StoreOutcome};
740
741    #[test]
742    fn stale_remove_event_for_recreated_file_is_suppressed() {
743        let temp_dir = tempfile::tempdir().unwrap();
744        let path = temp_dir.path().join("recreated");
745
746        assert!(super::should_emit_delete(&path));
747        fs::write(&path, b"new").unwrap();
748        assert!(!super::should_emit_delete(&path));
749    }
750
751    #[tokio::test(flavor = "current_thread")]
752    async fn contended_mutation_lock_does_not_block_runtime_worker() {
753        let temp_dir = tempfile::tempdir().unwrap();
754        let cancel_token = CancellationToken::new();
755        let store = FileStore::new(cancel_token.clone(), temp_dir.path());
756        let bucket = store.get_or_create_bucket("v1/tests", None).await.unwrap();
757        let held_lock = bucket.lock_mutations_blocking().unwrap();
758
759        let release_thread = std::thread::spawn(move || {
760            std::thread::sleep(Duration::from_millis(200));
761            drop(held_lock);
762        });
763
764        tokio::select! {
765            _ = tokio::time::sleep(Duration::from_millis(50)) => {}
766            _ = bucket.lock_mutations() => {
767                panic!("contended mutation lock blocked the current-thread runtime");
768            }
769        }
770
771        release_thread.join().unwrap();
772        cancel_token.cancel();
773    }
774
775    #[tokio::test]
776    async fn delete_wins_race_with_compare_and_replace() {
777        let temp_dir = tempfile::tempdir().unwrap();
778        let update_cancel = CancellationToken::new();
779        let delete_cancel = CancellationToken::new();
780        let update_store = FileStore::new(update_cancel.clone(), temp_dir.path());
781        let delete_store = FileStore::new(delete_cancel.clone(), temp_dir.path());
782        let update_bucket = Arc::new(
783            update_store
784                .get_or_create_bucket("v1/tests", None)
785                .await
786                .unwrap(),
787        );
788        let delete_bucket = Arc::new(
789            delete_store
790                .get_or_create_bucket("v1/tests", None)
791                .await
792                .unwrap(),
793        );
794        let key = Key::new("model".to_string());
795        update_bucket.insert(&key, "old".into(), 0).await.unwrap();
796
797        let barrier = Arc::new(Barrier::new(3));
798        let task_bucket = update_bucket.clone();
799        let task_key = key.clone();
800        let task_barrier = barrier.clone();
801        let update = tokio::spawn(async move {
802            task_barrier.wait().await;
803            task_bucket
804                .compare_and_replace(&task_key, "old".into(), "new".into())
805                .await
806        });
807        let task_bucket = delete_bucket.clone();
808        let task_key = key.clone();
809        let task_barrier = barrier.clone();
810        let delete = tokio::spawn(async move {
811            task_barrier.wait().await;
812            task_bucket.delete(&task_key).await
813        });
814
815        barrier.wait().await;
816        let update_result = update.await.unwrap();
817        delete.await.unwrap().unwrap();
818        assert!(update_result.is_ok() || matches!(update_result, Err(StoreError::MissingKey(_))));
819        assert_eq!(update_bucket.get(&key).await.unwrap(), None);
820
821        update_cancel.cancel();
822        delete_cancel.cancel();
823    }
824
825    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
826    async fn concurrent_bucket_open_shares_ownership_registry() {
827        let t = tempfile::tempdir().unwrap();
828        let cancel_token = CancellationToken::new();
829        let store = FileStore::new(cancel_token.clone(), t.path());
830        let barrier = Arc::new(Barrier::new(32));
831
832        let mut tasks = Vec::new();
833        for _ in 0..32 {
834            let store = store.clone();
835            let barrier = barrier.clone();
836            tasks.push(tokio::spawn(async move {
837                barrier.wait().await;
838                store
839                    .get_or_create_bucket("v1/concurrent", None)
840                    .await
841                    .unwrap()
842            }));
843        }
844
845        let mut directories = Vec::new();
846        for task in tasks {
847            directories.push(task.await.unwrap());
848        }
849        cancel_token.cancel();
850
851        let ownership = &directories[0].owned_files;
852        assert!(
853            directories
854                .iter()
855                .all(|directory| Arc::ptr_eq(ownership, &directory.owned_files))
856        );
857    }
858
859    #[test]
860    fn deleted_event_path_canonicalizes_existing_parent() {
861        let t = tempfile::tempdir().unwrap();
862        let canonical_root = t.path().join("canonical");
863        let bucket = canonical_root.join("v1/tests");
864        fs::create_dir_all(&bucket).unwrap();
865        let linked_root = t.path().join("linked");
866        symlink(&canonical_root, &linked_root).unwrap();
867
868        assert_eq!(
869            super::canonicalize_event_path(&linked_root.join("v1/tests/deleted")),
870            canonical_root
871                .canonicalize()
872                .unwrap()
873                .join("v1/tests/deleted")
874        );
875    }
876
877    #[tokio::test]
878    async fn external_delete_is_observed_under_noncanonical_root() {
879        let t = tempfile::tempdir().unwrap();
880        let canonical_root = t.path().join("canonical");
881        fs::create_dir_all(&canonical_root).unwrap();
882        let linked_root = t.path().join("linked");
883        symlink(&canonical_root, &linked_root).unwrap();
884        let watcher_cancel = CancellationToken::new();
885        let creator_cancel = CancellationToken::new();
886        let watcher_store = FileStore::new(watcher_cancel.clone(), &linked_root);
887        let creator_store = FileStore::new(creator_cancel.clone(), &canonical_root);
888        let watcher_bucket = watcher_store
889            .get_or_create_bucket("v1/tests", None)
890            .await
891            .unwrap();
892        let creator_bucket = creator_store
893            .get_or_create_bucket("v1/tests", None)
894            .await
895            .unwrap();
896        let mut events = watcher_bucket.watch().await.unwrap();
897        let key = Key::new("scope/item".to_string());
898
899        creator_bucket
900            .insert(&key, "value".into(), 0)
901            .await
902            .unwrap();
903        loop {
904            let event = tokio::time::timeout(Duration::from_secs(2), events.next())
905                .await
906                .expect("FileStore watcher did not observe value creation")
907                .expect("FileStore watcher ended after value creation");
908            if matches!(event, super::WatchEvent::Put(ref item) if item.key_str() == "v1/tests/scope/item")
909            {
910                break;
911            }
912        }
913
914        creator_bucket.delete(&key).await.unwrap();
915        let event = tokio::time::timeout(Duration::from_secs(2), events.next())
916            .await
917            .expect("FileStore watcher did not observe value deletion")
918            .expect("FileStore watcher ended after value deletion");
919        assert!(
920            matches!(event, super::WatchEvent::Delete(ref deleted) if deleted == &Key::new("v1/tests/scope/item".to_string()))
921        );
922
923        watcher_cancel.cancel();
924        creator_cancel.cancel();
925    }
926
927    #[tokio::test]
928    async fn test_entries_full_path() {
929        let t = tempfile::tempdir().unwrap();
930
931        let cancel_token = CancellationToken::new();
932        let m = FileStore::new(cancel_token.clone(), t.path());
933        let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
934        let _ = bucket
935            .insert(&Key::new("key1/multi/part".to_string()), "value1".into(), 0)
936            .await
937            .unwrap();
938        let _ = bucket
939            .insert(&Key::new("key2".to_string()), "value2".into(), 0)
940            .await
941            .unwrap();
942        let entries = bucket.entries().await.unwrap();
943        let mut events = bucket.watch().await.unwrap();
944        let snapshot = tokio::time::timeout(Duration::from_secs(2), events.next())
945            .await
946            .expect("FileStore watcher did not report its snapshot");
947        assert_eq!(snapshot, Some(super::WatchEvent::Resync(entries.clone())));
948        let keys: HashSet<Key> = entries.into_keys().collect();
949        cancel_token.cancel(); // stop the background thread
950
951        assert!(keys.contains(&Key::new("v1/tests/key1/multi/part".to_string())));
952        assert!(keys.contains(&Key::new("v1/tests/key2".to_string())));
953    }
954
955    #[test]
956    fn test_temp_file_creation_does_not_overwrite_existing_path() {
957        let t = tempfile::tempdir().unwrap();
958        let temp_path = t.path().join(".tmp_existing");
959
960        fs::write(&temp_path, b"sentinel").unwrap();
961        let created = super::write_temp_file_at(&temp_path, b"new").unwrap();
962
963        assert!(!created);
964        assert_eq!(fs::read(&temp_path).unwrap(), b"sentinel");
965    }
966
967    #[tokio::test]
968    async fn test_insert_revision_zero_is_create_if_absent() {
969        let t = tempfile::tempdir().unwrap();
970
971        let cancel_token = CancellationToken::new();
972        let m = FileStore::new(cancel_token.clone(), t.path());
973        let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
974        let key = Key::new("singleton".to_string());
975
976        let first = bucket.insert(&key, "winner".into(), 0).await.unwrap();
977        let second = bucket.insert(&key, "loser".into(), 0).await.unwrap();
978        let value = bucket.get(&key).await.unwrap().unwrap();
979        cancel_token.cancel();
980
981        assert_eq!(first, StoreOutcome::Created(0));
982        assert_eq!(second, StoreOutcome::Exists(0));
983        assert_eq!(value.as_ref(), b"winner");
984    }
985
986    #[tokio::test]
987    async fn test_insert_nonzero_revision_overwrites() {
988        let t = tempfile::tempdir().unwrap();
989
990        let cancel_token = CancellationToken::new();
991        let m = FileStore::new(cancel_token.clone(), t.path());
992        let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
993        let key = Key::new("existing".to_string());
994
995        bucket.insert(&key, "old".into(), 0).await.unwrap();
996        let outcome = bucket.insert(&key, "new".into(), 1).await.unwrap();
997        let value = bucket.get(&key).await.unwrap().unwrap();
998        cancel_token.cancel();
999
1000        assert_eq!(outcome, StoreOutcome::Created(1));
1001        assert_eq!(value.as_ref(), b"new");
1002    }
1003
1004    #[tokio::test]
1005    async fn test_concurrent_insert_revision_zero_has_one_winner() {
1006        let t = tempfile::tempdir().unwrap();
1007        let root = t.path().to_path_buf();
1008        let key = Key::new("singleton".to_string());
1009
1010        let mut tasks = Vec::new();
1011        for index in 0..16 {
1012            let root = root.clone();
1013            let key = key.clone();
1014            tasks.push(tokio::spawn(async move {
1015                let cancel_token = CancellationToken::new();
1016                let store = FileStore::new(cancel_token.clone(), root);
1017                let bucket = store.get_or_create_bucket("v1/claims", None).await.unwrap();
1018                let value = format!("value-{index}");
1019                let outcome = bucket.insert(&key, value.clone().into(), 0).await.unwrap();
1020                let stored = bucket.get(&key).await.unwrap().unwrap();
1021                cancel_token.cancel();
1022                (outcome, String::from_utf8(stored.to_vec()).unwrap(), value)
1023            }));
1024        }
1025
1026        let mut created_values = Vec::new();
1027        let mut observed_values = HashSet::new();
1028        for task in tasks {
1029            let (outcome, stored, attempted) = task.await.unwrap();
1030            observed_values.insert(stored);
1031            if outcome == StoreOutcome::Created(0) {
1032                created_values.push(attempted);
1033            } else {
1034                assert_eq!(outcome, StoreOutcome::Exists(0));
1035            }
1036        }
1037
1038        assert_eq!(created_values.len(), 1);
1039        assert_eq!(observed_values.len(), 1);
1040        assert_eq!(
1041            observed_values.into_iter().next().unwrap(),
1042            created_values.pop().unwrap()
1043        );
1044    }
1045}