1use serde::{Deserialize, Serialize};
2
3const COMMIT_MARKER: u64 = 0x4943_4D45_4D43_4F4D;
4const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
5const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
6
7pub trait ProtectedGenerationSlot: Eq {
12 fn generation(&self) -> u64;
14
15 fn validates(&self) -> bool;
17}
18
19pub trait DualProtectedCommitStore {
24 type Slot: ProtectedGenerationSlot;
26
27 fn slot0(&self) -> Option<&Self::Slot>;
29
30 fn slot1(&self) -> Option<&Self::Slot>;
32
33 fn is_uninitialized(&self) -> bool {
35 self.slot0().is_none() && self.slot1().is_none()
36 }
37
38 fn authoritative_slot(&self) -> Result<AuthoritativeSlot<'_, Self::Slot>, CommitRecoveryError> {
40 select_authoritative_slot(self.slot0(), self.slot1())
41 }
42
43 fn inactive_slot_index(&self) -> CommitSlotIndex {
48 match self.authoritative_slot() {
49 Ok(authoritative) => authoritative.index.opposite(),
50 Err(_) => CommitSlotIndex::Slot0,
51 }
52 }
53}
54
55#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
60pub enum CommitSlotIndex {
61 Slot0,
63 Slot1,
65}
66
67impl CommitSlotIndex {
68 #[must_use]
70 pub const fn opposite(self) -> Self {
71 match self {
72 Self::Slot0 => Self::Slot1,
73 Self::Slot1 => Self::Slot0,
74 }
75 }
76}
77
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub struct AuthoritativeSlot<'slot, T> {
84 pub index: CommitSlotIndex,
86 pub record: &'slot T,
88}
89
90pub fn select_authoritative_slot<'slot, T: ProtectedGenerationSlot>(
92 slot0: Option<&'slot T>,
93 slot1: Option<&'slot T>,
94) -> Result<AuthoritativeSlot<'slot, T>, CommitRecoveryError> {
95 let slot0 = slot0
96 .filter(|slot| slot.validates())
97 .map(|record| AuthoritativeSlot {
98 index: CommitSlotIndex::Slot0,
99 record,
100 });
101 let slot1 = slot1
102 .filter(|slot| slot.validates())
103 .map(|record| AuthoritativeSlot {
104 index: CommitSlotIndex::Slot1,
105 record,
106 });
107
108 match (slot0, slot1) {
109 (Some(left), Some(right))
110 if left.record.generation() == right.record.generation()
111 && left.record != right.record =>
112 {
113 Err(CommitRecoveryError::AmbiguousGeneration {
114 generation: left.record.generation(),
115 })
116 }
117 (Some(left), Some(right)) if right.record.generation() > left.record.generation() => {
118 Ok(right)
119 }
120 (Some(left), Some(_) | None) => Ok(left),
121 (None, Some(right)) => Ok(right),
122 (None, None) => Err(CommitRecoveryError::NoValidGeneration),
123 }
124}
125
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
131pub struct CommittedGenerationBytes {
132 pub generation: u64,
134 pub commit_marker: u64,
136 pub checksum: u64,
138 pub payload: Vec<u8>,
140}
141
142impl CommittedGenerationBytes {
143 #[must_use]
145 pub fn new(generation: u64, payload: Vec<u8>) -> Self {
146 let mut record = Self {
147 generation,
148 commit_marker: COMMIT_MARKER,
149 checksum: 0,
150 payload,
151 };
152 record.checksum = generation_checksum(&record);
153 record
154 }
155
156 #[must_use]
158 pub fn validates(&self) -> bool {
159 self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
160 }
161}
162
163impl ProtectedGenerationSlot for CommittedGenerationBytes {
164 fn generation(&self) -> u64 {
165 self.generation
166 }
167
168 fn validates(&self) -> bool {
169 self.validates()
170 }
171}
172
173#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
186pub struct DualCommitStore {
187 pub slot0: Option<CommittedGenerationBytes>,
189 pub slot1: Option<CommittedGenerationBytes>,
191}
192
193impl DualCommitStore {
194 #[must_use]
196 pub const fn is_uninitialized(&self) -> bool {
197 self.slot0.is_none() && self.slot1.is_none()
198 }
199
200 pub fn authoritative(&self) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
202 self.authoritative_slot()
203 .map(|authoritative| authoritative.record)
204 }
205
206 #[must_use]
208 pub fn diagnostic(&self) -> CommitStoreDiagnostic {
209 CommitStoreDiagnostic::from_store(self)
210 }
211
212 pub fn commit_payload(
218 &mut self,
219 payload: Vec<u8>,
220 ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
221 let next_generation =
222 match self.authoritative() {
223 Ok(record) => record.generation.checked_add(1).ok_or(
224 CommitRecoveryError::GenerationOverflow {
225 generation: record.generation,
226 },
227 )?,
228 Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => 0,
229 Err(err) => return Err(err),
230 };
231
232 self.commit_payload_at_generation(next_generation, payload)
233 }
234
235 pub fn commit_payload_at_generation(
241 &mut self,
242 generation: u64,
243 payload: Vec<u8>,
244 ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
245 match self.authoritative() {
246 Ok(record) => {
247 let expected = record.generation.checked_add(1).ok_or(
248 CommitRecoveryError::GenerationOverflow {
249 generation: record.generation,
250 },
251 )?;
252 if generation != expected {
253 return Err(CommitRecoveryError::UnexpectedGeneration {
254 expected,
255 actual: generation,
256 });
257 }
258 }
259 Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => {}
260 Err(err) => return Err(err),
261 }
262
263 let next = CommittedGenerationBytes::new(generation, payload);
264
265 if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
266 self.slot0 = Some(next);
267 } else {
268 self.slot1 = Some(next);
269 }
270
271 self.authoritative()
272 }
273
274 #[cfg(test)]
279 pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
280 let mut corrupt = CommittedGenerationBytes::new(generation, payload);
281 corrupt.checksum = corrupt.checksum.wrapping_add(1);
282
283 if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
284 self.slot0 = Some(corrupt);
285 } else {
286 self.slot1 = Some(corrupt);
287 }
288 }
289}
290
291impl DualProtectedCommitStore for DualCommitStore {
292 type Slot = CommittedGenerationBytes;
293
294 fn slot0(&self) -> Option<&Self::Slot> {
295 self.slot0.as_ref()
296 }
297
298 fn slot1(&self) -> Option<&Self::Slot> {
299 self.slot1.as_ref()
300 }
301}
302
303#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
308pub struct CommitStoreDiagnostic {
309 pub slot0: CommitSlotDiagnostic,
311 pub slot1: CommitSlotDiagnostic,
313 pub authoritative_generation: Option<u64>,
315 pub recovery_error: Option<CommitRecoveryError>,
317}
318
319impl CommitStoreDiagnostic {
320 #[must_use]
322 pub fn from_store<S: DualProtectedCommitStore>(store: &S) -> Self {
323 let (authoritative_generation, recovery_error) = match store.authoritative_slot() {
324 Ok(slot) => (Some(slot.record.generation()), None),
325 Err(err) => (None, Some(err)),
326 };
327 Self {
328 slot0: CommitSlotDiagnostic::from_slot(store.slot0()),
329 slot1: CommitSlotDiagnostic::from_slot(store.slot1()),
330 authoritative_generation,
331 recovery_error,
332 }
333 }
334}
335
336#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
341pub struct CommitSlotDiagnostic {
342 pub present: bool,
344 pub generation: Option<u64>,
346 pub valid: bool,
348}
349
350impl CommitSlotDiagnostic {
351 fn from_slot<T: ProtectedGenerationSlot>(slot: Option<&T>) -> Self {
352 match slot {
353 Some(record) => Self {
354 present: true,
355 generation: Some(record.generation()),
356 valid: record.validates(),
357 },
358 None => Self {
359 present: false,
360 generation: None,
361 valid: false,
362 },
363 }
364 }
365}
366
367#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
372pub enum CommitRecoveryError {
373 #[error("no valid committed ledger generation")]
375 NoValidGeneration,
376 #[error("ambiguous committed ledger generation {generation}")]
378 AmbiguousGeneration {
379 generation: u64,
381 },
382 #[error("committed ledger generation {generation} cannot be advanced without overflow")]
384 GenerationOverflow {
385 generation: u64,
387 },
388 #[error("expected committed ledger generation {expected}, got {actual}")]
390 UnexpectedGeneration {
391 expected: u64,
393 actual: u64,
395 },
396}
397
398fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
399 let mut hash = FNV_OFFSET;
400 hash = hash_u64(hash, generation.generation);
401 hash = hash_u64(hash, generation.commit_marker);
402 hash = hash_usize(hash, generation.payload.len());
403 for byte in &generation.payload {
404 hash = hash_byte(hash, *byte);
405 }
406 hash
407}
408
409fn hash_usize(hash: u64, value: usize) -> u64 {
410 hash_u64(hash, value as u64)
411}
412
413fn hash_u64(mut hash: u64, value: u64) -> u64 {
414 for byte in value.to_le_bytes() {
415 hash = hash_byte(hash, byte);
416 }
417 hash
418}
419
420const fn hash_byte(hash: u64, byte: u8) -> u64 {
421 (hash ^ byte as u64).wrapping_mul(FNV_PRIME)
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn payload(value: u8) -> Vec<u8> {
429 vec![value; 4]
430 }
431
432 #[test]
433 fn committed_generation_validates_marker_and_checksum() {
434 let mut generation = CommittedGenerationBytes::new(7, payload(1));
435 assert!(generation.validates());
436
437 generation.checksum = generation.checksum.wrapping_add(1);
438 assert!(!generation.validates());
439 }
440
441 #[test]
442 fn authoritative_selects_highest_valid_generation() {
443 let mut store = DualCommitStore::default();
444 store.commit_payload(payload(1)).expect("first commit");
445 store.commit_payload(payload(2)).expect("second commit");
446
447 let authoritative = store.authoritative().expect("authoritative");
448 let authoritative_slot =
449 select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
450 .expect("authoritative slot");
451
452 assert_eq!(authoritative.generation, 1);
453 assert_eq!(authoritative.payload, payload(2));
454 assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
455 assert_eq!(authoritative_slot.record.payload, payload(2));
456 }
457
458 #[test]
459 fn corrupt_newer_slot_leaves_prior_generation_authoritative() {
460 let mut store = DualCommitStore::default();
461 store.commit_payload(payload(1)).expect("first commit");
462 store.write_corrupt_inactive_slot(1, payload(2));
463
464 let authoritative = store.authoritative().expect("authoritative");
465
466 assert_eq!(authoritative.generation, 0);
467 assert_eq!(authoritative.payload, payload(1));
468 }
469
470 #[test]
471 fn no_valid_generation_fails_closed() {
472 let mut store = DualCommitStore::default();
473 store.write_corrupt_inactive_slot(0, payload(1));
474 store.write_corrupt_inactive_slot(1, payload(2));
475
476 let err = store.authoritative().expect_err("no valid slot");
477
478 assert_eq!(err, CommitRecoveryError::NoValidGeneration);
479 }
480
481 #[test]
482 fn same_generation_identical_slots_recover_deterministically() {
483 let committed = CommittedGenerationBytes::new(7, payload(1));
484 let store = DualCommitStore {
485 slot0: Some(committed.clone()),
486 slot1: Some(committed),
487 };
488
489 let authoritative = store.authoritative_slot().expect("authoritative");
490
491 assert_eq!(authoritative.index, CommitSlotIndex::Slot0);
492 assert_eq!(authoritative.record.generation, 7);
493 }
494
495 #[test]
496 fn same_generation_divergent_slots_fail_closed() {
497 let store = DualCommitStore {
498 slot0: Some(CommittedGenerationBytes::new(7, payload(1))),
499 slot1: Some(CommittedGenerationBytes::new(7, payload(2))),
500 };
501
502 let err = store.authoritative().expect_err("ambiguous generation");
503
504 assert_eq!(
505 err,
506 CommitRecoveryError::AmbiguousGeneration { generation: 7 }
507 );
508 }
509
510 #[test]
511 fn physical_generation_overflow_fails_closed() {
512 let mut store = DualCommitStore {
513 slot0: Some(CommittedGenerationBytes::new(u64::MAX, payload(1))),
514 slot1: None,
515 };
516
517 let err = store
518 .commit_payload(payload(2))
519 .expect_err("overflow must fail");
520
521 assert_eq!(
522 err,
523 CommitRecoveryError::GenerationOverflow {
524 generation: u64::MAX
525 }
526 );
527 }
528
529 #[test]
530 fn diagnostic_reports_authoritative_generation_and_corrupt_slots() {
531 let mut store = DualCommitStore::default();
532 store.commit_payload(payload(1)).expect("first commit");
533 store.write_corrupt_inactive_slot(1, payload(2));
534
535 let diagnostic = store.diagnostic();
536
537 assert_eq!(diagnostic.authoritative_generation, Some(0));
538 assert_eq!(diagnostic.recovery_error, None);
539 assert_eq!(diagnostic.slot0.generation, Some(0));
540 assert!(diagnostic.slot0.valid);
541 assert_eq!(diagnostic.slot1.generation, Some(1));
542 assert!(!diagnostic.slot1.valid);
543 }
544
545 #[test]
546 fn diagnostic_reports_no_valid_generation_for_empty_store() {
547 let diagnostic = DualCommitStore::default().diagnostic();
548
549 assert_eq!(diagnostic.authoritative_generation, None);
550 assert_eq!(
551 diagnostic.recovery_error,
552 Some(CommitRecoveryError::NoValidGeneration)
553 );
554 assert!(!diagnostic.slot0.present);
555 assert!(!diagnostic.slot1.present);
556 }
557
558 #[test]
559 fn diagnostic_builds_from_any_dual_protected_store() {
560 #[derive(Eq, PartialEq)]
561 struct TestSlot {
562 generation: u64,
563 valid: bool,
564 }
565
566 impl ProtectedGenerationSlot for TestSlot {
567 fn generation(&self) -> u64 {
568 self.generation
569 }
570
571 fn validates(&self) -> bool {
572 self.valid
573 }
574 }
575
576 struct TestStore {
577 slot0: Option<TestSlot>,
578 slot1: Option<TestSlot>,
579 }
580
581 impl DualProtectedCommitStore for TestStore {
582 type Slot = TestSlot;
583
584 fn slot0(&self) -> Option<&Self::Slot> {
585 self.slot0.as_ref()
586 }
587
588 fn slot1(&self) -> Option<&Self::Slot> {
589 self.slot1.as_ref()
590 }
591 }
592
593 let diagnostic = CommitStoreDiagnostic::from_store(&TestStore {
594 slot0: Some(TestSlot {
595 generation: 8,
596 valid: true,
597 }),
598 slot1: Some(TestSlot {
599 generation: 9,
600 valid: false,
601 }),
602 });
603
604 assert_eq!(diagnostic.authoritative_generation, Some(8));
605 assert!(diagnostic.slot0.valid);
606 assert_eq!(diagnostic.slot1.generation, Some(9));
607 assert!(!diagnostic.slot1.valid);
608 }
609
610 #[test]
611 fn uninitialized_distinguishes_empty_from_corrupt() {
612 let mut store = DualCommitStore::default();
613 assert!(store.is_uninitialized());
614
615 store.write_corrupt_inactive_slot(0, payload(1));
616
617 assert!(!store.is_uninitialized());
618 }
619
620 #[test]
621 fn commit_after_corrupt_slot_advances_from_prior_valid_generation() {
622 let mut store = DualCommitStore::default();
623 store.commit_payload(payload(1)).expect("first commit");
624 store.write_corrupt_inactive_slot(1, payload(2));
625 store.commit_payload(payload(3)).expect("third commit");
626
627 let authoritative = store.authoritative().expect("authoritative");
628
629 assert_eq!(authoritative.generation, 1);
630 assert_eq!(authoritative.payload, payload(3));
631 }
632}