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}