1/*
2 * SPDX-FileCopyrightText: 2026 Copyright (c) Contributors to the Eclipse Foundation
3 *
4 * See the NOTICE file(s) distributed with this work for additional
5 * information regarding copyright ownership.
6 *
7 * This program and the accompanying materials are made available under the
8 * terms of the Apache License Version 2.0 which is available at
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * SPDX-License-Identifier: Apache-2.0
12 */
13
14//! Local filesystem implementation of the [`Storage`] trait.
15
16use std::{
17 path::{Path, PathBuf},
18 sync::{
19 Arc,
20 atomic::{AtomicBool, Ordering},
21 },
22};
23
24use async_trait::async_trait;
25use cda_interfaces::storage_api::{
26 CollectionName, Operation, Storage, StorageError, Transaction, TransactionCommitter,
27};
28
29use crate::{
30 local_collection::LocalCollection,
31 paths,
32 recovery::{self, BACKUP_EXTENSION},
33 wal,
34};
35
36/// Local filesystem implementation of the [`Storage`] trait.
37///
38/// Collections are stored as subdirectories under `{root}/collections/`. The WAL and staging
39/// files live under `{root}/journal/`.
[docs] 40/// [[ dimpl~storage-local-filesystem-implementation, Local filesystem implementation of the Storage Access API, dimpl ]]
41pub struct LocalStorage {
42 /// The storage directory. Must exist; everything below it is created on demand.
43 root: PathBuf,
44 /// Base directory for collection data.
45 collections_dir: PathBuf,
46 /// Directory containing the WAL file and staging subdirectory.
47 journal_dir: PathBuf,
48 /// Read-write lock: reads take shared, commit takes exclusive.
49 data_lock: Arc<tokio::sync::RwLock<()>>,
50 /// Flag to enforce single-transaction-at-a-time.
51 tx_active: Arc<AtomicBool>,
52}
53
54impl LocalStorage {
55 /// Opens the storage rooted at the given directory.
56 ///
57 /// Recovers a transaction that a previous run was interrupted in, if there
58 /// is one. Otherwise never writes: the root itself is never created, and
59 /// the layout below it is created by the first write, which a read-only
60 /// storage fails with [`StorageError::ReadOnly`].
61 ///
62 /// # Errors
63 ///
64 /// Returns [`StorageError::Io`] with [`std::io::ErrorKind::NotFound`] if
65 /// the root does not exist (yet), e.g. because the storage is not mounted.
66 /// Returns a [`StorageError`] if recovery is pending but fails; the on-disk
67 /// state is then indeterminate and must not be used.
68 pub fn new(root: impl Into<PathBuf>) -> Result<Self, StorageError> {
69 let root = root.into();
70 let storage = Self {
71 collections_dir: root.join("collections"),
72 journal_dir: root.join("journal"),
73 root,
74 data_lock: Arc::new(tokio::sync::RwLock::new(())),
75 tx_active: Arc::new(AtomicBool::new(false)),
76 };
77 storage.require_root()?;
78
79 // Checked first, so a clean storage is opened without writing.
80 if recovery::has_pending_work(&storage.journal_dir, &storage.collections_dir) {
81 tracing::info!("Interrupted transaction found in storage; recovering");
82 recovery::recover(&storage.journal_dir, &storage.collections_dir)?;
83 }
84
85 tracing::info!(root = %storage.root.display(), "Local storage initialized");
86 Ok(storage)
87 }
88
89 /// Fails unless the root exists, so writes never create it, e.g. in the
90 /// mount point of storage that is not mounted.
91 fn require_root(&self) -> Result<(), StorageError> {
92 if self.root.is_dir() {
93 return Ok(());
94 }
95 Err(std::io::Error::new(
96 std::io::ErrorKind::NotFound,
97 format!("storage directory {} does not exist", self.root.display()),
98 )
99 .into())
100 }
101
102 /// Return the path to a collection's directory.
103 ///
104 /// # Errors
105 ///
106 /// Returns a [`StorageError`] if the collection name fails path sanitization (when the
107 /// `sanitize-paths` feature is enabled).
108 fn collection_dir(&self, name: &CollectionName) -> Result<PathBuf, StorageError> {
109 paths::sanitize_path_segment(name.as_str())?;
110 Ok(self.collections_dir.join(name.as_str()))
111 }
112}
113
114impl Storage for LocalStorage {
115 type CollectionHandle = LocalCollection;
116
117 async fn get_collection(
118 &self,
119 name: &CollectionName,
120 ) -> Result<Arc<LocalCollection>, StorageError> {
121 let _guard = self.data_lock.read().await;
122 let dir = self.collection_dir(name)?;
123 if !dir.exists() {
124 return Err(StorageError::CollectionNotFound(name.to_string()));
125 }
126 Ok(Arc::new(LocalCollection::new(
127 name.clone(),
128 dir,
129 Arc::clone(&self.data_lock),
130 )))
131 }
132
133 async fn get_or_create_collection(
134 &self,
135 name: &CollectionName,
136 ) -> Result<Arc<LocalCollection>, StorageError> {
137 let dir = self.collection_dir(name)?;
138
139 let read_guard = self.data_lock.read().await;
140 if dir.exists() {
141 return Ok(Arc::new(LocalCollection::new(
142 name.clone(),
143 dir,
144 Arc::clone(&self.data_lock),
145 )));
146 }
147 drop(read_guard);
148
149 // Collection does not exist -- acquire a write lock to create it.
150 // Reject if a transaction is active.
151 if self.tx_active.load(Ordering::Acquire) {
152 return Err(StorageError::TransactionBusy);
153 }
154 let write_guard = self.data_lock.write().await;
155 // Re-check after acquiring write lock
156 if !dir.exists() {
157 self.require_root()?;
158 std::fs::create_dir_all(&dir)?;
159 tracing::debug!(collection = %name, "Created collection directory");
160 }
161 drop(write_guard);
162
163 Ok(Arc::new(LocalCollection::new(
164 name.clone(),
165 dir,
166 Arc::clone(&self.data_lock),
167 )))
168 }
169
170 fn begin_transaction(&self) -> Result<Transaction, StorageError> {
171 // Enforce single-transaction-at-a-time.
172 let was_active = self.tx_active.swap(true, Ordering::AcqRel);
173 if was_active {
174 return Err(StorageError::TransactionBusy);
175 }
176
177 let wal_path = self.journal_dir.join(wal::WAL_FILE_NAME);
178 let staging_dir = self.journal_dir.join(wal::STAGING_DIR_NAME);
179
180 // Nothing is created at construction time, so a transaction sets up the
181 // layout it and its commit need. Any failure has to clear the flag.
182 let prepared = self.require_root().and_then(|()| {
183 std::fs::create_dir_all(&staging_dir)?;
184 std::fs::create_dir_all(&self.collections_dir)?;
185 wal::create_wal(&wal_path)
186 });
187 if let Err(e) = prepared {
188 self.tx_active.store(false, Ordering::Release);
189 return Err(e);
190 }
191
192 tracing::debug!("Transaction started");
193
194 Ok(Transaction::new(
195 wal_path,
196 staging_dir,
197 Arc::new(LocalStorageCommitter {
198 collections_dir: self.collections_dir.clone(),
199 data_lock: Arc::clone(&self.data_lock),
200 tx_active: Arc::clone(&self.tx_active),
201 }),
202 ))
203 }
204
205 fn create_collection(
206 &self,
207 tx: &mut Transaction,
208 name: &CollectionName,
209 ) -> impl std::future::Future<Output = Result<Arc<LocalCollection>, StorageError>> + Send {
210 let result = (|| {
211 let dir = self.collection_dir(name)?;
212 if dir.exists() {
213 return Err(StorageError::TransactionConflict(format!(
214 "Collection already exists: {name}"
215 )));
216 }
217
218 let op = Operation::CreateCollection { name: name.clone() };
219 wal::append_operation(tx.journal_path(), &op)?;
220 tx.record(op);
221
222 // Return a collection handle that points to where the directory *will* be after commit.
223 Ok(Arc::new(LocalCollection::new(
224 name.clone(),
225 dir,
226 Arc::clone(&self.data_lock),
227 )))
228 })();
229 std::future::ready(result)
230 }
231
232 fn delete_collection(
233 &self,
234 tx: &mut Transaction,
235 name: &CollectionName,
236 ) -> impl std::future::Future<Output = Result<(), StorageError>> + Send {
237 let result = (|| {
238 let dir = self.collection_dir(name)?;
239 if !dir.exists() {
240 return Err(StorageError::CollectionNotFound(name.to_string()));
241 }
242
243 let op = Operation::DeleteCollection { name: name.clone() };
244 wal::append_operation(tx.journal_path(), &op)?;
245 tx.record(op);
246
247 Ok(())
248 })();
249 std::future::ready(result)
250 }
251
252 fn copy_collection(
253 &self,
254 tx: &mut Transaction,
255 source: &CollectionName,
256 dest: &CollectionName,
257 ) -> impl std::future::Future<Output = Result<(), StorageError>> + Send {
258 let result = (|| {
259 let source_dir = self.collection_dir(source)?;
260 if !source_dir.exists() {
261 return Err(StorageError::CollectionNotFound(source.to_string()));
262 }
263
264 let op = Operation::CopyCollection {
265 source: source.clone(),
266 dest: dest.clone(),
267 dest_existed: self.collection_dir(dest)?.exists(),
268 };
269 wal::append_operation(tx.journal_path(), &op)?;
270 tx.record(op);
271
272 Ok(())
273 })();
274 std::future::ready(result)
275 }
276}
277
278/// Handles commit and rollback callbacks from [`Transaction`].
279///
280/// This struct is held by the `Transaction` via `Arc<dyn TransactionCommitter>` and provides the
281/// bridge back into the storage backend for applying or discarding operations.
282struct LocalStorageCommitter {
283 collections_dir: PathBuf,
284 data_lock: Arc<tokio::sync::RwLock<()>>,
285 tx_active: Arc<AtomicBool>,
286}
287
288#[async_trait]
289impl TransactionCommitter for LocalStorageCommitter {
[docs]290 /// [[ dimpl~storage-atomic-commit, Atomic commit of a staged transaction via WAL and backup-rename, dimpl ]]
291 async fn apply(
292 &self,
293 operations: Vec<Operation>,
294 journal_path: PathBuf,
295 ) -> Result<(), StorageError> {
296 async fn apply_inner(
297 committer: &LocalStorageCommitter,
298 operations: Vec<Operation>,
299 journal_path: PathBuf,
300 ) -> Result<(), StorageError> {
301 let staging_dir = journal_path.parent().map(|p| p.join(wal::STAGING_DIR_NAME));
302 let journal_dir = journal_path
303 .parent()
304 .ok_or_else(|| StorageError::Other("WAL path has no parent directory".to_string()))?
305 .to_path_buf();
306
307 // Step 1: Mark the WAL as COMMITTING (in-place pwrite, no fsync yet).
308 wal::mark_committing(&journal_path)?;
309
310 // Step 2: Fsync all staging files and the WAL in one batch.
311 // This single durability barrier makes both the COMMITTING status and all operation
312 // entries durable. If the process crashes after this point, recovery will see
313 // COMMITTING and know that operations may have been partially applied.
314 if let Some(ref staging) = staging_dir {
315 wal::sync_staging_files(staging)?;
316 }
317 wal::sync_wal(&journal_path)?;
318
319 // Step 3: Acquire exclusive access. Blocks all reads.
320 let _write_guard = committer.data_lock.write().await;
321
322 // Step 4: Apply each operation, creating backups for rollback.
323 let result = committer.apply_operations(&operations);
324
325 if let Err(e) = &result {
326 tracing::warn!(
327 error = %e,
328 "Commit failed, rolling back partially applied operations"
329 );
330 if let Err(rb_err) =
331 rollback_applied_operations(&committer.collections_dir, &operations)
332 {
333 tracing::warn!(error = %rb_err, "Rollback also encountered an error");
334 }
335 } else {
336 // Step 5: Fsync collection directories to make renames durable.
337 sync_collection_dirs(&committer.collections_dir, &operations)?;
338 }
339
340 // Step 6: Delete the WAL file and fsync the journal directory.
341 // This is the point of no return. Once the WAL deletion is durable, recovery will
342 // treat any remaining .bak files as orphans from a successfully committed transaction.
343 if journal_path.exists() {
344 std::fs::remove_file(&journal_path)?;
345 }
346 wal::sync_journal_dir(&journal_dir)?;
347
348 // Step 7: Clean up .bak files and staging (non-critical, best-effort after point of
349 // no return).
350 if result.is_ok()
351 && let Err(e) = cleanup_backup_files(&committer.collections_dir)
352 {
353 tracing::warn!(error = %e, "Failed to clean up .bak files after commit");
354 }
355 if let Some(ref staging) = staging_dir
356 && let Err(e) = cleanup_directory_contents(staging)
357 {
358 tracing::warn!(error = %e, "Failed to clean up staging directory after commit");
359 }
360
361 result
362 }
363 let result = apply_inner(self, operations, journal_path).await;
364
365 // Release the transaction flag.
366 self.tx_active.store(false, Ordering::Release);
367
368 if result.is_ok() {
369 tracing::info!("Transaction committed successfully");
370 }
371 result
372 }
373
374 fn discard(&self, staging_dir: &Path, journal_path: &Path) {
375 // Best-effort cleanup on rollback.
376 if let Err(e) = cleanup_directory_contents(staging_dir) {
377 tracing::warn!(error = %e, "Failed to clean up staging directory during rollback");
378 }
379 if journal_path.exists()
380 && let Err(e) = std::fs::remove_file(journal_path)
381 {
382 tracing::warn!(error = %e, "Failed to remove WAL file during rollback");
383 }
384 self.tx_active.store(false, Ordering::Release);
385 tracing::debug!("Transaction rolled back");
386 }
387}
388
389impl LocalStorageCommitter {
390 /// Apply all operations from the journal.
391 ///
392 /// The Delete* Operations ignore the case that the file or directory was already deleted
393 /// on the fs due to external modifications.
394 /// In these cases, it's better to continue with the commit instead of failing it,
395 /// as the end result is the same (the file/directory is gone).
396 fn apply_operations(&self, operations: &[Operation]) -> Result<(), StorageError> {
397 operations
398 .iter()
399 .try_for_each(|op| -> Result<(), StorageError> {
400 match op {
401 Operation::Write {
402 collection,
403 key,
404 staged_path,
405 } => {
406 let target_dir = self.collections_dir.join(collection.as_str());
407 std::fs::create_dir_all(&target_dir)?;
408 let target = target_dir.join(key);
409
410 // Backup existing file if present.
411 if target.exists() {
412 let backup = append_extension(&target, BACKUP_EXTENSION);
413 std::fs::rename(&target, &backup)?;
414 }
415
416 // Move staged file into place.
417 std::fs::rename(staged_path, &target)?;
418 }
419 Operation::Delete { collection, key } => {
420 let target = self.collections_dir.join(collection.as_str()).join(key);
421 if target.exists() {
422 let backup = append_extension(&target, BACKUP_EXTENSION);
423 std::fs::rename(&target, &backup)?;
424 }
425 }
426 Operation::DeleteAll { collection } => {
427 let dir = self.collections_dir.join(collection.as_str());
428 if dir.exists() {
429 backup_all_files_in_dir(&dir)?;
430 }
431 }
432 Operation::CreateCollection { name } => {
433 let dir = self.collections_dir.join(name.as_str());
434 std::fs::create_dir_all(&dir)?;
435 }
436 Operation::DeleteCollection { name } => {
437 let dir = self.collections_dir.join(name.as_str());
438 if dir.exists() {
439 let backup = append_extension(&dir, BACKUP_EXTENSION);
440 std::fs::rename(&dir, &backup)?;
441 }
442 }
443 Operation::CopyCollection { source, dest, .. } => {
444 let source_dir = self.collections_dir.join(source.as_str());
445 let dest_dir = self.collections_dir.join(dest.as_str());
446
447 // Back up an existing destination before creating the replacement.
448 if dest_dir.exists() {
449 let backup = append_extension(&dest_dir, BACKUP_EXTENSION);
450 std::fs::rename(&dest_dir, &backup)?;
451 }
452
453 // Create a fresh directory to provide replace rather than merge semantics.
454 std::fs::create_dir_all(&dest_dir)?;
455 copy_dir_contents(&source_dir, &dest_dir)?;
456 }
457 }
458 Ok(())
459 })
460 }
461}
462
463/// Append an extension to a path (e.g., `foo.txt` -> `foo.txt.bak`).
464fn append_extension(path: &Path, ext: &str) -> PathBuf {
465 let mut s = path.as_os_str().to_owned();
466 s.push(".");
467 s.push(ext);
468 PathBuf::from(s)
469}
470
471/// Rename every file in a directory to have a `.bak` extension.
472fn backup_all_files_in_dir(dir: &Path) -> Result<(), StorageError> {
473 for entry in std::fs::read_dir(dir)? {
474 let entry = entry?;
475 let path = entry.path();
476 if path.is_file() {
477 let backup = append_extension(&path, BACKUP_EXTENSION);
478 std::fs::rename(&path, &backup)?;
479 }
480 }
481 Ok(())
482}
483
484/// Copy all files from `source` to `dest`.
485fn copy_dir_contents(source: &Path, dest: &Path) -> Result<(), StorageError> {
486 for entry in std::fs::read_dir(source)? {
487 let entry = entry?;
488 let path = entry.path();
489 if path.is_file()
490 && let Some(name) = path.file_name()
491 {
492 let target = dest.join(name);
493 std::fs::copy(&path, &target)?;
494 }
495 }
496 Ok(())
497}
498
499/// Remove all `.bak` files and directories under `collections_dir`.
500fn cleanup_backup_files(collections_dir: &Path) -> Result<(), StorageError> {
501 let bak_ext = std::ffi::OsStr::new(BACKUP_EXTENSION);
502
503 for collection_entry in std::fs::read_dir(collections_dir)? {
504 let collection_entry = collection_entry?;
505 let path = collection_entry.path();
506
507 // Collection-level backups (directories with .bak extension).
508 if path.is_dir() && path.extension() == Some(bak_ext) {
509 std::fs::remove_dir_all(&path)?;
510 continue;
511 }
512
513 if path.is_dir() {
514 for entry in std::fs::read_dir(&path)? {
515 let entry = entry?;
516 let file_path = entry.path();
517 if file_path.extension() == Some(bak_ext) {
518 std::fs::remove_file(&file_path)?;
519 }
520 }
521 }
522 }
523
524 Ok(())
525}
526
527/// Roll back partially applied operations during a failed commit.
528///
529/// This removes newly created files and directories (for writes to new keys,
530/// `CreateCollection`, `CopyCollection`) and then restores `.bak` files (for
531/// overwrites/deletes). The order matters: new artifacts must be identified while `.bak` files
532/// are still in place (before restoration changes the filesystem state).
533fn rollback_applied_operations(
534 collections_dir: &Path,
535 operations: &[Operation],
536) -> Result<(), StorageError> {
537 recovery::remove_new_artifacts(collections_dir, operations)?;
538
539 recovery::rollback_partial_commit(collections_dir)?;
540
541 Ok(())
542}
543
544/// Fsync the directories that were modified during commit to ensure renames are durable.
545///
546/// On POSIX systems, `rename()` is atomic but not durable until the *directory* containing the
547/// entry is fsynced. This function fsyncs each unique collection directory that was touched by
548/// the operations.
549///
550/// On Windows this is a no-op because NTFS journals metadata changes internally.
551// disable the lint on windows to keep signature consistent
552#[cfg_attr(windows, allow(clippy::unnecessary_wraps))]
553fn sync_collection_dirs(
554 collections_dir: &Path,
555 operations: &[Operation],
556) -> Result<(), StorageError> {
557 // On Windows, NTFS journals metadata changes internally so explicit directory fsync is
558 // not needed.
559 // Additionally, `File::open` on a directory fails due to `FILE_FLAG_BACKUP_SEMANTICS`
560 // not being set by the standard library.
561 // The early return avoids both the unnecessary work and the platform-specific fsync
562 // implementation.
563 #[cfg(windows)]
564 {
565 // Suppress unused variable warnings.
566 let _ = &collections_dir;
567 let _ = &operations;
568 }
569 #[cfg(not(windows))]
570 {
571 let mut synced = std::collections::HashSet::new();
572
573 for op in operations {
574 let dir_name = match op {
575 Operation::Write { collection, .. }
576 | Operation::Delete { collection, .. }
577 | Operation::DeleteAll { collection } => Some(collection.as_str()),
578 Operation::CreateCollection { name } | Operation::DeleteCollection { name } => {
579 Some(name.as_str())
580 }
581 Operation::CopyCollection { dest, .. } => Some(dest.as_str()),
582 };
583
584 if let Some(name) = dir_name
585 && synced.insert(name.to_string())
586 {
587 let dir = collections_dir.join(name);
588 if dir.exists() {
589 let f = std::fs::File::open(&dir)?;
590 f.sync_all()?;
591 }
592 }
593 }
594
595 // Also fsync the collections directory itself (for create/delete collection operations).
596 let f = std::fs::File::open(collections_dir)?;
597 f.sync_all()?;
598 }
599
600 Ok(())
601}
602
603/// Remove all files (not directories) in the given directory.
604fn cleanup_directory_contents(dir: &Path) -> Result<(), StorageError> {
605 if !dir.exists() {
606 return Ok(());
607 }
608 for entry in std::fs::read_dir(dir)? {
609 let entry = entry?;
610 let path = entry.path();
611 if path.is_file() {
612 std::fs::remove_file(&path)?;
613 }
614 }
615 Ok(())
616}