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}