Skip to main content

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}