use std::{
collections::{BTreeMap, BTreeSet, HashMap},
iter,
sync::{Arc, Mutex},
};
use futures::{stream::FuturesUnordered, FutureExt as _, StreamExt as _};
#[cfg(with_metrics)]
use linera_base::prometheus_util::MeasureLatency as _;
use linera_base::{
crypto::ValidatorPublicKey,
data_types::{Blob, BlockHeight, Epoch, TimeDelta, Timestamp},
identifiers::{BlobId, ChainId, StreamId},
time::{timer::timeout, Duration},
};
use linera_chain::types::ConfirmedBlockCertificate;
use linera_execution::{committee::Committee, system::EPOCH_STREAM_NAME};
use linera_storage::{Arc as CacheArc, Clock as _, Storage};
use tokio::sync::mpsc;
use tracing::{debug, instrument, warn};
use crate::{
client::chain_client,
data_types::ChainInfoQuery,
node::{CrossChainMessageDelivery, NodeError, ValidatorNode, ValidatorNodeProvider},
remote_node::RemoteNode,
};
#[cfg(with_metrics)]
pub(crate) mod metrics {
use linera_base::prometheus_util::{
exponential_bucket_interval, exponential_bucket_latencies, register_histogram,
register_histogram_vec, register_int_counter, register_int_counter_vec, register_int_gauge,
register_int_gauge_vec,
};
use prometheus::{Histogram, HistogramVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec};
linera_base::declare_metrics! {
pub static CHAIN_SCOPED_BACKOFFS: IntCounterVec =
register_int_counter_vec(
"block_export_chain_scoped_backoffs",
"Sends deferred because a destination cannot accept a particular chain yet",
&["validator"],
);
pub static TRACKED_CHAINS: IntGauge =
register_int_gauge(
"block_export_tracked_chains",
"Chains the export queue is tracking for catch-up",
);
pub static QUEUE_SIZE: IntGauge =
register_int_gauge(
"block_export_queue_size",
"Blocks queued for export in this process",
);
pub static QUEUE_BYTES: IntGauge =
register_int_gauge(
"block_export_queue_bytes",
"Blob bytes held by blocks queued for export in this process",
);
pub static DROPPED_BLOCKS: IntCounter =
register_int_counter(
"block_export_dropped_blocks",
"Blocks dropped from a full export queue, to be re-sent from storage",
);
pub static EXPORT_LATENCY: Histogram =
register_histogram(
"block_export_latency",
"Time (ms) a block waits in the export queue before its sends are scheduled",
exponential_bucket_latencies(60_000.0),
);
pub static SEND_LATENCY: HistogramVec =
register_histogram_vec(
"block_export_send_latency",
"Time (ms) for one catch-up round against one destination validator",
&["validator"],
exponential_bucket_latencies(600_000.0),
);
pub static CERTIFICATE_SEND_LATENCY: HistogramVec =
register_histogram_vec(
"block_export_certificate_send_latency",
"Time (ms) for one certificate round trip to one destination validator",
&["validator"],
exponential_bucket_latencies(60_000.0),
);
pub static DESTINATION_WINDOW: IntGaugeVec =
register_int_gauge_vec(
"block_export_destination_window",
"AIMD in-flight window per destination validator",
&["validator"],
);
pub static DESTINATIONS: IntGauge =
register_int_gauge(
"block_export_destinations",
"Committee members this validator is currently exporting to",
);
pub static PARKED_CHAINS: IntGaugeVec =
register_int_gauge_vec(
"block_export_parked_chains",
"Chains parked at a destination, by park reason",
&["validator", "reason"],
);
pub static LAGGING_PAIRS: IntGauge =
register_int_gauge(
"block_export_lagging_pairs",
"Chain-destination pairs currently behind, summed over destinations",
);
pub static BLOCKS_OWED: IntGaugeVec =
register_int_gauge_vec(
"block_export_blocks_owed",
"Blocks still to send to a destination validator, summed over all chains",
&["validator"],
);
pub static MAX_CHAIN_GAP: IntGaugeVec =
register_int_gauge_vec(
"block_export_max_chain_gap",
"Blocks the furthest-behind chain owes a destination validator",
&["validator"],
);
pub static TOTAL_WINDOW: IntGauge =
register_int_gauge(
"block_export_total_window",
"Concurrent sends allowed across all destinations (AIMD on local storage failures)",
);
pub static SENDS_SUCCEEDED: IntCounterVec =
register_int_counter_vec(
"block_export_sends_succeeded",
"Export sends acknowledged by the destination validator",
&["validator"],
);
pub static DESTINATION_LAG: HistogramVec =
register_histogram_vec(
"block_export_destination_lag",
"Blocks a destination validator was missing when a block was pushed to it",
&["validator"],
exponential_bucket_interval(1.0, 10_000_000.0),
);
}
}
#[derive(Clone, Debug)]
pub struct BlockExportConfig {
pub certificate_upload_batch_size: u64,
pub queue_size: usize,
pub queue_bytes: usize,
pub max_in_flight_per_destination: usize,
pub max_in_flight_total: usize,
pub retry_delay: Duration,
pub max_retry_delay: Duration,
pub idle_catch_up_interval: Duration,
pub max_catch_up_blocks: u64,
pub converged_chain_retention: Duration,
}
impl BlockExportConfig {
pub fn check(&self) -> Result<(), String> {
if self.certificate_upload_batch_size == 0 {
return Err("block export batch size must be greater than zero".into());
}
if self.queue_size == 0 {
return Err("block export queue size must be greater than zero".into());
}
if self.queue_bytes == 0 {
return Err("block export queue byte budget must be greater than zero".into());
}
if self.max_in_flight_per_destination == 0 {
return Err("block export in-flight ceiling must be greater than zero".into());
}
if self.max_in_flight_total == 0 {
return Err("block export total in-flight budget must be greater than zero".into());
}
if self.max_in_flight_total < self.max_in_flight_per_destination {
return Err(
"block export total in-flight budget must be at least the per-destination ceiling"
.into(),
);
}
if self.max_catch_up_blocks == 0 {
return Err("block export catch-up bound must be greater than zero".into());
}
if self.idle_catch_up_interval.is_zero() {
return Err("block export idle interval must be greater than zero".into());
}
if self.retry_delay.is_zero() {
return Err("block export retry delay must be greater than zero".into());
}
if self.max_retry_delay.is_zero() {
return Err("block export max retry delay must be greater than zero".into());
}
if self.retry_delay > self.max_retry_delay {
return Err("block export retry delay must not exceed the max retry delay".into());
}
if self.converged_chain_retention.is_zero() {
return Err("block export converged-chain retention must be greater than zero".into());
}
Ok(())
}
}
impl Default for BlockExportConfig {
fn default() -> Self {
BlockExportConfig {
certificate_upload_batch_size: 100,
queue_size: 1024,
queue_bytes: 256 * 1024 * 1024,
max_in_flight_per_destination: 8,
max_in_flight_total: 64,
retry_delay: Duration::from_secs(1),
max_retry_delay: Duration::from_secs(60),
idle_catch_up_interval: Duration::from_millis(200),
max_catch_up_blocks: 200,
converged_chain_retention: Duration::from_secs(300),
}
}
}
struct ExportedBlock {
certificate: CacheArc<ConfirmedBlockCertificate>,
blobs: Vec<CacheArc<Blob>>,
epoch: Epoch,
exported_heights: BTreeMap<ValidatorPublicKey, BlockHeight>,
blob_bytes: usize,
#[cfg(with_metrics)]
queued_at: linera_base::time::Instant,
}
pub struct BlockExportHandle {
blocks: mpsc::Sender<ExportedBlock>,
progress: SharedProgress,
tips: SharedTips,
queued_bytes: Arc<std::sync::atomic::AtomicUsize>,
queue_bytes_budget: usize,
}
impl Clone for BlockExportHandle {
fn clone(&self) -> Self {
BlockExportHandle {
blocks: self.blocks.clone(),
progress: self.progress.clone(),
tips: self.tips.clone(),
queued_bytes: self.queued_bytes.clone(),
queue_bytes_budget: self.queue_bytes_budget,
}
}
}
type DestIndex = u32;
#[derive(Default)]
struct ProgressMap {
validators: Vec<ValidatorPublicKey>,
heights: HashMap<ChainId, Vec<(DestIndex, BlockHeight)>>,
}
impl ProgressMap {
fn forget_chains(
&mut self,
forgotten: &[ChainId],
) -> Option<HashMap<ChainId, Vec<(DestIndex, BlockHeight)>>> {
for chain_id in forgotten {
self.heights.remove(chain_id);
}
if self.heights.len() <= MAX_FORGET_PER_SWEEP
&& self.heights.capacity() > self.heights.len().saturating_mul(4)
{
let survivors = self.heights.drain().collect();
return Some(std::mem::replace(&mut self.heights, survivors));
}
None
}
}
type SharedProgress = Arc<Mutex<ProgressMap>>;
type SharedTips = Arc<Mutex<HashMap<ChainId, BlockHeight>>>;
impl BlockExportHandle {
pub(crate) fn export(
&self,
certificate: CacheArc<ConfirmedBlockCertificate>,
blobs: Vec<CacheArc<Blob>>,
epoch: Epoch,
exported_heights: BTreeMap<ValidatorPublicKey, BlockHeight>,
) {
{
let header = &certificate.block().header;
let tip = header.height.try_add_one().unwrap_or(BlockHeight::MAX);
let mut tips = self.tips.lock().expect("tips mutex is never poisoned");
let entry = tips.entry(header.chain_id).or_insert(tip);
*entry = (*entry).max(tip);
}
let blob_bytes = blobs.iter().map(|blob| blob.bytes().len()).sum::<usize>();
let prior = self
.queued_bytes
.fetch_add(blob_bytes, std::sync::atomic::Ordering::Relaxed);
if prior.saturating_add(blob_bytes) > self.queue_bytes_budget {
self.queued_bytes
.fetch_sub(blob_bytes, std::sync::atomic::Ordering::Relaxed);
debug!(
chain_id = %certificate.block().header.chain_id,
height = %certificate.block().header.height,
queued = prior, blob_bytes,
"Export queue byte budget exhausted; dropping the block for catch-up to re-send",
);
#[cfg(with_metrics)]
metrics::DROPPED_BLOCKS.inc();
return;
}
let block = ExportedBlock {
certificate,
blobs,
epoch,
exported_heights,
blob_bytes,
#[cfg(with_metrics)]
queued_at: linera_base::time::Instant::now(),
};
match self.blocks.try_send(block) {
Ok(()) => {
#[cfg(with_metrics)]
{
metrics::QUEUE_SIZE.inc();
metrics::QUEUE_BYTES.add(blob_bytes as i64);
}
}
Err(mpsc::error::TrySendError::Full(block)) => {
self.queued_bytes
.fetch_sub(blob_bytes, std::sync::atomic::Ordering::Relaxed);
debug!(
chain_id = %block.certificate.block().header.chain_id,
height = %block.certificate.block().header.height,
"Export queue full; dropping the block for catch-up to re-send",
);
#[cfg(with_metrics)]
metrics::DROPPED_BLOCKS.inc();
}
Err(mpsc::error::TrySendError::Closed(_)) => {
self.queued_bytes
.fetch_sub(blob_bytes, std::sync::atomic::Ordering::Relaxed);
warn!("Block export queue stopped unexpectedly; blocks are no longer exported");
}
}
}
pub(crate) fn progress(
&self,
chain_id: ChainId,
committee: &Committee,
) -> BTreeMap<ValidatorPublicKey, BlockHeight> {
let progress = self
.progress
.lock()
.expect("progress mutex is never poisoned");
let Some(chain_progress) = progress.heights.get(&chain_id) else {
return BTreeMap::new();
};
chain_progress
.iter()
.filter_map(|(index, height)| {
let validator = progress.validators.get(*index as usize)?;
committee
.validators()
.contains_key(validator)
.then_some((*validator, *height))
})
.collect()
}
}
pub fn spawn_block_export_queue<S, P>(
storage: S,
node_provider: Arc<P>,
config: BlockExportConfig,
own_public_key: Option<ValidatorPublicKey>,
) -> BlockExportHandle
where
S: Storage + Clone + Send + Sync + 'static,
P: ValidatorNodeProvider + Send + Sync + 'static,
P::Node: Send + Sync,
{
if let Err(message) = config.check() {
panic!("invalid block export configuration: {message}");
}
let (blocks, receiver) = mpsc::channel(config.queue_size);
let progress: SharedProgress = Arc::default();
let tips: SharedTips = Arc::default();
let queued_bytes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let queue_bytes_budget = config.queue_bytes;
let max_in_flight_total = config.max_in_flight_total;
let task = BlockExportQueue {
storage,
node_provider,
config,
own_public_key,
latest_epoch: None,
committee: None,
committee_dirty: false,
admin_chain_id: None,
ticks_until_scan: 0,
ticks_until_sweep: TICKS_PER_CONVERGENCE_SWEEP,
#[cfg(with_metrics)]
ticks_until_census: 0,
drain_cursor: None,
chains: HashMap::new(),
destinations: BTreeMap::new(),
dest_indices: BTreeMap::new(),
next_generation: 0,
total_window: max_in_flight_total,
announced_epoch: None,
scan_attempted_for: None,
destinations_changed: false,
queued_bytes: queued_bytes.clone(),
progress: progress.clone(),
tips: tips.clone(),
draining: false,
};
linera_base::Task::spawn(task.run(receiver)).forget();
BlockExportHandle {
blocks,
progress,
tips,
queued_bytes,
queue_bytes_budget,
}
}
struct ChainRecord {
tip: BlockHeight,
last_activity: Timestamp,
dests: Vec<(DestIndex, ChainDest)>,
}
impl ChainRecord {
fn new<N>(
now: Timestamp,
destinations: &BTreeMap<DestIndex, DestState<N>>,
exported_heights: &BTreeMap<ValidatorPublicKey, BlockHeight>,
) -> Self {
ChainRecord {
tip: BlockHeight::ZERO,
last_activity: now,
dests: destinations
.iter()
.map(|(index, dest)| {
let next_height = exported_heights
.get(&dest.validator)
.and_then(|height| height.try_add_one().ok());
(
*index,
ChainDest {
next_height,
..ChainDest::default()
},
)
})
.collect(),
}
}
fn dest(&self, index: DestIndex) -> Option<&ChainDest> {
let at = self
.dests
.binary_search_by_key(&index, |(at, _)| *at)
.ok()?;
Some(&self.dests[at].1)
}
fn dest_mut(&mut self, index: DestIndex) -> Option<&mut ChainDest> {
let at = self
.dests
.binary_search_by_key(&index, |(at, _)| *at)
.ok()?;
Some(&mut self.dests[at].1)
}
fn dest_entry(&mut self, index: DestIndex) -> &mut ChainDest {
let at = match self.dests.binary_search_by_key(&index, |(at, _)| *at) {
Ok(at) => at,
Err(at) => {
self.dests.insert(at, (index, ChainDest::default()));
at
}
};
&mut self.dests[at].1
}
}
impl ChainRecord {
fn seed_missing_cursors<N>(
&mut self,
destinations: &BTreeMap<DestIndex, DestState<N>>,
exported_heights: &BTreeMap<ValidatorPublicKey, BlockHeight>,
) {
for (index, dest) in destinations {
let chain_dest = self.dest_entry(*index);
if chain_dest.next_height.is_none() && chain_dest.in_flight.is_none() {
chain_dest.next_height = exported_heights
.get(&dest.validator)
.and_then(|height| height.try_add_one().ok());
}
}
}
}
#[derive(Default)]
struct ChainDest {
next_height: Option<BlockHeight>,
in_flight: Option<u64>,
retry_at: Option<Timestamp>,
failures: u32,
regressions: u32,
parked: Option<ParkReason>,
}
impl ChainDest {
fn record_reached(
&mut self,
reported: BlockHeight,
tip: BlockHeight,
now: Timestamp,
config: &BlockExportConfig,
) -> Option<BlockHeight> {
let reported = reported.min(tip);
let previous = self.next_height;
let advanced = previous.is_none_or(|height| reported > height);
let regressed = previous.is_some_and(|height| reported < height);
self.next_height = Some(reported);
if advanced {
self.failures = 0;
self.retry_at = None;
} else if regressed {
let attempt = self.failures.max(self.regressions);
self.retry_at = Some(now.saturating_add(backoff_delay(attempt, config)));
self.regressions = self.regressions.saturating_add(1);
} else if reported < tip {
back_off(&mut self.failures, &mut self.retry_at, now, config);
return None;
} else {
return None;
}
reported.try_sub_one().ok()
}
}
struct DestState<N> {
node: N,
validator: ValidatorPublicKey,
address: String,
generation: u64,
in_flight: usize,
window: usize,
retry_at: Option<Timestamp>,
failures: u32,
lagging: BTreeSet<ChainId>,
lagging_cursor: Option<ChainId>,
}
impl<N> DestState<N> {
fn drain_candidates(&self, budget: usize) -> Vec<ChainId> {
match self.lagging_cursor {
Some(cursor) => self
.lagging
.range(cursor..)
.chain(self.lagging.iter().take_while(|id| **id < cursor))
.copied()
.take(budget)
.collect(),
None => self.lagging.iter().copied().take(budget).collect(),
}
}
fn advance_cursor(&mut self, last_considered: Option<ChainId>) {
let Some(last) = last_considered else {
return;
};
self.lagging_cursor = self.lagging.range(last..).nth(1).copied();
}
}
enum SendOutcome {
Reached(BlockHeight),
ChainScoped(Box<chain_client::Error>),
DestinationScoped(Box<chain_client::Error>),
LocalScoped(Box<chain_client::Error>),
Unrecoverable(ParkReason, Box<chain_client::Error>),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ParkReason {
Corrupted,
}
impl ParkReason {
fn as_str(self) -> &'static str {
match self {
ParkReason::Corrupted => "corrupted",
}
}
}
fn park_reason(error: &chain_client::Error) -> Option<ParkReason> {
match error {
chain_client::Error::RemoteNodeError(NodeError::ChainError { error })
if error.contains("Corrupted chain state") =>
{
Some(ParkReason::Corrupted)
}
_ => None,
}
}
struct BlockExportQueue<S, P>
where
S: Storage,
P: ValidatorNodeProvider,
{
storage: S,
node_provider: Arc<P>,
config: BlockExportConfig,
own_public_key: Option<ValidatorPublicKey>,
latest_epoch: Option<Epoch>,
committee: Option<Arc<Committee>>,
committee_dirty: bool,
admin_chain_id: Option<ChainId>,
ticks_until_scan: u32,
ticks_until_sweep: u32,
#[cfg(with_metrics)]
ticks_until_census: u32,
drain_cursor: Option<DestIndex>,
chains: HashMap<ChainId, ChainRecord>,
destinations: BTreeMap<DestIndex, DestState<P::Node>>,
dest_indices: BTreeMap<ValidatorPublicKey, DestIndex>,
next_generation: u64,
total_window: usize,
announced_epoch: Option<Epoch>,
scan_attempted_for: Option<Epoch>,
destinations_changed: bool,
queued_bytes: Arc<std::sync::atomic::AtomicUsize>,
progress: SharedProgress,
tips: SharedTips,
draining: bool,
}
const TICKS_PER_COMMITTEE_SCAN: u32 = 10;
const LAGGING_SCAN_FACTOR: usize = 4;
const TICKS_PER_CONVERGENCE_SWEEP: u32 = 25;
#[cfg(with_metrics)]
const TICKS_PER_BACKLOG_CENSUS: u32 = 300;
fn rotated_order(indices: &[DestIndex], cursor: Option<DestIndex>) -> Vec<DestIndex> {
match cursor {
Some(at) => indices
.iter()
.copied()
.skip_while(|index| *index < at)
.chain(indices.iter().copied().take_while(|index| *index < at))
.collect(),
None => indices.to_vec(),
}
}
fn next_drain_cursor(indices: &[DestIndex], served_first: Option<DestIndex>) -> Option<DestIndex> {
let first = served_first?;
indices.iter().copied().find(|index| *index > first)
}
#[cfg(with_metrics)]
const MAX_CENSUS_PAIRS: usize = 50_000;
const MAX_FORGET_PER_SWEEP: usize = 4096;
impl<S, P> BlockExportQueue<S, P>
where
S: Storage + Clone + Send + Sync + 'static,
P: ValidatorNodeProvider,
P::Node: Clone + Send + 'static,
{
#[instrument(level = "debug", skip_all)]
async fn run(mut self, mut receiver: mpsc::Receiver<ExportedBlock>) {
enum Wake {
Done(JobDone),
Block(Option<ExportedBlock>),
Tick,
}
let mut jobs = FuturesUnordered::new();
let interval = self.config.idle_catch_up_interval;
let tick_delta = TimeDelta::from_micros(interval.as_micros() as u64);
let mut next_tick = self
.storage
.clock()
.current_time()
.saturating_add(tick_delta);
loop {
let now = self.storage.clock().current_time();
if next_tick.duration_since(now) > interval {
next_tick = now.saturating_add(tick_delta);
}
if now >= next_tick {
self.tick(&mut jobs).await;
next_tick = self
.storage
.clock()
.current_time()
.saturating_add(tick_delta);
continue;
}
let until_tick = next_tick.duration_since(now);
let wake = if jobs.is_empty() {
match timeout(until_tick, receiver.recv()).await {
Ok(received) => Wake::Block(received),
Err(_) => Wake::Tick,
}
} else {
futures::select_biased! {
done = jobs.next() => Wake::Done(done.expect("jobs is not empty")),
received = receiver.recv().fuse() => Wake::Block(received),
_ = self.storage.clock().sleep_for(until_tick).fuse() => Wake::Tick,
}
};
match wake {
Wake::Done(done) => self.on_done(done, &mut jobs),
Wake::Block(Some(block)) => self.on_block(block, &mut jobs),
Wake::Block(None) => break,
Wake::Tick => {
self.tick(&mut jobs).await;
next_tick = self
.storage
.clock()
.current_time()
.saturating_add(tick_delta);
}
}
}
self.draining = true;
while let Some(done) = jobs.next().await {
self.on_done(done, &mut jobs);
}
debug!("All block export handles dropped; stopping the export queue");
}
fn on_block(&mut self, block: ExportedBlock, jobs: &mut FuturesUnordered<JobFuture>) {
self.queued_bytes
.fetch_sub(block.blob_bytes, std::sync::atomic::Ordering::Relaxed);
#[cfg(with_metrics)]
{
metrics::QUEUE_SIZE.dec();
metrics::QUEUE_BYTES.sub(block.blob_bytes as i64);
metrics::EXPORT_LATENCY
.finish_measurement(block.queued_at.elapsed().as_secs_f64() * 1000.0);
}
let header = &block.certificate.block().header;
let (chain_id, height) = (header.chain_id, header.height);
if self
.announced_epoch
.is_none_or(|announced| block.epoch > announced)
{
self.announced_epoch = Some(block.epoch);
}
if self.committee_dirty {
self.sync_destinations();
}
let now = self.storage.clock().current_time();
let tip = height.try_add_one().unwrap_or(BlockHeight::MAX);
let record = self
.chains
.entry(chain_id)
.or_insert_with(|| ChainRecord::new(now, &self.destinations, &block.exported_heights));
record.tip = record.tip.max(tip);
record.last_activity = now;
record.seed_missing_cursors(&self.destinations, &block.exported_heights);
let indices = self.destinations.keys().copied().collect::<Vec<_>>();
for index in indices {
let budget = self.budget_remaining();
let record = self.chains.get_mut(&chain_id).expect("inserted above");
let record_tip = record.tip;
let chain_dest = record.dest_entry(index);
let dest = self
.destinations
.get_mut(&index)
.expect("iterating destinations");
let contiguous = chain_dest.next_height == Some(height);
let can_send_now = chain_dest.in_flight.is_none()
&& chain_dest.retry_at.is_none_or(|at| at <= now)
&& dest.retry_at.is_none_or(|at| at <= now)
&& dest.in_flight < dest.window;
if contiguous && can_send_now {
Self::spawn_job(
jobs,
&self.storage,
&self.config,
chain_id,
index,
dest,
chain_dest,
record_tip,
Some((block.certificate.clone(), block.blobs.clone())),
);
} else if chain_dest.next_height.is_none_or(|next| next < record_tip) {
dest.lagging.insert(chain_id);
if can_send_now {
Self::drain_ready(
&mut self.chains,
&self.storage,
&self.config,
index,
dest,
jobs,
now,
budget,
);
}
}
}
}
fn on_done(
&mut self,
(chain_id, index, generation, outcome): JobDone,
jobs: &mut FuturesUnordered<JobFuture>,
) {
let now = self.storage.clock().current_time();
let Some(dest) = self.destinations.get_mut(&index) else {
return; };
let validator = dest.validator;
if dest.generation != generation {
if let Some(chain_dest) = self
.chains
.get_mut(&chain_id)
.and_then(|record| record.dest_mut(index))
{
if chain_dest.in_flight == Some(generation) {
chain_dest.in_flight = None;
}
}
return;
}
dest.in_flight = dest.in_flight.saturating_sub(1);
match &outcome {
SendOutcome::Reached(_) => {
dest.failures = 0;
dest.retry_at = None;
dest.window = (dest.window + 1).min(self.config.max_in_flight_per_destination);
self.total_window = (self.total_window + 1).min(self.config.max_in_flight_total);
#[cfg(with_metrics)]
metrics::SENDS_SUCCEEDED
.with_label_values(&[&dest.address])
.inc();
}
SendOutcome::ChainScoped(_) => {}
SendOutcome::Unrecoverable(..) => {}
SendOutcome::LocalScoped(error) => {
warn!(
%chain_id, %error,
"Export could not read from local storage; halving the queue's total \
in-flight budget",
);
self.total_window = (self.total_window / 2).max(1);
#[cfg(with_metrics)]
metrics::TOTAL_WINDOW.set(self.total_window as i64);
}
SendOutcome::DestinationScoped(error) => {
warn!(
validator = %dest.address, %chain_id, %error,
"Failed to export to a validator; backing it off and re-resolving",
);
dest.window = (dest.window / 2).max(1);
back_off(&mut dest.failures, &mut dest.retry_at, now, &self.config);
match self
.node_provider
.make_nodes_from_list(iter::once((validator, dest.address.clone())))
{
Ok(mut nodes) => {
if let Some((_, node)) = nodes.next() {
dest.node = node;
}
}
Err(error) => {
warn!(%validator, %error, "Cannot re-resolve a failing destination");
}
}
}
}
#[cfg(with_metrics)]
metrics::DESTINATION_WINDOW
.with_label_values(&[&dest.address])
.set(dest.window as i64);
if let Some(record) = self.chains.get_mut(&chain_id) {
record.last_activity = now;
let record_tip = record.tip;
if let Some(chain_dest) = record.dest_mut(index) {
if chain_dest.in_flight == Some(generation) {
chain_dest.in_flight = None;
}
match &outcome {
SendOutcome::Reached(next_height) => {
if let Some(acked) =
chain_dest.record_reached(*next_height, record_tip, now, &self.config)
{
let mut progress = self
.progress
.lock()
.expect("progress mutex is never poisoned");
let heights = progress.heights.entry(chain_id).or_default();
match heights.binary_search_by_key(&index, |(at, _)| *at) {
Ok(at) => heights[at].1 = acked,
Err(at) => heights.insert(at, (index, acked)),
}
}
}
SendOutcome::LocalScoped(_) => {
back_off(
&mut chain_dest.failures,
&mut chain_dest.retry_at,
now,
&self.config,
);
}
SendOutcome::ChainScoped(error) => {
debug!(
%chain_id, %validator, %error,
"Destination cannot accept this chain yet; backing the pair off",
);
#[cfg(with_metrics)]
metrics::CHAIN_SCOPED_BACKOFFS
.with_label_values(&[&dest.address])
.inc();
chain_dest.next_height = None;
back_off(
&mut chain_dest.failures,
&mut chain_dest.retry_at,
now,
&self.config,
);
}
SendOutcome::DestinationScoped(_) => {
chain_dest.next_height = None;
}
SendOutcome::Unrecoverable(reason, error) => {
if chain_dest.parked.is_none() {
warn!(
%chain_id, %validator, %error, reason = reason.as_str(),
"Destination cannot accept this chain and retrying cannot help; \
parking the pair until restart",
);
#[cfg(with_metrics)]
metrics::PARKED_CHAINS
.with_label_values(&[&dest.address, reason.as_str()])
.inc();
}
chain_dest.parked = Some(*reason);
chain_dest.next_height = None;
}
}
}
}
if self.draining {
return;
}
let budget = self.budget_remaining();
let dest = self.destinations.get_mut(&index).expect("checked above");
if let Some(record) = self.chains.get_mut(&chain_id) {
let tip = record.tip;
if let Some(chain_dest) = record.dest_mut(index) {
if chain_dest.next_height.is_none_or(|next| next < tip) {
dest.lagging.insert(chain_id);
} else {
dest.lagging.remove(&chain_id);
}
}
}
Self::drain_ready(
&mut self.chains,
&self.storage,
&self.config,
index,
dest,
jobs,
now,
budget,
);
}
async fn tick(&mut self, jobs: &mut FuturesUnordered<JobFuture>) {
let now = self.storage.clock().current_time();
let announced_newer = self.announced_epoch.is_some_and(|epoch| {
self.latest_epoch.is_none_or(|latest| epoch > latest)
&& self.scan_attempted_for != Some(epoch)
});
if self.ticks_until_scan == 0 || announced_newer {
self.ticks_until_scan = TICKS_PER_COMMITTEE_SCAN;
self.scan_attempted_for = self.announced_epoch;
self.scan_committees().await;
} else {
self.ticks_until_scan -= 1;
}
if self.committee_dirty || (self.destinations.is_empty() && self.committee.is_some()) {
self.sync_destinations();
}
let tips = std::mem::take(&mut *self.tips.lock().expect("tips mutex is never poisoned"));
for (chain_id, tip) in tips {
let record = self
.chains
.entry(chain_id)
.or_insert_with(|| ChainRecord::new(now, &self.destinations, &BTreeMap::new()));
let advanced = record.tip < tip;
record.tip = record.tip.max(tip);
if advanced {
for (index, dest) in &mut self.destinations {
let chain_dest = record.dest_entry(*index);
if chain_dest.next_height.is_none_or(|next| next < record.tip) {
dest.lagging.insert(chain_id);
}
}
}
}
if self.destinations_changed {
self.destinations_changed = false;
for (chain_id, record) in &mut self.chains {
for (index, dest) in &mut self.destinations {
let chain_dest = record.dest_entry(*index);
if chain_dest.next_height.is_none_or(|next| next < record.tip) {
dest.lagging.insert(*chain_id);
}
}
}
}
let retention = self.config.converged_chain_retention;
let destinations = &self.destinations;
let mut forgotten = Vec::new();
if self.ticks_until_sweep == 0 {
self.ticks_until_sweep = TICKS_PER_CONVERGENCE_SWEEP;
self.chains.retain(|chain_id, record| {
if forgotten.len() >= MAX_FORGET_PER_SWEEP {
return true;
}
let converged = destinations.keys().all(|index| {
record.dest(*index).is_some_and(|chain_dest| {
chain_dest.in_flight.is_none()
&& chain_dest
.next_height
.is_some_and(|next| next >= record.tip)
})
});
if converged && now.duration_since(record.last_activity) > retention {
forgotten.push(*chain_id);
false
} else {
true
}
});
if self.chains.capacity() > self.chains.len().saturating_mul(4) {
self.chains.shrink_to_fit();
}
} else {
self.ticks_until_sweep -= 1;
}
if !forgotten.is_empty() {
let peak_table = self
.progress
.lock()
.expect("progress mutex is never poisoned")
.forget_chains(&forgotten);
drop(peak_table);
}
#[cfg(with_metrics)]
{
metrics::TRACKED_CHAINS.set(self.chains.len() as i64);
metrics::DESTINATIONS.set(self.destinations.len() as i64);
let lagging_pairs = self
.destinations
.values()
.map(|dest| dest.lagging.len())
.sum::<usize>();
metrics::LAGGING_PAIRS.set(lagging_pairs as i64);
metrics::TOTAL_WINDOW.set(self.total_window as i64);
if self.ticks_until_census == 0 {
self.ticks_until_census = TICKS_PER_BACKLOG_CENSUS;
self.publish_backlog();
} else {
self.ticks_until_census -= 1;
}
}
let mut budget = self.total_window.saturating_sub(
self.destinations
.values()
.map(|dest| dest.in_flight)
.sum::<usize>(),
);
let indices = self.destinations.keys().copied().collect::<Vec<_>>();
let order = rotated_order(&indices, self.drain_cursor);
self.drain_cursor = next_drain_cursor(&indices, order.first().copied());
for index in order {
let Some(dest) = self.destinations.get_mut(&index) else {
continue;
};
budget -= Self::drain_ready(
&mut self.chains,
&self.storage,
&self.config,
index,
dest,
jobs,
now,
budget,
);
}
}
async fn scan_committees(&mut self) {
if self.admin_chain_id.is_none() {
self.admin_chain_id = match self.storage.read_network_description().await {
Ok(Some(description)) => Some(description.admin_chain_id),
Ok(None) => return,
Err(error) => {
debug!(%error, "Cannot read the network description to scan for committees");
return;
}
};
}
let Some(admin_chain_id) = self.admin_chain_id else {
return;
};
let start = self
.latest_epoch
.map_or(0, |epoch| epoch.0.saturating_add(1));
let genesis = (start == 0).then_some(Epoch(0));
let mut candidates = match self
.storage
.read_events_from_index(&admin_chain_id, &StreamId::system(EPOCH_STREAM_NAME), start)
.await
{
Ok(events) => events
.into_iter()
.map(|event| Epoch(event.index))
.chain(genesis)
.collect::<Vec<_>>(),
Err(error) => {
debug!(%error, "Cannot list epoch events to scan for committees");
return;
}
};
candidates.sort_unstable_by(|a, b| b.cmp(a));
for epoch in candidates {
match self.storage.get_or_load_committee(epoch).await {
Ok(Some(committee)) => {
self.latest_epoch = Some(epoch);
self.committee = Some(committee);
self.committee_dirty = true;
return;
}
Ok(None) => debug!(%epoch, "An epoch event exists but its committee cannot load"),
Err(error) => debug!(%error, %epoch, "Cannot load a committee from storage"),
}
}
}
fn sync_destinations(&mut self) {
let Some(committee) = self.committee.clone() else {
return;
};
self.committee_dirty = false;
let mut carried = BTreeMap::new();
let mut rebuilt_any = false;
#[cfg(with_metrics)]
let mut rebuilt_addresses = Vec::new();
self.destinations.retain(|index, dest| {
let keep = committee
.validators()
.get(&dest.validator)
.is_some_and(|state| state.network_address == dest.address);
if !keep {
rebuilt_any = true;
if committee.validators().contains_key(&dest.validator) {
carried.insert(
*index,
(
std::mem::take(&mut dest.lagging),
dest.lagging_cursor.take(),
),
);
}
#[cfg(with_metrics)]
rebuilt_addresses.push(dest.address.clone());
}
keep
});
if rebuilt_any {
self.destinations_changed = true;
}
#[cfg(with_metrics)]
for address in &rebuilt_addresses {
metrics::DESTINATION_WINDOW
.remove_label_values(&[address])
.ok();
metrics::SEND_LATENCY.remove_label_values(&[address]).ok();
metrics::CERTIFICATE_SEND_LATENCY
.remove_label_values(&[address])
.ok();
metrics::SENDS_SUCCEEDED
.remove_label_values(&[address])
.ok();
metrics::DESTINATION_LAG
.remove_label_values(&[address])
.ok();
metrics::CHAIN_SCOPED_BACKOFFS
.remove_label_values(&[address])
.ok();
metrics::BLOCKS_OWED.remove_label_values(&[address]).ok();
metrics::MAX_CHAIN_GAP.remove_label_values(&[address]).ok();
}
for (validator, address) in committee.validator_addresses() {
if Some(validator) == self.own_public_key {
continue;
}
let index = self.dest_index(validator);
if self.destinations.contains_key(&index) {
continue;
}
match self
.node_provider
.make_nodes_from_list(iter::once((validator, address)))
{
Ok(mut nodes) => {
if let Some((_, node)) = nodes.next() {
self.next_generation += 1;
self.destinations_changed = true;
let (lagging, lagging_cursor) = carried.remove(&index).unwrap_or_default();
self.destinations.insert(
index,
DestState {
node,
validator,
address: address.to_owned(),
generation: self.next_generation,
in_flight: 0,
window: self.config.max_in_flight_per_destination,
retry_at: None,
failures: 0,
lagging,
lagging_cursor,
},
);
}
}
Err(error) => {
warn!(
%validator, %address, %error,
"Cannot resolve a committee member to export blocks to; \
continuing with the others",
);
}
}
}
let destinations = &self.destinations;
for record in self.chains.values_mut() {
record
.dests
.retain(|(index, _)| destinations.contains_key(index));
}
}
#[cfg(with_metrics)]
fn publish_backlog(&self) {
let mut remaining = MAX_CENSUS_PAIRS;
let mut unvisited = self.destinations.len();
for (index, dest) in &self.destinations {
let mut owed = 0u64;
let mut worst = 0u64;
let per_destination = remaining / unvisited.max(1);
unvisited = unvisited.saturating_sub(1);
let mut examined = 0usize;
for chain_id in &dest.lagging {
if examined >= per_destination {
break;
}
examined += 1;
let Some(record) = self.chains.get(chain_id) else {
continue;
};
let Some(chain_dest) = record.dest(*index) else {
continue;
};
let gap = chain_dest
.next_height
.map_or(record.tip.0, |next| record.tip.0.saturating_sub(next.0));
owed = owed.saturating_add(gap);
worst = worst.max(gap);
}
metrics::BLOCKS_OWED
.with_label_values(&[&dest.address])
.set(owed as i64);
metrics::MAX_CHAIN_GAP
.with_label_values(&[&dest.address])
.set(worst as i64);
remaining = remaining.saturating_sub(examined);
}
}
fn budget_remaining(&self) -> usize {
let in_flight = self
.destinations
.values()
.map(|dest| dest.in_flight)
.sum::<usize>();
self.total_window.saturating_sub(in_flight)
}
fn dest_index(&mut self, validator: ValidatorPublicKey) -> DestIndex {
if let Some(index) = self.dest_indices.get(&validator) {
return *index;
}
let mut progress = self
.progress
.lock()
.expect("progress mutex is never poisoned");
let index = progress.validators.len() as DestIndex;
progress.validators.push(validator);
self.dest_indices.insert(validator, index);
index
}
}
type JobDone = (ChainId, DestIndex, u64, SendOutcome);
#[cfg(not(web))]
type JobFuture = futures::future::BoxFuture<'static, JobDone>;
#[cfg(web)]
type JobFuture = futures::future::LocalBoxFuture<'static, JobDone>;
impl<S, P> BlockExportQueue<S, P>
where
S: Storage + Clone + Send + Sync + 'static,
P: ValidatorNodeProvider,
P::Node: Clone + Send + 'static,
{
#[expect(clippy::too_many_arguments)]
fn spawn_job(
jobs: &mut FuturesUnordered<JobFuture>,
storage: &S,
config: &BlockExportConfig,
chain_id: ChainId,
index: DestIndex,
dest: &mut DestState<P::Node>,
chain_dest: &mut ChainDest,
target: BlockHeight,
live: Option<(CacheArc<ConfirmedBlockCertificate>, Vec<CacheArc<Blob>>)>,
) {
let generation = dest.generation;
chain_dest.in_flight = Some(generation);
dest.in_flight += 1;
let mut sender = BlockSender {
remote_node: RemoteNode {
public_key: dest.validator,
node: dest.node.clone(),
},
storage: storage.clone(),
certificate_upload_batch_size: config.certificate_upload_batch_size,
#[cfg(with_metrics)]
address: dest.address.clone(),
};
let cursor = chain_dest.next_height;
let max_catch_up = config.max_catch_up_blocks;
#[cfg(with_metrics)]
let address = dest.address.clone();
let job = async move {
#[cfg(with_metrics)]
let send_latency = metrics::SEND_LATENCY.with_label_values(&[&address]);
#[cfg(with_metrics)]
let _latency = send_latency.measure_latency();
#[cfg(with_metrics)]
metrics::DESTINATION_LAG
.with_label_values(&[&address])
.observe(match cursor {
Some(next) => target.0.saturating_sub(next.0) as f64,
None => 1.0,
});
let result = match live {
Some((certificate, blobs)) => {
sender
.send_block(&certificate, &blobs, cursor, max_catch_up)
.await
}
None => {
sender
.send_missing_blocks(chain_id, target, cursor, max_catch_up)
.await
}
};
let outcome = match result {
Ok(next_height) => SendOutcome::Reached(next_height),
Err(error) if is_local_scoped(&error) => SendOutcome::LocalScoped(Box::new(error)),
Err(error) => match park_reason(&error) {
Some(reason) => SendOutcome::Unrecoverable(reason, Box::new(error)),
None if is_chain_scoped(&error) => SendOutcome::ChainScoped(Box::new(error)),
None => SendOutcome::DestinationScoped(Box::new(error)),
},
};
(chain_id, index, generation, outcome)
};
#[cfg(not(web))]
jobs.push(job.boxed());
#[cfg(web)]
jobs.push(job.boxed_local());
}
#[expect(clippy::too_many_arguments)]
fn drain_ready(
chains: &mut HashMap<ChainId, ChainRecord>,
storage: &S,
config: &BlockExportConfig,
index: DestIndex,
dest: &mut DestState<P::Node>,
jobs: &mut FuturesUnordered<JobFuture>,
now: Timestamp,
budget: usize,
) -> usize {
if dest.retry_at.is_some_and(|at| at > now) || dest.in_flight >= dest.window || budget == 0
{
return 0;
}
let ordered = dest.drain_candidates(dest.window.saturating_mul(LAGGING_SCAN_FACTOR));
let mut spawn = Vec::new();
let mut stale = Vec::new();
let mut last_visited = None;
for chain_id in ordered {
if dest.in_flight + spawn.len() >= dest.window || spawn.len() >= budget {
break;
}
last_visited = Some(chain_id);
let Some(record) = chains.get(&chain_id) else {
stale.push(chain_id);
continue;
};
let Some(chain_dest) = record.dest(index) else {
continue;
};
if chain_dest.parked.is_some() {
stale.push(chain_id);
continue;
}
let behind = chain_dest.next_height.is_none_or(|next| next < record.tip);
if behind
&& chain_dest.in_flight.is_none()
&& chain_dest.retry_at.is_none_or(|at| at <= now)
{
spawn.push(chain_id);
}
}
dest.advance_cursor(last_visited);
for chain_id in stale {
dest.lagging.remove(&chain_id);
}
let spawned = spawn.len();
for chain_id in spawn {
let Some(record) = chains.get_mut(&chain_id) else {
continue;
};
let tip = record.tip;
let Some(chain_dest) = record.dest_mut(index) else {
continue;
};
Self::spawn_job(
jobs, storage, config, chain_id, index, dest, chain_dest, tip, None,
);
}
spawned
}
}
fn is_chain_scoped(error: &chain_client::Error) -> bool {
matches!(
error,
chain_client::Error::RemoteNodeError(
NodeError::EventsNotFound(_)
| NodeError::BlobsNotFound(_)
| NodeError::InactiveChain(_)
)
)
}
fn is_local_scoped(error: &chain_client::Error) -> bool {
matches!(
error,
chain_client::Error::ReadCertificatesError(_) | chain_client::Error::ViewError(_)
)
}
fn back_off(
failures: &mut u32,
retry_at: &mut Option<Timestamp>,
now: Timestamp,
config: &BlockExportConfig,
) {
*retry_at = Some(now.saturating_add(backoff_delay(*failures, config)));
*failures = failures.saturating_add(1);
}
fn backoff_delay(attempt: u32, config: &BlockExportConfig) -> TimeDelta {
let delay = config
.retry_delay
.saturating_mul(1u32.checked_shl(attempt).unwrap_or(u32::MAX))
.min(config.max_retry_delay);
TimeDelta::from_micros(delay.as_micros() as u64)
}
pub(crate) struct BlockSender<S, N> {
pub(crate) remote_node: RemoteNode<N>,
pub(crate) storage: S,
pub(crate) certificate_upload_batch_size: u64,
#[cfg(with_metrics)]
pub(crate) address: String,
}
impl<S, N> BlockSender<S, N>
where
S: Storage + Clone + 'static,
N: ValidatorNode + Clone + 'static,
{
pub(crate) async fn send_block(
&mut self,
certificate: &CacheArc<ConfirmedBlockCertificate>,
blobs: &[CacheArc<Blob>],
destination_next_height: Option<BlockHeight>,
max_catch_up: u64,
) -> Result<BlockHeight, chain_client::Error> {
let block = certificate.block();
let (chain_id, height) = (block.header.chain_id, block.header.height);
let next_height = if destination_next_height == Some(height) {
height
} else {
self.send_missing_blocks(chain_id, height, destination_next_height, max_catch_up)
.await?
};
if next_height != height {
return Ok(next_height);
}
let info = self.send_confirmed_certificate(certificate, blobs).await?;
Ok(info.next_block_height)
}
pub(crate) async fn send_missing_blocks(
&mut self,
chain_id: ChainId,
target_next_height: BlockHeight,
destination_next_height: Option<BlockHeight>,
max_blocks: u64,
) -> Result<BlockHeight, chain_client::Error> {
let mut next_height = match destination_next_height {
Some(height) => height,
None => {
let query = ChainInfoQuery::new(chain_id);
self.remote_node
.handle_chain_info_query(query)
.await?
.next_block_height
}
};
let last = target_next_height
.0
.min(next_height.0.saturating_add(max_blocks));
let heights = (next_height.0..last).map(BlockHeight).collect::<Vec<_>>();
for chunk in heights.chunks(self.certificate_upload_batch_size as usize) {
let certificates = self
.storage
.read_certificates_by_heights(chain_id, chunk)
.await?;
for certificate in certificates.into_iter().flatten() {
if certificate.block().header.height < next_height {
continue;
}
let info = self.send_confirmed_certificate(&certificate, &[]).await?;
next_height = info.next_block_height;
}
}
Ok(next_height)
}
async fn send_confirmed_certificate(
&mut self,
certificate: &CacheArc<ConfirmedBlockCertificate>,
held: &[CacheArc<Blob>],
) -> Result<Box<crate::data_types::ChainInfo>, chain_client::Error> {
let delivery = CrossChainMessageDelivery::NonBlocking;
#[cfg(with_metrics)]
let certificate_latency =
metrics::CERTIFICATE_SEND_LATENCY.with_label_values(&[&self.address]);
#[cfg(with_metrics)]
let _certificate_latency = certificate_latency.measure_latency();
let mut result = self
.remote_node
.handle_optimized_confirmed_certificate(certificate, delivery)
.await;
let mut sent_blobs = false;
loop {
match result {
Err(NodeError::BlobsNotFound(blob_ids)) if !sent_blobs => {
self.remote_node
.check_blobs_not_found(certificate, &blob_ids)?;
let blobs = self.resolve_blobs(&blob_ids, held).await?;
self.remote_node
.node
.upload_blobs(blobs.into_iter().map(CacheArc::into_std).collect())
.await?;
sent_blobs = true;
}
result => return Ok(result?),
}
result = self
.remote_node
.handle_confirmed_certificate(certificate.clone(), delivery)
.await;
}
}
async fn resolve_blobs(
&self,
blob_ids: &[BlobId],
held: &[CacheArc<Blob>],
) -> Result<Vec<CacheArc<Blob>>, chain_client::Error> {
let mut blobs = Vec::with_capacity(blob_ids.len());
let mut to_read = Vec::new();
for blob_id in blob_ids {
match held.iter().find(|blob| blob.id() == *blob_id) {
Some(blob) => blobs.push(blob.clone()),
None => to_read.push(*blob_id),
}
}
if to_read.is_empty() {
return Ok(blobs);
}
let read = self
.storage
.read_blobs(&to_read)
.await?
.into_iter()
.collect::<Option<Vec<_>>>();
blobs.extend(read.ok_or(NodeError::BlobsNotFound(to_read))?);
Ok(blobs)
}
}
#[cfg(test)]
mod tests {
use linera_base::crypto::CryptoHash;
use super::*;
#[test]
fn config_check_rejects_each_zero_knob() {
assert!(BlockExportConfig::default().check().is_ok());
let invalid = [
BlockExportConfig {
certificate_upload_batch_size: 0,
..BlockExportConfig::default()
},
BlockExportConfig {
queue_size: 0,
..BlockExportConfig::default()
},
BlockExportConfig {
queue_bytes: 0,
..BlockExportConfig::default()
},
BlockExportConfig {
max_in_flight_per_destination: 0,
..BlockExportConfig::default()
},
BlockExportConfig {
max_catch_up_blocks: 0,
..BlockExportConfig::default()
},
BlockExportConfig {
idle_catch_up_interval: Duration::ZERO,
..BlockExportConfig::default()
},
BlockExportConfig {
retry_delay: Duration::ZERO,
..BlockExportConfig::default()
},
BlockExportConfig {
max_retry_delay: Duration::ZERO,
..BlockExportConfig::default()
},
BlockExportConfig {
retry_delay: Duration::from_secs(120),
max_retry_delay: Duration::from_secs(60),
..BlockExportConfig::default()
},
BlockExportConfig {
converged_chain_retention: Duration::ZERO,
..BlockExportConfig::default()
},
];
for config in invalid {
assert!(config.check().is_err(), "accepted: {config:?}");
}
}
#[test]
fn records_start_from_the_persisted_heights() {
let validator = ValidatorPublicKey::test_key(1);
let other = ValidatorPublicKey::test_key(2);
let destinations = test_destinations([validator, other]);
let exported = [(validator, BlockHeight(41))].into_iter().collect();
let record = ChainRecord::new(Timestamp::now(), &destinations, &exported);
assert_eq!(
record.dest(0).unwrap().next_height,
Some(BlockHeight(42)),
"a persisted height must seed the cursor for the block after it",
);
assert_eq!(
record.dest(1).unwrap().next_height,
None,
"a destination with nothing persisted must be queried, not assumed",
);
}
#[test]
fn a_cursor_left_unset_is_still_seeded() {
let validator = ValidatorPublicKey::test_key(1);
let destinations = test_destinations([validator]);
let exported: BTreeMap<_, _> = [(validator, BlockHeight(7))].into_iter().collect();
let mut record = ChainRecord::new(Timestamp::now(), &destinations, &BTreeMap::new());
assert_eq!(record.dest(0).unwrap().next_height, None);
record.seed_missing_cursors(&destinations, &exported);
assert_eq!(
record.dest(0).unwrap().next_height,
Some(BlockHeight(8)),
"a record the tick created must still pick up the persisted cursor",
);
}
#[test]
fn draining_rotates_through_the_backlog() {
let mut dest = test_dest_state();
dest.lagging = test_chain_ids(6).into_iter().collect();
let first = dest.drain_candidates(2);
dest.advance_cursor(first.last().copied());
let second = dest.drain_candidates(2);
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
assert!(
first.iter().all(|id| !second.contains(id)),
"the second round repeated the first: {first:?} then {second:?}",
);
}
#[test]
fn a_round_that_visits_nothing_keeps_its_place() {
let mut dest = test_dest_state();
let ids = test_chain_ids(6);
dest.lagging = ids.iter().copied().collect();
let first = dest.drain_candidates(2);
dest.advance_cursor(first.last().copied());
let parked = dest.lagging_cursor;
assert!(parked.is_some(), "the first round must park the cursor");
dest.advance_cursor(None);
assert_eq!(
dest.lagging_cursor, parked,
"a round that visited nothing moved the cursor",
);
let resumed = dest.drain_candidates(2);
assert!(
resumed.iter().all(|id| !first.contains(id)),
"the drain restarted at the front of the backlog: {first:?} then {resumed:?}",
);
}
#[test]
fn an_oscillating_peer_escalates_but_a_restored_one_does_not() {
let config = BlockExportConfig {
retry_delay: Duration::from_millis(100),
max_retry_delay: Duration::from_secs(60),
..BlockExportConfig::default()
};
let now = Timestamp::now();
let tip = BlockHeight(100);
let mut restored = ChainDest {
next_height: Some(BlockHeight(50)),
..ChainDest::default()
};
assert!(restored
.record_reached(BlockHeight(10), tip, now, &config)
.is_some());
let restore_penalty = restored.retry_at.expect("a regression backs the pair off");
assert_eq!(
restore_penalty,
now.saturating_add(TimeDelta::from_millis(100))
);
restored.record_reached(BlockHeight(20), tip, now, &config);
assert_eq!(
restored.retry_at, None,
"an advance after the restore must clear the penalty",
);
let mut liar = ChainDest {
next_height: Some(BlockHeight(50)),
..ChainDest::default()
};
let mut penalties = Vec::new();
for round in 0..4 {
liar.record_reached(BlockHeight(10), tip, now, &config);
penalties.push(
liar.retry_at
.expect("a regression backs the pair off")
.delta_since(now),
);
liar.record_reached(BlockHeight(50 + round), tip, now, &config);
}
assert_eq!(
penalties,
vec![
TimeDelta::from_millis(100),
TimeDelta::from_millis(200),
TimeDelta::from_millis(400),
TimeDelta::from_millis(800),
],
"an advance between regressions reset the penalty",
);
}
#[test]
fn a_height_above_our_tip_is_acknowledged_only_up_to_it() {
let config = BlockExportConfig::default();
let tip = BlockHeight(100);
let mut liar = ChainDest::default();
let acked = liar.record_reached(BlockHeight(u64::MAX), tip, Timestamp::now(), &config);
assert_eq!(
acked,
Some(BlockHeight(99)),
"acknowledged a height we never exported",
);
assert_eq!(
liar.next_height,
Some(tip),
"stored a cursor above our tip: the pair is now unschedulable and reads as converged",
);
let mut honest = ChainDest::default();
let acked = honest.record_reached(BlockHeight(40), tip, Timestamp::now(), &config);
assert_eq!(
acked,
Some(BlockHeight(39)),
"acknowledged more than the destination reported",
);
assert_eq!(honest.next_height, Some(BlockHeight(40)));
}
#[test]
fn a_chain_destination_pair_stays_small() {
assert_eq!(size_of::<ChainDest>(), 64);
}
#[test]
fn local_storage_errors_are_neither_chain_nor_destination_scoped() {
let view_error = chain_client::Error::ViewError(linera_views::ViewError::NotFound(
"storage is unhappy".to_owned(),
));
assert!(is_local_scoped(&view_error));
assert!(
!is_chain_scoped(&view_error),
"a storage failure would back off one pair and leave the budget untouched",
);
let events_missing =
chain_client::Error::RemoteNodeError(NodeError::EventsNotFound(vec![]));
assert!(is_chain_scoped(&events_missing));
assert!(!is_local_scoped(&events_missing));
}
#[test]
fn a_corrupt_chain_at_the_destination_parks_rather_than_penalising_the_peer() {
let corrupted = chain_client::Error::RemoteNodeError(NodeError::ChainError {
error: "Corrupted chain state: computed block outcome differs from the certificate."
.to_owned(),
});
assert_eq!(park_reason(&corrupted), Some(ParkReason::Corrupted));
assert!(
!is_chain_scoped(&corrupted) && !is_local_scoped(&corrupted),
"parking must be decided before the scoped arms, or the peer's window is halved",
);
let other = chain_client::Error::RemoteNodeError(NodeError::ChainError {
error: "Block proposal has size 999 which is too large".to_owned(),
});
assert_eq!(park_reason(&other), None);
}
#[test]
fn a_parked_pair_is_never_spawned_again() {
let mut chain_dest = ChainDest::default();
assert!(chain_dest.parked.is_none(), "pairs start unparked");
chain_dest.parked = Some(ParkReason::Corrupted);
assert!(
chain_dest.parked.is_some(),
"nothing in the retry path clears `parked`: the repair is manual on the peer's side, \
so re-arming on a timer would just resume failing against unfixed state",
);
assert_eq!(ParkReason::Corrupted.as_str(), "corrupted");
}
#[test]
fn the_budget_is_offered_to_a_different_destination_each_round() {
let indices = (0..12 as DestIndex).collect::<Vec<_>>();
let mut cursor = None;
let mut first_served = Vec::new();
for _ in 0..12 {
let order = rotated_order(&indices, cursor);
first_served.push(order[0]);
cursor = next_drain_cursor(&indices, order.first().copied());
}
assert_eq!(
first_served,
vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
"the same destinations kept first claim on the budget",
);
}
#[test]
fn forgetting_a_drained_burst_returns_its_table() {
let mut progress = ProgressMap::default();
let chains = test_chain_ids(20_000);
for chain_id in &chains {
progress.heights.insert(*chain_id, Vec::new());
}
let peak_capacity = progress.heights.capacity();
let keep = MAX_FORGET_PER_SWEEP + 1000;
let (bulk, _) = chains.split_at(chains.len() - keep);
assert!(
progress.forget_chains(bulk).is_none(),
"rebuilt a map still holding {} entries, past the {MAX_FORGET_PER_SWEEP} budget",
progress.heights.len(),
);
assert!(
progress.heights.capacity() > progress.heights.len().saturating_mul(4),
"the table has to be oversized here or the case proves nothing",
);
let survivors = 10;
let within_budget = chains.len() - survivors;
let old_table = progress.forget_chains(&chains[chains.len() - keep..within_budget]);
assert!(
old_table.is_some_and(|table| table.capacity() > peak_capacity / 2),
"the peak-sized table was not handed back for freeing off the mutex",
);
assert!(progress.heights.capacity() < peak_capacity / 4);
assert_eq!(progress.heights.len(), survivors);
}
fn test_chain_ids(count: usize) -> Vec<ChainId> {
let mut ids = (0..count)
.map(|i| ChainId(CryptoHash::test_hash(format!("chain{i}"))))
.collect::<Vec<_>>();
ids.sort_unstable();
ids
}
fn test_destinations(
validators: impl IntoIterator<Item = ValidatorPublicKey>,
) -> BTreeMap<DestIndex, DestState<()>> {
validators
.into_iter()
.enumerate()
.map(|(index, validator)| {
let dest = DestState {
validator,
..test_dest_state()
};
(index as DestIndex, dest)
})
.collect()
}
fn test_dest_state() -> DestState<()> {
DestState {
node: (),
validator: ValidatorPublicKey::test_key(0),
address: "grpc:localhost:1".to_string(),
generation: 1,
in_flight: 0,
window: 1,
retry_at: None,
failures: 0,
lagging: BTreeSet::new(),
lagging_cursor: None,
}
}
#[test]
fn back_off_escalates_and_caps() {
let config = BlockExportConfig {
retry_delay: Duration::from_millis(100),
max_retry_delay: Duration::from_millis(450),
..BlockExportConfig::default()
};
let mut failures = 0;
let mut retry_at = None;
let now = Timestamp::now();
let mut delays = Vec::new();
for _ in 0..4 {
back_off(&mut failures, &mut retry_at, now, &config);
delays.push(retry_at.expect("set by back_off").delta_since(now));
}
assert_eq!(
delays,
[
TimeDelta::from_millis(100),
TimeDelta::from_millis(200),
TimeDelta::from_millis(400),
TimeDelta::from_millis(450),
],
);
}
}