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