1#[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#[derive(Debug, Error)]
27pub enum MaterializedIndexError {
28 #[error("failed to open materialized index: {0}")]
30 Database(#[from] redb::DatabaseError),
31
32 #[error("failed to begin materialized-index transaction: {0}")]
34 Transaction(#[from] redb::TransactionError),
35
36 #[error("failed to open materialized-index table: {0}")]
38 Table(#[from] redb::TableError),
39
40 #[error("materialized-index storage failure: {0}")]
42 Storage(#[from] redb::StorageError),
43
44 #[error("failed to commit materialized-index transaction: {0}")]
46 Commit(#[from] redb::CommitError),
47
48 #[error("failed to select materialized-index durability: {0}")]
50 Durability(#[from] redb::SetDurabilityError),
51
52 #[error("invalid committed mutation: {0}")]
54 Mutation(#[from] MutationError),
55
56 #[error("malformed materialized-index checkpoint")]
58 MalformedCheckpoint,
59
60 #[error("materialized index checkpoint at sequence {sequence} diverges from the log")]
62 Diverged {
63 sequence: u64,
65 },
66
67 #[error("materialized idempotency receipt for {transaction_id} diverges from the log")]
69 IdempotencyDiverged {
70 transaction_id: Uuid,
72 },
73
74 #[error("materialized idempotency key is malformed")]
76 MalformedIdempotencyKey,
77
78 #[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}