1use std::cmp;
5use std::collections::HashSet;
6use std::ffi::OsString;
7use std::fmt;
8use std::fs;
9use std::fs::OpenOptions;
10use std::io::{ErrorKind, Write};
11use std::os::unix::ffi::OsStrExt;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::thread;
15use std::time::Duration;
16use std::time::SystemTime;
17use std::{collections::HashMap, pin::Pin};
18
19use anyhow::Context as _;
20use async_trait::async_trait;
21use futures::StreamExt;
22use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, event};
23use parking_lot::Mutex;
24use tokio_util::sync::CancellationToken;
25
26use super::{Bucket, Key, KeyValue, Store, StoreError, StoreOutcome, WatchEvent};
27
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;
39
40#[derive(Clone)]
42pub struct FileStore {
43 cancel_token: CancellationToken,
44 root: PathBuf,
45 connection_id: u64,
46 active_dirs: Arc<Mutex<HashMap<PathBuf, Directory>>>,
49}
50
51impl FileStore {
52 pub(super) fn new<P: Into<PathBuf>>(cancel_token: CancellationToken, root_dir: P) -> Self {
53 let fs = FileStore {
54 cancel_token,
55 root: root_dir.into(),
56 connection_id: rand::random::<u64>(),
57 active_dirs: Arc::new(Mutex::new(HashMap::new())),
58 };
59 let c = fs.clone();
60 thread::spawn(move || c.expiry_thread());
61 fs
62 }
63
64 fn expiry_thread(&self) {
70 loop {
71 let ttl = self.shortest_ttl();
72 let keep_alive_interval = cmp::max(ttl / 3, MIN_KEEP_ALIVE);
73
74 if self.cancel_token.is_cancelled() {
76 break;
77 }
78
79 thread::sleep(keep_alive_interval);
80
81 if self.cancel_token.is_cancelled() {
82 break;
83 }
84
85 self.keep_alive();
86 if let Err(err) = self.delete_expired_files() {
87 tracing::error!(error = %err, "FileStore delete_expired_files");
88 }
89 }
90 }
91
92 fn shortest_ttl(&self) -> Duration {
94 let mut ttl = DEFAULT_TTL;
95 let active_dirs = self.active_dirs.lock().clone();
96 for (_, dir) in active_dirs {
97 ttl = cmp::min(ttl, dir.ttl);
98 }
99 tracing::trace!("FileStore expiry shortest ttl {ttl:?}");
100 ttl
101 }
102
103 fn keep_alive(&self) {
104 let active_dirs = self.active_dirs.lock().clone();
105 for (_, dir) in active_dirs {
106 dir.keep_alive();
107 }
108 }
109
110 fn delete_expired_files(&self) -> anyhow::Result<()> {
111 let active_dirs = self.active_dirs.lock().clone();
112 for (path, dir) in active_dirs {
113 dir.delete_expired_files()
114 .with_context(|| path.display().to_string())?;
115 }
116 Ok(())
117 }
118}
119
120#[async_trait]
121impl Store for FileStore {
122 type Bucket = Directory;
123
124 async fn get_or_create_bucket(
126 &self,
127 bucket_name: &str,
128 ttl: Option<Duration>,
129 ) -> Result<Self::Bucket, StoreError> {
130 let p = self.root.join(bucket_name);
131 if let Some(dir) = self.active_dirs.lock().get(&p) {
132 return Ok(dir.clone());
133 };
134
135 if p.exists() {
136 if !p.is_dir() {
138 return Err(StoreError::FilesystemError(
139 "Bucket name is not a directory".to_string(),
140 ));
141 }
142 } else {
143 fs::create_dir_all(&p).map_err(to_fs_err)?;
145 }
146 let candidate = Directory::new(self.root.clone(), p.clone(), ttl.unwrap_or(DEFAULT_TTL));
147 let dir = self
148 .active_dirs
149 .lock()
150 .entry(p)
151 .or_insert(candidate)
152 .clone();
153 Ok(dir)
154 }
155
156 async fn get_bucket(&self, bucket_name: &str) -> Result<Option<Self::Bucket>, StoreError> {
158 let p = self.root.join(bucket_name);
159 if let Some(dir) = self.active_dirs.lock().get(&p) {
160 return Ok(Some(dir.clone()));
161 };
162
163 if !p.exists() {
164 return Ok(None);
165 }
166 if !p.is_dir() {
167 return Err(StoreError::FilesystemError(
168 "Bucket name is not a directory".to_string(),
169 ));
170 }
171 let candidate = Directory::new(self.root.clone(), p.clone(), DEFAULT_TTL);
173 let dir = self
174 .active_dirs
175 .lock()
176 .entry(p)
177 .or_insert(candidate)
178 .clone();
179 Ok(Some(dir))
180 }
181
182 fn connection_id(&self) -> u64 {
183 self.connection_id
184 }
185
186 fn shutdown(&self) {
189 for (_, mut dir) in self.active_dirs.lock().drain() {
190 if let Err(err) = dir.delete_owned_files() {
191 tracing::error!(error = %err, %dir, "Failed shutdown delete of owned files");
192 }
193 }
194 }
195}
196
197#[derive(Clone)]
198pub struct Directory {
199 root: PathBuf,
200 p: PathBuf,
201 ttl: Duration,
202 owned_files: Arc<Mutex<HashSet<PathBuf>>>,
204}
205
206impl Directory {
207 fn new(root: PathBuf, p: PathBuf, ttl: Duration) -> Self {
208 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
210 let canonical_path = p.canonicalize().unwrap_or_else(|_| p.clone());
211 if ttl < MIN_KEEP_ALIVE {
212 let h_ttl = humantime::format_duration(ttl);
213 tracing::warn!(path = %p.display(), ttl = %h_ttl, "ttl is too short, increasing to {}", humantime::format_duration(MIN_KEEP_ALIVE));
214 }
215 let ttl = cmp::max(ttl, MIN_KEEP_ALIVE);
216 Directory {
217 root: canonical_root,
218 p: canonical_path,
219 ttl,
220 owned_files: Arc::new(Mutex::new(HashSet::new())),
221 }
222 }
223
224 fn keep_alive(&self) {
226 let owned_files = self.owned_files.lock().clone();
227 for path in owned_files {
228 let file = match OpenOptions::new().write(true).open(&path) {
229 Ok(f) => f,
230 Err(err) => {
231 tracing::error!(path = %path.display(), error = %err, "FileStore::keep_alive failed opening owned file");
232 continue;
233 }
234 };
235 if let Err(err) = file.set_modified(SystemTime::now()) {
236 tracing::error!(path = %path.display(), error = %err, "FileStore::keep_alive failed set_modified on owned file");
237 continue;
238 }
239 tracing::trace!("FileStore keep_alive set {}", path.display());
240 }
241 }
242
243 fn delete_expired_files(&self) -> anyhow::Result<()> {
248 let deadline = SystemTime::now() - self.ttl;
249 let dirname = self.p.display().to_string();
250 for entry in fs::read_dir(&self.p).with_context(|| dirname.clone())? {
251 let entry = match entry {
252 Ok(p) => p,
253 Err(err) => {
254 tracing::warn!(dir = dirname, error = %err, "File store could read directory contents");
255 continue;
256 }
257 };
258 if !entry.file_type().map(|f| f.is_file()).unwrap_or(false) {
259 tracing::warn!(dir = dirname, entry = %entry.path().display(), "File store directory should only contain files");
260 continue;
261 }
262 let ctx = entry.path().display().to_string();
263 let metadata = match entry.metadata() {
264 Ok(m) => m,
265 Err(err) => {
266 tracing::warn!(path = %ctx, error = %err, "Failed fetching metadata");
267 continue;
268 }
269 };
270 let last_modified = match metadata.modified() {
271 Ok(lm) => lm,
272 Err(err) => {
273 tracing::warn!(path = %ctx, error = %err, "Failed reading mtime");
276 continue;
277 }
278 };
279 if last_modified < deadline {
280 tracing::info!(path = ctx, ?last_modified, "Expired");
281 if let Err(err) = fs::remove_file(entry.path()) {
282 tracing::warn!(path = %ctx, error = %err, "Failed removing");
283 }
284 }
285 }
286 Ok(())
287 }
288
289 fn delete_owned_files(&mut self) -> anyhow::Result<()> {
290 let mut errs = Vec::new();
291 for p in self.owned_files.lock().drain() {
292 if let Err(err) = fs::remove_file(&p) {
293 errs.push(format!("{}: {err}", p.display()));
294 }
295 }
296 if !errs.is_empty() {
297 anyhow::bail!(errs.join(", "));
298 }
299 Ok(())
300 }
301
302 fn write_temp_file(&self, value: &[u8]) -> Result<PathBuf, StoreError> {
303 for _ in 0..TEMP_FILE_CREATE_ATTEMPTS {
304 let temp_name = format!("{TEMP_FILE_PREFIX}{:016x}", rand::random::<u64>());
305 let temp_path = self.p.join(&temp_name);
306 if write_temp_file_at(&temp_path, value)? {
307 return Ok(temp_path);
308 }
309 }
310
311 Err(StoreError::FilesystemError(format!(
312 "failed to create unique FileStore temp file in {} after {TEMP_FILE_CREATE_ATTEMPTS} attempts",
313 self.p.display()
314 )))
315 }
316}
317
318impl fmt::Display for Directory {
319 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
320 write!(f, "{}", self.p.display())
321 }
322}
323
324#[async_trait]
325impl Bucket for Directory {
326 async fn insert(
332 &self,
333 key: &Key,
334 value: bytes::Bytes,
335 revision: u64,
336 ) -> Result<StoreOutcome, StoreError> {
337 let safe_key = key.url_safe();
338 let full_path = self.p.join(safe_key.as_ref());
339 let str_path = full_path.display().to_string();
340
341 let temp_path = self.write_temp_file(&value)?;
342
343 if revision == 0 {
344 match fs::hard_link(&temp_path, &full_path) {
347 Ok(()) => {
348 if let Err(err) = fs::remove_file(&temp_path) {
349 tracing::warn!(
350 path = %temp_path.display(),
351 error = %err,
352 "Failed to remove FileStore temp file after create-if-absent publish"
353 );
354 }
355 self.owned_files.lock().insert(full_path.clone());
356 return Ok(StoreOutcome::Created(0));
357 }
358 Err(err) if err.kind() == ErrorKind::AlreadyExists => {
359 if let Err(remove_err) = fs::remove_file(&temp_path) {
360 tracing::warn!(
361 path = %temp_path.display(),
362 error = %remove_err,
363 "Failed to remove unused FileStore temp file after create-if-absent conflict"
364 );
365 }
366 return Ok(StoreOutcome::Exists(0));
367 }
368 Err(err) => {
369 if let Err(remove_err) = fs::remove_file(&temp_path) {
370 tracing::warn!(
371 path = %temp_path.display(),
372 error = %remove_err,
373 "Failed to remove unused FileStore temp file after create-if-absent error"
374 );
375 }
376 return Err(to_fs_err(err));
377 }
378 }
379 }
380
381 fs::rename(&temp_path, &full_path)
383 .with_context(|| format!("renaming {} to {}", temp_path.display(), str_path))
384 .map_err(a_to_fs_err)?;
385
386 self.owned_files.lock().insert(full_path.clone());
387 Ok(StoreOutcome::Created(revision))
388 }
389
390 async fn get(&self, key: &Key) -> Result<Option<bytes::Bytes>, StoreError> {
392 let safe_key = key.url_safe();
393 let full_path = self.p.join(safe_key.as_ref());
394 if !full_path.exists() {
395 return Ok(None);
396 }
397 let str_path = full_path.display().to_string();
398 let data: bytes::Bytes = fs::read(&full_path)
399 .context(str_path)
400 .map_err(a_to_fs_err)?
401 .into();
402 Ok(Some(data))
403 }
404
405 async fn delete(&self, key: &Key) -> Result<(), StoreError> {
407 let safe_key = key.url_safe();
408 let full_path = self.p.join(safe_key.as_ref());
409 let str_path = full_path.display().to_string();
410 if !full_path.exists() {
411 return Err(StoreError::MissingKey(str_path));
412 }
413
414 self.owned_files.lock().remove(&full_path);
415
416 fs::remove_file(&full_path)
417 .context(str_path)
418 .map_err(a_to_fs_err)
419 }
420
421 async fn watch(
422 &self,
423 ) -> Result<Pin<Box<dyn futures::Stream<Item = WatchEvent> + Send + 'life0>>, StoreError> {
424 let (tx, mut rx) = tokio::sync::mpsc::channel(128);
425
426 let mut watcher = RecommendedWatcher::new(
427 move |res: Result<Event, notify::Error>| {
428 if let Err(err) = tx.blocking_send(res) {
429 tracing::error!(error = %err, "Failed to send file watch event");
430 }
431 },
432 Config::default(),
433 )
434 .map_err(to_fs_err)?;
435
436 watcher
437 .watch(&self.p, RecursiveMode::NonRecursive)
438 .map_err(to_fs_err)?;
439
440 let dir = self.p.clone();
441 let root = self.root.clone();
442
443 Ok(Box::pin(async_stream::stream! {
444 let _watcher = watcher;
446
447 while let Some(event_result) = rx.recv().await {
448 let event = match event_result {
449 Ok(event) => event,
450 Err(err) => {
451 tracing::error!(error = %err, "Failed receiving file watch event");
452 continue;
453 }
454 };
455 for item_path in event.paths {
456 if item_path == dir {
458 tracing::warn!("Unexpected event on the directory itself");
459 continue;
460 }
461
462 let canonical_item_path = canonicalize_event_path(&item_path);
463
464 let key = match canonical_item_path.strip_prefix(&root) {
465 Ok(stripped) => Key::from_url_safe(&stripped.display().to_string()),
466 Err(err) => {
467 tracing::error!(
470 error = %err,
471 item_path = %canonical_item_path.display(),
472 root = %root.display(),
473 "Item in file store is not prefixed with file store root. Should be impossible. Ignoring invalid key.");
474 continue;
475 }
476 };
477
478 if item_path.file_name()
480 .map(|n| n.to_string_lossy().starts_with(TEMP_FILE_PREFIX))
481 .unwrap_or(false)
482 {
483 continue;
484 }
485
486 match event.kind {
487 EventKind::Create(event::CreateKind::File)
489 | EventKind::Modify(event::ModifyKind::Data(event::DataChange::Content))
490 | EventKind::Modify(event::ModifyKind::Name(event::RenameMode::To)) => {
491 let data: bytes::Bytes = match fs::read(&item_path) {
492 Ok(data) => data.into(),
493 Err(err) => {
494 tracing::warn!(error = %err, item = %item_path.display(), "Failed reading event item. Skipping.");
495 continue;
496 }
497 };
498 let item = KeyValue::new(key, data);
499 yield WatchEvent::Put(item);
500 }
501 EventKind::Remove(_) => {
502 yield WatchEvent::Delete(key);
503 }
504 _ => {
505 continue;
507 }
508 }
509 }
510 }
511 }))
512 }
513
514 async fn entries(&self) -> Result<HashMap<Key, bytes::Bytes>, StoreError> {
515 let contents = fs::read_dir(&self.p)
516 .with_context(|| self.p.display().to_string())
517 .map_err(a_to_fs_err)?;
518 let mut out = HashMap::new();
519 for entry in contents {
520 let entry = entry.map_err(to_fs_err)?;
521 if !entry.path().is_file() {
522 tracing::warn!(
523 path = %entry.path().display(),
524 "Unexpected entry, directory should only contain files."
525 );
526 continue;
527 }
528
529 if entry
531 .file_name()
532 .to_string_lossy()
533 .starts_with(TEMP_FILE_PREFIX)
534 {
535 continue;
536 }
537
538 let canonical_entry_path = match entry.path().canonicalize() {
540 Ok(p) => p,
541 Err(err) => {
542 tracing::warn!(error = %err, path = %entry.path().display(), "Failed to canonicalize path. Using original path.");
543 entry.path()
544 }
545 };
546
547 let key = match canonical_entry_path.strip_prefix(&self.root) {
548 Ok(p) => Key::from_url_safe(&p.to_string_lossy()),
549 Err(err) => {
550 tracing::error!(
551 error = %err,
552 path = %canonical_entry_path.display(),
553 root = %self.root.display(),
554 "FileStore path not in root. Should be impossible. Skipping entry."
555 );
556 continue;
557 }
558 };
559 let data: bytes::Bytes = fs::read(entry.path())
560 .with_context(|| self.p.display().to_string())
561 .map_err(a_to_fs_err)?
562 .into();
563 out.insert(key, data);
564 }
565 Ok(out)
566 }
567}
568
569fn write_temp_file_at(temp_path: &Path, value: &[u8]) -> Result<bool, StoreError> {
570 let mut file = match OpenOptions::new()
571 .write(true)
572 .create_new(true)
573 .open(temp_path)
574 {
575 Ok(file) => file,
576 Err(err) if err.kind() == ErrorKind::AlreadyExists => return Ok(false),
577 Err(err) => {
578 let err = anyhow::Error::new(err)
579 .context(format!("creating temp file {}", temp_path.display()));
580 return Err(a_to_fs_err(err));
581 }
582 };
583
584 if let Err(err) = file.write_all(value) {
585 if let Err(remove_err) = fs::remove_file(temp_path) {
586 tracing::warn!(
587 path = %temp_path.display(),
588 error = %remove_err,
589 "Failed to remove FileStore temp file after write error"
590 );
591 }
592 let err =
593 anyhow::Error::new(err).context(format!("writing temp file {}", temp_path.display()));
594 return Err(a_to_fs_err(err));
595 }
596
597 Ok(true)
598}
599
600fn canonicalize_event_path(path: &Path) -> PathBuf {
601 if let Ok(canonical_path) = path.canonicalize() {
602 return canonical_path;
603 }
604 let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) else {
605 return path.to_path_buf();
606 };
607 let Ok(canonical_parent) = parent.canonicalize() else {
608 return path.to_path_buf();
609 };
610 canonical_parent.join(file_name)
611}
612
613fn a_to_fs_err(err: anyhow::Error) -> StoreError {
615 StoreError::FilesystemError(format!("{err:#}"))
616}
617
618fn to_fs_err<E: std::error::Error>(err: E) -> StoreError {
619 StoreError::FilesystemError(err.to_string())
620}
621
622#[cfg(test)]
623mod tests {
624 use std::collections::HashSet;
625 use std::fs;
626 use std::os::unix::fs::symlink;
627 use std::sync::Arc;
628 use std::time::Duration;
629
630 use futures::StreamExt;
631 use tokio::sync::Barrier;
632 use tokio_util::sync::CancellationToken;
633
634 use crate::storage::kv::{Bucket as _, FileStore, Key, Store as _, StoreOutcome};
635
636 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
637 async fn concurrent_bucket_open_shares_ownership_registry() {
638 let t = tempfile::tempdir().unwrap();
639 let cancel_token = CancellationToken::new();
640 let store = FileStore::new(cancel_token.clone(), t.path());
641 let barrier = Arc::new(Barrier::new(32));
642
643 let mut tasks = Vec::new();
644 for _ in 0..32 {
645 let store = store.clone();
646 let barrier = barrier.clone();
647 tasks.push(tokio::spawn(async move {
648 barrier.wait().await;
649 store
650 .get_or_create_bucket("v1/concurrent", None)
651 .await
652 .unwrap()
653 }));
654 }
655
656 let mut directories = Vec::new();
657 for task in tasks {
658 directories.push(task.await.unwrap());
659 }
660 cancel_token.cancel();
661
662 let ownership = &directories[0].owned_files;
663 assert!(
664 directories
665 .iter()
666 .all(|directory| Arc::ptr_eq(ownership, &directory.owned_files))
667 );
668 }
669
670 #[test]
671 fn deleted_event_path_canonicalizes_existing_parent() {
672 let t = tempfile::tempdir().unwrap();
673 let canonical_root = t.path().join("canonical");
674 let bucket = canonical_root.join("v1/tests");
675 fs::create_dir_all(&bucket).unwrap();
676 let linked_root = t.path().join("linked");
677 symlink(&canonical_root, &linked_root).unwrap();
678
679 assert_eq!(
680 super::canonicalize_event_path(&linked_root.join("v1/tests/deleted")),
681 canonical_root
682 .canonicalize()
683 .unwrap()
684 .join("v1/tests/deleted")
685 );
686 }
687
688 #[tokio::test]
689 async fn external_delete_is_observed_under_noncanonical_root() {
690 let t = tempfile::tempdir().unwrap();
691 let canonical_root = t.path().join("canonical");
692 fs::create_dir_all(&canonical_root).unwrap();
693 let linked_root = t.path().join("linked");
694 symlink(&canonical_root, &linked_root).unwrap();
695 let watcher_cancel = CancellationToken::new();
696 let creator_cancel = CancellationToken::new();
697 let watcher_store = FileStore::new(watcher_cancel.clone(), &linked_root);
698 let creator_store = FileStore::new(creator_cancel.clone(), &canonical_root);
699 let watcher_bucket = watcher_store
700 .get_or_create_bucket("v1/tests", None)
701 .await
702 .unwrap();
703 let creator_bucket = creator_store
704 .get_or_create_bucket("v1/tests", None)
705 .await
706 .unwrap();
707 let mut events = watcher_bucket.watch().await.unwrap();
708 let key = Key::new("scope/item".to_string());
709
710 creator_bucket
711 .insert(&key, "value".into(), 0)
712 .await
713 .unwrap();
714 loop {
715 let event = tokio::time::timeout(Duration::from_secs(2), events.next())
716 .await
717 .expect("FileStore watcher did not observe value creation")
718 .expect("FileStore watcher ended after value creation");
719 if matches!(event, super::WatchEvent::Put(ref item) if item.key_str() == "v1/tests/scope/item")
720 {
721 break;
722 }
723 }
724
725 creator_bucket.delete(&key).await.unwrap();
726 let event = tokio::time::timeout(Duration::from_secs(2), events.next())
727 .await
728 .expect("FileStore watcher did not observe value deletion")
729 .expect("FileStore watcher ended after value deletion");
730 assert!(
731 matches!(event, super::WatchEvent::Delete(ref deleted) if deleted == &Key::new("v1/tests/scope/item".to_string()))
732 );
733
734 watcher_cancel.cancel();
735 creator_cancel.cancel();
736 }
737
738 #[tokio::test]
739 async fn test_entries_full_path() {
740 let t = tempfile::tempdir().unwrap();
741
742 let cancel_token = CancellationToken::new();
743 let m = FileStore::new(cancel_token.clone(), t.path());
744 let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
745 let _ = bucket
746 .insert(&Key::new("key1/multi/part".to_string()), "value1".into(), 0)
747 .await
748 .unwrap();
749 let _ = bucket
750 .insert(&Key::new("key2".to_string()), "value2".into(), 0)
751 .await
752 .unwrap();
753 let entries = bucket.entries().await.unwrap();
754 let keys: HashSet<Key> = entries.into_keys().collect();
755 cancel_token.cancel(); assert!(keys.contains(&Key::new("v1/tests/key1/multi/part".to_string())));
758 assert!(keys.contains(&Key::new("v1/tests/key2".to_string())));
759 }
760
761 #[test]
762 fn test_temp_file_creation_does_not_overwrite_existing_path() {
763 let t = tempfile::tempdir().unwrap();
764 let temp_path = t.path().join(".tmp_existing");
765
766 fs::write(&temp_path, b"sentinel").unwrap();
767 let created = super::write_temp_file_at(&temp_path, b"new").unwrap();
768
769 assert!(!created);
770 assert_eq!(fs::read(&temp_path).unwrap(), b"sentinel");
771 }
772
773 #[tokio::test]
774 async fn test_insert_revision_zero_is_create_if_absent() {
775 let t = tempfile::tempdir().unwrap();
776
777 let cancel_token = CancellationToken::new();
778 let m = FileStore::new(cancel_token.clone(), t.path());
779 let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
780 let key = Key::new("singleton".to_string());
781
782 let first = bucket.insert(&key, "winner".into(), 0).await.unwrap();
783 let second = bucket.insert(&key, "loser".into(), 0).await.unwrap();
784 let value = bucket.get(&key).await.unwrap().unwrap();
785 cancel_token.cancel();
786
787 assert_eq!(first, StoreOutcome::Created(0));
788 assert_eq!(second, StoreOutcome::Exists(0));
789 assert_eq!(value.as_ref(), b"winner");
790 }
791
792 #[tokio::test]
793 async fn test_insert_nonzero_revision_overwrites() {
794 let t = tempfile::tempdir().unwrap();
795
796 let cancel_token = CancellationToken::new();
797 let m = FileStore::new(cancel_token.clone(), t.path());
798 let bucket = m.get_or_create_bucket("v1/tests", None).await.unwrap();
799 let key = Key::new("existing".to_string());
800
801 bucket.insert(&key, "old".into(), 0).await.unwrap();
802 let outcome = bucket.insert(&key, "new".into(), 1).await.unwrap();
803 let value = bucket.get(&key).await.unwrap().unwrap();
804 cancel_token.cancel();
805
806 assert_eq!(outcome, StoreOutcome::Created(1));
807 assert_eq!(value.as_ref(), b"new");
808 }
809
810 #[tokio::test]
811 async fn test_concurrent_insert_revision_zero_has_one_winner() {
812 let t = tempfile::tempdir().unwrap();
813 let root = t.path().to_path_buf();
814 let key = Key::new("singleton".to_string());
815
816 let mut tasks = Vec::new();
817 for index in 0..16 {
818 let root = root.clone();
819 let key = key.clone();
820 tasks.push(tokio::spawn(async move {
821 let cancel_token = CancellationToken::new();
822 let store = FileStore::new(cancel_token.clone(), root);
823 let bucket = store.get_or_create_bucket("v1/claims", None).await.unwrap();
824 let value = format!("value-{index}");
825 let outcome = bucket.insert(&key, value.clone().into(), 0).await.unwrap();
826 let stored = bucket.get(&key).await.unwrap().unwrap();
827 cancel_token.cancel();
828 (outcome, String::from_utf8(stored.to_vec()).unwrap(), value)
829 }));
830 }
831
832 let mut created_values = Vec::new();
833 let mut observed_values = HashSet::new();
834 for task in tasks {
835 let (outcome, stored, attempted) = task.await.unwrap();
836 observed_values.insert(stored);
837 if outcome == StoreOutcome::Created(0) {
838 created_values.push(attempted);
839 } else {
840 assert_eq!(outcome, StoreOutcome::Exists(0));
841 }
842 }
843
844 assert_eq!(created_values.len(), 1);
845 assert_eq!(observed_values.len(), 1);
846 assert_eq!(
847 observed_values.into_iter().next().unwrap(),
848 created_values.pop().unwrap()
849 );
850 }
851}