use crate::{
batch::{
Batch,
BlockSpan,
LogEvent,
},
checkpoint::{
CheckpointRing,
Slot,
},
error::{
ApplyError,
ConfigError,
DivergenceCause,
EngineStatus,
FoldError,
RollbackError,
},
fold::Fold,
position::{
BlockRef,
Position,
},
ring::{
BlockRing,
Observed,
},
};
const MIN_RING_CAPACITY: usize = 2;
const MAX_RING_CAPACITY: usize = 1 << 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EngineConfig {
pub ring_capacity: usize,
pub checkpoint_slots: usize,
}
impl EngineConfig {
pub(crate) fn validate(&self) -> Result<(), ConfigError> {
if !self.ring_capacity.is_power_of_two() {
return Err(ConfigError::RingCapacityNotPowerOfTwo {
got: self.ring_capacity,
});
}
if !(MIN_RING_CAPACITY..=MAX_RING_CAPACITY).contains(&self.ring_capacity) {
return Err(ConfigError::RingCapacityOutOfRange {
got: self.ring_capacity,
});
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ApplySummary {
pub applied: u64,
pub deduped: u64,
pub skipped: u64,
}
#[derive(Debug)]
pub struct Engine<F> {
fold: F,
cursor: Option<Position>,
ring: BlockRing,
checkpoints: CheckpointRing<F>,
status: EngineStatus,
last_verified: Option<BlockRef>,
skips: u64,
}
impl<F: Fold> Engine<F> {
pub fn new(fold: F, config: EngineConfig) -> Result<Self, ConfigError> {
config.validate()?;
Ok(Self {
ring: BlockRing::with_capacity(config.ring_capacity),
checkpoints: CheckpointRing::new(config.checkpoint_slots),
fold,
cursor: None,
status: EngineStatus::Active,
last_verified: None,
skips: 0,
})
}
pub fn status(&self) -> EngineStatus {
self.status
}
pub fn cursor(&self) -> Option<Position> {
self.cursor
}
pub fn last_verified(&self) -> Option<BlockRef> {
self.last_verified
}
pub fn skip_count(&self) -> u64 {
self.skips
}
pub fn checkpoint_count(&self) -> usize {
self.checkpoints.count()
}
pub fn durable_point(&self) -> Option<Position> {
self.checkpoints.oldest().and_then(|slot| slot.cursor)
}
#[cfg(feature = "wincode")]
pub(crate) fn oldest_checkpoint(&self) -> Option<&Slot<F>> {
self.checkpoints.oldest()
}
pub fn fold(&self) -> &F {
&self.fold
}
pub fn view(&self) -> F::View {
self.fold.view()
}
pub fn observed(&self) -> Observed<'_> {
self.ring.iter()
}
pub fn apply_batch(
&mut self,
batch: &Batch<F::Event>,
) -> Result<ApplySummary, ApplyError<F::Error>> {
if !self.status.is_active() {
return Err(ApplyError::NotActive {
status: self.status,
});
}
batch.validate().map_err(ApplyError::Shape)?;
if let Some(cursor) = self.cursor {
let boundary = batch.boundary.ok_or(ApplyError::MissingBoundary)?;
if boundary.number != cursor.block {
return Err(ApplyError::BoundaryNumberMismatch {
expected: cursor.block,
got: boundary.number,
});
}
let observed_hash = self.ring.hash_at(cursor.block).ok_or(
ApplyError::CursorBlockUnobserved {
block: cursor.block,
},
)?;
if observed_hash != boundary.hash {
return Err(fork_suspected(cursor.block, observed_hash, boundary));
}
self.last_verified = Some(boundary);
}
let mut summary = ApplySummary::default();
for span in &batch.spans {
let redelivered = self
.cursor
.is_some_and(|cursor| span.block.number <= cursor.block);
if redelivered
&& let Some(observed_hash) = self.ring.hash_at(span.block.number)
&& observed_hash != span.block.hash
{
return Err(fork_suspected(span.block.number, observed_hash, span.block));
}
self.apply_span(span, batch, &mut summary)?;
}
Ok(summary)
}
pub fn checkpoint(&mut self)
where
F: Clone,
{
if !self.status.is_active() {
return;
}
self.checkpoints.store(Slot {
fold: self.fold.clone(),
cursor: self.cursor,
ring: self.ring.clone(),
});
}
#[cold]
pub fn rollback_at_or_below(
&mut self,
block: u64,
) -> Result<Option<Position>, RollbackError>
where
F: Clone,
{
if let EngineStatus::Unrecoverable { cause } = self.status {
return Err(RollbackError::Unrecoverable { cause });
}
let slot = self
.checkpoints
.best_at_or_below(block)
.ok_or(RollbackError::NoCheckpointAtOrBelow { block })?;
self.fold = slot.fold.clone();
self.cursor = slot.cursor;
self.ring = slot.ring.clone();
self.status = EngineStatus::Active;
self.last_verified = None;
self.checkpoints.drop_above(block);
Ok(self.cursor)
}
pub fn reset(&mut self, fold: F) {
self.ring.clear();
self.checkpoints.clear();
self.fold = fold;
self.cursor = None;
self.status = EngineStatus::Active;
self.last_verified = None;
self.skips = 0;
}
#[cold]
pub fn mark_unrecoverable(&mut self, cause: DivergenceCause) {
self.status = EngineStatus::Unrecoverable { cause };
}
#[cfg(any(feature = "wincode", test))]
pub(crate) fn restore_cursor_and_ring(
&mut self,
cursor: Option<Position>,
ring: BlockRing,
) {
self.cursor = cursor;
self.ring = ring;
}
fn apply_span(
&mut self,
span: &BlockSpan,
batch: &Batch<F::Event>,
summary: &mut ApplySummary,
) -> Result<(), ApplyError<F::Error>> {
let events = &batch.events[span.start as usize..span.end as usize];
let pos = |entry: &LogEvent<F::Event>| {
Position::new(span.block.number, entry.log_index)
};
let deduped = match self.cursor {
Some(cursor) if span.block.number > cursor.block => 0,
Some(cursor) if span.block.number < cursor.block => events.len(),
Some(cursor) => {
events.partition_point(|entry| entry.log_index <= cursor.log_index)
}
None => 0,
};
summary.deduped += deduped as u64;
let fresh = &events[deduped..];
let Some(last) = fresh.last() else {
return Ok(());
};
for (index, entry) in fresh.iter().enumerate() {
let at = pos(entry);
match self.fold.apply(at, &entry.event) {
Ok(()) => summary.applied += 1,
Err(FoldError::Skip(_)) => {
self.skips += 1;
summary.skipped += 1;
}
Err(FoldError::Halt(error)) => {
self.consumed_through(span.block, fresh, &pos, index);
return Err(self.halt(at, error));
}
Err(FoldError::Poison(error)) => {
self.consumed_through(span.block, fresh, &pos, index);
return Err(self.poison(at, error));
}
}
}
self.advance(span.block, pos(last));
Ok(())
}
#[cold]
fn consumed_through(
&mut self,
block: BlockRef,
fresh: &[LogEvent<F::Event>],
pos: &impl Fn(&LogEvent<F::Event>) -> Position,
index: usize,
) {
if let Some(entry) = index.checked_sub(1).and_then(|i| fresh.get(i)) {
self.advance(block, pos(entry));
}
}
#[inline]
fn advance(&mut self, block: BlockRef, pos: Position) {
if self
.ring
.newest()
.is_none_or(|newest| newest.number < block.number)
{
self.ring.push(block);
self.last_verified = Some(block);
}
self.cursor = Some(pos);
}
#[cold]
fn halt(&mut self, at: Position, error: F::Error) -> ApplyError<F::Error> {
self.status = EngineStatus::Halted { at };
ApplyError::Halted { at, error }
}
#[cold]
fn poison(&mut self, at: Position, error: F::Error) -> ApplyError<F::Error> {
self.status = EngineStatus::Poisoned { at };
ApplyError::Poisoned { at, error }
}
}
#[cold]
fn fork_suspected<E>(
number: u64,
observed_hash: [u8; 32],
refetched: BlockRef,
) -> ApplyError<E> {
ApplyError::ForkSuspected {
observed: BlockRef {
number,
hash: observed_hash,
},
refetched,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
batch::{
BatchShapeError,
LogEvent,
},
test_util::{
FailKind,
RecordingFold,
},
};
#[cfg(not(feature = "std"))]
use alloc::{
vec,
vec::Vec,
};
#[cfg(feature = "std")]
use std::{
vec,
vec::Vec,
};
fn block(number: u64, salt: u8) -> BlockRef {
let mut hash = [0u8; 32];
hash[..8].copy_from_slice(&number.to_le_bytes());
hash[8] = salt;
BlockRef { number, hash }
}
fn batch_of(
boundary: Option<BlockRef>,
spans: Vec<(BlockRef, Vec<u64>)>,
) -> Batch<u64> {
let mut events = Vec::new();
let mut built_spans = Vec::new();
for (block, log_indices) in spans {
let start = events.len() as u32;
for log_index in log_indices {
events.push(LogEvent {
log_index,
event: log_index,
});
}
let end = events.len() as u32;
built_spans.push(BlockSpan { block, start, end });
}
Batch {
boundary,
spans: built_spans,
events,
}
}
fn new_engine() -> Engine<RecordingFold> {
Engine::new(
RecordingFold::default(),
EngineConfig {
ring_capacity: 8,
checkpoint_slots: 0,
},
)
.unwrap()
}
fn engine_with_checkpoints(slots: usize) -> Engine<RecordingFold> {
Engine::new(
RecordingFold::default(),
EngineConfig {
ring_capacity: 8,
checkpoint_slots: slots,
},
)
.unwrap()
}
fn scripted_engine(fail_at: Position, kind: FailKind) -> Engine<RecordingFold> {
Engine::new(
RecordingFold {
applied: Vec::new(),
fail_at: Some((fail_at, kind)),
},
EngineConfig {
ring_capacity: 8,
checkpoint_slots: 0,
},
)
.unwrap()
}
#[test]
fn first_batch_applies_without_boundary() {
let mut engine = new_engine();
let batch = batch_of(
None,
vec![(block(1, 0), vec![0, 1]), (block(2, 0), vec![0])],
);
let summary = engine.apply_batch(&batch).unwrap();
assert_eq!(
summary,
ApplySummary {
applied: 3,
deduped: 0,
skipped: 0,
}
);
assert_eq!(engine.cursor(), Some(Position::new(2, 0)));
}
#[test]
fn matching_boundary_updates_freshness() {
let mut engine = new_engine();
let first = batch_of(None, vec![(block(5, 0), vec![0])]);
engine.apply_batch(&first).unwrap();
let next = batch_of(Some(block(5, 0)), vec![]);
engine.apply_batch(&next).unwrap();
assert_eq!(engine.last_verified(), Some(block(5, 0)));
}
#[test]
fn mismatched_boundary_reports_fork() {
let mut engine = new_engine();
let first = batch_of(None, vec![(block(5, 0), vec![0])]);
engine.apply_batch(&first).unwrap();
let view_before = engine.view();
let next = batch_of(Some(block(5, 1)), vec![]);
let result = engine.apply_batch(&next);
assert_eq!(
result,
Err(ApplyError::ForkSuspected {
observed: block(5, 0),
refetched: block(5, 1),
})
);
assert_eq!(engine.view(), view_before);
}
#[test]
fn wrong_boundary_number_is_rejected() {
let mut engine = new_engine();
let first = batch_of(None, vec![(block(5, 0), vec![0])]);
engine.apply_batch(&first).unwrap();
let next = batch_of(Some(block(4, 0)), vec![]);
let result = engine.apply_batch(&next);
assert_eq!(
result,
Err(ApplyError::BoundaryNumberMismatch {
expected: 5,
got: 4,
})
);
}
#[test]
fn missing_boundary_with_cursor_is_rejected() {
let mut engine = new_engine();
let first = batch_of(None, vec![(block(5, 0), vec![0])]);
engine.apply_batch(&first).unwrap();
let next = batch_of(None, vec![]);
let result = engine.apply_batch(&next);
assert_eq!(result, Err(ApplyError::MissingBoundary));
}
#[test]
fn redelivered_span_with_same_hash_dedupes() {
let mut engine = new_engine();
let first = batch_of(None, vec![(block(5, 0), vec![0, 1])]);
engine.apply_batch(&first).unwrap();
let next = batch_of(Some(block(5, 0)), vec![(block(5, 0), vec![0, 1])]);
let summary = engine.apply_batch(&next).unwrap();
assert_eq!(
summary,
ApplySummary {
applied: 0,
deduped: 2,
skipped: 0,
}
);
}
#[test]
fn redelivered_span_with_different_hash_reports_fork() {
let mut engine = new_engine();
let first = batch_of(None, vec![(block(5, 0), vec![0])]);
engine.apply_batch(&first).unwrap();
let next = batch_of(Some(block(5, 0)), vec![(block(5, 1), vec![0])]);
let result = engine.apply_batch(&next);
assert_eq!(
result,
Err(ApplyError::ForkSuspected {
observed: block(5, 0),
refetched: block(5, 1),
})
);
}
#[test]
fn skip_advances_cursor_and_counter() {
let mut engine = scripted_engine(Position::new(1, 0), FailKind::Skip);
let batch = batch_of(None, vec![(block(1, 0), vec![0, 1])]);
let summary = engine.apply_batch(&batch).unwrap();
assert_eq!(
summary,
ApplySummary {
applied: 1,
deduped: 0,
skipped: 1,
}
);
assert_eq!(engine.cursor(), Some(Position::new(1, 1)));
assert_eq!(engine.skip_count(), 1);
}
#[test]
fn a_lone_skip_consumes_its_position_and_observes_the_block() {
let mut engine = scripted_engine(Position::new(1, 0), FailKind::Skip);
let batch = batch_of(None, vec![(block(1, 0), vec![0])]);
let summary = engine.apply_batch(&batch).unwrap();
assert_eq!(
summary,
ApplySummary {
applied: 0,
deduped: 0,
skipped: 1,
}
);
assert_eq!(engine.cursor(), Some(Position::new(1, 0)));
assert_eq!(engine.observed().collect::<Vec<_>>(), vec![block(1, 0)]);
assert_eq!(engine.last_verified(), Some(block(1, 0)));
}
#[test]
fn halt_stops_at_declared_position() {
let halt_pos = Position::new(1, 2);
let mut engine = scripted_engine(halt_pos, FailKind::Halt);
let batch = batch_of(None, vec![(block(1, 0), vec![0, 1, 2])]);
let result = engine.apply_batch(&batch);
assert_eq!(
result,
Err(ApplyError::Halted {
at: halt_pos,
error: FailKind::Halt,
})
);
assert_eq!(engine.cursor(), Some(Position::new(1, 1)));
assert_eq!(engine.status(), EngineStatus::Halted { at: halt_pos });
let next = batch_of(None, vec![(block(2, 0), vec![0])]);
let next_result = engine.apply_batch(&next);
assert_eq!(
next_result,
Err(ApplyError::NotActive {
status: EngineStatus::Halted { at: halt_pos },
})
);
}
#[test]
fn halt_mid_span_leaves_the_ring_on_the_cursor_block() {
let halt_pos = Position::new(1, 1);
let mut engine = Engine::new(
RecordingFold {
applied: Vec::new(),
fail_at: Some((halt_pos, FailKind::Halt)),
},
EngineConfig {
ring_capacity: 8,
checkpoint_slots: 2,
},
)
.unwrap();
let halted =
engine.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0, 1, 2])]));
assert!(matches!(halted, Err(ApplyError::Halted { .. })));
assert_eq!(engine.cursor(), Some(Position::new(1, 0)));
assert_eq!(engine.observed().collect::<Vec<_>>(), vec![block(1, 0)]);
}
#[test]
fn halt_before_a_block_leaves_it_out_of_the_ring() {
let halt_pos = Position::new(2, 0);
let mut engine = Engine::new(
RecordingFold {
applied: Vec::new(),
fail_at: Some((halt_pos, FailKind::Halt)),
},
EngineConfig {
ring_capacity: 8,
checkpoint_slots: 2,
},
)
.unwrap();
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
let halted = engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(2, 0), vec![0])]));
assert!(matches!(halted, Err(ApplyError::Halted { .. })));
assert_eq!(engine.cursor(), Some(Position::new(1, 0)));
assert_eq!(engine.observed().collect::<Vec<_>>(), vec![block(1, 0)]);
}
#[test]
fn checkpoint_after_a_halt_is_refused() {
let halt_pos = Position::new(1, 1);
let mut engine = Engine::new(
RecordingFold {
applied: Vec::new(),
fail_at: Some((halt_pos, FailKind::Halt)),
},
EngineConfig {
ring_capacity: 8,
checkpoint_slots: 4,
},
)
.unwrap();
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
engine.checkpoint();
let halted = engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(1, 0), vec![1])]));
assert!(matches!(halted, Err(ApplyError::Halted { .. })));
engine.checkpoint();
let restored = engine.rollback_at_or_below(1).unwrap();
assert_eq!(engine.checkpoint_count(), 1);
assert_eq!(restored, Some(Position::new(1, 0)));
assert_eq!(engine.status(), EngineStatus::Active);
let resumed = engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(2, 0), vec![0])]));
assert!(resumed.is_ok());
}
#[test]
fn rollback_clears_freshness() {
let mut engine = engine_with_checkpoints(4);
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
engine.checkpoint();
engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(2, 0), vec![0])]))
.unwrap();
assert_eq!(engine.last_verified(), Some(block(2, 0)));
engine.rollback_at_or_below(1).unwrap();
assert_eq!(engine.last_verified(), None);
}
#[test]
fn unobserved_cursor_block_is_typed() {
let mut engine = new_engine();
engine.restore_cursor_and_ring(
Some(Position::new(5, 0)),
BlockRing::with_capacity(8),
);
let result = engine.apply_batch(&batch_of(Some(block(5, 0)), vec![]));
assert_eq!(result, Err(ApplyError::CursorBlockUnobserved { block: 5 }));
}
#[test]
fn poison_marks_state_untrusted() {
let poison_pos = Position::new(1, 0);
let mut engine = scripted_engine(poison_pos, FailKind::Poison);
let batch = batch_of(None, vec![(block(1, 0), vec![0])]);
let result = engine.apply_batch(&batch);
assert_eq!(
result,
Err(ApplyError::Poisoned {
at: poison_pos,
error: FailKind::Poison,
})
);
assert_eq!(engine.status(), EngineStatus::Poisoned { at: poison_pos });
assert_eq!(engine.view(), vec![(poison_pos, 0)]);
}
#[test]
fn ring_records_each_observed_block_once() {
let mut engine = new_engine();
let first = batch_of(None, vec![(block(1, 0), vec![0]), (block(2, 0), vec![0])]);
engine.apply_batch(&first).unwrap();
let second = batch_of(
Some(block(2, 0)),
vec![(block(3, 0), vec![0]), (block(4, 0), vec![0])],
);
engine.apply_batch(&second).unwrap();
let observed: Vec<BlockRef> = engine.observed().collect();
assert_eq!(
observed,
vec![block(1, 0), block(2, 0), block(3, 0), block(4, 0)]
);
}
#[test]
fn invalid_shape_is_rejected_before_fold_runs() {
let mut engine = new_engine();
let batch = Batch {
boundary: None,
spans: vec![
BlockSpan {
block: block(1, 0),
start: 0,
end: 1,
},
BlockSpan {
block: block(2, 0),
start: 2,
end: 3,
},
],
events: vec![
LogEvent {
log_index: 0,
event: 0u64,
},
LogEvent {
log_index: 0,
event: 0u64,
},
LogEvent {
log_index: 0,
event: 0u64,
},
],
};
let result = engine.apply_batch(&batch);
assert_eq!(
result,
Err(ApplyError::Shape(BatchShapeError::SpansNotContiguous {
span: 1
}))
);
assert_eq!(engine.view(), Vec::<(Position, u64)>::new());
}
#[test]
fn config_rejects_non_power_of_two_ring() {
let config = EngineConfig {
ring_capacity: 12,
checkpoint_slots: 0,
};
let result = Engine::new(RecordingFold::default(), config);
assert_eq!(
result.err(),
Some(ConfigError::RingCapacityNotPowerOfTwo { got: 12 })
);
}
#[test]
fn checkpoint_then_rollback_restores_view() {
let mut engine = engine_with_checkpoints(4);
engine
.apply_batch(&batch_of(None, vec![(block(3, 0), vec![0])]))
.unwrap();
engine.checkpoint();
let checkpoint_view = engine.view();
let checkpoint_cursor = engine.cursor();
let rest = batch_of(
Some(block(3, 0)),
vec![
(block(4, 0), vec![0]),
(block(5, 0), vec![0]),
(block(6, 0), vec![0]),
],
);
engine.apply_batch(&rest).unwrap();
let restored = engine.rollback_at_or_below(4).unwrap();
assert_eq!(engine.view(), checkpoint_view);
assert_eq!(restored, checkpoint_cursor);
assert_eq!(engine.cursor(), checkpoint_cursor);
}
#[test]
fn rollback_prefers_newest_eligible_checkpoint() {
let mut engine = engine_with_checkpoints(4);
engine
.apply_batch(&batch_of(None, vec![(block(2, 0), vec![0])]))
.unwrap();
engine.checkpoint();
engine
.apply_batch(&batch_of(Some(block(2, 0)), vec![(block(4, 0), vec![0])]))
.unwrap();
engine.checkpoint();
engine
.apply_batch(&batch_of(Some(block(4, 0)), vec![(block(6, 0), vec![0])]))
.unwrap();
let restored = engine.rollback_at_or_below(5).unwrap();
assert_eq!(restored, Some(Position::new(4, 0)));
}
#[test]
fn rollback_without_eligible_checkpoint_is_typed() {
let mut engine = engine_with_checkpoints(4);
engine
.apply_batch(&batch_of(None, vec![(block(6, 0), vec![0])]))
.unwrap();
engine.checkpoint();
let result = engine.rollback_at_or_below(4);
assert_eq!(
result,
Err(RollbackError::NoCheckpointAtOrBelow { block: 4 })
);
}
#[test]
fn zero_slots_never_checkpoints() {
let mut engine = new_engine();
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
engine.checkpoint();
let result = engine.rollback_at_or_below(1);
assert_eq!(engine.checkpoint_count(), 0);
assert_eq!(
result,
Err(RollbackError::NoCheckpointAtOrBelow { block: 1 })
);
}
#[test]
fn slot_ring_overwrites_oldest() {
let mut engine = engine_with_checkpoints(2);
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
engine.checkpoint();
engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(2, 0), vec![0])]))
.unwrap();
engine.checkpoint();
engine
.apply_batch(&batch_of(Some(block(2, 0)), vec![(block(3, 0), vec![0])]))
.unwrap();
engine.checkpoint();
let result = engine.rollback_at_or_below(1);
assert_eq!(
result,
Err(RollbackError::NoCheckpointAtOrBelow { block: 1 })
);
}
#[test]
fn rollback_clears_halted_state() {
let halt_pos = Position::new(2, 0);
let mut engine = Engine::new(
RecordingFold {
applied: Vec::new(),
fail_at: Some((halt_pos, FailKind::Halt)),
},
EngineConfig {
ring_capacity: 8,
checkpoint_slots: 2,
},
)
.unwrap();
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
engine.checkpoint();
let checkpoint_cursor = engine.cursor();
let halted = engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(2, 0), vec![0])]));
assert_eq!(
halted,
Err(ApplyError::Halted {
at: halt_pos,
error: FailKind::Halt,
})
);
let restored = engine.rollback_at_or_below(1).unwrap();
assert_eq!(engine.status(), EngineStatus::Active);
assert_eq!(restored, checkpoint_cursor);
let resumed = engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(3, 0), vec![0])]));
assert!(resumed.is_ok());
assert_eq!(engine.cursor(), Some(Position::new(3, 0)));
}
#[test]
fn rollback_restores_poisoned_state() {
let poison_pos = Position::new(2, 0);
let mut engine = Engine::new(
RecordingFold {
applied: Vec::new(),
fail_at: Some((poison_pos, FailKind::Poison)),
},
EngineConfig {
ring_capacity: 8,
checkpoint_slots: 2,
},
)
.unwrap();
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
engine.checkpoint();
let checkpoint_view = engine.view();
let poisoned = engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(2, 0), vec![0])]));
assert!(matches!(poisoned, Err(ApplyError::Poisoned { .. })));
assert_ne!(engine.view(), checkpoint_view);
engine.rollback_at_or_below(1).unwrap();
assert_eq!(engine.view(), checkpoint_view);
assert_eq!(engine.status(), EngineStatus::Active);
}
#[test]
fn rollback_drops_checkpoints_above_restore_point() {
let mut engine = engine_with_checkpoints(4);
engine
.apply_batch(&batch_of(None, vec![(block(2, 0), vec![0])]))
.unwrap();
engine.checkpoint();
engine
.apply_batch(&batch_of(Some(block(2, 0)), vec![(block(5, 0), vec![0])]))
.unwrap();
engine.checkpoint();
engine.rollback_at_or_below(3).unwrap();
assert_eq!(engine.checkpoint_count(), 1);
}
#[test]
fn rollback_truncates_observed_ring() {
let mut engine = engine_with_checkpoints(4);
engine
.apply_batch(&batch_of(
None,
vec![(block(1, 0), vec![0]), (block(2, 0), vec![0])],
))
.unwrap();
engine.checkpoint();
engine
.apply_batch(&batch_of(
Some(block(2, 0)),
vec![(block(3, 0), vec![0]), (block(4, 0), vec![0])],
))
.unwrap();
engine.rollback_at_or_below(2).unwrap();
let observed: Vec<BlockRef> = engine.observed().collect();
assert_eq!(observed, vec![block(1, 0), block(2, 0)]);
let result = engine
.apply_batch(&batch_of(Some(block(2, 0)), vec![(block(3, 0), vec![0])]));
assert!(result.is_ok());
}
#[test]
fn reset_returns_engine_to_genesis() {
let mut engine = new_engine();
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
engine.reset(RecordingFold::default());
assert_eq!(engine.cursor(), None);
assert_eq!(engine.observed().len(), 0);
assert_eq!(engine.checkpoint_count(), 0);
assert_eq!(engine.status(), EngineStatus::Active);
}
#[test]
fn unrecoverable_refuses_apply_and_rollback() {
let mut engine = new_engine();
engine.mark_unrecoverable(DivergenceCause::ForkBeyondWindow);
let apply_result =
engine.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]));
let rollback_result = engine.rollback_at_or_below(0);
assert_eq!(
apply_result,
Err(ApplyError::NotActive {
status: EngineStatus::Unrecoverable {
cause: DivergenceCause::ForkBeyondWindow
},
})
);
assert_eq!(
rollback_result,
Err(RollbackError::Unrecoverable {
cause: DivergenceCause::ForkBeyondWindow,
})
);
}
#[test]
fn durable_point_is_the_oldest_retained_checkpoint_cursor() {
let mut engine = engine_with_checkpoints(3);
let mut boundary = None;
for number in [1u64, 3, 5, 7, 9, 11] {
engine
.apply_batch(&batch_of(boundary, vec![(block(number, 0), vec![0])]))
.unwrap();
engine.checkpoint();
boundary = Some(block(number, 0));
}
let point = engine.durable_point();
assert_eq!(point, Some(Position::new(7, 0)));
}
#[test]
fn durable_point_is_none_without_checkpoints() {
let engine = new_engine();
let point = engine.durable_point();
assert_eq!(point, None);
}
#[test]
fn durable_point_follows_rollback_dropping_newer_slots() {
let mut engine = engine_with_checkpoints(4);
engine
.apply_batch(&batch_of(None, vec![(block(2, 0), vec![0])]))
.unwrap();
engine.checkpoint();
engine
.apply_batch(&batch_of(Some(block(2, 0)), vec![(block(5, 0), vec![0])]))
.unwrap();
engine.checkpoint();
engine.rollback_at_or_below(3).unwrap();
assert_eq!(engine.durable_point(), Some(Position::new(2, 0)));
}
#[test]
fn cursorless_oldest_slot_yields_no_durable_point() {
let mut engine = engine_with_checkpoints(3);
engine.checkpoint();
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
engine.checkpoint();
let point = engine.durable_point();
assert_eq!(point, None);
}
#[test]
fn repeated_rollback_to_same_checkpoint_succeeds() {
let mut engine = engine_with_checkpoints(4);
engine
.apply_batch(&batch_of(None, vec![(block(1, 0), vec![0])]))
.unwrap();
engine.checkpoint();
let checkpoint_view = engine.view();
let checkpoint_cursor = engine.cursor();
engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(2, 0), vec![0])]))
.unwrap();
let first_restore = engine.rollback_at_or_below(1).unwrap();
engine
.apply_batch(&batch_of(Some(block(1, 0)), vec![(block(3, 0), vec![9])]))
.unwrap();
let second_restore = engine.rollback_at_or_below(1).unwrap();
assert_eq!(first_restore, checkpoint_cursor);
assert_eq!(second_restore, checkpoint_cursor);
assert_eq!(engine.view(), checkpoint_view);
}
#[test]
fn cursor_does_not_regress_when_the_stop_span_is_partially_deduped() {
let halt_pos = Position::new(5, 5);
let mut engine = scripted_engine(halt_pos, FailKind::Halt);
let first = batch_of(None, vec![(block(5, 0), vec![0, 1, 2, 3])]);
engine.apply_batch(&first).unwrap();
let next = batch_of(Some(block(5, 0)), vec![(block(5, 0), vec![0, 1, 5])]);
let result = engine.apply_batch(&next);
assert_eq!(
result,
Err(ApplyError::Halted {
at: halt_pos,
error: FailKind::Halt,
})
);
assert_eq!(engine.cursor(), Some(Position::new(5, 3)));
}
}