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 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 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 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 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 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 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 if entry
634 .file_name()
635 .to_string_lossy()
636 .starts_with(TEMP_FILE_PREFIX)
637 {
638 continue;
639 }
640
641 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
720fn 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(); 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}