Skip to main content

sui_core/validator_client_monitor/
monitor.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::authority_aggregator::AuthorityAggregator;
5use crate::authority_client::AuthorityAPI;
6use crate::validator_client_monitor::stats::ClientObservedStats;
7use crate::validator_client_monitor::{
8    OperationFeedback, OperationType, TransactionClass, metrics::ValidatorClientMetrics,
9};
10use arc_swap::ArcSwap;
11use parking_lot::RwLock;
12use rand::seq::SliceRandom;
13use std::collections::HashMap;
14use std::{
15    sync::{
16        Arc,
17        atomic::{AtomicBool, Ordering},
18    },
19    time::{Duration, Instant},
20};
21use sui_config::validator_client_monitor_config::ValidatorClientMonitorConfig;
22use sui_types::committee::{Committee, CommitteeTrait as _};
23use sui_types::{
24    base_types::{AuthorityName, ConciseableName as _},
25    messages_grpc::ValidatorHealthRequest,
26};
27use tokio::{
28    sync::Notify,
29    task::JoinSet,
30    time::{interval, timeout},
31};
32use tracing::{debug, info, warn};
33
34/// Minimum spacing between health-check rounds run on request, so a burst of
35/// staggered-submission rejections costs one extra round rather than one per
36/// rejection.
37const REQUESTED_HEALTH_CHECK_SPACING: Duration = Duration::from_secs(2);
38/// Requested rounds allowed between two scheduled rounds. Requests are honored only
39/// while the staggering view is off, so this caps what a validator that rejects
40/// while the network is not staggering can cost: two extra rounds per interval.
41/// Two rather than one so that a round run on the first rejection, before every
42/// validator has processed the activating commit, gets one follow-up for the
43/// laggards before the driver falls back to waiting for the scheduled round.
44const REQUESTED_HEALTH_CHECKS_PER_INTERVAL: u32 = 2;
45
46/// Monitors validator interactions from the client's perspective.
47///
48/// This component:
49/// - Collects client-side metrics from TransactionDriver operations
50/// - Runs periodic health checks on all validators from the client
51/// - Maintains client-observed statistics for reliability and latency
52/// - Provides intelligent validator selection based on client-observed performance
53/// - Handles epoch changes by cleaning up stale validator data
54///
55/// The monitor runs a background task for health checks and uses
56/// moving averages to smooth client-side measurements.
57pub struct ValidatorClientMonitor<A: Clone> {
58    config: ValidatorClientMonitorConfig,
59    metrics: Arc<ValidatorClientMetrics>,
60    client_stats: RwLock<ClientObservedStats>,
61    authority_aggregator: Arc<ArcSwap<AuthorityAggregator<A>>>,
62    cached_latencies: RwLock<HashMap<(AuthorityName, TransactionClass), Duration>>,
63    /// Latest staggering state each validator self-reported on its health-check
64    /// response, with the local receipt time that drives freshness.
65    staggering_reports: RwLock<HashMap<AuthorityName, (bool, Instant)>>,
66    /// The stake-weighted view over `staggering_reports`, recomputed when a report
67    /// arrives and after every health-check round (so aged-out reports drop off).
68    staggering_active: AtomicBool,
69    /// Wakes the health-check loop for an immediate round; see
70    /// [`Self::request_health_check`].
71    health_check_now: Notify,
72    /// Whether the round in progress was requested rather than scheduled; only read
73    /// to attribute a view flip in the log.
74    requested_round_in_progress: AtomicBool,
75}
76
77impl<A> ValidatorClientMonitor<A>
78where
79    A: AuthorityAPI + Send + Sync + 'static + Clone,
80{
81    pub fn new(
82        config: ValidatorClientMonitorConfig,
83        metrics: Arc<ValidatorClientMetrics>,
84        authority_aggregator: Arc<ArcSwap<AuthorityAggregator<A>>>,
85    ) -> Arc<Self> {
86        info!(
87            "Validator client monitor starting with config: {:?}",
88            config
89        );
90
91        let monitor = Arc::new(Self {
92            config: config.clone(),
93            metrics,
94            client_stats: RwLock::new(ClientObservedStats::new(config)),
95            authority_aggregator,
96            cached_latencies: RwLock::new(HashMap::new()),
97            staggering_reports: RwLock::new(HashMap::new()),
98            staggering_active: AtomicBool::new(false),
99            health_check_now: Notify::new(),
100            requested_round_in_progress: AtomicBool::new(false),
101        });
102
103        let monitor_clone = monitor.clone();
104        tokio::spawn(async move {
105            monitor_clone.run_health_checks().await;
106        });
107
108        monitor
109    }
110
111    #[cfg(test)]
112    pub fn new_for_test(authority_aggregator: Arc<AuthorityAggregator<A>>) -> Arc<Self> {
113        use prometheus::Registry;
114
115        Self::new(
116            ValidatorClientMonitorConfig::default(),
117            Arc::new(ValidatorClientMetrics::new(&Registry::default())),
118            Arc::new(ArcSwap::new(authority_aggregator)),
119        )
120    }
121
122    /// Background task that runs periodic health checks on all validators.
123    ///
124    /// Sends health check requests to all validators in parallel and records
125    /// the results (success/failure and latency). Timeouts are treated as
126    /// failures without recording latency to avoid polluting latency statistics.
127    async fn run_health_checks(self: Arc<Self>) {
128        let mut interval = interval(self.config.health_check_interval);
129        let mut last_round_started = tokio::time::Instant::now() - REQUESTED_HEALTH_CHECK_SPACING;
130        let mut requested_rounds_left = REQUESTED_HEALTH_CHECKS_PER_INTERVAL;
131
132        loop {
133            let requested = tokio::select! {
134                _ = interval.tick() => {
135                    requested_rounds_left = REQUESTED_HEALTH_CHECKS_PER_INTERVAL;
136                    false
137                }
138                _ = self.health_check_now.notified() => {
139                    // A request only has something to learn while the view is off; once it
140                    // is on, rejections are expected and the scheduled cadence is enough.
141                    if self.staggering_active() || requested_rounds_left == 0 {
142                        continue;
143                    }
144                    requested_rounds_left -= 1;
145                    tokio::time::sleep_until(last_round_started + REQUESTED_HEALTH_CHECK_SPACING)
146                        .await;
147                    // The requested round takes the place of the next scheduled one.
148                    interval.reset();
149                    true
150                }
151            };
152            last_round_started = tokio::time::Instant::now();
153            self.requested_round_in_progress
154                .store(requested, Ordering::Relaxed);
155
156            let authority_agg = self.authority_aggregator.load();
157
158            let current_validators: Vec<_> = authority_agg.committee.names().cloned().collect();
159            self.client_stats
160                .write()
161                .retain_validators(&current_validators);
162            self.staggering_reports
163                .write()
164                .retain(|name, _| current_validators.contains(name));
165
166            let mut tasks = JoinSet::new();
167
168            for (name, safe_client) in authority_agg.authority_clients.iter() {
169                let name = *name;
170                let display_name = authority_agg.get_display_name(&name);
171                let client = safe_client.clone();
172                let timeout_duration = self.config.health_check_timeout;
173                let monitor = self.clone();
174
175                tasks.spawn(async move {
176                    let start = Instant::now();
177                    match timeout(
178                        timeout_duration,
179                        client.validator_health(ValidatorHealthRequest {}),
180                    )
181                    .await
182                    {
183                        Ok(Ok(response)) => {
184                            monitor.record_staggering_report(name, response.staggering_active);
185                            let latency = start.elapsed();
186                            monitor.record_interaction_result(OperationFeedback {
187                                authority_name: name,
188                                display_name: display_name.clone(),
189                                operation: OperationType::HealthCheck,
190                                tx_class: None,
191                                ping_type: None,
192                                result: Ok(latency),
193                            });
194                        }
195                        Ok(Err(_)) => {
196                            let _latency = start.elapsed();
197                            monitor.record_interaction_result(OperationFeedback {
198                                authority_name: name,
199                                display_name: display_name.clone(),
200                                operation: OperationType::HealthCheck,
201                                tx_class: None,
202                                ping_type: None,
203                                result: Err(()),
204                            });
205                        }
206                        Err(_) => {
207                            monitor.record_interaction_result(OperationFeedback {
208                                authority_name: name,
209                                display_name,
210                                operation: OperationType::HealthCheck,
211                                tx_class: None,
212                                ping_type: None,
213                                result: Err(()),
214                            });
215                        }
216                    }
217                });
218            }
219
220            while let Some(result) = tasks.join_next().await {
221                if let Err(e) = result {
222                    warn!("Health check task failed: {}", e);
223                }
224            }
225
226            self.update_cached_latencies(&authority_agg);
227            // Ages out reports from validators that stopped answering.
228            self.refresh_staggering_view("end of round");
229            self.requested_round_in_progress
230                .store(false, Ordering::Relaxed);
231        }
232    }
233}
234
235impl<A: Clone> ValidatorClientMonitor<A> {
236    /// Calculate and cache latencies for all validators.
237    ///
238    /// This method is called periodically after health checks complete to update
239    /// the cached validator latencies. Those are the end to end latencies as calculated for each validator
240    /// taking into account the reliability of the validator.
241    fn update_cached_latencies(&self, authority_agg: &AuthorityAggregator<A>) {
242        let committee = &authority_agg.committee;
243        let mut cached_latencies = self.cached_latencies.write();
244
245        for tx_class in [TransactionClass::Restricted, TransactionClass::Unrestricted] {
246            let latencies_map = self
247                .client_stats
248                .read()
249                .get_all_validator_stats(committee, tx_class);
250
251            for (validator, latency) in latencies_map.iter() {
252                debug!(
253                    "Validator {}, class {:?}, latency {}",
254                    validator,
255                    tx_class,
256                    latency.as_secs_f64()
257                );
258                let display_name = authority_agg.get_display_name(validator);
259                self.metrics
260                    .performance
261                    .with_label_values(&[display_name.as_str(), tx_class.as_str()])
262                    .set(latency.as_secs_f64());
263                cached_latencies.insert((*validator, tx_class), *latency);
264            }
265        }
266    }
267
268    /// Record client-observed interaction result with a validator.
269    ///
270    /// Records operation results including success/failure status and latency
271    /// from the client's perspective. Updates both Prometheus metrics and
272    /// internal client statistics. This is the primary interface for the
273    /// TransactionDriver to report client-observed validator interactions.
274    /// TODO: Consider adding a byzantine flag to the feedback.
275    pub fn record_interaction_result(&self, feedback: OperationFeedback) {
276        let operation_str = match feedback.operation {
277            OperationType::Submit => "submit",
278            OperationType::Effects => "effects",
279            OperationType::HealthCheck => "health_check",
280            OperationType::SingleWriterFinality => "single_writer_finality",
281            OperationType::SharedObjectFinality => "shared_object_finality",
282        };
283        let ping_label = if feedback.ping_type.is_some() {
284            "true"
285        } else {
286            "false"
287        };
288
289        match feedback.result {
290            Ok(latency) => {
291                self.metrics
292                    .observed_latency
293                    .with_label_values(&[feedback.display_name.as_str(), operation_str, ping_label])
294                    .observe(latency.as_secs_f64());
295                self.metrics
296                    .operation_success
297                    .with_label_values(&[feedback.display_name.as_str(), operation_str, ping_label])
298                    .inc();
299            }
300            Err(()) => {
301                self.metrics
302                    .operation_failure
303                    .with_label_values(&[feedback.display_name.as_str(), operation_str, ping_label])
304                    .inc();
305            }
306        }
307
308        let mut client_stats = self.client_stats.write();
309        client_stats.record_interaction_result(feedback);
310    }
311
312    /// Whether any latency has been observed yet. Until then
313    /// `select_shuffled_preferred_validators` returns an arbitrary shuffle rather than a real
314    /// preference.
315    pub fn has_observed_latencies(&self) -> bool {
316        !self.cached_latencies.read().is_empty()
317    }
318
319    /// Reports older than this no longer count toward [`Self::staggering_active`].
320    /// Two health-check intervals, so a single missed health check does not flap the
321    /// view.
322    fn report_freshness(&self) -> Duration {
323        self.config.health_check_interval * 2
324    }
325
326    /// Record a validator's self-reported staggering state from its health-check
327    /// response. `None` (a validator predating the field) carries no information and
328    /// is ignored. Health checks to one validator are sequential, so reports from it
329    /// arrive in order and the latest simply wins.
330    pub fn record_staggering_report(&self, validator: AuthorityName, active: Option<bool>) {
331        let Some(active) = active else {
332            return;
333        };
334        self.staggering_reports
335            .write()
336            .insert(validator, (active, Instant::now()));
337        self.refresh_staggering_view(&format!("report from {}", validator.concise()));
338    }
339
340    /// Run a health-check round now rather than at the next interval tick, spaced by
341    /// `REQUESTED_HEALTH_CHECK_SPACING` from the previous round and capped at
342    /// `REQUESTED_HEALTH_CHECKS_PER_INTERVAL` while the staggering view is off (a
343    /// request while it is on is ignored). Validators report their staggering state
344    /// on health responses, so this is how the driver learns of an activation within
345    /// a round trip of its first staggered-submission rejection instead of within a
346    /// health interval. Requests coalesce while a round is pending.
347    pub fn request_health_check(&self) {
348        self.health_check_now.notify_one();
349    }
350
351    /// Whether staggered submission is currently considered active on the validator
352    /// network, as last computed by `refresh_staggering_view`: validators
353    /// reporting active within the freshness window must total at least f+1 by
354    /// stake, so at least one of them is honest — and honest validators converge on
355    /// the same mode, so one honest report reflects the network state. A fresh
356    /// report counts immediately (flipping on is cheap to believe: the cost of a
357    /// wrong belief in either direction is only bounded latency) and decays by aging
358    /// out of the window.
359    pub fn staggering_active(&self) -> bool {
360        self.staggering_active.load(Ordering::Relaxed)
361    }
362
363    /// Recomputes the view and logs a flip with what caused it, so a run's driver-side
364    /// detection can be timed against the validators' own transition logs.
365    fn refresh_staggering_view(&self, trigger: &str) {
366        let authority_agg = self.authority_aggregator.load();
367        let committee = &authority_agg.committee;
368        let freshness = self.report_freshness();
369        let now = Instant::now();
370        let active_stake: u64 = self
371            .staggering_reports
372            .read()
373            .iter()
374            .filter(|(_, (active, received_at))| {
375                *active && now.saturating_duration_since(*received_at) < freshness
376            })
377            .map(|(name, _)| committee.weight(name))
378            .sum();
379        let active = active_stake >= committee.validity_threshold();
380        if self.staggering_active.swap(active, Ordering::Relaxed) != active {
381            info!(
382                "Staggering view flipped {}: {} of {} stake reports staggering active \
383                 (validity threshold {}), on {} during a {} health-check round",
384                if active { "on" } else { "off" },
385                active_stake,
386                committee.total_votes(),
387                committee.validity_threshold(),
388                trigger,
389                if self.requested_round_in_progress.load(Ordering::Relaxed) {
390                    "requested"
391                } else {
392                    "scheduled"
393                },
394            );
395        }
396        self.metrics
397            .staggering_active_stake
398            .set(active_stake as i64);
399        self.metrics.staggering_active.set(active as i64);
400    }
401
402    #[cfg(test)]
403    pub fn record_staggering_report_at(&self, validator: AuthorityName, active: bool, at: Instant) {
404        self.staggering_reports
405            .write()
406            .insert(validator, (active, at));
407        self.refresh_staggering_view("test report");
408    }
409
410    /// Select validators based on client-observed performance for the given transaction type.
411    ///
412    /// The current committee is passed in to ensure this function has the latest committee information.
413    ///
414    /// Also the tx type is passed in so that we can select validators based on their respective latencies
415    /// for the transaction type.
416    ///
417    /// Validators with latencies within `delta` of the lowest latency in the given transaction type
418    /// are shuffled, to balance the load among the fastest validators.
419    ///
420    /// Returns a vector containing all validators, where
421    /// 1. Fast validators within `delta` of the lowest latency are shuffled.
422    /// 2. Remaining slow validators are sorted by latency in ascending order.
423    pub fn select_shuffled_preferred_validators(
424        &self,
425        committee: &Committee,
426        delta: f64,
427        tx_class: TransactionClass,
428    ) -> Vec<AuthorityName> {
429        let mut rng = rand::thread_rng();
430
431        let cached_latencies = self.cached_latencies.read();
432        if cached_latencies.is_empty() {
433            let mut validators: Vec<_> = committee.names().cloned().collect();
434            validators.shuffle(&mut rng);
435            return validators;
436        };
437
438        // Since the cached latencies are updated periodically, it is possible that it was ran on
439        // an out-of-date committee.
440        let mut validator_with_latencies: Vec<_> = committee
441            .names()
442            .map(|v| {
443                (
444                    *v,
445                    cached_latencies
446                        .get(&(*v, tx_class))
447                        .cloned()
448                        .unwrap_or(Duration::ZERO),
449                )
450            })
451            .collect();
452        if validator_with_latencies.is_empty() {
453            return vec![];
454        }
455        // Shuffle the validators to balance the load among validators with the same latency.
456        validator_with_latencies.shuffle(&mut rng);
457        // Sort by latency in ascending order. We want to select the validators with the lowest latencies.
458        validator_with_latencies.sort_by_key(|(_, latency)| *latency);
459
460        // Shuffle the validators within delta of the lowest latency, for load balancing.
461        let lowest_latency = validator_with_latencies[0].1;
462        let threshold = lowest_latency.mul_f64(1.0 + delta);
463        let k = validator_with_latencies
464            .iter()
465            .enumerate()
466            .find(|(_, (_, latency))| *latency > threshold)
467            .map(|(i, _)| i)
468            .unwrap_or(validator_with_latencies.len());
469        validator_with_latencies[..k].shuffle(&mut rng);
470        self.metrics.shuffled_validators.observe(k as f64);
471
472        validator_with_latencies
473            .into_iter()
474            .map(|(v, _)| v)
475            .collect()
476    }
477
478    #[cfg(test)]
479    pub fn force_update_cached_latencies(&self, authority_agg: &AuthorityAggregator<A>) {
480        self.update_cached_latencies(authority_agg);
481    }
482
483    #[cfg(test)]
484    pub fn get_client_stats_for_test(
485        &self,
486        validator: &AuthorityName,
487    ) -> crate::validator_client_monitor::stats::ValidatorClientStats {
488        self.client_stats
489            .read()
490            .validator_stats
491            .get(validator)
492            .cloned()
493            .unwrap()
494    }
495
496    #[cfg(test)]
497    pub fn get_client_stats_len(&self) -> usize {
498        self.client_stats.read().validator_stats.len()
499    }
500
501    #[cfg(test)]
502    pub fn has_validator_stats(&self, validator: &AuthorityName) -> bool {
503        self.client_stats
504            .read()
505            .validator_stats
506            .contains_key(validator)
507    }
508}