1use 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
28const DEFAULT_TTL: Duration = Duration::from_secs(10);
31
32const MIN_KEEP_ALIVE: Duration = Duration::from_secs(1);
34
35const TEMP_FILE_PREFIX: &str = ".tmp_";
38const TEMP_FILE_CREATE_ATTEMPTS: usize = 16;
39const MUTATION_LOCK_ATTEMPTS: usize = 100;
40const MUTATION_LOCK_RETRY_DELAY: Duration = Duration::from_millis(10);
41
42#[derive(Clone)]
44pub struct FileStore {
45 cancel_token: CancellationToken,
46 root: PathBuf,
47 connection_id: u64,
48 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 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 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 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 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 if !p.is_dir() {
140 return Err(StoreError::FilesystemError(
141 "Bucket name is not a directory".to_string(),
142 ));
143 }
144 } else {
145 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if entry
632 .file_name()
633 .to_string_lossy()
634 .starts_with(TEMP_FILE_PREFIX)
635 {
636 continue;
637 }
638
639 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
718fn 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(); 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}