1use crate::keyspace::{
11 normalize_key_prefix, scope_list_prefix, scope_object_key, unscope_listed_key,
12 validate_segments,
13};
14use crate::object_store::Result;
15use crate::{
16 ByteRange, ByteStream, ObjectBody, ObjectMetadata, ObjectStore, ObjectStoreError, PutMode,
17 StoredObjectChecksum,
18};
19use async_trait::async_trait;
20use bytes::Bytes;
21use futures::stream::{self, BoxStream, StreamExt};
22use loonfs_api::{sha256_digest, StorageChecksum};
23use std::io::SeekFrom;
24use std::path::{Component, Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26use tokio::fs::{self, File, OpenOptions};
27use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
28use tokio::sync::Mutex;
29
30const STORE_LOCK_FILE_NAME: &str = ".loonfs-store.lock";
34
35#[derive(Debug)]
41pub struct LocalFsStore {
42 root: PathBuf,
43 key_prefix: Option<String>,
45 write_lock: Mutex<()>,
46}
47
48impl LocalFsStore {
49 pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
55 Self::with_key_prefix(root, None)
56 }
57
58 pub fn with_key_prefix(root: impl Into<PathBuf>, key_prefix: Option<&str>) -> Result<Self> {
61 require_atomic_rename_replace()?;
62 let root = root.into();
63 std::fs::create_dir_all(&root).map_err(|err| {
64 ObjectStoreError::Configuration(format!(
65 "failed to create store root `{}`: {err}",
66 root.display()
67 ))
68 })?;
69 Ok(Self {
70 root,
71 key_prefix: normalize_key_prefix(key_prefix)?,
72 write_lock: Mutex::new(()),
73 })
74 }
75
76 async fn acquire_cross_process_write_lock(&self, key: &str) -> Result<std::fs::File> {
80 let lock_path = self.root.join(STORE_LOCK_FILE_NAME);
81 let lock_key = key.to_owned();
82 tokio::task::spawn_blocking(move || {
83 let file = std::fs::OpenOptions::new()
84 .read(true)
85 .write(true)
86 .create(true)
87 .truncate(false)
88 .open(&lock_path)
89 .map_err(|err| io_error(&lock_key, err))?;
90 fs4::fs_std::FileExt::lock_exclusive(&file).map_err(|err| io_error(&lock_key, err))?;
91 Ok(file)
92 })
93 .await
94 .map_err(|err| ObjectStoreError::transport(key, format!("store lock task failed: {err}")))?
95 }
96
97 pub fn root(&self) -> &Path {
99 &self.root
100 }
101
102 fn resolve_key(&self, key: &str) -> Result<PathBuf> {
103 let segments = validate_segments(key, false)?;
104 if segments.iter().any(|segment| is_scratch_name(segment)) {
108 return Err(ObjectStoreError::InvalidKey {
109 object_key: key.to_owned(),
110 message: "key uses a segment name reserved for store scratch files".to_owned(),
111 });
112 }
113 let mut path = self.root.clone();
114 for segment in segments {
115 path.push(segment);
116 }
117 Ok(path)
118 }
119
120 async fn metadata_for_path(key: &str, path: &Path) -> Result<Option<ObjectMetadata>> {
121 let metadata = match fs::metadata(path).await {
122 Ok(metadata) => metadata,
123 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
124 Err(err) => return Err(io_error(key, err)),
125 };
126 let Some(content_bytes) = Self::read_for_digest(key, path).await? else {
127 return Ok(None);
128 };
129 let content_digest = sha256_digest(&content_bytes);
130 Self::metadata_from_fs_metadata(key, &metadata, &content_digest, path).map(Some)
131 }
132
133 async fn read_for_digest(key: &str, path: &Path) -> Result<Option<Vec<u8>>> {
139 match fs::read(path).await {
140 Ok(bytes) => Ok(Some(bytes)),
141 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
142 Err(err) => Err(io_error(key, err)),
143 }
144 }
145
146 fn metadata_from_fs_metadata(
147 key: &str,
148 metadata: &std::fs::Metadata,
149 content_digest: &str,
150 path: &Path,
151 ) -> Result<ObjectMetadata> {
152 if !metadata.is_file() {
153 return Err(ObjectStoreError::transport(
154 key,
155 format!("object path is not a file: {}", path.display()),
156 ));
157 }
158
159 let last_modified_ms = metadata
160 .modified()
161 .ok()
162 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
163 .and_then(|elapsed| u64::try_from(elapsed.as_millis()).ok());
164 Ok(ObjectMetadata {
165 etag: Some(format!("local-fs-v1:{content_digest}")),
166 version: None,
167 size_bytes: metadata.len(),
168 last_modified_ms,
169 })
170 }
171
172 async fn create_new_object(key: &str, root: &Path, path: &Path, bytes: &[u8]) -> Result<()> {
173 let created_dirs = ensure_parent_dir(key, path).await?;
179 let temp_path = temp_path(path);
180 let mut file = OpenOptions::new()
181 .write(true)
182 .create_new(true)
183 .open(&temp_path)
184 .await
185 .map_err(|err| io_error(key, err))?;
186
187 let staged: Result<()> = async {
188 file.write_all(bytes)
189 .await
190 .map_err(|err| io_error(key, err))?;
191 file.sync_all().await.map_err(|err| io_error(key, err))
192 }
193 .await;
194 if staged.is_err() {
195 let _ = fs::remove_file(&temp_path).await;
196 return staged;
197 }
198
199 let linked = fs::hard_link(&temp_path, path)
200 .await
201 .map_err(|err| map_create_error(key, err));
202 let _ = fs::remove_file(&temp_path).await;
203 linked?;
204
205 if created_dirs {
206 sync_dir_chain(key, path, root).await?;
207 }
208 sync_parent_dir(key, path).await
209 }
210
211 async fn put_streamed_object(
219 &self,
220 key: &str,
221 mut body: ByteStream,
222 mode: PutMode,
223 ) -> Result<u64> {
224 let path = self.resolve_key(key)?;
225 let created_dirs = ensure_parent_dir(key, &path).await?;
226 let temp_path = temp_path(&path);
227 let mut file = OpenOptions::new()
228 .write(true)
229 .create_new(true)
230 .open(&temp_path)
231 .await
232 .map_err(|err| io_error(key, err))?;
233
234 let staged: Result<u64> = async {
235 let mut size_bytes = 0u64;
236 while let Some(chunk) = body.next().await {
237 let chunk = chunk?;
238 file.write_all(&chunk)
239 .await
240 .map_err(|err| io_error(key, err))?;
241 size_bytes += chunk.len() as u64;
242 }
243 file.sync_all().await.map_err(|err| io_error(key, err))?;
244 Ok(size_bytes)
245 }
246 .await;
247 let size_bytes = match staged {
248 Ok(size_bytes) => size_bytes,
249 Err(err) => {
250 let _ = fs::remove_file(&temp_path).await;
251 return Err(err);
252 }
253 };
254
255 let published = self
256 .publish_staged_object(key, &path, &temp_path, mode)
257 .await;
258 let _ = fs::remove_file(&temp_path).await;
259 published?;
260
261 if created_dirs {
262 sync_dir_chain(key, &path, &self.root).await?;
263 }
264 sync_parent_dir(key, &path).await?;
265 Ok(size_bytes)
266 }
267
268 async fn publish_staged_object(
271 &self,
272 key: &str,
273 path: &Path,
274 temp_path: &Path,
275 mode: PutMode,
276 ) -> Result<()> {
277 let _guard = self.write_lock.lock().await;
278 let _cross_process_guard = self.acquire_cross_process_write_lock(key).await?;
279 let precondition_failed = || ObjectStoreError::PreconditionFailed {
280 object_key: key.to_owned(),
281 };
282 match mode {
283 PutMode::Overwrite => fs::rename(temp_path, path)
284 .await
285 .map_err(|err| io_error(key, err)),
286 PutMode::CreateIfAbsent => fs::hard_link(temp_path, path)
289 .await
290 .map_err(|err| map_create_error(key, err)),
291 PutMode::CompareAndSwap { expected_etag } => {
292 let current = Self::metadata_for_path(key, path)
293 .await?
294 .ok_or_else(precondition_failed)?;
295 if current.etag.as_deref() != Some(expected_etag.as_str()) {
296 return Err(precondition_failed());
297 }
298 fs::rename(temp_path, path)
299 .await
300 .map_err(|err| io_error(key, err))
301 }
302 }
303 }
304
305 async fn replace_object(key: &str, root: &Path, path: &Path, bytes: &[u8]) -> Result<()> {
306 let created_dirs = ensure_parent_dir(key, path).await?;
307 let temp_path = temp_path(path);
308
309 let result: Result<()> = async {
310 let mut file = OpenOptions::new()
311 .write(true)
312 .create_new(true)
313 .open(&temp_path)
314 .await
315 .map_err(|err| io_error(key, err))?;
316 file.write_all(bytes)
317 .await
318 .map_err(|err| io_error(key, err))?;
319 file.sync_all().await.map_err(|err| io_error(key, err))?;
320
321 fs::rename(&temp_path, path)
322 .await
323 .map_err(|err| io_error(key, err))
324 }
325 .await;
326
327 if result.is_err() {
328 let _ = fs::remove_file(&temp_path).await;
329 return result;
330 }
331
332 if created_dirs {
333 sync_dir_chain(key, path, root).await?;
334 }
335 sync_parent_dir(key, path).await
336 }
337}
338
339impl LocalFsStore {
340 async fn head_object(&self, key: &str) -> Result<Option<ObjectMetadata>> {
341 let path = self.resolve_key(key)?;
342 Self::metadata_for_path(key, &path).await
343 }
344
345 async fn get_with_metadata_object(&self, key: &str) -> Result<Option<ObjectBody>> {
346 let path = self.resolve_key(key)?;
347 let mut file = match File::open(&path).await {
348 Ok(file) => file,
349 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
350 Err(err) => return Err(io_error(key, err)),
351 };
352 let fs_metadata = file.metadata().await.map_err(|err| io_error(key, err))?;
353
354 let mut bytes = Vec::new();
355 file.read_to_end(&mut bytes)
356 .await
357 .map_err(|err| io_error(key, err))?;
358
359 let content_digest = sha256_digest(&bytes);
360 let metadata = Self::metadata_from_fs_metadata(key, &fs_metadata, &content_digest, &path)?;
361 Ok(Some(ObjectBody { metadata, bytes }))
362 }
363
364 async fn get_object(&self, key: &str, range: Option<ByteRange>) -> Result<Option<Vec<u8>>> {
372 let path = self.resolve_key(key)?;
373 let mut file = match File::open(&path).await {
374 Ok(file) => file,
375 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
376 Err(err) => return Err(io_error(key, err)),
377 };
378
379 let Some(range) = range else {
380 let mut bytes = Vec::new();
381 file.read_to_end(&mut bytes)
382 .await
383 .map_err(|err| io_error(key, err))?;
384 return Ok(Some(bytes));
385 };
386
387 let invalid_range = || ObjectStoreError::InvalidRange {
388 object_key: key.to_owned(),
389 };
390 let size_bytes = file
391 .metadata()
392 .await
393 .map_err(|err| io_error(key, err))?
394 .len();
395 if range.end_exclusive < range.start_inclusive || range.start_inclusive > size_bytes {
396 return Err(invalid_range());
397 }
398 let end_exclusive = range.end_exclusive.min(size_bytes);
400 file.seek(SeekFrom::Start(range.start_inclusive))
401 .await
402 .map_err(|err| io_error(key, err))?;
403 let mut bytes = Vec::new();
404 file.take(end_exclusive - range.start_inclusive)
405 .read_to_end(&mut bytes)
406 .await
407 .map_err(|err| io_error(key, err))?;
408 Ok(Some(bytes))
409 }
410
411 async fn put_object(&self, key: &str, bytes: &[u8], mode: PutMode) -> Result<ObjectMetadata> {
412 let path = self.resolve_key(key)?;
413 let _guard = self.write_lock.lock().await;
414 let _cross_process_guard = self.acquire_cross_process_write_lock(key).await?;
415 let precondition_failed = || ObjectStoreError::PreconditionFailed {
416 object_key: key.to_owned(),
417 };
418
419 match mode {
420 PutMode::Overwrite => Self::replace_object(key, &self.root, &path, bytes).await?,
421 PutMode::CreateIfAbsent => {
422 if fs::try_exists(&path)
423 .await
424 .map_err(|err| io_error(key, err))?
425 {
426 return Err(precondition_failed());
427 }
428 Self::create_new_object(key, &self.root, &path, bytes).await?;
429 }
430 PutMode::CompareAndSwap { expected_etag } => {
431 let current = Self::metadata_for_path(key, &path)
432 .await?
433 .ok_or_else(precondition_failed)?;
434 if current.etag.as_deref() != Some(expected_etag.as_str()) {
435 return Err(precondition_failed());
436 }
437 Self::replace_object(key, &self.root, &path, bytes).await?;
438 }
439 }
440
441 Self::metadata_for_path(key, &path)
442 .await?
443 .ok_or_else(|| ObjectStoreError::transport(key, "object disappeared after write"))
444 }
445
446 async fn delete_object(&self, key: &str) -> Result<()> {
447 let path = self.resolve_key(key)?;
448 let _guard = self.write_lock.lock().await;
449 let _cross_process_guard = self.acquire_cross_process_write_lock(key).await?;
450
451 match fs::remove_file(&path).await {
452 Ok(()) => {
453 sync_parent_dir(key, &path).await?;
454 prune_empty_parent_dirs(key, path.parent(), &self.root).await?;
455 Ok(())
456 }
457 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
458 Err(err) => Err(io_error(key, err)),
459 }
460 }
461}
462
463impl LocalFsStore {
464 fn scoped(&self, key: &str) -> Result<String> {
466 scope_object_key(self.key_prefix.as_deref(), key)
467 }
468}
469
470#[async_trait]
471impl ObjectStore for LocalFsStore {
472 async fn head(&self, key: &str) -> Result<Option<ObjectMetadata>> {
473 self.head_object(&self.scoped(key)?).await
474 }
475
476 async fn head_stored_checksum(&self, key: &str) -> Result<Option<StoredObjectChecksum>> {
481 let scoped = self.scoped(key)?;
482 let Some(bytes) = self.get_object(&scoped, None).await? else {
483 return Ok(None);
484 };
485 Ok(Some(StoredObjectChecksum {
486 size_bytes: bytes.len() as u64,
487 storage_checksum: StorageChecksum::sha256(&bytes),
488 }))
489 }
490
491 async fn get_with_metadata(&self, key: &str) -> Result<Option<ObjectBody>> {
492 self.get_with_metadata_object(&self.scoped(key)?).await
493 }
494
495 async fn get(&self, key: &str, range: Option<ByteRange>) -> Result<Option<Bytes>> {
496 self.get_object(&self.scoped(key)?, range)
497 .await
498 .map(|maybe| maybe.map(Bytes::from))
499 }
500
501 async fn put(&self, key: &str, bytes: Bytes, mode: PutMode) -> Result<ObjectMetadata> {
502 self.put_object(&self.scoped(key)?, &bytes, mode).await
503 }
504
505 async fn put_streamed(&self, key: &str, body: ByteStream, mode: PutMode) -> Result<u64> {
506 self.put_streamed_object(&self.scoped(key)?, body, mode)
507 .await
508 }
509
510 async fn delete(&self, key: &str) -> Result<()> {
511 self.delete_object(&self.scoped(key)?).await
512 }
513
514 fn list_prefix_stream(&self, prefix: &str) -> BoxStream<'static, Result<String>> {
515 let scoped = match scope_list_prefix(self.key_prefix.as_deref(), prefix) {
516 Ok(scoped) => scoped,
517 Err(err) => return stream::once(async { Err(err) }).boxed(),
518 };
519 let root = self.root.clone();
520 let key_prefix = self.key_prefix.clone();
521 Box::pin(
522 stream::once(async move { list_prefix_for_root(root, scoped).await })
523 .flat_map(|result| match result {
524 Ok(keys) => stream::iter(keys.into_iter().map(Ok)).boxed(),
525 Err(err) => stream::once(async { Err(err) }).boxed(),
526 })
527 .filter_map(move |result| {
528 let key_prefix = key_prefix.clone();
529 async move {
530 match result {
531 Ok(key) => match key_prefix.as_deref() {
532 Some(prefix) => unscope_listed_key(Some(prefix), &key).map(Ok),
533 None => Some(Ok(key)),
534 },
535 Err(err) => Some(Err(err)),
536 }
537 }
538 }),
539 )
540 }
541}
542
543#[cfg(unix)]
544fn require_atomic_rename_replace() -> Result<()> {
545 Ok(())
546}
547
548#[cfg(not(unix))]
549fn require_atomic_rename_replace() -> Result<()> {
550 Err(ObjectStoreError::Configuration(
551 "local filesystem provider requires atomic rename-replace and is supported only on \
552 Unix-family platforms"
553 .to_owned(),
554 ))
555}
556
557async fn list_prefix_for_root(root: PathBuf, prefix: String) -> Result<Vec<String>> {
558 validate_segments(&prefix, true)?;
559
560 if !fs::try_exists(&root)
561 .await
562 .map_err(|err| io_error(&prefix, err))?
563 {
564 return Ok(Vec::new());
565 }
566
567 let mut keys = collect_keys(&prefix, root).await?;
568 keys.retain(|key| key.starts_with(&prefix));
569 keys.sort();
570 Ok(keys)
571}
572
573async fn ensure_parent_dir(key: &str, path: &Path) -> Result<bool> {
576 match path.parent() {
577 Some(parent) => {
578 if fs::try_exists(parent)
579 .await
580 .map_err(|err| io_error(key, err))?
581 {
582 return Ok(false);
583 }
584 fs::create_dir_all(parent)
585 .await
586 .map_err(|err| io_error(key, err))?;
587 Ok(true)
588 }
589 None => Err(ObjectStoreError::InvalidKey {
590 object_key: key.to_owned(),
591 message: format!("object path `{}` has no parent directory", path.display()),
592 }),
593 }
594}
595
596async fn sync_parent_dir(key: &str, path: &Path) -> Result<()> {
600 #[cfg(unix)]
601 {
602 if let Some(parent) = path.parent() {
603 let dir = File::open(parent).await.map_err(|err| io_error(key, err))?;
604 dir.sync_all().await.map_err(|err| io_error(key, err))?;
605 }
606 }
607 #[cfg(not(unix))]
608 {
609 let _ = (key, path);
610 }
611 Ok(())
612}
613
614async fn sync_dir_chain(key: &str, path: &Path, root: &Path) -> Result<()> {
618 #[cfg(unix)]
619 {
620 let mut current = path.parent();
621 while let Some(dir) = current {
622 let handle = File::open(dir).await.map_err(|err| io_error(key, err))?;
623 handle.sync_all().await.map_err(|err| io_error(key, err))?;
624 if dir == root {
625 break;
626 }
627 current = dir.parent();
628 }
629 }
630 #[cfg(not(unix))]
631 {
632 let _ = (key, path, root);
633 }
634 Ok(())
635}
636
637async fn collect_keys(prefix: &str, root: PathBuf) -> Result<Vec<String>> {
638 let mut keys = Vec::new();
639 let mut dirs = vec![root.clone()];
640
641 while let Some(current) = dirs.pop() {
642 let mut reader = match fs::read_dir(¤t).await {
648 Ok(reader) => reader,
649 Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
650 Err(err) => return Err(io_error(prefix, err)),
651 };
652 let mut entries = Vec::new();
653 loop {
654 match reader.next_entry().await {
655 Ok(Some(entry)) => entries.push(entry.path()),
656 Ok(None) => break,
657 Err(err) if err.kind() == std::io::ErrorKind::NotFound => break,
658 Err(err) => return Err(io_error(prefix, err)),
659 }
660 }
661 entries.sort();
662
663 for path in entries.into_iter().rev() {
664 if path
668 .file_name()
669 .and_then(|name| name.to_str())
670 .is_some_and(is_scratch_name)
671 {
672 continue;
673 }
674 let metadata = match fs::metadata(&path).await {
675 Ok(metadata) => metadata,
676 Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
677 Err(err) => return Err(io_error(prefix, err)),
678 };
679 if metadata.is_dir() {
680 dirs.push(path);
681 continue;
682 }
683
684 if metadata.is_file() {
685 keys.push(relative_key(prefix, &root, &path)?);
686 }
687 }
688 }
689
690 keys.sort();
691 Ok(keys)
692}
693
694fn relative_key(prefix: &str, root: &Path, path: &Path) -> Result<String> {
695 let relative = path.strip_prefix(root).map_err(|err| {
696 ObjectStoreError::transport(
697 prefix,
698 format!(
699 "failed to strip object-store root from path {}: {err}",
700 path.display()
701 ),
702 )
703 })?;
704
705 let mut parts = Vec::new();
706 for component in relative.components() {
707 match component {
708 Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
709 other => {
710 return Err(ObjectStoreError::transport(
711 prefix,
712 format!(
713 "unsupported relative path component {other:?} under local object store"
714 ),
715 ))
716 }
717 }
718 }
719
720 Ok(parts.join("/"))
721}
722
723async fn prune_empty_parent_dirs(key: &str, mut current: Option<&Path>, root: &Path) -> Result<()> {
724 while let Some(dir) = current {
725 if dir == root {
726 break;
727 }
728
729 match fs::remove_dir(dir).await {
730 Ok(()) => current = dir.parent(),
731 Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
732 Err(err) if err.kind() == std::io::ErrorKind::NotFound => break,
733 Err(err) => return Err(io_error(key, err)),
734 }
735 }
736
737 Ok(())
738}
739
740fn is_scratch_name(name: &str) -> bool {
746 name == STORE_LOCK_FILE_NAME || (name.starts_with('.') && name.contains(".tmp-"))
747}
748
749#[allow(clippy::disallowed_methods)]
750fn temp_path(path: &Path) -> PathBuf {
751 let file_name = path
753 .file_name()
754 .and_then(|name| name.to_str())
755 .unwrap_or("object");
756 let stamp = SystemTime::now()
757 .duration_since(UNIX_EPOCH)
758 .unwrap_or_default()
759 .as_nanos();
760
761 path.with_file_name(format!(".{file_name}.tmp-{}-{stamp}", std::process::id()))
762}
763
764fn map_create_error(key: &str, err: std::io::Error) -> ObjectStoreError {
765 if err.kind() == std::io::ErrorKind::AlreadyExists {
766 ObjectStoreError::PreconditionFailed {
767 object_key: key.to_owned(),
768 }
769 } else {
770 io_error(key, err)
771 }
772}
773
774fn io_error(key: &str, err: std::io::Error) -> ObjectStoreError {
775 if err.kind() == std::io::ErrorKind::PermissionDenied {
776 return ObjectStoreError::PermissionDenied {
777 object_key: key.to_owned(),
778 message: err.to_string(),
779 };
780 }
781 ObjectStoreError::transport(key, err.to_string())
782}
783
784#[cfg(test)]
785mod tests {
786 #![allow(clippy::panic)]
787 use super::LocalFsStore;
790 use super::{ByteRange, ObjectStore, ObjectStoreError, PutMode};
791 use crate::keys::{upload_session, wal_head};
792 use bytes::Bytes;
793 use std::fs;
794 use std::path::{Path, PathBuf};
795 use std::sync::Arc;
796 use std::time::{SystemTime, UNIX_EPOCH};
797 use tokio::sync::Barrier;
798
799 #[test]
800 fn construction_requires_atomic_rename_replace_support() {
801 let temp_dir = TestDir::new("construction-gate");
802 let result = LocalFsStore::new(temp_dir.path());
803
804 #[cfg(unix)]
805 assert!(result.is_ok());
806
807 #[cfg(not(unix))]
808 assert!(matches!(
809 result,
810 Err(ObjectStoreError::Configuration(message))
811 if message.contains("requires atomic rename-replace")
812 && message.contains("Unix-family")
813 ));
814 }
815
816 #[cfg(unix)]
817 #[tokio::test]
818 async fn listing_tolerates_entries_that_vanish_mid_walk() {
819 let temp_dir = TestDir::new("listing-races");
820 let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
821 let key = wal_head("ns-1");
822 store
823 .put(&key, Bytes::from_static(b"{}"), PutMode::Overwrite)
824 .await
825 .expect("seed object");
826
827 let dir = temp_dir.path().join("namespaces/ns-1/wal");
833 std::os::unix::fs::symlink(dir.join("missing"), dir.join(".head.json.tmp-1-2"))
834 .expect("dangling scratch entry");
835 std::os::unix::fs::symlink(dir.join("missing"), dir.join("vanished.json"))
836 .expect("dangling plain entry");
837
838 let keys = store
839 .list_prefix("namespaces/ns-1/")
840 .await
841 .expect("listing succeeds despite dangling entries");
842 assert_eq!(keys, vec![key]);
843 }
844
845 #[tokio::test]
846 async fn a_head_of_a_missing_object_answers_gone() {
847 let temp_dir = TestDir::new("head-missing");
848 let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
849
850 let answer = store.head(&wal_head("ns-1")).await.expect("head succeeds");
851 assert!(answer.is_none());
852 }
853
854 #[tokio::test]
860 async fn a_digest_read_of_a_vanished_object_answers_gone() {
861 let temp_dir = TestDir::new("digest-vanished");
862 let vanished = temp_dir.path().join("vanished.json");
863
864 let answer = LocalFsStore::read_for_digest("namespaces/ns-1/wal/head.json", &vanished)
865 .await
866 .expect("a vanished object is an answer, not an error");
867 assert!(answer.is_none());
868 }
869
870 #[tokio::test]
871 async fn a_digest_read_that_fails_for_another_reason_stays_an_error() {
872 let temp_dir = TestDir::new("digest-error");
873 let directory = temp_dir.path().join("dir");
874 fs::create_dir(&directory).expect("create directory");
875
876 let error = LocalFsStore::read_for_digest("namespaces/ns-1/wal/head.json", &directory)
877 .await
878 .expect_err("reading a directory is not a missing object");
879 assert!(matches!(error, ObjectStoreError::Transport { .. }));
880 }
881
882 #[tokio::test]
883 async fn overwrite_refreshes_head_and_visible_bytes() {
884 let temp_dir = TestDir::new("overwrite");
885 let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
886 let key = wal_head("ns-1");
887
888 let first = store
889 .put(
890 &key,
891 Bytes::from_static(br#"{"seq":1}"#),
892 PutMode::Overwrite,
893 )
894 .await
895 .expect("seed first object");
896 let second = store
897 .put(
898 &key,
899 Bytes::from_static(br#"{"seq":2}"#),
900 PutMode::Overwrite,
901 )
902 .await
903 .expect("overwrite object");
904
905 assert_eq!(
906 store.get(&key, None).await.expect("get object"),
907 Some(Bytes::from_static(br#"{"seq":2}"#))
908 );
909 let head = store
910 .head(&key)
911 .await
912 .expect("head object")
913 .expect("head exists");
914 assert_eq!(head.etag, second.etag);
915 assert_eq!(head.size_bytes, second.size_bytes);
916 assert_ne!(first, second);
917 }
918
919 #[tokio::test]
923 async fn ranged_reads_answer_their_range_and_refuse_impossible_ones() {
924 let temp_dir = TestDir::new("ranged-reads");
925 let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
926 let key = wal_head("ns-1");
927 let payload = Bytes::from_static(b"0123456789");
928 store
929 .put(&key, payload.clone(), PutMode::Overwrite)
930 .await
931 .expect("seed object");
932
933 let range = |start_inclusive, end_exclusive| {
934 Some(ByteRange {
935 start_inclusive,
936 end_exclusive,
937 })
938 };
939 assert_eq!(
940 store.get(&key, range(0, 4)).await.expect("leading range"),
941 Some(Bytes::from_static(b"0123"))
942 );
943 assert_eq!(
944 store.get(&key, range(4, 7)).await.expect("middle range"),
945 Some(Bytes::from_static(b"456"))
946 );
947 assert_eq!(
948 store
949 .get(&key, range(7, 99))
950 .await
951 .expect("range past the end is truncated"),
952 Some(Bytes::from_static(b"789"))
953 );
954 assert_eq!(
955 store
956 .get(&key, range(10, 10))
957 .await
958 .expect("a range at the end is empty, not missing"),
959 Some(Bytes::new())
960 );
961 assert!(matches!(
962 store.get(&key, range(11, 12)).await,
963 Err(ObjectStoreError::InvalidRange { .. })
964 ));
965 assert!(matches!(
966 store.get(&key, range(6, 2)).await,
967 Err(ObjectStoreError::InvalidRange { .. })
968 ));
969 assert!(store
970 .get(&wal_head("ns-missing"), range(0, 4))
971 .await
972 .expect("a ranged read of a missing object is absent, not an error")
973 .is_none());
974 }
975
976 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
978 async fn concurrent_readers_observe_complete_replacement_generations() {
979 const PAYLOAD_BYTES: usize = 16 * 1024;
980 const READER_COUNT: usize = 4;
981 const REPLACEMENT_COUNT: u8 = 32;
982
983 let temp_dir = TestDir::new("atomic-replacement");
984 let store = Arc::new(LocalFsStore::new(temp_dir.path()).expect("create local fs store"));
985 let key = wal_head("ns-atomic-replacement");
986 store
987 .put(
988 &key,
989 Bytes::from(vec![0; PAYLOAD_BYTES]),
990 PutMode::Overwrite,
991 )
992 .await
993 .expect("seed replacement object");
994
995 let round_barrier = Arc::new(Barrier::new(READER_COUNT + 1));
996 let mut readers = Vec::new();
997 for _ in 0..READER_COUNT {
998 let reader = Arc::clone(&store);
999 let reader_key = key.clone();
1000 let reader_barrier = Arc::clone(&round_barrier);
1001 readers.push(tokio::spawn(async move {
1002 for _ in 0..REPLACEMENT_COUNT {
1003 reader_barrier.wait().await;
1004 let bytes = reader
1005 .get(&reader_key, None)
1006 .await
1007 .expect("read during replacement")
1008 .expect("replacement key remains present");
1009 assert_eq!(bytes.len(), PAYLOAD_BYTES);
1010 let generation = bytes[0];
1011 assert!(generation <= REPLACEMENT_COUNT);
1012 assert!(bytes.iter().all(|byte| *byte == generation));
1013 reader_barrier.wait().await;
1014 }
1015 }));
1016 }
1017
1018 for generation in 1..=REPLACEMENT_COUNT {
1019 round_barrier.wait().await;
1020 store
1021 .put(
1022 &key,
1023 Bytes::from(vec![generation; PAYLOAD_BYTES]),
1024 PutMode::Overwrite,
1025 )
1026 .await
1027 .expect("replace object");
1028 round_barrier.wait().await;
1029 }
1030
1031 for reader in readers {
1032 reader.await.expect("reader task");
1033 }
1034 }
1035
1036 #[tokio::test]
1037 async fn delete_is_idempotent_and_head_reflects_removal() {
1038 let temp_dir = TestDir::new("delete");
1039 let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
1040 let key = upload_session("ns-1", "upl_00000000000000000000000000000001");
1041
1042 store
1043 .put_if_absent(&key, Bytes::from_static(br#"{"created":true}"#))
1044 .await
1045 .expect("seed upload object");
1046 assert!(store
1047 .head(&key)
1048 .await
1049 .expect("head before delete")
1050 .is_some());
1051 store.delete(&key).await.expect("delete existing object");
1052 store.delete(&key).await.expect("delete missing object");
1053 assert_eq!(store.head(&key).await.expect("head after delete"), None);
1054 }
1055
1056 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1057 async fn compare_and_swap_is_safe_across_store_instances() {
1058 let temp_dir = TestDir::new("cross-instance-cas");
1059 let key = wal_head("ns-cas");
1060 let seed = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
1061 seed.put(&key, Bytes::from_static(b"0"), PutMode::CreateIfAbsent)
1062 .await
1063 .expect("seed counter");
1064
1065 let mut writers = Vec::new();
1066 for _ in 0..2 {
1067 let root = temp_dir.path().to_path_buf();
1068 let key = key.clone();
1069 writers.push(tokio::spawn(async move {
1070 let store = LocalFsStore::new(&root).expect("create local fs store");
1073 for _ in 0..20 {
1074 loop {
1075 let body = store
1076 .get_with_metadata(&key)
1077 .await
1078 .expect("read counter")
1079 .expect("counter exists");
1080 let value: u64 = std::str::from_utf8(&body.bytes)
1081 .expect("utf8 counter")
1082 .parse()
1083 .expect("numeric counter");
1084 let expected_etag = body.metadata.etag.expect("counter etag");
1085 let put = store
1086 .put(
1087 &key,
1088 Bytes::from((value + 1).to_string()),
1089 PutMode::CompareAndSwap { expected_etag },
1090 )
1091 .await;
1092 match put {
1093 Ok(_) => break,
1094 Err(ObjectStoreError::PreconditionFailed { .. }) => continue,
1095 Err(err) => panic!("unexpected CAS error: {err}"),
1096 }
1097 }
1098 }
1099 }));
1100 }
1101 for writer in writers {
1102 writer.await.expect("writer task");
1103 }
1104
1105 let bytes = seed
1106 .get(&key, None)
1107 .await
1108 .expect("read final counter")
1109 .expect("counter exists");
1110 assert_eq!(std::str::from_utf8(&bytes).expect("utf8"), "40");
1111 }
1112
1113 #[tokio::test]
1114 async fn listings_hide_scratch_files_and_reject_scratch_keys() {
1115 let temp_dir = TestDir::new("scratch");
1116 let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
1117 let key = wal_head("ns-scratch");
1118 store
1119 .put(&key, Bytes::from_static(b"{}"), PutMode::Overwrite)
1120 .await
1121 .expect("put object");
1122
1123 assert!(temp_dir.path().join(super::STORE_LOCK_FILE_NAME).exists());
1126 let wal_dir = temp_dir.path().join("namespaces/ns-scratch/wal");
1127 fs::write(wal_dir.join(".head.json.tmp-123-456"), b"partial")
1128 .expect("write fake temp file");
1129
1130 let keys = store.list_prefix("").await.expect("list all");
1131 assert_eq!(keys, vec![key]);
1132
1133 let reserved = store.get(super::STORE_LOCK_FILE_NAME, None).await;
1134 assert!(matches!(
1135 reserved,
1136 Err(ObjectStoreError::InvalidKey { object_key, .. })
1137 if object_key == super::STORE_LOCK_FILE_NAME
1138 ));
1139 let temp_shaped = store
1140 .put(
1141 "namespaces/ns-scratch/control/.head.json.tmp-9-9",
1142 Bytes::from_static(b"x"),
1143 PutMode::Overwrite,
1144 )
1145 .await;
1146 assert!(matches!(
1147 temp_shaped,
1148 Err(ObjectStoreError::InvalidKey { .. })
1149 ));
1150 }
1151
1152 struct TestDir {
1153 path: PathBuf,
1154 }
1155
1156 impl TestDir {
1157 #[allow(clippy::disallowed_methods)]
1158 fn new(label: &str) -> Self {
1159 let stamp = SystemTime::now()
1161 .duration_since(UNIX_EPOCH)
1162 .unwrap_or_default()
1163 .as_nanos();
1164 let path = std::env::temp_dir().join(format!(
1165 "loonfs-local-fs-{label}-{}-{stamp}",
1166 std::process::id()
1167 ));
1168 fs::create_dir_all(&path).expect("create temp dir");
1169 Self { path }
1170 }
1171
1172 fn path(&self) -> &Path {
1173 &self.path
1174 }
1175 }
1176
1177 impl Drop for TestDir {
1178 fn drop(&mut self) {
1179 let _ = fs::remove_dir_all(&self.path);
1180 }
1181 }
1182}