refactor(fuzz): modularize fuzz testing (#211)

This commit is contained in:
Youngjoon Lee 2023-06-22 15:15:38 +09:00 committed by GitHub
parent f4194bc728
commit ec3fb62baf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 495 additions and 476 deletions

View File

@ -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());
}
}

View File

@ -0,0 +1,3 @@
mod ref_state;
pub mod sut;
mod transition;

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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);
}