Skip to main content

consensus_core/
commit_observer.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{sync::Arc, time::Duration};
5
6use parking_lot::RwLock;
7use tokio::time::Instant;
8use tracing::info;
9
10use crate::{
11    CommitConsumerArgs, CommitConsumerMonitor, CommittedSubDag,
12    block::{BlockAPI, VerifiedBlock},
13    commit::{CommitAPI, load_committed_subdag_from_store},
14    commit_consumer::ReplayMode,
15    commit_finalizer::{CommitFinalizer, CommitFinalizerHandle},
16    context::Context,
17    dag_state::DagState,
18    error::ConsensusResult,
19    linearizer::Linearizer,
20    storage::Store,
21    task::spawn_blocking,
22    transaction_vote_tracker::TransactionVoteTracker,
23};
24
25/// Role of CommitObserver
26/// - Called by core when try_commit() returns newly committed leaders.
27/// - The newly committed leaders are sent to commit observer and then commit observer
28///   gets subdags for each leader via the commit interpreter (linearizer)
29/// - The committed subdags are sent as consensus output via an unbounded tokio channel.
30///
31/// Live output has no flow control; consensus backpressure is applied earlier at consensus
32/// input level and on commit sync. Full-epoch replay can optionally be paced by consumer
33/// acknowledgements during recovery.
34///
35/// Commit is persisted in store before the CommittedSubDag is sent to the commit handler.
36/// When Sui recovers, it blocks until the commits it knows about are recovered. So consensus
37/// must be able to quickly recover the commits it has sent to Sui.
38pub(crate) struct CommitObserver {
39    context: Arc<Context>,
40    dag_state: Arc<RwLock<DagState>>,
41    /// Persistent storage for blocks, commits and other consensus data.
42    store: Arc<dyn Store>,
43    transaction_vote_tracker: TransactionVoteTracker,
44    /// Component to deterministically collect subdags for committed leaders.
45    commit_interpreter: Linearizer,
46    /// Handle to an unbounded channel to send output commits.
47    commit_finalizer_handle: CommitFinalizerHandle,
48    consumer_monitor: Arc<CommitConsumerMonitor>,
49}
50
51impl CommitObserver {
52    pub(crate) async fn new(
53        context: Arc<Context>,
54        commit_consumer: CommitConsumerArgs,
55        dag_state: Arc<RwLock<DagState>>,
56        transaction_vote_tracker: TransactionVoteTracker,
57    ) -> Self {
58        let store = dag_state.read().store();
59        let commit_interpreter = Linearizer::new(context.clone(), dag_state.clone());
60        let commit_finalizer_handle = CommitFinalizer::start(
61            context.clone(),
62            dag_state.clone(),
63            transaction_vote_tracker.clone(),
64            commit_consumer.commit_sender.clone(),
65        );
66
67        let mut observer = Self {
68            context,
69            dag_state,
70            store,
71            transaction_vote_tracker,
72            commit_interpreter,
73            commit_finalizer_handle,
74            consumer_monitor: commit_consumer.monitor(),
75        };
76        observer.recover_and_send_commits(&commit_consumer).await;
77
78        // Recover blocks needed for future commits (and block proposals).
79        // Some blocks might have been recovered as committed blocks in recover_and_send_commits().
80        // They will just be ignored.
81        if let Err(e) = spawn_blocking({
82            let transaction_vote_tracker = observer.transaction_vote_tracker.clone();
83            let gc_round = observer.dag_state.read().gc_round();
84            move || {
85                transaction_vote_tracker.recover_blocks_after_round(gc_round);
86            }
87        })
88        .await
89        {
90            info!("Skipping block recovery for transaction voting: {e}");
91        }
92
93        observer
94    }
95
96    pub(crate) async fn stop(&mut self) {
97        self.commit_finalizer_handle.stop().await;
98    }
99
100    /// Creates and returns a list of committed subdags containing committed blocks, from a sequence
101    /// of selected leader blocks, and whether they come from local committer or commit sync remotely.
102    ///
103    /// Also, buffers the commits to DagState and forwards committed subdags to commit finalizer.
104    pub(crate) fn handle_committed_leaders(
105        &mut self,
106        committed_leaders: Vec<VerifiedBlock>,
107        local: bool,
108    ) -> ConsensusResult<Vec<CommittedSubDag>> {
109        let _s = self
110            .context
111            .metrics
112            .node_metrics
113            .scope_processing_time
114            .with_label_values(&["CommitObserver::handle_committed_leaders"])
115            .start_timer();
116
117        let mut committed_sub_dags = self.commit_interpreter.handle_commit(committed_leaders);
118        self.report_metrics(&committed_sub_dags);
119
120        // Set if the commit is produced from local DAG, or received through commit sync.
121        for subdag in committed_sub_dags.iter_mut() {
122            subdag.decided_with_local_blocks = local;
123        }
124
125        for commit in committed_sub_dags.iter() {
126            tracing::debug!(
127                "Sending commit {} leader {} to finalization and execution.",
128                commit.commit_ref,
129                commit.leader
130            );
131            tracing::trace!("Committed subdag: {:#?}", commit);
132            // Failures in sender.send() are assumed to be permanent
133            self.send_to_finalizer(commit.clone())?;
134        }
135
136        self.dag_state
137            .write()
138            .add_scoring_subdags(committed_sub_dags.clone());
139
140        Ok(committed_sub_dags)
141    }
142
143    /// Forwards a committed subdag to the commit finalizer. Used by `Core::post_commit`.
144    pub(crate) fn send_to_finalizer(&self, subdag: CommittedSubDag) -> ConsensusResult<()> {
145        // Both the legacy and v3 commit paths report progress before consumer work.
146        self.consumer_monitor
147            .report_committed(subdag.commit_ref.index, subdag.leader.round);
148        self.commit_finalizer_handle.send(subdag)
149    }
150
151    async fn recover_and_send_commits(&mut self, commit_consumer: &CommitConsumerArgs) {
152        let now = Instant::now();
153
154        let replay_after_commit_index = match commit_consumer.replay_mode {
155            ReplayMode::FromWatermark {
156                replay_after_commit_index,
157                ..
158            } => replay_after_commit_index,
159            ReplayMode::FullEpochPaced => 0,
160        };
161
162        let last_commit = self
163            .store
164            .read_last_commit()
165            .expect("Reading the last commit should not fail");
166        if let Some(commit) = &last_commit {
167            self.consumer_monitor
168                .report_committed(commit.index(), commit.leader().round);
169        }
170        // Storage ownership, target discovery, and commit decoding stay here.
171        // Consumers rebuilding from empty never supply a second durable cursor.
172        let replay_target = if matches!(commit_consumer.replay_mode, ReplayMode::FullEpochPaced) {
173            let target = if self.context.protocol_config.transaction_voting_enabled() {
174                self.store
175                    .read_last_finalized_commit()
176                    .expect("Reading the last finalized commit should not fail")
177                    .map_or(0, |commit| commit.index)
178            } else {
179                last_commit.as_ref().map_or(0, |commit| commit.index())
180            };
181            assert!(
182                target <= last_commit.as_ref().map_or(0, |commit| commit.index()),
183                "finalized replay target cannot exceed the consensus store head"
184            );
185            self.consumer_monitor.set_replay_target(target);
186            target
187        } else {
188            0
189        };
190        let Some(last_commit) = &last_commit else {
191            assert_eq!(
192                replay_after_commit_index, 0,
193                "Commit replay should start at the beginning if there is no commit history"
194            );
195            info!("Nothing to recover for commit observer - starting new epoch");
196            return;
197        };
198
199        let last_commit_index = last_commit.index();
200        if last_commit_index == replay_after_commit_index {
201            info!(
202                "Nothing to recover for commit observer - replay is requested immediately after last commit index {last_commit_index}"
203            );
204            return;
205        }
206        assert!(last_commit_index > replay_after_commit_index);
207
208        info!(
209            "Recovering commit observer in the range [{}..={last_commit_index}]",
210            replay_after_commit_index + 1,
211        );
212
213        // To avoid scanning too many commits at once and load in memory,
214        // we limit the batch size to 250 and iterate over.
215        const COMMIT_RECOVERY_BATCH_SIZE: u32 = if cfg!(test) { 3 } else { 250 };
216
217        let mut last_sent_commit_index = replay_after_commit_index;
218
219        // Make sure that there is no pending commits to be written to the store.
220        self.dag_state.read().ensure_commits_to_write_is_empty();
221
222        let mut seen_unfinalized_commit = false;
223        for start_index in (replay_after_commit_index + 1..=last_commit_index)
224            .step_by(COMMIT_RECOVERY_BATCH_SIZE as usize)
225        {
226            let end_index = start_index
227                .saturating_add(COMMIT_RECOVERY_BATCH_SIZE - 1)
228                .min(last_commit_index);
229
230            let unsent_commits = self
231                .store
232                .scan_commits((start_index..=end_index).into())
233                .expect("Scanning commits should not fail");
234            assert_eq!(
235                unsent_commits.len() as u32,
236                end_index.checked_sub(start_index).unwrap() + 1,
237                "Gap in scanned commits: start index: {start_index}, end index: {end_index}, commits: {:?}",
238                unsent_commits,
239            );
240
241            // Buffered unsent commits in DAG state which is required to contain them when they are flushed
242            // by CommitFinalizer.
243            self.dag_state
244                .write()
245                .recover_commits_to_write(unsent_commits.clone());
246
247            info!(
248                "Recovering {} unsent commits in range [{start_index}..={end_index}]",
249                unsent_commits.len()
250            );
251
252            // Resend all the committed subdags to the consensus output channel
253            // for all the commits above the last processed index.
254            for commit in unsent_commits.into_iter() {
255                // Commit index must be continuous.
256                last_sent_commit_index += 1;
257                assert_eq!(commit.index(), last_sent_commit_index);
258
259                let committed_sub_dag =
260                    load_committed_subdag_from_store(self.store.as_ref(), commit);
261
262                if matches!(commit_consumer.replay_mode, ReplayMode::FullEpochPaced)
263                    && self.context.protocol_config.transaction_voting_enabled()
264                    && committed_sub_dag.commit_ref.index <= replay_target
265                {
266                    // Finalized commits must have rejection metadata, even if empty.
267                    // Without it, finalization may need later commits while replay
268                    // waits for an acknowledgement, blocking startup.
269                    assert!(
270                        committed_sub_dag.recovered_rejected_transactions,
271                        "missing rejected transactions below the finalized replay target"
272                    );
273                }
274
275                if !committed_sub_dag.recovered_rejected_transactions && !seen_unfinalized_commit {
276                    info!(
277                        "Starting to recover unfinalized commit from {}",
278                        committed_sub_dag.commit_ref
279                    );
280                    // When the commit has no associated storage entry for rejected transactions,
281                    // not even an empty set, the commit is unfinalized.
282                    seen_unfinalized_commit = true;
283                }
284
285                if seen_unfinalized_commit {
286                    // After observing the first unfinalized commit, the rest of recovered commits should all be unfinalized.
287                    assert!(!committed_sub_dag.recovered_rejected_transactions);
288                    // All unfinalized commit cannot be assumed to be decided with local blocks, because they
289                    // might have been received through commit sync.
290                    assert!(!committed_sub_dag.decided_with_local_blocks);
291                    // All unfinalized commits need to be processed by the CommitFinalizer, making it necessary to
292                    // recover and vote on the blocks in this commit.
293                    self.transaction_vote_tracker
294                        .recover_and_vote_on_blocks(committed_sub_dag.blocks.clone());
295                }
296
297                self.commit_finalizer_handle
298                    .send(committed_sub_dag)
299                    .unwrap();
300
301                self.context
302                    .metrics
303                    .node_metrics
304                    .commit_observer_last_recovered_commit_index
305                    .set(last_sent_commit_index as i64);
306
307                tokio::task::yield_now().await;
308            }
309            if matches!(commit_consumer.replay_mode, ReplayMode::FullEpochPaced) {
310                // Bound queued finalized history across BOTH the finalizer and
311                // consumer channels. Yielding alone does not apply backpressure.
312                // Do not await the unfinalized tail: it can require live consensus
313                // to finalize, and startup must be allowed to reach that phase.
314                tokio::select! {
315                    biased;
316                    () = self.consumer_monitor.wait_for_handled(end_index.min(replay_target)) => {},
317                    () = commit_consumer.commit_sender.closed() => {
318                        panic!("consensus commit consumer closed during startup replay");
319                    }
320                }
321            }
322        }
323
324        assert_eq!(
325            last_sent_commit_index, last_commit_index,
326            "We should have sent all commits up to the last commit {}",
327            last_commit_index
328        );
329
330        info!(
331            "Commit observer recovery [{}..={}] completed, took {:?}",
332            replay_after_commit_index + 1,
333            last_commit_index,
334            now.elapsed()
335        );
336    }
337
338    /// Reports per-commit metrics and logs the commit. Called for every commit on the
339    /// legacy path via `report_metrics`, and directly by `Core::post_commit` on the v3 path.
340    pub(crate) fn report_commit_metrics(&self, commit: &CommittedSubDag) {
341        let metrics = &self.context.metrics.node_metrics;
342        let utc_now = self.context.clock.timestamp_utc_ms();
343
344        info!(
345            "Consensus commit {} with leader {} has {} blocks",
346            commit.commit_ref,
347            commit.leader,
348            commit.blocks.len()
349        );
350
351        metrics
352            .last_committed_leader_round
353            .set(commit.leader.round as i64);
354        metrics
355            .last_commit_index
356            .set(commit.commit_ref.index as i64);
357        metrics
358            .blocks_per_commit_count
359            .observe(commit.blocks.len() as f64);
360
361        for block in &commit.blocks {
362            let latency_ms = utc_now.saturating_sub(block.timestamp_ms());
363            metrics
364                .block_commit_latency
365                .observe(Duration::from_millis(latency_ms).as_secs_f64());
366            if block.author() == self.context.own_index {
367                metrics
368                    .proposed_block_commit_latency
369                    .observe(Duration::from_millis(latency_ms).as_secs_f64());
370            }
371        }
372    }
373
374    fn report_metrics(&self, committed: &[CommittedSubDag]) {
375        for commit in committed {
376            self.report_commit_metrics(commit);
377        }
378        // Only the legacy path batches multiple subdags per call. The v3 path
379        // handles one commit at a time, so this metric would always observe 1
380        // and is intentionally not reported there.
381        self.context
382            .metrics
383            .node_metrics
384            .sub_dags_per_commit_count
385            .observe(committed.len() as f64);
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use consensus_types::block::BlockRef;
392    use mysten_metrics::monitored_mpsc::UnboundedReceiver;
393    use parking_lot::RwLock;
394    use rstest::rstest;
395    use tokio::time::timeout;
396
397    use super::*;
398    use crate::{
399        CommitIndex, block_verifier::NoopBlockVerifier, context::Context, dag_state::DagState,
400        linearizer::median_timestamp_by_stake, storage::mem_store::MemStore,
401        test_dag_builder::DagBuilder,
402    };
403
404    fn full_replay_fixture(
405        commits: u32,
406        finalized: u32,
407        voting: bool,
408    ) -> (Arc<Context>, Arc<RwLock<DagState>>, TransactionVoteTracker) {
409        let mut config = consensus_config::ConsensusProtocolConfig::for_testing();
410        config.set_transaction_voting_enabled_for_testing(voting);
411        let context = Arc::new(Context::new_for_test(4).0.with_protocol_config(config));
412        let store = Arc::new(MemStore::new());
413        if commits > 0 {
414            let mut builder = DagBuilder::new(context.clone());
415            builder.layers(1..=(commits * 2 + 2)).build();
416            let produced = builder.get_sub_dag_and_commits(1..=commits);
417            assert!(produced.len() >= commits as usize);
418            for (index, (subdag, commit)) in produced.into_iter().take(commits as usize).enumerate()
419            {
420                let finalized_rows = if (index as u32) < finalized {
421                    vec![(commit.reference(), std::collections::BTreeMap::new())]
422                } else {
423                    Vec::new()
424                };
425                store
426                    .write(crate::storage::WriteBatch::new(
427                        subdag.blocks,
428                        vec![commit],
429                        Vec::new(),
430                        finalized_rows,
431                    ))
432                    .unwrap();
433            }
434        }
435        let dag_state = Arc::new(RwLock::new(DagState::new(context.clone(), store)));
436        let tracker = TransactionVoteTracker::new(
437            context.clone(),
438            Arc::new(NoopBlockVerifier {}),
439            dag_state.clone(),
440        );
441        (context, dag_state, tracker)
442    }
443
444    #[tokio::test]
445    async fn supplied_watermark_replay_keeps_its_target_without_waiting_for_acknowledgements() {
446        let (context, dag_state, tracker) = full_replay_fixture(7, 7, true);
447        let (args, mut receiver) = CommitConsumerArgs::new(2, 4);
448        let monitor = args.monitor();
449        assert_eq!(monitor.replay_target(), Some(4));
450        assert_eq!(monitor.highest_handled_commit(), 2);
451        let mut observer = timeout(
452            Duration::from_secs(5),
453            CommitObserver::new(context, args, dag_state, tracker),
454        )
455        .await
456        .expect("legacy replay must not require application acknowledgements during startup");
457        assert_eq!(monitor.replay_target(), Some(4));
458        assert_eq!(monitor.progress().highest_committed_index, 7);
459        assert_eq!(monitor.highest_handled_commit(), 2);
460        for index in 3..=7 {
461            let commit = timeout(Duration::from_secs(5), receiver.recv())
462                .await
463                .unwrap()
464                .unwrap();
465            assert_eq!(commit.commit_ref.index, index);
466        }
467        monitor.set_highest_handled_commit(3);
468        assert!(
469            timeout(
470                Duration::from_millis(20),
471                monitor.replay_to_consumer_last_processed_commit_complete(),
472            )
473            .await
474            .is_err()
475        );
476        monitor.set_highest_handled_commit(4);
477        timeout(
478            Duration::from_secs(1),
479            monitor.replay_to_consumer_last_processed_commit_complete(),
480        )
481        .await
482        .expect("legacy readiness uses the supplied watermark, not the stored head");
483        observer.stop().await;
484    }
485
486    #[tokio::test]
487    async fn full_replay_is_paced_by_applied_commits_even_without_finalized_rows() {
488        let (context, dag_state, tracker) = full_replay_fixture(7, 0, false);
489        let stored_head_round = dag_state.read().last_commit_round();
490        let (args, mut receiver) = CommitConsumerArgs::new_with_full_replay();
491        let monitor = args.monitor();
492        let recovery = tokio::spawn(CommitObserver::new(context, args, dag_state, tracker));
493        for index in 1..=3 {
494            let commit = timeout(Duration::from_secs(5), receiver.recv())
495                .await
496                .unwrap()
497                .unwrap();
498            assert_eq!(commit.commit_ref.index, index);
499            assert_eq!(monitor.replay_target(), Some(7));
500            assert_eq!(monitor.progress().highest_committed_index, 7);
501            assert_eq!(
502                monitor.progress().highest_committed_round,
503                stored_head_round
504            );
505            if index < 3 {
506                monitor.set_highest_handled_commit(index);
507            }
508        }
509        // Receiving is not applying. Withhold the last acknowledgement and
510        // prove a second storage batch cannot get queued behind this one.
511        assert!(
512            timeout(Duration::from_millis(100), receiver.recv())
513                .await
514                .is_err(),
515            "recovery must wait for the consumer to apply the current batch"
516        );
517        assert!(!recovery.is_finished());
518        monitor.set_highest_handled_commit(3);
519        for index in 4..=7 {
520            let commit = timeout(Duration::from_secs(5), receiver.recv())
521                .await
522                .unwrap()
523                .unwrap();
524            assert_eq!(commit.commit_ref.index, index);
525            monitor.set_highest_handled_commit(index);
526        }
527        let mut observer = timeout(Duration::from_secs(5), recovery)
528            .await
529            .unwrap()
530            .unwrap();
531        monitor
532            .replay_to_consumer_last_processed_commit_complete()
533            .await;
534        observer.stop().await;
535    }
536
537    #[rstest]
538    #[case(0)]
539    #[case(3)]
540    #[tokio::test]
541    async fn full_replay_does_not_wait_for_an_unfinalized_tail(#[case] finalized: u32) {
542        let (context, dag_state, tracker) = full_replay_fixture(7, finalized, true);
543        let (args, mut receiver) = CommitConsumerArgs::new_with_full_replay();
544        let monitor = args.monitor();
545        let recovery = tokio::spawn(CommitObserver::new(context, args, dag_state, tracker));
546        for index in 1..=finalized {
547            let commit = timeout(Duration::from_secs(5), receiver.recv())
548                .await
549                .unwrap()
550                .unwrap();
551            assert_eq!(commit.commit_ref.index, index);
552            monitor.set_highest_handled_commit(index);
553        }
554        let mut observer = timeout(Duration::from_secs(5), recovery)
555            .await
556            .expect("unfinalized commits must not prevent live consensus startup")
557            .unwrap();
558        assert_eq!(monitor.replay_target(), Some(finalized));
559        assert_eq!(monitor.highest_handled_commit(), finalized);
560        monitor
561            .replay_to_consumer_last_processed_commit_complete()
562            .await;
563        observer.stop().await;
564    }
565
566    #[tokio::test]
567    async fn full_replay_of_an_empty_store_publishes_an_empty_target() {
568        let (context, dag_state, tracker) = full_replay_fixture(0, 0, false);
569        let (args, _receiver) = CommitConsumerArgs::new_with_full_replay();
570        let monitor = args.monitor();
571        assert_eq!(monitor.replay_target(), None);
572        let mut observer = CommitObserver::new(context, args, dag_state, tracker).await;
573        assert_eq!(monitor.replay_target(), Some(0));
574        timeout(
575            Duration::from_secs(1),
576            monitor.replay_to_consumer_last_processed_commit_complete(),
577        )
578        .await
579        .unwrap();
580        observer.stop().await;
581    }
582
583    #[tokio::test]
584    async fn a_closed_full_replay_consumer_does_not_leave_startup_waiting() {
585        let (context, dag_state, tracker) = full_replay_fixture(7, 0, false);
586        let (args, mut receiver) = CommitConsumerArgs::new_with_full_replay();
587        let recovery = tokio::spawn(CommitObserver::new(context, args, dag_state, tracker));
588        for _ in 0..3 {
589            timeout(Duration::from_secs(5), receiver.recv())
590                .await
591                .unwrap()
592                .unwrap();
593        }
594        drop(receiver);
595        let error = timeout(Duration::from_secs(5), recovery)
596            .await
597            .unwrap()
598            .err()
599            .expect(
600                "a vanished consumer must fail startup instead of waiting for its acknowledgement",
601            );
602        assert!(error.is_panic());
603        assert!(
604            error
605                .to_string()
606                .contains("consensus commit consumer closed during startup replay")
607        );
608    }
609
610    #[tokio::test]
611    #[should_panic(expected = "finalized replay target cannot exceed the consensus store head")]
612    async fn a_finalized_target_beyond_stored_history_fails_replay() {
613        let (context, dag_state, tracker) = full_replay_fixture(3, 3, true);
614        let store = dag_state.read().store();
615        let mut missing = store.read_last_commit().unwrap().unwrap().reference();
616        missing.index += 1;
617        store
618            .write(crate::storage::WriteBatch::new(
619                Vec::new(),
620                Vec::new(),
621                Vec::new(),
622                vec![(missing, std::collections::BTreeMap::new())],
623            ))
624            .unwrap();
625        let (args, _receiver) = CommitConsumerArgs::new_with_full_replay();
626        CommitObserver::new(context, args, dag_state, tracker).await;
627    }
628
629    #[tokio::test]
630    async fn a_finalization_hole_below_the_replay_target_fails_startup() {
631        let (context, dag_state, tracker) = full_replay_fixture(5, 0, true);
632        let store = dag_state.read().store();
633        let finalized = store
634            .scan_commits((1..=5).into())
635            .unwrap()
636            .into_iter()
637            .filter(|commit| commit.index() != 3)
638            .map(|commit| (commit.reference(), std::collections::BTreeMap::new()))
639            .collect();
640        store
641            .write(crate::storage::WriteBatch::new(
642                Vec::new(),
643                Vec::new(),
644                Vec::new(),
645                finalized,
646            ))
647            .unwrap();
648        let (args, mut receiver) = CommitConsumerArgs::new_with_full_replay();
649        let monitor = args.monitor();
650        let consume = tokio::spawn(async move {
651            while let Some(commit) = receiver.recv().await {
652                monitor.set_highest_handled_commit(commit.commit_ref.index);
653            }
654        });
655        let recovery = tokio::spawn(CommitObserver::new(context, args, dag_state, tracker));
656        let error = timeout(Duration::from_secs(5), recovery)
657            .await
658            .unwrap()
659            .err()
660            .expect("a hole in finalized history must fail startup");
661        assert!(error.is_panic());
662        assert!(
663            error
664                .to_string()
665                .contains("missing rejected transactions below the finalized replay target")
666        );
667        consume.abort();
668    }
669
670    #[rstest]
671    #[tokio::test]
672    async fn test_handle_commit() {
673        telemetry_subscribers::init_for_testing();
674        let num_authorities = 4;
675        let (context, _keys) = Context::new_for_test(num_authorities);
676        let context = Arc::new(context);
677
678        let mem_store = Arc::new(MemStore::new());
679        let dag_state = Arc::new(RwLock::new(DagState::new(
680            context.clone(),
681            mem_store.clone(),
682        )));
683        let last_processed_commit_index = 0;
684        let (commit_consumer, mut commit_receiver) =
685            CommitConsumerArgs::new(0, last_processed_commit_index);
686        let transaction_vote_tracker = TransactionVoteTracker::new(
687            context.clone(),
688            Arc::new(NoopBlockVerifier {}),
689            dag_state.clone(),
690        );
691
692        let mut observer = CommitObserver::new(
693            context.clone(),
694            commit_consumer,
695            dag_state.clone(),
696            transaction_vote_tracker.clone(),
697        )
698        .await;
699
700        // Populate fully connected test blocks for round 0 ~ 10, authorities 0 ~ 3.
701        let num_rounds = 10;
702        let mut builder = DagBuilder::new(context.clone());
703        builder
704            .layers(1..=num_rounds)
705            .build()
706            .persist_layers(dag_state.clone());
707        transaction_vote_tracker.add_voted_blocks(
708            builder
709                .all_blocks()
710                .iter()
711                .map(|b| (b.clone(), vec![]))
712                .collect(),
713        );
714
715        let leaders = builder
716            .leader_blocks(1..=num_rounds)
717            .into_iter()
718            .map(Option::unwrap)
719            .collect::<Vec<_>>();
720
721        let commits = observer
722            .handle_committed_leaders(leaders.clone(), true)
723            .unwrap();
724        let head = commits.last().unwrap();
725        assert_eq!(
726            observer.consumer_monitor.progress().highest_committed_index,
727            head.commit_ref.index
728        );
729        assert_eq!(
730            observer.consumer_monitor.progress().highest_committed_round,
731            head.leader.round
732        );
733        assert_eq!(observer.consumer_monitor.highest_handled_commit(), 0);
734
735        // Check commits are returned by CommitObserver::handle_committed_leaders is accurate
736        let mut expected_stored_refs: Vec<BlockRef> = vec![];
737        for (idx, subdag) in commits.iter().enumerate() {
738            tracing::info!("{subdag:?}");
739            assert_eq!(subdag.leader, leaders[idx].reference());
740
741            let expected_ts = {
742                let block_refs = leaders[idx]
743                    .ancestors()
744                    .iter()
745                    .filter(|block_ref| block_ref.round == leaders[idx].round() - 1)
746                    .cloned()
747                    .collect::<Vec<_>>();
748                let block_opts = dag_state.read().get_blocks(&block_refs);
749                let blocks = block_opts.iter().map(|block_opt| {
750                    block_opt
751                        .as_ref()
752                        .expect("We should have all blocks in dag state.")
753                });
754                median_timestamp_by_stake(&context, blocks).unwrap()
755            };
756
757            let expected_ts = if idx == 0 {
758                expected_ts
759            } else {
760                expected_ts.max(commits[idx - 1].timestamp_ms)
761            };
762
763            assert_eq!(expected_ts, subdag.timestamp_ms);
764
765            if idx == 0 {
766                // First subdag includes the leader block plus all ancestor blocks
767                // of the leader minus the genesis round blocks
768                assert_eq!(subdag.blocks.len(), 1);
769            } else {
770                // Every subdag after will be missing the leader block from the previous
771                // committed subdag
772                assert_eq!(subdag.blocks.len(), num_authorities);
773            }
774            for block in subdag.blocks.iter() {
775                expected_stored_refs.push(block.reference());
776                assert!(block.round() <= leaders[idx].round());
777            }
778            assert_eq!(subdag.commit_ref.index, idx as CommitIndex + 1);
779        }
780
781        // Check commits sent over consensus output channel is accurate
782        let mut processed_subdag_index = 0;
783        while let Ok(Some(subdag)) = timeout(Duration::from_secs(1), commit_receiver.recv()).await {
784            assert_eq!(subdag, commits[processed_subdag_index]);
785            processed_subdag_index = subdag.commit_ref.index as usize;
786            if processed_subdag_index == leaders.len() {
787                break;
788            }
789        }
790        assert_eq!(processed_subdag_index, leaders.len());
791
792        // Own block latencies are observed once per committed & finalized block
793        // authored by this authority.
794        let own_committed_blocks = commits
795            .iter()
796            .flat_map(|commit| commit.blocks.iter())
797            .filter(|block| block.author() == context.own_index)
798            .count() as u64;
799        assert!(own_committed_blocks > 0);
800        assert_eq!(
801            context
802                .metrics
803                .node_metrics
804                .proposed_block_commit_latency
805                .get_sample_count(),
806            own_committed_blocks
807        );
808        assert_eq!(
809            context
810                .metrics
811                .node_metrics
812                .proposed_block_finalization_latency
813                .get_sample_count(),
814            own_committed_blocks
815        );
816
817        verify_channel_empty(&mut commit_receiver).await;
818
819        // Check commits have been persisted to storage
820        let last_commit = mem_store.read_last_commit().unwrap().unwrap();
821        assert_eq!(
822            last_commit.index(),
823            commits.last().unwrap().commit_ref.index
824        );
825        let all_stored_commits = mem_store
826            .scan_commits((0..=CommitIndex::MAX).into())
827            .unwrap();
828        assert_eq!(all_stored_commits.len(), leaders.len());
829        let blocks_existence = mem_store.contains_blocks(&expected_stored_refs).unwrap();
830        assert!(blocks_existence.iter().all(|exists| *exists));
831    }
832
833    #[tokio::test]
834    async fn test_recover_and_send_commits() {
835        telemetry_subscribers::init_for_testing();
836        let num_authorities = 4;
837        let context = Arc::new(Context::new_for_test(num_authorities).0);
838        let mem_store = Arc::new(MemStore::new());
839        let dag_state = Arc::new(RwLock::new(DagState::new(
840            context.clone(),
841            mem_store.clone(),
842        )));
843        let transaction_vote_tracker = TransactionVoteTracker::new(
844            context.clone(),
845            Arc::new(NoopBlockVerifier {}),
846            dag_state.clone(),
847        );
848        let last_processed_commit_index = 0;
849        let (commit_consumer, mut commit_receiver) =
850            CommitConsumerArgs::new(0, last_processed_commit_index);
851
852        let mut observer = CommitObserver::new(
853            context.clone(),
854            commit_consumer,
855            dag_state.clone(),
856            transaction_vote_tracker.clone(),
857        )
858        .await;
859
860        // Populate fully connected test blocks for round 0 ~ 10, authorities 0 ~ 3.
861        let num_rounds = 10;
862        let mut builder = DagBuilder::new(context.clone());
863        builder
864            .layers(1..=num_rounds)
865            .build()
866            .persist_layers(dag_state.clone());
867        transaction_vote_tracker.add_voted_blocks(
868            builder
869                .all_blocks()
870                .iter()
871                .map(|b| (b.clone(), vec![]))
872                .collect(),
873        );
874
875        let leaders = builder
876            .leader_blocks(1..=num_rounds)
877            .into_iter()
878            .map(Option::unwrap)
879            .collect::<Vec<_>>();
880
881        // Commit first batch of leaders (2) and "receive" the subdags as the
882        // consumer of the consensus output channel.
883        let expected_last_processed_index: usize = 2;
884        let mut commits = observer
885            .handle_committed_leaders(leaders[..expected_last_processed_index].to_vec(), true)
886            .unwrap();
887
888        // Check commits sent over consensus output channel is accurate
889        let mut processed_subdag_index = 0;
890        while let Ok(Some(subdag)) = timeout(Duration::from_secs(1), commit_receiver.recv()).await {
891            tracing::info!("Processed {subdag}");
892            assert_eq!(subdag, commits[processed_subdag_index]);
893            processed_subdag_index = subdag.commit_ref.index as usize;
894            if processed_subdag_index == expected_last_processed_index {
895                break;
896            }
897        }
898        assert_eq!(processed_subdag_index, expected_last_processed_index);
899
900        verify_channel_empty(&mut commit_receiver).await;
901
902        // Check last stored commit is correct
903        let last_commit = mem_store.read_last_commit().unwrap().unwrap();
904        assert_eq!(
905            last_commit.index(),
906            expected_last_processed_index as CommitIndex
907        );
908
909        // Handle next batch of leaders (10 - 2 = 8), these will be sent by consensus but not
910        // "processed" by consensus output channel. Simulating something happened on
911        // the consumer side where the commits were not persisted.
912        commits.append(
913            &mut observer
914                .handle_committed_leaders(leaders[expected_last_processed_index..].to_vec(), true)
915                .unwrap(),
916        );
917
918        let expected_last_sent_index = num_rounds as usize;
919        while let Ok(Some(subdag)) = timeout(Duration::from_secs(1), commit_receiver.recv()).await {
920            tracing::info!("{subdag} was sent but not processed by consumer");
921            assert_eq!(subdag, commits[processed_subdag_index]);
922            assert!(subdag.decided_with_local_blocks);
923            processed_subdag_index = subdag.commit_ref.index as usize;
924            if processed_subdag_index == expected_last_sent_index {
925                break;
926            }
927        }
928        assert_eq!(processed_subdag_index, expected_last_sent_index);
929
930        verify_channel_empty(&mut commit_receiver).await;
931
932        // Check last stored commit is correct. We should persist the last commit
933        // that was sent over the channel regardless of how the consumer handled
934        // the commit on their end.
935        let last_commit = mem_store.read_last_commit().unwrap().unwrap();
936        assert_eq!(last_commit.index(), expected_last_sent_index as CommitIndex);
937
938        // Replay commits after index 2. And use last processed index by consumer at 10, which is the last persisted commit.
939        {
940            let replay_after_commit_index = 2;
941            let consumer_last_processed_commit_index = 10;
942            let dag_state = Arc::new(RwLock::new(DagState::new(
943                context.clone(),
944                mem_store.clone(),
945            )));
946            let (commit_consumer, mut commit_receiver) = CommitConsumerArgs::new(
947                replay_after_commit_index,
948                consumer_last_processed_commit_index,
949            );
950            let _observer = CommitObserver::new(
951                context.clone(),
952                commit_consumer,
953                dag_state.clone(),
954                transaction_vote_tracker.clone(),
955            )
956            .await;
957
958            let mut processed_subdag_index = replay_after_commit_index;
959            while let Ok(Some(mut subdag)) =
960                timeout(Duration::from_secs(1), commit_receiver.recv()).await
961            {
962                tracing::info!("Received {subdag} on recovery");
963                assert_eq!(subdag.commit_ref.index, processed_subdag_index + 1);
964                assert!(subdag.recovered_rejected_transactions);
965
966                // Allow comparison with committed subdag before recovery.
967                subdag.recovered_rejected_transactions = false;
968                assert_eq!(subdag, commits[processed_subdag_index as usize]);
969
970                assert!(subdag.decided_with_local_blocks);
971                processed_subdag_index = subdag.commit_ref.index;
972                if processed_subdag_index == consumer_last_processed_commit_index {
973                    break;
974                }
975            }
976            assert_eq!(processed_subdag_index, consumer_last_processed_commit_index);
977
978            verify_channel_empty(&mut commit_receiver).await;
979        }
980
981        // Replay commits from index 10, which is the last persisted commit.
982        {
983            let replay_after_commit_index = 10;
984            let consumer_last_processed_commit_index = 10;
985            let dag_state = Arc::new(RwLock::new(DagState::new(
986                context.clone(),
987                mem_store.clone(),
988            )));
989            // Re-create commit observer starting after index 10 which represents the
990            // last processed index from the consumer over consensus output channel
991            let (commit_consumer, mut commit_receiver) = CommitConsumerArgs::new(
992                replay_after_commit_index,
993                consumer_last_processed_commit_index,
994            );
995            let _observer = CommitObserver::new(
996                context.clone(),
997                commit_consumer,
998                dag_state.clone(),
999                transaction_vote_tracker.clone(),
1000            )
1001            .await;
1002
1003            // No commits should be resubmitted as consensus store's last commit index
1004            // is equal to replay after index by consumer
1005            verify_channel_empty(&mut commit_receiver).await;
1006        }
1007
1008        // Replay commits after index 2. And use last processed index by consumer at 4, less than the last persisted commit.
1009        {
1010            let replay_after_commit_index = 2;
1011            let consumer_last_processed_commit_index = 4;
1012            let dag_state = Arc::new(RwLock::new(DagState::new(
1013                context.clone(),
1014                mem_store.clone(),
1015            )));
1016            let (commit_consumer, mut commit_receiver) = CommitConsumerArgs::new(
1017                replay_after_commit_index,
1018                consumer_last_processed_commit_index,
1019            );
1020            let _observer = CommitObserver::new(
1021                context.clone(),
1022                commit_consumer,
1023                dag_state.clone(),
1024                transaction_vote_tracker.clone(),
1025            )
1026            .await;
1027
1028            // Checks that commits up to expected_last_sent_index are recovered as finalized.
1029            // The fact that they are finalized and have been recorded in the store.
1030            let mut processed_subdag_index = replay_after_commit_index;
1031            while let Ok(Some(subdag)) =
1032                timeout(Duration::from_secs(1), commit_receiver.recv()).await
1033            {
1034                tracing::info!("Received {subdag} on recovery");
1035                assert_eq!(subdag.commit_ref.index, processed_subdag_index + 1);
1036                assert!(subdag.decided_with_local_blocks);
1037                processed_subdag_index = subdag.commit_ref.index;
1038                if processed_subdag_index == expected_last_sent_index as CommitIndex {
1039                    break;
1040                }
1041            }
1042            assert_eq!(
1043                processed_subdag_index,
1044                expected_last_sent_index as CommitIndex
1045            );
1046
1047            verify_channel_empty(&mut commit_receiver).await;
1048        }
1049
1050        // Replay commits after index 2. And use last processed index by consumer at 20,
1051        // which is greater than the last persisted commit.
1052        // This allows removing from the store a suffix of commits which have not been part of certified checkpoints.
1053        {
1054            let replay_after_commit_index = 2;
1055            let consumer_last_processed_commit_index = 20;
1056            let dag_state = Arc::new(RwLock::new(DagState::new(
1057                context.clone(),
1058                mem_store.clone(),
1059            )));
1060            let (commit_consumer, mut commit_receiver) = CommitConsumerArgs::new(
1061                replay_after_commit_index,
1062                consumer_last_processed_commit_index,
1063            );
1064            let _observer = CommitObserver::new(
1065                context.clone(),
1066                commit_consumer,
1067                dag_state.clone(),
1068                transaction_vote_tracker.clone(),
1069            )
1070            .await;
1071
1072            // Check commits sent over consensus output channel is accurate starting
1073            // from last processed index of 2 and finishing at last sent index of 10.
1074            let mut processed_subdag_index = replay_after_commit_index;
1075            while let Ok(Some(mut subdag)) =
1076                timeout(Duration::from_secs(1), commit_receiver.recv()).await
1077            {
1078                tracing::info!("Received {subdag} on recovery");
1079                assert_eq!(subdag.commit_ref.index, processed_subdag_index + 1);
1080                assert!(subdag.recovered_rejected_transactions);
1081
1082                // Allow comparison with committed subdag before recovery.
1083                subdag.recovered_rejected_transactions = false;
1084                assert_eq!(subdag, commits[processed_subdag_index as usize]);
1085
1086                assert!(subdag.decided_with_local_blocks);
1087                processed_subdag_index = subdag.commit_ref.index;
1088                if processed_subdag_index == expected_last_sent_index as CommitIndex {
1089                    break;
1090                }
1091            }
1092            assert_eq!(
1093                processed_subdag_index,
1094                expected_last_sent_index as CommitIndex
1095            );
1096            assert_eq!(10, expected_last_sent_index);
1097
1098            verify_channel_empty(&mut commit_receiver).await;
1099        }
1100    }
1101
1102    /// After receiving all expected subdags, ensure channel is empty
1103    async fn verify_channel_empty(receiver: &mut UnboundedReceiver<CommittedSubDag>) {
1104        if let Ok(Some(_)) = timeout(Duration::from_secs(1), receiver.recv()).await {
1105            panic!("Expected the consensus output channel to be empty, but found more subdags.")
1106        }
1107    }
1108}