1use std::path::{Path, PathBuf};
4
5use thiserror::Error;
6use uuid::Uuid;
7
8pub const MAX_SCAN_PAGE_ENTRIES: usize = 4_096;
10
11use crate::log::transaction_digest;
12use crate::{
13 AppendOutcome, BackupError, BackupInfo, CommitReceipt, DataDirectory, DataDirectoryError,
14 DurableLog, LogError, MaterializedIndexError, Mutation, MutationError, RecoveredTransaction,
15 RecoveryReport, SnapshotError, SnapshotInfo,
16 index::MaterializedIndex,
17 manifest::StorageManifest,
18 mutation::validate_key,
19 snapshot::{create_snapshot, verify_snapshot},
20};
21
22#[derive(Debug, Error)]
24pub enum StorageError {
25 #[error(transparent)]
27 DataDirectory(#[from] DataDirectoryError),
28
29 #[error(transparent)]
31 Log(#[from] LogError),
32
33 #[error("materialized index failure: {source}")]
35 Index {
36 #[source]
38 source: Box<MaterializedIndexError>,
39 },
40
41 #[error(transparent)]
43 Mutation(#[from] MutationError),
44
45 #[error("transaction {receipt:?} is durable but not materialized; reopen to recover")]
47 CommittedButNotIndexed {
48 receipt: CommitReceipt,
50 #[source]
52 source: Box<MaterializedIndexError>,
53 },
54
55 #[error("materialized index is stale; reopen storage to replay the durable log")]
57 StaleIndex,
58
59 #[error("snapshot failure: {source}")]
61 Snapshot {
62 #[source]
64 source: Box<SnapshotError>,
65 },
66
67 #[error("storage manifest generation space is exhausted")]
69 ManifestGenerationExhausted,
70
71 #[error("prepared compaction segment is not empty: {path}")]
73 PreparedSegmentNotEmpty {
74 path: PathBuf,
76 },
77
78 #[error("scan page size {requested} is outside 1..={maximum}")]
80 InvalidScanLimit {
81 requested: usize,
83 maximum: usize,
85 },
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct StorageRecoveryReport {
91 pub log: RecoveryReport,
93 pub replayed_transactions: u64,
95}
96
97#[derive(Debug)]
99pub struct OpenedStorage {
100 pub storage: StorageEngine,
102 pub recovery: StorageRecoveryReport,
104}
105
106#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct CompactionReport {
109 pub generation: u64,
111 pub snapshot: SnapshotInfo,
113 pub retired_segment: PathBuf,
115 pub retired_segment_removed: bool,
117}
118
119#[derive(Clone, Debug, Eq, PartialEq)]
121pub struct KvEntry {
122 pub key: Vec<u8>,
124 pub value: Vec<u8>,
126}
127
128#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct KvPage {
131 pub entries: Vec<KvEntry>,
133 pub next_after: Option<Vec<u8>>,
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
139pub enum CompactionOutcome {
140 NoChanges {
142 snapshot: SnapshotInfo,
144 },
145 Compacted(CompactionReport),
147}
148
149#[derive(Debug)]
151pub struct StorageEngine {
152 log: DurableLog,
153 index: MaterializedIndex,
154 index_stale: bool,
155 directory: DataDirectory,
156}
157
158impl StorageEngine {
159 pub fn open(path: impl AsRef<Path>) -> Result<OpenedStorage, StorageError> {
166 let directory = DataDirectory::open(path)?;
167 let index_path = directory.path().join("indexes").join("primary.redb");
168 ensure_snapshot_base(&directory, &index_path)?;
169 let (base_sequence, base_digest) = directory.log_anchor();
170 let (log, log_recovery) =
171 DurableLog::open_file_at(directory.active_log_path(), base_sequence, base_digest)?;
172 let index = MaterializedIndex::open(index_path)?;
173 let replayed_transactions = index.replay(&log_recovery)?;
174 let _cleanup_complete = directory.cleanup_retired_logs();
175 let storage = Self {
176 log,
177 index,
178 index_stale: false,
179 directory,
180 };
181 Ok(OpenedStorage {
182 storage,
183 recovery: StorageRecoveryReport {
184 log: log_recovery,
185 replayed_transactions,
186 },
187 })
188 }
189
190 pub fn data_path(&self) -> &Path {
192 self.directory.path()
193 }
194
195 pub fn backup(&self, destination: impl AsRef<Path>) -> Result<BackupInfo, BackupError> {
203 crate::backup::create_backup(self, destination.as_ref())
204 }
205
206 pub fn write(
217 &mut self,
218 transaction_id: Uuid,
219 mutations: &[Mutation],
220 ) -> Result<AppendOutcome, StorageError> {
221 if self.index_stale {
222 return Err(StorageError::StaleIndex);
223 }
224 let operations = mutations
225 .iter()
226 .map(Mutation::encode)
227 .collect::<Result<Vec<_>, _>>()?;
228 let operation_count =
229 u32::try_from(operations.len()).map_err(|_| LogError::TooManyOperations)?;
230 let requested_digest = transaction_digest(&operations, operation_count)?;
231 if let Some(receipt) = self.index.receipt(transaction_id)? {
232 return if receipt.transaction_digest == requested_digest {
233 Ok(AppendOutcome::Existing(receipt))
234 } else {
235 Err(LogError::IdempotencyConflict { transaction_id }.into())
236 };
237 }
238 let outcome = match self.log.append_transaction(transaction_id, &operations) {
239 Ok(outcome) => outcome,
240 Err(source) => {
241 if self.log.is_poisoned() {
242 self.index_stale = true;
243 }
244 return Err(source.into());
245 }
246 };
247 let AppendOutcome::Committed(receipt) = outcome else {
248 return Ok(outcome);
249 };
250
251 let transaction = RecoveredTransaction {
252 receipt,
253 operations,
254 };
255 if let Err(source) = self.index.apply(&transaction) {
256 self.index_stale = true;
257 return Err(StorageError::CommittedButNotIndexed {
258 receipt,
259 source: Box::new(source),
260 });
261 }
262 Ok(outcome)
263 }
264
265 pub fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StorageError> {
272 if self.index_stale {
273 return Err(StorageError::StaleIndex);
274 }
275 validate_key(key)?;
276 Ok(self.index.get(key)?)
277 }
278
279 pub fn scan_page(&self, after: Option<&[u8]>, limit: usize) -> Result<KvPage, StorageError> {
289 if self.index_stale {
290 return Err(StorageError::StaleIndex);
291 }
292 if let Some(key) = after {
293 validate_key(key)?;
294 }
295 if limit == 0 || limit > MAX_SCAN_PAGE_ENTRIES {
296 return Err(StorageError::InvalidScanLimit {
297 requested: limit,
298 maximum: MAX_SCAN_PAGE_ENTRIES,
299 });
300 }
301 let mut raw = self.index.scan_after(after, limit.saturating_add(1))?;
302 let has_more = raw.len() > limit;
303 raw.truncate(limit);
304 let next_after = has_more
305 .then(|| raw.last().map(|(key, _)| key.clone()))
306 .flatten();
307 Ok(KvPage {
308 entries: raw
309 .into_iter()
310 .map(|(key, value)| KvEntry { key, value })
311 .collect(),
312 next_after,
313 })
314 }
315
316 pub fn index_path(&self) -> PathBuf {
318 self.directory.path().join("indexes").join("primary.redb")
319 }
320
321 pub fn snapshot(&self) -> Result<SnapshotInfo, StorageError> {
328 if self.index_stale {
329 return Err(StorageError::StaleIndex);
330 }
331 let snapshots = self.directory.path().join("snapshots");
332 let temporary = self.directory.path().join("tmp");
333 Ok(create_snapshot(&self.index, &snapshots, &temporary)?)
334 }
335
336 pub fn compact(&mut self) -> Result<CompactionOutcome, StorageError> {
347 if self.index_stale {
348 return Err(StorageError::StaleIndex);
349 }
350 let snapshot = self.snapshot()?;
351 let current = self.directory.manifest();
352 if snapshot.checkpoint_sequence == 0
353 || snapshot.checkpoint_sequence == current.base_sequence
354 {
355 return Ok(CompactionOutcome::NoChanges { snapshot });
356 }
357 let generation = current
358 .generation
359 .checked_add(1)
360 .ok_or(StorageError::ManifestGenerationExhausted)?;
361 let Some(base_digest) = snapshot.checkpoint_digest else {
362 return Err(SnapshotError::Invalid {
363 reason: "compaction snapshot lacks a checkpoint digest",
364 }
365 .into());
366 };
367 let next = StorageManifest {
368 generation,
369 active_segment: generation,
370 base_sequence: snapshot.checkpoint_sequence,
371 base_digest,
372 snapshot_digest: snapshot.snapshot_digest,
373 };
374 let next_segment = self.directory.log_path(generation);
375 let (next_log, prepared) =
376 DurableLog::open_file_at(&next_segment, next.base_sequence, next.base_digest)?;
377 if prepared.valid_bytes != 0 {
378 return Err(StorageError::PreparedSegmentNotEmpty { path: next_segment });
379 }
380
381 let retired_segment = self.directory.active_log_path();
382 self.directory.commit_manifest(next)?;
383 let retired_log = std::mem::replace(&mut self.log, next_log);
384 drop(retired_log);
385 let retired_segment_removed = remove_retired_segment(&retired_segment);
386 Ok(CompactionOutcome::Compacted(CompactionReport {
387 generation,
388 snapshot,
389 retired_segment,
390 retired_segment_removed,
391 }))
392 }
393}
394
395fn remove_retired_segment(path: &Path) -> bool {
396 match std::fs::remove_file(path) {
397 Ok(()) => {
398 #[cfg(unix)]
399 if let Some(parent) = path.parent()
400 && sync_directory(parent).is_err()
401 {
402 return false;
403 }
404 true
405 }
406 Err(source) if source.kind() == std::io::ErrorKind::NotFound => true,
407 Err(_) => false,
408 }
409}
410
411fn ensure_snapshot_base(directory: &DataDirectory, index_path: &Path) -> Result<(), StorageError> {
412 let manifest = directory.manifest();
413 if manifest.base_sequence == 0 {
414 return Ok(());
415 }
416 let snapshot_path = directory.snapshot_path(manifest.base_sequence);
417 let verified = verify_snapshot(&snapshot_path)?;
418 if verified.checkpoint_sequence != manifest.base_sequence
419 || verified.checkpoint_digest != Some(manifest.base_digest)
420 || verified.snapshot_digest != manifest.snapshot_digest
421 {
422 return Err(SnapshotError::Invalid {
423 reason: "snapshot does not match active storage manifest",
424 }
425 .into());
426 }
427 if index_path.exists() {
428 return Ok(());
429 }
430
431 let temporary_path = directory
432 .path()
433 .join("tmp")
434 .join(format!("index-restore-{}.redb.tmp", Uuid::now_v7()));
435 let restored = MaterializedIndex::restore_from_snapshot(&temporary_path, &snapshot_path)?;
436 if restored != verified {
437 return Err(SnapshotError::Invalid {
438 reason: "snapshot changed while rebuilding the materialized index",
439 }
440 .into());
441 }
442 std::fs::rename(&temporary_path, index_path)
443 .map_err(SnapshotError::from)
444 .map_err(StorageError::from)?;
445 #[cfg(unix)]
446 sync_directory(index_path.parent().ok_or(SnapshotError::Invalid {
447 reason: "materialized index path has no parent",
448 })?)?;
449 Ok(())
450}
451
452#[cfg(unix)]
453fn sync_directory(path: &Path) -> Result<(), StorageError> {
454 std::fs::File::open(path)
455 .and_then(|directory| directory.sync_all())
456 .map_err(SnapshotError::from)
457 .map_err(StorageError::from)
458}
459
460impl From<MaterializedIndexError> for StorageError {
461 fn from(source: MaterializedIndexError) -> Self {
462 Self::Index {
463 source: Box::new(source),
464 }
465 }
466}
467
468impl From<SnapshotError> for StorageError {
469 fn from(source: SnapshotError) -> Self {
470 Self::Snapshot {
471 source: Box::new(source),
472 }
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use std::{
479 error::Error,
480 fs::{self, OpenOptions},
481 io::{Seek, SeekFrom, Write},
482 };
483
484 use uuid::Uuid;
485
486 use super::{CompactionOutcome, DurableLog, StorageEngine, StorageError, StorageManifest};
487 use crate::{
488 AppendOutcome, DataDirectory, Mutation, SnapshotError, SnapshotReadLimits,
489 index::MaterializedIndex, load_snapshot, test_support::TestDirectory, verify_snapshot,
490 };
491
492 #[test]
493 fn atomic_batches_persist_and_delete() -> Result<(), Box<dyn Error>> {
494 let temporary = TestDirectory::new("storage-kv")?;
495 let root = temporary.path().join("data");
496 let mut opened = StorageEngine::open(&root)?;
497 opened.storage.write(
498 Uuid::now_v7(),
499 &[Mutation::put(b"a", b"one"), Mutation::put(b"b", b"two")],
500 )?;
501 assert_eq!(opened.storage.get(b"a")?, Some(b"one".to_vec()));
502 opened
503 .storage
504 .write(Uuid::now_v7(), &[Mutation::delete(b"a")])?;
505 drop(opened);
506
507 let reopened = StorageEngine::open(&root)?;
508 assert_eq!(reopened.storage.get(b"a")?, None);
509 assert_eq!(reopened.storage.get(b"b")?, Some(b"two".to_vec()));
510 assert_eq!(reopened.recovery.replayed_transactions, 0);
511 Ok(())
512 }
513
514 #[test]
515 fn exact_retry_does_not_reapply_a_batch() -> Result<(), Box<dyn Error>> {
516 let temporary = TestDirectory::new("storage-idempotency")?;
517 let transaction_id = Uuid::now_v7();
518 let mutations = [Mutation::put(b"key", b"value")];
519 let mut opened = StorageEngine::open(temporary.path())?;
520
521 let first = opened.storage.write(transaction_id, &mutations)?;
522 let second = opened.storage.write(transaction_id, &mutations)?;
523 assert!(matches!(first, AppendOutcome::Committed(_)));
524 assert!(matches!(second, AppendOutcome::Existing(_)));
525 assert_eq!(opened.storage.get(b"key")?, Some(b"value".to_vec()));
526
527 let conflict = opened
528 .storage
529 .write(transaction_id, &[Mutation::put(b"key", b"different")]);
530 assert!(matches!(
531 conflict,
532 Err(super::StorageError::Log(
533 crate::LogError::IdempotencyConflict { .. }
534 ))
535 ));
536 Ok(())
537 }
538
539 #[test]
540 fn reopen_replays_a_commit_missing_from_the_index() -> Result<(), Box<dyn Error>> {
541 let temporary = TestDirectory::new("storage-index-replay")?;
542 let root = temporary.path().join("data");
543 let directory = DataDirectory::open(&root)?;
544 let mutation = Mutation::put(b"recovered", b"yes");
545 let mut log = directory.open_log()?;
546 log.log
547 .append_transaction(Uuid::now_v7(), &[mutation.encode()?])?;
548 drop(log);
549 drop(directory);
550
551 let reopened = StorageEngine::open(&root)?;
552 assert_eq!(reopened.recovery.replayed_transactions, 1);
553 assert_eq!(reopened.storage.get(b"recovered")?, Some(b"yes".to_vec()));
554 Ok(())
555 }
556
557 #[test]
558 fn logical_snapshot_is_stable_and_detects_payload_corruption() -> Result<(), Box<dyn Error>> {
559 let temporary = TestDirectory::new("storage-snapshot")?;
560 let mut opened = StorageEngine::open(temporary.path().join("data"))?;
561 opened.storage.write(
562 Uuid::now_v7(),
563 &[
564 Mutation::put(b"beta", b"second"),
565 Mutation::put(b"alpha", b"first"),
566 ],
567 )?;
568
569 let created = opened.storage.snapshot()?;
570 assert_eq!(created.checkpoint_sequence, 4);
571 assert!(created.checkpoint_digest.is_some());
572 assert_eq!(created.entry_count, 2);
573 assert_eq!(created.receipt_count, 1);
574 assert_eq!(verify_snapshot(&created.path)?, created);
575 assert_eq!(opened.storage.snapshot()?, created);
576 let witness = load_snapshot(&created.path, &SnapshotReadLimits::default())?;
577 assert_eq!(witness.info, created);
578 assert_eq!(witness.entries.len(), 2);
579 assert_eq!(witness.entries[0].key, b"alpha");
580 assert_eq!(witness.entries[0].value, b"first");
581 assert!(matches!(
582 load_snapshot(
583 &created.path,
584 &SnapshotReadLimits {
585 entries: 1,
586 ..SnapshotReadLimits::default()
587 }
588 ),
589 Err(SnapshotError::EntryLimitExceeded {
590 actual: 2,
591 maximum: 1
592 })
593 ));
594
595 let corrupted_path = temporary.path().join("corrupted.hysnap");
596 fs::copy(&created.path, &corrupted_path)?;
597 let mut corrupted = OpenOptions::new()
598 .read(true)
599 .write(true)
600 .open(&corrupted_path)?;
601 corrupted.seek(SeekFrom::End(-1))?;
602 corrupted.write_all(&[0xff])?;
603 corrupted.sync_all()?;
604 drop(corrupted);
605
606 assert!(matches!(
607 verify_snapshot(&corrupted_path),
608 Err(SnapshotError::Invalid {
609 reason: "CRC32C mismatch"
610 })
611 ));
612 Ok(())
613 }
614
615 #[test]
616 fn empty_storage_has_a_canonical_empty_snapshot() -> Result<(), Box<dyn Error>> {
617 let temporary = TestDirectory::new("storage-empty-snapshot")?;
618 let opened = StorageEngine::open(temporary.path().join("data"))?;
619
620 let snapshot = opened.storage.snapshot()?;
621 assert_eq!(snapshot.checkpoint_sequence, 0);
622 assert_eq!(snapshot.checkpoint_digest, None);
623 assert_eq!(snapshot.entry_count, 0);
624 assert_eq!(snapshot.receipt_count, 0);
625 assert_eq!(snapshot.file_bytes, 112);
626 assert_eq!(verify_snapshot(&snapshot.path)?, snapshot);
627 Ok(())
628 }
629
630 #[test]
631 fn snapshot_rebuilds_kv_and_idempotency_state() -> Result<(), Box<dyn Error>> {
632 let temporary = TestDirectory::new("storage-snapshot-restore")?;
633 let root = temporary.path().join("data");
634 let transaction_id = Uuid::now_v7();
635 let mut opened = StorageEngine::open(&root)?;
636 let outcome = opened.storage.write(
637 transaction_id,
638 &[
639 Mutation::put(b"alpha", b"one"),
640 Mutation::put(b"beta", b"two"),
641 ],
642 )?;
643 let AppendOutcome::Committed(receipt) = outcome else {
644 return Err("new transaction was not committed".into());
645 };
646 let snapshot = opened.storage.snapshot()?;
647 drop(opened);
648
649 let restored_path = root.join("tmp/restored.redb");
650 assert_eq!(
651 MaterializedIndex::restore_from_snapshot(&restored_path, &snapshot.path)?,
652 snapshot
653 );
654 let restored = MaterializedIndex::open(&restored_path)?;
655 assert_eq!(restored.get(b"alpha")?, Some(b"one".to_vec()));
656 assert_eq!(restored.get(b"beta")?, Some(b"two".to_vec()));
657 assert_eq!(restored.receipt(transaction_id)?, Some(receipt));
658 Ok(())
659 }
660
661 #[test]
662 fn compaction_retires_history_and_snapshot_rebuilds_the_index() -> Result<(), Box<dyn Error>> {
663 let temporary = TestDirectory::new("storage-compaction")?;
664 let root = temporary.path().join("data");
665 let first_id = Uuid::now_v7();
666 let mut opened = StorageEngine::open(&root)?;
667 let first = opened
668 .storage
669 .write(first_id, &[Mutation::put(b"before", b"one")])?;
670 let AppendOutcome::Committed(first_receipt) = first else {
671 return Err("first transaction was not committed".into());
672 };
673
674 let compacted = opened.storage.compact()?;
675 let CompactionOutcome::Compacted(report) = compacted else {
676 return Err("committed history was not compacted".into());
677 };
678 assert_eq!(report.generation, 2);
679 assert!(report.retired_segment_removed);
680 assert!(!report.retired_segment.exists());
681 assert!(root.join("log/00000000000000000002.hylog").is_file());
682 assert!(matches!(
683 opened.storage.compact()?,
684 CompactionOutcome::NoChanges { .. }
685 ));
686
687 assert_eq!(
688 opened
689 .storage
690 .write(first_id, &[Mutation::put(b"before", b"one")])?,
691 AppendOutcome::Existing(first_receipt)
692 );
693 let second_id = Uuid::now_v7();
694 let second = opened
695 .storage
696 .write(second_id, &[Mutation::put(b"after", b"two")])?;
697 let AppendOutcome::Committed(second_receipt) = second else {
698 return Err("second transaction was not committed".into());
699 };
700 assert_eq!(
701 second_receipt.commit_sequence,
702 first_receipt.commit_sequence + 3
703 );
704 drop(opened);
705
706 fs::remove_file(root.join("indexes/primary.redb"))?;
707 let mut rebuilt = StorageEngine::open(&root)?;
708 assert_eq!(rebuilt.recovery.replayed_transactions, 1);
709 assert_eq!(rebuilt.storage.get(b"before")?, Some(b"one".to_vec()));
710 assert_eq!(rebuilt.storage.get(b"after")?, Some(b"two".to_vec()));
711 assert_eq!(
712 rebuilt
713 .storage
714 .write(first_id, &[Mutation::put(b"before", b"one")])?,
715 AppendOutcome::Existing(first_receipt)
716 );
717 Ok(())
718 }
719
720 #[test]
721 fn orphan_prepared_segment_is_ignored_until_manifest_commit() -> Result<(), Box<dyn Error>> {
722 let temporary = TestDirectory::new("storage-compaction-orphan")?;
723 let root = temporary.path().join("data");
724 let mut opened = StorageEngine::open(&root)?;
725 opened
726 .storage
727 .write(Uuid::now_v7(), &[Mutation::put(b"key", b"value")])?;
728 let snapshot = opened.storage.snapshot()?;
729 let base_digest = snapshot
730 .checkpoint_digest
731 .ok_or("snapshot checkpoint digest is absent")?;
732 let orphan_path = opened.storage.directory.log_path(2);
733 let (orphan, recovery) =
734 DurableLog::open_file_at(&orphan_path, snapshot.checkpoint_sequence, base_digest)?;
735 assert_eq!(recovery.valid_bytes, 0);
736 drop(orphan);
737 drop(opened);
738
739 let mut reopened = StorageEngine::open(&root)?;
740 assert_eq!(reopened.storage.directory.manifest().generation, 1);
741 assert_eq!(reopened.storage.get(b"key")?, Some(b"value".to_vec()));
742 assert!(matches!(
743 reopened.storage.compact()?,
744 CompactionOutcome::Compacted(_)
745 ));
746 assert_eq!(reopened.storage.directory.manifest().generation, 2);
747 Ok(())
748 }
749
750 #[test]
751 fn committed_manifest_wins_before_retired_log_cleanup() -> Result<(), Box<dyn Error>> {
752 let temporary = TestDirectory::new("storage-compaction-committed")?;
753 let root = temporary.path().join("data");
754 let mut opened = StorageEngine::open(&root)?;
755 opened
756 .storage
757 .write(Uuid::now_v7(), &[Mutation::put(b"key", b"value")])?;
758 let snapshot = opened.storage.snapshot()?;
759 let base_digest = snapshot
760 .checkpoint_digest
761 .ok_or("snapshot checkpoint digest is absent")?;
762 let next = StorageManifest {
763 generation: 2,
764 active_segment: 2,
765 base_sequence: snapshot.checkpoint_sequence,
766 base_digest,
767 snapshot_digest: snapshot.snapshot_digest,
768 };
769 let (prepared, recovery) = DurableLog::open_file_at(
770 opened.storage.directory.log_path(2),
771 next.base_sequence,
772 next.base_digest,
773 )?;
774 assert_eq!(recovery.valid_bytes, 0);
775 drop(prepared);
776 opened.storage.directory.commit_manifest(next)?;
777 let retired_path = opened.storage.directory.log_path(1);
778 assert!(retired_path.is_file());
779 drop(opened);
780
781 let reopened = StorageEngine::open(&root)?;
782 assert_eq!(reopened.storage.directory.manifest().generation, 2);
783 assert_eq!(reopened.storage.get(b"key")?, Some(b"value".to_vec()));
784 assert!(!retired_path.exists());
785 Ok(())
786 }
787
788 #[test]
789 fn uncertain_log_sync_blocks_the_handle_until_recovery() -> Result<(), Box<dyn Error>> {
790 let temporary = TestDirectory::new("storage-injected-log-sync")?;
791 let root = temporary.path().join("data");
792 let mut opened = StorageEngine::open(&root)?;
793 opened.storage.log.inject_sync_failure();
794
795 let result = opened
796 .storage
797 .write(Uuid::now_v7(), &[Mutation::put(b"recovered", b"yes")]);
798 assert!(matches!(
799 result,
800 Err(StorageError::Log(crate::LogError::Io(_)))
801 ));
802 assert!(matches!(
803 opened.storage.get(b"recovered"),
804 Err(StorageError::StaleIndex)
805 ));
806 assert!(matches!(
807 opened.storage.snapshot(),
808 Err(StorageError::StaleIndex)
809 ));
810 assert!(matches!(
811 opened.storage.compact(),
812 Err(StorageError::StaleIndex)
813 ));
814 drop(opened);
815
816 let reopened = StorageEngine::open(&root)?;
817 assert_eq!(reopened.recovery.replayed_transactions, 1);
818 assert_eq!(reopened.storage.get(b"recovered")?, Some(b"yes".to_vec()));
819 Ok(())
820 }
821
822 #[test]
823 fn post_commit_index_failure_recovers_from_the_log() -> Result<(), Box<dyn Error>> {
824 let temporary = TestDirectory::new("storage-injected-index")?;
825 let root = temporary.path().join("data");
826 let mut opened = StorageEngine::open(&root)?;
827 opened.storage.index.inject_apply_failure();
828
829 let result = opened
830 .storage
831 .write(Uuid::now_v7(), &[Mutation::put(b"durable", b"yes")]);
832 assert!(matches!(
833 result,
834 Err(StorageError::CommittedButNotIndexed { .. })
835 ));
836 assert!(matches!(
837 opened.storage.get(b"durable"),
838 Err(StorageError::StaleIndex)
839 ));
840 drop(opened);
841
842 let reopened = StorageEngine::open(&root)?;
843 assert_eq!(reopened.recovery.replayed_transactions, 1);
844 assert_eq!(reopened.storage.get(b"durable")?, Some(b"yes".to_vec()));
845 Ok(())
846 }
847
848 #[test]
849 fn kv_scan_pages_are_strictly_ordered_and_exclusive() -> Result<(), Box<dyn Error>> {
850 let temporary = TestDirectory::new("storage-scan-page")?;
851 let mut opened = StorageEngine::open(temporary.path().join("data"))?;
852 opened.storage.write(
853 Uuid::now_v7(),
854 &[
855 Mutation::put(b"c", b"three"),
856 Mutation::put(b"a", b"one"),
857 Mutation::put(b"b", b"two"),
858 ],
859 )?;
860
861 let first = opened.storage.scan_page(None, 2)?;
862 assert_eq!(
863 first
864 .entries
865 .iter()
866 .map(|entry| entry.key.as_slice())
867 .collect::<Vec<_>>(),
868 [b"a".as_slice(), b"b".as_slice()]
869 );
870 assert_eq!(first.next_after, Some(b"b".to_vec()));
871
872 let second = opened.storage.scan_page(first.next_after.as_deref(), 2)?;
873 assert_eq!(second.entries[0].key, b"c");
874 assert_eq!(second.next_after, None);
875 Ok(())
876 }
877}