Skip to main content

hyphae_storage/
engine.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::path::{Path, PathBuf};
4
5use thiserror::Error;
6use uuid::Uuid;
7
8/// Maximum KV entries returned by one ordered storage scan page.
9pub 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/// Failure while opening or operating the durable embedded storage engine.
23#[derive(Debug, Error)]
24pub enum StorageError {
25    /// The data directory could not be initialized or exclusively locked.
26    #[error(transparent)]
27    DataDirectory(#[from] DataDirectoryError),
28
29    /// The authoritative log rejected or failed an operation.
30    #[error(transparent)]
31    Log(#[from] LogError),
32
33    /// The rebuildable materialized index failed before a new log commit.
34    #[error("materialized index failure: {source}")]
35    Index {
36        /// Rebuildable-index failure.
37        #[source]
38        source: Box<MaterializedIndexError>,
39    },
40
41    /// A mutation violates the stable binary codec.
42    #[error(transparent)]
43    Mutation(#[from] MutationError),
44
45    /// The log commit is durable but its index update failed.
46    #[error("transaction {receipt:?} is durable but not materialized; reopen to recover")]
47    CommittedButNotIndexed {
48        /// Receipt proving that the log commit succeeded.
49        receipt: CommitReceipt,
50        /// Rebuildable-index failure.
51        #[source]
52        source: Box<MaterializedIndexError>,
53    },
54
55    /// Reads and further writes are blocked after an index update failure.
56    #[error("materialized index is stale; reopen storage to replay the durable log")]
57    StaleIndex,
58
59    /// Snapshot creation or verification failed.
60    #[error("snapshot failure: {source}")]
61    Snapshot {
62        /// Underlying snapshot failure.
63        #[source]
64        source: Box<SnapshotError>,
65    },
66
67    /// The immutable manifest generation space is exhausted.
68    #[error("storage manifest generation space is exhausted")]
69    ManifestGenerationExhausted,
70
71    /// A prepared compaction segment unexpectedly contains complete frames.
72    #[error("prepared compaction segment is not empty: {path}")]
73    PreparedSegmentNotEmpty {
74        /// Unexpected nonempty segment path.
75        path: PathBuf,
76    },
77
78    /// A KV scan page size is zero or exceeds the hard storage bound.
79    #[error("scan page size {requested} is outside 1..={maximum}")]
80    InvalidScanLimit {
81        /// Requested page size.
82        requested: usize,
83        /// Hard maximum page size.
84        maximum: usize,
85    },
86}
87
88/// Recovery evidence returned when the complete embedded storage layer opens.
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct StorageRecoveryReport {
91    /// Authoritative log verification and tail-repair evidence.
92    pub log: RecoveryReport,
93    /// Durable transactions newly applied to the materialized index.
94    pub replayed_transactions: u64,
95}
96
97/// A newly opened storage engine and its recovery evidence.
98#[derive(Debug)]
99pub struct OpenedStorage {
100    /// Ready-to-use storage engine.
101    pub storage: StorageEngine,
102    /// Evidence from log validation and index replay.
103    pub recovery: StorageRecoveryReport,
104}
105
106/// Evidence for one successfully committed compaction generation.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct CompactionReport {
109    /// Newly active immutable manifest generation.
110    pub generation: u64,
111    /// Snapshot anchoring the retired log prefix.
112    pub snapshot: SnapshotInfo,
113    /// Segment that became inactive after the manifest commit.
114    pub retired_segment: PathBuf,
115    /// Whether best-effort physical cleanup removed the retired segment.
116    pub retired_segment_removed: bool,
117}
118
119/// One binary KV entry in canonical key order.
120#[derive(Clone, Debug, Eq, PartialEq)]
121pub struct KvEntry {
122    /// Binary key.
123    pub key: Vec<u8>,
124    /// Opaque binary value.
125    pub value: Vec<u8>,
126}
127
128/// One bounded ordered KV scan page.
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct KvPage {
131    /// Entries strictly after the requested cursor.
132    pub entries: Vec<KvEntry>,
133    /// Last emitted key when more entries remain.
134    pub next_after: Option<Vec<u8>>,
135}
136
137/// Result of an online compaction request.
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub enum CompactionOutcome {
140    /// No committed frames exist beyond the already active snapshot anchor.
141    NoChanges {
142        /// Current verified snapshot.
143        snapshot: SnapshotInfo,
144    },
145    /// A new manifest and anchored segment were committed.
146    Compacted(CompactionReport),
147}
148
149/// Single-writer durable KV storage composed from the log and rebuildable redb index.
150#[derive(Debug)]
151pub struct StorageEngine {
152    log: DurableLog,
153    index: MaterializedIndex,
154    index_stale: bool,
155    directory: DataDirectory,
156}
157
158impl StorageEngine {
159    /// Opens a data directory, verifies its log, and catches the index up before use.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error for directory contention, log corruption, invalid committed
164    /// mutations, a divergent index checkpoint, or filesystem failures.
165    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    /// Returns the owned data-directory path.
191    pub fn data_path(&self) -> &Path {
192        self.directory.path()
193    }
194
195    /// Creates an atomic, independently verifiable backup at the current checkpoint.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error when the snapshot cannot be created, the destination
200    /// exists or is inside the live data directory, or synchronized promotion
201    /// of the complete backup fails.
202    pub fn backup(&self, destination: impl AsRef<Path>) -> Result<BackupInfo, BackupError> {
203        crate::backup::create_backup(self, destination.as_ref())
204    }
205
206    /// Durably commits an atomic batch and then materializes it.
207    ///
208    /// The log is synchronized before redb is updated. If redb fails, the error
209    /// includes the durable commit receipt and this handle blocks reads and writes
210    /// until reopen replays the log.
211    ///
212    /// # Errors
213    ///
214    /// Returns an error for invalid mutations, idempotency conflicts, log I/O,
215    /// or materialized-index failures.
216    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    /// Reads a binary value from the caught-up materialized index.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error for an empty or oversized key, an index read failure,
270    /// or a handle made stale by a prior post-commit index failure.
271    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    /// Scans one bounded page in strict binary-key order.
280    ///
281    /// `after` is exclusive. A returned `next_after` is present only when at
282    /// least one additional entry exists.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error for a stale handle, invalid cursor key, invalid page
287    /// size, or materialized-index failure.
288    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    /// Returns the internal materialized-index path for diagnostics.
317    pub fn index_path(&self) -> PathBuf {
318        self.directory.path().join("indexes").join("primary.redb")
319    }
320
321    /// Creates or reuses a verified logical snapshot at the current index checkpoint.
322    ///
323    /// # Errors
324    ///
325    /// Returns an error when the live index is stale, cannot be streamed, or the
326    /// snapshot cannot be synchronized, verified, and atomically promoted.
327    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    /// Retires the active log prefix behind a verified logical snapshot.
337    ///
338    /// The new empty segment is synchronized before an immutable manifest
339    /// generation selects it. Physical deletion of the retired segment happens
340    /// only after that commit and is reported independently.
341    ///
342    /// # Errors
343    ///
344    /// Returns an error while preparing the snapshot, segment, or manifest. A
345    /// poisoned or stale handle must be reopened before compaction.
346    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}