2023-06-22 06:15:38 +00:00
|
|
|
use std::collections::{BTreeMap, HashSet};
|
|
|
|
|
2023-06-26 11:20:55 +00:00
|
|
|
use consensus_engine::{
|
|
|
|
AggregateQc, Block, BlockId, LeaderProof, NodeId, Qc, StandardQc, TimeoutQc, View,
|
|
|
|
};
|
2023-06-22 06:15:38 +00:00
|
|
|
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 {
|
2023-06-26 11:34:12 +00:00
|
|
|
pub chain: BTreeMap<View, ViewEntry>,
|
|
|
|
pub highest_voted_view: View,
|
2023-06-22 06:15:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
2023-06-26 11:34:12 +00:00
|
|
|
pub struct ViewEntry {
|
|
|
|
pub blocks: HashSet<Block>,
|
|
|
|
pub timeout_qcs: HashSet<TimeoutQc>,
|
2023-06-22 06:15:38 +00:00
|
|
|
}
|
|
|
|
|
2023-07-11 15:16:49 +00:00
|
|
|
const LEADER_PROOF: LeaderProof = LeaderProof::LeaderId {
|
|
|
|
leader_id: NodeId::new([0; 32]),
|
|
|
|
};
|
2023-07-12 13:30:22 +00:00
|
|
|
const INITIAL_HIGHEST_VOTED_VIEW: View = View::new(-1);
|
2023-07-11 15:16:49 +00:00
|
|
|
const SENDER: NodeId = NodeId::new([0; 32]);
|
2023-06-22 06:15:38 +00:00
|
|
|
|
|
|
|
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 {
|
2023-07-12 13:30:22 +00:00
|
|
|
view: View::new(0),
|
2023-07-12 11:15:29 +00:00
|
|
|
id: BlockId::zeros(),
|
2023-06-22 06:15:38 +00:00
|
|
|
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(),
|
2023-06-26 23:34:16 +00:00
|
|
|
state.transition_receive_timeout_qc_for_recent_view(),
|
2023-06-22 06:15:38 +00:00
|
|
|
state.transition_receive_timeout_qc_for_old_view(),
|
2023-06-26 09:37:35 +00:00
|
|
|
state.transition_approve_new_view_with_latest_timeout_qc(),
|
|
|
|
state.transition_receive_safe_block_with_aggregated_qc(),
|
2023-06-22 06:15:38 +00:00
|
|
|
]
|
|
|
|
.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,
|
2023-06-26 11:20:55 +00:00
|
|
|
Transition::ReceiveSafeBlock(block) => {
|
|
|
|
state.contains_parent_of(block) && block.view >= state.current_view()
|
|
|
|
}
|
|
|
|
Transition::ReceiveUnsafeBlock(block) => {
|
|
|
|
state.contains_parent_of(block) && block.view < state.current_view()
|
|
|
|
}
|
2023-06-22 06:15:38 +00:00
|
|
|
Transition::ApproveBlock(block) => state.highest_voted_view < block.view,
|
|
|
|
Transition::ApprovePastBlock(block) => state.highest_voted_view >= block.view,
|
|
|
|
Transition::LocalTimeout => true,
|
2023-06-26 23:34:16 +00:00
|
|
|
Transition::ReceiveTimeoutQcForRecentView(timeout_qc) => {
|
2023-07-06 12:14:26 +00:00
|
|
|
timeout_qc.view() >= state.current_view()
|
2023-06-22 06:15:38 +00:00
|
|
|
}
|
|
|
|
Transition::ReceiveTimeoutQcForOldView(timeout_qc) => {
|
2023-06-28 14:37:27 +00:00
|
|
|
timeout_qc.view() < state.current_view()
|
2023-06-22 06:15:38 +00:00
|
|
|
}
|
2023-06-26 09:37:35 +00:00
|
|
|
Transition::ApproveNewViewWithLatestTimeoutQc(timeout_qc, _) => {
|
|
|
|
state.latest_timeout_qcs().contains(timeout_qc)
|
|
|
|
&& state.highest_voted_view < RefState::new_view_from(timeout_qc)
|
|
|
|
}
|
2023-06-22 06:15:38 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
}
|
2023-06-26 23:34:16 +00:00
|
|
|
Transition::ReceiveTimeoutQcForRecentView(timeout_qc) => {
|
2023-06-22 06:15:38 +00:00
|
|
|
state
|
|
|
|
.chain
|
2023-06-28 14:37:27 +00:00
|
|
|
.entry(timeout_qc.view())
|
2023-06-22 06:15:38 +00:00
|
|
|
.or_default()
|
|
|
|
.timeout_qcs
|
|
|
|
.insert(timeout_qc.clone());
|
|
|
|
}
|
|
|
|
Transition::ReceiveTimeoutQcForOldView(_) => {
|
|
|
|
// Nothing to do because we expect the state doesn't change.
|
|
|
|
}
|
2023-06-26 09:37:35 +00:00
|
|
|
Transition::ApproveNewViewWithLatestTimeoutQc(timeout_qc, _) => {
|
|
|
|
let new_view = RefState::new_view_from(timeout_qc);
|
2023-10-06 07:08:52 +00:00
|
|
|
state.chain.entry(new_view).or_default();
|
2023-06-26 09:37:35 +00:00
|
|
|
state.highest_voted_view = new_view;
|
|
|
|
}
|
2023-06-22 06:15:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
state
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl RefState {
|
|
|
|
// Generate a Transition::ReceiveSafeBlock.
|
|
|
|
fn transition_receive_safe_block(&self) -> BoxedStrategy<Transition> {
|
2023-07-04 09:31:33 +00:00
|
|
|
let parents: Vec<Block> = self
|
2023-06-22 06:15:38 +00:00
|
|
|
.chain
|
2023-07-04 09:31:33 +00:00
|
|
|
.get(&self.current_view())
|
|
|
|
.cloned()
|
|
|
|
.unwrap_or_default()
|
|
|
|
.blocks
|
|
|
|
.into_iter()
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
if parents.is_empty() {
|
2023-06-22 06:15:38 +00:00
|
|
|
Just(Transition::Nop).boxed()
|
|
|
|
} else {
|
|
|
|
// proptest::sample::select panics if the input is empty
|
2023-07-04 09:31:33 +00:00
|
|
|
proptest::sample::select(parents)
|
2023-06-22 06:15:38 +00:00
|
|
|
.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
|
2023-07-12 13:30:22 +00:00
|
|
|
.range(..self.current_view().prev())
|
2023-06-22 06:15:38 +00:00
|
|
|
.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
|
2023-07-12 13:30:22 +00:00
|
|
|
.range(self.highest_voted_view.next()..)
|
2023-06-22 06:15:38 +00:00
|
|
|
.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()
|
|
|
|
}
|
|
|
|
|
2023-06-26 23:34:16 +00:00
|
|
|
// Generate a Transition::ReceiveTimeoutQcForRecentView
|
|
|
|
fn transition_receive_timeout_qc_for_recent_view(&self) -> BoxedStrategy<Transition> {
|
2023-07-12 13:30:22 +00:00
|
|
|
let current_view: i64 = self.current_view().into();
|
2023-06-26 23:34:16 +00:00
|
|
|
let local_high_qc = self.high_qc();
|
|
|
|
let delta = 3;
|
|
|
|
|
|
|
|
let blocks_around_local_high_qc = self
|
|
|
|
.chain
|
2023-07-12 13:30:22 +00:00
|
|
|
.range(local_high_qc.view - View::new(delta)..=local_high_qc.view + View::new(delta)) // including past/future QCs
|
2023-06-26 23:34:16 +00:00
|
|
|
.flat_map(|(_, entry)| entry.blocks.iter().cloned())
|
|
|
|
.collect::<Vec<Block>>();
|
|
|
|
|
|
|
|
if blocks_around_local_high_qc.is_empty() {
|
|
|
|
Just(Transition::Nop).boxed()
|
|
|
|
} else {
|
|
|
|
proptest::sample::select(blocks_around_local_high_qc)
|
|
|
|
.prop_flat_map(move |block| {
|
|
|
|
(current_view..=current_view + delta) // including future views
|
|
|
|
.prop_map(move |view| {
|
2023-06-28 14:37:27 +00:00
|
|
|
Transition::ReceiveTimeoutQcForRecentView(TimeoutQc::new(
|
2023-07-12 13:30:22 +00:00
|
|
|
View::new(view),
|
2023-06-28 14:37:27 +00:00
|
|
|
StandardQc {
|
2023-06-26 23:34:16 +00:00
|
|
|
view: block.view,
|
|
|
|
id: block.id,
|
|
|
|
},
|
2023-06-28 14:37:27 +00:00
|
|
|
SENDER,
|
|
|
|
))
|
2023-06-26 23:34:16 +00:00
|
|
|
})
|
|
|
|
})
|
|
|
|
.boxed()
|
|
|
|
}
|
2023-06-22 06:15:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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)| {
|
2023-06-28 14:37:27 +00:00
|
|
|
Transition::ReceiveTimeoutQcForOldView(TimeoutQc::new(
|
2023-06-22 06:15:38 +00:00
|
|
|
view,
|
2023-06-28 14:37:27 +00:00
|
|
|
entry.high_qc().unwrap(),
|
|
|
|
SENDER,
|
|
|
|
))
|
2023-06-22 06:15:38 +00:00
|
|
|
})
|
|
|
|
.boxed()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-06-26 09:37:35 +00:00
|
|
|
// Generate a Transition::ApproveNewViewWithLatestTimeoutQc.
|
|
|
|
fn transition_approve_new_view_with_latest_timeout_qc(&self) -> BoxedStrategy<Transition> {
|
|
|
|
let latest_timeout_qcs: Vec<TimeoutQc> = self
|
|
|
|
.latest_timeout_qcs()
|
|
|
|
.iter()
|
|
|
|
.filter(|timeout_qc| self.highest_voted_view < RefState::new_view_from(timeout_qc))
|
|
|
|
.cloned()
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
if latest_timeout_qcs.is_empty() {
|
|
|
|
Just(Transition::Nop).boxed()
|
|
|
|
} else {
|
|
|
|
proptest::sample::select(latest_timeout_qcs)
|
|
|
|
.prop_map(move |timeout_qc| {
|
|
|
|
//TODO: set new_views
|
|
|
|
Transition::ApproveNewViewWithLatestTimeoutQc(timeout_qc, HashSet::new())
|
|
|
|
})
|
|
|
|
.boxed()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Generate a Transition::ReceiveSafeBlock, but with AggregatedQc.
|
|
|
|
fn transition_receive_safe_block_with_aggregated_qc(&self) -> BoxedStrategy<Transition> {
|
|
|
|
//TODO: more randomness
|
|
|
|
let current_view = self.current_view();
|
|
|
|
|
|
|
|
Just(Transition::ReceiveSafeBlock(Block {
|
2023-07-12 13:30:22 +00:00
|
|
|
view: current_view.next(),
|
2023-07-12 11:15:29 +00:00
|
|
|
id: BlockId::random(&mut rand::thread_rng()),
|
2023-06-26 09:37:35 +00:00
|
|
|
parent_qc: Qc::Aggregated(AggregateQc {
|
|
|
|
high_qc: self.high_qc(),
|
|
|
|
view: current_view,
|
|
|
|
}),
|
|
|
|
leader_proof: LEADER_PROOF.clone(),
|
|
|
|
}))
|
|
|
|
.boxed()
|
|
|
|
}
|
|
|
|
|
2023-06-22 06:15:38 +00:00
|
|
|
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 {
|
2023-07-12 13:30:22 +00:00
|
|
|
timeout_qc.view().next()
|
2023-06-22 06:15:38 +00:00
|
|
|
}
|
|
|
|
|
2023-06-26 11:34:12 +00:00
|
|
|
pub fn high_qc(&self) -> StandardQc {
|
2023-06-26 09:37:35 +00:00
|
|
|
self.chain
|
2023-06-26 23:34:16 +00:00
|
|
|
.values()
|
|
|
|
.map(|entry| entry.high_qc().unwrap_or_else(StandardQc::genesis))
|
|
|
|
.max_by_key(|qc| qc.view)
|
|
|
|
.unwrap_or_else(StandardQc::genesis)
|
2023-06-26 09:37:35 +00:00
|
|
|
}
|
|
|
|
|
2023-06-26 11:34:12 +00:00
|
|
|
pub fn latest_timeout_qcs(&self) -> Vec<TimeoutQc> {
|
2023-06-26 09:37:35 +00:00
|
|
|
let latest_timeout_qc_view_entry = self
|
|
|
|
.chain
|
|
|
|
.iter()
|
|
|
|
.rev()
|
|
|
|
.find(|(_, entry)| !entry.timeout_qcs.is_empty());
|
|
|
|
|
|
|
|
match latest_timeout_qc_view_entry {
|
|
|
|
Some((_, entry)) => entry
|
|
|
|
.timeout_qcs
|
|
|
|
.iter()
|
|
|
|
.cloned()
|
|
|
|
.collect::<Vec<TimeoutQc>>(),
|
|
|
|
None => vec![],
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-06-26 11:20:55 +00:00
|
|
|
fn contains_parent_of(&self, block: &Block) -> bool {
|
|
|
|
self.contains_block(block.parent_qc.block())
|
|
|
|
}
|
|
|
|
|
|
|
|
fn contains_block(&self, block_id: BlockId) -> bool {
|
|
|
|
self.chain
|
|
|
|
.iter()
|
|
|
|
.any(|(_, entry)| entry.blocks.iter().any(|block| block.id == block_id))
|
|
|
|
}
|
|
|
|
|
2023-06-22 06:15:38 +00:00
|
|
|
fn consecutive_block(parent: &Block) -> Block {
|
|
|
|
Block {
|
|
|
|
// use rand because we don't want this to be shrinked by proptest
|
2023-07-12 13:30:22 +00:00
|
|
|
view: parent.view.next(),
|
2023-07-12 11:15:29 +00:00
|
|
|
id: BlockId::random(&mut rand::thread_rng()),
|
2023-06-22 06:15:38 +00:00
|
|
|
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()
|
2023-06-28 14:37:27 +00:00
|
|
|
.map(|timeout_qc| timeout_qc.high_qc().clone());
|
2023-06-22 06:15:38 +00:00
|
|
|
iter1.chain(iter2).max_by_key(|qc| qc.view)
|
|
|
|
}
|
|
|
|
}
|