linera_core/chain_worker/export.rs
1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pushing executed blocks to the other validators in the committee.
5//!
6//! One queue task per server process. Chain workers hand it every block they execute —
7//! certificate and blobs, both already in memory — and it pushes them to the rest of the
8//! committee. This is the dissemination that makes each validator a complete replica; consensus
9//! alone only guarantees that a quorum holds any given block.
10//!
11//! One task per *process* rather than per chain worker, for two reasons. Nothing on this path may
12//! touch a chain worker — a task that does resets the worker's keep-alive clock and keeps it
13//! resident forever — so everything here reads storage only. And destination state must be
14//! shared: with per-chain tasks, one unreachable validator is discovered, retried, and backed off
15//! independently by every chain, which at scale is thousands of connection attempts against a
16//! peer that may already be struggling. Here each destination has one connection, one backoff,
17//! and one AIMD window that all chains share: a success widens the window additively, a
18//! transport failure halves it, so the total in-flight load on a struggling peer shrinks
19//! exponentially while it struggles and recovers once it stops.
20//!
21//! Failures are scoped by what they say. A timeout or transport error is about the
22//! *destination*, so it halves the window and backs the destination off (and re-resolves its
23//! node, which rotates to the next proxy when the transport is relayed). An error like
24//! `EventsNotFound` is about one *chain* — the destination lacks the committee that signed the
25//! certificate — so it backs off only that chain-destination pair: the admin chain's own export
26//! is what will fix it, and must not be throttled by it.
27//!
28//! The queue is bounded, and a full queue drops the block rather than blocking the worker.
29//! Dropping is safe because it is *repaired*: the queue tracks each chain's tip against what
30//! every destination acknowledged, and closes any gap from storage during idle rounds. Per-chain
31//! height ordering is preserved by allowing at most one in-flight send per chain-destination
32//! pair.
33//!
34//! Chains converge and are forgotten: a record exists only while some destination is behind, so
35//! memory is bounded by lagging chains, not by every chain the process ever served. Note what
36//! that means during an outage — a destination that is down keeps every chain that produced a
37//! block while it was gone, because that set *is* the work-list its catch-up needs. That is
38//! bounded by the chains that were active, and [`metrics::TRACKED_CHAINS`] exposes it. The
39//! work-list therefore covers chains seen since the process started — a validator that needs the
40//! full history of a chain that never produces blocks again is out of scope here.
41
42use std::{
43 collections::{BTreeMap, BTreeSet, HashMap},
44 iter,
45 sync::{Arc, Mutex},
46};
47
48use futures::{stream::FuturesUnordered, FutureExt as _, StreamExt as _};
49#[cfg(with_metrics)]
50use linera_base::prometheus_util::MeasureLatency as _;
51use linera_base::{
52 crypto::ValidatorPublicKey,
53 data_types::{Blob, BlockHeight, Epoch, TimeDelta, Timestamp},
54 identifiers::{BlobId, ChainId, StreamId},
55 time::{timer::timeout, Duration},
56};
57use linera_chain::types::ConfirmedBlockCertificate;
58use linera_execution::{committee::Committee, system::EPOCH_STREAM_NAME};
59use linera_storage::{Arc as CacheArc, Clock as _, Storage};
60use tokio::sync::mpsc;
61use tracing::{debug, instrument, warn};
62
63use crate::{
64 client::chain_client,
65 data_types::ChainInfoQuery,
66 node::{CrossChainMessageDelivery, NodeError, ValidatorNode, ValidatorNodeProvider},
67 remote_node::RemoteNode,
68};
69
70#[cfg(with_metrics)]
71pub(crate) mod metrics {
72 use linera_base::prometheus_util::{
73 exponential_bucket_interval, exponential_bucket_latencies, register_histogram,
74 register_histogram_vec, register_int_counter, register_int_counter_vec, register_int_gauge,
75 register_int_gauge_vec,
76 };
77 use prometheus::{Histogram, HistogramVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec};
78
79 linera_base::declare_metrics! {
80 /// Sends refused for a reason about one chain rather than the destination — most often a
81 /// committee the destination has not learned yet. Self-healing, so a rising rate is the
82 /// signal, not the count: one that stays flat and non-zero means a destination is stuck on
83 /// some chain and nothing is repairing it.
84 pub static CHAIN_SCOPED_BACKOFFS: IntCounterVec = register_int_counter_vec(
85 "block_export_chain_scoped_backoffs",
86 "Sends deferred because a destination cannot accept a particular chain yet",
87 &["validator"],
88 );
89
90 /// Chains the queue is tracking: those with a destination still behind, plus recently
91 /// converged ones inside the retention window. A destination that is down holds every chain
92 /// that produced a block during the outage here — that is the work-list its catch-up needs,
93 /// and this is how an operator sees it growing.
94 pub static TRACKED_CHAINS: IntGauge = register_int_gauge(
95 "block_export_tracked_chains",
96 "Chains the export queue is tracking for catch-up",
97 );
98
99 /// Blocks waiting in the export queue.
100 pub static QUEUE_SIZE: IntGauge = register_int_gauge(
101 "block_export_queue_size",
102 "Blocks queued for export in this process",
103 );
104
105 /// Blob payload bytes held by queued blocks, since a block count alone hides the memory a
106 /// backlog of large blobs pins.
107 pub static QUEUE_BYTES: IntGauge = register_int_gauge(
108 "block_export_queue_bytes",
109 "Blob bytes held by blocks queued for export in this process",
110 );
111
112 /// Blocks dropped because the queue was full. Each is repaired by a later catch-up round, so
113 /// this counting up means latency, not loss.
114 pub static DROPPED_BLOCKS: IntCounter = register_int_counter(
115 "block_export_dropped_blocks",
116 "Blocks dropped from a full export queue, to be re-sent from storage",
117 );
118
119 /// Time from a block being queued to its sends being scheduled.
120 pub static EXPORT_LATENCY: Histogram = register_histogram(
121 "block_export_latency",
122 "Time (ms) a block waits in the export queue before its sends are scheduled",
123 exponential_bucket_latencies(60_000.0),
124 );
125
126 /// Time for one catch-up ROUND against one destination — every certificate it pushed, not
127 /// one block. A round closes up to `max_catch_up_blocks` of the gap, so this scales with
128 /// how far behind the destination is and says nothing on its own about how responsive that
129 /// destination is. Use `CERTIFICATE_SEND_LATENCY` for that, and read this one as "how long
130 /// a unit of catch-up work takes".
131 ///
132 /// The ceiling is well above `max_retry_delay` so a slow round lands in a real bucket
133 /// instead of piling into `+Inf`: at 60 s every destination with a backlog pegged at the
134 /// top bucket and the quantiles stopped discriminating between them.
135 pub static SEND_LATENCY: HistogramVec =
136 register_histogram_vec(
137 "block_export_send_latency",
138 "Time (ms) for one catch-up round against one destination validator",
139 &["validator"],
140 exponential_bucket_latencies(600_000.0),
141 );
142
143 /// Time for a SINGLE certificate push, which is one round trip to the destination.
144 ///
145 /// This is the destination's actual responsiveness, independent of how much catch-up the
146 /// round carried: a peer that is far behind makes long rounds out of fast round trips, and
147 /// only this metric can tell that apart from a peer that is genuinely slow to answer.
148 pub static CERTIFICATE_SEND_LATENCY: HistogramVec =
149 register_histogram_vec(
150 "block_export_certificate_send_latency",
151 "Time (ms) for one certificate round trip to one destination validator",
152 &["validator"],
153 exponential_bucket_latencies(60_000.0),
154 );
155
156 /// How many concurrent sends each destination is currently allowed.
157 pub static DESTINATION_WINDOW: IntGaugeVec = register_int_gauge_vec(
158 "block_export_destination_window",
159 "AIMD in-flight window per destination validator",
160 &["validator"],
161 );
162
163 /// Destinations currently resolved. Zero with export enabled means the committee could not
164 /// be loaded or no address resolved — on a dashboard that is otherwise indistinguishable
165 /// from a healthy validator with nothing to send.
166 pub static DESTINATIONS: IntGauge = register_int_gauge(
167 "block_export_destinations",
168 "Committee members this validator is currently exporting to",
169 );
170
171 /// Chains parked against a destination because retrying cannot help — the destination
172 /// returned an error no amount of resending fixes. Split by reason so a corrupted peer is
173 /// distinguishable from whatever we learn to park for next. Only a restart clears these:
174 /// the repair is manual on the destination's side, so re-arming on a timer would just
175 /// resume failing against state nobody has fixed.
176 pub static PARKED_CHAINS: IntGaugeVec =
177 register_int_gauge_vec(
178 "block_export_parked_chains",
179 "Chains parked at a destination, by park reason",
180 &["validator", "reason"],
181 );
182
183 /// Lagging (chain, destination) *pairs*, which is what the queue's memory tracks — a chain
184 /// behind on ten destinations costs ten times one behind on one.
185 pub static LAGGING_PAIRS: IntGauge = register_int_gauge(
186 "block_export_lagging_pairs",
187 "Chain-destination pairs currently behind, summed over destinations",
188 );
189
190 /// Blocks this validator still owes each destination, summed over every chain it is behind
191 /// on. The aggregate backlog, which is what "is that validator caught up" actually asks.
192 pub static BLOCKS_OWED: IntGaugeVec = register_int_gauge_vec(
193 "block_export_blocks_owed",
194 "Blocks still to send to a destination validator, summed over all chains",
195 &["validator"],
196 );
197
198 /// The furthest behind any single chain is for a destination. A quantile over chains would
199 /// need a per-pair observation, measured at 150 ms per sweep at a million pairs against
200 /// 2 ms for this; and the maximum is the tail a quantile would hide anyway.
201 pub static MAX_CHAIN_GAP: IntGaugeVec = register_int_gauge_vec(
202 "block_export_max_chain_gap",
203 "Blocks the furthest-behind chain owes a destination validator",
204 &["validator"],
205 );
206
207 /// The queue-wide in-flight budget, halved whenever our own storage fails a read.
208 pub static TOTAL_WINDOW: IntGauge = register_int_gauge(
209 "block_export_total_window",
210 "Concurrent sends allowed across all destinations (AIMD on local storage failures)",
211 );
212
213 /// Sends the destination actually answered, as opposed to attempts: `SEND_LATENCY` counts
214 /// every completion, failures included, so success rate needs its own counter.
215 pub static SENDS_SUCCEEDED: IntCounterVec = register_int_counter_vec(
216 "block_export_sends_succeeded",
217 "Export sends acknowledged by the destination validator",
218 &["validator"],
219 );
220
221 /// How many blocks the destination was behind when we pushed to it: zero whenever the block
222 /// was contiguous there, and the size of the gap we had to fill otherwise.
223 pub static DESTINATION_LAG: HistogramVec = register_histogram_vec(
224 "block_export_destination_lag",
225 "Blocks a destination validator was missing when a block was pushed to it",
226 &["validator"],
227 exponential_bucket_interval(1.0, 10_000_000.0),
228 );
229 }
230}
231
232/// Configuration for pushing executed blocks to the other committee validators.
233#[derive(Clone, Debug)]
234pub struct BlockExportConfig {
235 /// How many certificates are read from storage per catch-up read. Smaller than the
236 /// client's 500: a chunk lives inside one send job, and `max_catch_up_blocks` bounds the
237 /// round anyway.
238 pub certificate_upload_batch_size: u64,
239 /// How many blocks the export queue holds before dropping new ones for catch-up to repair.
240 pub queue_size: usize,
241 /// The most blob payload bytes queued blocks may pin before new ones are dropped for
242 /// catch-up to repair — a block count alone lets 1024 blob-heavy blocks pin gigabytes.
243 pub queue_bytes: usize,
244 /// The most concurrent sends one destination is ever allowed — the AIMD window's ceiling.
245 pub max_in_flight_per_destination: usize,
246 /// The most concurrent sends across *all* destinations. Each one can be reading up to
247 /// `max_catch_up_blocks` certificates, so without this the aggregate read concurrency is
248 /// the per-destination window times the committee size, and nothing shrinks it when our own
249 /// storage is the bottleneck.
250 pub max_in_flight_total: usize,
251 /// How long a destination is skipped after a failed push, doubling up to `max_retry_delay`.
252 /// Coarser than the transport's per-request retries: those decide whether one call is worth
253 /// repeating, this decides whether the destination is worth attempting at all right now.
254 pub retry_delay: Duration,
255 /// The longest a failing destination is skipped for.
256 pub max_retry_delay: Duration,
257 /// How long the queue waits for a new block before spending a round catching up destinations
258 /// that are behind. With `max_catch_up_blocks` this sets the backfill rate, so tune them
259 /// together.
260 pub idle_catch_up_interval: Duration,
261 /// How many missing blocks are pushed to one destination per round. Deliberately small: it
262 /// bounds what a live block may wait behind, and a validator that just joined reports height
263 /// 0, so its catch-up is otherwise arbitrarily large.
264 pub max_catch_up_blocks: u64,
265 /// How long a converged chain's record is kept before being forgotten. Long enough for the
266 /// chain's worker to fold the final heights into `exported_heights` on its next save; after
267 /// it, a lost cursor costs one query to rebuild.
268 pub converged_chain_retention: Duration,
269}
270
271impl BlockExportConfig {
272 /// Rejects values that would make export misbehave rather than merely perform badly. Checked
273 /// at startup, so a typo fails fast instead of surfacing as a panic, a spinning task, or gaps
274 /// that are never repaired.
275 pub fn check(&self) -> Result<(), String> {
276 if self.certificate_upload_batch_size == 0 {
277 // `slice::chunks(0)` panics.
278 return Err("block export batch size must be greater than zero".into());
279 }
280 if self.queue_size == 0 {
281 // Every block would be dropped and export would run on catch-up alone.
282 return Err("block export queue size must be greater than zero".into());
283 }
284 if self.queue_bytes == 0 {
285 // Every block carrying any blob would be dropped.
286 return Err("block export queue byte budget must be greater than zero".into());
287 }
288 if self.max_in_flight_per_destination == 0 {
289 // No destination could ever be sent anything.
290 return Err("block export in-flight ceiling must be greater than zero".into());
291 }
292 if self.max_in_flight_total == 0 {
293 // The queue-wide budget would admit nothing, so no send would ever start.
294 return Err("block export total in-flight budget must be greater than zero".into());
295 }
296 if self.max_in_flight_total < self.max_in_flight_per_destination {
297 // One destination could never reach its own ceiling, and the AIMD window would
298 // advertise a capacity the queue refuses to grant.
299 return Err(
300 "block export total in-flight budget must be at least the per-destination ceiling"
301 .into(),
302 );
303 }
304 if self.max_catch_up_blocks == 0 {
305 // Every gap would stay open forever, silently.
306 return Err("block export catch-up bound must be greater than zero".into());
307 }
308 if self.idle_catch_up_interval.is_zero() {
309 // The idle timer would fire continuously, turning the task into a busy loop.
310 return Err("block export idle interval must be greater than zero".into());
311 }
312 if self.retry_delay.is_zero() {
313 // A failing destination would be retried without pause.
314 return Err("block export retry delay must be greater than zero".into());
315 }
316 if self.max_retry_delay.is_zero() {
317 // The cap would clamp every computed backoff to zero, same busy retry as above.
318 return Err("block export max retry delay must be greater than zero".into());
319 }
320 if self.retry_delay > self.max_retry_delay {
321 // The very first backoff would already exceed its own ceiling.
322 return Err("block export retry delay must not exceed the max retry delay".into());
323 }
324 if self.converged_chain_retention.is_zero() {
325 // The progress of a converged chain would be dropped before its worker folds it in.
326 return Err("block export converged-chain retention must be greater than zero".into());
327 }
328 Ok(())
329 }
330}
331
332impl Default for BlockExportConfig {
333 fn default() -> Self {
334 BlockExportConfig {
335 certificate_upload_batch_size: 100,
336 queue_size: 1024,
337 queue_bytes: 256 * 1024 * 1024,
338 max_in_flight_per_destination: 8,
339 max_in_flight_total: 64,
340 retry_delay: Duration::from_secs(1),
341 max_retry_delay: Duration::from_secs(60),
342 idle_catch_up_interval: Duration::from_millis(200),
343 max_catch_up_blocks: 200,
344 converged_chain_retention: Duration::from_secs(300),
345 }
346 }
347}
348
349/// A block a chain worker has executed, on its way to the other validators.
350struct ExportedBlock {
351 certificate: CacheArc<ConfirmedBlockCertificate>,
352 /// The block's required blobs, so that a destination missing them — which is always the case
353 /// for a blob this very block publishes — is served without a read from storage. Held as the
354 /// storage cache's pointers, so queued blocks share the allocations rather than copying them.
355 blobs: Vec<CacheArc<Blob>>,
356 /// The chain's epoch *after* the block was applied — a hint that lets the queue load a newer
357 /// committee from storage, so a validator joining in this block is exported to immediately.
358 epoch: Epoch,
359 /// The persisted `exported_heights` of the chain, seeding missing cursors so a restart
360 /// re-sends at most one block per destination instead of a history.
361 exported_heights: BTreeMap<ValidatorPublicKey, BlockHeight>,
362 /// Blob payload bytes, counted at enqueue so the dequeue releases the same amount of the
363 /// byte budget.
364 blob_bytes: usize,
365 #[cfg(with_metrics)]
366 /// Wall clock, not the storage clock: this only feeds the queue-latency histogram, and a
367 /// simulated clock would report time that no operator waited.
368 queued_at: linera_base::time::Instant,
369}
370
371/// The chain workers' end of the export queue: hands blocks over and reads back progress.
372pub struct BlockExportHandle {
373 blocks: mpsc::Sender<ExportedBlock>,
374 progress: SharedProgress,
375 tips: SharedTips,
376 /// Blob payload bytes currently pinned by queued blocks, enforced against
377 /// [`BlockExportConfig::queue_bytes`].
378 queued_bytes: Arc<std::sync::atomic::AtomicUsize>,
379 queue_bytes_budget: usize,
380}
381
382impl Clone for BlockExportHandle {
383 fn clone(&self) -> Self {
384 BlockExportHandle {
385 blocks: self.blocks.clone(),
386 progress: self.progress.clone(),
387 tips: self.tips.clone(),
388 queued_bytes: self.queued_bytes.clone(),
389 queue_bytes_budget: self.queue_bytes_budget,
390 }
391 }
392}
393
394/// A destination's dense index, assigned once per public key and never reused.
395///
396/// All per-chain state is keyed by this rather than by `ValidatorPublicKey`, which is 88 bytes
397/// and whose `Ord` re-encodes the curve point on *every comparison* — measured at 36 ns, against
398/// about 1 ns for an integer. Per-chain state is the one place that cost is multiplied by the
399/// number of chains a down destination leaves behind.
400type DestIndex = u32;
401
402/// The highest height each destination has acknowledged, per chain, written by the queue task
403/// and folded into each chain's `exported_heights` by its worker on save. Entries are removed
404/// when a chain converges, so this holds lagging chains only.
405#[derive(Default)]
406struct ProgressMap {
407 /// What the indices below refer to. Append-only: an index keeps its meaning for the life of
408 /// the process, so a validator that leaves and rejoins cannot inherit another's cursors.
409 validators: Vec<ValidatorPublicKey>,
410 heights: HashMap<ChainId, Vec<(DestIndex, BlockHeight)>>,
411}
412
413impl ProgressMap {
414 /// Drops the given chains, and returns a burst's peak-sized table for the caller to free
415 /// *outside* the mutex.
416 ///
417 /// `remove` never shrinks, so a drained burst would otherwise pin its peak allocation
418 /// forever. The obvious `shrink_to_fit` is not usable here: it frees the peak-sized bucket
419 /// array in place, and this runs under the mutex every chain worker takes per executed block
420 /// — whose hold `MAX_FORGET_PER_SWEEP` exists to bound. Rebuilding the few survivors into a
421 /// fitted map costs within that budget; handing the old table back moves the
422 /// peak-proportional free off the lock.
423 fn forget_chains(
424 &mut self,
425 forgotten: &[ChainId],
426 ) -> Option<HashMap<ChainId, Vec<(DestIndex, BlockHeight)>>> {
427 for chain_id in forgotten {
428 self.heights.remove(chain_id);
429 }
430 if self.heights.len() <= MAX_FORGET_PER_SWEEP
431 && self.heights.capacity() > self.heights.len().saturating_mul(4)
432 {
433 let survivors = self.heights.drain().collect();
434 return Some(std::mem::replace(&mut self.heights, survivors));
435 }
436 None
437 }
438}
439
440type SharedProgress = Arc<Mutex<ProgressMap>>;
441
442/// The height after each chain's newest announced block. Written on every `export` call before
443/// the queue is tried, so a block the full queue drops still raises the repair target the tick
444/// measures destinations against.
445type SharedTips = Arc<Mutex<HashMap<ChainId, BlockHeight>>>;
446
447impl BlockExportHandle {
448 /// Queues a block for export and returns immediately. A full queue drops the block — never
449 /// blocks the worker — and the tip announced below is what lets catch-up re-send it from
450 /// storage. `exported_heights` seeds the chain's cursors on its first block this process.
451 pub(crate) fn export(
452 &self,
453 certificate: CacheArc<ConfirmedBlockCertificate>,
454 blobs: Vec<CacheArc<Blob>>,
455 epoch: Epoch,
456 exported_heights: BTreeMap<ValidatorPublicKey, BlockHeight>,
457 ) {
458 // Announced before the queue is tried: a dropped block must still raise the repair
459 // target, or a chain whose *last* block was dropped would never be repaired at all.
460 {
461 let header = &certificate.block().header;
462 let tip = header.height.try_add_one().unwrap_or(BlockHeight::MAX);
463 let mut tips = self.tips.lock().expect("tips mutex is never poisoned");
464 let entry = tips.entry(header.chain_id).or_insert(tip);
465 *entry = (*entry).max(tip);
466 }
467 let blob_bytes = blobs.iter().map(|blob| blob.bytes().len()).sum::<usize>();
468 // The byte budget is enforced, not merely measured: a block count alone would let a few
469 // blob-heavy blocks pin memory far past the cache's own bounds. Reserved *before* the
470 // send: incrementing after `try_send` would let the queue task's decrement run first and
471 // wrap the counter, spuriously exhausting the budget for every concurrent caller.
472 let prior = self
473 .queued_bytes
474 .fetch_add(blob_bytes, std::sync::atomic::Ordering::Relaxed);
475 if prior.saturating_add(blob_bytes) > self.queue_bytes_budget {
476 self.queued_bytes
477 .fetch_sub(blob_bytes, std::sync::atomic::Ordering::Relaxed);
478 debug!(
479 chain_id = %certificate.block().header.chain_id,
480 height = %certificate.block().header.height,
481 queued = prior, blob_bytes,
482 "Export queue byte budget exhausted; dropping the block for catch-up to re-send",
483 );
484 #[cfg(with_metrics)]
485 metrics::DROPPED_BLOCKS.inc();
486 return;
487 }
488 let block = ExportedBlock {
489 certificate,
490 blobs,
491 epoch,
492 exported_heights,
493 blob_bytes,
494 #[cfg(with_metrics)]
495 queued_at: linera_base::time::Instant::now(),
496 };
497 match self.blocks.try_send(block) {
498 Ok(()) => {
499 #[cfg(with_metrics)]
500 {
501 metrics::QUEUE_SIZE.inc();
502 metrics::QUEUE_BYTES.add(i64::try_from(blob_bytes).unwrap_or(i64::MAX));
503 }
504 }
505 Err(mpsc::error::TrySendError::Full(block)) => {
506 self.queued_bytes
507 .fetch_sub(blob_bytes, std::sync::atomic::Ordering::Relaxed);
508 debug!(
509 chain_id = %block.certificate.block().header.chain_id,
510 height = %block.certificate.block().header.height,
511 "Export queue full; dropping the block for catch-up to re-send",
512 );
513 #[cfg(with_metrics)]
514 metrics::DROPPED_BLOCKS.inc();
515 }
516 Err(mpsc::error::TrySendError::Closed(_)) => {
517 self.queued_bytes
518 .fetch_sub(blob_bytes, std::sync::atomic::Ordering::Relaxed);
519 warn!("Block export queue stopped unexpectedly; blocks are no longer exported");
520 }
521 }
522 }
523
524 /// Returns how far each validator has been exported to on `chain_id`, restricted to
525 /// `committee` so that validators which left it are pruned.
526 pub(crate) fn progress(
527 &self,
528 chain_id: ChainId,
529 committee: &Committee,
530 ) -> BTreeMap<ValidatorPublicKey, BlockHeight> {
531 let progress = self
532 .progress
533 .lock()
534 .expect("progress mutex is never poisoned");
535 let Some(chain_progress) = progress.heights.get(&chain_id) else {
536 return BTreeMap::new();
537 };
538 chain_progress
539 .iter()
540 .filter_map(|(index, height)| {
541 let validator = progress.validators.get(*index as usize)?;
542 committee
543 .validators()
544 .contains_key(validator)
545 .then_some((*validator, *height))
546 })
547 .collect()
548 }
549}
550
551/// Spawns the process-wide export queue task and returns the handle chain workers push to.
552///
553/// The task runs until every clone of the returned handle is dropped, and reads only from
554/// `storage` — never through a chain worker, whose TTL a touch would reset.
555pub fn spawn_block_export_queue<S, P>(
556 storage: S,
557 node_provider: Arc<P>,
558 config: BlockExportConfig,
559 own_public_key: Option<ValidatorPublicKey>,
560) -> BlockExportHandle
561where
562 S: Storage + Clone + Send + Sync + 'static,
563 P: ValidatorNodeProvider + Send + Sync + 'static,
564 P::Node: Send + Sync,
565{
566 // Enforced here rather than only at the CLI: every constructor, tests included, must go
567 // through it, and an invalid config panics at startup instead of mid-export.
568 if let Err(message) = config.check() {
569 panic!("invalid block export configuration: {message}");
570 }
571 let (blocks, receiver) = mpsc::channel(config.queue_size);
572 let progress: SharedProgress = Arc::default();
573 let tips: SharedTips = Arc::default();
574 let queued_bytes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
575 let queue_bytes_budget = config.queue_bytes;
576 let max_in_flight_total = config.max_in_flight_total;
577
578 let task = BlockExportQueue {
579 storage,
580 node_provider,
581 config,
582 own_public_key,
583 latest_epoch: None,
584 committee: None,
585 committee_dirty: false,
586 admin_chain_id: None,
587 ticks_until_scan: 0,
588 ticks_until_sweep: TICKS_PER_CONVERGENCE_SWEEP,
589 #[cfg(with_metrics)]
590 ticks_until_census: 0,
591 drain_cursor: None,
592 chains: HashMap::new(),
593 destinations: BTreeMap::new(),
594 dest_indices: BTreeMap::new(),
595 next_generation: 0,
596 total_window: max_in_flight_total,
597 announced_epoch: None,
598 scan_attempted_for: None,
599 destinations_changed: false,
600 queued_bytes: queued_bytes.clone(),
601 progress: progress.clone(),
602 tips: tips.clone(),
603 draining: false,
604 };
605 linera_base::Task::spawn(task.run(receiver)).forget();
606
607 BlockExportHandle {
608 blocks,
609 progress,
610 tips,
611 queued_bytes,
612 queue_bytes_budget,
613 }
614}
615
616/// What we know of one chain: its tip, and each destination's position below it. Kept while some
617/// destination is behind, and for a grace window after convergence so the chain's worker can
618/// still fold the final heights into its state; then forgotten.
619struct ChainRecord {
620 /// The height after the chain's last known block: what a destination must reach to be
621 /// caught up.
622 tip: BlockHeight,
623 /// When this chain last saw a block or a completed send, for the convergence sweep.
624 last_activity: Timestamp,
625 /// Sorted by index, so lookups binary-search integers. A flat vector rather than a map
626 /// because this is allocated per tracked chain: a `BTreeMap` pays for an eleven-slot leaf
627 /// whatever the committee size.
628 dests: Vec<(DestIndex, ChainDest)>,
629}
630
631impl ChainRecord {
632 /// Starts a record with a cursor for every current destination.
633 ///
634 /// Seeding here rather than at the call sites is what makes the empty-cursor state
635 /// unrepresentable: a record with no cursors is invisible to the requeue loop and trivially
636 /// "converged" to the sweep, so such a chain would be abandoned in silence. One creation
637 /// path used to miss the seeding, and nothing in the suite could see it.
638 fn new<N>(
639 now: Timestamp,
640 destinations: &BTreeMap<DestIndex, DestState<N>>,
641 exported_heights: &BTreeMap<ValidatorPublicKey, BlockHeight>,
642 ) -> Self {
643 ChainRecord {
644 tip: BlockHeight::ZERO,
645 last_activity: now,
646 // Already in index order, which is the order `dests` must keep.
647 dests: destinations
648 .iter()
649 .map(|(index, dest)| {
650 // Seeded here rather than by a later fill: a cursor pre-populated as `None`
651 // and then only `or_insert`-ed would silently swallow the persisted height,
652 // leaving `exported_heights` write-only and costing a query per destination
653 // on every restart.
654 let next_height = exported_heights
655 .get(&dest.validator)
656 .and_then(|height| height.try_add_one().ok());
657 (
658 *index,
659 ChainDest {
660 next_height,
661 ..ChainDest::default()
662 },
663 )
664 })
665 .collect(),
666 }
667 }
668
669 fn dest(&self, index: DestIndex) -> Option<&ChainDest> {
670 let at = self
671 .dests
672 .binary_search_by_key(&index, |(at, _)| *at)
673 .ok()?;
674 Some(&self.dests[at].1)
675 }
676
677 fn dest_mut(&mut self, index: DestIndex) -> Option<&mut ChainDest> {
678 let at = self
679 .dests
680 .binary_search_by_key(&index, |(at, _)| *at)
681 .ok()?;
682 Some(&mut self.dests[at].1)
683 }
684
685 /// The cursor for `index`, created empty if this record does not have one yet.
686 fn dest_entry(&mut self, index: DestIndex) -> &mut ChainDest {
687 let at = match self.dests.binary_search_by_key(&index, |(at, _)| *at) {
688 Ok(at) => at,
689 Err(at) => {
690 self.dests.insert(at, (index, ChainDest::default()));
691 at
692 }
693 };
694 &mut self.dests[at].1
695 }
696}
697
698impl ChainRecord {
699 /// Fills in every cursor this record does not have from the chain's persisted heights.
700 ///
701 /// Applied on every block rather than at record creation, and idempotent. Tying the seeding
702 /// to creation has failed twice — the tick creates records too, from a tip announced before
703 /// its block was dequeued, and it has no heights to seed with; an `or_insert` then cannot
704 /// correct a cursor that already exists as `None`.
705 ///
706 /// Safe for a cursor a failure cleared, too: the persisted height is a lower bound, so the
707 /// worst case is re-offering blocks the destination already holds, and its reply corrects
708 /// the cursor in either direction.
709 fn seed_missing_cursors<N>(
710 &mut self,
711 destinations: &BTreeMap<DestIndex, DestState<N>>,
712 exported_heights: &BTreeMap<ValidatorPublicKey, BlockHeight>,
713 ) {
714 for (index, dest) in destinations {
715 let chain_dest = self.dest_entry(*index);
716 // Guard first: this runs per block, and in the steady state every cursor is set, so
717 // the pubkey lookup would be pure waste.
718 if chain_dest.next_height.is_none() && chain_dest.in_flight.is_none() {
719 chain_dest.next_height = exported_heights
720 .get(&dest.validator)
721 .and_then(|height| height.try_add_one().ok());
722 }
723 }
724 }
725}
726
727/// One chain's cursor at one destination.
728#[derive(Default)]
729struct ChainDest {
730 /// The next height the destination needs, or `None` when we have to ask it — before the
731 /// first push, and after any failed one.
732 next_height: Option<BlockHeight>,
733 /// The destination generation of the send currently running for this pair, if any. Carrying
734 /// the generation is what stops a stale job's completion from clearing a *newer* job's flag
735 /// and breaking the one-send-per-pair ordering invariant.
736 in_flight: Option<u64>,
737 /// Backoff for *chain-scoped* failures — the destination is healthy but cannot accept this
738 /// chain yet, e.g. it lacks the committee and the admin chain's export has not reached it.
739 retry_at: Option<Timestamp>,
740 failures: u32,
741 /// How many times this destination has reported a *lower* height than it had. Counted apart
742 /// from `failures` because an advance clears those, and a peer alternating advance with
743 /// regression would otherwise reset its own penalty on every second answer and never
744 /// escalate.
745 regressions: u32,
746 /// Set once the destination gives an answer no retry can fix; cleared only by a restart.
747 parked: Option<ParkReason>,
748}
749
750impl ChainDest {
751 /// Folds in a height the destination reported, returning the height to record as
752 /// acknowledged, if any.
753 ///
754 /// The validator is authoritative about its own height in *both* directions: only one send
755 /// per pair is ever in flight and stale generations are filtered out, so a lower report is
756 /// not a race. One restored from a backup reports lower, and refusing to believe it would
757 /// leave that gap unrepaired for good — we would think it caught up and never re-send what
758 /// it lost. Believing it is therefore required; paying for it is what stops a peer from
759 /// lying its way into an unthrottled re-send loop.
760 fn record_reached(
761 &mut self,
762 reported: BlockHeight,
763 tip: BlockHeight,
764 now: Timestamp,
765 config: &BlockExportConfig,
766 ) -> Option<BlockHeight> {
767 // Clamped once, here, so the cursor, the counters and the acknowledgement all read the
768 // same height. A destination legitimately runs ahead of us — the client broadcasts to
769 // everyone — but the value is *its* claim, and storing a claim above our tip satisfies
770 // none of the "behind" predicates that schedule work, while satisfying every
771 // "converged" one. Since only a completed send can rewrite the cursor, and no send is
772 // ever scheduled for a pair that looks converged, an over-report would strand that pair
773 // for the life of the process. Clamped, it is self-correcting: the pair re-enters the
774 // work set as soon as our own tip passes the clamp.
775 let reported = reported.min(tip);
776 let previous = self.next_height;
777 let advanced = previous.is_none_or(|height| reported > height);
778 let regressed = previous.is_some_and(|height| reported < height);
779 self.next_height = Some(reported);
780 if advanced {
781 self.failures = 0;
782 self.retry_at = None;
783 } else if regressed {
784 // Escalates on the regression count, which an advance does not clear: a genuine
785 // restore regresses once and pays one delay, an oscillating peer pays double each
786 // time it lies.
787 let attempt = self.failures.max(self.regressions);
788 self.retry_at = Some(now.saturating_add(backoff_delay(attempt, config)));
789 self.regressions = self.regressions.saturating_add(1);
790 } else if reported < tip {
791 // Answered but moved nothing — a gap our storage cannot fill — so back this pair
792 // off rather than spinning on it.
793 back_off(&mut self.failures, &mut self.retry_at, now, config);
794 return None;
795 } else {
796 return None;
797 }
798 reported.try_sub_one().ok()
799 }
800}
801
802/// One destination validator: its connection and the health state every chain shares.
803struct DestState<N> {
804 node: N,
805 /// Kept here because per-chain state refers to this destination by index, not by key.
806 validator: ValidatorPublicKey,
807 address: String,
808 /// Bumped whenever this state is rebuilt, so a send started against a previous incarnation
809 /// cannot corrupt the new one's accounting when it completes.
810 generation: u64,
811 /// Sends currently running against this destination, over all chains.
812 in_flight: usize,
813 /// How many concurrent sends the AIMD control currently allows: +1 per success up to the
814 /// configured ceiling, halved per transport failure down to 1.
815 window: usize,
816 /// Backoff for *destination-scoped* failures: transport errors and timeouts.
817 retry_at: Option<Timestamp>,
818 failures: u32,
819 /// Chains this destination is behind on, maintained as pairs fall behind and converge
820 /// rather than rediscovered by scanning every chain each tick — that scan was O(tracked
821 /// chains x destinations) at 5 Hz, which at a million lagging chains took longer than the
822 /// tick interval itself and starved the queue of everything else.
823 ///
824 /// Membership *is* the "needs work" flag, so there is no separate `queued` bit to fall out
825 /// of step with it.
826 lagging: BTreeSet<ChainId>,
827 /// Where the next drain resumes in `lagging`. Without it the set's ordering hands the window
828 /// to the same lowest chain ids every time and starves the rest of the backlog — the
829 /// fairness the previous FIFO had for free.
830 lagging_cursor: Option<ChainId>,
831}
832
833impl<N> DestState<N> {
834 /// The chains to consider this round, resuming where the last one stopped and wrapping around.
835 ///
836 /// The rotation is the point: `lagging` is ordered, so walking it from the start every tick
837 /// would hand the window to the same lowest chain ids forever and starve the rest.
838 fn drain_candidates(&self, budget: usize) -> Vec<ChainId> {
839 match self.lagging_cursor {
840 Some(cursor) => self
841 .lagging
842 .range(cursor..)
843 .chain(self.lagging.iter().take_while(|id| **id < cursor))
844 .copied()
845 .take(budget)
846 .collect(),
847 None => self.lagging.iter().copied().take(budget).collect(),
848 }
849 }
850
851 /// Moves the cursor past the last chain considered, so the next round advances.
852 ///
853 /// A round that considered nothing — a saturated destination breaks before looking at its
854 /// first candidate — leaves the cursor alone. Clearing it there restarts the next drain at
855 /// the lowest chain id, which is the starvation the cursor exists to prevent, and a busy
856 /// destination is saturated on almost every tick.
857 fn advance_cursor(&mut self, last_considered: Option<ChainId>) {
858 let Some(last) = last_considered else {
859 return;
860 };
861 self.lagging_cursor = self.lagging.range(last..).nth(1).copied();
862 }
863}
864
865/// How one send ended, scoped to what the error tells us about.
866enum SendOutcome {
867 /// The validator answered; its reported next height.
868 Reached(BlockHeight),
869 /// The failure is about this chain on this destination, not about the destination.
870 ChainScoped(Box<chain_client::Error>),
871 /// The failure is about the destination itself.
872 DestinationScoped(Box<chain_client::Error>),
873 /// Our own storage failed. Nobody's health signal but ours.
874 LocalScoped(Box<chain_client::Error>),
875 /// The destination cannot accept this chain and resending will never change that.
876 Unrecoverable(ParkReason, Box<chain_client::Error>),
877}
878
879/// Why a chain is parked at a destination.
880///
881/// Distinct from a chain-scoped backoff, which assumes the destination will catch up on its own:
882/// a park says retrying is pointless until a human repairs the destination.
883#[derive(Clone, Copy, Debug, PartialEq, Eq)]
884enum ParkReason {
885 /// The destination's own copy of this chain is corrupt — it recomputed a block and got an
886 /// outcome the certificate contradicts. Resending the same certificate reproduces it exactly.
887 Corrupted,
888}
889
890impl ParkReason {
891 /// Stable metric label; keep these in sync with the exporter dashboard.
892 fn as_str(self) -> &'static str {
893 match self {
894 ParkReason::Corrupted => "corrupted",
895 }
896 }
897}
898
899/// Whether the destination's answer means "never retry this chain", and why.
900///
901/// Matching the rendered message is deliberate and cannot currently be avoided: `ChainError`
902/// crosses the wire through `From<ChainError> for NodeError`, whose catch-all arm converts every
903/// unmapped variant to `NodeError::ChainError { error: String }`. The type is destroyed by the
904/// *sender*, so there is nothing structured left to match on by the time it reaches us. Adding a
905/// dedicated `NodeError` variant would not help either: `NodeError` is bincode-encoded
906/// (`rpc.proto`, "a bincode wrapper around NodeError"), so variants are indexed positionally and a
907/// new one is a wire break — and the peers this exists to detect are precisely the ones on older
908/// builds, which would keep sending the stringly-typed form regardless.
909fn park_reason(error: &chain_client::Error) -> Option<ParkReason> {
910 match error {
911 chain_client::Error::RemoteNodeError(NodeError::ChainError { error })
912 if error.contains("Corrupted chain state") =>
913 {
914 Some(ParkReason::Corrupted)
915 }
916 _ => None,
917 }
918}
919
920/// The body of the process-wide export queue task.
921struct BlockExportQueue<S, P>
922where
923 S: Storage,
924 P: ValidatorNodeProvider,
925{
926 storage: S,
927 node_provider: Arc<P>,
928 config: BlockExportConfig,
929 own_public_key: Option<ValidatorPublicKey>,
930 /// The newest committee seen, from exported blocks and from scanning storage forward. Used
931 /// for the destination set of every chain: a chain's own committee cannot announce a
932 /// newcomer, and current committee members need every chain regardless of its epoch.
933 latest_epoch: Option<Epoch>,
934 committee: Option<Arc<Committee>>,
935 /// Set when `committee` changed and the destination set has not been rebuilt yet, so the
936 /// rebuild runs per committee change rather than per block.
937 committee_dirty: bool,
938 /// The admin chain, read from the network description once, for the silent epoch probe.
939 admin_chain_id: Option<ChainId>,
940 /// Ticks until the next storage scan for a committee no block has carried yet.
941 ticks_until_scan: u32,
942 /// Ticks until the next sweep of converged chains.
943 ticks_until_sweep: u32,
944 /// Ticks until the next backlog census.
945 #[cfg(with_metrics)]
946 ticks_until_census: u32,
947 /// Which destination the queue-wide budget is offered to first, rotated every tick.
948 drain_cursor: Option<DestIndex>,
949 chains: HashMap<ChainId, ChainRecord>,
950 destinations: BTreeMap<DestIndex, DestState<P::Node>>,
951 /// Every validator ever registered as a destination, and the index its per-chain state uses.
952 /// Never pruned, so a validator that rejoins reuses its index rather than taking a departed
953 /// one's.
954 dest_indices: BTreeMap<ValidatorPublicKey, DestIndex>,
955 /// The last destination generation handed out; never reused within this queue's lifetime.
956 next_generation: u64,
957 /// Concurrent sends allowed across all destinations right now: halved when our own storage
958 /// fails a read, restored one slot per success up to `max_in_flight_total`. A destination's
959 /// own window bounds what one peer can consume; this bounds what the queue as a whole asks
960 /// of storage.
961 total_window: usize,
962 /// The newest epoch any exported block has announced; the tick loads its committee when it
963 /// is ahead of `latest_epoch`.
964 announced_epoch: Option<Epoch>,
965 /// The announcement the eager scan last acted on, so one that fails to load is retried on
966 /// the normal cadence rather than on every tick.
967 scan_attempted_for: Option<Epoch>,
968 /// Set when `sync_destinations` changed the set, so the per-record cursor fill runs once
969 /// per change instead of once per tick.
970 destinations_changed: bool,
971 /// Blob bytes pinned by queued blocks, shared with every handle for budget enforcement.
972 queued_bytes: Arc<std::sync::atomic::AtomicUsize>,
973 progress: SharedProgress,
974 /// The highest height each chain has announced, written by every `export` call — including
975 /// ones the full queue dropped — so a dropped block still moves the repair target.
976 tips: SharedTips,
977 /// True once every handle is dropped: completions may finish, nothing new starts.
978 draining: bool,
979}
980
981/// How many ticks apart the queue probes storage for committees no block has announced.
982const TICKS_PER_COMMITTEE_SCAN: u32 = 10;
983
984/// How many times the window a single drain may look past before giving up for this tick, so a
985/// backlog of ineligible entries cannot turn the drain back into a full scan.
986const LAGGING_SCAN_FACTOR: usize = 4;
987
988/// How many ticks apart converged chains are swept. The window they are held for is minutes, so
989/// this only decides how promptly the memory comes back.
990const TICKS_PER_CONVERGENCE_SWEEP: u32 = 25;
991
992/// How many ticks apart the backlog census runs. Slower than the sweep because it costs the size
993/// of the real backlog and answers a dashboard question, not a scheduling one.
994#[cfg(with_metrics)]
995const TICKS_PER_BACKLOG_CENSUS: u32 = 300;
996
997/// Destinations in the order the queue-wide budget is offered to them, resuming past `cursor`
998/// and wrapping. Rotating matters because the budget is shared: served in index order every
999/// time, the first `max_in_flight_total / max_in_flight_per_destination` destinations absorb all
1000/// of it and the rest never get a catch-up slot.
1001fn rotated_order(indices: &[DestIndex], cursor: Option<DestIndex>) -> Vec<DestIndex> {
1002 match cursor {
1003 Some(at) => indices
1004 .iter()
1005 .copied()
1006 .skip_while(|index| *index < at)
1007 .chain(indices.iter().copied().take_while(|index| *index < at))
1008 .collect(),
1009 None => indices.to_vec(),
1010 }
1011}
1012
1013/// Where the next round starts: past the destination this one served first.
1014fn next_drain_cursor(indices: &[DestIndex], served_first: Option<DestIndex>) -> Option<DestIndex> {
1015 let first = served_first?;
1016 indices.iter().copied().find(|index| *index > first)
1017}
1018
1019/// The most (chain, destination) pairs one census walks. Each is a random lookup into the chain
1020/// map plus a search of its cursors — measured at roughly half a microsecond per pair, so this
1021/// caps the stall at about 25 ms however deep the backlog is. Past the cap the gauges are a
1022/// floor rather than a total, which is the right trade for a number read off a dashboard:
1023/// `block_export_lagging_pairs` is exact and free, and says how far past the cap we are.
1024#[cfg(with_metrics)]
1025const MAX_CENSUS_PAIRS: usize = 50_000;
1026
1027/// How many chains one sweep may forget, bounding how long it holds the progress mutex that every
1028/// chain worker takes on every block.
1029const MAX_FORGET_PER_SWEEP: usize = 4096;
1030
1031impl<S, P> BlockExportQueue<S, P>
1032where
1033 S: Storage + Clone + Send + Sync + 'static,
1034 P: ValidatorNodeProvider,
1035 P::Node: Clone + Send + 'static,
1036{
1037 /// Exports blocks until every handle is dropped, ticking every `idle_catch_up_interval`
1038 /// whether or not sends are in flight — backoff expiry and gap repair must not wait for a
1039 /// process-wide lull that a busy validator never has.
1040 #[instrument(level = "debug", skip_all)]
1041 async fn run(mut self, mut receiver: mpsc::Receiver<ExportedBlock>) {
1042 /// What woke the loop, decided inside the select so its borrows end before handling.
1043 enum Wake {
1044 Done(JobDone),
1045 Block(Option<ExportedBlock>),
1046 Tick,
1047 }
1048 let mut jobs = FuturesUnordered::new();
1049 // One deadline carried across iterations: a timer rebuilt per iteration restarts on
1050 // every completion or block, so under sustained load it would never fire — and with it
1051 // would die every tick-only duty (drop repair, backoff expiry, the committee scan, the
1052 // convergence sweep).
1053 let interval = self.config.idle_catch_up_interval;
1054 let tick_delta =
1055 TimeDelta::from_micros(u64::try_from(interval.as_micros()).unwrap_or(u64::MAX));
1056 let mut next_tick = self
1057 .storage
1058 .clock()
1059 .current_time()
1060 .saturating_add(tick_delta);
1061 loop {
1062 let now = self.storage.clock().current_time();
1063 // The storage clock is the wall clock in production, so it can step backwards (NTP,
1064 // a restored snapshot). The deadline is a timestamp but the wait is real time, so a
1065 // backward step of N seconds would otherwise suspend every tick-only duty — backoff
1066 // expiry, drop repair, the committee scan — for N seconds. Never wait longer than
1067 // one interval.
1068 if next_tick.duration_since(now) > interval {
1069 next_tick = now.saturating_add(tick_delta);
1070 }
1071 if now >= next_tick {
1072 self.tick(&mut jobs).await;
1073 next_tick = self
1074 .storage
1075 .clock()
1076 .current_time()
1077 .saturating_add(tick_delta);
1078 continue;
1079 }
1080 let until_tick = next_tick.duration_since(now);
1081 let wake = if jobs.is_empty() {
1082 match timeout(until_tick, receiver.recv()).await {
1083 Ok(received) => Wake::Block(received),
1084 Err(_) => Wake::Tick,
1085 }
1086 } else {
1087 // `futures::select_biased` rather than `tokio::select`, which does not compile
1088 // for the web target. Completions first, then fresh blocks, then the tick.
1089 futures::select_biased! {
1090 done = jobs.next() => Wake::Done(done.expect("jobs is not empty")),
1091 received = receiver.recv().fuse() => Wake::Block(received),
1092 _ = self.storage.clock().sleep_for(until_tick).fuse() => Wake::Tick,
1093 }
1094 };
1095 match wake {
1096 Wake::Done(done) => self.on_done(done, &mut jobs),
1097 Wake::Block(Some(block)) => self.on_block(block, &mut jobs),
1098 Wake::Block(None) => break,
1099 Wake::Tick => {
1100 self.tick(&mut jobs).await;
1101 next_tick = self
1102 .storage
1103 .clock()
1104 .current_time()
1105 .saturating_add(tick_delta);
1106 }
1107 }
1108 }
1109 // The workers are gone. Let in-flight sends finish, without starting new ones — every
1110 // completion would otherwise drain more catch-up work and hold shutdown open.
1111 self.draining = true;
1112 while let Some(done) = jobs.next().await {
1113 self.on_done(done, &mut jobs);
1114 }
1115 debug!("All block export handles dropped; stopping the export queue");
1116 }
1117
1118 /// Folds a fresh block in: advances the chain's tip and fans out to every destination that
1119 /// can take it now; the rest catch up from storage when their turn comes.
1120 fn on_block(&mut self, block: ExportedBlock, jobs: &mut FuturesUnordered<JobFuture>) {
1121 self.queued_bytes
1122 .fetch_sub(block.blob_bytes, std::sync::atomic::Ordering::Relaxed);
1123 #[cfg(with_metrics)]
1124 {
1125 metrics::QUEUE_SIZE.dec();
1126 metrics::QUEUE_BYTES.sub(i64::try_from(block.blob_bytes).unwrap_or(i64::MAX));
1127 metrics::EXPORT_LATENCY
1128 .finish_measurement(block.queued_at.elapsed().as_secs_f64() * 1000.0);
1129 }
1130 let header = &block.certificate.block().header;
1131 let (chain_id, height) = (header.chain_id, header.height);
1132 // Only recorded here: loading the committee reads storage, and an await in this handler
1133 // stalls every in-flight send — the tick's scan does the loading.
1134 if self
1135 .announced_epoch
1136 .is_none_or(|announced| block.epoch > announced)
1137 {
1138 self.announced_epoch = Some(block.epoch);
1139 }
1140 // Per committee change only. Never on "destinations are empty": when a committee's
1141 // addresses cannot be resolved that stays true, and the rebuild walks every tracked
1142 // chain and re-warns per member — on the block-execution path. The tick retries it.
1143 if self.committee_dirty {
1144 self.sync_destinations();
1145 }
1146
1147 let now = self.storage.clock().current_time();
1148 let tip = height.try_add_one().unwrap_or(BlockHeight::MAX);
1149 let record = self
1150 .chains
1151 .entry(chain_id)
1152 .or_insert_with(|| ChainRecord::new(now, &self.destinations, &block.exported_heights));
1153 record.tip = record.tip.max(tip);
1154 record.last_activity = now;
1155 record.seed_missing_cursors(&self.destinations, &block.exported_heights);
1156 let indices = self.destinations.keys().copied().collect::<Vec<_>>();
1157 for index in indices {
1158 let budget = self.budget_remaining();
1159 let record = self.chains.get_mut(&chain_id).expect("inserted above");
1160 let record_tip = record.tip;
1161 let chain_dest = record.dest_entry(index);
1162 let dest = self
1163 .destinations
1164 .get_mut(&index)
1165 .expect("iterating destinations");
1166 let contiguous = chain_dest.next_height == Some(height);
1167 let can_send_now = chain_dest.in_flight.is_none()
1168 && chain_dest.retry_at.is_none_or(|at| at <= now)
1169 && dest.retry_at.is_none_or(|at| at <= now)
1170 && dest.in_flight < dest.window;
1171 if contiguous && can_send_now {
1172 // The fast path: the block is already in memory and the destination is ready,
1173 // so no storage read at all.
1174 Self::spawn_job(
1175 jobs,
1176 &self.storage,
1177 &self.config,
1178 chain_id,
1179 index,
1180 dest,
1181 chain_dest,
1182 record_tip,
1183 Some((block.certificate.clone(), block.blobs.clone())),
1184 );
1185 } else if chain_dest.next_height.is_none_or(|next| next < record_tip) {
1186 dest.lagging.insert(chain_id);
1187 if can_send_now {
1188 Self::drain_ready(
1189 &mut self.chains,
1190 &self.storage,
1191 &self.config,
1192 index,
1193 dest,
1194 jobs,
1195 now,
1196 budget,
1197 );
1198 }
1199 }
1200 }
1201 }
1202
1203 /// Folds one finished send back into the destination's and the chain's state.
1204 fn on_done(
1205 &mut self,
1206 (chain_id, index, generation, outcome): JobDone,
1207 jobs: &mut FuturesUnordered<JobFuture>,
1208 ) {
1209 let now = self.storage.clock().current_time();
1210 let Some(dest) = self.destinations.get_mut(&index) else {
1211 return; // The validator left the committee while its send was in flight.
1212 };
1213 let validator = dest.validator;
1214 if dest.generation != generation {
1215 // The send ran against a previous incarnation of this destination; its slot was
1216 // never counted here and its result must not touch the fresh state.
1217 if let Some(chain_dest) = self
1218 .chains
1219 .get_mut(&chain_id)
1220 .and_then(|record| record.dest_mut(index))
1221 {
1222 // Only the flag this very job set — the pair may since carry a newer job's.
1223 if chain_dest.in_flight == Some(generation) {
1224 chain_dest.in_flight = None;
1225 }
1226 }
1227 return;
1228 }
1229 dest.in_flight = dest.in_flight.saturating_sub(1);
1230
1231 // Destination health comes from the outcome alone, never gated on the chain record —
1232 // a transport failure must shrink the window and set the backoff even for a chain the
1233 // queue has since forgotten.
1234 match &outcome {
1235 SendOutcome::Reached(_) => {
1236 dest.failures = 0;
1237 dest.retry_at = None;
1238 dest.window = (dest.window + 1).min(self.config.max_in_flight_per_destination);
1239 self.total_window = (self.total_window + 1).min(self.config.max_in_flight_total);
1240 #[cfg(with_metrics)]
1241 metrics::SENDS_SUCCEEDED
1242 .with_label_values(&[&dest.address])
1243 .inc();
1244 }
1245 SendOutcome::ChainScoped(_) => {}
1246 // One corrupt chain says nothing about the destination's health, and the peer answered
1247 // us promptly to say so. Halving its window here would let a handful of bad chains
1248 // throttle catch-up for every good one — the shape that made stakefi drain slowly.
1249 SendOutcome::Unrecoverable(..) => {}
1250 SendOutcome::LocalScoped(error) => {
1251 // Our storage, not the peer: leave the destination's window alone and halve the
1252 // queue's own budget, so the pressure is relieved across every destination
1253 // rather than one pair at a time while the rest keep reading.
1254 warn!(
1255 %chain_id, %error,
1256 "Export could not read from local storage; halving the queue's total \
1257 in-flight budget",
1258 );
1259 self.total_window = (self.total_window / 2).max(1);
1260 #[cfg(with_metrics)]
1261 metrics::TOTAL_WINDOW.set(i64::try_from(self.total_window).unwrap_or(i64::MAX));
1262 }
1263 SendOutcome::DestinationScoped(error) => {
1264 warn!(
1265 validator = %dest.address, %chain_id, %error,
1266 "Failed to export to a validator; backing it off and re-resolving",
1267 );
1268 dest.window = (dest.window / 2).max(1);
1269 back_off(&mut dest.failures, &mut dest.retry_at, now, &self.config);
1270 // Re-resolve so a relayed transport draws the next proxy from the rotation; the
1271 // backoff above still applies if the validator itself is the problem. Resolved
1272 // through the list API because the test provider resolves by *key*, which only
1273 // the list variant carries.
1274 match self
1275 .node_provider
1276 .make_nodes_from_list(iter::once((validator, dest.address.clone())))
1277 {
1278 Ok(mut nodes) => {
1279 if let Some((_, node)) = nodes.next() {
1280 dest.node = node;
1281 }
1282 }
1283 Err(error) => {
1284 warn!(%validator, %error, "Cannot re-resolve a failing destination");
1285 }
1286 }
1287 }
1288 }
1289 #[cfg(with_metrics)]
1290 metrics::DESTINATION_WINDOW
1291 .with_label_values(&[&dest.address])
1292 .set(i64::try_from(dest.window).unwrap_or(i64::MAX));
1293
1294 if let Some(record) = self.chains.get_mut(&chain_id) {
1295 record.last_activity = now;
1296 let record_tip = record.tip;
1297 if let Some(chain_dest) = record.dest_mut(index) {
1298 if chain_dest.in_flight == Some(generation) {
1299 chain_dest.in_flight = None;
1300 }
1301 match &outcome {
1302 SendOutcome::Reached(next_height) => {
1303 if let Some(acked) =
1304 chain_dest.record_reached(*next_height, record_tip, now, &self.config)
1305 {
1306 let mut progress = self
1307 .progress
1308 .lock()
1309 .expect("progress mutex is never poisoned");
1310 let heights = progress.heights.entry(chain_id).or_default();
1311 match heights.binary_search_by_key(&index, |(at, _)| *at) {
1312 Ok(at) => heights[at].1 = acked,
1313 Err(at) => heights.insert(at, (index, acked)),
1314 }
1315 }
1316 }
1317 SendOutcome::LocalScoped(_) => {
1318 // The global budget already shrank; back the pair off too so the same
1319 // unreadable range is not retried immediately.
1320 back_off(
1321 &mut chain_dest.failures,
1322 &mut chain_dest.retry_at,
1323 now,
1324 &self.config,
1325 );
1326 }
1327 SendOutcome::ChainScoped(error) => {
1328 debug!(
1329 %chain_id, %validator, %error,
1330 "Destination cannot accept this chain yet; backing the pair off",
1331 );
1332 #[cfg(with_metrics)]
1333 metrics::CHAIN_SCOPED_BACKOFFS
1334 .with_label_values(&[&dest.address])
1335 .inc();
1336 chain_dest.next_height = None;
1337 back_off(
1338 &mut chain_dest.failures,
1339 &mut chain_dest.retry_at,
1340 now,
1341 &self.config,
1342 );
1343 }
1344 SendOutcome::DestinationScoped(_) => {
1345 chain_dest.next_height = None;
1346 }
1347 SendOutcome::Unrecoverable(reason, error) => {
1348 // warn!, not debug!: unlike a chain-scoped backoff this never self-heals,
1349 // and the chain id is what the destination's operator needs to repair it.
1350 if chain_dest.parked.is_none() {
1351 warn!(
1352 %chain_id, %validator, %error, reason = reason.as_str(),
1353 "Destination cannot accept this chain and retrying cannot help; \
1354 parking the pair until restart",
1355 );
1356 #[cfg(with_metrics)]
1357 metrics::PARKED_CHAINS
1358 .with_label_values(&[&dest.address, reason.as_str()])
1359 .inc();
1360 }
1361 chain_dest.parked = Some(*reason);
1362 chain_dest.next_height = None;
1363 }
1364 }
1365 }
1366 }
1367
1368 if self.draining {
1369 return;
1370 }
1371 // A pair that advanced but is still behind continues on the next free slot rather than
1372 // waiting for a tick — multi-round catch-up must not depend on a process-wide lull.
1373 let budget = self.budget_remaining();
1374 let dest = self.destinations.get_mut(&index).expect("checked above");
1375 if let Some(record) = self.chains.get_mut(&chain_id) {
1376 let tip = record.tip;
1377 if let Some(chain_dest) = record.dest_mut(index) {
1378 // Membership tracks "behind", so convergence removes it and nothing else has
1379 // to remember to.
1380 if chain_dest.next_height.is_none_or(|next| next < tip) {
1381 dest.lagging.insert(chain_id);
1382 } else {
1383 dest.lagging.remove(&chain_id);
1384 }
1385 }
1386 }
1387 Self::drain_ready(
1388 &mut self.chains,
1389 &self.storage,
1390 &self.config,
1391 index,
1392 dest,
1393 jobs,
1394 now,
1395 budget,
1396 );
1397 }
1398
1399 /// An idle moment: pick up committee changes from storage and requeue expired backoffs.
1400 async fn tick(&mut self, jobs: &mut FuturesUnordered<JobFuture>) {
1401 let now = self.storage.clock().current_time();
1402 // Occasionally scan storage for committees no block has carried — how a validator
1403 // admitted while every chain is idle still becomes a destination. On its own cadence
1404 // because the probe reads storage, and ticks now fire even under load.
1405 // Eagerly, but at most once per announced epoch: a committee that cannot be loaded
1406 // leaves `latest_epoch` behind, and re-triggering on the same announcement would turn
1407 // the throttled scan into a storage read every tick for as long as it stays unloadable.
1408 // The cadence below still retries it.
1409 let announced_newer = self.announced_epoch.is_some_and(|epoch| {
1410 self.latest_epoch.is_none_or(|latest| epoch > latest)
1411 && self.scan_attempted_for != Some(epoch)
1412 });
1413 if self.ticks_until_scan == 0 || announced_newer {
1414 self.ticks_until_scan = TICKS_PER_COMMITTEE_SCAN;
1415 self.scan_attempted_for = self.announced_epoch;
1416 self.scan_committees().await;
1417 } else {
1418 self.ticks_until_scan -= 1;
1419 }
1420 if self.committee_dirty || (self.destinations.is_empty() && self.committee.is_some()) {
1421 self.sync_destinations();
1422 }
1423
1424 // Drain announced tips in: this is what repairs a block the full queue dropped, and what
1425 // creates the record when even a chain's first block was dropped. Taken rather than
1426 // cloned — the workers contend on this mutex every block, and once folded the records
1427 // carry the truth.
1428 let tips = std::mem::take(&mut *self.tips.lock().expect("tips mutex is never poisoned"));
1429 for (chain_id, tip) in tips {
1430 let record = self
1431 .chains
1432 .entry(chain_id)
1433 .or_insert_with(|| ChainRecord::new(now, &self.destinations, &BTreeMap::new()));
1434 let advanced = record.tip < tip;
1435 record.tip = record.tip.max(tip);
1436 if advanced {
1437 // The scan that used to notice this is gone, so a tip moving forward records
1438 // the chains it just put behind, here and now.
1439 for (index, dest) in &mut self.destinations {
1440 let chain_dest = record.dest_entry(*index);
1441 if chain_dest.next_height.is_none_or(|next| next < record.tip) {
1442 dest.lagging.insert(chain_id);
1443 }
1444 }
1445 }
1446 }
1447 // When the destination set changed, every destination gets a cursor on every tracked
1448 // chain, so a validator that joined after a chain's last block is still caught up on it.
1449 // Gated on the change: this walks every record.
1450 if self.destinations_changed {
1451 self.destinations_changed = false;
1452 // The one place a full pass is unavoidable: a destination that just joined has no
1453 // idea which chains it is behind on. It runs per committee change, not per tick.
1454 for (chain_id, record) in &mut self.chains {
1455 for (index, dest) in &mut self.destinations {
1456 let chain_dest = record.dest_entry(*index);
1457 if chain_dest.next_height.is_none_or(|next| next < record.tip) {
1458 dest.lagging.insert(*chain_id);
1459 }
1460 }
1461 }
1462 }
1463
1464 // Forget chains that converged and stayed quiet past the retention window, so memory
1465 // tracks recent and lagging chains rather than every chain ever seen. The grace window
1466 // is what lets the chain's worker fold the final heights in before they vanish.
1467 // Convergence is judged against the *current* destination set — an empty one (a
1468 // single-validator committee) is trivially converged, not immortal.
1469 let retention = self.config.converged_chain_retention;
1470 let destinations = &self.destinations;
1471 let mut forgotten = Vec::new();
1472 // On its own cadence: this walks every tracked chain, while the retention window it
1473 // enforces is measured in minutes. Running it per tick spent a quarter of a core at
1474 // 100k chains to reclaim memory a few seconds sooner.
1475 if self.ticks_until_sweep == 0 {
1476 self.ticks_until_sweep = TICKS_PER_CONVERGENCE_SWEEP;
1477 self.chains.retain(|chain_id, record| {
1478 // Bounded per sweep: the removals below are what the chain workers block on, so
1479 // the mutex hold has to be a constant, not a function of how much converged at
1480 // once. The remainder goes on the next sweep.
1481 if forgotten.len() >= MAX_FORGET_PER_SWEEP {
1482 return true;
1483 }
1484 let converged = destinations.keys().all(|index| {
1485 record.dest(*index).is_some_and(|chain_dest| {
1486 // `>=`: a destination is routinely *ahead* of our tip — the client
1487 // broadcasts to everyone — and ahead must count as done, not as never
1488 // converging.
1489 chain_dest.in_flight.is_none()
1490 && chain_dest
1491 .next_height
1492 .is_some_and(|next| next >= record.tip)
1493 })
1494 });
1495 if converged && now.duration_since(record.last_activity) > retention {
1496 forgotten.push(*chain_id);
1497 false
1498 } else {
1499 true
1500 }
1501 });
1502 // `retain` never shrinks the table, so without this a one-off burst of chains would
1503 // hold its peak allocation for the life of the process.
1504 if self.chains.capacity() > self.chains.len().saturating_mul(4) {
1505 self.chains.shrink_to_fit();
1506 }
1507 } else {
1508 self.ticks_until_sweep -= 1;
1509 }
1510 if !forgotten.is_empty() {
1511 let peak_table = self
1512 .progress
1513 .lock()
1514 .expect("progress mutex is never poisoned")
1515 .forget_chains(&forgotten);
1516 // The free of a burst's peak-sized table happens here, after the guard above is
1517 // gone — measured at milliseconds per gigabyte-scale table, which is fine for the
1518 // queue task and was not fine under the mutex.
1519 drop(peak_table);
1520 }
1521
1522 #[cfg(with_metrics)]
1523 {
1524 metrics::TRACKED_CHAINS.set(i64::try_from(self.chains.len()).unwrap_or(i64::MAX));
1525 metrics::DESTINATIONS.set(i64::try_from(self.destinations.len()).unwrap_or(i64::MAX));
1526 let lagging_pairs = self
1527 .destinations
1528 .values()
1529 .map(|dest| dest.lagging.len())
1530 .sum::<usize>();
1531 metrics::LAGGING_PAIRS.set(i64::try_from(lagging_pairs).unwrap_or(i64::MAX));
1532 metrics::TOTAL_WINDOW.set(i64::try_from(self.total_window).unwrap_or(i64::MAX));
1533 if self.ticks_until_census == 0 {
1534 self.ticks_until_census = TICKS_PER_BACKLOG_CENSUS;
1535 self.publish_backlog();
1536 } else {
1537 self.ticks_until_census -= 1;
1538 }
1539 }
1540
1541 // No requeue scan: each destination's `lagging` set already *is* the list of chains it
1542 // owes work on, kept current as pairs fall behind and converge. Draining it is
1543 // proportional to the work available, not to how much state the process is holding.
1544 let mut budget = self.total_window.saturating_sub(
1545 self.destinations
1546 .values()
1547 .map(|dest| dest.in_flight)
1548 .sum::<usize>(),
1549 );
1550 // Resume where the last round stopped. The budget is queue-wide, so serving destinations
1551 // in index order every time lets the first `total_window / window` of them absorb all of
1552 // it — at a committee larger than that ratio (8 with the defaults) the rest would get no
1553 // catch-up at all. Same starvation the per-chain cursor exists to prevent, one level up.
1554 let indices = self.destinations.keys().copied().collect::<Vec<_>>();
1555 let order = rotated_order(&indices, self.drain_cursor);
1556 self.drain_cursor = next_drain_cursor(&indices, order.first().copied());
1557 for index in order {
1558 let Some(dest) = self.destinations.get_mut(&index) else {
1559 continue;
1560 };
1561 budget -= Self::drain_ready(
1562 &mut self.chains,
1563 &self.storage,
1564 &self.config,
1565 index,
1566 dest,
1567 jobs,
1568 now,
1569 budget,
1570 );
1571 }
1572 }
1573
1574 /// Scans storage for committees newer than the one in use, by listing the admin chain's
1575 /// epoch events from the frontier — one bulk read, immune to holes in the history, and
1576 /// silent when nothing is new.
1577 async fn scan_committees(&mut self) {
1578 if self.admin_chain_id.is_none() {
1579 self.admin_chain_id = match self.storage.read_network_description().await {
1580 Ok(Some(description)) => Some(description.admin_chain_id),
1581 Ok(None) => return,
1582 Err(error) => {
1583 debug!(%error, "Cannot read the network description to scan for committees");
1584 return;
1585 }
1586 };
1587 }
1588 let Some(admin_chain_id) = self.admin_chain_id else {
1589 return;
1590 };
1591 let start = self
1592 .latest_epoch
1593 .map_or(0, |epoch| epoch.0.saturating_add(1));
1594 // Epoch 0 comes from the genesis blob, not an event, so it is never in the list.
1595 let genesis = (start == 0).then_some(Epoch(0));
1596 let mut candidates = match self
1597 .storage
1598 .read_events_from_index(&admin_chain_id, &StreamId::system(EPOCH_STREAM_NAME), start)
1599 .await
1600 {
1601 Ok(events) => events
1602 .into_iter()
1603 .map(|event| Epoch(event.index))
1604 .chain(genesis)
1605 .collect::<Vec<_>>(),
1606 Err(error) => {
1607 debug!(%error, "Cannot list epoch events to scan for committees");
1608 return;
1609 }
1610 };
1611 // Newest first, and stop at the first that loads: a committee lists the full validator
1612 // set, so the newest usable one subsumes the rest. Trying only the newest would stall
1613 // the whole scan whenever its blob happens to be missing while an older — but still
1614 // newer than ours — one is right there.
1615 candidates.sort_unstable_by(|a, b| b.cmp(a));
1616 for epoch in candidates {
1617 match self.storage.committee_for_epoch(epoch).await {
1618 Ok(Some(committee)) => {
1619 self.latest_epoch = Some(epoch);
1620 self.committee = Some(committee);
1621 self.committee_dirty = true;
1622 return;
1623 }
1624 Ok(None) => debug!(%epoch, "An epoch event exists but its committee cannot load"),
1625 Err(error) => debug!(%error, %epoch, "Cannot load a committee from storage"),
1626 }
1627 }
1628 }
1629
1630 /// Brings the destination set in line with the latest committee: adds joiners, drops
1631 /// leavers, and re-resolves a changed address.
1632 fn sync_destinations(&mut self) {
1633 // Cloned so the index registry below can be updated while the committee is read.
1634 let Some(committee) = self.committee.clone() else {
1635 return;
1636 };
1637 self.committee_dirty = false;
1638 // A destination whose address merely changed keeps the backlog it had accumulated: it is
1639 // the same peer, and rediscovering that list costs a pass over every tracked chain.
1640 let mut carried = BTreeMap::new();
1641 let mut rebuilt_any = false;
1642 #[cfg(with_metrics)]
1643 let mut rebuilt_addresses = Vec::new();
1644 self.destinations.retain(|index, dest| {
1645 let keep = committee
1646 .validators()
1647 .get(&dest.validator)
1648 .is_some_and(|state| state.network_address == dest.address);
1649 if !keep {
1650 rebuilt_any = true;
1651 if committee.validators().contains_key(&dest.validator) {
1652 carried.insert(
1653 *index,
1654 (
1655 std::mem::take(&mut dest.lagging),
1656 dest.lagging_cursor.take(),
1657 ),
1658 );
1659 }
1660 #[cfg(with_metrics)]
1661 rebuilt_addresses.push(dest.address.clone());
1662 }
1663 keep
1664 });
1665 if rebuilt_any {
1666 self.destinations_changed = true;
1667 }
1668 // Drop the metric series of every address that just went away, so a departed validator
1669 // does not leave a window gauge frozen at its last value and a lag histogram that never
1670 // moves again — both read as a live destination that simply stopped changing.
1671 #[cfg(with_metrics)]
1672 for address in &rebuilt_addresses {
1673 // A series that was never created is simply absent; nothing to report either way.
1674 metrics::DESTINATION_WINDOW
1675 .remove_label_values(&[address])
1676 .ok();
1677 metrics::SEND_LATENCY.remove_label_values(&[address]).ok();
1678 metrics::CERTIFICATE_SEND_LATENCY
1679 .remove_label_values(&[address])
1680 .ok();
1681 metrics::SENDS_SUCCEEDED
1682 .remove_label_values(&[address])
1683 .ok();
1684 metrics::DESTINATION_LAG
1685 .remove_label_values(&[address])
1686 .ok();
1687 metrics::CHAIN_SCOPED_BACKOFFS
1688 .remove_label_values(&[address])
1689 .ok();
1690 metrics::BLOCKS_OWED.remove_label_values(&[address]).ok();
1691 metrics::MAX_CHAIN_GAP.remove_label_values(&[address]).ok();
1692 }
1693 for (validator, address) in committee.validator_addresses() {
1694 if Some(validator) == self.own_public_key {
1695 continue;
1696 }
1697 let index = self.dest_index(validator);
1698 if self.destinations.contains_key(&index) {
1699 continue;
1700 }
1701 // One at a time: a batch fails whole on the first bad address, and one unresolvable
1702 // validator must not keep every other one out of the destination set. Through the
1703 // list API rather than `make_node`, because the test provider resolves by *key*,
1704 // which only the list variant carries.
1705 match self
1706 .node_provider
1707 .make_nodes_from_list(iter::once((validator, address)))
1708 {
1709 Ok(mut nodes) => {
1710 if let Some((_, node)) = nodes.next() {
1711 // Monotonic across the queue's lifetime: derived from surviving
1712 // destinations it could repeat after the set empties, and a repeat lets
1713 // a stale in-flight send corrupt a fresh incarnation's accounting.
1714 self.next_generation += 1;
1715 self.destinations_changed = true;
1716 let (lagging, lagging_cursor) = carried.remove(&index).unwrap_or_default();
1717 self.destinations.insert(
1718 index,
1719 DestState {
1720 node,
1721 validator,
1722 address: address.to_owned(),
1723 generation: self.next_generation,
1724 in_flight: 0,
1725 window: self.config.max_in_flight_per_destination,
1726 retry_at: None,
1727 failures: 0,
1728 lagging,
1729 lagging_cursor,
1730 },
1731 );
1732 }
1733 }
1734 Err(error) => {
1735 warn!(
1736 %validator, %address, %error,
1737 "Cannot resolve a committee member to export blocks to; \
1738 continuing with the others",
1739 );
1740 }
1741 }
1742 }
1743 // A validator that left takes its cursors with it.
1744 let destinations = &self.destinations;
1745 for record in self.chains.values_mut() {
1746 record
1747 .dests
1748 .retain(|(index, _)| destinations.contains_key(index));
1749 }
1750 }
1751
1752 /// Publishes how far behind each destination is, walking the `lagging` sets rather than
1753 /// every tracked chain.
1754 ///
1755 /// Those sets already name exactly the chains a destination owes blocks on, so this costs
1756 /// the size of the real backlog: nothing when everyone is caught up, and proportional to the
1757 /// outage when they are not. Folding it into the convergence sweep instead would have cost
1758 /// that sweep its short-circuit — measured at +142 ms per sweep at 100k chains and a
1759 /// 20-member committee, against 9 ms for this.
1760 ///
1761 /// The maximum rather than a quantile: a per-pair histogram observation measured 150 ms per
1762 /// sweep at a million pairs, and the maximum is the tail a quantile would hide anyway.
1763 #[cfg(with_metrics)]
1764 fn publish_backlog(&self) {
1765 // Shared across destinations so one enormous backlog cannot spend the whole budget and
1766 // leave every later destination reporting zero.
1767 let mut remaining = MAX_CENSUS_PAIRS;
1768 let mut unvisited = self.destinations.len();
1769 for (index, dest) in &self.destinations {
1770 let mut owed = 0u64;
1771 let mut worst = 0u64;
1772 // Divided by the destinations still to come, not by all of them: dividing by the
1773 // total while `remaining` shrinks gives each successive destination a smaller share
1774 // than the last, so the ones late in the index order would under-report their
1775 // backlog. This way a destination that needs less than its share leaves the surplus
1776 // to the rest, and the last one may use whatever is left.
1777 let per_destination = remaining / unvisited.max(1);
1778 unvisited = unvisited.saturating_sub(1);
1779 let mut examined = 0usize;
1780 for chain_id in &dest.lagging {
1781 if examined >= per_destination {
1782 break;
1783 }
1784 examined += 1;
1785 let Some(record) = self.chains.get(chain_id) else {
1786 continue;
1787 };
1788 let Some(chain_dest) = record.dest(*index) else {
1789 continue;
1790 };
1791 let gap = chain_dest
1792 .next_height
1793 .map_or(record.tip.0, |next| record.tip.0.saturating_sub(next.0));
1794 owed = owed.saturating_add(gap);
1795 worst = worst.max(gap);
1796 }
1797 // Published for every destination, not just the ones behind: a gauge left at its last
1798 // value would read as a permanent debt after the peer caught up.
1799 metrics::BLOCKS_OWED
1800 .with_label_values(&[&dest.address])
1801 .set(i64::try_from(owed).unwrap_or(i64::MAX));
1802 metrics::MAX_CHAIN_GAP
1803 .with_label_values(&[&dest.address])
1804 .set(i64::try_from(worst).unwrap_or(i64::MAX));
1805 remaining = remaining.saturating_sub(examined);
1806 }
1807 }
1808
1809 /// Sends the queue-wide budget still allows, summed from the destinations rather than
1810 /// tracked in a counter that could drift out of step with them.
1811 fn budget_remaining(&self) -> usize {
1812 let in_flight = self
1813 .destinations
1814 .values()
1815 .map(|dest| dest.in_flight)
1816 .sum::<usize>();
1817 self.total_window.saturating_sub(in_flight)
1818 }
1819
1820 /// The index `validator`'s per-chain state is keyed by, registering it on first sight.
1821 ///
1822 /// Published to the handles under the progress mutex, because the chain workers read that
1823 /// map back and only the registry can name the validators in it.
1824 fn dest_index(&mut self, validator: ValidatorPublicKey) -> DestIndex {
1825 if let Some(index) = self.dest_indices.get(&validator) {
1826 return *index;
1827 }
1828 let mut progress = self
1829 .progress
1830 .lock()
1831 .expect("progress mutex is never poisoned");
1832 let index = DestIndex::try_from(progress.validators.len())
1833 .expect("a committee cannot hold four billion validators");
1834 progress.validators.push(validator);
1835 self.dest_indices.insert(validator, index);
1836 index
1837 }
1838}
1839
1840/// The result of one send job: which pair it was for, under which destination generation, and
1841/// how it went.
1842type JobDone = (ChainId, DestIndex, u64, SendOutcome);
1843
1844/// One send in flight, boxed so jobs from different call sites share a queue.
1845#[cfg(not(web))]
1846type JobFuture = futures::future::BoxFuture<'static, JobDone>;
1847#[cfg(web)]
1848type JobFuture = futures::future::LocalBoxFuture<'static, JobDone>;
1849
1850impl<S, P> BlockExportQueue<S, P>
1851where
1852 S: Storage + Clone + Send + Sync + 'static,
1853 P: ValidatorNodeProvider,
1854 P::Node: Clone + Send + 'static,
1855{
1856 /// Starts one send for `(chain_id, validator)`: the held block when contiguous, catch-up
1857 /// from storage otherwise.
1858 #[expect(clippy::too_many_arguments)]
1859 fn spawn_job(
1860 jobs: &mut FuturesUnordered<JobFuture>,
1861 storage: &S,
1862 config: &BlockExportConfig,
1863 chain_id: ChainId,
1864 index: DestIndex,
1865 dest: &mut DestState<P::Node>,
1866 chain_dest: &mut ChainDest,
1867 target: BlockHeight,
1868 live: Option<(CacheArc<ConfirmedBlockCertificate>, Vec<CacheArc<Blob>>)>,
1869 ) {
1870 let generation = dest.generation;
1871 chain_dest.in_flight = Some(generation);
1872 dest.in_flight += 1;
1873 let mut sender = BlockSender {
1874 remote_node: RemoteNode {
1875 public_key: dest.validator,
1876 node: dest.node.clone(),
1877 },
1878 storage: storage.clone(),
1879 certificate_upload_batch_size: config.certificate_upload_batch_size,
1880 #[cfg(with_metrics)]
1881 address: dest.address.clone(),
1882 };
1883 let cursor = chain_dest.next_height;
1884 let max_catch_up = config.max_catch_up_blocks;
1885 #[cfg(with_metrics)]
1886 let address = dest.address.clone();
1887 let job = async move {
1888 #[cfg(with_metrics)]
1889 let send_latency = metrics::SEND_LATENCY.with_label_values(&[&address]);
1890 #[cfg(with_metrics)]
1891 let _latency = send_latency.measure_latency();
1892 #[cfg(with_metrics)]
1893 metrics::DESTINATION_LAG
1894 .with_label_values(&[&address])
1895 .observe(match cursor {
1896 Some(next) => target.0.saturating_sub(next.0) as f64,
1897 None => 1.0,
1898 });
1899 let result = match live {
1900 Some((certificate, blobs)) => {
1901 sender
1902 .send_block(&certificate, &blobs, cursor, max_catch_up)
1903 .await
1904 }
1905 None => {
1906 sender
1907 .send_missing_blocks(chain_id, target, cursor, max_catch_up)
1908 .await
1909 }
1910 };
1911 let outcome = match result {
1912 Ok(next_height) => SendOutcome::Reached(next_height),
1913 Err(error) if is_local_scoped(&error) => SendOutcome::LocalScoped(Box::new(error)),
1914 // Before the scoped arms: an unrecoverable answer is about neither our storage nor
1915 // the destination's health, and must not move either backoff.
1916 Err(error) => match park_reason(&error) {
1917 Some(reason) => SendOutcome::Unrecoverable(reason, Box::new(error)),
1918 None if is_chain_scoped(&error) => SendOutcome::ChainScoped(Box::new(error)),
1919 None => SendOutcome::DestinationScoped(Box::new(error)),
1920 },
1921 };
1922 (chain_id, index, generation, outcome)
1923 };
1924 #[cfg(not(web))]
1925 jobs.push(job.boxed());
1926 #[cfg(web)]
1927 jobs.push(job.boxed_local());
1928 }
1929
1930 /// Starts catch-up sends from this destination's ready list until its window is full.
1931 #[expect(clippy::too_many_arguments)]
1932 fn drain_ready(
1933 chains: &mut HashMap<ChainId, ChainRecord>,
1934 storage: &S,
1935 config: &BlockExportConfig,
1936 index: DestIndex,
1937 dest: &mut DestState<P::Node>,
1938 jobs: &mut FuturesUnordered<JobFuture>,
1939 now: Timestamp,
1940 budget: usize,
1941 ) -> usize {
1942 if dest.retry_at.is_some_and(|at| at > now) || dest.in_flight >= dest.window || budget == 0
1943 {
1944 return 0;
1945 }
1946 // Only as far as the window allows, so the cost is the sends we are about to make and
1947 // not the size of the backlog. Entries stay in `lagging` until they converge: one that
1948 // is mid-send or serving its own backoff is skipped here and picked up by a later tick,
1949 // with nothing to remember to re-add it.
1950 // From the cursor onwards, then wrapping to the start: every chain gets its turn even
1951 // when the backlog is far larger than the window. Bounded by a multiple of the window so
1952 // a backlog of ineligible entries cannot turn this back into a full scan.
1953 let ordered = dest.drain_candidates(dest.window.saturating_mul(LAGGING_SCAN_FACTOR));
1954 let mut spawn = Vec::new();
1955 let mut stale = Vec::new();
1956 let mut last_visited = None;
1957 for chain_id in ordered {
1958 if dest.in_flight + spawn.len() >= dest.window || spawn.len() >= budget {
1959 break;
1960 }
1961 last_visited = Some(chain_id);
1962 let Some(record) = chains.get(&chain_id) else {
1963 // The chain was forgotten under us; drop the entry rather than walk past it on
1964 // every drain from here on.
1965 stale.push(chain_id);
1966 continue;
1967 };
1968 let Some(chain_dest) = record.dest(index) else {
1969 continue;
1970 };
1971 if chain_dest.parked.is_some() {
1972 // Drop it from `lagging` as well: a parked pair never converges, so leaving it
1973 // would make every future scan walk past it forever.
1974 stale.push(chain_id);
1975 continue;
1976 }
1977 let behind = chain_dest.next_height.is_none_or(|next| next < record.tip);
1978 if behind
1979 && chain_dest.in_flight.is_none()
1980 && chain_dest.retry_at.is_none_or(|at| at <= now)
1981 {
1982 spawn.push(chain_id);
1983 }
1984 }
1985 dest.advance_cursor(last_visited);
1986 for chain_id in stale {
1987 dest.lagging.remove(&chain_id);
1988 }
1989 let spawned = spawn.len();
1990 for chain_id in spawn {
1991 let Some(record) = chains.get_mut(&chain_id) else {
1992 continue;
1993 };
1994 let tip = record.tip;
1995 let Some(chain_dest) = record.dest_mut(index) else {
1996 continue;
1997 };
1998 Self::spawn_job(
1999 jobs, storage, config, chain_id, index, dest, chain_dest, tip, None,
2000 );
2001 }
2002 spawned
2003 }
2004}
2005
2006/// Whether this failure is about one chain rather than about the destination. `EventsNotFound`
2007/// is the destination lacking a committee — the admin chain's export fixes that, and must not be
2008/// throttled by it; the others are gaps on our own side.
2009fn is_chain_scoped(error: &chain_client::Error) -> bool {
2010 matches!(
2011 error,
2012 chain_client::Error::RemoteNodeError(
2013 NodeError::EventsNotFound(_)
2014 | NodeError::BlobsNotFound(_)
2015 | NodeError::InactiveChain(_)
2016 )
2017 )
2018}
2019
2020/// Whether this failure is our own storage rather than anything about the destination.
2021///
2022/// Halving the destination's window for it would punish the wrong side, but backing off only the
2023/// one pair leaves every other pair reading at full rate — so the control loop cannot see the
2024/// bottleneck it is creating. These shrink the queue's *global* budget instead.
2025fn is_local_scoped(error: &chain_client::Error) -> bool {
2026 matches!(
2027 error,
2028 chain_client::Error::ReadCertificatesError(_) | chain_client::Error::ViewError(_)
2029 )
2030}
2031
2032/// Escalating backoff shared by both scopes: doubles from `retry_delay` per consecutive failure,
2033/// capped at `max_retry_delay`.
2034fn back_off(
2035 failures: &mut u32,
2036 retry_at: &mut Option<Timestamp>,
2037 now: Timestamp,
2038 config: &BlockExportConfig,
2039) {
2040 *retry_at = Some(now.saturating_add(backoff_delay(*failures, config)));
2041 *failures = failures.saturating_add(1);
2042}
2043
2044/// The delay after `attempt` consecutive failures: doubles per attempt, capped.
2045fn backoff_delay(attempt: u32, config: &BlockExportConfig) -> TimeDelta {
2046 let delay = config
2047 .retry_delay
2048 .saturating_mul(1u32.checked_shl(attempt).unwrap_or(u32::MAX))
2049 .min(config.max_retry_delay);
2050 TimeDelta::from_micros(u64::try_from(delay.as_micros()).unwrap_or(u64::MAX))
2051}
2052
2053/// Sends this validator's blocks to one other validator, reading everything it needs from
2054/// storage.
2055///
2056/// This is deliberately not [`crate::updater::RemoteNodeUpdater`]: that is the client's tool and
2057/// holds a local node, and anything on the export path that reaches a chain worker resets its
2058/// TTL. Blobs and certificates for committed blocks are always durable before export sees them
2059/// (`write_blobs_and_certificate` precedes execution), so storage is sufficient.
2060pub(crate) struct BlockSender<S, N> {
2061 pub(crate) remote_node: RemoteNode<N>,
2062 pub(crate) storage: S,
2063 pub(crate) certificate_upload_batch_size: u64,
2064 /// Destination address, carried only to label per-certificate latency.
2065 #[cfg(with_metrics)]
2066 pub(crate) address: String,
2067}
2068
2069impl<S, N> BlockSender<S, N>
2070where
2071 S: Storage + Clone + 'static,
2072 N: ValidatorNode + Clone + 'static,
2073{
2074 /// Pushes a block the caller already holds, first closing up to `max_catch_up` of any gap
2075 /// below it, and returns the height the validator reports afterwards.
2076 ///
2077 /// The held block is sent only once the gap is gone: one landing above a gap is silently
2078 /// preprocessed and never advances the tip.
2079 pub(crate) async fn send_block(
2080 &mut self,
2081 certificate: &CacheArc<ConfirmedBlockCertificate>,
2082 blobs: &[CacheArc<Blob>],
2083 destination_next_height: Option<BlockHeight>,
2084 max_catch_up: u64,
2085 ) -> Result<BlockHeight, chain_client::Error> {
2086 let block = certificate.block();
2087 let (chain_id, height) = (block.header.chain_id, block.header.height);
2088
2089 let next_height = if destination_next_height == Some(height) {
2090 height
2091 } else {
2092 self.send_missing_blocks(chain_id, height, destination_next_height, max_catch_up)
2093 .await?
2094 };
2095 // Not exactly at this block: either the gap was larger than one chunk, or the validator
2096 // is already past it — a re-executed chain re-offers its whole history — and in both
2097 // cases sending would be waste, so report the truth instead.
2098 if next_height != height {
2099 return Ok(next_height);
2100 }
2101 let info = self.send_confirmed_certificate(certificate, blobs).await?;
2102 Ok(info.next_block_height)
2103 }
2104
2105 /// Sends up to `max_blocks` of the blocks of `chain_id` the validator is missing below
2106 /// `target_next_height`, returning the height it reports afterwards.
2107 ///
2108 /// Bounded so a validator that just joined converges over rounds instead of blocking the
2109 /// caller once. Heights whose certificates are not in storage are skipped — a chain we merely
2110 /// receive from is stored only at its message-bearing blocks, and the destination
2111 /// preprocesses above such gaps.
2112 pub(crate) async fn send_missing_blocks(
2113 &mut self,
2114 chain_id: ChainId,
2115 target_next_height: BlockHeight,
2116 destination_next_height: Option<BlockHeight>,
2117 max_blocks: u64,
2118 ) -> Result<BlockHeight, chain_client::Error> {
2119 let mut next_height = match destination_next_height {
2120 Some(height) => height,
2121 None => {
2122 let query = ChainInfoQuery::new(chain_id);
2123 self.remote_node
2124 .handle_chain_info_query(query)
2125 .await?
2126 .next_block_height
2127 }
2128 };
2129 let last = target_next_height
2130 .0
2131 .min(next_height.0.saturating_add(max_blocks));
2132 let heights = (next_height.0..last).map(BlockHeight).collect::<Vec<_>>();
2133 let batch = usize::try_from(self.certificate_upload_batch_size).unwrap_or(usize::MAX);
2134 for chunk in heights.chunks(batch) {
2135 let certificates = self
2136 .storage
2137 .read_certificates_by_heights(chain_id, chunk)
2138 .await?;
2139 for certificate in certificates.into_iter().flatten() {
2140 // The validator's own responses move the cursor, so skip anything it has since
2141 // reported holding rather than re-sending it.
2142 if certificate.block().header.height < next_height {
2143 continue;
2144 }
2145 let info = self.send_confirmed_certificate(&certificate, &[]).await?;
2146 next_height = info.next_block_height;
2147 }
2148 }
2149 Ok(next_height)
2150 }
2151
2152 /// Sends one confirmed certificate, uploading blobs the validator reports missing.
2153 ///
2154 /// A missing *committee* (`EventsNotFound` for the epoch stream) is not recovered here: the
2155 /// admin chain is a chain like any other, so its own export brings the destination up to
2156 /// date, and this block succeeds on a later round. Replaying the admin chain from inside
2157 /// another chain's push is how one export round used to stall on an unbounded foreign
2158 /// history.
2159 ///
2160 /// The pair retries indefinitely — the backoff caps at `max_retry_delay` — so it recovers
2161 /// whenever the destination learns the epoch, from the admin chain's own export or from the
2162 /// client, which pushes it on this same error. After a restart the admin chain re-enters
2163 /// this queue's work-list only once it produces a block, so a quiet admin chain can leave a
2164 /// pair deferred for a while; `CHAIN_SCOPED_BACKOFFS` is what makes that visible.
2165 async fn send_confirmed_certificate(
2166 &mut self,
2167 certificate: &CacheArc<ConfirmedBlockCertificate>,
2168 held: &[CacheArc<Blob>],
2169 ) -> Result<Box<crate::data_types::ChainInfo>, chain_client::Error> {
2170 let delivery = CrossChainMessageDelivery::NonBlocking;
2171 // Covers the blob-recovery retries below too: what the caller cares about is how long this
2172 // certificate took to land, not how many attempts it needed.
2173 #[cfg(with_metrics)]
2174 let certificate_latency =
2175 metrics::CERTIFICATE_SEND_LATENCY.with_label_values(&[&self.address]);
2176 #[cfg(with_metrics)]
2177 let _certificate_latency = certificate_latency.measure_latency();
2178 let mut result = self
2179 .remote_node
2180 .handle_optimized_confirmed_certificate(certificate, delivery)
2181 .await;
2182 // The same once-per-cause loop as the client's `RemoteNodeUpdater`: a second
2183 // `BlobsNotFound` naming new blobs is still recoverable, only repeating a cause is not.
2184 let mut sent_blobs = false;
2185 loop {
2186 match result {
2187 Err(NodeError::BlobsNotFound(blob_ids)) if !sent_blobs => {
2188 self.remote_node
2189 .check_blobs_not_found(&**certificate, &blob_ids)?;
2190 let blobs = self.resolve_blobs(&blob_ids, held).await?;
2191 self.remote_node
2192 .node
2193 .upload_blobs(blobs.into_iter().map(CacheArc::into_std).collect())
2194 .await?;
2195 sent_blobs = true;
2196 }
2197 result => return Ok(result?),
2198 }
2199 result = self
2200 .remote_node
2201 .handle_confirmed_certificate(certificate.clone(), delivery)
2202 .await;
2203 }
2204 }
2205
2206 /// Collects the given blobs, taking each from `held` if present and the rest from storage.
2207 async fn resolve_blobs(
2208 &self,
2209 blob_ids: &[BlobId],
2210 held: &[CacheArc<Blob>],
2211 ) -> Result<Vec<CacheArc<Blob>>, chain_client::Error> {
2212 let mut blobs = Vec::with_capacity(blob_ids.len());
2213 let mut to_read = Vec::new();
2214 for blob_id in blob_ids {
2215 match held.iter().find(|blob| blob.id() == *blob_id) {
2216 Some(blob) => blobs.push(blob.clone()),
2217 None => to_read.push(*blob_id),
2218 }
2219 }
2220 if to_read.is_empty() {
2221 return Ok(blobs);
2222 }
2223 let read = self
2224 .storage
2225 .read_blobs(&to_read)
2226 .await?
2227 .into_iter()
2228 .collect::<Option<Vec<_>>>();
2229 blobs.extend(read.ok_or(NodeError::BlobsNotFound(to_read))?);
2230 Ok(blobs)
2231 }
2232}
2233
2234#[cfg(test)]
2235mod tests {
2236 use linera_base::crypto::CryptoHash;
2237
2238 use super::*;
2239
2240 /// Every rejection in `check()` guards a distinct failure mode, so each invalid field must be
2241 /// caught on its own — including `max_retry_delay`, whose zero used to slip through and turn
2242 /// backoff into a busy retry.
2243 #[test]
2244 fn config_check_rejects_each_zero_knob() {
2245 assert!(BlockExportConfig::default().check().is_ok());
2246 let invalid = [
2247 BlockExportConfig {
2248 certificate_upload_batch_size: 0,
2249 ..BlockExportConfig::default()
2250 },
2251 BlockExportConfig {
2252 queue_size: 0,
2253 ..BlockExportConfig::default()
2254 },
2255 BlockExportConfig {
2256 queue_bytes: 0,
2257 ..BlockExportConfig::default()
2258 },
2259 BlockExportConfig {
2260 max_in_flight_per_destination: 0,
2261 ..BlockExportConfig::default()
2262 },
2263 BlockExportConfig {
2264 max_catch_up_blocks: 0,
2265 ..BlockExportConfig::default()
2266 },
2267 BlockExportConfig {
2268 idle_catch_up_interval: Duration::ZERO,
2269 ..BlockExportConfig::default()
2270 },
2271 BlockExportConfig {
2272 retry_delay: Duration::ZERO,
2273 ..BlockExportConfig::default()
2274 },
2275 BlockExportConfig {
2276 max_retry_delay: Duration::ZERO,
2277 ..BlockExportConfig::default()
2278 },
2279 BlockExportConfig {
2280 retry_delay: Duration::from_secs(120),
2281 max_retry_delay: Duration::from_secs(60),
2282 ..BlockExportConfig::default()
2283 },
2284 BlockExportConfig {
2285 converged_chain_retention: Duration::ZERO,
2286 ..BlockExportConfig::default()
2287 },
2288 ];
2289 for config in invalid {
2290 assert!(config.check().is_err(), "accepted: {config:?}");
2291 }
2292 }
2293
2294 /// A record starts from what the chain persisted, so a restart does not re-query every
2295 /// destination of every chain.
2296 ///
2297 /// This is the one place the persisted `exported_heights` enters the queue. It regressed
2298 /// twice — once when the seeding ran only at record creation and the tick could create
2299 /// records without it, once when adding the cursor-always-present invariant made the seeding
2300 /// a silent no-op — and neither showed up in any behavioural test, because a missing seed
2301 /// only costs a query.
2302 #[test]
2303 fn records_start_from_the_persisted_heights() {
2304 let validator = ValidatorPublicKey::test_key(1);
2305 let other = ValidatorPublicKey::test_key(2);
2306 let destinations = test_destinations([validator, other]);
2307 let exported = [(validator, BlockHeight(41))].into_iter().collect();
2308
2309 let record = ChainRecord::new(Timestamp::now(), &destinations, &exported);
2310
2311 assert_eq!(
2312 record.dest(0).unwrap().next_height,
2313 Some(BlockHeight(42)),
2314 "a persisted height must seed the cursor for the block after it",
2315 );
2316 assert_eq!(
2317 record.dest(1).unwrap().next_height,
2318 None,
2319 "a destination with nothing persisted must be queried, not assumed",
2320 );
2321 }
2322
2323 /// A cursor already present as `None` — the state a tick-created record starts in — is still
2324 /// seeded from the persisted heights.
2325 ///
2326 /// The seeding has regressed three times, each time because it was tied to record *creation*
2327 /// while a second path also creates records. This pins the property that actually matters:
2328 /// after the fill, a missing cursor is seeded no matter who made the record.
2329 #[test]
2330 fn a_cursor_left_unset_is_still_seeded() {
2331 let validator = ValidatorPublicKey::test_key(1);
2332 let destinations = test_destinations([validator]);
2333 let exported: BTreeMap<_, _> = [(validator, BlockHeight(7))].into_iter().collect();
2334
2335 // As the tick builds it: no heights to hand over, so the cursor starts unset.
2336 let mut record = ChainRecord::new(Timestamp::now(), &destinations, &BTreeMap::new());
2337 assert_eq!(record.dest(0).unwrap().next_height, None);
2338
2339 // The fill `on_block` performs once a block for that chain arrives.
2340 record.seed_missing_cursors(&destinations, &exported);
2341
2342 assert_eq!(
2343 record.dest(0).unwrap().next_height,
2344 Some(BlockHeight(8)),
2345 "a record the tick created must still pick up the persisted cursor",
2346 );
2347 }
2348
2349 /// The drain rotates through the backlog instead of always serving its lowest chain ids.
2350 ///
2351 /// The maintained set replaced a FIFO, and a set is *ordered* — walking it from the start
2352 /// every tick hands the whole window to the same few chains and starves everything after
2353 /// them, which at a large backlog means those chains are never exported at all.
2354 #[test]
2355 fn draining_rotates_through_the_backlog() {
2356 let mut dest = test_dest_state();
2357 dest.lagging = test_chain_ids(6).into_iter().collect();
2358
2359 // Two rounds of a two-chain window; the second must move past the first.
2360 let first = dest.drain_candidates(2);
2361 dest.advance_cursor(first.last().copied());
2362 let second = dest.drain_candidates(2);
2363
2364 assert_eq!(first.len(), 2);
2365 assert_eq!(second.len(), 2);
2366 assert!(
2367 first.iter().all(|id| !second.contains(id)),
2368 "the second round repeated the first: {first:?} then {second:?}",
2369 );
2370 }
2371
2372 /// A round that considered nothing must not throw the rotation away.
2373 ///
2374 /// `drain_ready` returns before looking at a single candidate when the destination is
2375 /// already at its window, which for a destination with a backlog is almost every tick. If
2376 /// that round reset the cursor, the next one would restart at the lowest chain id and the
2377 /// tail of the backlog would never be reached — the starvation the cursor exists to prevent,
2378 /// reintroduced by the cursor's own bookkeeping.
2379 #[test]
2380 fn a_round_that_visits_nothing_keeps_its_place() {
2381 let mut dest = test_dest_state();
2382 let ids = test_chain_ids(6);
2383 dest.lagging = ids.iter().copied().collect();
2384
2385 let first = dest.drain_candidates(2);
2386 dest.advance_cursor(first.last().copied());
2387 let parked = dest.lagging_cursor;
2388 assert!(parked.is_some(), "the first round must park the cursor");
2389
2390 // The saturated round: no candidate is visited, so nothing was considered.
2391 dest.advance_cursor(None);
2392
2393 assert_eq!(
2394 dest.lagging_cursor, parked,
2395 "a round that visited nothing moved the cursor",
2396 );
2397 let resumed = dest.drain_candidates(2);
2398 assert!(
2399 resumed.iter().all(|id| !first.contains(id)),
2400 "the drain restarted at the front of the backlog: {first:?} then {resumed:?}",
2401 );
2402 }
2403
2404 /// A peer that alternates advancing and regressing its reported height pays a doubling
2405 /// penalty, while one that genuinely restored pays a single delay.
2406 ///
2407 /// The first attempt at this backed a regression off using the shared `failures` counter,
2408 /// which an advance resets — so alternating answers pinned the delay at the base value
2409 /// forever and the "throttled" peer kept a pair re-reading its whole catch-up window at
2410 /// roughly two sends a second.
2411 #[test]
2412 fn an_oscillating_peer_escalates_but_a_restored_one_does_not() {
2413 let config = BlockExportConfig {
2414 retry_delay: Duration::from_millis(100),
2415 max_retry_delay: Duration::from_secs(60),
2416 ..BlockExportConfig::default()
2417 };
2418 let now = Timestamp::now();
2419 let tip = BlockHeight(100);
2420
2421 // A genuine restore: one regression, then it advances from there for good.
2422 let mut restored = ChainDest {
2423 next_height: Some(BlockHeight(50)),
2424 ..ChainDest::default()
2425 };
2426 assert!(restored
2427 .record_reached(BlockHeight(10), tip, now, &config)
2428 .is_some());
2429 let restore_penalty = restored.retry_at.expect("a regression backs the pair off");
2430 assert_eq!(
2431 restore_penalty,
2432 now.saturating_add(TimeDelta::from_millis(100))
2433 );
2434 restored.record_reached(BlockHeight(20), tip, now, &config);
2435 assert_eq!(
2436 restored.retry_at, None,
2437 "an advance after the restore must clear the penalty",
2438 );
2439
2440 // An oscillator: every regression costs double the last, however many advances it
2441 // interleaves.
2442 let mut liar = ChainDest {
2443 next_height: Some(BlockHeight(50)),
2444 ..ChainDest::default()
2445 };
2446 let mut penalties = Vec::new();
2447 for round in 0..4 {
2448 liar.record_reached(BlockHeight(10), tip, now, &config);
2449 penalties.push(
2450 liar.retry_at
2451 .expect("a regression backs the pair off")
2452 .delta_since(now),
2453 );
2454 liar.record_reached(BlockHeight(50 + round), tip, now, &config);
2455 }
2456 assert_eq!(
2457 penalties,
2458 vec![
2459 TimeDelta::from_millis(100),
2460 TimeDelta::from_millis(200),
2461 TimeDelta::from_millis(400),
2462 TimeDelta::from_millis(800),
2463 ],
2464 "an advance between regressions reset the penalty",
2465 );
2466 }
2467
2468 /// A destination claiming a height above our own tip is acknowledged only up to that tip.
2469 ///
2470 /// The acknowledged height is merged into the chain's persisted `exported_heights` by
2471 /// maximum, so believing an over-report would write a "converged" marker that outlives the
2472 /// process and silently ends export to that pair. Being *ahead* is normal — the client
2473 /// broadcasts to everyone — so the report cannot simply be rejected either.
2474 #[test]
2475 fn a_height_above_our_tip_is_acknowledged_only_up_to_it() {
2476 let config = BlockExportConfig::default();
2477 let tip = BlockHeight(100);
2478
2479 // An over-report is clamped — in the acknowledgement AND in the stored cursor. Leaving
2480 // the cursor raw satisfies every "converged" predicate and no "behind" one, and only a
2481 // completed send can rewrite it, so the pair would never be scheduled again.
2482 let mut liar = ChainDest::default();
2483 let acked = liar.record_reached(BlockHeight(u64::MAX), tip, Timestamp::now(), &config);
2484 assert_eq!(
2485 acked,
2486 Some(BlockHeight(99)),
2487 "acknowledged a height we never exported",
2488 );
2489 assert_eq!(
2490 liar.next_height,
2491 Some(tip),
2492 "stored a cursor above our tip: the pair is now unschedulable and reads as converged",
2493 );
2494
2495 // And an honest report below the tip is taken at its word, not rounded up to it —
2496 // otherwise "always acknowledge tip - 1" would satisfy the clamp just as well.
2497 let mut honest = ChainDest::default();
2498 let acked = honest.record_reached(BlockHeight(40), tip, Timestamp::now(), &config);
2499 assert_eq!(
2500 acked,
2501 Some(BlockHeight(39)),
2502 "acknowledged more than the destination reported",
2503 );
2504 assert_eq!(honest.next_height, Some(BlockHeight(40)));
2505 }
2506
2507 /// This is per (chain, destination) and a down peer holds one per tracked chain, so growth
2508 /// here is deliberate or not at all.
2509 ///
2510 /// 56 -> 64 when `parked` was added: the previous fields packed exactly, leaving no padding to
2511 /// absorb it. The cost is 8 bytes per pair — ~10 KB at conway's observed 210 tracked chains
2512 /// across 6 destinations, under 1 MB even if every one of ~19k chains were tracked at once.
2513 /// Paid rather than squeezing `regressions` down to `u16`, which would have kept 56 bytes but
2514 /// changed a field unrelated to parking.
2515 #[test]
2516 fn a_chain_destination_pair_stays_small() {
2517 assert_eq!(size_of::<ChainDest>(), 64);
2518 }
2519
2520 /// Our own storage failing is not the destination's fault, and not one pair's problem
2521 /// either: it shrinks the queue-wide budget, leaving the peer's window alone.
2522 ///
2523 /// Classifying it per-pair (as `ChainScoped` did) backs off one pair at a time while every
2524 /// other pair keeps reading at full rate, so the control loop cannot see the bottleneck it
2525 /// is creating.
2526 #[test]
2527 fn local_storage_errors_are_neither_chain_nor_destination_scoped() {
2528 let view_error = chain_client::Error::ViewError(linera_views::ViewError::NotFound(
2529 "storage is unhappy".to_owned(),
2530 ));
2531 assert!(is_local_scoped(&view_error));
2532 assert!(
2533 !is_chain_scoped(&view_error),
2534 "a storage failure would back off one pair and leave the budget untouched",
2535 );
2536
2537 // A destination genuinely lacking the chain's events stays chain-scoped.
2538 let events_missing =
2539 chain_client::Error::RemoteNodeError(NodeError::EventsNotFound(vec![]));
2540 assert!(is_chain_scoped(&events_missing));
2541 assert!(!is_local_scoped(&events_missing));
2542 }
2543
2544 /// A destination reporting ITS chain corrupt must not be treated as an unhealthy destination.
2545 ///
2546 /// Before parking, this fell through to `DestinationScoped`, which halves the peer's AIMD
2547 /// window — so a handful of corrupt chains throttled catch-up for every healthy chain at that
2548 /// peer. Observed on testnet-conway 2026-08-25: stakefi had 10 corrupt chains out of ~19k and
2549 /// drained far slower than a peer with none.
2550 #[test]
2551 fn a_corrupt_chain_at_the_destination_parks_rather_than_penalising_the_peer() {
2552 let corrupted = chain_client::Error::RemoteNodeError(NodeError::ChainError {
2553 error: "Corrupted chain state: computed block outcome differs from the certificate."
2554 .to_owned(),
2555 });
2556 assert_eq!(park_reason(&corrupted), Some(ParkReason::Corrupted));
2557 assert!(
2558 !is_chain_scoped(&corrupted) && !is_local_scoped(&corrupted),
2559 "parking must be decided before the scoped arms, or the peer's window is halved",
2560 );
2561
2562 // A different ChainError is NOT unrecoverable: it keeps its old classification, so this
2563 // change cannot silently swallow errors that retrying does fix.
2564 let other = chain_client::Error::RemoteNodeError(NodeError::ChainError {
2565 error: "Block proposal has size 999 which is too large".to_owned(),
2566 });
2567 assert_eq!(park_reason(&other), None);
2568 }
2569
2570 /// A parked pair is skipped by the drain gate, and only a restart clears it.
2571 #[test]
2572 fn a_parked_pair_is_never_spawned_again() {
2573 let mut chain_dest = ChainDest::default();
2574 assert!(chain_dest.parked.is_none(), "pairs start unparked");
2575 chain_dest.parked = Some(ParkReason::Corrupted);
2576 assert!(
2577 chain_dest.parked.is_some(),
2578 "nothing in the retry path clears `parked`: the repair is manual on the peer's side, \
2579 so re-arming on a timer would just resume failing against unfixed state",
2580 );
2581 assert_eq!(ParkReason::Corrupted.as_str(), "corrupted");
2582 }
2583
2584 /// The queue-wide budget is offered to a different destination each round.
2585 ///
2586 /// It is shared across destinations, so serving them in index order every time lets the
2587 /// first `max_in_flight_total / max_in_flight_per_destination` of them absorb all of it —
2588 /// with the defaults that is eight, and every destination past the eighth in a larger
2589 /// committee would get no catch-up at all.
2590 #[test]
2591 fn the_budget_is_offered_to_a_different_destination_each_round() {
2592 let indices = (0..12 as DestIndex).collect::<Vec<_>>();
2593 let mut cursor = None;
2594 let mut first_served = Vec::new();
2595
2596 for _ in 0..12 {
2597 let order = rotated_order(&indices, cursor);
2598 first_served.push(order[0]);
2599 cursor = next_drain_cursor(&indices, order.first().copied());
2600 }
2601
2602 assert_eq!(
2603 first_served,
2604 vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
2605 "the same destinations kept first claim on the budget",
2606 );
2607 }
2608
2609 /// A drained burst hands back its peak-sized table for freeing off the mutex; a live one
2610 /// keeps its table.
2611 ///
2612 /// Pinned because the sweep's mutex-hold budget has been broken twice: once by never
2613 /// shrinking at all, once by an in-place shrink whose free of the peak table ran under the
2614 /// lock.
2615 #[test]
2616 fn forgetting_a_drained_burst_returns_its_table() {
2617 let mut progress = ProgressMap::default();
2618 let chains = test_chain_ids(20_000);
2619 for chain_id in &chains {
2620 progress.heights.insert(*chain_id, Vec::new());
2621 }
2622 let peak_capacity = progress.heights.capacity();
2623
2624 // Still holding more than one sweep's budget: no rebuild, however oversized the table.
2625 // This is the case that separates the two halves of the guard — the table below is
2626 // several times too big, so a capacity test on its own would rebuild it here, under the
2627 // mutex, at a size the sweep's budget was written to exclude.
2628 let keep = MAX_FORGET_PER_SWEEP + 1000;
2629 let (bulk, _) = chains.split_at(chains.len() - keep);
2630 assert!(
2631 progress.forget_chains(bulk).is_none(),
2632 "rebuilt a map still holding {} entries, past the {MAX_FORGET_PER_SWEEP} budget",
2633 progress.heights.len(),
2634 );
2635 assert!(
2636 progress.heights.capacity() > progress.heights.len().saturating_mul(4),
2637 "the table has to be oversized here or the case proves nothing",
2638 );
2639
2640 // Down to within the budget: the peak table is handed back, not freed in place.
2641 let survivors = 10;
2642 let within_budget = chains.len() - survivors;
2643 let old_table = progress.forget_chains(&chains[chains.len() - keep..within_budget]);
2644 assert!(
2645 old_table.is_some_and(|table| table.capacity() > peak_capacity / 2),
2646 "the peak-sized table was not handed back for freeing off the mutex",
2647 );
2648 assert!(progress.heights.capacity() < peak_capacity / 4);
2649 assert_eq!(progress.heights.len(), survivors);
2650 }
2651
2652 /// Chain ids in the order `lagging` holds them, so a test can name "the front" of a backlog.
2653 fn test_chain_ids(count: usize) -> Vec<ChainId> {
2654 let mut ids = (0..count)
2655 .map(|i| ChainId(CryptoHash::test_hash(format!("chain{i}"))))
2656 .collect::<Vec<_>>();
2657 ids.sort_unstable();
2658 ids
2659 }
2660
2661 /// Destinations indexed the way `sync_destinations` assigns them: in registration order.
2662 fn test_destinations(
2663 validators: impl IntoIterator<Item = ValidatorPublicKey>,
2664 ) -> BTreeMap<DestIndex, DestState<()>> {
2665 validators
2666 .into_iter()
2667 .enumerate()
2668 .map(|(index, validator)| {
2669 let dest = DestState {
2670 validator,
2671 ..test_dest_state()
2672 };
2673 (
2674 DestIndex::try_from(index).expect("test committees are small"),
2675 dest,
2676 )
2677 })
2678 .collect()
2679 }
2680
2681 fn test_dest_state() -> DestState<()> {
2682 DestState {
2683 node: (),
2684 validator: ValidatorPublicKey::test_key(0),
2685 address: "grpc:localhost:1".to_string(),
2686 generation: 1,
2687 in_flight: 0,
2688 window: 1,
2689 retry_at: None,
2690 failures: 0,
2691 lagging: BTreeSet::new(),
2692 lagging_cursor: None,
2693 }
2694 }
2695
2696 /// The backoff must escalate across consecutive failures — computing it from a reset counter
2697 /// is how it once retried a hopeless destination at the base delay forever.
2698 #[test]
2699 fn back_off_escalates_and_caps() {
2700 let config = BlockExportConfig {
2701 retry_delay: Duration::from_millis(100),
2702 max_retry_delay: Duration::from_millis(450),
2703 ..BlockExportConfig::default()
2704 };
2705 let mut failures = 0;
2706 let mut retry_at = None;
2707 let now = Timestamp::now();
2708 let mut delays = Vec::new();
2709 for _ in 0..4 {
2710 back_off(&mut failures, &mut retry_at, now, &config);
2711 delays.push(retry_at.expect("set by back_off").delta_since(now));
2712 }
2713 assert_eq!(
2714 delays,
2715 [
2716 TimeDelta::from_millis(100),
2717 TimeDelta::from_millis(200),
2718 TimeDelta::from_millis(400),
2719 TimeDelta::from_millis(450),
2720 ],
2721 );
2722 }
2723}