sui_core/authority/
transaction_reject_reason_cache.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeMap;
5
6use consensus_config::AuthorityIndex;
7use consensus_types::block::{BlockDigest, BlockRef, TransactionIndex};
8use mysten_metrics::monitored_scope;
9use parking_lot::RwLock;
10use sui_types::committee::EpochId;
11use sui_types::error::SuiError;
12use sui_types::messages_consensus::ConsensusPosition;
13use tracing::trace;
14
15use crate::authority::consensus_tx_status_cache::CONSENSUS_STATUS_RETENTION_ROUNDS;
16
17#[cfg(test)]
18use consensus_types::block::Round;
19
20/// A cache that maintains rejection reasons (SuiError) when validators cast reject votes for transactions
21/// during the Mysticeti consensus fast path voting process.
22///
23/// This cache serves as a bridge between the consensus voting mechanism and client-facing APIs,
24/// allowing detailed error information to be returned when querying transaction status.
25///
26/// ## Key Characteristics:
27/// - **Mysticeti Fast Path Only**: Only populated when transactions are voted on via the mysticeti
28///   fast path, as it relies on consensus position (epoch, block, index) to uniquely identify transactions
29/// - **Pre-consensus Rejections**: Direct rejections during transaction submission (before consensus
30///   propagation) are not cached since these transactions never enter the consensus pipeline
31/// - **Automatic Cleanup**: Maintains a retention period based on the last committed leader round
32///   and automatically purges older entries to prevent unbounded memory growth
33///
34/// ## Use Cases:
35/// - Providing detailed rejection reasons to clients querying transaction status
36/// - Debugging transaction failures in the fast path voting process
37pub(crate) struct TransactionRejectReasonCache {
38    cache: RwLock<BTreeMap<ConsensusPosition, SuiError>>,
39    retention_rounds: u32,
40    epoch: EpochId,
41}
42
43impl TransactionRejectReasonCache {
44    pub fn new(retention_rounds: Option<u32>, epoch: EpochId) -> Self {
45        Self {
46            cache: Default::default(),
47            retention_rounds: retention_rounds.unwrap_or(CONSENSUS_STATUS_RETENTION_ROUNDS),
48            epoch,
49        }
50    }
51
52    /// Records a rejection vote reason for a transaction at the specified consensus position. The consensus `position` that
53    /// uniquely identifies the transaction and the `reason` (SuiError) that caused the transaction to be rejected during voting
54    /// should be provided.
55    pub fn set_rejection_vote_reason(&self, position: ConsensusPosition, reason: &SuiError) {
56        debug_assert_eq!(position.epoch, self.epoch, "Epoch mismatch");
57        self.cache.write().insert(position, reason.clone());
58    }
59
60    /// Returns the rejection vote reason for the transaction at the specified consensus position. The result will be `None` when:
61    /// * this node has never casted a reject vote for the transaction in question (either accepted or not processed it).
62    /// * the transaction vote reason has been cleaned up due to the retention policy.
63    pub fn get_rejection_vote_reason(&self, position: ConsensusPosition) -> Option<SuiError> {
64        debug_assert_eq!(position.epoch, self.epoch, "Epoch mismatch");
65        self.cache.read().get(&position).cloned()
66    }
67
68    /// Sets the last committed leader round. This is used to clean up the cache based on the retention policy.
69    pub fn set_last_committed_leader_round(&self, round: u32) {
70        let _scope =
71            monitored_scope("TransactionRejectReasonCache::set_last_committed_leader_round");
72        let cut_off_round = round.saturating_sub(self.retention_rounds) + 1;
73        let cut_off_position = ConsensusPosition {
74            epoch: self.epoch,
75            block: BlockRef::new(cut_off_round, AuthorityIndex::MIN, BlockDigest::MIN),
76            index: TransactionIndex::MIN,
77        };
78
79        let mut cache = self.cache.write();
80        let remaining = cache.split_off(&cut_off_position);
81        trace!("Cleaned up {} entries", cache.len());
82        *cache = remaining;
83    }
84}
85
86#[cfg(test)]
87mod test {
88    use sui_types::error::SuiErrorKind;
89
90    use super::*;
91
92    #[tokio::test]
93    async fn test_set_rejection_vote_reason_and_get_reason() {
94        let cache = TransactionRejectReasonCache::new(None, 1);
95        let position = ConsensusPosition {
96            epoch: 1,
97            block: BlockRef::new(1, AuthorityIndex::MAX, BlockDigest::MAX),
98            index: 1,
99        };
100
101        // Set the reject reason for the position once
102        {
103            let reason = SuiErrorKind::ValidatorHaltedAtEpochEnd.into();
104            cache.set_rejection_vote_reason(position, &reason);
105            assert_eq!(cache.get_rejection_vote_reason(position), Some(reason));
106        }
107
108        // Set the reject reason for the position again will overwrite the previous reason
109        {
110            let reason = SuiErrorKind::InvalidTransactionDigest.into();
111            cache.set_rejection_vote_reason(position, &reason);
112            assert_eq!(cache.get_rejection_vote_reason(position), Some(reason));
113        }
114
115        // Get the reject reason for a non existing position will return None
116        {
117            let position = ConsensusPosition {
118                epoch: 1,
119                block: BlockRef::new(1, AuthorityIndex::MAX, BlockDigest::MIN),
120                index: 2,
121            };
122            assert_eq!(cache.get_rejection_vote_reason(position), None);
123        }
124    }
125
126    #[tokio::test]
127    async fn test_set_last_committed_leader_round() {
128        const RETENTION_ROUNDS: u32 = 4;
129        const TOTAL_ROUNDS: u32 = 10;
130        let cache = TransactionRejectReasonCache::new(Some(RETENTION_ROUNDS), 1);
131
132        let position = |round: Round, transaction_index: u16| ConsensusPosition {
133            epoch: 1,
134            block: BlockRef::new(
135                round,
136                AuthorityIndex::new_for_test(transaction_index as u32),
137                BlockDigest::MAX,
138            ),
139            index: transaction_index,
140        };
141
142        // Set a few reject reasons for different positions before and after the last committed leader round (6)
143        for round in 0..TOTAL_ROUNDS {
144            for transaction_index in 0..5 {
145                cache.set_rejection_vote_reason(
146                    position(round, transaction_index),
147                    &SuiErrorKind::InvalidTransactionDigest.into(),
148                );
149            }
150        }
151
152        // Set the last committed leader round to 6, which should clean up the cache up to round (including) 6-4 = 2.
153        cache.set_last_committed_leader_round(6);
154
155        // The reject reasons from rounds 0-2 should be cleaned up
156        for round in 0..TOTAL_ROUNDS {
157            for transaction_index in 0..5 {
158                let position = position(round, transaction_index);
159                if round <= 2 {
160                    assert_eq!(cache.get_rejection_vote_reason(position), None);
161                } else {
162                    assert_eq!(
163                        cache.get_rejection_vote_reason(position),
164                        Some(SuiErrorKind::InvalidTransactionDigest.into())
165                    );
166                }
167            }
168        }
169
170        // Now set the last committed leader round to 10, which should clean up the cache up to round (including) 10-4 = 6.
171        cache.set_last_committed_leader_round(10);
172
173        // The reject reasons from rounds 0-6 should be cleaned up
174        for round in 0..TOTAL_ROUNDS {
175            for transaction_index in 0..5 {
176                let position = position(round, transaction_index);
177                if round <= 6 {
178                    assert_eq!(cache.get_rejection_vote_reason(position), None);
179                } else {
180                    assert_eq!(
181                        cache.get_rejection_vote_reason(position),
182                        Some(SuiErrorKind::InvalidTransactionDigest.into())
183                    );
184                }
185            }
186        }
187    }
188}