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