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//! Startup recovery for interrupted transactions.
 15//!
 16//! When [`LocalStorage`](crate::LocalStorage) is created, it calls [`recover`] to check for
 17//! leftover WAL, staging, and `.bak` files from a previous run.
 18//!
 19//! Recovery decisions are driven by the WAL file header status:
 20//!
 21//! - **No WAL file (or corrupt/unreadable header):** Either no transaction was in progress, or
 22//!   a commit completed successfully but cleanup was interrupted. Any orphaned `.bak` files are
 23//!   deleted and staging is cleaned.
 24//! - **WAL with `RECORDING` status:** The transaction was still recording operations. Nothing
 25//!   was applied to collections. Safe to discard the WAL and staging files.
 26//! - **WAL with `COMMITTING` status:** The commit phase started but did not complete (the WAL
 27//!   was not yet deleted). Operations may have been partially applied. Recovery reads the WAL
 28//!   entries, removes newly created artifacts, and restores `.bak` files.
 29
 30use std::path::Path;
 31
 32use cda_interfaces::storage_api::{Operation, StorageError};
 33
 34use crate::wal::{self, WalStatus};
 35
 36/// File extension used for backup files created during the commit phase.
 37pub(crate) const BACKUP_EXTENSION: &str = "bak";
 38
 39/// File extension used for staging (temporary) files created during write operations.
 40pub(crate) const STAGING_EXTENSION: &str = "tmp";
 41
 42/// Whether a previous run left work for [`recover`] to do.
 43///
 44/// Read-only, so a clean storage needs no write access at startup.
 45pub(crate) fn has_pending_work(journal_dir: &Path, collections_dir: &Path) -> bool {
 46    let wal_path = journal_dir.join(wal::WAL_FILE_NAME);
 47    if wal_path.exists() {
 48        return true;
 49    }
 50
 51    if has_backup_files(collections_dir) {
 52        return true;
 53    }
 54
 55    // Leftover staging files from an interrupted transaction.
 56    std::fs::read_dir(journal_dir.join(wal::STAGING_DIR_NAME))
 57        .is_ok_and(|mut entries| entries.any(|entry| entry.is_ok_and(|e| e.path().is_file())))
 58}
 59
 60/// Perform startup recovery on the given journal and collections directories.
 61///
 62/// This function is idempotent. Calling it multiple times on a clean state is a no-op.
 63///
 64/// # Errors
 65///
 66/// Returns a [`StorageError`] if recovery cannot complete (e.g., filesystem is read-only or
 67/// corrupted beyond repair).
[docs] 68/// [[ dimpl~storage-crash-recovery, Startup recovery of interrupted transactions from the WAL, dimpl ]]
 69pub(crate) fn recover(journal_dir: &Path, collections_dir: &Path) -> Result<(), StorageError> {
 70    let wal_path = journal_dir.join(wal::WAL_FILE_NAME);
 71    let staging_dir = journal_dir.join(wal::STAGING_DIR_NAME);
 72
 73    let status = wal::open_wal(&wal_path)?;
 74
 75    match status {
 76        Some((WalStatus::Committing, wal_data)) => {
 77            // Commit was in progress. Operations may have been partially applied.
 78            tracing::info!("WAL indicates commit was in progress -- rolling back");
 79
 80            // Read the operations from the WAL so we know what to undo.
 81            let wal_result = wal::read_wal(&wal_data)?;
 82
 83            if wal_result.truncated {
 84                // Check if any operations were actually applied to the filesystem
 85                // to determine if it was a crash during recording or a corruption of the fs.
 86                let has_backups = has_backup_files(collections_dir);
 87                let has_applied_artifacts =
 88                    has_new_artifacts(collections_dir, &wal_result.operations);
 89
 90                if has_backups || has_applied_artifacts {
 91                    // Operations were partially applied but WAL is corrupted.
 92                    // Unable to guarantee a consistent rollback.
 93                    return Err(StorageError::Corruption(
 94                        "WAL is truncated and operations were partially applied, indicating \
 95                         possible filesystem corruption."
 96                            .to_string(),
 97                    ));
 98                }
 99                // No evidence of the transaction being actually applied.
100                // -> Safe to discard the WAL.
101                tracing::info!(
102                    "WAL is truncated but no operations were applied. Discarding safely"
103                );
104            } else {
105                // Full WAL available -- perform complete rollback.
106                // Remove newly created artifacts first. This must happen before
107                // rollback_partial_commit as restoring .bak files could make
108                // previously-overwritten files look like new artifacts.
109                remove_new_artifacts(collections_dir, &wal_result.operations)?;
110
111                // Restore .bak files (undoes overwrites and deletes).
112                rollback_partial_commit(collections_dir)?;
113            }
114        }
115        Some((WalStatus::Recording, _wal_data)) => {
116            // Transaction was still recording. Nothing was applied.
117            tracing::info!("WAL indicates transaction was still recording -- discarding");
118        }
119        None => {
120            // No valid WAL. Either clean state or commit completed with interrupted cleanup.
121            // Delete any orphaned .bak files (leftover from a completed commit whose cleanup
122            // was interrupted between WAL deletion and .bak removal).
123            if has_backup_files(collections_dir) {
124                tracing::info!("No WAL but found orphaned .bak files -- cleaning up");
125                delete_all_backup_files(collections_dir)?;
126            }
127        }
128    }
129
130    // Always clean up WAL and staging files.
131    if wal_path.exists() {
132        std::fs::remove_file(&wal_path)?;
133    }
134    cleanup_staging_dir(&staging_dir)?;
135
136    tracing::info!("Recovery complete");
137    Ok(())
138}
139
140/// Roll back a partially applied commit by restoring `.bak` files.
141///
142/// This is also called by the commit path in `local_storage.rs` when `apply_operations` fails
143/// mid-commit.
144///
145/// For each `.bak` file found under `collections_dir`:
146/// 1. If the corresponding non-`.bak` file exists (i.e., the new file was already renamed into
147///    place), remove it.
148/// 2. Rename the `.bak` file back to its original name.
149pub(crate) fn rollback_partial_commit(collections_dir: &Path) -> Result<(), StorageError> {
150    if !collections_dir.exists() {
151        return Ok(());
152    }
153
154    for collection_entry in std::fs::read_dir(collections_dir)? {
155        let collection_entry = collection_entry?;
156        let collection_path = collection_entry.path();
157        if !collection_path.is_dir() {
158            continue;
159        }
160
161        // Check for .bak files that are actually directories (collection-level backups from
162        // delete_collection).
163        let bak_ext = std::ffi::OsStr::new(BACKUP_EXTENSION);
164        if collection_path.extension() == Some(bak_ext) {
165            let original = collection_path.with_extension("");
166            if original.exists() {
167                std::fs::remove_dir_all(&original)?;
168            }
169            std::fs::rename(&collection_path, &original)?;
170            tracing::info!(
171                path = %original.display(),
172                "Restored collection directory from backup"
173            );
174            continue;
175        }
176
177        restore_bak_files_in_dir(&collection_path)?;
178    }
179
180    Ok(())
181}
182
183/// Remove files and directories that were newly created by a partially applied commit.
184///
185/// This handles the case where operations created new artifacts (write to new key,
186/// `CreateCollection`, `CopyCollection`) that have no `.bak` counterpart and would otherwise
187/// remain after `.bak` restoration.
188pub(crate) fn remove_new_artifacts(
189    collections_dir: &Path,
190    operations: &[Operation],
191) -> Result<(), StorageError> {
192    for op in operations.iter().rev() {
193        match op {
194            Operation::Write {
195                collection, key, ..
196            } => {
197                let target = collections_dir.join(collection.as_str()).join(key);
198                let backup = append_bak_extension(&target);
199                // If no .bak exists, this was a new file (not an overwrite). Remove it.
200                if target.exists() && !backup.exists() {
201                    std::fs::remove_file(&target)?;
202                    tracing::debug!(path = %target.display(), "Removed newly created file");
203                }
204            }
205            Operation::CreateCollection { name } => {
206                let dir = collections_dir.join(name.as_str());
207                // Only remove if it is empty (its contents, if any, are handled by other ops).
208                if dir.exists() && is_dir_empty(&dir)? {
209                    std::fs::remove_dir(&dir)?;
210                    tracing::debug!(path = %dir.display(), "Removed newly created collection dir");
211                }
212            }
213            Operation::CopyCollection {
214                dest, dest_existed, ..
215            } => {
216                let dest_dir = collections_dir.join(dest.as_str());
217                let backup = append_bak_extension(&dest_dir);
218                // Existing destinations are restored from their .bak file. Only remove a
219                // destination that was created by this transaction and has no backup.
220                if !dest_existed && dest_dir.exists() && !backup.exists() {
221                    std::fs::remove_dir_all(&dest_dir)?;
222                    tracing::debug!(
223                        path = %dest_dir.display(),
224                        "Removed newly created copy-collection dir"
225                    );
226                }
227            }
228            // Delete and DeleteAll only create .bak files -- handled by rollback_partial_commit.
229            Operation::Delete { .. }
230            | Operation::DeleteAll { .. }
231            | Operation::DeleteCollection { .. } => {}
232        }
233    }
234    Ok(())
235}
236
237/// Check whether any `.bak` files or directories exist under `collections_dir`.
238fn has_backup_files(collections_dir: &Path) -> bool {
239    if !collections_dir.exists() {
240        return false;
241    }
242
243    let bak_ext = std::ffi::OsStr::new(BACKUP_EXTENSION);
244
245    let Ok(entries) = std::fs::read_dir(collections_dir) else {
246        return false;
247    };
248
249    for entry in entries {
250        let Ok(entry) = entry else {
251            continue;
252        };
253        let path = entry.path();
254
255        // Collection-level .bak directory.
256        if path.extension() == Some(bak_ext) {
257            return true;
258        }
259
260        // Check inside each collection directory for .bak files.
261        if path.is_dir() {
262            let Ok(inner_entries) = std::fs::read_dir(&path) else {
263                continue;
264            };
265            for inner in inner_entries {
266                let Ok(inner) = inner else {
267                    continue;
268                };
269                if inner.path().extension() == Some(bak_ext) {
270                    return true;
271                }
272            }
273        }
274    }
275
276    false
277}
278
279/// Check whether any operations from the WAL appear to have been applied to the filesystem.
280///
281/// This looks for artifacts that would have been created by the given operations (new files,
282/// new collection directories) that exist on disk without a corresponding `.bak` backup.
283fn has_new_artifacts(collections_dir: &Path, operations: &[Operation]) -> bool {
284    for op in operations {
285        match op {
286            Operation::Write {
287                collection, key, ..
288            } => {
289                let target = collections_dir.join(collection.as_str()).join(key);
290                let backup = append_bak_extension(&target);
291                // A file exists without a .bak -> it was newly created by this operation.
292                if target.exists() && !backup.exists() {
293                    return true;
294                }
295            }
296            Operation::CreateCollection { name } => {
297                let dir = collections_dir.join(name.as_str());
298                if dir.exists() {
299                    return true;
300                }
301            }
302            Operation::CopyCollection {
303                dest, dest_existed, ..
304            } => {
305                let dest_dir = collections_dir.join(dest.as_str());
306                let backup = append_bak_extension(&dest_dir);
307                if !dest_existed && dest_dir.exists() && !backup.exists() {
308                    return true;
309                }
310            }
311            Operation::Delete { .. }
312            | Operation::DeleteAll { .. }
313            | Operation::DeleteCollection { .. } => {}
314        }
315    }
316    false
317}
318
319/// Delete all `.bak` files and directories under `collections_dir`.
320///
321/// Used when no WAL is present but orphaned `.bak` files remain from a successfully committed
322/// transaction whose post-commit cleanup was interrupted.
323fn delete_all_backup_files(collections_dir: &Path) -> Result<(), StorageError> {
324    let bak_ext = std::ffi::OsStr::new(BACKUP_EXTENSION);
325
326    for collection_entry in std::fs::read_dir(collections_dir)? {
327        let collection_entry = collection_entry?;
328        let path = collection_entry.path();
329
330        // Collection-level .bak directory.
331        if path.is_dir() && path.extension() == Some(bak_ext) {
332            std::fs::remove_dir_all(&path)?;
333            tracing::debug!(path = %path.display(), "Removed orphaned .bak directory");
334            continue;
335        }
336
337        // File-level .bak files inside collection directories.
338        if path.is_dir() {
339            for entry in std::fs::read_dir(&path)? {
340                let entry = entry?;
341                let file_path = entry.path();
342                if file_path.extension() == Some(bak_ext) {
343                    std::fs::remove_file(&file_path)?;
344                    tracing::debug!(path = %file_path.display(), "Removed orphaned .bak file");
345                }
346            }
347        }
348    }
349
350    Ok(())
351}
352
353/// Restore all `.bak` files in a single directory to their original names.
354fn restore_bak_files_in_dir(dir: &Path) -> Result<(), StorageError> {
355    let bak_ext = std::ffi::OsStr::new(BACKUP_EXTENSION);
356
357    for entry in std::fs::read_dir(dir)? {
358        let entry = entry?;
359        let path = entry.path();
360
361        if path.extension() == Some(bak_ext) {
362            let original = path.with_extension("");
363            // Remove the partially-applied new file if it exists.
364            if original.exists() {
365                std::fs::remove_file(&original)?;
366            }
367            std::fs::rename(&path, &original)?;
368            tracing::info!(path = %original.display(), "Restored file from backup");
369        }
370
371        // Also clean up any orphaned .tmp files (staging files that were renamed into the
372        // collection directory during commit but shouldn't be there).
373        let tmp_ext = std::ffi::OsStr::new(STAGING_EXTENSION);
374        if path.extension() == Some(tmp_ext) {
375            std::fs::remove_file(&path)?;
376            tracing::warn!(path = %path.display(), "Removed orphaned staging file");
377        }
378    }
379
380    Ok(())
381}
382
383/// Remove all files in the staging directory.
384fn cleanup_staging_dir(staging_dir: &Path) -> Result<(), StorageError> {
385    if !staging_dir.exists() {
386        return Ok(());
387    }
388
389    for entry in std::fs::read_dir(staging_dir)? {
390        let entry = entry?;
391        let path = entry.path();
392        if path.is_file() {
393            std::fs::remove_file(&path)?;
394        }
395    }
396
397    Ok(())
398}
399
400/// Append `.bak` to a path.
401fn append_bak_extension(path: &Path) -> std::path::PathBuf {
402    let mut s = path.as_os_str().to_owned();
403    s.push(".");
404    s.push(BACKUP_EXTENSION);
405    std::path::PathBuf::from(s)
406}
407
408/// Check if a directory is empty.
409fn is_dir_empty(dir: &Path) -> Result<bool, StorageError> {
410    let mut entries = std::fs::read_dir(dir)?;
411    Ok(entries.next().is_none())
412}