Skip to main content

sui_core/authority/
consensus_tx_status_cache.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeMap;
5
6use consensus_types::block::Round;
7use mysten_common::sync::notify_read::NotifyRead;
8use parking_lot::RwLock;
9use sui_types::{
10    error::{SuiErrorKind, SuiResult},
11    messages_consensus::ConsensusPosition,
12};
13use tokio::sync::watch;
14use tracing::debug;
15
16/// The number of consensus rounds to retain transaction status information before garbage collection.
17/// Used to expire positions from old rounds, as well as to check if a transaction is too far ahead of the last committed round.
18/// Assuming a max round rate of 15/sec, this allows status updates to be valid within a window of ~25-30 seconds.
19pub(crate) const CONSENSUS_STATUS_RETENTION_ROUNDS: u32 = 400;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub(crate) enum ConsensusTxStatus {
23    // Transaction is rejected, either by a quorum of validators or indirectly post-commit.
24    Rejected,
25    // Transaction is finalized post commit.
26    Finalized,
27    // Transaction is dropped post-consensus.
28    // This decision must be consistent across all validators.
29    //
30    // A transaction is dropped without execution when it has invalid owned object
31    // inputs (stale versions or conflicts with a locked object), or when it is
32    // ignored during epoch close (consensus certs no longer accepted, or its block
33    // author already sent EndOfPublish). All causes are deterministic functions of
34    // the transaction and prior consensus commits.
35    Dropped,
36}
37
38impl ConsensusTxStatus {
39    pub(crate) fn metric_label(&self) -> &'static str {
40        match self {
41            ConsensusTxStatus::Finalized => "finalized",
42            ConsensusTxStatus::Rejected => "rejected",
43            ConsensusTxStatus::Dropped => "dropped",
44        }
45    }
46}
47
48#[derive(Debug, Clone)]
49pub(crate) enum NotifyReadConsensusTxStatusResult {
50    // The consensus position to be read has been updated with a new status.
51    Status(ConsensusTxStatus),
52    // The consensus position to be read has expired.
53    // Provided with the last committed round that was used to check for expiration.
54    Expired(u32),
55}
56
57pub(crate) struct ConsensusTxStatusCache {
58    inner: RwLock<Inner>,
59
60    status_notify_read: NotifyRead<ConsensusPosition, ConsensusTxStatus>,
61    /// Watch channel for last committed leader round updates
62    last_committed_leader_round_tx: watch::Sender<Option<u32>>,
63    last_committed_leader_round_rx: watch::Receiver<Option<u32>>,
64}
65
66#[derive(Default)]
67struct Inner {
68    /// A map of transaction position to its status from consensus.
69    transaction_status: BTreeMap<ConsensusPosition, ConsensusTxStatus>,
70    /// The last leader round updated in update_last_committed_leader_round().
71    last_committed_leader_round: Option<Round>,
72}
73
74impl ConsensusTxStatusCache {
75    pub(crate) fn new(consensus_gc_depth: Round) -> Self {
76        assert!(
77            consensus_gc_depth < CONSENSUS_STATUS_RETENTION_ROUNDS,
78            "{} vs {}",
79            consensus_gc_depth,
80            CONSENSUS_STATUS_RETENTION_ROUNDS
81        );
82        let (last_committed_leader_round_tx, last_committed_leader_round_rx) = watch::channel(None);
83        Self {
84            inner: Default::default(),
85            status_notify_read: Default::default(),
86            last_committed_leader_round_tx,
87            last_committed_leader_round_rx,
88        }
89    }
90
91    /// Single-update convenience wrapper around [`Self::set_transaction_statuses`].
92    /// Production code uses the batched form; this is retained for tests.
93    #[cfg(test)]
94    pub(crate) fn set_transaction_status(&self, pos: ConsensusPosition, status: ConsensusTxStatus) {
95        self.set_transaction_statuses(vec![(pos, status)]);
96    }
97
98    /// Batched form of `set_transaction_status`: applies all updates under a
99    /// single write lock and issues the notifications after the lock is released.
100    /// The consensus commit handler uses this to replace a per-transaction lock
101    /// acquisition (and a notify held across the write lock) with one acquisition
102    /// per commit. Semantics are otherwise identical: stale updates are dropped, a
103    /// conflicting status for an already-recorded position panics, and every applied
104    /// update is notified.
105    pub(crate) fn set_transaction_statuses(
106        &self,
107        updates: Vec<(ConsensusPosition, ConsensusTxStatus)>,
108    ) {
109        // The committed leader round is constant for the duration of a commit, so
110        // read it once for the whole batch rather than per update.
111        let last_committed_leader_round = *self.last_committed_leader_round_rx.borrow();
112        let mut to_notify = Vec::with_capacity(updates.len());
113        {
114            let mut inner = self.inner.write();
115            for (pos, status) in updates {
116                if let Some(last_committed_leader_round) = last_committed_leader_round
117                    && pos.block.round + CONSENSUS_STATUS_RETENTION_ROUNDS
118                        <= last_committed_leader_round
119                {
120                    // Ignore stale status updates.
121                    continue;
122                }
123                let old_status = inner.transaction_status.insert(pos, status);
124                if let Some(old_status) = old_status
125                    && old_status != status
126                {
127                    panic!(
128                        "Conflicting status updates for transaction {:?}: {:?} -> {:?}",
129                        pos, old_status, status
130                    );
131                }
132                debug!("Transaction status is set for {}: {:?}", pos, status);
133                to_notify.push((pos, status));
134            }
135        }
136        for (pos, status) in to_notify {
137            self.status_notify_read.notify(&pos, &status);
138        }
139    }
140
141    /// Given a known previous status provided by `old_status`, this function will return a new
142    /// status once the transaction status has changed, or if the consensus position has expired.
143    pub(crate) async fn notify_read_transaction_status(
144        &self,
145        consensus_position: ConsensusPosition,
146    ) -> NotifyReadConsensusTxStatusResult {
147        let registration = self.status_notify_read.register_one(&consensus_position);
148        let mut round_rx = self.last_committed_leader_round_rx.clone();
149        {
150            let inner = self.inner.read();
151            if let Some(status) = inner.transaction_status.get(&consensus_position) {
152                return NotifyReadConsensusTxStatusResult::Status(*status);
153            }
154            // Inner read lock dropped here.
155        }
156        let expiration_check = async {
157            loop {
158                if let Some(last_committed_leader_round) = *round_rx.borrow()
159                    && consensus_position.block.round + CONSENSUS_STATUS_RETENTION_ROUNDS
160                        <= last_committed_leader_round
161                {
162                    return last_committed_leader_round;
163                }
164                // Channel closed - this should never happen in practice, so panic
165                round_rx
166                    .changed()
167                    .await
168                    .expect("last_committed_leader_round watch channel closed unexpectedly");
169            }
170        };
171        tokio::select! {
172            status = registration => NotifyReadConsensusTxStatusResult::Status(status),
173            last_committed_leader_round = expiration_check => NotifyReadConsensusTxStatusResult::Expired(last_committed_leader_round),
174        }
175    }
176
177    pub(crate) fn update_last_committed_leader_round(&self, last_committed_leader_round: u32) {
178        debug!(
179            "Updating last committed leader round: {}",
180            last_committed_leader_round
181        );
182
183        let mut inner = self.inner.write();
184
185        // Consensus only bumps GC round after generating a commit. So if we expire and GC transactions
186        // based on the latest committed leader round, we may expire transactions in the current commit, or
187        // make these transactions' statuses very short lived.
188        // So we only expire and GC transactions with the previous committed leader round.
189        let Some(leader_round) = inner
190            .last_committed_leader_round
191            .replace(last_committed_leader_round)
192        else {
193            // This is the first update. Do not expire or GC any transactions.
194            return;
195        };
196
197        // Remove transactions that are expired.
198        while let Some((position, _)) = inner.transaction_status.first_key_value() {
199            if position.block.round + CONSENSUS_STATUS_RETENTION_ROUNDS <= leader_round {
200                inner.transaction_status.pop_first();
201            } else {
202                break;
203            }
204        }
205
206        // Send update through watch channel.
207        let _ = self.last_committed_leader_round_tx.send(Some(leader_round));
208    }
209
210    pub(crate) fn get_last_committed_leader_round(&self) -> Option<u32> {
211        *self.last_committed_leader_round_rx.borrow()
212    }
213
214    /// Returns true if the position is too far ahead of the last committed round.
215    pub(crate) fn check_position_too_ahead(&self, position: &ConsensusPosition) -> SuiResult<()> {
216        if let Some(last_committed_leader_round) = *self.last_committed_leader_round_rx.borrow()
217            && position.block.round
218                > last_committed_leader_round + CONSENSUS_STATUS_RETENTION_ROUNDS
219        {
220            return Err(SuiErrorKind::ValidatorConsensusLagging {
221                round: position.block.round,
222                last_committed_round: last_committed_leader_round,
223            }
224            .into());
225        }
226        Ok(())
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use std::{sync::Arc, time::Duration};
233
234    use super::*;
235    use consensus_types::block::{BlockRef, TransactionIndex};
236
237    fn create_test_tx_position(round: u64, index: u64) -> ConsensusPosition {
238        ConsensusPosition {
239            epoch: Default::default(),
240            block: BlockRef {
241                round: round as u32,
242                author: Default::default(),
243                digest: Default::default(),
244            },
245            index: index as TransactionIndex,
246        }
247    }
248
249    #[tokio::test]
250    async fn test_set_and_get_transaction_status() {
251        let cache = ConsensusTxStatusCache::new(60);
252        let tx_pos = create_test_tx_position(1, 0);
253
254        // Set initial status
255        cache.set_transaction_status(tx_pos, ConsensusTxStatus::Finalized);
256
257        // Read status immediately
258        let result = cache.notify_read_transaction_status(tx_pos).await;
259        assert!(matches!(
260            result,
261            NotifyReadConsensusTxStatusResult::Status(ConsensusTxStatus::Finalized)
262        ));
263    }
264
265    #[tokio::test]
266    async fn test_status_notification() {
267        let cache = Arc::new(ConsensusTxStatusCache::new(60));
268        let tx_pos = create_test_tx_position(1, 0);
269
270        // Spawn a task that waits for status update
271        let cache_clone = cache.clone();
272        let handle =
273            tokio::spawn(async move { cache_clone.notify_read_transaction_status(tx_pos).await });
274
275        // Small delay to ensure the task is waiting
276        tokio::time::sleep(Duration::from_millis(10)).await;
277
278        // Set the status
279        cache.set_transaction_status(tx_pos, ConsensusTxStatus::Finalized);
280
281        // Verify the notification was received
282        let result = handle.await.unwrap();
283        assert!(matches!(
284            result,
285            NotifyReadConsensusTxStatusResult::Status(ConsensusTxStatus::Finalized)
286        ));
287    }
288
289    #[tokio::test]
290    async fn test_round_expiration() {
291        let cache = ConsensusTxStatusCache::new(60);
292        let tx_pos = create_test_tx_position(1, 0);
293
294        // Set initial status
295        cache.set_transaction_status(tx_pos, ConsensusTxStatus::Finalized);
296
297        // Set initial leader round which doesn't GC anything.
298        cache.update_last_committed_leader_round(CONSENSUS_STATUS_RETENTION_ROUNDS + 1);
299
300        // Update with round that will trigger GC using previous round (CONSENSUS_STATUS_RETENTION_ROUNDS + 1)
301        // This will expire transactions up to and including round 1
302        cache.update_last_committed_leader_round(CONSENSUS_STATUS_RETENTION_ROUNDS + 2);
303
304        // Try to read status - should be expired
305        let result = cache.notify_read_transaction_status(tx_pos).await;
306        assert!(matches!(
307            result,
308            NotifyReadConsensusTxStatusResult::Expired(_)
309        ));
310    }
311
312    #[tokio::test]
313    async fn test_cleanup_expired_rounds() {
314        let cache = ConsensusTxStatusCache::new(60);
315
316        // Add transactions for multiple rounds
317        for round in 1..=5 {
318            let tx_pos = create_test_tx_position(round, 0);
319            cache.set_transaction_status(tx_pos, ConsensusTxStatus::Rejected);
320        }
321
322        // Set initial leader round which doesn't GC anything.
323        cache.update_last_committed_leader_round(CONSENSUS_STATUS_RETENTION_ROUNDS + 2);
324
325        // No rounds should be cleaned up yet since this was the initial update
326        {
327            let inner = cache.inner.read();
328            let rounds = inner
329                .transaction_status
330                .keys()
331                .map(|p| p.block.round)
332                .collect::<Vec<_>>();
333            assert_eq!(rounds, vec![1, 2, 3, 4, 5]);
334        }
335
336        // Update that triggers GC using previous round (CONSENSUS_STATUS_RETENTION_ROUNDS + 2)
337        // This will expire transactions up to and including round 2
338        cache.update_last_committed_leader_round(CONSENSUS_STATUS_RETENTION_ROUNDS + 3);
339
340        // Verify rounds 1-2 are cleaned up, 3-5 remain
341        {
342            let inner = cache.inner.read();
343            let rounds = inner
344                .transaction_status
345                .keys()
346                .map(|p| p.block.round)
347                .collect::<Vec<_>>();
348            assert_eq!(rounds, vec![3, 4, 5]);
349        }
350
351        // Another update using previous round (CONSENSUS_STATUS_RETENTION_ROUNDS + 3) for GC
352        // This will expire transactions up to and including round 3
353        cache.update_last_committed_leader_round(CONSENSUS_STATUS_RETENTION_ROUNDS + 4);
354
355        // Verify rounds 1-3 are cleaned up, 4-5 remain
356        {
357            let inner = cache.inner.read();
358            let rounds = inner
359                .transaction_status
360                .keys()
361                .map(|p| p.block.round)
362                .collect::<Vec<_>>();
363            assert_eq!(rounds, vec![4, 5]);
364        }
365    }
366
367    #[tokio::test]
368    async fn test_concurrent_operations() {
369        let cache = Arc::new(ConsensusTxStatusCache::new(60));
370        let tx_pos = create_test_tx_position(1, 0);
371
372        // Spawn multiple tasks that wait for status
373        let mut handles = vec![];
374        for _ in 0..3 {
375            let cache_clone = cache.clone();
376            handles.push(tokio::spawn(async move {
377                cache_clone.notify_read_transaction_status(tx_pos).await
378            }));
379        }
380
381        // Small delay to ensure tasks are waiting
382        tokio::time::sleep(Duration::from_millis(10)).await;
383
384        // Set the status
385        cache.set_transaction_status(tx_pos, ConsensusTxStatus::Finalized);
386
387        // Verify all notifications were received
388        for handle in handles {
389            let result = handle.await.unwrap();
390            assert!(matches!(
391                result,
392                NotifyReadConsensusTxStatusResult::Status(ConsensusTxStatus::Finalized)
393            ));
394        }
395    }
396
397    #[tokio::test]
398    #[should_panic(expected = "Conflicting status updates")]
399    async fn test_out_of_order_status_updates() {
400        let cache = Arc::new(ConsensusTxStatusCache::new(60));
401        let tx_pos = create_test_tx_position(1, 0);
402
403        // First update status to Finalized.
404        cache.set_transaction_status(tx_pos, ConsensusTxStatus::Finalized);
405
406        // This should cause a panic.
407        cache.set_transaction_status(tx_pos, ConsensusTxStatus::Rejected);
408    }
409}