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}