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//! Integration tests for the local filesystem storage backend.
15
16use std::io::Write as _;
17
18use cda_interfaces::storage_api::{
19 Collection as _, CollectionName, RandomAccessData as _, Storage, StorageError,
20};
21use cda_storage::LocalStorage;
22
23/// Create a fresh `LocalStorage` in a unique temp directory.
24fn create_test_storage() -> (LocalStorage, tempfile::TempDir) {
25 let dir = tempfile::tempdir().expect("Failed to create temp dir");
26 let storage = LocalStorage::new(dir.path()).expect("Failed to create LocalStorage");
27 (storage, dir)
28}
29
30#[tokio::test]
31async fn write_read_roundtrip() {
32 let (storage, _dir) = create_test_storage();
33 let name = CollectionName::DiagnosticDatabase;
34 let collection = storage.get_or_create_collection(&name).await.unwrap();
35
36 let mut tx = storage.begin_transaction().unwrap();
37 let mut data: &[u8] = b"hello world";
38 collection
39 .write(&mut tx, "greeting", &mut data)
40 .await
41 .unwrap();
42 tx.commit().await.unwrap();
43
44 let handle = collection.read("greeting").await.unwrap();
45 let mut buf = vec![0u8; 11];
46 let n = handle.read_at(0, &mut buf).unwrap();
47 assert_eq!(n, 11);
48 assert_eq!(&buf, b"hello world");
49}
50
51#[tokio::test]
52async fn read_nonexistent_key_returns_not_found() {
53 let (storage, _dir) = create_test_storage();
54 let name = CollectionName::DiagnosticDatabase;
55 let collection = storage.get_or_create_collection(&name).await.unwrap();
56
57 let result = collection.read("nonexistent").await;
58 assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
59}
60
61#[tokio::test]
62async fn delete_removes_key_after_commit() {
63 let (storage, _dir) = create_test_storage();
64 let name = CollectionName::DiagnosticDatabase;
65 let collection = storage.get_or_create_collection(&name).await.unwrap();
66
67 // Write a key.
68 let mut tx = storage.begin_transaction().unwrap();
69 let mut data: &[u8] = b"to be deleted";
70 collection
71 .write(&mut tx, "doomed", &mut data)
72 .await
73 .unwrap();
74 tx.commit().await.unwrap();
75
76 // Delete the key.
77 let mut tx = storage.begin_transaction().unwrap();
78 collection.delete(&mut tx, "doomed").await.unwrap();
79 tx.commit().await.unwrap();
80
81 let result = collection.read("doomed").await;
82 assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
83}
84
85#[tokio::test]
86async fn delete_all_removes_all_keys() {
87 let (storage, _dir) = create_test_storage();
88 let name = CollectionName::DiagnosticDatabase;
89 let collection = storage.get_or_create_collection(&name).await.unwrap();
90
91 // Write multiple keys.
92 let mut tx = storage.begin_transaction().unwrap();
93 let mut d1: &[u8] = b"one";
94 let mut d2: &[u8] = b"two";
95 collection.write(&mut tx, "key1", &mut d1).await.unwrap();
96 collection.write(&mut tx, "key2", &mut d2).await.unwrap();
97 tx.commit().await.unwrap();
98
99 // Delete all.
100 let mut tx = storage.begin_transaction().unwrap();
101 collection.delete_all(&mut tx).await.unwrap();
102 tx.commit().await.unwrap();
103
104 let is_empty = collection.is_empty().await.unwrap();
105 assert!(is_empty);
106}
107
108// Transaction semantics
109#[tokio::test]
110async fn rollback_discards_writes() {
111 let (storage, _dir) = create_test_storage();
112 let name = CollectionName::DiagnosticDatabase;
113 let collection = storage.get_or_create_collection(&name).await.unwrap();
114
115 let mut tx = storage.begin_transaction().unwrap();
116 let mut data: &[u8] = b"should not persist";
117 collection
118 .write(&mut tx, "ephemeral", &mut data)
119 .await
120 .unwrap();
121 tx.rollback();
122
123 let result = collection.read("ephemeral").await;
124 assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
125}
126
127#[tokio::test]
128async fn drop_without_commit_is_implicit_rollback() {
129 let (storage, _dir) = create_test_storage();
130 let name = CollectionName::DiagnosticDatabase;
131 let collection = storage.get_or_create_collection(&name).await.unwrap();
132
133 {
134 let mut tx = storage.begin_transaction().unwrap();
135 let mut data: &[u8] = b"dropped";
136 collection.write(&mut tx, "ghost", &mut data).await.unwrap();
137 // tx is dropped here without commit.
138 }
139
140 let result = collection.read("ghost").await;
141 assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
142}
143
144#[tokio::test]
145async fn no_read_your_writes() {
146 let (storage, _dir) = create_test_storage();
147 let name = CollectionName::DiagnosticDatabase;
148 let collection = storage.get_or_create_collection(&name).await.unwrap();
149
150 let mut tx = storage.begin_transaction().unwrap();
151 let mut data: &[u8] = b"uncommitted";
152 collection
153 .write(&mut tx, "pending", &mut data)
154 .await
155 .unwrap();
156
157 // Read should not see the uncommitted write.
158 let result = collection.read("pending").await;
159 assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
160
161 tx.commit().await.unwrap();
162
163 // Now it should be visible.
164 let handle = collection.read("pending").await.unwrap();
165 assert_eq!(handle.data_size().unwrap(), 11);
166}
167
168#[tokio::test]
169async fn single_transaction_enforcement() {
170 let (storage, _dir) = create_test_storage();
171
172 let _tx = storage.begin_transaction().unwrap();
173 let result = storage.begin_transaction();
174 assert!(matches!(result, Err(StorageError::TransactionBusy)));
175}
176
177#[tokio::test]
178async fn can_begin_transaction_after_previous_commits() {
179 let (storage, _dir) = create_test_storage();
180
181 let tx = storage.begin_transaction().unwrap();
182 tx.commit().await.unwrap();
183
184 // Should be able to begin a new transaction now.
185 let tx2 = storage.begin_transaction().unwrap();
186 tx2.commit().await.unwrap();
187}
188
189#[tokio::test]
190async fn can_begin_transaction_after_previous_rollback() {
191 let (storage, _dir) = create_test_storage();
192
193 let tx = storage.begin_transaction().unwrap();
194 tx.rollback();
195
196 let tx2 = storage.begin_transaction().unwrap();
197 tx2.commit().await.unwrap();
198}
199
200#[tokio::test]
201async fn case_insensitive_keys() {
202 let (storage, _dir) = create_test_storage();
203 let name = CollectionName::DiagnosticDatabase;
204 let collection = storage.get_or_create_collection(&name).await.unwrap();
205
206 let mut tx = storage.begin_transaction().unwrap();
207 let mut data: &[u8] = b"case test";
208 collection
209 .write(&mut tx, "FooBar", &mut data)
210 .await
211 .unwrap();
212 tx.commit().await.unwrap();
213
214 // Reading with different cases should find the same entry.
215 let handle = collection.read("foobar").await.unwrap();
216 assert_eq!(handle.data_size().unwrap(), 9);
217
218 let handle = collection.read("FOOBAR").await.unwrap();
219 assert_eq!(handle.data_size().unwrap(), 9);
220}
221
222// Collection management
223#[tokio::test]
224async fn get_nonexistent_collection_returns_not_found() {
225 let (storage, _dir) = create_test_storage();
226 let name = CollectionName::Custom("nonexistent".to_string());
227 let result = storage.get_collection(&name).await;
228 assert!(matches!(result, Err(StorageError::CollectionNotFound(_))));
229}
230
231#[tokio::test]
232async fn create_and_delete_collection() {
233 let (storage, _dir) = create_test_storage();
234 let name = CollectionName::Custom("temp_collection".to_string());
235
236 let mut tx = storage.begin_transaction().unwrap();
237 let collection = storage.create_collection(&mut tx, &name).await.unwrap();
238
239 // Write data into the new collection (before commit, the dir doesn't exist yet for reads
240 // but the write goes to staging).
241 let mut data: &[u8] = b"in new collection";
242 collection.write(&mut tx, "item", &mut data).await.unwrap();
243 tx.commit().await.unwrap();
244
245 // Should be readable now. Drop the handle before the next transaction to avoid deadlock.
246 let collection = storage.get_collection(&name).await.unwrap();
247 {
248 let handle = collection.read("item").await.unwrap();
249 assert_eq!(handle.data_size().unwrap(), 17);
250 }
251
252 // Delete the collection.
253 let mut tx = storage.begin_transaction().unwrap();
254 storage.delete_collection(&mut tx, &name).await.unwrap();
255 tx.commit().await.unwrap();
256
257 let result = storage.get_collection(&name).await;
258 assert!(matches!(result, Err(StorageError::CollectionNotFound(_))));
259}
260
261#[tokio::test]
262async fn copy_collection() {
263 let (storage, _dir) = create_test_storage();
264 let source = CollectionName::DiagnosticDatabase;
265 let dest = CollectionName::DiagnosticDatabaseBackup;
266
267 // Write some data to source.
268 let source_col = storage.get_or_create_collection(&source).await.unwrap();
269 let mut tx = storage.begin_transaction().unwrap();
270 let mut d1: &[u8] = b"alpha";
271 let mut d2: &[u8] = b"beta";
272 source_col.write(&mut tx, "a", &mut d1).await.unwrap();
273 source_col.write(&mut tx, "b", &mut d2).await.unwrap();
274 tx.commit().await.unwrap();
275
276 // Copy source to dest.
277 let mut tx = storage.begin_transaction().unwrap();
278 storage
279 .copy_collection(&mut tx, &source, &dest)
280 .await
281 .unwrap();
282 tx.commit().await.unwrap();
283
284 // Verify dest has the same data.
285 let dest_col = storage.get_collection(&dest).await.unwrap();
286 let handle_a = dest_col.read("a").await.unwrap();
287 let mut buf_a = vec![0u8; 5];
288 handle_a.read_at(0, &mut buf_a).unwrap();
289 assert_eq!(&buf_a, b"alpha");
290
291 let handle_b = dest_col.read("b").await.unwrap();
292 let mut buf_b = vec![0u8; 4];
293 handle_b.read_at(0, &mut buf_b).unwrap();
294 assert_eq!(&buf_b, b"beta");
295}
296
297#[tokio::test]
298async fn copy_collection_replaces_existing_dest() {
299 let (storage, _dir) = create_test_storage();
300 let source = CollectionName::DiagnosticDatabase;
301 let dest = CollectionName::DiagnosticDatabaseBackup;
302
303 // Write data to source.
304 let source_col = storage.get_or_create_collection(&source).await.unwrap();
305 let mut tx = storage.begin_transaction().unwrap();
306 let mut d1: &[u8] = b"alpha";
307 source_col.write(&mut tx, "a", &mut d1).await.unwrap();
308 tx.commit().await.unwrap();
309
310 // Write different data to dest (pre-existing content that should be replaced).
311 let dest_col = storage.get_or_create_collection(&dest).await.unwrap();
312 let mut tx = storage.begin_transaction().unwrap();
313 let mut d_old: &[u8] = b"old_stuff";
314 let mut d_extra: &[u8] = b"extra_file";
315 dest_col.write(&mut tx, "a", &mut d_old).await.unwrap();
316 dest_col
317 .write(&mut tx, "extra", &mut d_extra)
318 .await
319 .unwrap();
320 tx.commit().await.unwrap();
321
322 // Copy source -> dest. Dest should be fully replaced.
323 let mut tx = storage.begin_transaction().unwrap();
324 storage
325 .copy_collection(&mut tx, &source, &dest)
326 .await
327 .unwrap();
328 tx.commit().await.unwrap();
329
330 // Verify dest has exactly source's contents (key "a" with "alpha"), not the old data.
331 let dest_col = storage.get_collection(&dest).await.unwrap();
332 let handle_a = dest_col.read("a").await.unwrap();
333 let mut buf = vec![0u8; 5];
334 handle_a.read_at(0, &mut buf).unwrap();
335 assert_eq!(&buf, b"alpha");
336
337 // The "extra" key that was only in dest should be gone (replaced, not merged).
338 let result = dest_col.read("extra").await;
339 assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
340}
341
342// Metadata, list, len
343#[tokio::test]
344async fn metadata_returns_correct_size() {
345 let (storage, _dir) = create_test_storage();
346 let name = CollectionName::DiagnosticDatabase;
347 let collection = storage.get_or_create_collection(&name).await.unwrap();
348
349 let mut tx = storage.begin_transaction().unwrap();
350 let mut data: &[u8] = b"twelve chars";
351 collection.write(&mut tx, "sized", &mut data).await.unwrap();
352 tx.commit().await.unwrap();
353
354 let meta = collection.metadata("sized").await.unwrap();
355 assert_eq!(meta.name, "sized");
356 assert_eq!(meta.data_size, 12);
357}
358
359#[tokio::test]
360async fn list_and_len_reflect_committed_state() {
361 let (storage, _dir) = create_test_storage();
362 let name = CollectionName::DiagnosticDatabase;
363 let collection = storage.get_or_create_collection(&name).await.unwrap();
364
365 assert!(collection.is_empty().await.unwrap());
366 assert_eq!(collection.len().await.unwrap(), 0);
367
368 let mut tx = storage.begin_transaction().unwrap();
369 let mut d1: &[u8] = b"x";
370 let mut d2: &[u8] = b"y";
371 let mut d3: &[u8] = b"z";
372 collection.write(&mut tx, "one", &mut d1).await.unwrap();
373 collection.write(&mut tx, "two", &mut d2).await.unwrap();
374 collection.write(&mut tx, "three", &mut d3).await.unwrap();
375 tx.commit().await.unwrap();
376
377 assert_eq!(collection.len().await.unwrap(), 3);
378 assert!(!collection.is_empty().await.unwrap());
379
380 let mut keys = collection.list().await.unwrap();
381 keys.sort();
382 assert_eq!(keys, vec!["one", "three", "two"]);
383}
384
385// Random access data
386#[tokio::test]
387async fn random_access_read_at_offset() {
388 let (storage, _dir) = create_test_storage();
389 let name = CollectionName::DiagnosticDatabase;
390 let collection = storage.get_or_create_collection(&name).await.unwrap();
391
392 let mut tx = storage.begin_transaction().unwrap();
393 let mut data: &[u8] = b"0123456789ABCDEF";
394 collection.write(&mut tx, "hex", &mut data).await.unwrap();
395 tx.commit().await.unwrap();
396
397 let handle = collection.read("hex").await.unwrap();
398 assert_eq!(handle.data_size().unwrap(), 16);
399
400 // Read from offset 10.
401 let mut buf = vec![0u8; 6];
402 let n = handle.read_at(10, &mut buf).unwrap();
403 assert_eq!(n, 6);
404 assert_eq!(&buf, b"ABCDEF");
405
406 // Read from offset 14..only 2 bytes remaining.
407 let mut buf = vec![0u8; 6];
408 let n = handle.read_at(14, &mut buf).unwrap();
409 assert_eq!(n, 2);
410 assert_eq!(buf.get(..2).unwrap(), b"EF");
411}
412
413// Recovery tests
414
415/// Simulates a crash during the recording phase: a WAL file exists with `RECORDING` status and
416/// entries, and orphaned staging files are present, but nothing was ever applied to the
417/// collections directory. Recovery must discard the WAL and staging files and leave the
418/// collection untouched, since the transaction never reached the commit phase.
[docs]419/// [[ test~storage-atomicity-recovery-discards-recording-phase-crash, Recovery discards an incomplete transaction that crashed while still recording, test ]]
420#[tokio::test]
421async fn recovery_cleans_up_incomplete_transaction() {
422 let dir = tempfile::tempdir().unwrap();
423 let root = dir.path();
424 let collections_dir = root.join("collections");
425 let journal_dir = root.join("journal");
426 let staging_dir = journal_dir.join("staging");
427 std::fs::create_dir_all(&collections_dir).unwrap();
428 std::fs::create_dir_all(&staging_dir).unwrap();
429
430 // Create a WAL with RECORDING header and a single write operation.
431 let wal_path = journal_dir.join("transaction.wal");
432 cda_storage::wal::create_wal(&wal_path).unwrap();
433
434 let staged = staging_dir.join("some_file.tmp");
435 std::fs::write(&staged, b"orphaned data").unwrap();
436 let staged_str = staged
437 .to_str()
438 .expect("Failed to convert staging path to string")
439 .to_string();
440
441 cda_storage::wal::append_operation(
442 &wal_path,
443 &cda_interfaces::storage_api::Operation::Write {
444 collection: CollectionName::DiagnosticDatabase,
445 key: "test".to_string(),
446 staged_path: staged_str,
447 },
448 )
449 .unwrap();
450
451 // Creating a new LocalStorage should trigger recovery.
452 let storage = LocalStorage::new(root).unwrap();
453
454 // The WAL and staging file should be cleaned up.
455 assert!(!wal_path.exists());
456 assert!(!staged.exists());
457
458 // And the collection should not have the key.
459 let collection = storage
460 .get_or_create_collection(&CollectionName::DiagnosticDatabase)
461 .await
462 .unwrap();
463 let result = collection.read("test").await;
464 assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
465}
466
467/// Simulates a crash during the commit phase where an existing key was being overwritten: the
468/// WAL has `COMMITTING` status and a `.bak` file exists alongside the partially-written new
469/// file. Recovery must restore the original data from the `.bak` file, upholding the
470/// all-or-nothing guarantee for `Write` operations that overwrite existing keys.
[docs]471/// [[ test~storage-atomicity-recovery-restores-overwritten-file, Recovery restores the original file from its backup after an interrupted overwrite, test ]]
472#[tokio::test]
473async fn recovery_rolls_back_partial_commit_with_bak_files() {
474 let dir = tempfile::tempdir().unwrap();
475 let root = dir.path();
476 let collections_dir = root.join("collections");
477 let journal_dir = root.join("journal");
478 let staging_dir = journal_dir.join("staging");
479 let db_dir = collections_dir.join("diagnostic_database");
480 std::fs::create_dir_all(&db_dir).unwrap();
481 std::fs::create_dir_all(&staging_dir).unwrap();
482
483 // Simulate: original file was backed up, new file was partially written.
484 std::fs::write(db_dir.join("mykey.bak"), b"original data").unwrap();
485 std::fs::write(db_dir.join("mykey"), b"new data").unwrap();
486
487 // Create a WAL with COMMITTING status.
488 let wal_path = journal_dir.join("transaction.wal");
489 cda_storage::wal::create_wal(&wal_path).unwrap();
490 cda_storage::wal::append_operation(
491 &wal_path,
492 &cda_interfaces::storage_api::Operation::Write {
493 collection: CollectionName::DiagnosticDatabase,
494 key: "mykey".to_string(),
495 staged_path: "/tmp/does_not_matter.tmp".to_string(),
496 },
497 )
498 .unwrap();
499 cda_storage::wal::mark_committing(&wal_path).unwrap();
500
501 // Recovery should detect COMMITTING + .bak files and restore them.
502 let storage = LocalStorage::new(root).unwrap();
503
504 let collection = storage
505 .get_collection(&CollectionName::DiagnosticDatabase)
506 .await
507 .unwrap();
508 let handle = collection.read("mykey").await.unwrap();
509 let mut buf = vec![0u8; 13];
510 let n = handle.read_at(0, &mut buf).unwrap();
511 assert_eq!(n, 13);
512 assert_eq!(&buf, b"original data");
513}
514
515/// Whgen a crash happens during commit where only NEW files were created (no overwrites, so no `.bak` files exist).
516/// Recovery must still detect the partial commit via the `COMMITTING` WAL status and remove the newly created files,
517/// since a `Write` operation that introduces a brand-new key must be all-or-nothing just like an overwrite.
[docs]518/// [[ test~storage-atomicity-recovery-removes-new-file, Recovery removes a newly-written file left by an interrupted commit with no backup to restore, test ]]
519#[tokio::test]
520async fn recovery_rolls_back_new_file_writes_without_bak() {
521 let dir = tempfile::tempdir().unwrap();
522 let root = dir.path();
523 let collections_dir = root.join("collections");
524 let journal_dir = root.join("journal");
525 let staging_dir = journal_dir.join("staging");
526 let db_dir = collections_dir.join("diagnostic_database");
527 std::fs::create_dir_all(&db_dir).unwrap();
528 std::fs::create_dir_all(&staging_dir).unwrap();
529
530 // Simulate: a new file was written into the collection during a partially applied commit.
531 // No .bak file exists because there was nothing to overwrite.
532 std::fs::write(db_dir.join("new_key"), b"partially committed data").unwrap();
533
534 // Create a WAL with COMMITTING status containing the write operation.
535 let wal_path = journal_dir.join("transaction.wal");
536 cda_storage::wal::create_wal(&wal_path).unwrap();
537 cda_storage::wal::append_operation(
538 &wal_path,
539 &cda_interfaces::storage_api::Operation::Write {
540 collection: CollectionName::DiagnosticDatabase,
541 key: "new_key".to_string(),
542 staged_path: "/tmp/does_not_matter.tmp".to_string(),
543 },
544 )
545 .unwrap();
546 cda_storage::wal::mark_committing(&wal_path).unwrap();
547
548 // Recovery should detect COMMITTING, read the WAL, and remove the new file.
549 let storage = LocalStorage::new(root).unwrap();
550
551 let collection = storage
552 .get_collection(&CollectionName::DiagnosticDatabase)
553 .await
554 .unwrap();
555 let result = collection.read("new_key").await;
556 assert!(matches!(result, Err(StorageError::KeyNotFound(_))));
557}
558
559/// Simulates a crash during commit of a `CreateCollection` operation, where no backup can exist
560/// because the collection is entirely new. Recovery must remove the empty collection directory
561/// so that a partially-applied `CreateCollection` never leaves a visible trace behind.
[docs]562/// [[ test~storage-atomicity-recovery-removes-new-collection, Recovery removes an empty collection directory left by an interrupted `CreateCollection` commit, test ]]
563#[tokio::test]
564async fn recovery_rolls_back_new_collection_without_bak() {
565 let dir = tempfile::tempdir().unwrap();
566 let root = dir.path();
567 let collections_dir = root.join("collections");
568 let journal_dir = root.join("journal");
569 let staging_dir = journal_dir.join("staging");
570 std::fs::create_dir_all(&collections_dir).unwrap();
571 std::fs::create_dir_all(&staging_dir).unwrap();
572
573 // Simulate: a new empty collection directory was created during partial commit.
574 let new_col_dir = collections_dir.join("brand_new");
575 std::fs::create_dir_all(&new_col_dir).unwrap();
576
577 // Create a WAL with COMMITTING status.
578 let wal_path = journal_dir.join("transaction.wal");
579 cda_storage::wal::create_wal(&wal_path).unwrap();
580 cda_storage::wal::append_operation(
581 &wal_path,
582 &cda_interfaces::storage_api::Operation::CreateCollection {
583 name: CollectionName::Custom("brand_new".to_string()),
584 },
585 )
586 .unwrap();
587 cda_storage::wal::mark_committing(&wal_path).unwrap();
588
589 // Recovery should remove the newly created collection directory.
590 let _storage = LocalStorage::new(root).unwrap();
591
592 assert!(!new_col_dir.exists());
593}
594
595/// Simulates a case where a commit succeeded (the WAL was deleted, marking the point of no
596/// return) but a subsequent crash interrupted the best-effort `.bak` cleanup step. Recovery must
597/// treat the absence of a WAL as "already committed" and simply delete the orphaned backups,
598/// keeping the already-committed data intact.
[docs]599/// [[ test~storage-atomicity-recovery-cleans-orphaned-backups, Recovery cleans up orphaned backup files left after a successful commit, test ]]
600#[tokio::test]
601async fn recovery_handles_no_wal_with_orphaned_bak_files() {
602 let dir = tempfile::tempdir().unwrap();
603 let root = dir.path();
604 let collections_dir = root.join("collections");
605 let journal_dir = root.join("journal");
606 let staging_dir = journal_dir.join("staging");
607 let db_dir = collections_dir.join("diagnostic_database");
608 std::fs::create_dir_all(&db_dir).unwrap();
609 std::fs::create_dir_all(&staging_dir).unwrap();
610
611 // The new committed data is in place, but old .bak files remain.
612 std::fs::write(db_dir.join("mykey"), b"new committed data").unwrap();
613 std::fs::write(db_dir.join("mykey.bak"), b"old data").unwrap();
614
615 // No WAL file exists (it was successfully deleted as point of no return).
616 let wal_path = journal_dir.join("transaction.wal");
617 assert!(!wal_path.exists());
618
619 // Recovery should delete the orphaned .bak and keep the committed data.
620 let storage = LocalStorage::new(root).unwrap();
621
622 assert!(!db_dir.join("mykey.bak").exists());
623 let collection = storage
624 .get_collection(&CollectionName::DiagnosticDatabase)
625 .await
626 .unwrap();
627 let handle = collection.read("mykey").await.unwrap();
628 let mut buf = vec![0u8; 18];
629 let n = handle.read_at(0, &mut buf).unwrap();
630 assert_eq!(n, 18);
631 assert_eq!(&buf, b"new committed data");
632}
633
634/// Creates a WAL with a valid entry followed by corrupted (torn-write) bytes. Recovery must
635/// stop reading at the first invalid entry and discard the whole transaction, since a
636/// `RECORDING`-status WAL means nothing was ever applied to collections - so a corrupt tail is
637/// safe to ignore rather than treated as unrecoverable filesystem corruption.
[docs]638/// [[ test~storage-atomicity-recovery-discards-corrupt-wal, Recovery discards a WAL with a corrupt checksum during the recording phase, test ]]
639#[tokio::test]
640async fn recovery_discards_wal_with_corrupt_checksum() {
641 let dir = tempfile::tempdir().unwrap();
642 let root = dir.path();
643 let collections_dir = root.join("collections");
644 let journal_dir = root.join("journal");
645 let staging_dir = journal_dir.join("staging");
646 std::fs::create_dir_all(&collections_dir).unwrap();
647 std::fs::create_dir_all(&staging_dir).unwrap();
648
649 let wal_path = journal_dir.join("transaction.wal");
650 cda_storage::wal::create_wal(&wal_path).unwrap();
651
652 // Append a valid entry.
653 cda_storage::wal::append_operation(
654 &wal_path,
655 &cda_interfaces::storage_api::Operation::CreateCollection {
656 name: CollectionName::Custom("valid_collection".to_string()),
657 },
658 )
659 .unwrap();
660
661 // Append garbage bytes to simulate a torn write (corrupt checksum).
662 let mut file = std::fs::OpenOptions::new()
663 .append(true)
664 .open(&wal_path)
665 .unwrap();
666 file.write_all(&[0xFF; 20]).unwrap();
667
668 // Recovery should succeed, the corrupt entry is simply ignored.
669 let _storage = LocalStorage::new(root).unwrap();
670
671 // The WAL should be cleaned up.
672 assert!(!wal_path.exists());
673}
674
675#[tokio::test]
676async fn overwrite_existing_key() {
677 let (storage, _dir) = create_test_storage();
678 let name = CollectionName::DiagnosticDatabase;
679 let collection = storage.get_or_create_collection(&name).await.unwrap();
680
681 // Write initial value.
682 let mut tx = storage.begin_transaction().unwrap();
683 let mut data: &[u8] = b"version 1";
684 collection
685 .write(&mut tx, "config", &mut data)
686 .await
687 .unwrap();
688 tx.commit().await.unwrap();
689
690 // Overwrite with new value.
691 let mut tx = storage.begin_transaction().unwrap();
692 let mut data: &[u8] = b"version 2";
693 collection
694 .write(&mut tx, "config", &mut data)
695 .await
696 .unwrap();
697 tx.commit().await.unwrap();
698
699 let handle = collection.read("config").await.unwrap();
700 let mut buf = vec![0u8; 9];
701 handle.read_at(0, &mut buf).unwrap();
702 assert_eq!(&buf, b"version 2");
703}
704
705/// Verifies the WAL on-disk format at the byte level: appends a mix of `CreateCollection`,
706/// `Write`, and `Delete` operations, then reads them back and checks that each operation is
707/// decoded correctly, in order, with its checksum intact and `truncated` reported as `false`.
708/// This underpins the crash-recovery guarantees, which depend on being able to faithfully replay
709/// exactly the operations that were durably recorded before a crash.
[docs]710/// [[ test~storage-wal-checksum-round-trip, WAL entries round-trip through checksum-verified encode/decode, test ]]
711// WAL checksum round-trip
712#[tokio::test]
713async fn wal_round_trip_with_checksum_verification() {
714 let dir = tempfile::tempdir().unwrap();
715 let wal_path = dir.path().join("test.wal");
716 cda_storage::wal::create_wal(&wal_path).unwrap();
717
718 let ops = vec![
719 cda_interfaces::storage_api::Operation::CreateCollection {
720 name: CollectionName::Custom("col_a".to_string()),
721 },
722 cda_interfaces::storage_api::Operation::Write {
723 collection: CollectionName::DiagnosticDatabase,
724 key: "my_key".to_string(),
725 staged_path: "/tmp/fake.tmp".to_string(),
726 },
727 cda_interfaces::storage_api::Operation::Delete {
728 collection: CollectionName::DiagnosticDatabase,
729 key: "old_key".to_string(),
730 },
731 ];
732
733 for op in &ops {
734 cda_storage::wal::append_operation(&wal_path, op).unwrap();
735 }
736
737 let wal_data = std::fs::read(&wal_path).unwrap();
738 let wal_result = cda_storage::wal::read_wal(&wal_data).unwrap();
739 assert!(!wal_result.truncated);
740 assert_eq!(wal_result.operations.len(), 3);
741
742 // Verify the operations match using .get() to satisfy clippy::indexing_slicing.
743 let read_op_0 = wal_result
744 .operations
745 .first()
746 .expect("Expected 3 operations");
747 assert!(matches!(
748 read_op_0,
749 cda_interfaces::storage_api::Operation::CreateCollection { name }
750 if name.as_str() == "col_a"
751 ));
752 let read_op_1 = wal_result.operations.get(1).expect("Expected 3 operations");
753 assert!(matches!(
754 read_op_1,
755 cda_interfaces::storage_api::Operation::Write { key, .. }
756 if key == "my_key"
757 ));
758 let read_op_2 = wal_result.operations.get(2).expect("Expected 3 operations");
759 assert!(matches!(
760 read_op_2,
761 cda_interfaces::storage_api::Operation::Delete { key, .. }
762 if key == "old_key"
763 ));
764}
765
766/// Writes two valid WAL entries, then truncates the file mid-way through the second entry to
767/// simulate a torn write. The reader must return exactly the first (fully valid) operation and
768/// report `truncated: true`, rather than erroring out or fabricating data for the incomplete
769/// entry. This is the detection mechanism that lets recovery safely distinguish "crash mid-write
770/// to the WAL itself" from genuine filesystem corruption.
[docs]771/// [[ test~storage-wal-truncation-detection, WAL reader detects and stops at a truncated entry, test ]]
772#[tokio::test]
773async fn wal_stops_at_truncated_entry() {
774 let dir = tempfile::tempdir().unwrap();
775 let wal_path = dir.path().join("test.wal");
776 cda_storage::wal::create_wal(&wal_path).unwrap();
777
778 // Write two valid entries.
779 cda_storage::wal::append_operation(
780 &wal_path,
781 &cda_interfaces::storage_api::Operation::CreateCollection {
782 name: CollectionName::Custom("first".to_string()),
783 },
784 )
785 .unwrap();
786 cda_storage::wal::append_operation(
787 &wal_path,
788 &cda_interfaces::storage_api::Operation::CreateCollection {
789 name: CollectionName::Custom("second".to_string()),
790 },
791 )
792 .unwrap();
793
794 // Truncate the file to corrupt the second entry (keep first + partial second).
795 let data = std::fs::read(&wal_path).unwrap();
796 // Write only 80% of the data to truncate the second entry.
797 let truncated_len = data.len() * 4 / 5;
798 std::fs::write(&wal_path, data.get(..truncated_len).unwrap()).unwrap();
799
800 let wal_data = std::fs::read(&wal_path).unwrap();
801 let wal_result = cda_storage::wal::read_wal(&wal_data).unwrap();
802 assert!(wal_result.truncated);
803 // Should only have the first valid entry.
804 assert_eq!(wal_result.operations.len(), 1);
805 let read_op_0 = wal_result.operations.first().expect("Expected 1 operation");
806 assert!(matches!(
807 read_op_0,
808 cda_interfaces::storage_api::Operation::CreateCollection { name }
809 if name.as_str() == "first"
810 ));
811}
812
813/// Simulates a crash during commit of a multi-operation transaction where a `CreateCollection`
814/// and a `Write` into that same new collection were both partially applied (the directory and
815/// its file both exist on disk, with the WAL in `COMMITTING` status). Since the collection did
816/// not exist before this transaction, recovery must roll back the *entire* transaction as a
817/// unit, removing both the written file and the now-empty collection directory - demonstrating
818/// that atomicity applies across all staged operations in an execution, not just individually.
[docs]819/// [[ 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 ]]
820#[tokio::test]
821async fn recovery_create_collection_with_write_removes_orphaned_dir() {
822 let dir = tempfile::tempdir().unwrap();
823 let root = dir.path();
824 let collections_dir = root.join("collections");
825 let journal_dir = root.join("journal");
826 let staging_dir = journal_dir.join("staging");
827 std::fs::create_dir_all(&collections_dir).unwrap();
828 std::fs::create_dir_all(&staging_dir).unwrap();
829
830 // Simulate partial commit state: the collection directory was created AND a file was
831 // written into it before the crash.
832 let new_col_dir = collections_dir.join("fresh_collection");
833 std::fs::create_dir_all(&new_col_dir).unwrap();
834 std::fs::write(new_col_dir.join("data_file"), b"written during commit").unwrap();
835
836 // WAL with COMMITTING status containing both operations in natural order:
837 // CreateCollection first, then Write to that collection.
838 let wal_path = journal_dir.join("transaction.wal");
839 cda_storage::wal::create_wal(&wal_path).unwrap();
840 cda_storage::wal::append_operation(
841 &wal_path,
842 &cda_interfaces::storage_api::Operation::CreateCollection {
843 name: CollectionName::Custom("fresh_collection".to_string()),
844 },
845 )
846 .unwrap();
847 cda_storage::wal::append_operation(
848 &wal_path,
849 &cda_interfaces::storage_api::Operation::Write {
850 collection: CollectionName::Custom("fresh_collection".to_string()),
851 key: "data_file".to_string(),
852 staged_path: "/tmp/irrelevant.tmp".to_string(),
853 },
854 )
855 .unwrap();
856 cda_storage::wal::mark_committing(&wal_path).unwrap();
857
858 // Recovery should fully roll back - the collection did not exist before this transaction.
859 let _storage = LocalStorage::new(root).unwrap();
860
861 // The collection directory must be completely gone after recovery.
862 assert!(
863 !new_col_dir.exists(),
864 "Orphaned empty collection directory was left behind after recovery"
865 );
866}
867
868/// A failed collection swap can leave a COMMITTING WAL where a pre-existing destination was
869/// never renamed to its backup (for example, when `rename` returns EXDEV). Recovery must retain
870/// that untouched current collection rather than treating it as an artifact of the failed copy.
871#[tokio::test]
872async fn recovery_preserves_untouched_existing_copy_destination() {
873 let dir = tempfile::tempdir().unwrap();
874 let root = dir.path();
875 let collections_dir = root.join("collections");
876 let journal_dir = root.join("journal");
877 let staging_dir = journal_dir.join("staging");
878 let current_dir = collections_dir.join("diagnostic_database");
879 std::fs::create_dir_all(¤t_dir).unwrap();
880 std::fs::create_dir_all(&staging_dir).unwrap();
881 std::fs::write(current_dir.join("ecu1.mdd"), b"current data").unwrap();
882
883 let wal_path = journal_dir.join("transaction.wal");
884 cda_storage::wal::create_wal(&wal_path).unwrap();
885 cda_storage::wal::append_operation(
886 &wal_path,
887 &cda_interfaces::storage_api::Operation::CopyCollection {
888 source: CollectionName::DiagnosticDatabaseNextUpdate,
889 dest: CollectionName::DiagnosticDatabase,
890 dest_existed: true,
891 },
892 )
893 .unwrap();
894 cda_storage::wal::mark_committing(&wal_path).unwrap();
895
896 let storage = LocalStorage::new(root).unwrap();
897 let current = storage
898 .get_collection(&CollectionName::DiagnosticDatabase)
899 .await
900 .unwrap();
901 let handle = current.read("ecu1.mdd").await.unwrap();
902 let mut data = vec![0; "current data".len()];
903 handle.read_at(0, &mut data).unwrap();
904 assert_eq!(data, b"current data");
905}
906
907/// Regression test for a bug where `list()` returned a file's on-disk (possibly mixed-case)
908/// name verbatim, while `metadata()`/`read()`/`file_path()` normalize keys to lowercase before
909/// resolving them to a path. On a case-sensitive filesystem this mismatch caused every
910/// mixed-case file to appear in `list()` but then fail with `KeyNotFound` as soon as any
911/// per-key lookup (`metadata`, `read`, `file_path`) was attempted - exactly the "key vanished
912/// during iteration" symptom, even with no concurrent writer at all.
913///
914/// Simulates a file that ended up on disk with a mixed-case name outside of `Collection::write`
915/// (e.g. placed there during initial provisioning), then verifies that `list()`, `metadata()`,
916/// and `read()` are all consistent with each other.
917#[tokio::test]
918async fn list_normalizes_mixed_case_filenames_to_match_metadata_and_read() {
919 let (storage, dir) = create_test_storage();
920 let name = CollectionName::DiagnosticDatabase;
921
922 // Create the collection directory and place a mixed-case file directly on disk, bypassing
923 // `Collection::write` (which would have normalized the name to lowercase).
924 let collection_dir = dir.path().join("collections").join(name.as_str());
925 std::fs::create_dir_all(&collection_dir).unwrap();
926 std::fs::write(
927 collection_dir.join("FLXC1000_06.18.13.mdd"),
928 b"mixed case data",
929 )
930 .unwrap();
931
932 let collection = storage.get_or_create_collection(&name).await.unwrap();
933
934 // `list()` must return the normalized (lowercase) key, matching what `metadata`/`read`/
935 // `file_path` expect.
936 let keys = collection.list().await.unwrap();
937 assert_eq!(keys, vec!["flxc1000_06.18.13.mdd".to_string()]);
938
939 // Every key returned by `list()` must be resolvable via `metadata()` without a
940 // `KeyNotFound` error.
941 for key in &keys {
942 let meta = collection.metadata(key).await.unwrap();
943 assert_eq!(meta.data_size, "mixed case data".len() as u64);
944 }
945
946 // Every key returned by `list()` must also be resolvable via `read()`.
947 let Some(key) = keys.first() else {
948 panic!("expected one key");
949 };
950 let handle = collection.read(key).await.unwrap();
951 let mut buf = vec![0u8; "mixed case data".len()];
952 handle.read_at(0, &mut buf).unwrap();
953 assert_eq!(&buf, b"mixed case data");
954}