refactor(fuzz): modularize fuzz testing (#211)
This commit is contained in:
parent
f4194bc728
commit
ec3fb62baf
|
@ -416,7 +416,7 @@ mod test {
|
|||
let mut next_id = block.id;
|
||||
next_id[0] += 1;
|
||||
|
||||
return Block {
|
||||
Block {
|
||||
view: block.view + 1,
|
||||
id: next_id,
|
||||
parent_qc: Qc::Standard(StandardQc {
|
||||
|
@ -424,7 +424,7 @@ mod test {
|
|||
id: block.id,
|
||||
}),
|
||||
leader_proof: LeaderProof::LeaderId { leader_id: [0; 32] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -463,8 +463,8 @@ mod test {
|
|||
|
||||
let mut block2 = next_block(&block1);
|
||||
block2.id = block1.id;
|
||||
engine = engine.receive_block(block2.clone()).unwrap();
|
||||
assert_eq!(engine.blocks_in_view(1), vec![block1.clone()]);
|
||||
engine = engine.receive_block(block2).unwrap();
|
||||
assert_eq!(engine.blocks_in_view(1), vec![block1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -484,7 +484,7 @@ mod test {
|
|||
leader_proof: LeaderProof::LeaderId { leader_id: [0; 32] },
|
||||
};
|
||||
|
||||
let _ = engine.receive_block(block.clone());
|
||||
let _ = engine.receive_block(block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -518,6 +518,7 @@ mod test {
|
|||
let block2 = next_block(&block1);
|
||||
engine = engine.receive_block(block2.clone()).unwrap();
|
||||
|
||||
#[allow(clippy::redundant_clone)]
|
||||
let mut block3 = block2.clone();
|
||||
block3.id = [3; 32]; // use a new ID, so that this block isn't ignored
|
||||
engine = engine.receive_block(block3.clone()).unwrap();
|
||||
|
@ -554,7 +555,7 @@ mod test {
|
|||
);
|
||||
|
||||
let block4 = next_block(&block3);
|
||||
engine = engine.receive_block(block4.clone()).unwrap();
|
||||
engine = engine.receive_block(block4).unwrap();
|
||||
assert_eq!(engine.latest_committed_block(), block2);
|
||||
assert_eq!(
|
||||
engine.committed_blocks(),
|
||||
|
@ -589,7 +590,7 @@ mod test {
|
|||
let engine = init_from_genesis();
|
||||
|
||||
let block = next_block(&engine.genesis_block());
|
||||
let _ = engine.approve_block(block.clone());
|
||||
let _ = engine.approve_block(block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -611,7 +612,7 @@ mod test {
|
|||
fn local_timeout() {
|
||||
let mut engine = init_from_genesis();
|
||||
let block = next_block(&engine.genesis_block());
|
||||
engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet
|
||||
engine = engine.receive_block(block).unwrap(); // received but not approved yet
|
||||
|
||||
let (engine, send) = engine.local_timeout();
|
||||
assert_eq!(engine.highest_voted_view, 1); // updated from 0 (genesis) to 1 (current_view)
|
||||
|
@ -637,7 +638,7 @@ mod test {
|
|||
fn receive_timeout_qc_after_local_timeout() {
|
||||
let mut engine = init_from_genesis();
|
||||
let block = next_block(&engine.genesis_block());
|
||||
engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet
|
||||
engine = engine.receive_block(block).unwrap(); // received but not approved yet
|
||||
|
||||
let (mut engine, _) = engine.local_timeout();
|
||||
|
||||
|
@ -652,7 +653,7 @@ mod test {
|
|||
};
|
||||
engine = engine.receive_timeout_qc(timeout_qc.clone());
|
||||
assert_eq!(engine.local_high_qc, timeout_qc.high_qc);
|
||||
assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc.clone()));
|
||||
assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc));
|
||||
assert_eq!(engine.current_view(), 2);
|
||||
}
|
||||
|
||||
|
@ -661,7 +662,7 @@ mod test {
|
|||
fn receive_timeout_qc_before_local_timeout() {
|
||||
let mut engine = init_from_genesis();
|
||||
let block = next_block(&engine.genesis_block());
|
||||
engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet
|
||||
engine = engine.receive_block(block).unwrap(); // received but not approved yet
|
||||
|
||||
// before local_timeout occurs
|
||||
|
||||
|
@ -676,7 +677,7 @@ mod test {
|
|||
};
|
||||
engine = engine.receive_timeout_qc(timeout_qc.clone());
|
||||
assert_eq!(engine.local_high_qc, timeout_qc.high_qc);
|
||||
assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc.clone()));
|
||||
assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc));
|
||||
assert_eq!(engine.current_view(), 2);
|
||||
}
|
||||
|
||||
|
@ -685,7 +686,7 @@ mod test {
|
|||
fn approve_new_view() {
|
||||
let mut engine = init_from_genesis();
|
||||
let block = next_block(&engine.genesis_block());
|
||||
engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet
|
||||
engine = engine.receive_block(block).unwrap(); // received but not approved yet
|
||||
|
||||
assert_eq!(engine.current_view(), 1); // still waiting for a QC(view=1)
|
||||
let timeout_qc = TimeoutQc {
|
||||
|
@ -707,12 +708,12 @@ mod test {
|
|||
assert_eq!(engine.current_view(), 2); // not changed
|
||||
assert_eq!(engine.highest_voted_view, 2);
|
||||
assert_eq!(
|
||||
send.clone().payload,
|
||||
send.payload,
|
||||
Payload::NewView(NewView {
|
||||
view: 2,
|
||||
sender: [0; 32],
|
||||
timeout_qc: timeout_qc.clone(),
|
||||
high_qc: timeout_qc.clone().high_qc,
|
||||
high_qc: timeout_qc.high_qc,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -722,7 +723,7 @@ mod test {
|
|||
fn approve_new_view_not_bigger_than_timeout_qc() {
|
||||
let mut engine = init_from_genesis();
|
||||
let block = next_block(&engine.genesis_block());
|
||||
engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet
|
||||
engine = engine.receive_block(block).unwrap(); // received but not approved yet
|
||||
|
||||
assert_eq!(engine.current_view(), 1);
|
||||
let timeout_qc1 = TimeoutQc {
|
||||
|
@ -746,9 +747,9 @@ mod test {
|
|||
sender: [0; 32],
|
||||
};
|
||||
engine = engine.receive_timeout_qc(timeout_qc2.clone());
|
||||
assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc2.clone()));
|
||||
assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc2));
|
||||
|
||||
// we expect new_view(timeout_qc2), but...
|
||||
let _ = engine.approve_new_view(timeout_qc1.clone(), HashSet::new());
|
||||
let _ = engine.approve_new_view(timeout_qc1, HashSet::new());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
mod ref_state;
|
||||
pub mod sut;
|
||||
mod transition;
|
|
@ -0,0 +1,319 @@
|
|||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use consensus_engine::{Block, LeaderProof, NodeId, Qc, StandardQc, TimeoutQc, View};
|
||||
use proptest::prelude::*;
|
||||
use proptest::strategy::BoxedStrategy;
|
||||
use proptest_state_machine::ReferenceStateMachine;
|
||||
|
||||
use crate::fuzz::transition::Transition;
|
||||
|
||||
// A reference state machine (RefState) is used to generated state transitions.
|
||||
// To generate some kinds of transition, we may need to keep historical blocks in RefState.
|
||||
// Also, RefState can be used to check invariants of the real state machine in some cases.
|
||||
//
|
||||
// We should try to design this reference state as simple/intuitive as possible,
|
||||
// so that we don't need to replicate the logic implemented in consensus-engine.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RefState {
|
||||
chain: BTreeMap<View, ViewEntry>,
|
||||
highest_voted_view: View,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct ViewEntry {
|
||||
blocks: HashSet<Block>,
|
||||
timeout_qcs: HashSet<TimeoutQc>,
|
||||
}
|
||||
|
||||
const LEADER_PROOF: LeaderProof = LeaderProof::LeaderId { leader_id: [0; 32] };
|
||||
const INITIAL_HIGHEST_VOTED_VIEW: View = -1;
|
||||
const SENDER: NodeId = [0; 32];
|
||||
|
||||
impl ReferenceStateMachine for RefState {
|
||||
type State = Self;
|
||||
|
||||
type Transition = Transition;
|
||||
|
||||
// Initialize the reference state machine
|
||||
fn init_state() -> BoxedStrategy<Self::State> {
|
||||
let genesis_block = Block {
|
||||
view: 0,
|
||||
id: [0; 32],
|
||||
parent_qc: Qc::Standard(StandardQc::genesis()),
|
||||
leader_proof: LEADER_PROOF.clone(),
|
||||
};
|
||||
|
||||
Just(RefState {
|
||||
chain: BTreeMap::from([(
|
||||
genesis_block.view,
|
||||
ViewEntry {
|
||||
blocks: HashSet::from([genesis_block]),
|
||||
timeout_qcs: Default::default(),
|
||||
},
|
||||
)]),
|
||||
highest_voted_view: INITIAL_HIGHEST_VOTED_VIEW,
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
// Generate transitions based on the current reference state machine
|
||||
fn transitions(state: &Self::State) -> BoxedStrategy<Self::Transition> {
|
||||
// Instead of using verbose `if` statements here to filter out the types of transitions
|
||||
// which cannot be created based on the current reference state,
|
||||
// each `state.transition_*` function returns a Nop transition
|
||||
// if it cannot generate the promised transition for the current reference state.
|
||||
// Both reference and real state machine do nothing for Nop transitions.
|
||||
prop_oneof![
|
||||
state.transition_receive_safe_block(),
|
||||
state.transition_receive_unsafe_block(),
|
||||
state.transition_approve_block(),
|
||||
state.transition_approve_past_block(),
|
||||
state.transition_local_timeout(),
|
||||
state.transition_receive_timeout_qc_for_current_view(),
|
||||
state.transition_receive_timeout_qc_for_old_view(),
|
||||
]
|
||||
.boxed()
|
||||
}
|
||||
|
||||
// Check if the transition is valid for a given reference state, before applying the transition
|
||||
// If invalid, the transition will be ignored and a new transition will be generated.
|
||||
//
|
||||
// Also, preconditions are used for shrinking in failure cases.
|
||||
// Preconditions check if the transition is still valid after some shrinking is applied.
|
||||
// If the transition became invalid for the shrinked state, the shrinking is stopped or
|
||||
// is continued to other directions.
|
||||
fn preconditions(state: &Self::State, transition: &Self::Transition) -> bool {
|
||||
// In most cases, we need to check the same conditions again used to create transitions.
|
||||
// This is redundant for success cases, but is necessary for shrinking in failure cases,
|
||||
// because some transitions may no longer be valid after some shrinking is applied.
|
||||
match transition {
|
||||
Transition::Nop => true,
|
||||
Transition::ReceiveSafeBlock(block) => block.view >= state.current_view(),
|
||||
Transition::ReceiveUnsafeBlock(block) => block.view < state.current_view(),
|
||||
Transition::ApproveBlock(block) => state.highest_voted_view < block.view,
|
||||
Transition::ApprovePastBlock(block) => state.highest_voted_view >= block.view,
|
||||
Transition::LocalTimeout => true,
|
||||
Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => {
|
||||
timeout_qc.view == state.current_view()
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForOldView(timeout_qc) => {
|
||||
timeout_qc.view < state.current_view()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the given transition on the reference state machine,
|
||||
// so that it can be used to generate next transitions.
|
||||
fn apply(mut state: Self::State, transition: &Self::Transition) -> Self::State {
|
||||
match transition {
|
||||
Transition::Nop => {}
|
||||
Transition::ReceiveSafeBlock(block) => {
|
||||
state
|
||||
.chain
|
||||
.entry(block.view)
|
||||
.or_default()
|
||||
.blocks
|
||||
.insert(block.clone());
|
||||
}
|
||||
Transition::ReceiveUnsafeBlock(_) => {
|
||||
// Nothing to do because we expect the state doesn't change.
|
||||
}
|
||||
Transition::ApproveBlock(block) => {
|
||||
state.highest_voted_view = block.view;
|
||||
}
|
||||
Transition::ApprovePastBlock(_) => {
|
||||
// Nothing to do because we expect the state doesn't change.
|
||||
}
|
||||
Transition::LocalTimeout => {
|
||||
state.highest_voted_view = state.current_view();
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => {
|
||||
state
|
||||
.chain
|
||||
.entry(timeout_qc.view)
|
||||
.or_default()
|
||||
.timeout_qcs
|
||||
.insert(timeout_qc.clone());
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForOldView(_) => {
|
||||
// Nothing to do because we expect the state doesn't change.
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
impl RefState {
|
||||
// Generate a Transition::ReceiveSafeBlock.
|
||||
fn transition_receive_safe_block(&self) -> BoxedStrategy<Transition> {
|
||||
let recent_parents = self
|
||||
.chain
|
||||
.range(self.current_view() - 1..)
|
||||
.flat_map(|(_view, entry)| entry.blocks.iter().cloned())
|
||||
.collect::<Vec<Block>>();
|
||||
|
||||
if recent_parents.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
// proptest::sample::select panics if the input is empty
|
||||
proptest::sample::select(recent_parents)
|
||||
.prop_map(move |parent| -> Transition {
|
||||
Transition::ReceiveSafeBlock(Self::consecutive_block(&parent))
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::ReceiveUnsafeBlock.
|
||||
fn transition_receive_unsafe_block(&self) -> BoxedStrategy<Transition> {
|
||||
let old_parents = self
|
||||
.chain
|
||||
.range(..self.current_view() - 1)
|
||||
.flat_map(|(_view, entry)| entry.blocks.iter().cloned())
|
||||
.collect::<Vec<Block>>();
|
||||
|
||||
if old_parents.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
// proptest::sample::select panics if the input is empty
|
||||
proptest::sample::select(old_parents)
|
||||
.prop_map(move |parent| -> Transition {
|
||||
Transition::ReceiveUnsafeBlock(Self::consecutive_block(&parent))
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::ApproveBlock.
|
||||
fn transition_approve_block(&self) -> BoxedStrategy<Transition> {
|
||||
let blocks_not_voted = self
|
||||
.chain
|
||||
.range(self.highest_voted_view + 1..)
|
||||
.flat_map(|(_view, entry)| entry.blocks.iter().cloned())
|
||||
.collect::<Vec<Block>>();
|
||||
|
||||
if blocks_not_voted.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
// proptest::sample::select panics if the input is empty
|
||||
proptest::sample::select(blocks_not_voted)
|
||||
.prop_map(Transition::ApproveBlock)
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::ApprovePastBlock.
|
||||
fn transition_approve_past_block(&self) -> BoxedStrategy<Transition> {
|
||||
let past_blocks = self
|
||||
.chain
|
||||
.range(INITIAL_HIGHEST_VOTED_VIEW..self.highest_voted_view)
|
||||
.flat_map(|(_view, entry)| entry.blocks.iter().cloned())
|
||||
.collect::<Vec<Block>>();
|
||||
|
||||
if past_blocks.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
// proptest::sample::select panics if the input is empty
|
||||
proptest::sample::select(past_blocks)
|
||||
.prop_map(Transition::ApprovePastBlock)
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::LocalTimeout.
|
||||
fn transition_local_timeout(&self) -> BoxedStrategy<Transition> {
|
||||
Just(Transition::LocalTimeout).boxed()
|
||||
}
|
||||
|
||||
// Generate a Transition::ReceiveTimeoutQcForCurrentView
|
||||
fn transition_receive_timeout_qc_for_current_view(&self) -> BoxedStrategy<Transition> {
|
||||
let view = self.current_view();
|
||||
let high_qc = self
|
||||
.chain
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|(_, entry)| entry.high_qc());
|
||||
|
||||
if let Some(high_qc) = high_qc {
|
||||
Just(Transition::ReceiveTimeoutQcForCurrentView(TimeoutQc {
|
||||
view,
|
||||
high_qc,
|
||||
sender: SENDER,
|
||||
}))
|
||||
.boxed()
|
||||
} else {
|
||||
Just(Transition::Nop).boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::ReceiveTimeoutQcForOldView
|
||||
fn transition_receive_timeout_qc_for_old_view(&self) -> BoxedStrategy<Transition> {
|
||||
let old_view_entries: Vec<(View, ViewEntry)> = self
|
||||
.chain
|
||||
.range(..self.current_view())
|
||||
.filter(|(_, entry)| !entry.is_empty())
|
||||
.map(|(&view, entry)| (view, entry.clone()))
|
||||
.collect();
|
||||
|
||||
if old_view_entries.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
proptest::sample::select(old_view_entries)
|
||||
.prop_map(move |(view, entry)| {
|
||||
Transition::ReceiveTimeoutQcForOldView(TimeoutQc {
|
||||
view,
|
||||
high_qc: entry.high_qc().unwrap(),
|
||||
sender: SENDER,
|
||||
})
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highest_voted_view(&self) -> View {
|
||||
self.highest_voted_view
|
||||
}
|
||||
|
||||
pub fn current_view(&self) -> View {
|
||||
let (&last_view, last_entry) = self.chain.last_key_value().unwrap();
|
||||
if last_entry.timeout_qcs.is_empty() {
|
||||
last_view
|
||||
} else {
|
||||
let timeout_qc = last_entry.timeout_qcs.iter().next().unwrap();
|
||||
RefState::new_view_from(timeout_qc)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_view_from(timeout_qc: &TimeoutQc) -> View {
|
||||
timeout_qc.view + 1
|
||||
}
|
||||
|
||||
fn consecutive_block(parent: &Block) -> Block {
|
||||
Block {
|
||||
// use rand because we don't want this to be shrinked by proptest
|
||||
id: rand::thread_rng().gen(),
|
||||
view: parent.view + 1,
|
||||
parent_qc: Qc::Standard(StandardQc {
|
||||
view: parent.view,
|
||||
id: parent.id,
|
||||
}),
|
||||
leader_proof: LEADER_PROOF.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewEntry {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.blocks.is_empty() && self.timeout_qcs.is_empty()
|
||||
}
|
||||
|
||||
fn high_qc(&self) -> Option<StandardQc> {
|
||||
let iter1 = self.blocks.iter().map(|block| block.parent_qc.high_qc());
|
||||
let iter2 = self
|
||||
.timeout_qcs
|
||||
.iter()
|
||||
.map(|timeout_qc| timeout_qc.high_qc.clone());
|
||||
iter1.chain(iter2).max_by_key(|qc| qc.view)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
use std::panic;
|
||||
|
||||
use consensus_engine::{
|
||||
overlay::{FlatOverlay, RoundRobin, Settings},
|
||||
*,
|
||||
};
|
||||
use proptest_state_machine::{ReferenceStateMachine, StateMachineTest};
|
||||
|
||||
use crate::fuzz::ref_state::RefState;
|
||||
use crate::fuzz::transition::Transition;
|
||||
|
||||
// ConsensusEngineTest defines a state that we want to test.
|
||||
// This is called as SUT (System Under Test).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConsensusEngineTest {
|
||||
pub engine: Carnot<FlatOverlay<RoundRobin>>,
|
||||
}
|
||||
|
||||
impl ConsensusEngineTest {
|
||||
pub fn new() -> Self {
|
||||
let engine = Carnot::from_genesis(
|
||||
[0; 32],
|
||||
Block {
|
||||
view: 0,
|
||||
id: [0; 32],
|
||||
parent_qc: Qc::Standard(StandardQc::genesis()),
|
||||
leader_proof: LeaderProof::LeaderId { leader_id: [0; 32] },
|
||||
},
|
||||
FlatOverlay::new(Settings {
|
||||
nodes: vec![[0; 32]],
|
||||
leader: RoundRobin::default(),
|
||||
}),
|
||||
);
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
}
|
||||
|
||||
// StateMachineTest defines how transitions are applied to the real state machine
|
||||
// and what checks should be performed.
|
||||
impl StateMachineTest for ConsensusEngineTest {
|
||||
// SUT is the real state machine that we want to test.
|
||||
type SystemUnderTest = Self;
|
||||
|
||||
// A reference state machine that should be compared against the SUT.
|
||||
type Reference = RefState;
|
||||
|
||||
// Initialize the SUT state
|
||||
fn init_test(
|
||||
_ref_state: &<Self::Reference as proptest_state_machine::ReferenceStateMachine>::State,
|
||||
) -> Self::SystemUnderTest {
|
||||
ConsensusEngineTest::new()
|
||||
}
|
||||
|
||||
// Apply the transition on the SUT state and check post-conditions
|
||||
fn apply(
|
||||
state: Self::SystemUnderTest,
|
||||
_ref_state: &<Self::Reference as proptest_state_machine::ReferenceStateMachine>::State,
|
||||
transition: <Self::Reference as proptest_state_machine::ReferenceStateMachine>::Transition,
|
||||
) -> Self::SystemUnderTest {
|
||||
println!("{transition:?}");
|
||||
|
||||
match transition {
|
||||
Transition::Nop => state,
|
||||
Transition::ReceiveSafeBlock(block) => {
|
||||
// Because we generate/apply transitions sequentially,
|
||||
// this transition will always be applied to the same state that it was generated against.
|
||||
// In other words, we can assume that the `block` is always safe for the current state.
|
||||
let engine = state.engine.receive_block(block.clone()).unwrap();
|
||||
assert!(engine.blocks_in_view(block.view).contains(&block));
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
Transition::ReceiveUnsafeBlock(block) => {
|
||||
let result = panic::catch_unwind(|| state.engine.receive_block(block));
|
||||
assert!(result.is_err() || result.unwrap().is_err());
|
||||
|
||||
state
|
||||
}
|
||||
Transition::ApproveBlock(block) => {
|
||||
let (engine, _) = state.engine.approve_block(block.clone());
|
||||
assert_eq!(engine.highest_voted_view(), block.view);
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
Transition::ApprovePastBlock(block) => {
|
||||
let result = panic::catch_unwind(|| state.engine.approve_block(block));
|
||||
assert!(result.is_err());
|
||||
|
||||
state
|
||||
}
|
||||
Transition::LocalTimeout => {
|
||||
let (engine, _) = state.engine.local_timeout();
|
||||
assert_eq!(engine.highest_voted_view(), engine.current_view());
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => {
|
||||
let engine = state.engine.receive_timeout_qc(timeout_qc.clone());
|
||||
assert_eq!(engine.current_view(), RefState::new_view_from(&timeout_qc));
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForOldView(timeout_qc) => {
|
||||
#[allow(clippy::redundant_clone)]
|
||||
let prev_engine = state.engine.clone();
|
||||
let engine = state.engine.receive_timeout_qc(timeout_qc);
|
||||
|
||||
// Check that the engine state didn't change.
|
||||
assert_eq!(engine, prev_engine);
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check invariants after every transition.
|
||||
// We have the option to use RefState for comparison in some cases.
|
||||
fn check_invariants(
|
||||
state: &Self::SystemUnderTest,
|
||||
ref_state: &<Self::Reference as ReferenceStateMachine>::State,
|
||||
) {
|
||||
assert_eq!(state.engine.current_view(), ref_state.current_view());
|
||||
|
||||
assert_eq!(
|
||||
state.engine.highest_voted_view(),
|
||||
ref_state.highest_voted_view()
|
||||
);
|
||||
|
||||
//TODO: add more invariants with more public functions of Carnot
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
use consensus_engine::{Block, TimeoutQc};
|
||||
|
||||
// State transtitions that will be picked randomly
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Transition {
|
||||
Nop,
|
||||
ReceiveSafeBlock(Block),
|
||||
ReceiveUnsafeBlock(Block),
|
||||
ApproveBlock(Block),
|
||||
ApprovePastBlock(Block),
|
||||
LocalTimeout,
|
||||
ReceiveTimeoutQcForCurrentView(TimeoutQc),
|
||||
ReceiveTimeoutQcForOldView(TimeoutQc),
|
||||
//TODO: add more invalid transitions that must be rejected by consensus-engine
|
||||
//TODO: add more transitions
|
||||
}
|
|
@ -1,13 +1,12 @@
|
|||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
panic,
|
||||
};
|
||||
mod fuzz;
|
||||
|
||||
use std::panic;
|
||||
|
||||
use consensus_engine::{Block, LeaderProof, NodeId, Qc, StandardQc, TimeoutQc, View};
|
||||
use proptest::prelude::*;
|
||||
use proptest::test_runner::Config;
|
||||
use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest};
|
||||
use system_under_test::ConsensusEngineTest;
|
||||
|
||||
use fuzz::sut::ConsensusEngineTest;
|
||||
|
||||
prop_state_machine! {
|
||||
#![proptest_config(Config {
|
||||
|
@ -19,456 +18,5 @@ prop_state_machine! {
|
|||
|
||||
#[test]
|
||||
// run 50 state transitions per test case
|
||||
fn happy_path(sequential 1..50 => ConsensusEngineTest);
|
||||
}
|
||||
|
||||
// A reference state machine (RefState) is used to generated state transitions.
|
||||
// To generate some kinds of transition, we may need to keep historical blocks in RefState.
|
||||
// Also, RefState can be used to check invariants of the real state machine in some cases.
|
||||
//
|
||||
// We should try to design this reference state as simple/intuitive as possible,
|
||||
// so that we don't need to replicate the logic implemented in consensus-engine.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RefState {
|
||||
chain: BTreeMap<View, ViewEntry>,
|
||||
highest_voted_view: View,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct ViewEntry {
|
||||
blocks: HashSet<Block>,
|
||||
timeout_qcs: HashSet<TimeoutQc>,
|
||||
}
|
||||
|
||||
impl ViewEntry {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.blocks.is_empty() && self.timeout_qcs.is_empty()
|
||||
}
|
||||
|
||||
fn high_qc(&self) -> Option<StandardQc> {
|
||||
let iter1 = self.blocks.iter().map(|block| block.parent_qc.high_qc());
|
||||
let iter2 = self
|
||||
.timeout_qcs
|
||||
.iter()
|
||||
.map(|timeout_qc| timeout_qc.high_qc.clone());
|
||||
iter1.chain(iter2).max_by_key(|qc| qc.view)
|
||||
}
|
||||
}
|
||||
|
||||
// State transtitions that will be picked randomly
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Transition {
|
||||
Nop,
|
||||
ReceiveSafeBlock(Block),
|
||||
ReceiveUnsafeBlock(Block),
|
||||
ApproveBlock(Block),
|
||||
ApprovePastBlock(Block),
|
||||
LocalTimeout,
|
||||
ReceiveTimeoutQcForCurrentView(TimeoutQc),
|
||||
ReceiveTimeoutQcForOldView(TimeoutQc),
|
||||
//TODO: add more invalid transitions that must be rejected by consensus-engine
|
||||
//TODO: add more transitions
|
||||
}
|
||||
|
||||
const LEADER_PROOF: LeaderProof = LeaderProof::LeaderId { leader_id: [0; 32] };
|
||||
const INITIAL_HIGHEST_VOTED_VIEW: View = -1;
|
||||
const SENDER: NodeId = [0; 32];
|
||||
|
||||
impl ReferenceStateMachine for RefState {
|
||||
type State = Self;
|
||||
|
||||
type Transition = Transition;
|
||||
|
||||
// Initialize the reference state machine
|
||||
fn init_state() -> BoxedStrategy<Self::State> {
|
||||
let genesis_block = Block {
|
||||
view: 0,
|
||||
id: [0; 32],
|
||||
parent_qc: Qc::Standard(StandardQc::genesis()),
|
||||
leader_proof: LEADER_PROOF.clone(),
|
||||
};
|
||||
|
||||
Just(RefState {
|
||||
chain: BTreeMap::from([(
|
||||
genesis_block.view,
|
||||
ViewEntry {
|
||||
blocks: HashSet::from([genesis_block]),
|
||||
timeout_qcs: Default::default(),
|
||||
},
|
||||
)]),
|
||||
highest_voted_view: INITIAL_HIGHEST_VOTED_VIEW,
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
// Generate transitions based on the current reference state machine
|
||||
fn transitions(state: &Self::State) -> BoxedStrategy<Self::Transition> {
|
||||
// Instead of using verbose `if` statements here to filter out the types of transitions
|
||||
// which cannot be created based on the current reference state,
|
||||
// each `state.transition_*` function returns a Nop transition
|
||||
// if it cannot generate the promised transition for the current reference state.
|
||||
// Both reference and real state machine do nothing for Nop transitions.
|
||||
prop_oneof![
|
||||
state.transition_receive_safe_block(),
|
||||
state.transition_receive_unsafe_block(),
|
||||
state.transition_approve_block(),
|
||||
state.transition_approve_past_block(),
|
||||
state.transition_local_timeout(),
|
||||
state.transition_receive_timeout_qc_for_current_view(),
|
||||
state.transition_receive_timeout_qc_for_old_view(),
|
||||
]
|
||||
.boxed()
|
||||
}
|
||||
|
||||
// Check if the transition is valid for a given reference state, before applying the transition
|
||||
// If invalid, the transition will be ignored and a new transition will be generated.
|
||||
//
|
||||
// Also, preconditions are used for shrinking in failure cases.
|
||||
// Preconditions check if the transition is still valid after some shrinking is applied.
|
||||
// If the transition became invalid for the shrinked state, the shrinking is stopped or
|
||||
// is continued to other directions.
|
||||
fn preconditions(state: &Self::State, transition: &Self::Transition) -> bool {
|
||||
// In most cases, we need to check the same conditions again used to create transitions.
|
||||
// This is redundant for success cases, but is necessary for shrinking in failure cases,
|
||||
// because some transitions may no longer be valid after some shrinking is applied.
|
||||
match transition {
|
||||
Transition::Nop => true,
|
||||
Transition::ReceiveSafeBlock(block) => block.view >= state.current_view(),
|
||||
Transition::ReceiveUnsafeBlock(block) => block.view < state.current_view(),
|
||||
Transition::ApproveBlock(block) => state.highest_voted_view < block.view,
|
||||
Transition::ApprovePastBlock(block) => state.highest_voted_view >= block.view,
|
||||
Transition::LocalTimeout => true,
|
||||
Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => {
|
||||
timeout_qc.view == state.current_view()
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForOldView(timeout_qc) => {
|
||||
timeout_qc.view < state.current_view()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the given transition on the reference state machine,
|
||||
// so that it can be used to generate next transitions.
|
||||
fn apply(mut state: Self::State, transition: &Self::Transition) -> Self::State {
|
||||
match transition {
|
||||
Transition::Nop => {}
|
||||
Transition::ReceiveSafeBlock(block) => {
|
||||
state
|
||||
.chain
|
||||
.entry(block.view)
|
||||
.or_default()
|
||||
.blocks
|
||||
.insert(block.clone());
|
||||
}
|
||||
Transition::ReceiveUnsafeBlock(_) => {
|
||||
// Nothing to do because we expect the state doesn't change.
|
||||
}
|
||||
Transition::ApproveBlock(block) => {
|
||||
state.highest_voted_view = block.view;
|
||||
}
|
||||
Transition::ApprovePastBlock(_) => {
|
||||
// Nothing to do because we expect the state doesn't change.
|
||||
}
|
||||
Transition::LocalTimeout => {
|
||||
state.highest_voted_view = state.current_view();
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => {
|
||||
state
|
||||
.chain
|
||||
.entry(timeout_qc.view)
|
||||
.or_default()
|
||||
.timeout_qcs
|
||||
.insert(timeout_qc.clone());
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForOldView(_) => {
|
||||
// Nothing to do because we expect the state doesn't change.
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
impl RefState {
|
||||
// Generate a Transition::ReceiveSafeBlock.
|
||||
fn transition_receive_safe_block(&self) -> BoxedStrategy<Transition> {
|
||||
let recent_parents = self
|
||||
.chain
|
||||
.range(self.current_view() - 1..)
|
||||
.flat_map(|(_view, entry)| entry.blocks.iter().cloned())
|
||||
.collect::<Vec<Block>>();
|
||||
|
||||
if recent_parents.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
// proptest::sample::select panics if the input is empty
|
||||
proptest::sample::select(recent_parents)
|
||||
.prop_map(move |parent| -> Transition {
|
||||
Transition::ReceiveSafeBlock(Self::consecutive_block(&parent))
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::ReceiveUnsafeBlock.
|
||||
fn transition_receive_unsafe_block(&self) -> BoxedStrategy<Transition> {
|
||||
let old_parents = self
|
||||
.chain
|
||||
.range(..self.current_view() - 1)
|
||||
.flat_map(|(_view, entry)| entry.blocks.iter().cloned())
|
||||
.collect::<Vec<Block>>();
|
||||
|
||||
if old_parents.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
// proptest::sample::select panics if the input is empty
|
||||
proptest::sample::select(old_parents)
|
||||
.prop_map(move |parent| -> Transition {
|
||||
Transition::ReceiveUnsafeBlock(Self::consecutive_block(&parent))
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::ApproveBlock.
|
||||
fn transition_approve_block(&self) -> BoxedStrategy<Transition> {
|
||||
let blocks_not_voted = self
|
||||
.chain
|
||||
.range(self.highest_voted_view + 1..)
|
||||
.flat_map(|(_view, entry)| entry.blocks.iter().cloned())
|
||||
.collect::<Vec<Block>>();
|
||||
|
||||
if blocks_not_voted.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
// proptest::sample::select panics if the input is empty
|
||||
proptest::sample::select(blocks_not_voted)
|
||||
.prop_map(Transition::ApproveBlock)
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::ApprovePastBlock.
|
||||
fn transition_approve_past_block(&self) -> BoxedStrategy<Transition> {
|
||||
let past_blocks = self
|
||||
.chain
|
||||
.range(INITIAL_HIGHEST_VOTED_VIEW..self.highest_voted_view)
|
||||
.flat_map(|(_view, entry)| entry.blocks.iter().cloned())
|
||||
.collect::<Vec<Block>>();
|
||||
|
||||
if past_blocks.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
// proptest::sample::select panics if the input is empty
|
||||
proptest::sample::select(past_blocks)
|
||||
.prop_map(Transition::ApprovePastBlock)
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::LocalTimeout.
|
||||
fn transition_local_timeout(&self) -> BoxedStrategy<Transition> {
|
||||
Just(Transition::LocalTimeout).boxed()
|
||||
}
|
||||
|
||||
// Generate a Transition::ReceiveTimeoutQcForCurrentView
|
||||
fn transition_receive_timeout_qc_for_current_view(&self) -> BoxedStrategy<Transition> {
|
||||
let view = self.current_view();
|
||||
let high_qc = self
|
||||
.chain
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|(_, entry)| entry.high_qc());
|
||||
|
||||
if let Some(high_qc) = high_qc {
|
||||
Just(Transition::ReceiveTimeoutQcForCurrentView(TimeoutQc {
|
||||
view: view.clone(),
|
||||
high_qc,
|
||||
sender: SENDER,
|
||||
}))
|
||||
.boxed()
|
||||
} else {
|
||||
Just(Transition::Nop).boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a Transition::ReceiveTimeoutQcForOldView
|
||||
fn transition_receive_timeout_qc_for_old_view(&self) -> BoxedStrategy<Transition> {
|
||||
let old_view_entries: Vec<(View, ViewEntry)> = self
|
||||
.chain
|
||||
.range(..self.current_view())
|
||||
.filter(|(_, entry)| !entry.is_empty())
|
||||
.map(|(&view, entry)| (view, entry.clone()))
|
||||
.collect();
|
||||
|
||||
if old_view_entries.is_empty() {
|
||||
Just(Transition::Nop).boxed()
|
||||
} else {
|
||||
proptest::sample::select(old_view_entries)
|
||||
.prop_map(move |(view, entry)| {
|
||||
Transition::ReceiveTimeoutQcForOldView(TimeoutQc {
|
||||
view: view.clone(),
|
||||
high_qc: entry.high_qc().unwrap(),
|
||||
sender: SENDER,
|
||||
})
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
fn current_view(&self) -> View {
|
||||
let (&last_view, last_entry) = self.chain.last_key_value().unwrap();
|
||||
if last_entry.timeout_qcs.is_empty() {
|
||||
last_view
|
||||
} else {
|
||||
let timeout_qc = last_entry.timeout_qcs.iter().next().unwrap();
|
||||
RefState::new_view_from(&timeout_qc)
|
||||
}
|
||||
}
|
||||
|
||||
fn new_view_from(timeout_qc: &TimeoutQc) -> View {
|
||||
timeout_qc.view + 1
|
||||
}
|
||||
|
||||
fn consecutive_block(parent: &Block) -> Block {
|
||||
Block {
|
||||
// use rand because we don't want this to be shrinked by proptest
|
||||
id: rand::thread_rng().gen(),
|
||||
view: parent.view + 1,
|
||||
parent_qc: Qc::Standard(StandardQc {
|
||||
view: parent.view,
|
||||
id: parent.id,
|
||||
}),
|
||||
leader_proof: LEADER_PROOF.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StateMachineTest defines how transitions are applied to the real state machine
|
||||
// and what checks should be performed.
|
||||
impl StateMachineTest for ConsensusEngineTest {
|
||||
// SUT is the real state machine that we want to test.
|
||||
type SystemUnderTest = Self;
|
||||
|
||||
// A reference state machine that should be compared against the SUT.
|
||||
type Reference = RefState;
|
||||
|
||||
// Initialize the SUT state
|
||||
fn init_test(
|
||||
_ref_state: &<Self::Reference as proptest_state_machine::ReferenceStateMachine>::State,
|
||||
) -> Self::SystemUnderTest {
|
||||
ConsensusEngineTest::new()
|
||||
}
|
||||
|
||||
// Apply the transition on the SUT state and check post-conditions
|
||||
fn apply(
|
||||
state: Self::SystemUnderTest,
|
||||
_ref_state: &<Self::Reference as proptest_state_machine::ReferenceStateMachine>::State,
|
||||
transition: <Self::Reference as proptest_state_machine::ReferenceStateMachine>::Transition,
|
||||
) -> Self::SystemUnderTest {
|
||||
println!("{transition:?}");
|
||||
|
||||
match transition {
|
||||
Transition::Nop => state,
|
||||
Transition::ReceiveSafeBlock(block) => {
|
||||
// Because we generate/apply transitions sequentially,
|
||||
// this transition will always be applied to the same state that it was generated against.
|
||||
// In other words, we can assume that the `block` is always safe for the current state.
|
||||
let engine = state.engine.receive_block(block.clone()).unwrap();
|
||||
assert!(engine.blocks_in_view(block.view).contains(&block));
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
Transition::ReceiveUnsafeBlock(block) => {
|
||||
let result = panic::catch_unwind(|| state.engine.receive_block(block));
|
||||
assert!(result.is_err() || result.unwrap().is_err());
|
||||
|
||||
state
|
||||
}
|
||||
Transition::ApproveBlock(block) => {
|
||||
let (engine, _) = state.engine.approve_block(block.clone());
|
||||
assert_eq!(engine.highest_voted_view(), block.view);
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
Transition::ApprovePastBlock(block) => {
|
||||
let result = panic::catch_unwind(|| state.engine.approve_block(block));
|
||||
assert!(result.is_err());
|
||||
|
||||
state
|
||||
}
|
||||
Transition::LocalTimeout => {
|
||||
let (engine, _) = state.engine.local_timeout();
|
||||
assert_eq!(engine.highest_voted_view(), engine.current_view());
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => {
|
||||
let engine = state.engine.receive_timeout_qc(timeout_qc.clone());
|
||||
assert_eq!(engine.current_view(), RefState::new_view_from(&timeout_qc));
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
Transition::ReceiveTimeoutQcForOldView(timeout_qc) => {
|
||||
let prev_engine = state.engine.clone();
|
||||
let engine = state.engine.receive_timeout_qc(timeout_qc);
|
||||
|
||||
// Check that the engine state didn't change.
|
||||
assert_eq!(engine, prev_engine);
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check invariants after every transition.
|
||||
// We have the option to use RefState for comparison in some cases.
|
||||
fn check_invariants(
|
||||
state: &Self::SystemUnderTest,
|
||||
ref_state: &<Self::Reference as ReferenceStateMachine>::State,
|
||||
) {
|
||||
assert_eq!(state.engine.current_view(), ref_state.current_view());
|
||||
|
||||
assert_eq!(
|
||||
state.engine.highest_voted_view(),
|
||||
ref_state.highest_voted_view
|
||||
);
|
||||
|
||||
//TODO: add more invariants with more public functions of Carnot
|
||||
}
|
||||
}
|
||||
|
||||
// SUT is a system (state) that we want to test.
|
||||
mod system_under_test {
|
||||
use consensus_engine::{
|
||||
overlay::{FlatOverlay, RoundRobin, Settings},
|
||||
*,
|
||||
};
|
||||
|
||||
use crate::LEADER_PROOF;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConsensusEngineTest {
|
||||
pub engine: Carnot<FlatOverlay<RoundRobin>>,
|
||||
}
|
||||
|
||||
impl ConsensusEngineTest {
|
||||
pub fn new() -> Self {
|
||||
let engine = Carnot::from_genesis(
|
||||
[0; 32],
|
||||
Block {
|
||||
view: 0,
|
||||
id: [0; 32],
|
||||
parent_qc: Qc::Standard(StandardQc::genesis()),
|
||||
leader_proof: LEADER_PROOF.clone(),
|
||||
},
|
||||
FlatOverlay::new(Settings {
|
||||
nodes: vec![[0; 32]],
|
||||
leader: RoundRobin::default(),
|
||||
}),
|
||||
);
|
||||
|
||||
ConsensusEngineTest { engine }
|
||||
}
|
||||
}
|
||||
fn consensus_engine_test(sequential 1..50 => ConsensusEngineTest);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue