Skip to main content

consensus_core/
round_prober.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! RoundProber periodically checks each peer for the latest rounds they received and accepted
5//! from others. This provides insight into how effectively each authority's blocks are propagated
6//! and accepted across the network.
7//!
8//! Unlike inferring accepted rounds from the DAG of each block, RoundProber has the benefit that
9//! it remains active even when peers are not proposing. This makes it essential for determining
10//! when to disable optimizations that improve DAG quality but may compromise liveness.
11//!
12//! RoundProber's data sources include the `highest_received_rounds` & `highest_accepted_rounds` tracked
13//! by the CoreThreadDispatcher and DagState. The received rounds are updated after blocks are verified
14//! but before checking for dependencies. This should make the values more indicative of how well authorities
15//! propagate blocks, and less influenced by the quality of ancestors in the proposed blocks. The
16//! accepted rounds are updated after checking for dependencies which should indicate the quality
17//! of the proposed blocks including its ancestors.
18
19use std::{sync::Arc, time::Duration};
20
21use consensus_config::Stake;
22use consensus_types::block::Round;
23use futures::stream::{FuturesUnordered, StreamExt as _};
24use mysten_common::sync::notify_once::NotifyOnce;
25use mysten_metrics::monitored_scope;
26use parking_lot::RwLock;
27use tokio::{task::JoinHandle, time::MissedTickBehavior};
28
29use crate::{
30    BlockAPI as _, ConsumerMonitor, context::Context, core_thread::CoreThreadDispatcher,
31    dag_state::DagState, network::ValidatorNetworkClient, round_tracker::RoundTracker,
32    task::join_and_propagate_panic,
33};
34
35// Handle to control the RoundProber loop and read latest round gaps.
36pub(crate) struct RoundProberHandle {
37    prober_task: JoinHandle<()>,
38    shutdown_notify: Arc<NotifyOnce>,
39}
40
41impl RoundProberHandle {
42    pub(crate) async fn stop(self) {
43        let _ = self.shutdown_notify.notify();
44        // Do not abort prober task, which waits for requests to be cancelled.
45        join_and_propagate_panic(self.prober_task).await;
46    }
47}
48
49pub(crate) struct RoundProber<C: ValidatorNetworkClient> {
50    context: Arc<Context>,
51    core_thread_dispatcher: Arc<dyn CoreThreadDispatcher>,
52    round_tracker: Arc<RwLock<RoundTracker>>,
53    dag_state: Arc<RwLock<DagState>>,
54    network_client: Arc<C>,
55    consumer_monitor: Arc<ConsumerMonitor>,
56    shutdown_notify: Arc<NotifyOnce>,
57}
58
59impl<C: ValidatorNetworkClient> RoundProber<C> {
60    pub(crate) fn new(
61        context: Arc<Context>,
62        core_thread_dispatcher: Arc<dyn CoreThreadDispatcher>,
63        round_tracker: Arc<RwLock<RoundTracker>>,
64        dag_state: Arc<RwLock<DagState>>,
65        network_client: Arc<C>,
66        consumer_monitor: Arc<ConsumerMonitor>,
67    ) -> Self {
68        Self {
69            context,
70            core_thread_dispatcher,
71            round_tracker,
72            dag_state,
73            network_client,
74            consumer_monitor,
75            shutdown_notify: Arc::new(NotifyOnce::new()),
76        }
77    }
78
79    pub(crate) fn start(self) -> RoundProberHandle {
80        let shutdown_notify = self.shutdown_notify.clone();
81        let loop_shutdown_notify = shutdown_notify.clone();
82        let prober_task = tokio::spawn(async move {
83            // With 200 validators, this would result in 200 * 4 * 200 / 2 = 80KB of additional
84            // bandwidth usage per sec. We can consider using adaptive intervals, for example
85            // 10s by default but reduced to 2s when the propagation delay is higher.
86            let mut interval = tokio::time::interval(Duration::from_millis(
87                self.context.parameters.round_prober_interval_ms,
88            ));
89            interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
90            loop {
91                tokio::select! {
92                    _ = interval.tick() => {
93                        self.probe().await;
94                    }
95                    _ = loop_shutdown_notify.wait() => {
96                        break;
97                    }
98                }
99            }
100        });
101        RoundProberHandle {
102            prober_task,
103            shutdown_notify,
104        }
105    }
106
107    // Probes each peer for the latest rounds they received from others.
108    // Returns the propagation delay of own blocks.
109    pub(crate) async fn probe(&self) -> Round {
110        let _scope = monitored_scope("RoundProber");
111
112        let node_metrics = &self.context.metrics.node_metrics;
113        let request_timeout =
114            Duration::from_millis(self.context.parameters.round_prober_request_timeout_ms);
115        let own_index = self.context.own_index;
116        let mut requests = FuturesUnordered::new();
117
118        for (peer, _) in self.context.committee.authorities() {
119            if peer == own_index {
120                continue;
121            }
122            let network_client = self.network_client.clone();
123            requests.push(async move {
124                let result = tokio::time::timeout(
125                    request_timeout,
126                    network_client.get_latest_rounds(peer, request_timeout),
127                )
128                .await;
129                (peer, result)
130            });
131        }
132
133        let mut highest_received_rounds =
134            vec![vec![0; self.context.committee.size()]; self.context.committee.size()];
135        let mut highest_accepted_rounds =
136            vec![vec![0; self.context.committee.size()]; self.context.committee.size()];
137
138        let blocks = self
139            .dag_state
140            .read()
141            .get_last_cached_block_per_authority(Round::MAX);
142        let local_highest_accepted_rounds = blocks
143            .into_iter()
144            .map(|(block, _)| block.round())
145            .collect::<Vec<_>>();
146        let last_proposed_round = local_highest_accepted_rounds[own_index];
147
148        // For our own index, the highest received & accepted round is our last
149        // accepted round or our last proposed round.
150        highest_received_rounds[own_index] =
151            self.round_tracker.read().local_highest_received_rounds();
152        highest_accepted_rounds[own_index] = local_highest_accepted_rounds;
153        highest_received_rounds[own_index][own_index] = last_proposed_round;
154        highest_accepted_rounds[own_index][own_index] = last_proposed_round;
155
156        // Stake of the peers whose request succeeded in this probe. This is reported to the
157        // consumer monitor as a reachability signal and is not used by consensus.
158        let mut responsive_stake: Stake = 0;
159
160        loop {
161            tokio::select! {
162                result = requests.next() => {
163                    let Some((peer, result)) = result else { break };
164                    let peer_name = &self.context.committee.authority(peer).hostname;
165                    match result {
166                        Ok(Ok((received, accepted))) => {
167                            responsive_stake += self.context.committee.stake(peer);
168                            if received.len() == self.context.committee.size()
169                            {
170                                highest_received_rounds[peer] = received;
171                            } else {
172                                node_metrics.round_prober_request_errors.with_label_values(&["invalid_received_rounds"]).inc();
173                                tracing::warn!("Received invalid number of received rounds from peer {}", peer_name);
174                            }
175
176                            if accepted.len() == self.context.committee.size() {
177                                highest_accepted_rounds[peer] = accepted;
178                            } else {
179                                node_metrics.round_prober_request_errors.with_label_values(&["invalid_accepted_rounds"]).inc();
180                                tracing::warn!("Received invalid number of accepted rounds from peer {}", peer_name);
181                            }
182                        },
183                        // When a request fails, the highest received rounds from that authority will be 0
184                        // for the subsequent computations.
185                        // For propagation delay, this behavior is desirable because the computed delay
186                        // increases as this authority has more difficulty communicating with peers. Logic
187                        // triggered by high delay should usually be triggered with frequent probing failures
188                        // as well.
189                        // For quorum rounds computed for peer, this means the values should be used for
190                        // positive signals (peer A can propagate its blocks well) rather than negative signals
191                        // (peer A cannot propagate its blocks well). It can be difficult to distinguish between
192                        // own probing failures and actual propagation issues.
193                        Ok(Err(err)) => {
194                            node_metrics.round_prober_request_errors.with_label_values(&["failed_fetch"]).inc();
195                            tracing::debug!("Failed to get latest rounds from peer {}: {:?}", peer_name, err);
196                        },
197                        Err(_) => {
198                            node_metrics.round_prober_request_errors.with_label_values(&["timeout"]).inc();
199                            tracing::debug!("Timeout while getting latest rounds from peer {}", peer_name);
200                        },
201                    }
202                }
203                _ = self.shutdown_notify.wait() => break,
204            }
205        }
206
207        self.round_tracker
208            .write()
209            .update_from_probe(highest_accepted_rounds, highest_received_rounds);
210        let propagation_delay = self
211            .round_tracker
212            .read()
213            .calculate_propagation_delay(last_proposed_round);
214
215        let _ = self
216            .core_thread_dispatcher
217            .set_propagation_delay(propagation_delay);
218
219        // A probe that shutdown interrupted has an incomplete responsive stake, which a reader
220        // would interpret as peers not responding. Skip the report in that case and keep the
221        // last complete probe.
222        if requests.is_empty() {
223            let quorum_accepted_round = self.round_tracker.read().highest_accepted_quorum_round();
224            self.consumer_monitor.report_probe(
225                quorum_accepted_round,
226                responsive_stake,
227                self.context.clock.timestamp_utc_ms(),
228            );
229        }
230
231        propagation_delay
232    }
233}
234
235#[cfg(test)]
236mod test {
237    use std::{
238        collections::BTreeSet,
239        sync::{
240            Arc,
241            atomic::{AtomicBool, Ordering},
242        },
243        time::Duration,
244    };
245
246    use async_trait::async_trait;
247    use bytes::Bytes;
248    use consensus_config::AuthorityIndex;
249    use consensus_types::block::{BlockRef, Round};
250    use parking_lot::RwLock;
251
252    use crate::{
253        ConsumerMonitor, TestBlock, VerifiedBlock,
254        commit::{CertifiedCommits, CommitRange},
255        context::Context,
256        core_thread::{CoreError, CoreThreadDispatcher},
257        dag_state::DagState,
258        error::{ConsensusError, ConsensusResult},
259        network::{BlockStream, ValidatorNetworkClient},
260        round_prober::RoundProber,
261        round_tracker::RoundTracker,
262        storage::mem_store::MemStore,
263    };
264
265    struct FakeThreadDispatcher {}
266
267    impl FakeThreadDispatcher {
268        fn new() -> Self {
269            Self {}
270        }
271    }
272
273    #[async_trait]
274    impl CoreThreadDispatcher for FakeThreadDispatcher {
275        async fn add_blocks(
276            &self,
277            _blocks: Vec<VerifiedBlock>,
278        ) -> Result<BTreeSet<BlockRef>, CoreError> {
279            unimplemented!()
280        }
281
282        async fn check_block_refs(
283            &self,
284            _block_refs: Vec<BlockRef>,
285        ) -> Result<BTreeSet<BlockRef>, CoreError> {
286            unimplemented!()
287        }
288
289        async fn add_certified_commits(
290            &self,
291            _commits: CertifiedCommits,
292        ) -> Result<BTreeSet<BlockRef>, CoreError> {
293            unimplemented!()
294        }
295
296        async fn new_block(&self, _round: Round, _force: bool) -> Result<(), CoreError> {
297            unimplemented!()
298        }
299
300        async fn get_missing_blocks(&self) -> Result<BTreeSet<BlockRef>, CoreError> {
301            unimplemented!()
302        }
303
304        fn set_propagation_delay(&self, _propagation_delay: Round) -> Result<(), CoreError> {
305            Ok(())
306        }
307
308        fn set_last_known_proposed_round(&self, _round: Round) -> Result<(), CoreError> {
309            unimplemented!()
310        }
311    }
312
313    struct FakeNetworkClient {
314        highest_received_rounds: RwLock<Vec<Vec<Round>>>,
315        highest_accepted_rounds: RwLock<Vec<Vec<Round>>>,
316        // When set, requests never complete.
317        hanging: AtomicBool,
318    }
319
320    impl FakeNetworkClient {
321        fn new(
322            highest_received_rounds: Vec<Vec<Round>>,
323            highest_accepted_rounds: Vec<Vec<Round>>,
324        ) -> Self {
325            Self {
326                highest_received_rounds: RwLock::new(highest_received_rounds),
327                highest_accepted_rounds: RwLock::new(highest_accepted_rounds),
328                hanging: AtomicBool::new(false),
329            }
330        }
331
332        // Replaces the rounds returned to later probes. A peer with empty rounds fails its request.
333        fn set_rounds(
334            &self,
335            highest_received_rounds: Vec<Vec<Round>>,
336            highest_accepted_rounds: Vec<Vec<Round>>,
337        ) {
338            *self.highest_received_rounds.write() = highest_received_rounds;
339            *self.highest_accepted_rounds.write() = highest_accepted_rounds;
340        }
341    }
342
343    #[async_trait]
344    impl ValidatorNetworkClient for FakeNetworkClient {
345        #[cfg(not(msim))]
346        async fn send_block(
347            &self,
348            _peer: AuthorityIndex,
349            _serialized_block: &VerifiedBlock,
350            _timeout: Duration,
351        ) -> ConsensusResult<()> {
352            unimplemented!("Unimplemented")
353        }
354
355        async fn subscribe_blocks(
356            &self,
357            _peer: AuthorityIndex,
358            _last_received: Round,
359            _timeout: Duration,
360        ) -> ConsensusResult<BlockStream> {
361            unimplemented!("Unimplemented")
362        }
363
364        async fn fetch_blocks(
365            &self,
366            _peer: AuthorityIndex,
367            _block_refs: Vec<BlockRef>,
368            _fetch_after_rounds: Vec<Round>,
369            _fetch_missing_ancestors: bool,
370            _timeout: Duration,
371        ) -> ConsensusResult<Vec<Bytes>> {
372            unimplemented!("Unimplemented")
373        }
374
375        async fn fetch_commits(
376            &self,
377            _peer: AuthorityIndex,
378            _commit_range: CommitRange,
379            _timeout: Duration,
380        ) -> ConsensusResult<(Vec<Bytes>, Vec<Bytes>)> {
381            unimplemented!("Unimplemented")
382        }
383
384        async fn fetch_latest_blocks(
385            &self,
386            _peer: AuthorityIndex,
387            _authorities: Vec<AuthorityIndex>,
388            _timeout: Duration,
389        ) -> ConsensusResult<Vec<Bytes>> {
390            unimplemented!("Unimplemented")
391        }
392
393        async fn get_latest_rounds(
394            &self,
395            peer: AuthorityIndex,
396            _timeout: Duration,
397        ) -> ConsensusResult<(Vec<Round>, Vec<Round>)> {
398            if self.hanging.load(Ordering::Relaxed) {
399                return std::future::pending().await;
400            }
401            let received_rounds = self.highest_received_rounds.read()[peer].clone();
402            let accepted_rounds = self.highest_accepted_rounds.read()[peer].clone();
403            if received_rounds.is_empty() && accepted_rounds.is_empty() {
404                Err(ConsensusError::NetworkRequestTimeout("test".to_string()))
405            } else {
406                Ok((received_rounds, accepted_rounds))
407            }
408        }
409    }
410
411    #[tokio::test]
412    async fn test_round_prober() {
413        telemetry_subscribers::init_for_testing();
414        const NUM_AUTHORITIES: usize = 7;
415        let context = Arc::new(Context::new_for_test(NUM_AUTHORITIES).0);
416        let core_thread_dispatcher = Arc::new(FakeThreadDispatcher::new());
417        let store = Arc::new(MemStore::new());
418        let dag_state = Arc::new(RwLock::new(DagState::new(context.clone(), store)));
419        // Have some peers return error or incorrect number of rounds.
420        let network_client = Arc::new(FakeNetworkClient::new(
421            vec![
422                vec![],
423                vec![109, 121, 131, 0, 151, 161, 171],
424                vec![101, 0, 103, 104, 105, 166, 107],
425                vec![],
426                vec![100, 102, 133, 0, 155, 106, 177],
427                vec![105, 115, 103, 0, 125, 126, 127],
428                vec![10, 20, 30, 40, 50, 60],
429            ], // highest_received_rounds
430            vec![
431                vec![],
432                vec![0, 121, 131, 0, 151, 161, 171],
433                vec![1, 0, 103, 104, 105, 166, 107],
434                vec![],
435                vec![0, 102, 133, 0, 155, 106, 177],
436                vec![1, 115, 103, 0, 125, 126, 127],
437                vec![1, 20, 30, 40, 50, 60],
438            ], // highest_accepted_rounds
439        ));
440
441        // Initialize RoundTracker with the local highest_received_rounds
442        let round_tracker = Arc::new(RwLock::new(RoundTracker::new(
443            context.clone(),
444            vec![110, 120, 130, 140, 150, 160, 170],
445        )));
446        let consumer_monitor = Arc::new(ConsumerMonitor::new(0, Some(0)));
447        let prober = RoundProber::new(
448            context.clone(),
449            core_thread_dispatcher.clone(),
450            round_tracker.clone(),
451            dag_state.clone(),
452            network_client.clone(),
453            consumer_monitor.clone(),
454        );
455
456        // Create test blocks for each authority with incrementing rounds starting at 110
457        let blocks = (0..NUM_AUTHORITIES)
458            .map(|authority| {
459                let round = 110 + (authority as u32 * 10);
460                VerifiedBlock::new_for_test(TestBlock::new(round, authority as u32).build())
461            })
462            .collect::<Vec<_>>();
463
464        dag_state.write().accept_blocks(blocks);
465
466        // Compute quorum rounds and propagation delay based on last proposed round = 110,
467        // and highest received rounds:
468        // 110, 120, 130, 140, 150, 160, 170,
469        // 109, 121, 131, 0,   151, 161, 171,
470        // 101, 0,   103, 104, 105, 166, 107,
471        // 0,   0,   0,   0,   0,   0,   0,
472        // 100, 102, 133, 0,   155, 106, 177,
473        // 105, 115, 103, 0,   125, 126, 127,
474        // 0,   0,   0,   0,   0,   0,   0,
475
476        let propagation_delay = prober.probe().await;
477
478        assert_eq!(propagation_delay, 10);
479
480        // Peers 1, 2, 4, 5 and 6 answered the probe, peer 3 failed and peer 0 is this node.
481        // Peer 6 answered with malformed rounds, which still counts as responsive.
482        // With a quorum of 5, authority 6 has the highest accepted quorum round:
483        // its rounds 177, 171, 170, 127, 107, 0, 0 give a low bound of 107.
484        let peer_progress = consumer_monitor.peer_progress();
485        assert_eq!(peer_progress.responsive_stake, 5);
486        assert_eq!(peer_progress.quorum_accepted_round, 107);
487        assert_eq!(peer_progress.quorum_commit_index, 0);
488        assert!(peer_progress.probed_at_ms > 0);
489    }
490
491    #[tokio::test(flavor = "current_thread", start_paused = true)]
492    async fn round_prober_publishes_peer_progress() {
493        telemetry_subscribers::init_for_testing();
494        const NUM_AUTHORITIES: usize = 4;
495        let context = Arc::new(Context::new_for_test(NUM_AUTHORITIES).0);
496        let core_thread_dispatcher = Arc::new(FakeThreadDispatcher::new());
497        let store = Arc::new(MemStore::new());
498        let dag_state = Arc::new(RwLock::new(DagState::new(context.clone(), store)));
499        let round_tracker = Arc::new(RwLock::new(RoundTracker::new(context.clone(), vec![])));
500        let consumer_monitor = Arc::new(ConsumerMonitor::new(0, Some(0)));
501
502        // This node has accepted round 20 blocks from every authority.
503        let blocks = (0..NUM_AUTHORITIES)
504            .map(|authority| {
505                VerifiedBlock::new_for_test(TestBlock::new(20, authority as u32).build())
506            })
507            .collect::<Vec<_>>();
508        dag_state.write().accept_blocks(blocks);
509
510        // Every peer reports `round` for every authority. This node's own entry is unused.
511        let answers = |round: Round| {
512            vec![
513                vec![],
514                vec![round; NUM_AUTHORITIES],
515                vec![round; NUM_AUTHORITIES],
516                vec![round; NUM_AUTHORITIES],
517            ]
518        };
519        let network_client = Arc::new(FakeNetworkClient::new(answers(12), answers(12)));
520        let prober = RoundProber::new(
521            context.clone(),
522            core_thread_dispatcher,
523            round_tracker,
524            dag_state,
525            network_client.clone(),
526            consumer_monitor.clone(),
527        );
528
529        // All peers answer. With a quorum of 3, this node reports 20 and three peers report 12
530        // for every authority, so 12 is the highest round accepted by a quorum.
531        prober.probe().await;
532        let first = consumer_monitor.peer_progress();
533        assert_eq!(first.quorum_accepted_round, 12);
534        assert_eq!(first.responsive_stake, 3);
535        assert_eq!(first.quorum_commit_index, 0);
536        assert!(first.probed_at_ms > 0);
537
538        // All peers fail. No stake is responsive and the probe time advances. Only this node
539        // reports accepted rounds, which is less than a quorum, so the quorum round is 0.
540        network_client.set_rounds(vec![vec![]; NUM_AUTHORITIES], vec![vec![]; NUM_AUTHORITIES]);
541        tokio::time::advance(Duration::from_millis(1_000)).await;
542        prober.probe().await;
543        let second = consumer_monitor.peer_progress();
544        assert_eq!(second.quorum_accepted_round, 0);
545        assert_eq!(second.responsive_stake, 0);
546        assert!(second.probed_at_ms >= first.probed_at_ms + 1_000);
547
548        // Peers 1 and 2 answer with round 30 and peer 3 fails. Rounds per authority are 20 from
549        // this node, 30, 30 and 0, so the quorum low bound is 20 and the responsive stake is 2.
550        let mut partial = answers(30);
551        partial[3] = vec![];
552        network_client.set_rounds(partial.clone(), partial);
553        tokio::time::advance(Duration::from_millis(1_000)).await;
554        prober.probe().await;
555        let third = consumer_monitor.peer_progress();
556        assert_eq!(third.quorum_accepted_round, 20);
557        assert_eq!(third.responsive_stake, 2);
558        assert!(third.probed_at_ms >= second.probed_at_ms + 1_000);
559
560        // Peers stop answering and shutdown interrupts the next probe while requests are
561        // pending. The interrupted probe is not reported.
562        network_client.hanging.store(true, Ordering::Relaxed);
563        tokio::time::advance(Duration::from_millis(1_000)).await;
564        let probe = prober.probe();
565        tokio::pin!(probe);
566        assert!(futures::poll!(&mut probe).is_pending());
567        prober.shutdown_notify.notify().unwrap();
568        probe.await;
569        assert_eq!(consumer_monitor.peer_progress(), third);
570    }
571}