Skip to main content

linera_core/
updater.rs

1// Copyright (c) Facebook, Inc. and its affiliates.
2// Copyright (c) Zefchain Labs, Inc.
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{
6    collections::{BTreeMap, BTreeSet, HashMap},
7    fmt,
8    hash::Hash,
9    mem,
10};
11
12use futures::{future, Future, StreamExt};
13use linera_base::{
14    crypto::ValidatorPublicKey,
15    data_types::{BlockHeight, Round, TimeDelta},
16    ensure,
17    identifiers::{BlobId, BlobType, ChainId, StreamId},
18    time::{timer::timeout, Duration, Instant},
19};
20use linera_chain::{
21    data_types::{BlockProposal, LiteVote},
22    manager::LockingBlock,
23    types::{ConfirmedBlockCertificate, ValidatedBlockCertificate},
24};
25use linera_execution::{committee::Committee, system::EPOCH_STREAM_NAME, BlobOrigin};
26use linera_storage::{Arc as CacheArc, Clock, Storage};
27use thiserror::Error;
28use tokio::sync::mpsc;
29use tracing::{instrument, Level};
30
31use crate::{
32    client::chain_client,
33    data_types::{ChainInfo, ChainInfoQuery},
34    environment::Environment,
35    local_node::LocalNodeClient,
36    node::{CrossChainMessageDelivery, NodeError, ValidatorNode},
37    remote_node::RemoteNode,
38    LocalNodeError,
39};
40
41/// The default amount of time we wait for additional validators to contribute
42/// to the result, as a fraction of how long it took to reach a quorum.
43pub const DEFAULT_QUORUM_GRACE_PERIOD: f64 = 0.2;
44
45/// A report of clock skew from a validator, sent before retrying due to `InvalidTimestamp`.
46pub type ClockSkewReport = (ValidatorPublicKey, TimeDelta);
47/// The maximum timeout for requests to a stake-weighted quorum if no quorum is reached.
48const MAX_TIMEOUT: Duration = Duration::from_secs(60 * 60 * 24); // 1 day.
49
50#[cfg(with_metrics)]
51mod metrics {
52    use std::sync::LazyLock;
53
54    use linera_base::prometheus_util::{
55        exponential_bucket_latencies, register_histogram_vec, register_int_counter_vec,
56    };
57    use prometheus::{HistogramVec, IntCounterVec};
58
59    /// Requests dispatched to each validator while communicating with a quorum.
60    ///
61    /// Incremented at dispatch rather than on completion, so the series exists even for a
62    /// validator that never answers. Subtracting the responses below from this yields the
63    /// share of requests a validator left unanswered.
64    ///
65    /// The `address` label carries the validator's gRPC URL so that dashboards and alerts can
66    /// name a validator without an out-of-band lookup of its public key. It is functionally
67    /// dependent on `validator`, so it adds no series.
68    pub(super) static QUORUM_REQUESTS: LazyLock<IntCounterVec> = LazyLock::new(|| {
69        register_int_counter_vec(
70            "communicate_with_quorum_requests_total",
71            "Requests dispatched to each validator while communicating with a quorum",
72            &["validator", "address"],
73        )
74    });
75
76    /// Responses received from each validator while communicating with a quorum.
77    ///
78    /// The `outcome` label is `before_quorum` when the response arrived while a quorum was
79    /// still outstanding, and `after_quorum` when it only arrived during the grace period
80    /// that follows a quorum being reached. A validator whose responses are consistently
81    /// `after_quorum` is holding up nothing yet, but it is the one that will stall
82    /// confirmation as soon as any other validator degrades.
83    pub(super) static QUORUM_RESPONSES: LazyLock<IntCounterVec> = LazyLock::new(|| {
84        register_int_counter_vec(
85            "communicate_with_quorum_responses_total",
86            "Responses from each validator, by whether a quorum had already been reached",
87            &["validator", "address", "outcome"],
88        )
89    });
90
91    /// Time each validator took to respond while communicating with a quorum.
92    pub(super) static QUORUM_RESPONSE_TIME: LazyLock<HistogramVec> = LazyLock::new(|| {
93        register_histogram_vec(
94            "communicate_with_quorum_response_time_ms",
95            "Time taken by each validator to respond while communicating with a quorum, \
96             in milliseconds",
97            &["validator", "address"],
98            exponential_bucket_latencies(60_000.0),
99        )
100    });
101}
102
103/// Used for `communicate_chain_action`
104#[derive(Clone)]
105pub enum CommunicateAction {
106    SubmitBlock {
107        proposal: Box<BlockProposal>,
108        blob_ids: Vec<BlobId>,
109        /// Channel to report clock skew before sleeping, so the caller can aggregate reports.
110        clock_skew_sender: mpsc::UnboundedSender<ClockSkewReport>,
111    },
112    FinalizeBlock {
113        certificate: Box<ValidatedBlockCertificate>,
114        delivery: CrossChainMessageDelivery,
115    },
116    RequestTimeout {
117        chain_id: ChainId,
118        height: BlockHeight,
119        round: Round,
120    },
121}
122
123impl CommunicateAction {
124    /// The round to which this action pertains.
125    pub fn round(&self) -> Round {
126        match self {
127            CommunicateAction::SubmitBlock { proposal, .. } => proposal.content.round,
128            CommunicateAction::FinalizeBlock { certificate, .. } => certificate.round,
129            CommunicateAction::RequestTimeout { round, .. } => *round,
130        }
131    }
132}
133
134/// Pushes data to a single validator to bring it up to date with the local node.
135///
136/// This deliberately holds a [`LocalNodeClient`] rather than a full client: updating another
137/// node must not mutate the client's own state. When a validator turns out to be *ahead* of the
138/// local node, the updater signals that with [`chain_client::Error::LocalNodeLagging`] and lets
139/// the caller decide whether to pull the missing state.
140pub struct RemoteNodeUpdater<Env>
141where
142    Env: Environment,
143{
144    pub remote_node: RemoteNode<Env::ValidatorNode>,
145    pub local_node: LocalNodeClient<Env::Storage>,
146    pub admin_chain_id: ChainId,
147    pub certificate_upload_batch_size: usize,
148}
149
150impl<Env: Environment> Clone for RemoteNodeUpdater<Env> {
151    fn clone(&self) -> Self {
152        RemoteNodeUpdater {
153            remote_node: self.remote_node.clone(),
154            local_node: self.local_node.clone(),
155            admin_chain_id: self.admin_chain_id,
156            certificate_upload_batch_size: self.certificate_upload_batch_size,
157        }
158    }
159}
160
161/// An error result for requests to a stake-weighted quorum.
162#[derive(Error, Debug)]
163pub enum CommunicationError<E: fmt::Debug> {
164    /// No consensus is possible since validators returned different possibilities
165    /// for the next block
166    #[error(
167        "No error but failed to find a consensus block. Consensus threshold: {0}, Proposals: {1:?}"
168    )]
169    NoConsensus(u64, Vec<(u64, usize)>),
170    /// A single error that was returned by a sufficient number of nodes to be trusted as
171    /// valid.
172    #[error("Failed to communicate with a quorum of validators: {0}")]
173    Trusted(E),
174    /// No single error reached the validity threshold so we're returning a sample of
175    /// errors for debugging purposes, together with their weight.
176    #[error("Failed to communicate with a quorum of validators:\n{:#?}", .0)]
177    Sample(Vec<(E, u64)>),
178}
179
180/// Executes a sequence of actions in parallel for all validators.
181///
182/// Tries to stop early when a quorum is reached. If `quorum_grace_period` is specified, other
183/// validators are given additional time to contribute to the result. The grace period is
184/// calculated as a fraction (defaulting to `DEFAULT_QUORUM_GRACE_PERIOD`) of the time taken to
185/// reach quorum.
186pub async fn communicate_with_quorum<'a, A, V, K, F, R, G>(
187    validator_clients: &'a [RemoteNode<A>],
188    committee: &Committee,
189    group_by: G,
190    execute: F,
191    // Grace period as a fraction of time taken to reach quorum.
192    quorum_grace_period: f64,
193) -> Result<(K, Vec<(ValidatorPublicKey, V)>), CommunicationError<NodeError>>
194where
195    A: ValidatorNode + Clone + 'static,
196    F: Clone + Fn(RemoteNode<A>) -> R,
197    R: Future<Output = Result<V, chain_client::Error>> + 'a,
198    G: Fn(&V) -> K,
199    K: Hash + PartialEq + Eq + Clone + 'static,
200    V: 'static,
201{
202    let mut responses: futures::stream::FuturesUnordered<_> = validator_clients
203        .iter()
204        .filter_map(|remote_node| {
205            if committee.weight(&remote_node.public_key) == 0 {
206                // This should not happen but better prevent it because certificates
207                // are not allowed to include votes with weight 0.
208                return None;
209            }
210            let execute = execute.clone();
211            let remote_node = remote_node.clone();
212            #[cfg(with_metrics)]
213            metrics::QUORUM_REQUESTS
214                .with_label_values(&[&remote_node.public_key.to_string(), &remote_node.address()])
215                .inc();
216            Some(async move {
217                let public_key = remote_node.public_key;
218                #[cfg(with_metrics)]
219                let address = remote_node.address();
220                #[cfg(with_metrics)]
221                let request_start = Instant::now();
222                let result = execute(remote_node).await;
223                #[cfg(with_metrics)]
224                metrics::QUORUM_RESPONSE_TIME
225                    .with_label_values(&[&public_key.to_string(), &address])
226                    .observe(request_start.elapsed().as_secs_f64() * 1000.0);
227                (public_key, result)
228            })
229        })
230        .collect();
231
232    let start_time = Instant::now();
233    let mut end_time: Option<Instant> = None;
234    let mut remaining_votes = committee.total_votes();
235    let mut highest_key_score = 0;
236    let mut value_scores: HashMap<K, (u64, Vec<(ValidatorPublicKey, V)>)> = HashMap::new();
237    let mut error_scores = HashMap::new();
238    #[cfg(with_metrics)]
239    let addresses: HashMap<ValidatorPublicKey, String> = validator_clients
240        .iter()
241        .map(|remote_node| (remote_node.public_key, remote_node.address()))
242        .collect();
243
244    'vote_wait: while let Ok(Some((name, result))) = timeout(
245        end_time.map_or(MAX_TIMEOUT, |t| t.saturating_duration_since(Instant::now())),
246        responses.next(),
247    )
248    .await
249    {
250        remaining_votes -= committee.weight(&name);
251        #[cfg(with_metrics)]
252        metrics::QUORUM_RESPONSES
253            .with_label_values(&[
254                &name.to_string(),
255                addresses.get(&name).map_or("", String::as_str),
256                if end_time.is_none() {
257                    "before_quorum"
258                } else {
259                    "after_quorum"
260                },
261            ])
262            .inc();
263        match result {
264            Ok(value) => {
265                let key = group_by(&value);
266                let entry = value_scores.entry(key.clone()).or_insert((0, Vec::new()));
267                entry.0 += committee.weight(&name);
268                entry.1.push((name, value));
269                highest_key_score = highest_key_score.max(entry.0);
270            }
271            Err(err) => {
272                // TODO(#2857): Handle non-remote errors properly.
273                let err = match err {
274                    chain_client::Error::RemoteNodeError(err) => err,
275                    err => NodeError::ResponseHandlingError {
276                        error: err.to_string(),
277                    },
278                };
279                let entry = error_scores.entry(err.clone()).or_insert(0);
280                *entry += committee.weight(&name);
281            }
282        }
283        // If it becomes clear that no key can reach a quorum, break early.
284        if highest_key_score + remaining_votes < committee.quorum_threshold() {
285            break 'vote_wait;
286        }
287
288        // If a key reaches a quorum, wait for the grace period to collect more values
289        // or error information and then stop.
290        if end_time.is_none() && highest_key_score >= committee.quorum_threshold() {
291            end_time = Some(Instant::now() + start_time.elapsed().mul_f64(quorum_grace_period));
292        }
293    }
294
295    let scores = value_scores
296        .values()
297        .map(|(weight, values)| (*weight, values.len()))
298        .collect();
299    // If a key has a quorum, return it with its values.
300    if let Some((key, (_, values))) = value_scores
301        .into_iter()
302        .find(|(_, (score, _))| *score >= committee.quorum_threshold())
303    {
304        return Ok((key, values));
305    }
306
307    let mut sample = error_scores.into_iter().collect::<Vec<_>>();
308    sample.sort_by_key(|(_, score)| std::cmp::Reverse(*score));
309    sample.truncate(4);
310    Err(match sample.as_slice() {
311        [] => CommunicationError::NoConsensus(committee.quorum_threshold(), scores),
312        [(_, score), ..] if *score >= committee.validity_threshold() => {
313            // At least one honest validator returned this error.
314            CommunicationError::Trusted(sample.into_iter().next().unwrap().0)
315        }
316        // Otherwise no specific error is available to report reliably.}
317        _ => CommunicationError::Sample(sample),
318    })
319}
320
321impl<Env> RemoteNodeUpdater<Env>
322where
323    Env: Environment + 'static,
324{
325    /// Logs a warning if the error is not an expected part of the protocol flow.
326    fn warn_if_unexpected(&self, err: &NodeError) {
327        if !err.is_expected() {
328            tracing::warn!(
329                remote_node = self.remote_node.address(),
330                %err,
331                "unexpected error from validator",
332            );
333        }
334    }
335
336    #[instrument(
337        level = "trace", skip_all, err(level = Level::DEBUG),
338        fields(chain_id = %certificate.block().header.chain_id)
339    )]
340    async fn send_confirmed_certificate(
341        &mut self,
342        certificate: &CacheArc<ConfirmedBlockCertificate>,
343        delivery: CrossChainMessageDelivery,
344    ) -> Result<Box<ChainInfo>, chain_client::Error> {
345        let mut result = self
346            .remote_node
347            .handle_optimized_confirmed_certificate(certificate, delivery)
348            .await;
349
350        let mut sent_admin_chain = false;
351        let mut sent_blobs = false;
352        let mut sent_blocks = false;
353        loop {
354            match result {
355                Err(NodeError::EventsNotFound(event_ids))
356                    if !sent_admin_chain
357                        && certificate.inner().chain_id() != self.admin_chain_id
358                        && event_ids.iter().all(|event_id| {
359                            event_id.stream_id == StreamId::system(EPOCH_STREAM_NAME)
360                                && event_id.chain_id == self.admin_chain_id
361                        }) =>
362                {
363                    // The validator doesn't have the committee that signed the certificate.
364                    self.update_admin_chain().await?;
365                    sent_admin_chain = true;
366                }
367                Err(NodeError::BlobsNotFound(blob_ids)) if !sent_blobs => {
368                    // The validator is missing the blobs required by the certificate.
369                    let cert: &ConfirmedBlockCertificate = certificate;
370                    self.remote_node.check_blobs_not_found(cert, &blob_ids)?;
371                    // The certificate is confirmed, so the blobs must be in storage.
372                    let maybe_blobs = self.local_node.read_blobs_from_storage(&blob_ids).await?;
373                    let blobs = maybe_blobs.ok_or(NodeError::BlobsNotFound(blob_ids))?;
374                    self.remote_node
375                        .node
376                        .upload_blobs(blobs.into_iter().map(|b| b.into_std()).collect())
377                        .await?;
378                    sent_blobs = true;
379                }
380                Err(NodeError::BlocksNotFound(hashes)) if !sent_blocks => {
381                    // The validator has recorded these hashes as trusted by a
382                    // checkpoint cert it verified, but is missing the actual block
383                    // bytes. Upload each from local storage; the worker's
384                    // trust-mark accept path lets them through regardless of their
385                    // (possibly revoked) epoch.
386                    let storage = self.local_node.storage_client();
387                    let certificates = storage.read_certificates(&hashes).await?;
388                    for (hash, maybe_cert) in hashes.iter().zip(certificates) {
389                        let cert = maybe_cert.ok_or_else(|| {
390                            chain_client::Error::ReadCertificatesError(vec![*hash])
391                        })?;
392                        self.remote_node
393                            .handle_confirmed_certificate(cert, delivery)
394                            .await?;
395                    }
396                    sent_blocks = true;
397                }
398                result => {
399                    if let Err(err) = &result {
400                        self.warn_if_unexpected(err);
401                    }
402                    return Ok(result?);
403                }
404            }
405            result = self
406                .remote_node
407                .handle_confirmed_certificate(certificate.clone(), delivery)
408                .await;
409        }
410    }
411
412    async fn send_validated_certificate(
413        &mut self,
414        certificate: ValidatedBlockCertificate,
415        delivery: CrossChainMessageDelivery,
416    ) -> Result<Box<ChainInfo>, chain_client::Error> {
417        let result = self
418            .remote_node
419            .handle_optimized_validated_certificate(&certificate, delivery)
420            .await;
421
422        let chain_id = certificate.inner().chain_id();
423        match &result {
424            Err(original_err @ NodeError::BlobsNotFound(blob_ids)) => {
425                self.remote_node
426                    .check_blobs_not_found(&certificate, blob_ids)?;
427                // The certificate is for a validated block, i.e. for our locking block.
428                // Take the missing blobs from our local chain manager.
429                let blobs = self
430                    .local_node
431                    .get_locking_blobs(blob_ids, chain_id)
432                    .await?
433                    .ok_or_else(|| original_err.clone())?;
434                self.remote_node.send_pending_blobs(chain_id, blobs).await?;
435            }
436            Err(error) => {
437                self.sync_remote_if_needed(
438                    chain_id,
439                    certificate.round,
440                    certificate.block().header.height,
441                    error,
442                )
443                .await?;
444            }
445            _ => return Ok(result?),
446        }
447        let result = self
448            .remote_node
449            .handle_validated_certificate(certificate)
450            .await;
451        if let Err(err) = &result {
452            self.warn_if_unexpected(err);
453        }
454        Ok(result?)
455    }
456
457    /// Requests a vote for a timeout certificate for the given round from the remote node.
458    ///
459    /// If the remote node is not in that round or at that height yet, sends the chain information
460    /// to update it.
461    async fn request_timeout(
462        &mut self,
463        chain_id: ChainId,
464        round: Round,
465        height: BlockHeight,
466    ) -> Result<Box<ChainInfo>, chain_client::Error> {
467        let query = ChainInfoQuery::new(chain_id).with_timeout(height, round);
468        let result = self
469            .remote_node
470            .handle_chain_info_query(query.clone())
471            .await;
472        if let Err(err) = &result {
473            self.sync_remote_if_needed(chain_id, round, height, err)
474                .await?;
475            self.warn_if_unexpected(err);
476        }
477        Ok(result?)
478    }
479
480    /// Sends chain information to the remote node if it is the one lagging behind.
481    ///
482    /// If the error reveals that the *local* node is behind instead, returns
483    /// [`chain_client::Error::LocalNodeLagging`] carrying the original error: pulling remote
484    /// state is a client-level decision, not something updating another node may do.
485    async fn sync_remote_if_needed(
486        &mut self,
487        chain_id: ChainId,
488        round: Round,
489        height: BlockHeight,
490        error: &NodeError,
491    ) -> Result<(), chain_client::Error> {
492        let address = &self.remote_node.address();
493        match error {
494            NodeError::WrongRound(validator_round) if *validator_round > round => {
495                tracing::debug!(
496                    address, %chain_id, %validator_round, %round,
497                    "validator is at a higher round; local node needs to synchronize",
498                );
499                return Err(chain_client::Error::LocalNodeLagging {
500                    chain_id,
501                    error: Box::new(error.clone()),
502                });
503            }
504            NodeError::UnexpectedBlockHeight {
505                expected_block_height,
506                found_block_height,
507            } if expected_block_height > found_block_height => {
508                tracing::debug!(
509                    address,
510                    %chain_id,
511                    %expected_block_height,
512                    %found_block_height,
513                    "validator is at a higher height; local node needs to synchronize",
514                );
515                return Err(chain_client::Error::LocalNodeLagging {
516                    chain_id,
517                    error: Box::new(error.clone()),
518                });
519            }
520            NodeError::WrongRound(validator_round) if *validator_round < round => {
521                tracing::debug!(
522                    address, %chain_id, %validator_round, %round,
523                    "validator is at a lower round; sending chain info",
524                );
525                self.send_chain_information(
526                    chain_id,
527                    height,
528                    CrossChainMessageDelivery::NonBlocking,
529                    None,
530                )
531                .await?;
532            }
533            NodeError::UnexpectedBlockHeight {
534                expected_block_height,
535                found_block_height,
536            } if expected_block_height < found_block_height => {
537                tracing::debug!(
538                    address,
539                    %chain_id,
540                    %expected_block_height,
541                    %found_block_height,
542                    "Validator is at a lower height; sending chain info.",
543                );
544                self.send_chain_information(
545                    chain_id,
546                    height,
547                    CrossChainMessageDelivery::NonBlocking,
548                    None,
549                )
550                .await?;
551            }
552            NodeError::InactiveChain(inactive_chain_id) => {
553                tracing::debug!(
554                    address,
555                    chain_id = %inactive_chain_id,
556                    "Validator has inactive chain; sending chain info.",
557                );
558                self.send_chain_information(
559                    *inactive_chain_id,
560                    height,
561                    CrossChainMessageDelivery::NonBlocking,
562                    None,
563                )
564                .await?;
565            }
566            _ => {}
567        }
568        Ok(())
569    }
570
571    async fn send_block_proposal(
572        &mut self,
573        proposal: Box<BlockProposal>,
574        mut blob_ids: Vec<BlobId>,
575        clock_skew_sender: mpsc::UnboundedSender<ClockSkewReport>,
576    ) -> Result<Box<ChainInfo>, chain_client::Error> {
577        let chain_id = proposal.content.block.chain_id;
578        // One-shot guards: `synced_cross_chain_updates` for the missing-bundles path,
579        // `synced_round_and_height` for the round/height mismatch path.
580        let mut synced_cross_chain_updates = false;
581        let mut synced_round_and_height = false;
582        let mut publisher_chain_ids_sent = BTreeSet::new();
583        let storage = self.local_node.storage_client();
584        loop {
585            let local_time = storage.clock().current_time();
586            match self
587                .remote_node
588                .handle_block_proposal(proposal.clone())
589                .await
590            {
591                Ok(info) => return Ok(info),
592                Err(err @ (NodeError::WrongRound(_) | NodeError::UnexpectedBlockHeight { .. }))
593                    if !synced_round_and_height =>
594                {
595                    // The validator disagrees with the proposal's round or height. If it is
596                    // behind, `sync_remote_if_needed` pushes the chain and we retry; if it is
597                    // ahead, the `LocalNodeLagging` signal propagates so the caller can pull
598                    // its state and rebuild the proposal. One-shot: if the validator still
599                    // disagrees after a sync, retrying would not make progress.
600                    synced_round_and_height = true;
601                    tracing::debug!(
602                        remote_node = self.remote_node.address(),
603                        %chain_id,
604                        %err,
605                        "validator disagrees on round or height; synchronizing",
606                    );
607                    self.sync_remote_if_needed(
608                        chain_id,
609                        proposal.content.round,
610                        proposal.content.block.height,
611                        &err,
612                    )
613                    .await?;
614                }
615                // The validator reports *every* missing cross-chain bundle in a single
616                // `MissingCrossChainUpdates`, so we sync all of them at once and retry. Some
617                // received certificates may be missing for this validator (e.g. to create the
618                // chain or make the balance sufficient). If it still reports missing bundles
619                // after we synced the whole set, retrying would not make progress, so we surface
620                // the error instead of looping.
621                Err(NodeError::MissingCrossChainUpdates {
622                    chain_id: dependencies_chain_id,
623                    bundles,
624                }) if dependencies_chain_id == proposal.content.block.chain_id => {
625                    ensure!(
626                        !synced_cross_chain_updates,
627                        NodeError::ResponseHandlingError {
628                            error: format!(
629                                "validator still reports missing cross-chain updates for chain \
630                                 {dependencies_chain_id} after they were all synced"
631                            ),
632                        }
633                    );
634                    synced_cross_chain_updates = true;
635                    tracing::debug!(
636                        remote_node = %self.remote_node.address(),
637                        %chain_id,
638                        bundles = bundles.len(),
639                        "validator reported missing cross-chain updates; syncing them in one batch",
640                    );
641                    // Sync each reported origin chain up to the needed height, collapsing any
642                    // duplicate origins to the highest height.
643                    let mut origin_heights: BTreeMap<ChainId, BlockHeight> = BTreeMap::new();
644                    for (origin, height) in bundles {
645                        let target = height.try_add_one()?;
646                        let entry = origin_heights.entry(origin).or_insert(target);
647                        *entry = (*entry).max(target);
648                    }
649                    self.send_chain_info_up_to_heights(
650                        origin_heights,
651                        CrossChainMessageDelivery::Blocking,
652                    )
653                    .await?;
654                }
655                Err(NodeError::EventsNotFound(event_ids)) => {
656                    let mut publisher_heights = BTreeMap::new();
657                    let chain_ids = event_ids
658                        .iter()
659                        .map(|event_id| event_id.chain_id)
660                        .filter(|chain_id| !publisher_chain_ids_sent.contains(chain_id))
661                        .collect::<BTreeSet<_>>();
662                    tracing::debug!(
663                        remote_node = self.remote_node.address(),
664                        ?chain_ids,
665                        "missing events; sending chains to validator",
666                    );
667                    ensure!(!chain_ids.is_empty(), NodeError::EventsNotFound(event_ids));
668                    for chain_id in chain_ids {
669                        let height = self
670                            .local_node
671                            .get_next_height_to_preprocess(chain_id)
672                            .await?;
673                        publisher_heights.insert(chain_id, height);
674                        publisher_chain_ids_sent.insert(chain_id);
675                    }
676                    self.send_chain_info_up_to_heights(
677                        publisher_heights,
678                        CrossChainMessageDelivery::NonBlocking,
679                    )
680                    .await?;
681                }
682                Err(error @ NodeError::ChainError { .. }) => {
683                    // The validator rejected the proposal because of its local chain
684                    // manager state — most commonly an incompatible confirmed vote tied
685                    // to a locking block we don't yet have. The caller should pull
686                    // manager values from this validator so the local node absorbs
687                    // whatever justified the rejection; if the local state actually
688                    // advances, `execute_operations` will rebuild and re-propose; if
689                    // not, the error propagates as usual.
690                    self.warn_if_unexpected(&error);
691                    tracing::debug!(
692                        remote_node = self.remote_node.address(),
693                        %chain_id,
694                        %error,
695                        "validator rejected proposal; manager state needs to be pulled",
696                    );
697                    return Err(chain_client::Error::LocalNodeLagging {
698                        chain_id,
699                        error: Box::new(error),
700                    });
701                }
702                Err(NodeError::BlobsNotFound(_) | NodeError::InactiveChain(_))
703                    if !blob_ids.is_empty() =>
704                {
705                    tracing::debug!("Missing blobs");
706                    // For `BlobsNotFound`, we assume that the local node should already be
707                    // updated with the needed blobs, so sending the chain information about the
708                    // certificates that last used the blobs to the validator node should be enough.
709                    let published_blob_ids =
710                        BTreeSet::from_iter(proposal.content.block.published_blob_ids());
711                    blob_ids.retain(|blob_id| !published_blob_ids.contains(blob_id));
712                    let published_blobs = self
713                        .local_node
714                        .get_proposed_blobs(chain_id, published_blob_ids.into_iter().collect())
715                        .await?;
716                    self.remote_node
717                        .send_pending_blobs(chain_id, published_blobs)
718                        .await?;
719                    let missing_blob_ids = self
720                        .remote_node
721                        .node
722                        .missing_blob_ids(mem::take(&mut blob_ids))
723                        .await?;
724
725                    tracing::debug!("Sending chains for missing blobs");
726                    self.send_chain_info_for_blobs(
727                        &missing_blob_ids,
728                        CrossChainMessageDelivery::NonBlocking,
729                    )
730                    .await?;
731                }
732                Err(NodeError::InvalidTimestamp {
733                    block_timestamp,
734                    local_time: validator_local_time,
735                    ..
736                }) => {
737                    // The validator's clock is behind the block's timestamp. We need to
738                    // wait for two things:
739                    // 1. Our clock to reach block_timestamp (in case the block timestamp
740                    //    is in the future from our perspective too).
741                    // 2. The validator's clock to catch up (in case of clock skew between
742                    //    us and the validator).
743                    let clock_skew = local_time.delta_since(validator_local_time);
744                    tracing::debug!(
745                        remote_node = self.remote_node.address(),
746                        %chain_id,
747                        %block_timestamp,
748                        ?clock_skew,
749                        "validator's clock is behind; waiting and retrying",
750                    );
751                    // Report the clock skew before sleeping so the caller can aggregate.
752                    // Receiver may have been dropped if the caller is no longer interested.
753                    clock_skew_sender
754                        .send((self.remote_node.public_key, clock_skew))
755                        .ok();
756                    storage
757                        .clock()
758                        .sleep_until(block_timestamp.saturating_add(clock_skew))
759                        .await;
760                }
761                // Fail immediately on other errors.
762                Err(err) => {
763                    self.warn_if_unexpected(&err);
764                    return Err(err.into());
765                }
766            }
767        }
768    }
769
770    async fn update_admin_chain(&mut self) -> Result<(), chain_client::Error> {
771        let local_admin_info = self.local_node.chain_info(self.admin_chain_id).await?;
772        Box::pin(self.send_chain_information(
773            self.admin_chain_id,
774            local_admin_info.next_block_height,
775            CrossChainMessageDelivery::NonBlocking,
776            None,
777        ))
778        .await
779    }
780
781    /// Sends chain information to bring a validator up to date with a specific chain.
782    ///
783    /// This method performs a two-phase synchronization:
784    /// 1. **Height synchronization**: sends the certificates we hold locally for the range
785    ///    `[validator_next_height, target_block_height)`, in order.
786    /// 2. **Round synchronization**: If heights match, ensures the validator has proposals/certificates
787    ///    for the current consensus round.
788    ///
789    /// Only certificates that are actually in our local storage are sent; heights we don't have are
790    /// silently skipped (see [`Self::read_certificates_for_heights`]). This is deliberate and is what
791    /// makes the "leave gaps on the validator side" behavior (#4181) work: a chain we merely *receive*
792    /// from is stored only at its message-bearing heights, so we push exactly those. The validator
793    /// executes the contiguous prefix and preprocesses any block that sits above a gap — enough to
794    /// deliver that block's cross-chain bundles without our ever having to send the intervening
795    /// non-message blocks (which we don't have anyway).
796    ///
797    /// Because our local storage is guaranteed to hold every block we needed to build a proposal (a
798    /// bundle can only be consumed after its ordered message-bearing predecessors were downloaded),
799    /// this is the reliable way to catch a validator up. Deriving the set to send from a
800    /// `MissingCrossChainUpdates` error instead is *not* reliable: that error lists only the bundles
801    /// the current proposal is missing and omits already-consumed ancestors, which the validator
802    /// still needs executed before it can schedule a later gap block's bundle.
803    ///
804    /// # Height Sync Strategy
805    /// - For existing chains (target_block_height > 0):
806    ///   * Optimistically sends the last certificate first (often that's all that's missing).
807    ///   * Falls back to a full chain query if the validator needs more context.
808    ///   * Sends any additional locally-held certificates in order.
809    /// - For new chains (target_block_height == 0):
810    ///   * Sends the chain description and dependencies first.
811    ///   * Then queries the validator's state.
812    ///
813    /// # Round Sync Strategy
814    /// Once heights match, if the local node is at a higher round, sends the evidence
815    /// (proposal, validated block, or timeout certificate) that proves the current round.
816    ///
817    /// # Parameters
818    /// - `chain_id`: The chain to synchronize
819    /// - `target_block_height`: The height the validator should reach
820    /// - `delivery`: Message delivery mode (blocking or non-blocking)
821    /// - `latest_certificate`: Optional certificate at target_block_height - 1 to avoid a storage lookup
822    ///
823    /// # Returns
824    /// - `Ok(())` if synchronization completed successfully or the validator is already up to date
825    /// - `Err` if there was a communication or storage error
826    #[instrument(level = "debug", skip_all, fields(%chain_id))]
827    pub async fn send_chain_information(
828        &mut self,
829        chain_id: ChainId,
830        target_block_height: BlockHeight,
831        delivery: CrossChainMessageDelivery,
832        latest_certificate: Option<CacheArc<ConfirmedBlockCertificate>>,
833    ) -> Result<(), chain_client::Error> {
834        // Phase 1: Height synchronization
835        let info = if target_block_height.0 > 0 {
836            self.sync_chain_height(chain_id, target_block_height, delivery, latest_certificate)
837                .await?
838        } else {
839            self.initialize_new_chain_on_validator(chain_id).await?
840        };
841
842        // Phase 2: Round synchronization (if needed)
843        // Height synchronization is complete. Now check if we need to synchronize
844        // the consensus round at this height.
845        let (remote_height, remote_round) = (info.next_block_height, info.manager.current_round);
846        let query = ChainInfoQuery::new(chain_id).with_manager_values();
847        let local_info = match self.local_node.handle_chain_info_query(query).await {
848            Ok(response) => response.info,
849            // If we don't have the full chain description locally, we can't help the
850            // validator with round synchronization. This is not an error - the validator
851            // should retry later once the chain is fully initialized locally.
852            Err(LocalNodeError::BlobsNotFound(_)) => {
853                tracing::debug!("local chain description not fully available, skipping round sync");
854                return Ok(());
855            }
856            Err(error) => return Err(error.into()),
857        };
858
859        let manager = local_info.manager;
860        if local_info.next_block_height != remote_height || manager.current_round <= remote_round {
861            return Ok(());
862        }
863
864        // Validator is at our height but behind on consensus round
865        self.sync_consensus_round(remote_round, &manager).await
866    }
867
868    /// Synchronizes a validator to a specific block height by sending the certificates we hold.
869    ///
870    /// Uses an optimistic approach: sends the last certificate first, then, based on the
871    /// validator's reported height, sends the earlier certificates in the range. Only the heights
872    /// we actually have in local storage are sent — any we're missing are silently skipped rather
873    /// than treated as an error, which is what leaves genuine gaps on the validator (see
874    /// [`Self::send_chain_information`] for why that is both safe and intended).
875    ///
876    /// Returns the [`ChainInfo`] from the validator after synchronization.
877    async fn sync_chain_height(
878        &mut self,
879        chain_id: ChainId,
880        target_block_height: BlockHeight,
881        delivery: CrossChainMessageDelivery,
882        latest_certificate: Option<CacheArc<ConfirmedBlockCertificate>>,
883    ) -> Result<Box<ChainInfo>, chain_client::Error> {
884        let height = target_block_height.try_sub_one()?;
885
886        // Get the certificate for the last block we want to send
887        let certificate = if let Some(cert) = latest_certificate {
888            cert
889        } else {
890            self.read_certificates_for_heights(chain_id, vec![height])
891                .await?
892                .into_iter()
893                .next()
894                .ok_or_else(|| {
895                    chain_client::Error::InternalError(
896                        "failed to read latest certificate for height sync",
897                    )
898                })?
899        };
900
901        // Optimistically try sending just the last certificate
902        let info = match self
903            .send_confirmed_certificate(&certificate, delivery)
904            .await
905        {
906            Ok(info) => info,
907            Err(error) => {
908                tracing::debug!(
909                    address = self.remote_node.address(), %error,
910                    "validator failed to handle confirmed certificate; sending whole chain",
911                );
912                let query = ChainInfoQuery::new(chain_id);
913                self.remote_node.handle_chain_info_query(query).await?
914            }
915        };
916
917        // Push a checkpoint if we have one above the validator's tip, to skip past
918        // pre-checkpoint blocks. Mirrors `bootstrap_chain_from_checkpoint`.
919        let info = self
920            .push_checkpoint_if_useful(chain_id, info, delivery)
921            .await?;
922
923        // Calculate which block heights the validator is still missing
924        let heights: Vec<_> = (info.next_block_height.0..target_block_height.0)
925            .map(BlockHeight)
926            .collect();
927
928        if heights.is_empty() {
929            return Ok(info);
930        }
931
932        let batch_size = self.certificate_upload_batch_size;
933        for chunk in heights.chunks(batch_size) {
934            let certificates = self
935                .read_certificates_for_heights(chain_id, chunk.to_vec())
936                .await?;
937
938            for certificate in certificates {
939                self.send_confirmed_certificate(&certificate, delivery)
940                    .await?;
941            }
942        }
943
944        Ok(info)
945    }
946
947    /// Reads certificates for the given heights from local storage.
948    ///
949    /// Heights we don't have are silently dropped: the returned vector contains only the
950    /// certificates actually present, so callers naturally skip any block we never downloaded
951    /// (e.g. a sender's non-message-bearing blocks). Callers must not assume the result covers
952    /// every requested height.
953    async fn read_certificates_for_heights(
954        &self,
955        chain_id: ChainId,
956        heights: Vec<BlockHeight>,
957    ) -> Result<Vec<CacheArc<ConfirmedBlockCertificate>>, chain_client::Error> {
958        let storage = self.local_node.storage_client();
959
960        let certificates_by_height = storage
961            .read_certificates_by_heights(chain_id, &heights)
962            .await?;
963
964        Ok(certificates_by_height.into_iter().flatten().collect())
965    }
966
967    /// If we hold a checkpoint at a height the validator hasn't reached yet, pushes
968    /// the checkpoint certificate so the validator can install our chain's execution
969    /// state without replaying every pre-checkpoint block. The worker's first attempt
970    /// will report any pre-checkpoint sender blocks it doesn't yet have via
971    /// `BlocksNotFound`, which `send_confirmed_certificate` then uploads before
972    /// retrying. Returns the validator's chain info after the push (unchanged if
973    /// there's nothing useful to push).
974    async fn push_checkpoint_if_useful(
975        &mut self,
976        chain_id: ChainId,
977        info: Box<ChainInfo>,
978        delivery: CrossChainMessageDelivery,
979    ) -> Result<Box<ChainInfo>, chain_client::Error> {
980        let local_query = ChainInfoQuery::new(chain_id).with_latest_checkpoint_height();
981        let local_info = self
982            .local_node
983            .handle_chain_info_query(local_query)
984            .await?
985            .info;
986        let Some(checkpoint_height) = local_info.requested_latest_checkpoint_height else {
987            return Ok(info);
988        };
989        if checkpoint_height < info.next_block_height {
990            return Ok(info);
991        }
992        let Some(checkpoint_cert) = self
993            .read_certificates_for_heights(chain_id, vec![checkpoint_height])
994            .await?
995            .into_iter()
996            .next()
997        else {
998            return Ok(info);
999        };
1000        self.send_confirmed_certificate(&checkpoint_cert, delivery)
1001            .await
1002    }
1003
1004    /// Initializes a new chain on the validator by sending the chain description and dependencies.
1005    ///
1006    /// This is called when the validator doesn't know about the chain yet.
1007    ///
1008    /// Returns the [`ChainInfo`] from the validator after initialization.
1009    async fn initialize_new_chain_on_validator(
1010        &self,
1011        chain_id: ChainId,
1012    ) -> Result<Box<ChainInfo>, chain_client::Error> {
1013        // Send chain description and all dependency chains
1014        self.send_chain_info_for_blobs(
1015            &[BlobId::new(chain_id.0, BlobType::ChainDescription)],
1016            CrossChainMessageDelivery::NonBlocking,
1017        )
1018        .await?;
1019
1020        // Query the validator's state for this chain
1021        let query = ChainInfoQuery::new(chain_id);
1022        let info = self.remote_node.handle_chain_info_query(query).await?;
1023        Ok(info)
1024    }
1025
1026    /// Synchronizes the consensus round state with the validator.
1027    ///
1028    /// If the validator is at the same height but an earlier round, sends the evidence
1029    /// (proposal, validated block, or timeout certificate) that justifies the current round.
1030    ///
1031    /// This is a best-effort operation - failures are logged but don't fail the entire sync.
1032    async fn sync_consensus_round(
1033        &self,
1034        remote_round: Round,
1035        manager: &linera_chain::manager::ChainManagerInfo,
1036    ) -> Result<(), chain_client::Error> {
1037        let target_round = manager.current_round;
1038
1039        // First, push the locking certificate if it justifies our current round. A
1040        // locking block from an earlier round is not enough on its own to advance the
1041        // remote: the remote may still be ahead via a timeout or signed proposal, and
1042        // pushing a stale lock would not move them. Push only the current-round lock.
1043        if let Some(LockingBlock::Regular(validated)) = manager.requested_locking.as_deref() {
1044            if validated.round == target_round {
1045                match self
1046                    .remote_node
1047                    .handle_optimized_validated_certificate(
1048                        validated,
1049                        CrossChainMessageDelivery::NonBlocking,
1050                    )
1051                    .await
1052                {
1053                    Ok(info) => {
1054                        tracing::debug!("successfully sent validated block for round sync");
1055                        if info.manager.current_round >= target_round {
1056                            return Ok(());
1057                        }
1058                    }
1059                    Err(error) => {
1060                        tracing::debug!(%error, "failed to send validated block");
1061                    }
1062                }
1063            }
1064        }
1065
1066        // Try to send a timeout certificate. The remote applies `next_round(cert.round)`
1067        // to its current round, which (for the cert we hold) lands at our current round.
1068        if let Some(cert) = &manager.timeout {
1069            if cert.round >= remote_round {
1070                match self
1071                    .remote_node
1072                    .handle_timeout_certificate(cert.as_ref().clone())
1073                    .await
1074                {
1075                    Ok(info) => {
1076                        tracing::debug!(round = %cert.round, "successfully sent timeout certificate");
1077                        if info.manager.current_round >= target_round {
1078                            return Ok(());
1079                        }
1080                    }
1081                    Err(error) => {
1082                        tracing::debug!(%error, round = %cert.round, "failed to send timeout certificate");
1083                    }
1084                }
1085            }
1086        }
1087
1088        // Finally, try to push a proposal at the current round.
1089        for proposal in manager
1090            .requested_proposed
1091            .iter()
1092            .chain(manager.requested_signed_proposal.iter())
1093        {
1094            if proposal.content.round == target_round {
1095                match self
1096                    .remote_node
1097                    .handle_block_proposal(proposal.clone())
1098                    .await
1099                {
1100                    Ok(info) => {
1101                        tracing::debug!("successfully sent block proposal for round sync");
1102                        if info.manager.current_round >= target_round {
1103                            return Ok(());
1104                        }
1105                    }
1106                    Err(error) => {
1107                        tracing::debug!(%error, "failed to send block proposal");
1108                    }
1109                }
1110            }
1111        }
1112
1113        // If we reach here, either we had no round sync data to send, or all attempts failed.
1114        // This is not a fatal error - height sync succeeded which is the primary goal.
1115        tracing::debug!("round sync not performed: no applicable data or all attempts failed");
1116        Ok(())
1117    }
1118
1119    /// Sends chain information for all chains referenced by the given blobs.
1120    ///
1121    /// Reads blob states from storage, determines the specific chain heights needed,
1122    /// and sends chain information for those heights. With sparse chains, this only
1123    /// sends the specific blocks containing the blobs, not all blocks up to those heights.
1124    async fn send_chain_info_for_blobs(
1125        &self,
1126        blob_ids: &[BlobId],
1127        delivery: CrossChainMessageDelivery,
1128    ) -> Result<(), chain_client::Error> {
1129        let blob_states = self
1130            .local_node
1131            .read_blob_states_from_storage(blob_ids)
1132            .await?;
1133
1134        let mut chain_heights: BTreeMap<ChainId, BTreeSet<BlockHeight>> = BTreeMap::new();
1135        for blob_state in blob_states {
1136            match blob_state.origin {
1137                // Genesis blobs aren't published by any block; the recipient has
1138                // them from its own genesis config. Nothing to ship.
1139                BlobOrigin::Genesis => continue,
1140                BlobOrigin::Published {
1141                    chain_id,
1142                    block_height,
1143                } => {
1144                    chain_heights
1145                        .entry(chain_id)
1146                        .or_default()
1147                        .insert(block_height);
1148                }
1149            }
1150        }
1151
1152        self.send_chain_info_at_heights(chain_heights, delivery)
1153            .await
1154    }
1155
1156    /// Sends the blocks at exactly the specified heights on multiple chains.
1157    ///
1158    /// Unlike [`Self::send_chain_info_up_to_heights`], this sends *only* the blocks at the given
1159    /// heights, not the locally-held prefix leading up to them. Use it only when the required
1160    /// blocks are fully self-describing to the validator — e.g. bringing over the specific blocks
1161    /// that carry a set of blobs.
1162    async fn send_chain_info_at_heights(
1163        &self,
1164        chain_heights: impl IntoIterator<Item = (ChainId, BTreeSet<BlockHeight>)>,
1165        delivery: CrossChainMessageDelivery,
1166    ) -> Result<(), chain_client::Error> {
1167        future::try_join_all(chain_heights.into_iter().map(|(chain_id, heights)| {
1168            let mut updater = self.clone();
1169            async move {
1170                // Get all block hashes for this chain at the specified heights in one call
1171                let heights_vec = heights.into_iter().collect::<Vec<_>>();
1172                let certificates = updater
1173                    .local_node
1174                    .storage_client()
1175                    .read_certificates_by_heights(chain_id, &heights_vec)
1176                    .await?
1177                    .into_iter()
1178                    .flatten()
1179                    .collect::<Vec<_>>();
1180
1181                // Send each certificate
1182                for certificate in certificates {
1183                    updater
1184                        .send_confirmed_certificate(&certificate, delivery)
1185                        .await?;
1186                }
1187
1188                Ok::<_, chain_client::Error>(())
1189            }
1190        }))
1191        .await?;
1192        Ok(())
1193    }
1194
1195    /// Brings a validator up to each given `(chain, height)` by pushing the locally-held prefix
1196    /// of that chain (via [`Self::send_chain_information`]), for all chains concurrently.
1197    async fn send_chain_info_up_to_heights(
1198        &self,
1199        chain_heights: impl IntoIterator<Item = (ChainId, BlockHeight)>,
1200        delivery: CrossChainMessageDelivery,
1201    ) -> Result<(), chain_client::Error> {
1202        future::try_join_all(chain_heights.into_iter().map(|(chain_id, height)| {
1203            let mut updater = self.clone();
1204            async move {
1205                updater
1206                    .send_chain_information(chain_id, height, delivery, None)
1207                    .await
1208            }
1209        }))
1210        .await?;
1211        Ok(())
1212    }
1213
1214    pub async fn send_chain_update(
1215        &mut self,
1216        action: CommunicateAction,
1217    ) -> Result<LiteVote, chain_client::Error> {
1218        let chain_id = match &action {
1219            CommunicateAction::SubmitBlock { proposal, .. } => proposal.content.block.chain_id,
1220            CommunicateAction::FinalizeBlock { certificate, .. } => {
1221                certificate.inner().block().header.chain_id
1222            }
1223            CommunicateAction::RequestTimeout { chain_id, .. } => *chain_id,
1224        };
1225        // Send the block proposal, certificate or timeout request and return a vote.
1226        let vote = match action {
1227            CommunicateAction::SubmitBlock {
1228                proposal,
1229                blob_ids,
1230                clock_skew_sender,
1231            } => {
1232                let info = self
1233                    .send_block_proposal(proposal, blob_ids, clock_skew_sender)
1234                    .await?;
1235                info.manager.pending.ok_or_else(|| {
1236                    NodeError::MissingVoteInValidatorResponse("submit a block proposal".into())
1237                })?
1238            }
1239            CommunicateAction::FinalizeBlock {
1240                certificate,
1241                delivery,
1242            } => {
1243                let info = self
1244                    .send_validated_certificate(*certificate, delivery)
1245                    .await?;
1246                info.manager.pending.ok_or_else(|| {
1247                    NodeError::MissingVoteInValidatorResponse("finalize a block".into())
1248                })?
1249            }
1250            CommunicateAction::RequestTimeout { round, height, .. } => {
1251                let info = self.request_timeout(chain_id, round, height).await?;
1252                info.manager.timeout_vote.ok_or_else(|| {
1253                    NodeError::MissingVoteInValidatorResponse("request a timeout".into())
1254                })?
1255            }
1256        };
1257        vote.check(self.remote_node.public_key)?;
1258        Ok(vote)
1259    }
1260}