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    local_node::LocalNodeClient,
35    node::{CrossChainMessageDelivery, NodeError, ValidatorNode},
36    remote_node::RemoteNode,
37    LocalNodeError,
38};
39
40/// The default amount of time we wait for additional validators to contribute
41/// to the result, as a fraction of how long it took to reach a quorum.
42pub const DEFAULT_QUORUM_GRACE_PERIOD: f64 = 0.2;
43
44/// A report of clock skew from a validator, sent before retrying due to `InvalidTimestamp`.
45pub type ClockSkewReport = (ValidatorPublicKey, TimeDelta);
46/// The maximum timeout for requests to a stake-weighted quorum if no quorum is reached.
47const MAX_TIMEOUT: Duration = Duration::from_secs(60 * 60 * 24); // 1 day.
48
49#[cfg(with_metrics)]
50pub(crate) mod metrics {
51    use linera_base::prometheus_util::{
52        exponential_bucket_latencies, register_histogram_vec, register_int_counter_vec,
53    };
54    use prometheus::{HistogramVec, IntCounterVec};
55
56    linera_base::declare_metrics! {
57        /// Requests dispatched to each validator while communicating with a quorum.
58        ///
59        /// Incremented at dispatch rather than on completion, so the series exists even for a
60        /// validator that never answers. Subtracting the responses below from this yields the
61        /// share of requests a validator left unanswered.
62        ///
63        /// The `address` label carries the validator's gRPC URL so that dashboards and alerts can
64        /// name a validator without an out-of-band lookup of its public key. It is functionally
65        /// dependent on `validator`, so it adds no series.
66        pub(super) static QUORUM_REQUESTS: IntCounterVec =
67            register_int_counter_vec(
68                "communicate_with_quorum_requests_total",
69                "Requests dispatched to each validator while communicating with a quorum",
70                &["validator", "address"],
71            );
72
73        /// Responses received from each validator while communicating with a quorum.
74        ///
75        /// The `outcome` label is `before_quorum` when the response arrived while a quorum was
76        /// still outstanding, and `after_quorum` when it only arrived during the grace period
77        /// that follows a quorum being reached. A validator whose responses are consistently
78        /// `after_quorum` is holding up nothing yet, but it is the one that will stall
79        /// confirmation as soon as any other validator degrades.
80        pub(super) static QUORUM_RESPONSES: IntCounterVec =
81            register_int_counter_vec(
82                "communicate_with_quorum_responses_total",
83                "Responses from each validator, by whether a quorum had already been reached",
84                &["validator", "address", "outcome"],
85            );
86
87        /// Time each validator took to respond while communicating with a quorum.
88        pub(super) static QUORUM_RESPONSE_TIME: HistogramVec =
89            register_histogram_vec(
90                "communicate_with_quorum_response_time_ms",
91                "Time taken by each validator to respond while communicating with a quorum, \
92                 in milliseconds",
93                &["validator", "address"],
94                exponential_bucket_latencies(60_000.0),
95            );
96    }
97}
98
99/// Used for `communicate_chain_action`
100#[derive(Clone)]
101pub enum CommunicateAction {
102    SubmitBlock {
103        proposal: Box<BlockProposal>,
104        blob_ids: Vec<BlobId>,
105        /// Channel to report clock skew before sleeping, so the caller can aggregate reports.
106        clock_skew_sender: mpsc::UnboundedSender<ClockSkewReport>,
107    },
108    FinalizeBlock {
109        certificate: Box<ValidatedBlockCertificate>,
110        delivery: CrossChainMessageDelivery,
111    },
112    RequestTimeout {
113        chain_id: ChainId,
114        height: BlockHeight,
115        round: Round,
116    },
117}
118
119impl CommunicateAction {
120    /// The round to which this action pertains.
121    pub fn round(&self) -> Round {
122        match self {
123            CommunicateAction::SubmitBlock { proposal, .. } => proposal.content.round,
124            CommunicateAction::FinalizeBlock { certificate, .. } => certificate.round,
125            CommunicateAction::RequestTimeout { round, .. } => *round,
126        }
127    }
128}
129
130/// Pushes data to a single validator to bring it up to date with the local node.
131///
132/// This deliberately holds a [`LocalNodeClient`] rather than a full client: updating another
133/// node must not mutate the client's own state. When a validator turns out to be *ahead* of the
134/// local node, the updater signals that with [`chain_client::Error::LocalNodeLagging`] and lets
135/// the caller decide whether to pull the missing state.
136pub struct RemoteNodeUpdater<S, N>
137where
138    S: Storage,
139{
140    pub remote_node: RemoteNode<N>,
141    pub local_node: LocalNodeClient<S>,
142    pub admin_chain_id: ChainId,
143    pub certificate_upload_batch_size: usize,
144}
145
146impl<S: Storage + Clone, N: Clone> Clone for RemoteNodeUpdater<S, N> {
147    fn clone(&self) -> Self {
148        RemoteNodeUpdater {
149            remote_node: self.remote_node.clone(),
150            local_node: self.local_node.clone(),
151            admin_chain_id: self.admin_chain_id,
152            certificate_upload_batch_size: self.certificate_upload_batch_size,
153        }
154    }
155}
156
157/// An error result for requests to a stake-weighted quorum.
158#[derive(Error, Debug)]
159pub enum CommunicationError<E: fmt::Debug> {
160    /// No consensus is possible since validators returned different possibilities
161    /// for the next block
162    #[error(
163        "No error but failed to find a consensus block. Consensus threshold: {0}, Proposals: {1:?}"
164    )]
165    NoConsensus(u64, Vec<(u64, usize)>),
166    /// A single error that was returned by a sufficient number of nodes to be trusted as
167    /// valid.
168    #[error("Failed to communicate with a quorum of validators: {0}")]
169    Trusted(E),
170    /// No single error reached the validity threshold so we're returning a sample of
171    /// errors for debugging purposes, together with their weight.
172    #[error("Failed to communicate with a quorum of validators:\n{:#?}", .0)]
173    Sample(Vec<(E, u64)>),
174}
175
176/// Executes a sequence of actions in parallel for all validators.
177///
178/// Tries to stop early when a quorum is reached. If `quorum_grace_period` is specified, other
179/// validators are given additional time to contribute to the result. The grace period is
180/// calculated as a fraction (defaulting to `DEFAULT_QUORUM_GRACE_PERIOD`) of the time taken to
181/// reach quorum.
182pub async fn communicate_with_quorum<'a, A, V, K, F, R, G>(
183    validator_clients: &'a [RemoteNode<A>],
184    committee: &Committee,
185    group_by: G,
186    execute: F,
187    // Grace period as a fraction of time taken to reach quorum.
188    quorum_grace_period: f64,
189) -> Result<(K, Vec<(ValidatorPublicKey, V)>), CommunicationError<NodeError>>
190where
191    A: ValidatorNode + Clone + 'static,
192    F: Clone + Fn(RemoteNode<A>) -> R,
193    R: Future<Output = Result<V, chain_client::Error>> + 'a,
194    G: Fn(&V) -> K,
195    K: Hash + PartialEq + Eq + Clone + 'static,
196    V: 'static,
197{
198    let mut responses: futures::stream::FuturesUnordered<_> = validator_clients
199        .iter()
200        .filter_map(|remote_node| {
201            if committee.weight(&remote_node.public_key) == 0 {
202                // This should not happen but better prevent it because certificates
203                // are not allowed to include votes with weight 0.
204                return None;
205            }
206            let execute = execute.clone();
207            let remote_node = remote_node.clone();
208            #[cfg(with_metrics)]
209            metrics::QUORUM_REQUESTS
210                .with_label_values(&[&remote_node.public_key.to_string(), &remote_node.address()])
211                .inc();
212            Some(async move {
213                let public_key = remote_node.public_key;
214                #[cfg(with_metrics)]
215                let address = remote_node.address();
216                #[cfg(with_metrics)]
217                let request_start = Instant::now();
218                let result = execute(remote_node).await;
219                #[cfg(with_metrics)]
220                metrics::QUORUM_RESPONSE_TIME
221                    .with_label_values(&[&public_key.to_string(), &address])
222                    .observe(request_start.elapsed().as_secs_f64() * 1000.0);
223                (public_key, result)
224            })
225        })
226        .collect();
227
228    let start_time = Instant::now();
229    let mut end_time: Option<Instant> = None;
230    let mut remaining_votes = committee.total_votes();
231    let mut highest_key_score = 0;
232    let mut value_scores: HashMap<K, (u64, Vec<(ValidatorPublicKey, V)>)> = HashMap::new();
233    let mut error_scores = HashMap::new();
234    #[cfg(with_metrics)]
235    let addresses: HashMap<ValidatorPublicKey, String> = validator_clients
236        .iter()
237        .map(|remote_node| (remote_node.public_key, remote_node.address()))
238        .collect();
239
240    'vote_wait: while let Ok(Some((name, result))) = timeout(
241        end_time.map_or(MAX_TIMEOUT, |t| t.saturating_duration_since(Instant::now())),
242        responses.next(),
243    )
244    .await
245    {
246        remaining_votes -= committee.weight(&name);
247        #[cfg(with_metrics)]
248        metrics::QUORUM_RESPONSES
249            .with_label_values(&[
250                &name.to_string(),
251                addresses.get(&name).map_or("", String::as_str),
252                if end_time.is_none() {
253                    "before_quorum"
254                } else {
255                    "after_quorum"
256                },
257            ])
258            .inc();
259        match result {
260            Ok(value) => {
261                let key = group_by(&value);
262                let entry = value_scores.entry(key.clone()).or_insert((0, Vec::new()));
263                entry.0 += committee.weight(&name);
264                entry.1.push((name, value));
265                highest_key_score = highest_key_score.max(entry.0);
266            }
267            Err(err) => {
268                // TODO(#2857): Handle non-remote errors properly.
269                let err = match err {
270                    chain_client::Error::RemoteNodeError(err) => err,
271                    err => NodeError::ResponseHandlingError {
272                        error: err.to_string(),
273                    },
274                };
275                let entry = error_scores.entry(err.clone()).or_insert(0);
276                *entry += committee.weight(&name);
277            }
278        }
279        // If it becomes clear that no key can reach a quorum, break early.
280        if highest_key_score + remaining_votes < committee.quorum_threshold() {
281            break 'vote_wait;
282        }
283
284        // If a key reaches a quorum, wait for the grace period to collect more values
285        // or error information and then stop.
286        if end_time.is_none() && highest_key_score >= committee.quorum_threshold() {
287            end_time = Some(Instant::now() + start_time.elapsed().mul_f64(quorum_grace_period));
288        }
289    }
290
291    let scores = value_scores
292        .values()
293        .map(|(weight, values)| (*weight, values.len()))
294        .collect();
295    // If a key has a quorum, return it with its values.
296    if let Some((key, (_, values))) = value_scores
297        .into_iter()
298        .find(|(_, (score, _))| *score >= committee.quorum_threshold())
299    {
300        return Ok((key, values));
301    }
302
303    let mut sample = error_scores.into_iter().collect::<Vec<_>>();
304    sample.sort_by_key(|(_, score)| std::cmp::Reverse(*score));
305    sample.truncate(4);
306    Err(match sample.as_slice() {
307        [] => CommunicationError::NoConsensus(committee.quorum_threshold(), scores),
308        [(_, score), ..] if *score >= committee.validity_threshold() => {
309            // At least one honest validator returned this error.
310            CommunicationError::Trusted(sample.into_iter().next().unwrap().0)
311        }
312        // Otherwise no specific error is available to report reliably.}
313        _ => CommunicationError::Sample(sample),
314    })
315}
316
317impl<S, N> RemoteNodeUpdater<S, N>
318where
319    S: Storage + Clone + 'static,
320    N: ValidatorNode + Clone + 'static,
321{
322    /// Logs a warning if the error is not an expected part of the protocol flow.
323    fn warn_if_unexpected(&self, err: &NodeError) {
324        if !err.is_expected() {
325            tracing::warn!(
326                remote_node = self.remote_node.address(),
327                %err,
328                "unexpected error from validator",
329            );
330        }
331    }
332
333    #[instrument(
334        level = "trace", skip_all, err(level = Level::DEBUG),
335        fields(chain_id = %certificate.block().header.chain_id)
336    )]
337    /// Sends a single confirmed certificate, uploading its missing dependencies and retrying.
338    async fn send_confirmed_certificate(
339        &mut self,
340        certificate: &CacheArc<ConfirmedBlockCertificate>,
341        delivery: CrossChainMessageDelivery,
342    ) -> Result<Box<ChainInfo>, chain_client::Error> {
343        let mut result = self
344            .remote_node
345            .handle_optimized_confirmed_certificate(certificate, delivery)
346            .await;
347
348        let mut sent_admin_chain = false;
349        let mut sent_blobs = false;
350        let mut sent_blocks = false;
351        loop {
352            match result {
353                Err(NodeError::EventsNotFound(event_ids))
354                    if !sent_admin_chain
355                        && certificate.inner().chain_id() != self.admin_chain_id
356                        && event_ids.iter().all(|event_id| {
357                            event_id.stream_id == StreamId::system(EPOCH_STREAM_NAME)
358                                && event_id.chain_id == self.admin_chain_id
359                        }) =>
360                {
361                    // The validator doesn't have the committee that signed the certificate.
362                    self.update_admin_chain().await?;
363                    sent_admin_chain = true;
364                }
365                Err(NodeError::BlobsNotFound(blob_ids)) if !sent_blobs => {
366                    // The validator is missing the blobs required by the certificate.
367                    let cert: &ConfirmedBlockCertificate = certificate;
368                    self.remote_node.check_blobs_not_found(cert, &blob_ids)?;
369                    // The certificate is confirmed, so the blobs must be in storage.
370                    let maybe_blobs = self.local_node.read_blobs_from_storage(&blob_ids).await?;
371                    let blobs = maybe_blobs.ok_or(NodeError::BlobsNotFound(blob_ids))?;
372                    self.remote_node
373                        .node
374                        .upload_blobs(blobs.into_iter().map(CacheArc::into_std).collect())
375                        .await?;
376                    sent_blobs = true;
377                }
378                Err(NodeError::BlocksNotFound(hashes)) if !sent_blocks => {
379                    // The validator has recorded these hashes as trusted by a
380                    // checkpoint cert it verified, but is missing the actual block
381                    // bytes. Upload each from local storage; the worker's
382                    // trust-mark accept path lets them through regardless of their
383                    // (possibly revoked) epoch.
384                    let storage = self.local_node.storage_client();
385                    let certificates = storage.read_certificates(&hashes).await?;
386                    for (hash, maybe_cert) in hashes.iter().zip(certificates) {
387                        let cert = maybe_cert.ok_or_else(|| {
388                            chain_client::Error::ReadCertificatesError(vec![*hash])
389                        })?;
390                        self.remote_node
391                            .handle_confirmed_certificate(cert, delivery)
392                            .await?;
393                    }
394                    sent_blocks = true;
395                }
396                result => {
397                    if let Err(err) = &result {
398                        self.warn_if_unexpected(err);
399                    }
400                    return Ok(result?);
401                }
402            }
403            result = self
404                .remote_node
405                .handle_confirmed_certificate(certificate.clone(), delivery)
406                .await;
407        }
408    }
409
410    async fn send_validated_certificate(
411        &mut self,
412        certificate: ValidatedBlockCertificate,
413        delivery: CrossChainMessageDelivery,
414    ) -> Result<Box<ChainInfo>, chain_client::Error> {
415        let result = self
416            .remote_node
417            .handle_optimized_validated_certificate(&certificate, delivery)
418            .await;
419
420        let chain_id = certificate.inner().chain_id();
421        match &result {
422            Err(original_err @ NodeError::BlobsNotFound(blob_ids)) => {
423                self.remote_node
424                    .check_blobs_not_found(&certificate, blob_ids)?;
425                // The certificate is for a validated block, i.e. for our locking block.
426                // Take the missing blobs from our local chain manager.
427                let blobs = self
428                    .local_node
429                    .get_locking_blobs(blob_ids, chain_id)
430                    .await?
431                    .ok_or_else(|| original_err.clone())?;
432                self.remote_node.send_pending_blobs(chain_id, blobs).await?;
433            }
434            Err(error) => {
435                self.sync_remote_if_needed(
436                    chain_id,
437                    certificate.round,
438                    certificate.block().header.height,
439                    error,
440                )
441                .await?;
442            }
443            _ => return Ok(result?),
444        }
445        let result = self
446            .remote_node
447            .handle_validated_certificate(certificate)
448            .await;
449        if let Err(err) = &result {
450            self.warn_if_unexpected(err);
451        }
452        Ok(result?)
453    }
454
455    /// Requests a vote for a timeout certificate for the given round from the remote node.
456    ///
457    /// If the remote node is not in that round or at that height yet, sends the chain information
458    /// to update it.
459    async fn request_timeout(
460        &mut self,
461        chain_id: ChainId,
462        round: Round,
463        height: BlockHeight,
464    ) -> Result<Box<ChainInfo>, chain_client::Error> {
465        let query = ChainInfoQuery::new(chain_id).with_timeout(height, round);
466        let result = self
467            .remote_node
468            .handle_chain_info_query(query.clone())
469            .await;
470        if let Err(err) = &result {
471            self.sync_remote_if_needed(chain_id, round, height, err)
472                .await?;
473            self.warn_if_unexpected(err);
474        }
475        Ok(result?)
476    }
477
478    /// Sends chain information to the remote node if it is the one lagging behind.
479    ///
480    /// If the error reveals that the *local* node is behind instead, returns
481    /// [`chain_client::Error::LocalNodeLagging`] carrying the original error: pulling remote
482    /// state is a client-level decision, not something updating another node may do.
483    async fn sync_remote_if_needed(
484        &mut self,
485        chain_id: ChainId,
486        round: Round,
487        height: BlockHeight,
488        error: &NodeError,
489    ) -> Result<(), chain_client::Error> {
490        let address = &self.remote_node.address();
491        match error {
492            NodeError::WrongRound(validator_round) if *validator_round > round => {
493                tracing::debug!(
494                    address, %chain_id, %validator_round, %round,
495                    "validator is at a higher round; local node needs to synchronize",
496                );
497                return Err(chain_client::Error::LocalNodeLagging {
498                    chain_id,
499                    error: Box::new(error.clone()),
500                });
501            }
502            NodeError::UnexpectedBlockHeight {
503                expected_block_height,
504                found_block_height,
505            } if expected_block_height > found_block_height => {
506                tracing::debug!(
507                    address,
508                    %chain_id,
509                    %expected_block_height,
510                    %found_block_height,
511                    "validator is at a higher height; local node needs to synchronize",
512                );
513                return Err(chain_client::Error::LocalNodeLagging {
514                    chain_id,
515                    error: Box::new(error.clone()),
516                });
517            }
518            NodeError::WrongRound(validator_round) if *validator_round < round => {
519                tracing::debug!(
520                    address, %chain_id, %validator_round, %round,
521                    "validator is at a lower round; sending chain info",
522                );
523                self.send_chain_information(
524                    chain_id,
525                    height,
526                    CrossChainMessageDelivery::NonBlocking,
527                    None,
528                )
529                .await?;
530            }
531            NodeError::UnexpectedBlockHeight {
532                expected_block_height,
533                found_block_height,
534            } if expected_block_height < found_block_height => {
535                tracing::debug!(
536                    address,
537                    %chain_id,
538                    %expected_block_height,
539                    %found_block_height,
540                    "Validator is at a lower height; sending chain info.",
541                );
542                self.send_chain_information(
543                    chain_id,
544                    height,
545                    CrossChainMessageDelivery::NonBlocking,
546                    None,
547                )
548                .await?;
549            }
550            NodeError::InactiveChain(inactive_chain_id) => {
551                tracing::debug!(
552                    address,
553                    chain_id = %inactive_chain_id,
554                    "Validator has inactive chain; sending chain info.",
555                );
556                self.send_chain_information(
557                    *inactive_chain_id,
558                    height,
559                    CrossChainMessageDelivery::NonBlocking,
560                    None,
561                )
562                .await?;
563            }
564            _ => {}
565        }
566        Ok(())
567    }
568
569    async fn send_block_proposal(
570        &mut self,
571        proposal: Box<BlockProposal>,
572        mut blob_ids: Vec<BlobId>,
573        clock_skew_sender: mpsc::UnboundedSender<ClockSkewReport>,
574    ) -> Result<Box<ChainInfo>, chain_client::Error> {
575        let chain_id = proposal.content.block.chain_id;
576        // One-shot guards: `synced_cross_chain_updates` for the missing-bundles path,
577        // `synced_round_and_height` for the round/height mismatch path.
578        let mut synced_cross_chain_updates = false;
579        let mut synced_round_and_height = false;
580        let mut publisher_chain_ids_sent = BTreeSet::new();
581        let storage = self.local_node.storage_client();
582        loop {
583            let local_time = storage.clock().current_time();
584            match self
585                .remote_node
586                .handle_block_proposal(proposal.clone())
587                .await
588            {
589                Ok(info) => return Ok(info),
590                Err(err @ (NodeError::WrongRound(_) | NodeError::UnexpectedBlockHeight { .. }))
591                    if !synced_round_and_height =>
592                {
593                    // The validator disagrees with the proposal's round or height. If it is
594                    // behind, `sync_remote_if_needed` pushes the chain and we retry; if it is
595                    // ahead, the `LocalNodeLagging` signal propagates so the caller can pull
596                    // its state and rebuild the proposal. One-shot: if the validator still
597                    // disagrees after a sync, retrying would not make progress.
598                    synced_round_and_height = true;
599                    tracing::debug!(
600                        remote_node = self.remote_node.address(),
601                        %chain_id,
602                        %err,
603                        "validator disagrees on round or height; synchronizing",
604                    );
605                    self.sync_remote_if_needed(
606                        chain_id,
607                        proposal.content.round,
608                        proposal.content.block.height,
609                        &err,
610                    )
611                    .await?;
612                }
613                // The validator reports *every* missing cross-chain bundle in a single
614                // `MissingCrossChainUpdates`, so we sync all of them at once and retry. Some
615                // received certificates may be missing for this validator (e.g. to create the
616                // chain or make the balance sufficient). If it still reports missing bundles
617                // after we synced the whole set, retrying would not make progress, so we surface
618                // the error instead of looping.
619                Err(NodeError::MissingCrossChainUpdates {
620                    chain_id: dependencies_chain_id,
621                    bundles,
622                }) if dependencies_chain_id == proposal.content.block.chain_id => {
623                    ensure!(
624                        !synced_cross_chain_updates,
625                        NodeError::ResponseHandlingError {
626                            error: format!(
627                                "validator still reports missing cross-chain updates for chain \
628                                 {dependencies_chain_id} after they were all synced"
629                            ),
630                        }
631                    );
632                    synced_cross_chain_updates = true;
633                    tracing::debug!(
634                        remote_node = %self.remote_node.address(),
635                        %chain_id,
636                        bundles = bundles.len(),
637                        "validator reported missing cross-chain updates; syncing them in one batch",
638                    );
639                    // Sync each reported origin chain up to the needed height, collapsing any
640                    // duplicate origins to the highest height.
641                    let mut origin_heights: BTreeMap<ChainId, BlockHeight> = BTreeMap::new();
642                    for (origin, height) in bundles {
643                        let target = height.try_add_one()?;
644                        let entry = origin_heights.entry(origin).or_insert(target);
645                        *entry = (*entry).max(target);
646                    }
647                    self.send_chain_info_up_to_heights(
648                        origin_heights,
649                        CrossChainMessageDelivery::Blocking,
650                    )
651                    .await?;
652                }
653                Err(NodeError::EventsNotFound(event_ids)) => {
654                    let mut publisher_heights = BTreeMap::new();
655                    let chain_ids = event_ids
656                        .iter()
657                        .map(|event_id| event_id.chain_id)
658                        .filter(|chain_id| !publisher_chain_ids_sent.contains(chain_id))
659                        .collect::<BTreeSet<_>>();
660                    tracing::debug!(
661                        remote_node = self.remote_node.address(),
662                        ?chain_ids,
663                        "missing events; sending chains to validator",
664                    );
665                    ensure!(!chain_ids.is_empty(), NodeError::EventsNotFound(event_ids));
666                    for chain_id in chain_ids {
667                        let height = self
668                            .local_node
669                            .get_next_height_to_preprocess(chain_id)
670                            .await?;
671                        publisher_heights.insert(chain_id, height);
672                        publisher_chain_ids_sent.insert(chain_id);
673                    }
674                    self.send_chain_info_up_to_heights(
675                        publisher_heights,
676                        CrossChainMessageDelivery::NonBlocking,
677                    )
678                    .await?;
679                }
680                Err(error @ NodeError::ChainError { .. }) => {
681                    // The validator rejected the proposal because of its local chain
682                    // manager state — most commonly an incompatible confirmed vote tied
683                    // to a locking block we don't yet have. The caller should pull
684                    // manager values from this validator so the local node absorbs
685                    // whatever justified the rejection; if the local state actually
686                    // advances, `execute_operations` will rebuild and re-propose; if
687                    // not, the error propagates as usual.
688                    self.warn_if_unexpected(&error);
689                    tracing::debug!(
690                        remote_node = self.remote_node.address(),
691                        %chain_id,
692                        %error,
693                        "validator rejected proposal; manager state needs to be pulled",
694                    );
695                    return Err(chain_client::Error::LocalNodeLagging {
696                        chain_id,
697                        error: Box::new(error),
698                    });
699                }
700                Err(NodeError::BlobsNotFound(_) | NodeError::InactiveChain(_))
701                    if !blob_ids.is_empty() =>
702                {
703                    tracing::debug!("Missing blobs");
704                    // For `BlobsNotFound`, we assume that the local node should already be
705                    // updated with the needed blobs, so sending the chain information about the
706                    // certificates that last used the blobs to the validator node should be enough.
707                    let published_blob_ids =
708                        BTreeSet::from_iter(proposal.content.block.published_blob_ids());
709                    blob_ids.retain(|blob_id| !published_blob_ids.contains(blob_id));
710                    let published_blobs = self
711                        .local_node
712                        .get_proposed_blobs(chain_id, published_blob_ids.into_iter().collect())
713                        .await?;
714                    self.remote_node
715                        .send_pending_blobs(chain_id, published_blobs)
716                        .await?;
717                    let missing_blob_ids = self
718                        .remote_node
719                        .node
720                        .missing_blob_ids(mem::take(&mut blob_ids))
721                        .await?;
722
723                    tracing::debug!("Sending chains for missing blobs");
724                    self.send_chain_info_for_blobs(
725                        &missing_blob_ids,
726                        CrossChainMessageDelivery::NonBlocking,
727                    )
728                    .await?;
729                }
730                Err(NodeError::InvalidTimestamp {
731                    block_timestamp,
732                    local_time: validator_local_time,
733                    ..
734                }) => {
735                    // The validator's clock is behind the block's timestamp. We need to
736                    // wait for two things:
737                    // 1. Our clock to reach block_timestamp (in case the block timestamp
738                    //    is in the future from our perspective too).
739                    // 2. The validator's clock to catch up (in case of clock skew between
740                    //    us and the validator).
741                    let clock_skew = local_time.delta_since(validator_local_time);
742                    tracing::debug!(
743                        remote_node = self.remote_node.address(),
744                        %chain_id,
745                        %block_timestamp,
746                        ?clock_skew,
747                        "validator's clock is behind; waiting and retrying",
748                    );
749                    // Report the clock skew before sleeping so the caller can aggregate.
750                    // Receiver may have been dropped if the caller is no longer interested.
751                    clock_skew_sender
752                        .send((self.remote_node.public_key, clock_skew))
753                        .ok();
754                    storage
755                        .clock()
756                        .sleep_until(block_timestamp.saturating_add(clock_skew))
757                        .await;
758                }
759                // Fail immediately on other errors.
760                Err(err) => {
761                    self.warn_if_unexpected(&err);
762                    return Err(err.into());
763                }
764            }
765        }
766    }
767
768    async fn update_admin_chain(&mut self) -> Result<(), chain_client::Error> {
769        let local_admin_info = self.local_node.chain_info(self.admin_chain_id).await?;
770        let admin_chain_id = self.admin_chain_id;
771        let target = local_admin_info.next_block_height;
772        Box::pin(self.send_chain_information(
773            admin_chain_id,
774            target,
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}