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#![cfg(test)] // Workaround to make Clippy in pre-commit hook recognize this file as test code
 15
 16use std::{io::Write, path::PathBuf};
 17
 18use cda_interfaces::storage_api::{
 19    Collection, CollectionName, RandomAccessData, Storage, StorageError,
 20};
 21use cda_storage::LocalStorage;
 22
 23/// Simulates a crash during the recording phase: a WAL file exists with `RECORDING` status and
 24/// entries, and orphaned staging files are present, but nothing was ever applied to the
 25/// collections directory. Recovery must discard the WAL and staging files and leave the
 26/// collection untouched, since the transaction never reached the commit phase.
[docs] 27/// [[ test~storage-atomicity-recovery-discards-recording-phase-crash, Recovery discards an incomplete transaction that crashed while still recording, test ]]
 28#[tokio::test]
 29async fn recovery_cleans_up_incomplete_transaction() {
 30    let fixture = Fixture::new();
 31
 32    // Create a WAL with RECORDING header and a single write operation.
 33    cda_storage::wal::create_wal(&fixture.wal_file).unwrap();
 34
 35    let staged = fixture.staging_dir.join("some_file.tmp");
 36    std::fs::write(&staged, b"orphaned data").unwrap();
 37    let staged_str = staged
 38        .to_str()
 39        .expect("Failed to convert staging path to string")
 40        .to_string();
 41
 42    cda_storage::wal::append_operation(
 43        &fixture.wal_file,
 44        &cda_interfaces::storage_api::Operation::Write {
 45            collection: CollectionName::DiagnosticDatabase,
 46            key: "test".to_string(),
 47            staged_path: staged_str,
 48        },
 49    )
 50    .unwrap();
 51
 52    // Creating a new LocalStorage should trigger recovery.
 53    let storage = fixture.create_storage();
 54
 55    // The WAL and staging file should be cleaned up.
 56    assert!(!fixture.wal_file.exists());
 57    assert!(!staged.exists());
 58
 59    // And the collection should not have the key.
 60    let collection = storage
 61        .get_or_create_collection(&CollectionName::DiagnosticDatabase)
 62        .await
 63        .unwrap();
 64    let result = collection.read("test").await;
 65    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
 66}
 67
 68/// Simulates a crash during the commit phase where an existing key was being overwritten: the
 69/// WAL has `COMMITTING` status and a `.bak` file exists alongside the partially-written new
 70/// file. Recovery must restore the original data from the `.bak` file, upholding the
 71/// all-or-nothing guarantee for `Write` operations that overwrite existing keys.
[docs] 72/// [[ test~storage-atomicity-recovery-restores-overwritten-file, Recovery restores the original file from its backup after an interrupted overwrite, test ]]
 73#[tokio::test]
 74async fn recovery_rolls_back_partial_commit_with_bak_files() {
 75    let fixture = Fixture::new();
 76    let db_dir = &fixture.diagnostic_database_dir;
 77
 78    // Simulate: original file was backed up, new file was partially written.
 79    std::fs::write(db_dir.join("mykey.bak"), b"original data").unwrap();
 80    std::fs::write(db_dir.join("mykey"), b"new data").unwrap();
 81
 82    // Create a WAL with COMMITTING status.
 83    cda_storage::wal::create_wal(&fixture.wal_file).unwrap();
 84    cda_storage::wal::append_operation(
 85        &fixture.wal_file,
 86        &cda_interfaces::storage_api::Operation::Write {
 87            collection: CollectionName::DiagnosticDatabase,
 88            key: "mykey".to_string(),
 89            staged_path: "/tmp/does_not_matter.tmp".to_string(),
 90        },
 91    )
 92    .unwrap();
 93    cda_storage::wal::mark_committing(&fixture.wal_file).unwrap();
 94
 95    // Recovery should detect COMMITTING + .bak files and restore them.
 96    let storage = fixture.create_storage();
 97
 98    let collection = storage
 99        .get_collection(&CollectionName::DiagnosticDatabase)
100        .await
101        .unwrap();
102    let handle = collection.read("mykey").await.unwrap();
103    let mut buf = vec![0u8; 13];
104    let n = handle.read_at(0, &mut buf).unwrap();
105    assert_eq!(n, 13);
106    assert_eq!(&buf, b"original data");
107}
108
109/// Whgen a crash happens during commit where only NEW files were  created (no overwrites, so no `.bak` files exist).
110/// Recovery must still detect the partial commit via the `COMMITTING` WAL status and remove the newly created files,
111/// since a `Write` operation that introduces a brand-new key must be all-or-nothing just like an overwrite.
[docs]112/// [[ test~storage-atomicity-recovery-removes-new-file, Recovery removes a newly-written file left by an interrupted commit with no backup to restore, test ]]
113#[tokio::test]
114async fn recovery_rolls_back_new_file_writes_without_bak() {
115    let fixture = Fixture::new();
116    let db_dir = &fixture.diagnostic_database_dir;
117
118    // Simulate: a new file was written into the collection during a partially applied commit.
119    // No .bak file exists because there was nothing to overwrite.
120    std::fs::write(db_dir.join("new_key"), b"partially committed data").unwrap();
121
122    // Create a WAL with COMMITTING status containing the write operation.
123    cda_storage::wal::create_wal(&fixture.wal_file).unwrap();
124    cda_storage::wal::append_operation(
125        &fixture.wal_file,
126        &cda_interfaces::storage_api::Operation::Write {
127            collection: CollectionName::DiagnosticDatabase,
128            key: "new_key".to_string(),
129            staged_path: "/tmp/does_not_matter.tmp".to_string(),
130        },
131    )
132    .unwrap();
133    cda_storage::wal::mark_committing(&fixture.wal_file).unwrap();
134
135    // Recovery should detect COMMITTING, read the WAL, and remove the new file.
136    let storage = fixture.create_storage();
137
138    let collection = storage
139        .get_collection(&CollectionName::DiagnosticDatabase)
140        .await
141        .unwrap();
142    let result = collection.read("new_key").await;
143    assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
144}
145
146/// Simulates a crash during commit of a `CreateCollection` operation, where no backup can exist
147/// because the collection is entirely new. Recovery must remove the empty collection directory
148/// so that a partially-applied `CreateCollection` never leaves a visible trace behind.
[docs]149/// [[ test~storage-atomicity-recovery-removes-new-collection, Recovery removes an empty collection directory left by an interrupted `CreateCollection` commit, test ]]
150#[tokio::test]
151async fn recovery_rolls_back_new_collection_without_bak() {
152    let fixture = Fixture::new();
153
154    // Simulate: a new empty collection directory was created during partial commit.
155    let new_col_dir = fixture.collections_dir.join("brand_new");
156    std::fs::create_dir_all(&new_col_dir).unwrap();
157
158    // Create a WAL with COMMITTING status.
159    cda_storage::wal::create_wal(&fixture.wal_file).unwrap();
160    cda_storage::wal::append_operation(
161        &fixture.wal_file,
162        &cda_interfaces::storage_api::Operation::CreateCollection {
163            name: CollectionName::Custom("brand_new".to_string()),
164        },
165    )
166    .unwrap();
167    cda_storage::wal::mark_committing(&fixture.wal_file).unwrap();
168
169    // Recovery should remove the newly created collection directory.
170    let _storage = fixture.create_storage();
171
172    assert!(!new_col_dir.exists());
173}
174
175/// Simulates a case where a commit succeeded (the WAL was deleted, marking the point of no
176/// return) but a subsequent crash interrupted the best-effort `.bak` cleanup step. Recovery must
177/// treat the absence of a WAL as "already committed" and simply delete the orphaned backups,
178/// keeping the already-committed data intact.
[docs]179/// [[ test~storage-atomicity-recovery-cleans-orphaned-backups, Recovery cleans up orphaned backup files left after a successful commit, test ]]
180#[tokio::test]
181async fn recovery_handles_no_wal_with_orphaned_bak_files() {
182    let fixture = Fixture::new();
183    let db_dir = &fixture.diagnostic_database_dir;
184
185    // The new committed data is in place, but old .bak files remain.
186    std::fs::write(db_dir.join("mykey"), b"new committed data").unwrap();
187    std::fs::write(db_dir.join("mykey.bak"), b"old data").unwrap();
188
189    // No WAL file exists (it was successfully deleted as point of no return).
190    assert!(!fixture.wal_file.exists());
191
192    // Recovery should delete the orphaned .bak and keep the committed data.
193    let storage = fixture.create_storage();
194
195    assert!(!db_dir.join("mykey.bak").exists());
196    let collection = storage
197        .get_collection(&CollectionName::DiagnosticDatabase)
198        .await
199        .unwrap();
200    let handle = collection.read("mykey").await.unwrap();
201    let mut buf = vec![0u8; 18];
202    let n = handle.read_at(0, &mut buf).unwrap();
203    assert_eq!(n, 18);
204    assert_eq!(&buf, b"new committed data");
205}
206
207/// Creates a WAL with a valid entry followed by corrupted (torn-write) bytes. Recovery must
208/// stop reading at the first invalid entry and discard the whole transaction, since a
209/// `RECORDING`-status WAL means nothing was ever applied to collections - so a corrupt tail is
210/// safe to ignore rather than treated as unrecoverable filesystem corruption.
[docs]211/// [[ test~storage-atomicity-recovery-discards-corrupt-wal, Recovery discards a WAL with a corrupt checksum during the recording phase, test ]]
212#[tokio::test]
213async fn recovery_discards_wal_with_corrupt_checksum() {
214    let fixture = Fixture::new();
215
216    cda_storage::wal::create_wal(&fixture.wal_file).unwrap();
217
218    // Append a valid entry.
219    cda_storage::wal::append_operation(
220        &fixture.wal_file,
221        &cda_interfaces::storage_api::Operation::CreateCollection {
222            name: CollectionName::Custom("valid_collection".to_string()),
223        },
224    )
225    .unwrap();
226
227    // Append garbage bytes to simulate a torn write (corrupt checksum).
228    let mut file = std::fs::OpenOptions::new()
229        .append(true)
230        .open(&fixture.wal_file)
231        .unwrap();
232    file.write_all(&[0xFF; 20]).unwrap();
233
234    // Recovery should succeed, the corrupt entry is simply ignored.
235    let _storage = fixture.create_storage();
236
237    // The WAL should be cleaned up.
238    assert!(!fixture.wal_file.exists());
239}
240
241/// Simulates a crash during commit of a multi-operation transaction where a `CreateCollection`
242/// and a `Write` into that same new collection were both partially applied (the directory and
243/// its file both exist on disk, with the WAL in `COMMITTING` status). Since the collection did
244/// not exist before this transaction, recovery must roll back the *entire* transaction as a
245/// unit, removing both the written file and the now-empty collection directory - demonstrating
246/// that atomicity applies across all staged operations in an execution, not just individually.
[docs]247/// [[ test~storage-atomicity-recovery-removes-orphaned-collection-dir, Recovery fully rolls back a multi-operation transaction that created a collection and wrote into it, test ]]
248#[tokio::test]
249async fn recovery_create_collection_with_write_removes_orphaned_dir() {
250    let fixture = Fixture::new();
251
252    // Simulate partial commit state: the collection directory was created AND a file was
253    // written into it before the crash.
254    let new_col_dir = fixture.collections_dir.join("fresh_collection");
255    std::fs::create_dir_all(&new_col_dir).unwrap();
256    std::fs::write(new_col_dir.join("data_file"), b"written during commit").unwrap();
257
258    // WAL with COMMITTING status containing both operations in natural order:
259    // CreateCollection first, then Write to that collection.
260    cda_storage::wal::create_wal(&fixture.wal_file).unwrap();
261    cda_storage::wal::append_operation(
262        &fixture.wal_file,
263        &cda_interfaces::storage_api::Operation::CreateCollection {
264            name: CollectionName::Custom("fresh_collection".to_string()),
265        },
266    )
267    .unwrap();
268    cda_storage::wal::append_operation(
269        &fixture.wal_file,
270        &cda_interfaces::storage_api::Operation::Write {
271            collection: CollectionName::Custom("fresh_collection".to_string()),
272            key: "data_file".to_string(),
273            staged_path: "/tmp/irrelevant.tmp".to_string(),
274        },
275    )
276    .unwrap();
277    cda_storage::wal::mark_committing(&fixture.wal_file).unwrap();
278
279    // Recovery should fully roll back - the collection did not exist before this transaction.
280    let _storage = fixture.create_storage();
281
282    // The collection directory must be completely gone after recovery.
283    assert!(
284        !new_col_dir.exists(),
285        "Orphaned empty collection directory was left behind after recovery"
286    );
287}
288
289/// A failed collection swap can leave a COMMITTING WAL where a pre-existing destination was
290/// never renamed to its backup (for example, when `rename` returns EXDEV). Recovery must retain
291/// that untouched current collection rather than treating it as an artifact of the failed copy.
292#[tokio::test]
293async fn recovery_preserves_untouched_existing_copy_destination() {
294    let fixture = Fixture::new();
295    let current_dir = &fixture.diagnostic_database_dir;
296    std::fs::write(current_dir.join("ecu1.mdd"), b"current data").unwrap();
297
298    cda_storage::wal::create_wal(&fixture.wal_file).unwrap();
299    cda_storage::wal::append_operation(
300        &fixture.wal_file,
301        &cda_interfaces::storage_api::Operation::CopyCollection {
302            source: CollectionName::DiagnosticDatabaseNextUpdate,
303            dest: CollectionName::DiagnosticDatabase,
304            dest_existed: true,
305        },
306    )
307    .unwrap();
308    cda_storage::wal::mark_committing(&fixture.wal_file).unwrap();
309
310    let storage = fixture.create_storage();
311    let current = storage
312        .get_collection(&CollectionName::DiagnosticDatabase)
313        .await
314        .unwrap();
315    let handle = current.read("ecu1.mdd").await.unwrap();
316    let mut data = vec![0; "current data".len()];
317    handle.read_at(0, &mut data).unwrap();
318    assert_eq!(data, b"current data");
319}
320
321struct Fixture {
322    root_dir: tempfile::TempDir,
323    collections_dir: PathBuf,
324    staging_dir: PathBuf,
325    diagnostic_database_dir: PathBuf,
326    wal_file: PathBuf,
327}
328
329impl Fixture {
330    fn new() -> Self {
331        let root_dir = tempfile::tempdir().unwrap();
332        let root = root_dir.path();
333
334        let collections_dir = root.join("collections");
335        std::fs::create_dir_all(&collections_dir).unwrap();
336
337        let journal_dir = root.join("journal");
338
339        let staging_dir = journal_dir.join("staging");
340        std::fs::create_dir_all(&staging_dir).unwrap();
341
342        let diagnostic_database_dir = collections_dir.join("diagnostic_database");
343        std::fs::create_dir_all(&diagnostic_database_dir).unwrap();
344
345        let wal_file = journal_dir.join("transaction.wal");
346
347        Self {
348            root_dir,
349            collections_dir,
350            staging_dir,
351            diagnostic_database_dir,
352            wal_file,
353        }
354    }
355
356    fn create_storage(&self) -> LocalStorage {
357        LocalStorage::new(self.root_dir.path()).unwrap()
358    }
359}