Skip to main content

hyphae_storage/
index.rs

1// SPDX-License-Identifier: Apache-2.0
2
3#[cfg(test)]
4use std::cell::Cell;
5use std::{ops::Bound, path::Path};
6
7use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition};
8use thiserror::Error;
9
10use uuid::Uuid;
11
12use crate::{
13    CommitReceipt, Mutation, MutationError, RecoveredTransaction, RecoveryReport,
14    snapshot::{SnapshotError, SnapshotInfo, SnapshotRecordVisitor, read_snapshot_records},
15};
16
17const KV: TableDefinition<&[u8], &[u8]> = TableDefinition::new("hyphae_kv_v1");
18const METADATA: TableDefinition<&str, &[u8]> = TableDefinition::new("hyphae_metadata_v1");
19const IDEMPOTENCY: TableDefinition<&[u8], &[u8]> = TableDefinition::new("hyphae_idempotency_v1");
20const APPLIED_SEQUENCE: &str = "applied_sequence";
21const APPLIED_DIGEST: &str = "applied_digest";
22const RECEIPT_LENGTH: usize = 72;
23type RawKvEntry = (Vec<u8>, Vec<u8>);
24
25/// Failure while opening, verifying, or updating the rebuildable redb index.
26#[derive(Debug, Error)]
27pub enum MaterializedIndexError {
28    /// redb could not open or create its database file.
29    #[error("failed to open materialized index: {0}")]
30    Database(#[from] redb::DatabaseError),
31
32    /// redb could not begin a transaction.
33    #[error("failed to begin materialized-index transaction: {0}")]
34    Transaction(#[from] redb::TransactionError),
35
36    /// A redb table could not be opened.
37    #[error("failed to open materialized-index table: {0}")]
38    Table(#[from] redb::TableError),
39
40    /// A redb table read or write failed.
41    #[error("materialized-index storage failure: {0}")]
42    Storage(#[from] redb::StorageError),
43
44    /// A redb transaction could not be committed.
45    #[error("failed to commit materialized-index transaction: {0}")]
46    Commit(#[from] redb::CommitError),
47
48    /// A redb durability mode could not be selected.
49    #[error("failed to select materialized-index durability: {0}")]
50    Durability(#[from] redb::SetDurabilityError),
51
52    /// A committed operation is not a valid canonical mutation.
53    #[error("invalid committed mutation: {0}")]
54    Mutation(#[from] MutationError),
55
56    /// Stored index metadata has an invalid length or combination.
57    #[error("malformed materialized-index checkpoint")]
58    MalformedCheckpoint,
59
60    /// The index checkpoint does not identify a commit in the verified log.
61    #[error("materialized index checkpoint at sequence {sequence} diverges from the log")]
62    Diverged {
63        /// Checkpoint sequence that could not be verified.
64        sequence: u64,
65    },
66
67    /// A persisted idempotency receipt is malformed or conflicts with the log.
68    #[error("materialized idempotency receipt for {transaction_id} diverges from the log")]
69    IdempotencyDiverged {
70        /// Transaction identifier with conflicting durable identity.
71        transaction_id: Uuid,
72    },
73
74    /// A persisted idempotency key is not a UUID.
75    #[error("materialized idempotency key is malformed")]
76    MalformedIdempotencyKey,
77
78    /// A test-only injected index failure occurred.
79    #[cfg(test)]
80    #[error("injected materialized-index failure")]
81    InjectedFailure,
82}
83
84#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
85pub(crate) struct IndexCheckpoint {
86    pub(crate) sequence: u64,
87    pub(crate) digest: Option<[u8; 32]>,
88}
89
90#[derive(Debug)]
91pub(crate) struct MaterializedIndex {
92    database: Database,
93    #[cfg(test)]
94    fail_next_apply: Cell<bool>,
95}
96
97impl MaterializedIndex {
98    pub(crate) fn open(path: impl AsRef<Path>) -> Result<Self, MaterializedIndexError> {
99        let database = Database::create(path)?;
100        let mut transaction = database.begin_write()?;
101        transaction.set_durability(Durability::Immediate)?;
102        {
103            let _table = transaction.open_table(KV)?;
104        }
105        {
106            let _table = transaction.open_table(METADATA)?;
107        }
108        {
109            let _table = transaction.open_table(IDEMPOTENCY)?;
110        }
111        transaction.commit()?;
112        Ok(Self {
113            database,
114            #[cfg(test)]
115            fail_next_apply: Cell::new(false),
116        })
117    }
118
119    pub(crate) fn restore_from_snapshot(
120        index_path: &Path,
121        snapshot_path: &Path,
122    ) -> Result<SnapshotInfo, SnapshotError> {
123        let index = Self::open(index_path)?;
124        let mut write = index
125            .database
126            .begin_write()
127            .map_err(MaterializedIndexError::from)?;
128        write
129            .set_durability(Durability::Immediate)
130            .map_err(MaterializedIndexError::from)?;
131        let snapshot = {
132            let mut visitor = IndexRestoreVisitor { write: &mut write };
133            read_snapshot_records(snapshot_path, &mut visitor)?
134        };
135        {
136            let mut metadata = write
137                .open_table(METADATA)
138                .map_err(MaterializedIndexError::from)?;
139            if snapshot.checkpoint_sequence > 0 {
140                let Some(digest) = snapshot.checkpoint_digest else {
141                    return Err(SnapshotError::Invalid {
142                        reason: "nonempty snapshot lacks a checkpoint digest",
143                    });
144                };
145                metadata
146                    .insert(
147                        APPLIED_SEQUENCE,
148                        snapshot.checkpoint_sequence.to_le_bytes().as_slice(),
149                    )
150                    .map_err(MaterializedIndexError::from)?;
151                metadata
152                    .insert(APPLIED_DIGEST, digest.as_slice())
153                    .map_err(MaterializedIndexError::from)?;
154            }
155        }
156        write.commit().map_err(MaterializedIndexError::from)?;
157        Ok(snapshot)
158    }
159
160    pub(crate) fn replay(&self, recovery: &RecoveryReport) -> Result<u64, MaterializedIndexError> {
161        let checkpoint = self.checkpoint()?;
162        if checkpoint.sequence == 0 {
163            if checkpoint.digest.is_some() || recovery.base_sequence != 0 {
164                return Err(MaterializedIndexError::MalformedCheckpoint);
165            }
166        } else if checkpoint.sequence == recovery.base_sequence {
167            if checkpoint.digest != Some(recovery.base_digest) {
168                return Err(MaterializedIndexError::Diverged {
169                    sequence: checkpoint.sequence,
170                });
171            }
172        } else {
173            let Some(transaction) = recovery
174                .transactions
175                .iter()
176                .find(|transaction| transaction.receipt.commit_sequence == checkpoint.sequence)
177            else {
178                return Err(MaterializedIndexError::Diverged {
179                    sequence: checkpoint.sequence,
180                });
181            };
182            if checkpoint.digest != Some(transaction.receipt.commit_digest) {
183                return Err(MaterializedIndexError::Diverged {
184                    sequence: checkpoint.sequence,
185                });
186            }
187        }
188
189        self.reconcile_idempotency(recovery)?;
190
191        let mut replayed = 0_u64;
192        for transaction in recovery
193            .transactions
194            .iter()
195            .filter(|transaction| transaction.receipt.commit_sequence > checkpoint.sequence)
196        {
197            self.apply(transaction)?;
198            replayed = replayed.saturating_add(1);
199        }
200        Ok(replayed)
201    }
202
203    pub(crate) fn apply(
204        &self,
205        transaction: &RecoveredTransaction,
206    ) -> Result<(), MaterializedIndexError> {
207        #[cfg(test)]
208        if self.fail_next_apply.replace(false) {
209            return Err(MaterializedIndexError::InjectedFailure);
210        }
211        let mutations = transaction
212            .operations
213            .iter()
214            .map(|operation| Mutation::decode(operation))
215            .collect::<Result<Vec<_>, _>>()?;
216
217        let mut write = self.database.begin_write()?;
218        write.set_durability(redb::Durability::Immediate)?;
219        {
220            let mut table = write.open_table(KV)?;
221            for mutation in mutations {
222                match mutation {
223                    Mutation::Put { key, value } => {
224                        table.insert(key.as_slice(), value.as_slice())?;
225                    }
226                    Mutation::Delete { key } => {
227                        table.remove(key.as_slice())?;
228                    }
229                }
230            }
231        }
232        {
233            let mut metadata = write.open_table(METADATA)?;
234            metadata.insert(
235                APPLIED_SEQUENCE,
236                transaction.receipt.commit_sequence.to_le_bytes().as_slice(),
237            )?;
238            metadata.insert(APPLIED_DIGEST, transaction.receipt.commit_digest.as_slice())?;
239        }
240        {
241            let mut idempotency = write.open_table(IDEMPOTENCY)?;
242            idempotency.insert(
243                transaction.receipt.transaction_id.as_bytes().as_slice(),
244                encode_receipt(&transaction.receipt).as_slice(),
245            )?;
246        }
247        write.commit()?;
248        Ok(())
249    }
250
251    pub(crate) fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, MaterializedIndexError> {
252        let read = self.database.begin_read()?;
253        let table = read.open_table(KV)?;
254        let value = table.get(key)?.map(|value| value.value().to_vec());
255        Ok(value)
256    }
257
258    pub(crate) fn scan_after(
259        &self,
260        after: Option<&[u8]>,
261        limit: usize,
262    ) -> Result<Vec<RawKvEntry>, MaterializedIndexError> {
263        let read = self.database.begin_read()?;
264        let table = read.open_table(KV)?;
265        let bounds = (
266            after.map_or(Bound::Unbounded, Bound::Excluded),
267            Bound::Unbounded,
268        );
269        let mut entries = Vec::with_capacity(limit);
270        for entry in table.range::<&[u8]>(bounds)?.take(limit) {
271            let (key, value) = entry?;
272            entries.push((key.value().to_vec(), value.value().to_vec()));
273        }
274        Ok(entries)
275    }
276
277    #[cfg(test)]
278    pub(crate) fn inject_apply_failure(&self) {
279        self.fail_next_apply.set(true);
280    }
281
282    pub(crate) fn for_each_entry(
283        &self,
284        mut visitor: impl FnMut(&[u8], &[u8]),
285    ) -> Result<(), MaterializedIndexError> {
286        let read = self.database.begin_read()?;
287        let table = read.open_table(KV)?;
288        for entry in table.iter()? {
289            let (key, value) = entry?;
290            visitor(key.value(), value.value());
291        }
292        Ok(())
293    }
294
295    pub(crate) fn receipt(
296        &self,
297        transaction_id: Uuid,
298    ) -> Result<Option<CommitReceipt>, MaterializedIndexError> {
299        let read = self.database.begin_read()?;
300        let table = read.open_table(IDEMPOTENCY)?;
301        table
302            .get(transaction_id.as_bytes().as_slice())?
303            .map(|encoded| decode_receipt(transaction_id, encoded.value()))
304            .transpose()
305    }
306
307    pub(crate) fn for_each_receipt(
308        &self,
309        mut visitor: impl FnMut(&CommitReceipt),
310    ) -> Result<(), MaterializedIndexError> {
311        let read = self.database.begin_read()?;
312        let table = read.open_table(IDEMPOTENCY)?;
313        for entry in table.iter()? {
314            let (key, value) = entry?;
315            let transaction_id = Uuid::from_slice(key.value())
316                .map_err(|_| MaterializedIndexError::MalformedIdempotencyKey)?;
317            let receipt = decode_receipt(transaction_id, value.value())?;
318            visitor(&receipt);
319        }
320        Ok(())
321    }
322
323    pub(crate) fn checkpoint(&self) -> Result<IndexCheckpoint, MaterializedIndexError> {
324        let read = self.database.begin_read()?;
325        let metadata = read.open_table(METADATA)?;
326        let sequence = metadata
327            .get(APPLIED_SEQUENCE)?
328            .map(|value| decode_sequence(value.value()))
329            .transpose()?
330            .unwrap_or(0);
331        let digest = metadata
332            .get(APPLIED_DIGEST)?
333            .map(|value| decode_digest(value.value()))
334            .transpose()?;
335        Ok(IndexCheckpoint { sequence, digest })
336    }
337
338    fn reconcile_idempotency(
339        &self,
340        recovery: &RecoveryReport,
341    ) -> Result<(), MaterializedIndexError> {
342        let mut write = self.database.begin_write()?;
343        write.set_durability(Durability::Immediate)?;
344        {
345            let mut table = write.open_table(IDEMPOTENCY)?;
346            for transaction in &recovery.transactions {
347                let receipt = transaction.receipt;
348                if let Some(encoded) = table.get(receipt.transaction_id.as_bytes().as_slice())? {
349                    let existing = decode_receipt(receipt.transaction_id, encoded.value())?;
350                    if existing != receipt {
351                        return Err(MaterializedIndexError::IdempotencyDiverged {
352                            transaction_id: receipt.transaction_id,
353                        });
354                    }
355                } else {
356                    table.insert(
357                        receipt.transaction_id.as_bytes().as_slice(),
358                        encode_receipt(&receipt).as_slice(),
359                    )?;
360                }
361            }
362        }
363        write.commit()?;
364        Ok(())
365    }
366}
367
368struct IndexRestoreVisitor<'transaction> {
369    write: &'transaction mut redb::WriteTransaction,
370}
371
372impl SnapshotRecordVisitor for IndexRestoreVisitor<'_> {
373    fn put(&mut self, key: &[u8], value: &[u8]) -> Result<(), SnapshotError> {
374        let mut table = self
375            .write
376            .open_table(KV)
377            .map_err(MaterializedIndexError::from)?;
378        table
379            .insert(key, value)
380            .map_err(MaterializedIndexError::from)?;
381        Ok(())
382    }
383
384    fn receipt(&mut self, receipt: &CommitReceipt) -> Result<(), SnapshotError> {
385        let mut table = self
386            .write
387            .open_table(IDEMPOTENCY)
388            .map_err(MaterializedIndexError::from)?;
389        table
390            .insert(
391                receipt.transaction_id.as_bytes().as_slice(),
392                encode_receipt(receipt).as_slice(),
393            )
394            .map_err(MaterializedIndexError::from)?;
395        Ok(())
396    }
397}
398
399fn encode_receipt(receipt: &CommitReceipt) -> [u8; RECEIPT_LENGTH] {
400    let mut encoded = [0_u8; RECEIPT_LENGTH];
401    encoded[..8].copy_from_slice(&receipt.commit_sequence.to_le_bytes());
402    encoded[8..40].copy_from_slice(&receipt.commit_digest);
403    encoded[40..72].copy_from_slice(&receipt.transaction_digest);
404    encoded
405}
406
407fn decode_receipt(
408    transaction_id: Uuid,
409    encoded: &[u8],
410) -> Result<CommitReceipt, MaterializedIndexError> {
411    if encoded.len() != RECEIPT_LENGTH {
412        return Err(MaterializedIndexError::IdempotencyDiverged { transaction_id });
413    }
414    Ok(CommitReceipt {
415        transaction_id,
416        commit_sequence: u64::from_le_bytes(copy_array(&encoded[..8])),
417        commit_digest: copy_array(&encoded[8..40]),
418        transaction_digest: copy_array(&encoded[40..72]),
419    })
420}
421
422fn decode_sequence(encoded: &[u8]) -> Result<u64, MaterializedIndexError> {
423    if encoded.len() != 8 {
424        return Err(MaterializedIndexError::MalformedCheckpoint);
425    }
426    Ok(u64::from_le_bytes(copy_array(encoded)))
427}
428
429fn decode_digest(encoded: &[u8]) -> Result<[u8; 32], MaterializedIndexError> {
430    if encoded.len() != 32 {
431        return Err(MaterializedIndexError::MalformedCheckpoint);
432    }
433    Ok(copy_array(encoded))
434}
435
436fn copy_array<const N: usize>(source: &[u8]) -> [u8; N] {
437    let mut output = [0_u8; N];
438    output.copy_from_slice(source);
439    output
440}
441
442#[cfg(test)]
443mod tests {
444    use std::error::Error;
445
446    use uuid::Uuid;
447
448    use super::{MaterializedIndex, MaterializedIndexError};
449    use crate::{DurableLog, Mutation, test_support::TestDirectory};
450
451    fn recovery_with_operation(
452        path: &std::path::Path,
453        operation: Vec<u8>,
454    ) -> Result<crate::RecoveryReport, Box<dyn Error>> {
455        let (mut log, _) = DurableLog::open_file(path)?;
456        log.append_transaction(Uuid::now_v7(), &[operation])?;
457        drop(log);
458        let (_, recovery) = DurableLog::open_file(path)?;
459        Ok(recovery)
460    }
461
462    #[test]
463    fn checkpoint_rejects_a_different_log_history() -> Result<(), Box<dyn Error>> {
464        let temporary = TestDirectory::new("index-divergence")?;
465        let first = recovery_with_operation(
466            &temporary.path().join("first.hylog"),
467            Mutation::put(b"key", b"first").encode()?,
468        )?;
469        let second = recovery_with_operation(
470            &temporary.path().join("second.hylog"),
471            Mutation::put(b"key", b"second").encode()?,
472        )?;
473        let index = MaterializedIndex::open(temporary.path().join("index.redb"))?;
474        assert_eq!(index.replay(&first)?, 1);
475
476        let result = index.replay(&second);
477        assert!(matches!(
478            result,
479            Err(MaterializedIndexError::Diverged { sequence: 3 })
480        ));
481        assert_eq!(index.get(b"key")?, Some(b"first".to_vec()));
482        Ok(())
483    }
484
485    #[test]
486    fn invalid_committed_operation_never_advances_checkpoint() -> Result<(), Box<dyn Error>> {
487        let temporary = TestDirectory::new("index-invalid-operation")?;
488        let recovery = recovery_with_operation(
489            &temporary.path().join("segment.hylog"),
490            b"not-a-mutation".to_vec(),
491        )?;
492        let index = MaterializedIndex::open(temporary.path().join("index.redb"))?;
493
494        assert!(matches!(
495            index.replay(&recovery),
496            Err(MaterializedIndexError::Mutation(_))
497        ));
498        assert!(matches!(
499            index.replay(&recovery),
500            Err(MaterializedIndexError::Mutation(_))
501        ));
502        Ok(())
503    }
504
505    #[test]
506    fn replay_backfills_a_missing_idempotency_receipt() -> Result<(), Box<dyn Error>> {
507        let temporary = TestDirectory::new("index-idempotency-backfill")?;
508        let recovery = recovery_with_operation(
509            &temporary.path().join("segment.hylog"),
510            Mutation::put(b"key", b"value").encode()?,
511        )?;
512        let receipt = recovery.transactions[0].receipt;
513        let index = MaterializedIndex::open(temporary.path().join("index.redb"))?;
514        assert_eq!(index.replay(&recovery)?, 1);
515        assert_eq!(index.receipt(receipt.transaction_id)?, Some(receipt));
516
517        let mut write = index.database.begin_write()?;
518        write.set_durability(redb::Durability::Immediate)?;
519        {
520            let mut table = write.open_table(super::IDEMPOTENCY)?;
521            table.remove(receipt.transaction_id.as_bytes().as_slice())?;
522        }
523        write.commit()?;
524        assert_eq!(index.receipt(receipt.transaction_id)?, None);
525
526        assert_eq!(index.replay(&recovery)?, 0);
527        assert_eq!(index.receipt(receipt.transaction_id)?, Some(receipt));
528        Ok(())
529    }
530}