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