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