Skip to main content

sui_core/authority/
execution_time_estimator.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    collections::HashMap,
6    hash::{Hash, Hasher},
7    num::NonZeroUsize,
8    sync::{Arc, Weak},
9    time::{Duration, SystemTime},
10};
11
12use serde::{Deserialize, Serialize};
13
14use super::authority_per_epoch_store::AuthorityPerEpochStore;
15use super::weighted_moving_average::WeightedMovingAverage;
16use crate::consensus_adapter::SubmitToConsensus;
17use governor::{Quota, RateLimiter, clock::MonotonicClock};
18use itertools::Itertools;
19use lru::LruCache;
20#[cfg(not(msim))]
21use mysten_common::in_antithesis;
22use mysten_common::{assert_reachable, debug_fatal, in_test_configuration};
23use mysten_metrics::{monitored_scope, spawn_monitored_task};
24use rand::{Rng, SeedableRng, random, rngs, thread_rng};
25use simple_moving_average::{SMA, SingleSumSMA};
26use sui_config::node::ExecutionTimeObserverConfig;
27use sui_protocol_config::{ExecutionTimeEstimateParams, PerObjectCongestionControlMode};
28use sui_types::{
29    base_types::ObjectID,
30    committee::Committee,
31    error::SuiErrorKind,
32    execution::{ExecutionTimeObservationKey, ExecutionTiming},
33    messages_consensus::{AuthorityIndex, ConsensusTransaction, ExecutionTimeObservation},
34    transaction::{
35        Command, ProgrammableTransaction, StoredExecutionTimeObservations, TransactionData,
36        TransactionDataAPI, TransactionKind,
37    },
38};
39use tokio::{sync::mpsc, time::Instant};
40use tracing::{debug, info, trace, warn};
41
42// TODO: Move this into ExecutionTimeObserverConfig, if we switch to a moving average
43// implmentation without the window size in the type.
44const SMA_LOCAL_OBSERVATION_WINDOW_SIZE: usize = 20;
45const OBJECT_UTILIZATION_METRIC_HASH_MODULUS: u8 = 32;
46
47/// Determines whether to inject synthetic execution time in Antithesis environments.
48///
49/// This function checks two conditions:
50/// 1. Whether the code is running in an Antithesis environment
51/// 2. Whether injection is enabled via the `ANTITHESIS_ENABLE_EXECUTION_TIME_INJECTION` env var
52///    (enabled by default)
53#[cfg(not(msim))]
54fn antithesis_enable_injecting_synthetic_execution_time() -> bool {
55    use std::sync::OnceLock;
56    static ENABLE_INJECTION: OnceLock<bool> = OnceLock::new();
57    *ENABLE_INJECTION.get_or_init(|| {
58        if !in_antithesis() {
59            return false;
60        }
61
62        std::env::var("ANTITHESIS_ENABLE_EXECUTION_TIME_INJECTION")
63            .map(|v| v.to_lowercase() == "true" || v == "1")
64            .unwrap_or(true)
65    })
66}
67
68// Collects local execution time estimates to share via consensus.
69pub struct ExecutionTimeObserver {
70    epoch_store: Weak<AuthorityPerEpochStore>,
71    consensus_adapter: Box<dyn SubmitToConsensus>,
72
73    protocol_params: ExecutionTimeEstimateParams,
74    config: ExecutionTimeObserverConfig,
75
76    local_observations: LruCache<ExecutionTimeObservationKey, LocalObservations>,
77
78    // For each object, tracks the amount of time above our utilization target that we spent
79    // executing transactions. This is used to decide which observations should be shared
80    // via consensus.
81    object_utilization_tracker: LruCache<ObjectID, ObjectUtilization>,
82
83    // Pre-resolved metric children for objects listed in the config, so that recording
84    // utilization for a tracked object does not allocate.
85    tracked_object_counters: HashMap<ObjectID, prometheus::Counter>,
86
87    // Sorted list of recently indebted objects, updated by consensus handler.
88    indebted_objects: Vec<ObjectID>,
89
90    sharing_rate_limiter: RateLimiter<
91        governor::state::NotKeyed,
92        governor::state::InMemoryState,
93        governor::clock::MonotonicClock,
94        governor::middleware::NoOpMiddleware<
95            <governor::clock::MonotonicClock as governor::clock::Clock>::Instant,
96        >,
97    >,
98
99    next_generation_number: u64,
100}
101
102#[derive(Debug, Clone)]
103pub struct LocalObservations {
104    moving_average: SingleSumSMA<Duration, u32, SMA_LOCAL_OBSERVATION_WINDOW_SIZE>,
105    weighted_moving_average: WeightedMovingAverage,
106    last_shared: Option<(Duration, Instant)>,
107    config: ExecutionTimeObserverConfig,
108}
109
110impl LocalObservations {
111    fn new(config: ExecutionTimeObserverConfig, default_duration: Duration) -> Self {
112        let window_size = config.weighted_moving_average_window_size();
113        Self {
114            moving_average: SingleSumSMA::from_zero(Duration::ZERO),
115            weighted_moving_average: WeightedMovingAverage::new(
116                default_duration.as_micros() as u64,
117                window_size,
118            ),
119            last_shared: None,
120            config,
121        }
122    }
123
124    fn add_sample(&mut self, duration: Duration, gas_price: u64) {
125        self.moving_average.add_sample(duration);
126        self.weighted_moving_average
127            .add_sample(duration.as_micros() as u64, gas_price);
128    }
129
130    fn get_average(&self) -> Duration {
131        if self.config.enable_gas_price_weighting() {
132            Duration::from_micros(self.weighted_moving_average.get_weighted_average())
133        } else {
134            self.moving_average.get_average()
135        }
136    }
137
138    fn diff_exceeds_threshold(
139        &self,
140        new_average: Duration,
141        threshold: f64,
142        min_interval: Duration,
143    ) -> bool {
144        let Some((last_shared, last_shared_timestamp)) = self.last_shared else {
145            // Diff threshold exceeded by default if we haven't shared anything yet.
146            return true;
147        };
148
149        if last_shared_timestamp.elapsed() < min_interval {
150            return false;
151        }
152
153        if threshold >= 0.0 {
154            // Positive threshold requires upward change.
155            new_average
156                .checked_sub(last_shared)
157                .is_some_and(|diff| diff > last_shared.mul_f64(threshold))
158        } else {
159            // Negative threshold requires downward change.
160            last_shared
161                .checked_sub(new_average)
162                .is_some_and(|diff| diff > last_shared.mul_f64(-threshold))
163        }
164    }
165}
166
167#[derive(Debug, Clone)]
168pub struct ObjectUtilization {
169    excess_execution_time: Duration,
170    last_measured: Option<Instant>,
171    was_overutilized: bool, // true if the object has ever had excess_execution_time
172}
173
174impl ObjectUtilization {
175    pub fn overutilized(&self, config: &ExecutionTimeObserverConfig) -> bool {
176        self.excess_execution_time > config.observation_sharing_object_utilization_threshold()
177    }
178}
179
180fn tracked_object_counters(
181    config: &ExecutionTimeObserverConfig,
182    metrics: &crate::epoch::epoch_metrics::EpochMetrics,
183) -> HashMap<ObjectID, prometheus::Counter> {
184    config
185        .object_utilization_metric_tracked_ids()
186        .map(|(id, name)| {
187            let counter = metrics
188                .epoch_execution_time_observer_tracked_object_utilization
189                .with_label_values(&[id.to_string().as_str(), name]);
190            (*id, counter)
191        })
192        .collect()
193}
194
195// Tracks local execution time observations and shares them via consensus.
196impl ExecutionTimeObserver {
197    pub fn spawn(
198        epoch_store: Arc<AuthorityPerEpochStore>,
199        consensus_adapter: Box<dyn SubmitToConsensus>,
200        config: ExecutionTimeObserverConfig,
201    ) {
202        let PerObjectCongestionControlMode::ExecutionTimeEstimate(protocol_params) = epoch_store
203            .protocol_config()
204            .per_object_congestion_control_mode()
205        else {
206            info!(
207                "ExecutionTimeObserver disabled because per-object congestion control mode is not ExecutionTimeEstimate"
208            );
209            return;
210        };
211
212        let (tx_local_execution_time, mut rx_local_execution_time) =
213            mpsc::channel(config.observation_channel_capacity().into());
214        let (tx_object_debts, mut rx_object_debts) =
215            mpsc::channel(config.object_debt_channel_capacity().into());
216        epoch_store.set_local_execution_time_channels(tx_local_execution_time, tx_object_debts);
217
218        // TODO: pre-populate local observations with stored data from prior epoch.
219        let mut observer = Self {
220            epoch_store: Arc::downgrade(&epoch_store),
221            consensus_adapter,
222            local_observations: LruCache::new(config.observation_cache_size()),
223            object_utilization_tracker: LruCache::new(config.object_utilization_cache_size()),
224            tracked_object_counters: tracked_object_counters(&config, &epoch_store.metrics),
225            indebted_objects: Vec::new(),
226            sharing_rate_limiter: RateLimiter::direct_with_clock(
227                Quota::per_second(config.observation_sharing_rate_limit())
228                    .allow_burst(config.observation_sharing_burst_limit()),
229                &MonotonicClock,
230            ),
231            protocol_params,
232            config,
233            next_generation_number: SystemTime::now()
234                .duration_since(std::time::UNIX_EPOCH)
235                .expect("Sui did not exist prior to 1970")
236                .as_micros()
237                .try_into()
238                .expect("This build of sui is not supported in the year 500,000"),
239        };
240        spawn_monitored_task!(epoch_store.within_alive_epoch(async move {
241            loop {
242                tokio::select! {
243                    // TODO: add metrics for messages received.
244                    Some(object_debts) = rx_object_debts.recv() => {
245                        observer.update_indebted_objects(object_debts);
246                    }
247                    Some((tx, timings, total_duration, gas_price)) = rx_local_execution_time.recv() => {
248                        observer
249                            .record_local_observations(&tx, &timings, total_duration, gas_price);
250                    }
251                    else => { break }
252                }
253            }
254            info!("shutting down ExecutionTimeObserver");
255        }));
256    }
257
258    #[cfg(test)]
259    fn new_for_testing(
260        epoch_store: Arc<AuthorityPerEpochStore>,
261        consensus_adapter: Box<dyn SubmitToConsensus>,
262        observation_sharing_object_utilization_threshold: Duration,
263        enable_gas_price_weighting: bool,
264    ) -> Self {
265        let PerObjectCongestionControlMode::ExecutionTimeEstimate(protocol_params) = epoch_store
266            .protocol_config()
267            .per_object_congestion_control_mode()
268        else {
269            panic!(
270                "tried to construct test ExecutionTimeObserver when congestion control mode is not ExecutionTimeEstimate"
271            );
272        };
273        Self {
274            epoch_store: Arc::downgrade(&epoch_store),
275            consensus_adapter,
276            protocol_params,
277            config: ExecutionTimeObserverConfig {
278                observation_sharing_object_utilization_threshold: Some(
279                    observation_sharing_object_utilization_threshold,
280                ),
281                enable_gas_price_weighting: Some(enable_gas_price_weighting),
282                ..ExecutionTimeObserverConfig::default()
283            },
284            local_observations: LruCache::new(NonZeroUsize::new(10000).unwrap()),
285            object_utilization_tracker: LruCache::new(NonZeroUsize::new(50000).unwrap()),
286            tracked_object_counters: HashMap::new(),
287            indebted_objects: Vec::new(),
288            sharing_rate_limiter: RateLimiter::direct_with_clock(
289                Quota::per_hour(std::num::NonZeroU32::MAX),
290                &MonotonicClock,
291            ),
292            next_generation_number: SystemTime::now()
293                .duration_since(std::time::UNIX_EPOCH)
294                .expect("Sui did not exist prior to 1970")
295                .as_micros()
296                .try_into()
297                .expect("This build of sui is not supported in the year 500,000"),
298        }
299    }
300
301    // Used by execution to report observed per-entry-point execution times to the estimator.
302    // Updates moving averages and submits observation to consensus if local observation differs
303    // from consensus median.
304    // TODO: Consider more detailed heuristic to account for overhead outside of commands.
305    fn record_local_observations(
306        &mut self,
307        tx: &ProgrammableTransaction,
308        timings: &[ExecutionTiming],
309        total_duration: Duration,
310        gas_price: u64,
311    ) {
312        let _scope = monitored_scope("ExecutionTimeObserver::record_local_observations");
313
314        // Simulate timing in test contexts to trigger congestion control.
315        #[cfg(msim)]
316        let should_inject = self.config.inject_synthetic_execution_time();
317        #[cfg(not(msim))]
318        let should_inject = antithesis_enable_injecting_synthetic_execution_time();
319
320        if should_inject {
321            let (generated_timings, generated_duration) = self.generate_test_timings(tx, timings);
322            self.record_local_observations_timing(
323                tx,
324                &generated_timings,
325                generated_duration,
326                gas_price,
327            )
328        } else {
329            self.record_local_observations_timing(tx, timings, total_duration, gas_price)
330        }
331    }
332
333    fn record_local_observations_timing(
334        &mut self,
335        tx: &ProgrammableTransaction,
336        timings: &[ExecutionTiming],
337        total_duration: Duration,
338        gas_price: u64,
339    ) {
340        let Some(epoch_store) = self.epoch_store.upgrade() else {
341            debug!("epoch is ending, dropping execution time observation");
342            return;
343        };
344        let timings = if timings.len() > tx.commands.len() {
345            warn!(
346                executed_commands = timings.len(),
347                original_commands = tx.commands.len(),
348                "execution produced more timings than the original PTB commands; using the trailing timings for local execution-time observations"
349            );
350            &timings[timings.len() - tx.commands.len()..]
351        } else {
352            timings
353        };
354
355        let mut uses_indebted_object = false;
356
357        // Update the accumulated excess execution time for shared object
358        // used for exclusive access in this transaction, and determine the max overage.
359        let max_excess_per_object_execution_time = tx
360            .shared_input_objects()
361            .filter_map(|obj| obj.is_accessed_exclusively().then_some(obj.id))
362            .map(|id| {
363                // Mark if any object used in the tx is indebted.
364                if !uses_indebted_object && self.indebted_objects.binary_search(&id).is_ok() {
365                    uses_indebted_object = true;
366                }
367
368                // For each object:
369                // - add the execution time of the current transaction to the tracker
370                // - subtract the maximum amount of time available for execution according
371                //   to our utilization target since the last report was received
372                //   (clamping to zero)
373                //
374                // What remains is the amount of excess time spent executing transactions on
375                // the object above the intended limit. If this value is greater than zero,
376                // it means the object is overutilized.
377                let now = Instant::now();
378                let utilization =
379                    self.object_utilization_tracker
380                        .get_or_insert_mut(id, || ObjectUtilization {
381                            excess_execution_time: Duration::ZERO,
382                            last_measured: None,
383                            was_overutilized: false,
384                        });
385                let overutilized_at_start = utilization.overutilized(&self.config);
386                utilization.excess_execution_time += total_duration;
387                utilization.excess_execution_time =
388                    utilization.excess_execution_time.saturating_sub(
389                        utilization
390                            .last_measured
391                            .map(|last_measured| {
392                                now.duration_since(last_measured)
393                                    .mul_f64(self.protocol_params.target_utilization as f64 / 100.0)
394                            })
395                            .unwrap_or(Duration::MAX),
396                    );
397                utilization.last_measured = Some(now);
398                if utilization.overutilized(&self.config) {
399                    utilization.was_overutilized = true;
400                }
401
402                // Update overutilized objects metrics.
403                if !overutilized_at_start && utilization.overutilized(&self.config) {
404                    trace!("object {id:?} is overutilized");
405                    epoch_store
406                        .metrics
407                        .epoch_execution_time_observer_overutilized_objects
408                        .inc();
409                } else if overutilized_at_start && !utilization.overutilized(&self.config) {
410                    epoch_store
411                        .metrics
412                        .epoch_execution_time_observer_overutilized_objects
413                        .dec();
414                }
415                if utilization.was_overutilized {
416                    let key = if self.config.report_object_utilization_metric_with_full_id() {
417                        id.to_string()
418                    } else {
419                        let key_lsb = id.into_bytes()[ObjectID::LENGTH - 1];
420                        let hash = key_lsb % OBJECT_UTILIZATION_METRIC_HASH_MODULUS;
421                        format!("{:x}", hash)
422                    };
423
424                    epoch_store
425                        .metrics
426                        .epoch_execution_time_observer_object_utilization
427                        .with_label_values(&[key.as_str()])
428                        .inc_by(total_duration.as_secs_f64());
429                }
430                if let Some(counter) = self.tracked_object_counters.get(&id) {
431                    counter.inc_by(total_duration.as_secs_f64());
432                }
433
434                utilization.excess_execution_time
435            })
436            .max()
437            .unwrap_or(Duration::ZERO);
438        epoch_store
439            .metrics
440            .epoch_execution_time_observer_utilization_cache_size
441            .set(self.object_utilization_tracker.len() as i64);
442
443        let total_command_duration: Duration = timings.iter().map(|t| t.duration()).sum();
444        let extra_overhead = total_duration - total_command_duration;
445
446        let mut to_share = Vec::with_capacity(tx.commands.len());
447        for (i, timing) in timings.iter().enumerate() {
448            let command = &tx.commands[i];
449
450            // Special-case handling for Publish command: only use hard-coded default estimate.
451            if matches!(command, Command::Publish(_, _)) {
452                continue;
453            }
454
455            // TODO: Consider using failure/success information in computing estimates.
456            let mut command_duration = timing.duration();
457
458            // Distribute overhead proportionally to each command's measured duration.
459            let overhead_factor = if total_command_duration > Duration::ZERO {
460                command_duration.as_secs_f64() / total_command_duration.as_secs_f64()
461            } else {
462                // divisor here must be >0 or this loop would not be running at all
463                1.0 / (tx.commands.len() as f64)
464            };
465            command_duration += extra_overhead.mul_f64(overhead_factor);
466
467            // For native commands, adjust duration by length of command's inputs/outputs.
468            // This is sort of arbitrary, but hopefully works okay as a heuristic.
469            command_duration = command_duration.div_f64(command_length(command).get() as f64);
470
471            // Update gas-weighted moving-average observation for the command.
472            let key = ExecutionTimeObservationKey::from_command(command);
473            let local_observation = self.local_observations.get_or_insert_mut(key.clone(), || {
474                LocalObservations::new(self.config.clone(), Duration::ZERO)
475            });
476            local_observation.add_sample(command_duration, gas_price);
477
478            // Send a new observation through consensus if:
479            // - our current moving average differs too much from the last one we shared, and
480            // - the tx has at least one mutable shared object with utilization that's too high
481            // TODO: Consider only sharing observations that disagree with consensus estimate.
482            let new_average = local_observation.get_average();
483            let mut should_share = false;
484
485            // Share upward adjustments if an object is overutilized.
486            if max_excess_per_object_execution_time
487                >= self
488                    .config
489                    .observation_sharing_object_utilization_threshold()
490                && local_observation.diff_exceeds_threshold(
491                    new_average,
492                    self.config.observation_sharing_diff_threshold(),
493                    self.config.observation_sharing_min_interval(),
494                )
495            {
496                should_share = true;
497                epoch_store
498                    .metrics
499                    .epoch_execution_time_observations_sharing_reason
500                    .with_label_values(&["utilization"])
501                    .inc();
502            };
503
504            // Share downward adjustments if an object is indebted.
505            if uses_indebted_object
506                && local_observation.diff_exceeds_threshold(
507                    new_average,
508                    -self.config.observation_sharing_diff_threshold(),
509                    self.config.observation_sharing_min_interval(),
510                )
511            {
512                should_share = true;
513                epoch_store
514                    .metrics
515                    .epoch_execution_time_observations_sharing_reason
516                    .with_label_values(&["indebted"])
517                    .inc();
518            }
519
520            if should_share {
521                debug!("sharing new execution time observation for {key:?}: {new_average:?}");
522                to_share.push((key, new_average));
523                local_observation.last_shared = Some((new_average, Instant::now()));
524            }
525        }
526
527        // Share new observations.
528        self.share_observations(to_share);
529    }
530
531    fn generate_test_timings(
532        &self,
533        tx: &ProgrammableTransaction,
534        timings: &[ExecutionTiming],
535    ) -> (Vec<ExecutionTiming>, Duration) {
536        #[allow(clippy::disallowed_methods)]
537        let generated_timings: Vec<_> = tx
538            .commands
539            .iter()
540            // TODO: migrate to zip_debug_eq once PR #26125 fixes the timings/commands length mismatch
541            .zip(timings.iter())
542            .map(|(command, timing)| {
543                let key = ExecutionTimeObservationKey::from_command(command);
544                let duration = self.get_test_duration(&key);
545                if timing.is_abort() {
546                    ExecutionTiming::Abort(duration)
547                } else {
548                    ExecutionTiming::Success(duration)
549                }
550            })
551            .collect();
552
553        let total_duration = generated_timings
554            .iter()
555            .map(|t| t.duration())
556            .sum::<Duration>()
557            + thread_rng().gen_range(Duration::from_millis(10)..Duration::from_millis(50));
558
559        (generated_timings, total_duration)
560    }
561
562    fn get_test_duration(&self, key: &ExecutionTimeObservationKey) -> Duration {
563        #[cfg(msim)]
564        let should_inject = self.config.inject_synthetic_execution_time();
565        #[cfg(not(msim))]
566        let should_inject = false;
567
568        if !in_test_configuration() && !should_inject {
569            panic!("get_test_duration called in non-test configuration");
570        }
571
572        static PER_TEST_SEED: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
573
574        let mut hasher = std::collections::hash_map::DefaultHasher::new();
575
576        let checkpoint_digest_used = self
577            .epoch_store
578            .upgrade()
579            .and_then(|store| {
580                store
581                    .get_lowest_non_genesis_checkpoint_summary()
582                    .ok()
583                    .flatten()
584            })
585            .map(|summary| summary.content_digest.hash(&mut hasher))
586            .is_some();
587
588        if !checkpoint_digest_used {
589            PER_TEST_SEED.get_or_init(random::<u64>).hash(&mut hasher);
590        }
591
592        key.hash(&mut hasher);
593        let mut rng = rngs::StdRng::seed_from_u64(hasher.finish());
594        rng.gen_range(Duration::from_millis(100)..Duration::from_millis(600))
595    }
596
597    fn share_observations(&mut self, to_share: Vec<(ExecutionTimeObservationKey, Duration)>) {
598        if to_share.is_empty() {
599            return;
600        }
601        let Some(epoch_store) = self.epoch_store.upgrade() else {
602            debug!("epoch is ending, dropping execution time observation");
603            return;
604        };
605
606        let num_observations = to_share.len() as u64;
607
608        // Enforce global observation-sharing rate limit.
609        if let Err(e) = self.sharing_rate_limiter.check() {
610            epoch_store
611                .metrics
612                .epoch_execution_time_observations_dropped
613                .with_label_values(&["global_rate_limit"])
614                .inc_by(num_observations);
615            debug!("rate limit exceeded, dropping execution time observation; {e:?}");
616            return;
617        }
618
619        let epoch_store = epoch_store.clone();
620        let transaction = ConsensusTransaction::new_execution_time_observation(
621            ExecutionTimeObservation::new(epoch_store.name, self.next_generation_number, to_share),
622        );
623        self.next_generation_number += 1;
624
625        if let Err(e) = self.consensus_adapter.submit_best_effort(
626            &transaction,
627            &epoch_store,
628            Duration::from_secs(5),
629        ) {
630            if !matches!(e.as_inner(), SuiErrorKind::EpochEnded(_)) {
631                epoch_store
632                    .metrics
633                    .epoch_execution_time_observations_dropped
634                    .with_label_values(&["submit_to_consensus"])
635                    .inc_by(num_observations);
636                warn!("failed to submit execution time observation: {e:?}");
637            }
638        } else {
639            // Note: it is not actually guaranteed that the observation has been submitted at this point,
640            // but that is also not true with ConsensusAdapter::submit_to_consensus. The only way to know
641            // for sure is to observe that the message is processed by consensus handler.
642            assert_reachable!("successfully shares execution time observations");
643            epoch_store
644                .metrics
645                .epoch_execution_time_observations_shared
646                .inc_by(num_observations);
647        }
648    }
649
650    fn update_indebted_objects(&mut self, mut object_debts: Vec<ObjectID>) {
651        let _scope = monitored_scope("ExecutionTimeObserver::update_indebted_objects");
652
653        let Some(epoch_store) = self.epoch_store.upgrade() else {
654            debug!("epoch is ending, dropping indebted object update");
655            return;
656        };
657
658        object_debts.sort_unstable();
659        object_debts.dedup();
660        self.indebted_objects = object_debts;
661        epoch_store
662            .metrics
663            .epoch_execution_time_observer_indebted_objects
664            .set(self.indebted_objects.len() as i64);
665    }
666}
667
668// Key used to save StoredExecutionTimeObservations in the Sui system state object's
669// `extra_fields` Bag.
670pub const EXTRA_FIELD_EXECUTION_TIME_ESTIMATES_KEY: u64 = 0;
671
672// Key used to save the chunk count for chunked execution time observations
673pub const EXTRA_FIELD_EXECUTION_TIME_ESTIMATES_CHUNK_COUNT_KEY: u64 = 1;
674
675// Tracks global execution time observations provided by validators from consensus
676// and computes deterministic per-command estimates for use in congestion control.
677pub struct ExecutionTimeEstimator {
678    committee: Arc<Committee>,
679    protocol_params: ExecutionTimeEstimateParams,
680
681    consensus_observations: HashMap<ExecutionTimeObservationKey, ConsensusObservations>,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
685pub struct ConsensusObservations {
686    observations: Vec<(u64 /* generation */, Option<Duration>)>, // keyed by authority index
687    stake_weighted_median: Option<Duration>,                     // cached value
688}
689
690impl ConsensusObservations {
691    fn update_stake_weighted_median(
692        &mut self,
693        committee: &Committee,
694        config: &ExecutionTimeEstimateParams,
695    ) {
696        let mut stake_with_observations = 0;
697        let sorted_observations: Vec<_> = self
698            .observations
699            .iter()
700            .enumerate()
701            .filter_map(|(i, (_, duration))| {
702                duration.map(|duration| {
703                    let authority_index: AuthorityIndex = i.try_into().unwrap();
704                    stake_with_observations += committee.stake_by_index(authority_index).unwrap();
705                    (duration, authority_index)
706                })
707            })
708            .sorted()
709            .collect();
710
711        // Don't use observations until we have received enough.
712        if stake_with_observations < config.stake_weighted_median_threshold {
713            self.stake_weighted_median = None;
714            return;
715        }
716
717        // Compute stake-weighted median.
718        let median_stake = stake_with_observations / 2;
719        let mut running_stake = 0;
720        for (duration, authority_index) in sorted_observations {
721            running_stake += committee.stake_by_index(authority_index).unwrap();
722            if running_stake > median_stake {
723                self.stake_weighted_median = Some(duration);
724                break;
725            }
726        }
727    }
728}
729
730impl ExecutionTimeEstimator {
731    pub fn new(
732        committee: Arc<Committee>,
733        protocol_params: ExecutionTimeEstimateParams,
734        initial_observations: impl Iterator<
735            Item = (
736                AuthorityIndex,
737                Option<u64>,
738                ExecutionTimeObservationKey,
739                Duration,
740            ),
741        >,
742    ) -> Self {
743        let mut estimator = Self {
744            committee,
745            protocol_params,
746            consensus_observations: HashMap::new(),
747        };
748        for (source, generation, key, duration) in initial_observations {
749            estimator.process_observation_from_consensus(
750                source,
751                generation,
752                key.to_owned(),
753                duration,
754                true,
755            );
756        }
757        for observation in estimator.consensus_observations.values_mut() {
758            observation
759                .update_stake_weighted_median(&estimator.committee, &estimator.protocol_params);
760        }
761        estimator
762    }
763
764    #[cfg(test)]
765    pub fn new_for_testing() -> Self {
766        let (committee, _) = Committee::new_simple_test_committee_of_size(1);
767        Self {
768            committee: Arc::new(committee),
769            protocol_params: ExecutionTimeEstimateParams {
770                target_utilization: 100,
771                max_estimate_us: u64::MAX,
772                ..ExecutionTimeEstimateParams::default()
773            },
774            consensus_observations: HashMap::new(),
775        }
776    }
777
778    pub fn process_observations_from_consensus(
779        &mut self,
780        source: AuthorityIndex,
781        generation: Option<u64>,
782        observations: &[(ExecutionTimeObservationKey, Duration)],
783    ) {
784        for (key, duration) in observations {
785            self.process_observation_from_consensus(
786                source,
787                generation,
788                key.to_owned(),
789                *duration,
790                false,
791            );
792        }
793    }
794
795    fn process_observation_from_consensus(
796        &mut self,
797        source: AuthorityIndex,
798        generation: Option<u64>,
799        observation_key: ExecutionTimeObservationKey,
800        duration: Duration,
801        skip_update: bool,
802    ) {
803        if matches!(observation_key, ExecutionTimeObservationKey::Publish) {
804            // Special-case handling for Publish command: only use hard-coded default estimate.
805            warn!(
806                "dropping Publish observation received from possibly-Byzanitine authority {source}"
807            );
808            return;
809        }
810
811        assert_reachable!("receives some valid execution time observations");
812
813        let observations = self
814            .consensus_observations
815            .entry(observation_key)
816            .or_insert_with(|| {
817                let len = self.committee.num_members();
818                let mut empty_observations = Vec::with_capacity(len);
819                empty_observations.resize(len, (0, None));
820                ConsensusObservations {
821                    observations: empty_observations,
822                    stake_weighted_median: if self
823                        .protocol_params
824                        .default_none_duration_for_new_keys
825                    {
826                        None
827                    } else {
828                        Some(Duration::ZERO)
829                    },
830                }
831            });
832
833        let (obs_generation, obs_duration) =
834            &mut observations.observations[TryInto::<usize>::try_into(source).unwrap()];
835        if generation.is_some_and(|generation| *obs_generation >= generation) {
836            // Ignore outdated observation.
837            return;
838        }
839        *obs_generation = generation.unwrap_or(0);
840        *obs_duration = Some(duration);
841        if !skip_update {
842            observations.update_stake_weighted_median(&self.committee, &self.protocol_params);
843        }
844    }
845
846    pub fn get_estimate(&self, tx: &TransactionData) -> Duration {
847        let TransactionKind::ProgrammableTransaction(tx) = tx.kind() else {
848            debug_fatal!("get_estimate called on non-ProgrammableTransaction");
849            return Duration::ZERO;
850        };
851        tx.commands
852            .iter()
853            .map(|command| {
854                let key = ExecutionTimeObservationKey::from_command(command);
855                self.consensus_observations
856                    .get(&key)
857                    .and_then(|obs| obs.stake_weighted_median)
858                    .unwrap_or_else(|| key.default_duration())
859                    // For native commands, adjust duration by length of command's inputs/outputs.
860                    // This is sort of arbitrary, but hopefully works okay as a heuristic.
861                    .mul_f64(command_length(command).get() as f64)
862            })
863            .sum::<Duration>()
864            .min(Duration::from_micros(self.protocol_params.max_estimate_us))
865    }
866
867    pub fn take_observations(&mut self) -> StoredExecutionTimeObservations {
868        StoredExecutionTimeObservations::V1(
869            self.consensus_observations
870                .drain()
871                .map(|(key, observations)| {
872                    let observations = observations
873                        .observations
874                        .into_iter()
875                        .enumerate()
876                        .filter_map(|(idx, (_, duration))| {
877                            duration.map(|d| {
878                                (
879                                    self.committee
880                                        .authority_by_index(idx.try_into().unwrap())
881                                        .cloned()
882                                        .unwrap(),
883                                    d,
884                                )
885                            })
886                        })
887                        .collect();
888                    (key, observations)
889                })
890                .collect(),
891        )
892    }
893
894    pub fn get_observations(&self) -> Vec<(ExecutionTimeObservationKey, ConsensusObservations)> {
895        self.consensus_observations
896            .iter()
897            .map(|(key, observations)| (key.clone(), observations.clone()))
898            .collect()
899    }
900}
901
902fn command_length(command: &Command) -> NonZeroUsize {
903    // Commands with variable-length inputs/outputs are reported as +1
904    // to account for fixed overhead and prevent divide-by-zero.
905    NonZeroUsize::new(match command {
906        Command::MoveCall(_) => 1,
907        Command::TransferObjects(src, _) => src.len() + 1,
908        Command::SplitCoins(_, amts) => amts.len() + 1,
909        Command::MergeCoins(_, src) => src.len() + 1,
910        Command::Publish(_, _) => 1,
911        Command::MakeMoveVec(_, src) => src.len() + 1,
912        Command::Upgrade(_, _, _, _) => 1,
913    })
914    .unwrap()
915}
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920    use crate::authority::test_authority_builder::TestAuthorityBuilder;
921    use crate::checkpoints::CheckpointStore;
922    use crate::consensus_adapter::{
923        ConsensusAdapter, ConsensusAdapterMetrics, MockConsensusClient,
924    };
925    use std::collections::BTreeMap;
926    use sui_protocol_config::ProtocolConfig;
927    use sui_types::base_types::{ObjectID, SequenceNumber, SuiAddress};
928    use sui_types::transaction::{
929        Argument, CallArg, ObjectArg, ProgrammableMoveCall, SharedObjectMutability,
930    };
931    use {
932        rand::{Rng, SeedableRng},
933        sui_protocol_config::ProtocolVersion,
934        sui_types::supported_protocol_versions::Chain,
935    };
936
937    #[tokio::test]
938    async fn test_record_local_observations() {
939        telemetry_subscribers::init_for_testing();
940
941        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
942            config.set_per_object_congestion_control_mode_for_testing(
943                PerObjectCongestionControlMode::ExecutionTimeEstimate(
944                    ExecutionTimeEstimateParams {
945                        target_utilization: 100,
946                        allowed_txn_cost_overage_burst_limit_us: 0,
947                        randomness_scalar: 100,
948                        max_estimate_us: u64::MAX,
949                        stored_observations_num_included_checkpoints: 10,
950                        stored_observations_limit: u64::MAX,
951                        stake_weighted_median_threshold: 0,
952                        default_none_duration_for_new_keys: true,
953                        observations_chunk_size: Some(18),
954                    },
955                ),
956            );
957            config
958        });
959
960        let mock_consensus_client = MockConsensusClient::new();
961        let authority = TestAuthorityBuilder::new().build().await;
962        let epoch_store = authority.epoch_store_for_testing();
963        let consensus_adapter = Arc::new(ConsensusAdapter::new(
964            Arc::new(mock_consensus_client),
965            CheckpointStore::new_for_tests(),
966            authority.name,
967            100_000,
968            100_000,
969            ConsensusAdapterMetrics::new_test(),
970            Arc::new(tokio::sync::Notify::new()),
971        ));
972        let mut observer = ExecutionTimeObserver::new_for_testing(
973            epoch_store.clone(),
974            Box::new(consensus_adapter.clone()),
975            Duration::ZERO, // disable object utilization thresholds for this test
976            false,          // disable gas price weighting for this test
977        );
978
979        // Create a simple PTB with one move call
980        let package = ObjectID::random();
981        let module = "test_module".to_string();
982        let function = "test_function".to_string();
983        let ptb = ProgrammableTransaction {
984            inputs: vec![],
985            commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
986                package,
987                module: module.clone(),
988                function: function.clone(),
989                type_arguments: vec![],
990                arguments: vec![],
991            }))],
992        };
993
994        // Record an observation
995        let timings = vec![ExecutionTiming::Success(Duration::from_millis(100))];
996        let total_duration = Duration::from_millis(110);
997        observer.record_local_observations(&ptb, &timings, total_duration, 1);
998
999        let key = ExecutionTimeObservationKey::MoveEntryPoint {
1000            package,
1001            module: module.clone(),
1002            function: function.clone(),
1003            type_arguments: vec![],
1004        };
1005
1006        // Check that local observation was recorded and shared
1007        let local_obs = observer.local_observations.get(&key).unwrap();
1008        assert_eq!(
1009            local_obs.get_average(),
1010            // 10ms overhead should be entirely apportioned to the one command in the PTB
1011            Duration::from_millis(110)
1012        );
1013        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(110));
1014
1015        // Record another observation
1016        let timings = vec![ExecutionTiming::Success(Duration::from_millis(110))];
1017        let total_duration = Duration::from_millis(120);
1018        observer.record_local_observations(&ptb, &timings, total_duration, 1);
1019
1020        // Check that moving average was updated
1021        let local_obs = observer.local_observations.get(&key).unwrap();
1022        assert_eq!(
1023            local_obs.get_average(),
1024            // average of 110ms and 120ms observations
1025            Duration::from_millis(115)
1026        );
1027        // new 115ms average should not be shared; it's <5% different from 110ms
1028        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(110));
1029
1030        // Record another observation
1031        let timings = vec![ExecutionTiming::Success(Duration::from_millis(120))];
1032        let total_duration = Duration::from_millis(130);
1033        observer.record_local_observations(&ptb, &timings, total_duration, 1);
1034
1035        // Check that moving average was updated
1036        let local_obs = observer.local_observations.get(&key).unwrap();
1037        assert_eq!(
1038            local_obs.get_average(),
1039            // average of [110ms, 120ms, 130ms]
1040            Duration::from_millis(120)
1041        );
1042        // new 120ms average should not be shared; it's >5% different from 110ms,
1043        // but not enough time has passed
1044        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(110));
1045
1046        // Manually update last-shared time to long ago
1047        observer
1048            .local_observations
1049            .get_mut(&key)
1050            .unwrap()
1051            .last_shared = Some((
1052            Duration::from_millis(110),
1053            Instant::now() - Duration::from_secs(60),
1054        ));
1055
1056        // Record last observation
1057        let timings = vec![ExecutionTiming::Success(Duration::from_millis(120))];
1058        let total_duration = Duration::from_millis(160);
1059        observer.record_local_observations(&ptb, &timings, total_duration, 1);
1060
1061        // Verify that moving average is the same and a new observation was shared, as
1062        // enough time has now elapsed
1063        let local_obs = observer.local_observations.get(&key).unwrap();
1064        assert_eq!(
1065            local_obs.get_average(),
1066            // average of [110ms, 120ms, 130ms, 160ms]
1067            Duration::from_millis(130)
1068        );
1069        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(130));
1070    }
1071
1072    #[tokio::test]
1073    async fn test_record_local_observations_with_gas_price_weighting() {
1074        telemetry_subscribers::init_for_testing();
1075
1076        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
1077            config.set_per_object_congestion_control_mode_for_testing(
1078                PerObjectCongestionControlMode::ExecutionTimeEstimate(
1079                    ExecutionTimeEstimateParams {
1080                        target_utilization: 100,
1081                        allowed_txn_cost_overage_burst_limit_us: 0,
1082                        randomness_scalar: 100,
1083                        max_estimate_us: u64::MAX,
1084                        stored_observations_num_included_checkpoints: 10,
1085                        stored_observations_limit: u64::MAX,
1086                        stake_weighted_median_threshold: 0,
1087                        default_none_duration_for_new_keys: true,
1088                        observations_chunk_size: Some(18),
1089                    },
1090                ),
1091            );
1092            config
1093        });
1094
1095        let mock_consensus_client = MockConsensusClient::new();
1096        let authority = TestAuthorityBuilder::new().build().await;
1097        let epoch_store = authority.epoch_store_for_testing();
1098        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1099            Arc::new(mock_consensus_client),
1100            CheckpointStore::new_for_tests(),
1101            authority.name,
1102            100_000,
1103            100_000,
1104            ConsensusAdapterMetrics::new_test(),
1105            Arc::new(tokio::sync::Notify::new()),
1106        ));
1107        let mut observer = ExecutionTimeObserver::new_for_testing(
1108            epoch_store.clone(),
1109            Box::new(consensus_adapter.clone()),
1110            Duration::ZERO, // disable object utilization thresholds for this test
1111            true,           // enable gas price weighting for this test
1112        );
1113
1114        // Create a simple PTB with one move call
1115        let package = ObjectID::random();
1116        let module = "test_module".to_string();
1117        let function = "test_function".to_string();
1118        let ptb = ProgrammableTransaction {
1119            inputs: vec![],
1120            commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1121                package,
1122                module: module.clone(),
1123                function: function.clone(),
1124                type_arguments: vec![],
1125                arguments: vec![],
1126            }))],
1127        };
1128
1129        // Record an observation
1130        let timings = vec![ExecutionTiming::Success(Duration::from_millis(100))];
1131        let total_duration = Duration::from_millis(110);
1132        observer.record_local_observations(&ptb, &timings, total_duration, 1);
1133
1134        let key = ExecutionTimeObservationKey::MoveEntryPoint {
1135            package,
1136            module: module.clone(),
1137            function: function.clone(),
1138            type_arguments: vec![],
1139        };
1140
1141        // Check that local observation was recorded and shared
1142        let local_obs = observer.local_observations.get(&key).unwrap();
1143        assert_eq!(
1144            local_obs.get_average(),
1145            // 10ms overhead should be entirely apportioned to the one command in the PTB
1146            Duration::from_millis(110)
1147        );
1148        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(110));
1149
1150        // Record another observation
1151        let timings = vec![ExecutionTiming::Success(Duration::from_millis(110))];
1152        let total_duration = Duration::from_millis(120);
1153        observer.record_local_observations(&ptb, &timings, total_duration, 2);
1154
1155        // Check that weighted moving average was updated
1156        let local_obs = observer.local_observations.get(&key).unwrap();
1157        assert_eq!(
1158            local_obs.get_average(),
1159            // Our local observation averages are weighted by gas price:
1160            // 110ms * 1 + 110ms * 2 / (1 + 2) = 116.666ms
1161            Duration::from_micros(116_666)
1162        );
1163    }
1164
1165    #[tokio::test]
1166    async fn test_record_local_observations_with_multiple_commands() {
1167        telemetry_subscribers::init_for_testing();
1168
1169        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
1170            config.set_per_object_congestion_control_mode_for_testing(
1171                PerObjectCongestionControlMode::ExecutionTimeEstimate(
1172                    ExecutionTimeEstimateParams {
1173                        target_utilization: 100,
1174                        allowed_txn_cost_overage_burst_limit_us: 0,
1175                        randomness_scalar: 0,
1176                        max_estimate_us: u64::MAX,
1177                        stored_observations_num_included_checkpoints: 10,
1178                        stored_observations_limit: u64::MAX,
1179                        stake_weighted_median_threshold: 0,
1180                        default_none_duration_for_new_keys: true,
1181                        observations_chunk_size: Some(18),
1182                    },
1183                ),
1184            );
1185            config
1186        });
1187
1188        let mock_consensus_client = MockConsensusClient::new();
1189        let authority = TestAuthorityBuilder::new().build().await;
1190        let epoch_store = authority.epoch_store_for_testing();
1191        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1192            Arc::new(mock_consensus_client),
1193            CheckpointStore::new_for_tests(),
1194            authority.name,
1195            100_000,
1196            100_000,
1197            ConsensusAdapterMetrics::new_test(),
1198            Arc::new(tokio::sync::Notify::new()),
1199        ));
1200        let mut observer = ExecutionTimeObserver::new_for_testing(
1201            epoch_store.clone(),
1202            Box::new(consensus_adapter.clone()),
1203            Duration::ZERO, // disable object utilization thresholds for this test
1204            false,          // disable gas price weighting for this test
1205        );
1206
1207        // Create a PTB with multiple commands.
1208        let package = ObjectID::random();
1209        let module = "test_module".to_string();
1210        let function = "test_function".to_string();
1211        let ptb = ProgrammableTransaction {
1212            inputs: vec![],
1213            commands: vec![
1214                Command::MoveCall(Box::new(ProgrammableMoveCall {
1215                    package,
1216                    module: module.clone(),
1217                    function: function.clone(),
1218                    type_arguments: vec![],
1219                    arguments: vec![],
1220                })),
1221                Command::TransferObjects(
1222                    // Inputs don't exist above, but doesn't matter for this test.
1223                    vec![Argument::Input(1), Argument::Input(2)],
1224                    Argument::Input(0),
1225                ),
1226            ],
1227        };
1228        let timings = vec![
1229            ExecutionTiming::Success(Duration::from_millis(100)),
1230            ExecutionTiming::Success(Duration::from_millis(50)),
1231        ];
1232        let total_duration = Duration::from_millis(180);
1233        observer.record_local_observations(&ptb, &timings, total_duration, 1);
1234
1235        // Check that both commands were recorded
1236        let move_key = ExecutionTimeObservationKey::MoveEntryPoint {
1237            package,
1238            module: module.clone(),
1239            function: function.clone(),
1240            type_arguments: vec![],
1241        };
1242        let move_obs = observer.local_observations.get(&move_key).unwrap();
1243        assert_eq!(
1244            move_obs.get_average(),
1245            // 100/150 == 2/3 of 30ms overhead distributed to Move command
1246            Duration::from_millis(120)
1247        );
1248
1249        let transfer_obs = observer
1250            .local_observations
1251            .get(&ExecutionTimeObservationKey::TransferObjects)
1252            .unwrap();
1253        assert_eq!(
1254            transfer_obs.get_average(),
1255            // 50ms time before adjustments
1256            // 50/150 == 1/3 of 30ms overhead distributed to object xfer
1257            // 60ms adjusetd time / 3 command length == 20ms
1258            Duration::from_millis(20)
1259        );
1260    }
1261
1262    #[tokio::test]
1263    async fn test_record_local_observations_with_object_utilization_threshold() {
1264        telemetry_subscribers::init_for_testing();
1265
1266        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
1267            config.set_per_object_congestion_control_mode_for_testing(
1268                PerObjectCongestionControlMode::ExecutionTimeEstimate(
1269                    ExecutionTimeEstimateParams {
1270                        target_utilization: 100,
1271                        allowed_txn_cost_overage_burst_limit_us: 0,
1272                        randomness_scalar: 0,
1273                        max_estimate_us: u64::MAX,
1274                        stored_observations_num_included_checkpoints: 10,
1275                        stored_observations_limit: u64::MAX,
1276                        stake_weighted_median_threshold: 0,
1277                        default_none_duration_for_new_keys: true,
1278                        observations_chunk_size: Some(18),
1279                    },
1280                ),
1281            );
1282            config
1283        });
1284
1285        let mock_consensus_client = MockConsensusClient::new();
1286        let authority = TestAuthorityBuilder::new().build().await;
1287        let epoch_store = authority.epoch_store_for_testing();
1288        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1289            Arc::new(mock_consensus_client),
1290            CheckpointStore::new_for_tests(),
1291            authority.name,
1292            100_000,
1293            100_000,
1294            ConsensusAdapterMetrics::new_test(),
1295            Arc::new(tokio::sync::Notify::new()),
1296        ));
1297        let mut observer = ExecutionTimeObserver::new_for_testing(
1298            epoch_store.clone(),
1299            Box::new(consensus_adapter.clone()),
1300            Duration::from_millis(500), // only share observations with excess utilization >= 500ms
1301            false,                      // disable gas price weighting for this test
1302        );
1303
1304        // Create a simple PTB with one move call and one mutable shared input
1305        let package = ObjectID::random();
1306        let module = "test_module".to_string();
1307        let function = "test_function".to_string();
1308        let shared_object_id = ObjectID::random();
1309        let ptb = ProgrammableTransaction {
1310            inputs: vec![CallArg::Object(ObjectArg::SharedObject {
1311                id: shared_object_id,
1312                initial_shared_version: SequenceNumber::new(),
1313                mutability: SharedObjectMutability::Mutable,
1314            })],
1315            commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1316                package,
1317                module: module.clone(),
1318                function: function.clone(),
1319                type_arguments: vec![],
1320                arguments: vec![],
1321            }))],
1322        };
1323        let key = ExecutionTimeObservationKey::MoveEntryPoint {
1324            package,
1325            module: module.clone(),
1326            function: function.clone(),
1327            type_arguments: vec![],
1328        };
1329
1330        tokio::time::pause();
1331
1332        // First observation - should not share due to low utilization
1333        let timings = vec![ExecutionTiming::Success(Duration::from_secs(1))];
1334        observer.record_local_observations(&ptb, &timings, Duration::from_secs(2), 1);
1335        assert!(
1336            observer
1337                .local_observations
1338                .get(&key)
1339                .unwrap()
1340                .last_shared
1341                .is_none()
1342        );
1343
1344        // Second observation - no time has passed, so now utilization is high; should share upward change
1345        let timings = vec![ExecutionTiming::Success(Duration::from_secs(1))];
1346        observer.record_local_observations(&ptb, &timings, Duration::from_secs(2), 1);
1347        assert_eq!(
1348            observer
1349                .local_observations
1350                .get(&key)
1351                .unwrap()
1352                .last_shared
1353                .unwrap()
1354                .0,
1355            Duration::from_secs(2)
1356        );
1357
1358        // Third execution with significant upward diff and high utilization - should share again
1359        tokio::time::advance(Duration::from_secs(5)).await;
1360        let timings = vec![ExecutionTiming::Success(Duration::from_secs(3))];
1361        observer.record_local_observations(&ptb, &timings, Duration::from_secs(5), 1);
1362        assert_eq!(
1363            observer
1364                .local_observations
1365                .get(&key)
1366                .unwrap()
1367                .last_shared
1368                .unwrap()
1369                .0,
1370            Duration::from_secs(3)
1371        );
1372
1373        // Fourth execution with significant downward diff but still overutilized - should NOT share downward change
1374        // (downward changes are only shared for indebted objects, not overutilized ones)
1375        tokio::time::advance(Duration::from_millis(150)).await;
1376        let timings = vec![ExecutionTiming::Success(Duration::from_millis(100))];
1377        observer.record_local_observations(&ptb, &timings, Duration::from_millis(500), 1);
1378        assert_eq!(
1379            observer
1380                .local_observations
1381                .get(&key)
1382                .unwrap()
1383                .last_shared
1384                .unwrap()
1385                .0,
1386            Duration::from_secs(3) // still the old value, no sharing of downward change
1387        );
1388
1389        // Fifth execution after utilization drops - should not share upward diff since not overutilized
1390        tokio::time::advance(Duration::from_secs(60)).await;
1391        let timings = vec![ExecutionTiming::Success(Duration::from_secs(11))];
1392        observer.record_local_observations(&ptb, &timings, Duration::from_secs(11), 1);
1393        assert_eq!(
1394            observer
1395                .local_observations
1396                .get(&key)
1397                .unwrap()
1398                .last_shared
1399                .unwrap()
1400                .0,
1401            Duration::from_secs(3) // still the old value, no sharing when not overutilized
1402        );
1403    }
1404
1405    #[tokio::test]
1406    async fn test_object_utilization_metric_tracked_ids() {
1407        telemetry_subscribers::init_for_testing();
1408
1409        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
1410            config.set_per_object_congestion_control_mode_for_testing(
1411                PerObjectCongestionControlMode::ExecutionTimeEstimate(
1412                    ExecutionTimeEstimateParams {
1413                        target_utilization: 100,
1414                        allowed_txn_cost_overage_burst_limit_us: 0,
1415                        randomness_scalar: 0,
1416                        max_estimate_us: u64::MAX,
1417                        stored_observations_num_included_checkpoints: 10,
1418                        stored_observations_limit: u64::MAX,
1419                        stake_weighted_median_threshold: 0,
1420                        default_none_duration_for_new_keys: true,
1421                        observations_chunk_size: Some(18),
1422                    },
1423                ),
1424            );
1425            config
1426        });
1427
1428        let mock_consensus_client = MockConsensusClient::new();
1429        let authority = TestAuthorityBuilder::new().build().await;
1430        let epoch_store = authority.epoch_store_for_testing();
1431        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1432            Arc::new(mock_consensus_client),
1433            CheckpointStore::new_for_tests(),
1434            authority.name,
1435            100_000,
1436            100_000,
1437            ConsensusAdapterMetrics::new_test(),
1438            Arc::new(tokio::sync::Notify::new()),
1439        ));
1440        let mut observer = ExecutionTimeObserver::new_for_testing(
1441            epoch_store.clone(),
1442            Box::new(consensus_adapter.clone()),
1443            Duration::from_millis(500),
1444            false,
1445        );
1446
1447        // Both objects hash to the same bucket of the aggregate metric.
1448        let mut bytes = [0u8; ObjectID::LENGTH];
1449        bytes[ObjectID::LENGTH - 1] = 0x05;
1450        bytes[0] = 1;
1451        let tracked_id = ObjectID::new(bytes);
1452        bytes[0] = 2;
1453        let untracked_id = ObjectID::new(bytes);
1454        let bucket_key = "5";
1455        let tracked_name = "tracked-object";
1456        observer.config.object_utilization_metric_tracked_ids =
1457            Some(BTreeMap::from([(tracked_id, tracked_name.to_string())]));
1458        observer.tracked_object_counters =
1459            tracked_object_counters(&observer.config, &epoch_store.metrics);
1460
1461        let package = ObjectID::random();
1462        let make_ptb = |id: ObjectID| ProgrammableTransaction {
1463            inputs: vec![CallArg::Object(ObjectArg::SharedObject {
1464                id,
1465                initial_shared_version: SequenceNumber::new(),
1466                mutability: SharedObjectMutability::Mutable,
1467            })],
1468            commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1469                package,
1470                module: "test_module".to_string(),
1471                function: "test_function".to_string(),
1472                type_arguments: vec![],
1473                arguments: vec![],
1474            }))],
1475        };
1476        let tracked_ptb = make_ptb(tracked_id);
1477        let untracked_ptb = make_ptb(untracked_id);
1478        let aggregate_metric = &epoch_store
1479            .metrics
1480            .epoch_execution_time_observer_object_utilization;
1481        let tracked_metric = &epoch_store
1482            .metrics
1483            .epoch_execution_time_observer_tracked_object_utilization;
1484        let timings = vec![ExecutionTiming::Success(Duration::from_secs(1))];
1485
1486        tokio::time::pause();
1487
1488        // First observation: neither object is overutilized yet, so the aggregate metric is
1489        // untouched, but the tracked object is already reported in the tracked metric.
1490        observer.record_local_observations(&tracked_ptb, &timings, Duration::from_secs(2), 1);
1491        observer.record_local_observations(&untracked_ptb, &timings, Duration::from_secs(2), 1);
1492        assert_eq!(
1493            tracked_metric
1494                .with_label_values(&[tracked_id.to_string().as_str(), tracked_name])
1495                .get(),
1496            2.0
1497        );
1498        assert_eq!(aggregate_metric.with_label_values(&[bucket_key]).get(), 0.0);
1499
1500        // Second observation with no time elapsed: both objects are now overutilized and
1501        // both land in the aggregate bucket. Only the tracked object is in the tracked metric.
1502        observer.record_local_observations(&tracked_ptb, &timings, Duration::from_secs(2), 1);
1503        observer.record_local_observations(&untracked_ptb, &timings, Duration::from_secs(2), 1);
1504        assert_eq!(
1505            tracked_metric
1506                .with_label_values(&[tracked_id.to_string().as_str(), tracked_name])
1507                .get(),
1508            4.0
1509        );
1510        assert_eq!(
1511            tracked_metric
1512                .with_label_values(&[untracked_id.to_string().as_str(), tracked_name])
1513                .get(),
1514            0.0
1515        );
1516        assert_eq!(aggregate_metric.with_label_values(&[bucket_key]).get(), 4.0);
1517    }
1518
1519    #[tokio::test]
1520    async fn test_record_local_observations_with_indebted_objects() {
1521        telemetry_subscribers::init_for_testing();
1522
1523        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
1524            config.set_per_object_congestion_control_mode_for_testing(
1525                PerObjectCongestionControlMode::ExecutionTimeEstimate(
1526                    ExecutionTimeEstimateParams {
1527                        target_utilization: 100,
1528                        allowed_txn_cost_overage_burst_limit_us: 0,
1529                        randomness_scalar: 0,
1530                        max_estimate_us: u64::MAX,
1531                        stored_observations_num_included_checkpoints: 10,
1532                        stored_observations_limit: u64::MAX,
1533                        stake_weighted_median_threshold: 0,
1534                        default_none_duration_for_new_keys: true,
1535                        observations_chunk_size: Some(18),
1536                    },
1537                ),
1538            );
1539            config
1540        });
1541
1542        let mock_consensus_client = MockConsensusClient::new();
1543        let authority = TestAuthorityBuilder::new().build().await;
1544        let epoch_store = authority.epoch_store_for_testing();
1545        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1546            Arc::new(mock_consensus_client),
1547            CheckpointStore::new_for_tests(),
1548            authority.name,
1549            100_000,
1550            100_000,
1551            ConsensusAdapterMetrics::new_test(),
1552            Arc::new(tokio::sync::Notify::new()),
1553        ));
1554        let mut observer = ExecutionTimeObserver::new_for_testing(
1555            epoch_store.clone(),
1556            Box::new(consensus_adapter.clone()),
1557            Duration::from_millis(500), // Low utilization threshold to enable overutilized sharing initially
1558            false,                      // disable gas price weighting for this test
1559        );
1560
1561        // Create a simple PTB with one move call and one mutable shared input
1562        let package = ObjectID::random();
1563        let module = "test_module".to_string();
1564        let function = "test_function".to_string();
1565        let shared_object_id = ObjectID::random();
1566        let ptb = ProgrammableTransaction {
1567            inputs: vec![CallArg::Object(ObjectArg::SharedObject {
1568                id: shared_object_id,
1569                initial_shared_version: SequenceNumber::new(),
1570                mutability: SharedObjectMutability::Mutable,
1571            })],
1572            commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1573                package,
1574                module: module.clone(),
1575                function: function.clone(),
1576                type_arguments: vec![],
1577                arguments: vec![],
1578            }))],
1579        };
1580        let key = ExecutionTimeObservationKey::MoveEntryPoint {
1581            package,
1582            module: module.clone(),
1583            function: function.clone(),
1584            type_arguments: vec![],
1585        };
1586
1587        tokio::time::pause();
1588
1589        // First observation - should not share due to low utilization
1590        let timings = vec![ExecutionTiming::Success(Duration::from_secs(1))];
1591        observer.record_local_observations(&ptb, &timings, Duration::from_secs(1), 1);
1592        assert!(
1593            observer
1594                .local_observations
1595                .get(&key)
1596                .unwrap()
1597                .last_shared
1598                .is_none()
1599        );
1600
1601        // Second observation - no time has passed, so now utilization is high; should share upward change
1602        let timings = vec![ExecutionTiming::Success(Duration::from_secs(2))];
1603        observer.record_local_observations(&ptb, &timings, Duration::from_secs(2), 1);
1604        assert_eq!(
1605            observer
1606                .local_observations
1607                .get(&key)
1608                .unwrap()
1609                .last_shared
1610                .unwrap()
1611                .0,
1612            Duration::from_millis(1500) // (1s + 2s) / 2 = 1.5s
1613        );
1614
1615        // Mark the shared object as indebted and increase utilization threshold to prevent overutilized sharing
1616        observer.update_indebted_objects(vec![shared_object_id]);
1617        observer
1618            .config
1619            .observation_sharing_object_utilization_threshold = Some(Duration::from_secs(1000));
1620
1621        // Wait for min interval and record a significant downward change
1622        // This should share because the object is indebted
1623        tokio::time::advance(Duration::from_secs(60)).await;
1624        let timings = vec![ExecutionTiming::Success(Duration::from_millis(300))];
1625        observer.record_local_observations(&ptb, &timings, Duration::from_millis(300), 1);
1626
1627        // Moving average should be (1s + 2s + 0.3s) / 3 = 1.1s
1628        // This downward change should have been shared for indebted object
1629        assert_eq!(
1630            observer
1631                .local_observations
1632                .get(&key)
1633                .unwrap()
1634                .last_shared
1635                .unwrap()
1636                .0,
1637            Duration::from_millis(1100)
1638        );
1639    }
1640
1641    #[tokio::test]
1642    async fn test_stake_weighted_median() {
1643        telemetry_subscribers::init_for_testing();
1644
1645        let (committee, _) =
1646            Committee::new_simple_test_committee_with_normalized_voting_power(vec![10, 20, 30, 40]);
1647
1648        let params = ExecutionTimeEstimateParams {
1649            stake_weighted_median_threshold: 0,
1650            ..Default::default()
1651        };
1652
1653        let mut tracker = ConsensusObservations {
1654            observations: vec![
1655                (0, Some(Duration::from_secs(1))), // 10% stake
1656                (0, Some(Duration::from_secs(2))), // 20% stake
1657                (0, Some(Duration::from_secs(3))), // 30% stake
1658                (0, Some(Duration::from_secs(4))), // 40% stake
1659            ],
1660            stake_weighted_median: None,
1661        };
1662        tracker.update_stake_weighted_median(&committee, &params);
1663        // With stake weights [10,20,30,40]:
1664        // - Duration 1 covers 10% of stake
1665        // - Duration 2 covers 30% of stake (10+20)
1666        // - Duration 3 covers 60% of stake (10+20+30)
1667        // - Duration 4 covers 100% of stake
1668        // Median should be 3 since that's where we cross 50% of stake
1669        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(3)));
1670
1671        // Test duration sorting
1672        let mut tracker = ConsensusObservations {
1673            observations: vec![
1674                (0, Some(Duration::from_secs(3))), // 10% stake
1675                (0, Some(Duration::from_secs(4))), // 20% stake
1676                (0, Some(Duration::from_secs(1))), // 30% stake
1677                (0, Some(Duration::from_secs(2))), // 40% stake
1678            ],
1679            stake_weighted_median: None,
1680        };
1681        tracker.update_stake_weighted_median(&committee, &params);
1682        // With sorted stake weights [30,40,10,20]:
1683        // - Duration 1 covers 30% of stake
1684        // - Duration 2 covers 70% of stake (30+40)
1685        // - Duration 3 covers 80% of stake (30+40+10)
1686        // - Duration 4 covers 100% of stake
1687        // Median should be 2 since that's where we cross 50% of stake
1688        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(2)));
1689
1690        // Test with one missing observation
1691        let mut tracker = ConsensusObservations {
1692            observations: vec![
1693                (0, Some(Duration::from_secs(1))), // 10% stake
1694                (0, None),                         // 20% stake (missing)
1695                (0, Some(Duration::from_secs(3))), // 30% stake
1696                (0, Some(Duration::from_secs(4))), // 40% stake
1697            ],
1698            stake_weighted_median: None,
1699        };
1700        tracker.update_stake_weighted_median(&committee, &params);
1701        // With missing observation for 20% stake:
1702        // - Duration 1 covers 10% of stake
1703        // - Duration 3 covers 40% of stake (10+30)
1704        // - Duration 4 covers 80% of stake (10+30+40)
1705        // Median should be 4 since that's where we pass half of available stake (80% / 2 == 40%)
1706        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(4)));
1707
1708        // Test with multiple missing observations
1709        let mut tracker = ConsensusObservations {
1710            observations: vec![
1711                (0, Some(Duration::from_secs(1))), // 10% stake
1712                (0, Some(Duration::from_secs(2))), // 20% stake
1713                (0, None),                         // 30% stake (missing)
1714                (0, None),                         // 40% stake (missing)
1715            ],
1716            stake_weighted_median: None,
1717        };
1718        tracker.update_stake_weighted_median(&committee, &params);
1719        // With missing observations:
1720        // - Duration 1 covers 10% of stake
1721        // - Duration 2 covers 30% of stake (10+20)
1722        // Median should be 2 since that's where we cross half of available stake (40% / 2 == 20%)
1723        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(2)));
1724
1725        // Test with one observation
1726        let mut tracker = ConsensusObservations {
1727            observations: vec![
1728                (0, None),                         // 10% stake
1729                (0, None),                         // 20% stake
1730                (0, Some(Duration::from_secs(3))), // 30% stake
1731                (0, None),                         // 40% stake
1732            ],
1733            stake_weighted_median: None,
1734        };
1735        tracker.update_stake_weighted_median(&committee, &params);
1736        // With only one observation, median should be that observation
1737        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(3)));
1738
1739        // Test with all same durations
1740        let mut tracker = ConsensusObservations {
1741            observations: vec![
1742                (0, Some(Duration::from_secs(5))), // 10% stake
1743                (0, Some(Duration::from_secs(5))), // 20% stake
1744                (0, Some(Duration::from_secs(5))), // 30% stake
1745                (0, Some(Duration::from_secs(5))), // 40% stake
1746            ],
1747            stake_weighted_median: None,
1748        };
1749        tracker.update_stake_weighted_median(&committee, &params);
1750        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(5)));
1751    }
1752
1753    #[tokio::test]
1754    async fn test_stake_weighted_median_threshold() {
1755        telemetry_subscribers::init_for_testing();
1756
1757        let (committee, _) =
1758            Committee::new_simple_test_committee_with_normalized_voting_power(vec![10, 20, 30, 40]);
1759
1760        // Test with threshold requiring at least 50% stake
1761        let params = ExecutionTimeEstimateParams {
1762            stake_weighted_median_threshold: 5000,
1763            ..Default::default()
1764        };
1765
1766        // Test with insufficient stake (only 30% have observations)
1767        let mut tracker = ConsensusObservations {
1768            observations: vec![
1769                (0, Some(Duration::from_secs(1))), // 10% stake
1770                (0, Some(Duration::from_secs(2))), // 20% stake
1771                (0, None),                         // 30% stake (missing)
1772                (0, None),                         // 40% stake (missing)
1773            ],
1774            stake_weighted_median: None,
1775        };
1776        tracker.update_stake_weighted_median(&committee, &params);
1777        // Should not compute median since only 30% stake has observations (< 50% threshold)
1778        assert_eq!(tracker.stake_weighted_median, None);
1779
1780        // Test with sufficient stake (60% have observations)
1781        let mut tracker = ConsensusObservations {
1782            observations: vec![
1783                (0, Some(Duration::from_secs(1))), // 10% stake
1784                (0, Some(Duration::from_secs(2))), // 20% stake
1785                (0, Some(Duration::from_secs(3))), // 30% stake
1786                (0, None),                         // 40% stake (missing)
1787            ],
1788            stake_weighted_median: None,
1789        };
1790        tracker.update_stake_weighted_median(&committee, &params);
1791        // Should compute median since 60% stake has observations (>= 50% threshold)
1792        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(3)));
1793    }
1794
1795    #[tokio::test]
1796    async fn test_execution_time_estimator() {
1797        telemetry_subscribers::init_for_testing();
1798
1799        let (committee, _) =
1800            Committee::new_simple_test_committee_with_normalized_voting_power(vec![10, 20, 30, 40]);
1801        let mut estimator = ExecutionTimeEstimator::new(
1802            Arc::new(committee),
1803            ExecutionTimeEstimateParams {
1804                target_utilization: 50,
1805                max_estimate_us: 1_500_000,
1806
1807                // Not used in this test.
1808                allowed_txn_cost_overage_burst_limit_us: 0,
1809                randomness_scalar: 0,
1810                stored_observations_num_included_checkpoints: 10,
1811                stored_observations_limit: u64::MAX,
1812                stake_weighted_median_threshold: 0,
1813                default_none_duration_for_new_keys: true,
1814                observations_chunk_size: Some(18),
1815            },
1816            std::iter::empty(),
1817        );
1818        // Create test keys
1819        let package = ObjectID::random();
1820        let module = "test_module".to_string();
1821        let function = "test_function".to_string();
1822        let move_key = ExecutionTimeObservationKey::MoveEntryPoint {
1823            package,
1824            module: module.clone(),
1825            function: function.clone(),
1826            type_arguments: vec![],
1827        };
1828        let transfer_key = ExecutionTimeObservationKey::TransferObjects;
1829
1830        // Record observations from different validators
1831        // First record some old observations that should be ignored
1832        estimator.process_observation_from_consensus(
1833            0,
1834            Some(1),
1835            move_key.clone(),
1836            Duration::from_millis(1000),
1837            false,
1838        );
1839        estimator.process_observation_from_consensus(
1840            1,
1841            Some(1),
1842            move_key.clone(),
1843            Duration::from_millis(1000),
1844            false,
1845        );
1846        estimator.process_observation_from_consensus(
1847            2,
1848            Some(1),
1849            move_key.clone(),
1850            Duration::from_millis(1000),
1851            false,
1852        );
1853
1854        estimator.process_observation_from_consensus(
1855            0,
1856            Some(1),
1857            transfer_key.clone(),
1858            Duration::from_millis(500),
1859            false,
1860        );
1861        estimator.process_observation_from_consensus(
1862            1,
1863            Some(1),
1864            transfer_key.clone(),
1865            Duration::from_millis(500),
1866            false,
1867        );
1868        estimator.process_observation_from_consensus(
1869            2,
1870            Some(1),
1871            transfer_key.clone(),
1872            Duration::from_millis(500),
1873            false,
1874        );
1875
1876        // Now record newer observations that should be used
1877        estimator.process_observation_from_consensus(
1878            0,
1879            Some(2),
1880            move_key.clone(),
1881            Duration::from_millis(100),
1882            false,
1883        );
1884        estimator.process_observation_from_consensus(
1885            1,
1886            Some(2),
1887            move_key.clone(),
1888            Duration::from_millis(200),
1889            false,
1890        );
1891        estimator.process_observation_from_consensus(
1892            2,
1893            Some(2),
1894            move_key.clone(),
1895            Duration::from_millis(300),
1896            false,
1897        );
1898
1899        estimator.process_observation_from_consensus(
1900            0,
1901            Some(2),
1902            transfer_key.clone(),
1903            Duration::from_millis(50),
1904            false,
1905        );
1906        estimator.process_observation_from_consensus(
1907            1,
1908            Some(2),
1909            transfer_key.clone(),
1910            Duration::from_millis(60),
1911            false,
1912        );
1913        estimator.process_observation_from_consensus(
1914            2,
1915            Some(2),
1916            transfer_key.clone(),
1917            Duration::from_millis(70),
1918            false,
1919        );
1920
1921        // Try to record old observations again - these should be ignored
1922        estimator.process_observation_from_consensus(
1923            0,
1924            Some(1),
1925            move_key.clone(),
1926            Duration::from_millis(1000),
1927            false,
1928        );
1929        estimator.process_observation_from_consensus(
1930            1,
1931            Some(1),
1932            transfer_key.clone(),
1933            Duration::from_millis(500),
1934            false,
1935        );
1936        estimator.process_observation_from_consensus(
1937            2,
1938            Some(1),
1939            move_key.clone(),
1940            Duration::from_millis(1000),
1941            false,
1942        );
1943
1944        // Test single command transaction
1945        let single_move_tx = TransactionData::new_programmable(
1946            SuiAddress::ZERO,
1947            vec![],
1948            ProgrammableTransaction {
1949                inputs: vec![],
1950                commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1951                    package,
1952                    module: module.clone(),
1953                    function: function.clone(),
1954                    type_arguments: vec![],
1955                    arguments: vec![],
1956                }))],
1957            },
1958            100,
1959            100,
1960        );
1961
1962        // Should return median of move call observations (300ms)
1963        assert_eq!(
1964            estimator.get_estimate(&single_move_tx),
1965            Duration::from_millis(300)
1966        );
1967
1968        // Test multi-command transaction
1969        let multi_command_tx = TransactionData::new_programmable(
1970            SuiAddress::ZERO,
1971            vec![],
1972            ProgrammableTransaction {
1973                inputs: vec![],
1974                commands: vec![
1975                    Command::MoveCall(Box::new(ProgrammableMoveCall {
1976                        package,
1977                        module: module.clone(),
1978                        function: function.clone(),
1979                        type_arguments: vec![],
1980                        arguments: vec![],
1981                    })),
1982                    Command::TransferObjects(
1983                        vec![Argument::Input(1), Argument::Input(2)],
1984                        Argument::Input(0),
1985                    ),
1986                ],
1987            },
1988            100,
1989            100,
1990        );
1991
1992        // Should return sum of median move call (300ms)
1993        // plus the median transfer (70ms) * command length (3)
1994        assert_eq!(
1995            estimator.get_estimate(&multi_command_tx),
1996            Duration::from_millis(510)
1997        );
1998    }
1999
2000    #[derive(Debug, Clone, Serialize, Deserialize)]
2001    struct ExecutionTimeObserverSnapshot {
2002        protocol_version: u64,
2003        consensus_observations: Vec<(ExecutionTimeObservationKey, ConsensusObservations)>,
2004        transaction_estimates: Vec<(String, Duration)>, // (transaction_description, estimated_duration)
2005    }
2006
2007    fn generate_test_inputs(
2008        seed: u64,
2009        num_validators: usize,
2010        generation_override: Option<u64>,
2011    ) -> Vec<(
2012        AuthorityIndex,
2013        Option<u64>,
2014        ExecutionTimeObservationKey,
2015        Duration,
2016    )> {
2017        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
2018
2019        let observation_keys = [
2020            ExecutionTimeObservationKey::MoveEntryPoint {
2021                package: ObjectID::from_hex_literal("0x1").unwrap(),
2022                module: "coin".to_string(),
2023                function: "transfer".to_string(),
2024                type_arguments: vec![],
2025            },
2026            ExecutionTimeObservationKey::MoveEntryPoint {
2027                package: ObjectID::from_hex_literal("0x2").unwrap(),
2028                module: "nft".to_string(),
2029                function: "mint".to_string(),
2030                type_arguments: vec![],
2031            },
2032            ExecutionTimeObservationKey::TransferObjects,
2033            ExecutionTimeObservationKey::SplitCoins,
2034            ExecutionTimeObservationKey::MergeCoins,
2035            ExecutionTimeObservationKey::MakeMoveVec,
2036            ExecutionTimeObservationKey::Upgrade,
2037        ];
2038
2039        let mut inputs = Vec::new();
2040        let target_samples = 25;
2041
2042        for _ in 0..target_samples {
2043            let key = observation_keys[rng.gen_range(0..observation_keys.len())].clone();
2044            let authority_index =
2045                AuthorityIndex::try_from(rng.gen_range(0..num_validators)).unwrap();
2046
2047            // Use realistic range where newer generations might replace older ones
2048            let generation = generation_override.unwrap_or_else(|| rng.gen_range(1..=10));
2049
2050            // Generate duration based on key type with realistic variance
2051            // Sometimes generate zero values to test corner cases with byzantine validators
2052            let base_duration = if rng.gen_ratio(1, 20) {
2053                // 5% chance of zero duration to test corner cases
2054                0
2055            } else {
2056                match &key {
2057                    ExecutionTimeObservationKey::MoveEntryPoint { .. } => rng.gen_range(50..=500),
2058                    ExecutionTimeObservationKey::TransferObjects => rng.gen_range(10..=100),
2059                    ExecutionTimeObservationKey::SplitCoins => rng.gen_range(20..=80),
2060                    ExecutionTimeObservationKey::MergeCoins => rng.gen_range(15..=70),
2061                    ExecutionTimeObservationKey::MakeMoveVec => rng.gen_range(5..=30),
2062                    ExecutionTimeObservationKey::Upgrade => rng.gen_range(100..=1000),
2063                    ExecutionTimeObservationKey::Publish => rng.gen_range(200..=2000),
2064                }
2065            };
2066
2067            let duration = Duration::from_millis(base_duration);
2068
2069            inputs.push((authority_index, Some(generation), key, duration));
2070        }
2071
2072        inputs
2073    }
2074
2075    fn generate_test_transactions(seed: u64) -> Vec<(String, TransactionData)> {
2076        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
2077        let mut transactions = Vec::new();
2078
2079        let package3 = ObjectID::from_hex_literal("0x3").unwrap();
2080        transactions.push((
2081            "coin_transfer_call".to_string(),
2082            TransactionData::new_programmable(
2083                SuiAddress::ZERO,
2084                vec![],
2085                ProgrammableTransaction {
2086                    inputs: vec![],
2087                    commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
2088                        package: ObjectID::from_hex_literal("0x1").unwrap(),
2089                        module: "coin".to_string(),
2090                        function: "transfer".to_string(),
2091                        type_arguments: vec![],
2092                        arguments: vec![],
2093                    }))],
2094                },
2095                rng.gen_range(100..1000),
2096                rng.gen_range(100..1000),
2097            ),
2098        ));
2099
2100        transactions.push((
2101            "mixed_move_calls".to_string(),
2102            TransactionData::new_programmable(
2103                SuiAddress::ZERO,
2104                vec![],
2105                ProgrammableTransaction {
2106                    inputs: vec![],
2107                    commands: vec![
2108                        Command::MoveCall(Box::new(ProgrammableMoveCall {
2109                            package: ObjectID::from_hex_literal("0x1").unwrap(),
2110                            module: "coin".to_string(),
2111                            function: "transfer".to_string(),
2112                            type_arguments: vec![],
2113                            arguments: vec![],
2114                        })),
2115                        Command::MoveCall(Box::new(ProgrammableMoveCall {
2116                            package: ObjectID::from_hex_literal("0x2").unwrap(),
2117                            module: "nft".to_string(),
2118                            function: "mint".to_string(),
2119                            type_arguments: vec![],
2120                            arguments: vec![],
2121                        })),
2122                    ],
2123                },
2124                rng.gen_range(100..1000),
2125                rng.gen_range(100..1000),
2126            ),
2127        ));
2128
2129        transactions.push((
2130            "native_commands_with_observations".to_string(),
2131            TransactionData::new_programmable(
2132                SuiAddress::ZERO,
2133                vec![],
2134                ProgrammableTransaction {
2135                    inputs: vec![],
2136                    commands: vec![
2137                        Command::TransferObjects(vec![Argument::Input(0)], Argument::Input(1)),
2138                        Command::SplitCoins(Argument::Input(2), vec![Argument::Input(3)]),
2139                        Command::MergeCoins(Argument::Input(4), vec![Argument::Input(5)]),
2140                        Command::MakeMoveVec(None, vec![Argument::Input(6)]),
2141                    ],
2142                },
2143                rng.gen_range(100..1000),
2144                rng.gen_range(100..1000),
2145            ),
2146        ));
2147
2148        let num_objects = rng.gen_range(1..=5);
2149        transactions.push((
2150            format!("transfer_objects_{}_items", num_objects),
2151            TransactionData::new_programmable(
2152                SuiAddress::ZERO,
2153                vec![],
2154                ProgrammableTransaction {
2155                    inputs: vec![],
2156                    commands: vec![Command::TransferObjects(
2157                        (0..num_objects).map(Argument::Input).collect(),
2158                        Argument::Input(num_objects),
2159                    )],
2160                },
2161                rng.gen_range(100..1000),
2162                rng.gen_range(100..1000),
2163            ),
2164        ));
2165
2166        let num_amounts = rng.gen_range(1..=4);
2167        transactions.push((
2168            format!("split_coins_{}_amounts", num_amounts),
2169            TransactionData::new_programmable(
2170                SuiAddress::ZERO,
2171                vec![],
2172                ProgrammableTransaction {
2173                    inputs: vec![],
2174                    commands: vec![Command::SplitCoins(
2175                        Argument::Input(0),
2176                        (1..=num_amounts).map(Argument::Input).collect(),
2177                    )],
2178                },
2179                rng.gen_range(100..1000),
2180                rng.gen_range(100..1000),
2181            ),
2182        ));
2183
2184        let num_sources = rng.gen_range(1..=3);
2185        transactions.push((
2186            format!("merge_coins_{}_sources", num_sources),
2187            TransactionData::new_programmable(
2188                SuiAddress::ZERO,
2189                vec![],
2190                ProgrammableTransaction {
2191                    inputs: vec![],
2192                    commands: vec![Command::MergeCoins(
2193                        Argument::Input(0),
2194                        (1..=num_sources).map(Argument::Input).collect(),
2195                    )],
2196                },
2197                rng.gen_range(100..1000),
2198                rng.gen_range(100..1000),
2199            ),
2200        ));
2201
2202        let num_elements = rng.gen_range(0..=6);
2203        transactions.push((
2204            format!("make_move_vec_{}_elements", num_elements),
2205            TransactionData::new_programmable(
2206                SuiAddress::ZERO,
2207                vec![],
2208                ProgrammableTransaction {
2209                    inputs: vec![],
2210                    commands: vec![Command::MakeMoveVec(
2211                        None,
2212                        (0..num_elements).map(Argument::Input).collect(),
2213                    )],
2214                },
2215                rng.gen_range(100..1000),
2216                rng.gen_range(100..1000),
2217            ),
2218        ));
2219
2220        transactions.push((
2221            "mixed_commands".to_string(),
2222            TransactionData::new_programmable(
2223                SuiAddress::ZERO,
2224                vec![],
2225                ProgrammableTransaction {
2226                    inputs: vec![],
2227                    commands: vec![
2228                        Command::MoveCall(Box::new(ProgrammableMoveCall {
2229                            package: package3,
2230                            module: "game".to_string(),
2231                            function: "play".to_string(),
2232                            type_arguments: vec![],
2233                            arguments: vec![],
2234                        })),
2235                        Command::TransferObjects(
2236                            vec![Argument::Input(1), Argument::Input(2)],
2237                            Argument::Input(0),
2238                        ),
2239                        Command::SplitCoins(Argument::Input(3), vec![Argument::Input(4)]),
2240                    ],
2241                },
2242                rng.gen_range(100..1000),
2243                rng.gen_range(100..1000),
2244            ),
2245        ));
2246
2247        transactions.push((
2248            "upgrade_package".to_string(),
2249            TransactionData::new_programmable(
2250                SuiAddress::ZERO,
2251                vec![],
2252                ProgrammableTransaction {
2253                    inputs: vec![],
2254                    commands: vec![Command::Upgrade(
2255                        vec![],
2256                        vec![],
2257                        package3,
2258                        Argument::Input(0),
2259                    )],
2260                },
2261                rng.gen_range(100..1000),
2262                rng.gen_range(100..1000),
2263            ),
2264        ));
2265
2266        transactions
2267    }
2268
2269    // Safeguard against forking because of changes to the execution time estimator.
2270    //
2271    // Within an epoch, each estimator must reach the same conclusion about the observations and
2272    // stake_weighted_median from the observations shared by other validators, as this is used
2273    // for transaction ordering.
2274    //
2275    // Therefore; any change in the calculation of the observations or stake_weighted_median
2276    // not accompanied by a protocol version change may fork.
2277    //
2278    // This test uses snapshots of computed stake weighted median at particular protocol versions
2279    // to attempt to discover regressions that might fork.
2280    #[test]
2281    fn snapshot_tests() {
2282        println!("\n============================================================================");
2283        println!("!                                                                          !");
2284        println!("! IMPORTANT: never update snapshots from this test. only add new versions! !");
2285        println!("!                                                                          !");
2286        println!("============================================================================\n");
2287
2288        let max_version = ProtocolVersion::MAX.as_u64();
2289
2290        let test_versions: Vec<u64> = (max_version.saturating_sub(9)..=max_version).collect();
2291
2292        for version in test_versions {
2293            let protocol_version = ProtocolVersion::new(version);
2294            let protocol_config = ProtocolConfig::get_for_version(protocol_version, Chain::Unknown);
2295            let (committee, _) = Committee::new_simple_test_committee_of_size(4);
2296            let committee = Arc::new(committee);
2297
2298            let initial_generation =
2299                if let PerObjectCongestionControlMode::ExecutionTimeEstimate(params) =
2300                    protocol_config.per_object_congestion_control_mode()
2301                {
2302                    if params.default_none_duration_for_new_keys {
2303                        None
2304                    } else {
2305                        Some(0)
2306                    }
2307                } else {
2308                    Some(0) // fallback for versions without execution time estimate mode
2309                };
2310
2311            let initial_observations =
2312                generate_test_inputs(0, committee.num_members(), initial_generation);
2313            let mut estimator = ExecutionTimeEstimator::new(
2314                committee.clone(),
2315                ExecutionTimeEstimateParams {
2316                    max_estimate_us: u64::MAX, // Allow unlimited estimates for testing
2317                    ..ExecutionTimeEstimateParams::default()
2318                },
2319                initial_observations.into_iter(),
2320            );
2321
2322            let test_inputs = generate_test_inputs(version, committee.num_members(), None);
2323
2324            for (source, generation, observation_key, duration) in test_inputs {
2325                estimator.process_observation_from_consensus(
2326                    source,
2327                    generation,
2328                    observation_key,
2329                    duration,
2330                    false,
2331                );
2332            }
2333
2334            let mut final_observations = estimator.get_observations();
2335            final_observations.sort_by_key(|a| a.0.to_string());
2336
2337            let test_transactions = generate_test_transactions(version);
2338            let mut transaction_estimates = Vec::new();
2339            for (description, tx_data) in test_transactions {
2340                let estimate = estimator.get_estimate(&tx_data);
2341                transaction_estimates.push((description, estimate));
2342            }
2343
2344            let snapshot_data = ExecutionTimeObserverSnapshot {
2345                protocol_version: version,
2346                consensus_observations: final_observations.clone(),
2347                transaction_estimates,
2348            };
2349            insta::assert_yaml_snapshot!(
2350                format!("execution_time_observer_v{}", version),
2351                snapshot_data
2352            );
2353        }
2354    }
2355}