Skip to main content

sui_core/
admission_queue.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::authority::authority_per_epoch_store::AuthorityPerEpochStore;
5use crate::consensus_adapter::ConsensusAdapter;
6use crate::staggered_submission::proposers_metric_label;
7use arc_swap::ArcSwap;
8use mysten_common::debug_fatal;
9use mysten_metrics::{COUNT_BUCKETS, spawn_monitored_task};
10use prometheus::{
11    Histogram, HistogramVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Registry,
12    register_histogram_vec_with_registry, register_histogram_with_registry,
13    register_int_counter_vec_with_registry, register_int_counter_with_registry,
14    register_int_gauge_vec_with_registry, register_int_gauge_with_registry,
15};
16use std::collections::{BTreeMap, HashMap, VecDeque};
17use std::net::IpAddr;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use std::sync::{Arc, Mutex};
20use std::time::{Duration, Instant};
21use sui_macros::handle_fail_point_if;
22use sui_network::tonic;
23use sui_types::error::{SuiError, SuiErrorKind, SuiResult};
24use sui_types::messages_consensus::{
25    ConsensusPosition, ConsensusTransaction, ConsensusTransactionKey,
26};
27use tokio::sync::{mpsc, oneshot};
28use tracing::debug;
29
30/// A transaction (or soft bundle) waiting in the admission queue for consensus submission.
31pub struct QueueEntry {
32    pub gas_price: u64,
33    pub transactions: Vec<ConsensusTransaction>,
34    pub position_sender: oneshot::Sender<Result<Vec<ConsensusPosition>, tonic::Status>>,
35    pub submitter_client_addr: Option<IpAddr>,
36    pub enqueue_time: Instant,
37}
38
39/// What `PriorityAdmissionQueue::pop_batch_while` does with an examined entry.
40pub enum PopAction {
41    /// Pop the entry into the included partition.
42    Include,
43    /// Pop the entry into the excluded partition.
44    Exclude,
45    /// Leave the entry queued and continue with the next entry.
46    Skip,
47    /// Leave the entry queued and stop iterating.
48    Stop,
49}
50
51pub trait AdmissionQueueEntry {
52    fn gas_price(&self) -> u64;
53    fn transaction_keys(&self) -> impl Iterator<Item = ConsensusTransactionKey>;
54    fn notify_evicted(self, min_gas_price: u64);
55    fn notify_rejected(self, min_gas_price: u64);
56}
57
58/// Outcome of [`PriorityAdmissionQueue::try_insert`]. Any displaced entry is
59/// carried here unnotified so that the caller can deliver the notification
60/// outside of any held locks.
61#[must_use = "call `notify` to deliver the outcome to the displaced entry"]
62pub struct InsertOutcome<E> {
63    outcome: Option<Outcome<E>>,
64    created_at: &'static std::panic::Location<'static>,
65}
66
67enum Outcome<E> {
68    Inserted {
69        /// False if an entry sharing one of the transaction keys was already queued.
70        newly_inserted: bool,
71        /// The lowest-priced entry evicted to make room, if any.
72        evicted: Option<E>,
73        /// The inserted entry's gas price.
74        gas_price: u64,
75    },
76    /// Rejected: the queue is full and `min_gas_price` was not met.
77    Rejected { entry: E, min_gas_price: u64 },
78}
79
80impl<E: AdmissionQueueEntry> InsertOutcome<E> {
81    #[track_caller]
82    fn new(outcome: Outcome<E>) -> Self {
83        Self {
84            outcome: Some(outcome),
85            created_at: std::panic::Location::caller(),
86        }
87    }
88
89    /// Notifies the displaced entry, if any, then reports the insert result.
90    /// `try_insert(e).notify()` is equivalent to `insert(e)`.
91    pub fn notify(mut self) -> SuiResult<bool> {
92        match self
93            .outcome
94            .take()
95            .expect("outcome is pending until notified")
96        {
97            Outcome::Inserted {
98                newly_inserted,
99                evicted,
100                gas_price,
101            } => {
102                if let Some(evicted) = evicted {
103                    evicted.notify_evicted(gas_price);
104                }
105                Ok(newly_inserted)
106            }
107            Outcome::Rejected {
108                entry,
109                min_gas_price,
110            } => {
111                entry.notify_rejected(min_gas_price);
112                Err(
113                    SuiErrorKind::TransactionRejectedDueToOutbiddingDuringCongestion {
114                        min_gas_price,
115                    }
116                    .into(),
117                )
118            }
119        }
120    }
121}
122
123impl<E> Drop for InsertOutcome<E> {
124    fn drop(&mut self) {
125        if self.outcome.is_some() && !std::thread::panicking() {
126            debug_fatal!(
127                "InsertOutcome from try_insert at {} dropped without notify",
128                self.created_at
129            );
130        }
131    }
132}
133
134impl AdmissionQueueEntry for QueueEntry {
135    fn gas_price(&self) -> u64 {
136        self.gas_price
137    }
138
139    fn transaction_keys(&self) -> impl Iterator<Item = ConsensusTransactionKey> {
140        self.transactions.iter().map(ConsensusTransaction::key)
141    }
142
143    fn notify_evicted(self, min_gas_price: u64) {
144        let _ = self
145            .position_sender
146            .send(Err(tonic::Status::from(SuiError::from(
147                SuiErrorKind::TransactionRejectedDueToOutbiddingDuringCongestion { min_gas_price },
148            ))));
149    }
150
151    fn notify_rejected(self, _min_gas_price: u64) {
152        // Nothing to do here in push mode. The rejected caller receives the
153        // outbid error via the insert result instead.
154    }
155}
156
157impl QueueEntry {
158    #[cfg(test)]
159    pub fn new_for_test(
160        gas_price: u64,
161        position_sender: oneshot::Sender<Result<Vec<ConsensusPosition>, tonic::Status>>,
162    ) -> Self {
163        Self {
164            gas_price,
165            transactions: vec![],
166            position_sender,
167            submitter_client_addr: None,
168            enqueue_time: Instant::now(),
169        }
170    }
171}
172
173/// Prometheus metrics for the admission queue.
174pub struct AdmissionQueueMetrics {
175    // Pull mode intentionally reuses these admission metrics for its user lane because
176    // both modes implement the same admission policy and are mutually exclusive on a
177    // validator. This preserves dashboard continuity when switching modes.
178    pub queue_depth: IntGauge,
179    pub queue_wait_latency: HistogramVec,
180    pub evictions: IntCounter,
181    pub rejections: IntCounter,
182    pub expired: IntCounter,
183    pub duplicate_inserts: IntCounter,
184
185    pub pool_depth: IntGaugeVec,
186    pub pool_bytes: IntGaugeVec,
187    pub pool_staggered_held: IntGauge,
188    pub pool_staggered_rejected: IntCounter,
189    pub pool_staggered_delay: Histogram,
190    pub pool_stagger_shadow_decisions: IntCounterVec,
191    pub pool_stagger_shadow_delay: Histogram,
192    pub pool_taken_per_proposal: Histogram,
193    pub pool_requeued_on_dropped_ack: IntCounter,
194    pub pool_gc_notified: IntCounter,
195    pub pool_waiting_inserts: IntGauge,
196    pub pool_already_processed: IntCounterVec,
197    pub pool_rejected_at_proposal: IntCounterVec,
198    pub pool_commit_latency: HistogramVec,
199    pub pool_abandoned: IntCounterVec,
200    pub pool_byte_budget_bytes: IntGauge,
201    pub pool_byte_budget_exhausted: IntCounter,
202}
203
204impl AdmissionQueueMetrics {
205    pub fn new(registry: &Registry) -> Self {
206        Self {
207            queue_depth: register_int_gauge_with_registry!(
208                "admission_queue_depth",
209                "Current number of entries in the admission priority queue",
210                registry,
211            )
212            .unwrap(),
213            queue_wait_latency: register_histogram_vec_with_registry!(
214                "admission_queue_wait_latency",
215                "Time a submission spends waiting in the admission queue or transaction pool before being drained or proposed",
216                &["lane", "proposers"],
217                mysten_metrics::SUBSECOND_LATENCY_SEC_BUCKETS.to_vec(),
218                registry,
219            )
220            .unwrap(),
221            evictions: register_int_counter_with_registry!(
222                "admission_queue_evictions",
223                "Number of entries evicted from the admission queue by higher gas price transactions",
224                registry,
225            )
226            .unwrap(),
227            rejections: register_int_counter_with_registry!(
228                "admission_queue_rejections",
229                "Number of transactions rejected because the queue was full and their gas price was too low",
230                registry,
231            )
232            .unwrap(),
233            expired: register_int_counter_with_registry!(
234                "admission_queue_expired",
235                "Number of queued transactions dropped for exceeding their time to live",
236                registry,
237            )
238            .unwrap(),
239            duplicate_inserts: register_int_counter_with_registry!(
240                "admission_queue_duplicate_inserts",
241                "Transactions admitted to the queue whose ConsensusTransactionKey duplicated an entry already present. Tallied as spam for DoS protection.",
242                registry,
243            )
244            .unwrap(),
245            pool_depth: register_int_gauge_vec_with_registry!(
246                "consensus_transaction_pool_depth",
247                "Current number of entries in each consensus transaction pool lane",
248                &["lane"],
249                registry,
250            )
251            .unwrap(),
252            pool_bytes: register_int_gauge_vec_with_registry!(
253                "consensus_transaction_pool_bytes",
254                "Current serialized transaction bytes in each consensus transaction pool lane",
255                &["lane"],
256                registry,
257            )
258            .unwrap(),
259            pool_staggered_held: register_int_gauge_with_registry!(
260                "consensus_transaction_pool_staggered_held",
261                "User-lane entries in the pool stamped with a staggered-submission hold, whether or not the hold has elapsed",
262                registry,
263            )
264            .unwrap(),
265            pool_staggered_rejected: register_int_counter_with_registry!(
266                "consensus_transaction_pool_staggered_rejected",
267                "User submissions rejected because this validator is outside the transaction's staggered-submission proposer set",
268                registry,
269            )
270            .unwrap(),
271            pool_staggered_delay: register_histogram_with_registry!(
272                "consensus_transaction_pool_staggered_delay",
273                "The staggered-submission hold applied to a user-lane entry without allowed proposers before it may be proposed",
274                mysten_metrics::SUBSECOND_LATENCY_SEC_BUCKETS.to_vec(),
275                registry,
276            )
277            .unwrap(),
278            pool_stagger_shadow_decisions: register_int_counter_vec_with_registry!(
279                "consensus_transaction_pool_stagger_shadow_decisions",
280                "Decision (free, hold or reject) staggering would have made per user-lane submission while inactive",
281                &["decision"],
282                registry,
283            )
284            .unwrap(),
285            pool_stagger_shadow_delay: register_histogram_with_registry!(
286                "consensus_transaction_pool_stagger_shadow_delay",
287                "Hold staggering would have applied to a user-lane submission while inactive",
288                mysten_metrics::SUBSECOND_LATENCY_SEC_BUCKETS.to_vec(),
289                registry,
290            )
291            .unwrap(),
292            pool_taken_per_proposal: register_histogram_with_registry!(
293                "consensus_transaction_pool_taken_per_proposal",
294                "Transactions taken from the consensus transaction pool per proposal",
295                COUNT_BUCKETS.to_vec(),
296                registry,
297            )
298            .unwrap(),
299            pool_requeued_on_dropped_ack: register_int_counter_with_registry!(
300                "consensus_transaction_pool_requeued_on_dropped_ack",
301                "Entries requeued because a proposal acknowledgement was dropped",
302                registry,
303            )
304            .unwrap(),
305            pool_gc_notified: register_int_counter_with_registry!(
306                "consensus_transaction_pool_gc_notified",
307                "Block-status subscribers notified that their block was garbage collected",
308                registry,
309            )
310            .unwrap(),
311            pool_waiting_inserts: register_int_gauge_with_registry!(
312                "consensus_transaction_pool_waiting_inserts",
313                "Pool submissions waiting for the matching epoch pool to become available",
314                registry,
315            )
316            .unwrap(),
317            pool_already_processed: register_int_counter_vec_with_registry!(
318                "consensus_transaction_pool_already_processed",
319                "User submissions not proposed because they were already processed elsewhere, by the stage that detected it and the path that processed them",
320                &["stage", "method"],
321                registry,
322            )
323            .unwrap(),
324            pool_rejected_at_proposal: register_int_counter_vec_with_registry!(
325                "consensus_transaction_pool_rejected_at_proposal",
326                "User submissions dropped at proposal time because their owned input objects no longer match live state, by error",
327                &["error"],
328                registry,
329            )
330            .unwrap(),
331            pool_commit_latency: register_histogram_vec_with_registry!(
332                "consensus_transaction_pool_commit_latency",
333                "Time from insert into the transaction pool to commit of the block that proposed the entry",
334                &["lane", "proposers"],
335                mysten_metrics::LATENCY_SEC_BUCKETS.to_vec(),
336                registry,
337            )
338            .unwrap(),
339            pool_abandoned: register_int_counter_vec_with_registry!(
340                "consensus_transaction_pool_abandoned",
341                "Pool entries dropped at proposal time because their submitter stopped waiting",
342                &["lane"],
343                registry,
344            )
345            .unwrap(),
346            pool_byte_budget_bytes: register_int_gauge_with_registry!(
347                "consensus_transaction_pool_byte_budget_bytes",
348                "User-lane bytes the rate limiter allows the next proposal to take",
349                registry,
350            )
351            .unwrap(),
352            pool_byte_budget_exhausted: register_int_counter_with_registry!(
353                "consensus_transaction_pool_byte_budget_exhausted",
354                "Proposals in which the user-lane byte rate limit stopped the take",
355                registry,
356            )
357            .unwrap(),
358        }
359    }
360
361    pub fn new_for_tests() -> Self {
362        Self::new(&Registry::new())
363    }
364}
365
366/// Bounded priority queue that orders transactions by gas price. Uses a BTreeMap
367/// for efficient access at both ends: lowest gas price (for eviction) and highest
368/// gas price (for draining to consensus). Entries at the same gas price are FIFO.
369pub struct PriorityAdmissionQueue<E: AdmissionQueueEntry> {
370    capacity: usize,
371    map: BTreeMap<u64, VecDeque<E>>,
372    /// Number of queue entries per transaction key, for duplicate detection.
373    queued_keys: HashMap<ConsensusTransactionKey, u32>,
374    total_len: usize,
375    metrics: Arc<AdmissionQueueMetrics>,
376}
377
378impl<E: AdmissionQueueEntry> PriorityAdmissionQueue<E> {
379    pub fn new(capacity: usize, metrics: Arc<AdmissionQueueMetrics>) -> Self {
380        Self {
381            capacity,
382            map: BTreeMap::new(),
383            queued_keys: HashMap::new(),
384            total_len: 0,
385            metrics,
386        }
387    }
388
389    pub fn len(&self) -> usize {
390        self.total_len
391    }
392
393    pub fn min_gas_price(&self) -> Option<u64> {
394        self.map.first_key_value().map(|(&k, _)| k)
395    }
396
397    /// On success, returns `Ok(true)` or `Ok(false)` to indicate whether the
398    /// value was newly inserted. Returns `Err` if the queue was full and the
399    /// tx's gas price was not high enough to evict an existing entry.
400    pub fn insert(&mut self, entry: E) -> SuiResult<bool> {
401        self.try_insert(entry).notify()
402    }
403
404    /// Same as `insert`, without notifying displaced entries. The evicted entry (on
405    /// success) or the refused entry itself (on rejection) is handed back in the
406    /// outcome for the caller to notify manually.
407    #[track_caller]
408    pub fn try_insert(&mut self, entry: E) -> InsertOutcome<E> {
409        let keys: Vec<_> = entry.transaction_keys().collect();
410        let newly_inserted = !keys.iter().any(|k| self.queued_keys.contains_key(k));
411        if !newly_inserted {
412            self.metrics.duplicate_inserts.inc();
413        }
414
415        let gas_price = entry.gas_price();
416        if self.total_len < self.capacity {
417            self.push_entry(entry, keys, false);
418            return InsertOutcome::new(Outcome::Inserted {
419                newly_inserted,
420                evicted: None,
421                gas_price,
422            });
423        }
424
425        let min_gas_price = self.min_gas_price().unwrap();
426        if gas_price > min_gas_price {
427            let evicted = self.evict_lowest();
428            self.push_entry(entry, keys, false);
429            self.metrics.evictions.inc();
430            return InsertOutcome::new(Outcome::Inserted {
431                newly_inserted,
432                evicted: Some(evicted),
433                gas_price,
434            });
435        }
436
437        self.metrics.rejections.inc();
438        InsertOutcome::new(Outcome::Rejected {
439            entry,
440            min_gas_price,
441        })
442    }
443
444    /// Pop up to `count` entries, highest gas price first.
445    /// Within the same gas price, entries are returned in FIFO order.
446    pub fn pop_batch(&mut self, count: usize) -> Vec<E> {
447        let mut remaining = count;
448        self.pop_batch_while(|_| {
449            if remaining == 0 {
450                PopAction::Stop
451            } else {
452                remaining -= 1;
453                PopAction::Include
454            }
455        })
456        .0
457    }
458
459    pub fn into_entries(mut self) -> Vec<E> {
460        let len = self.len();
461        self.pop_batch(len)
462    }
463
464    /// Pop entries highest gas price first (FIFO within a price level) until
465    /// `action` returns `Stop` or the queue is exhausted. `Skip`ped entries and
466    /// the entry that stopped iteration stay queued. Popped entries are returned
467    /// partitioned into those the callback chose to `Include` and those to
468    /// `Exclude`.
469    pub fn pop_batch_while(
470        &mut self,
471        mut action: impl FnMut(&mut E) -> PopAction,
472    ) -> (Vec<E>, Vec<E>) {
473        let mut included = Vec::new();
474        let mut excluded = Vec::new();
475        let mut stopped = false;
476        for deque in self.map.values_mut().rev() {
477            if stopped {
478                break;
479            }
480            // Drain the level once, writing retained entries back in order: a batch with
481            // removals costs one pass over the level rather than a shift per removal.
482            // This runs on every block proposal, so it must stay linear even when most
483            // entries are skipped (e.g. held by staggered submission).
484            let mut source = std::mem::take(deque);
485            while let Some(mut entry) = source.pop_front() {
486                if stopped {
487                    deque.push_back(entry);
488                    continue;
489                }
490                match action(&mut entry) {
491                    PopAction::Stop => {
492                        stopped = true;
493                        deque.push_back(entry);
494                    }
495                    PopAction::Skip => deque.push_back(entry),
496                    PopAction::Include => {
497                        self.total_len -= 1;
498                        included.push(entry);
499                    }
500                    PopAction::Exclude => {
501                        self.total_len -= 1;
502                        excluded.push(entry);
503                    }
504                }
505            }
506        }
507        self.map.retain(|_, deque| !deque.is_empty());
508        for entry in included.iter().chain(&excluded) {
509            self.remove_keys(entry);
510        }
511        self.metrics.queue_depth.set(self.total_len as i64);
512        (included, excluded)
513    }
514
515    /// Pops, from the front of each price level, the entries for which `pred` holds,
516    /// stopping each level at the first entry for which it does not. Within a level
517    /// entries are ordered by admission time, so a predicate on age pops the aged prefix
518    /// and a level with no match costs one call.
519    pub fn pop_front_while(&mut self, mut pred: impl FnMut(&mut E) -> bool) -> Vec<E> {
520        let mut popped = Vec::new();
521        for deque in self.map.values_mut() {
522            while let Some(front) = deque.front_mut()
523                && pred(front)
524            {
525                popped.push(deque.pop_front().expect("front was just observed"));
526            }
527        }
528        if popped.is_empty() {
529            return popped;
530        }
531        self.total_len -= popped.len();
532        for entry in &popped {
533            self.remove_keys(entry);
534        }
535        self.map.retain(|_, deque| !deque.is_empty());
536        self.metrics.queue_depth.set(self.total_len as i64);
537        popped
538    }
539
540    pub fn is_empty(&self) -> bool {
541        self.total_len == 0
542    }
543
544    pub fn reinsert_front(&mut self, entry: E) {
545        let keys: Vec<_> = entry.transaction_keys().collect();
546        self.push_entry(entry, keys, true);
547    }
548
549    fn push_entry(&mut self, entry: E, keys: Vec<ConsensusTransactionKey>, front: bool) {
550        for key in keys {
551            *self.queued_keys.entry(key).or_insert(0) += 1;
552        }
553        let level = self.map.entry(entry.gas_price()).or_default();
554        if front {
555            level.push_front(entry);
556        } else {
557            level.push_back(entry);
558        }
559        self.total_len += 1;
560        self.metrics.queue_depth.set(self.total_len as i64);
561    }
562
563    fn evict_lowest(&mut self) -> E {
564        let evicted = {
565            let mut first = self
566                .map
567                .first_entry()
568                .expect("evict_lowest called on empty queue");
569            let deque = first.get_mut();
570            let evicted = deque.pop_front().unwrap();
571            if deque.is_empty() {
572                first.remove();
573            }
574            evicted
575        };
576        self.remove_keys(&evicted);
577        self.total_len -= 1;
578        evicted
579    }
580
581    fn remove_keys(&mut self, entry: &E) {
582        for key in entry.transaction_keys() {
583            let std::collections::hash_map::Entry::Occupied(mut slot) = self.queued_keys.entry(key)
584            else {
585                debug_fatal!("remove_keys on absent key");
586                continue;
587            };
588            *slot.get_mut() -= 1;
589            if *slot.get() == 0 {
590                slot.remove();
591            }
592        }
593    }
594}
595
596/// Command sent from RPC handlers to the admission queue actor via mpsc channel.
597struct InsertCommand {
598    entry: QueueEntry,
599    response: oneshot::Sender<SuiResult<bool>>,
600}
601
602/// Cloneable handle for submitting transactions to the admission queue actor.
603/// Held by RPC handlers; the actor runs in a separate spawned task.
604#[derive(Clone)]
605pub struct AdmissionQueueHandle {
606    sender: mpsc::Sender<InsertCommand>,
607    /// The moment the queue last submitted an entry to consensus.
608    last_drain: Arc<Mutex<Instant>>,
609    queue_depth: Arc<AtomicUsize>,
610    failover_timeout: Duration,
611}
612
613impl AdmissionQueueHandle {
614    /// Returns true if the queue has been non-empty for longer than
615    /// `failover_timeout` without any drain to consensus. Callers should
616    /// bypass the queue entirely when this is true.
617    pub fn failover_tripped(&self) -> bool {
618        if self.queue_depth.load(Ordering::Relaxed) == 0 {
619            return false;
620        }
621        self.last_drain.lock().unwrap().elapsed() > self.failover_timeout
622    }
623
624    /// Returns `(position_receiver, newly_inserted)` on admission. Returns `Err` on outbid
625    /// rejection.
626    pub async fn try_insert(
627        &self,
628        gas_price: u64,
629        transactions: Vec<ConsensusTransaction>,
630        submitter_client_addr: Option<IpAddr>,
631    ) -> SuiResult<(
632        oneshot::Receiver<Result<Vec<ConsensusPosition>, tonic::Status>>,
633        bool,
634    )> {
635        let (position_tx, position_rx) = oneshot::channel();
636        let entry = QueueEntry {
637            gas_price,
638            transactions,
639            position_sender: position_tx,
640            submitter_client_addr,
641            enqueue_time: Instant::now(),
642        };
643
644        let (resp_tx, resp_rx) = oneshot::channel();
645        let cmd = InsertCommand {
646            entry,
647            response: resp_tx,
648        };
649
650        self.sender
651            .send(cmd)
652            .await
653            .map_err(|_| SuiError::from(SuiErrorKind::TooManyTransactionsPendingConsensus))?;
654
655        let newly_inserted = resp_rx
656            .await
657            .map_err(|_| SuiError::from(SuiErrorKind::TooManyTransactionsPendingConsensus))??;
658
659        Ok((position_rx, newly_inserted))
660    }
661}
662
663/// Manages the lifecycle of per-epoch admission queue actors.
664/// Holds immutable config and shared metrics; call `spawn()` each epoch
665/// with the new epoch store to create a fresh actor and handle.
666pub struct AdmissionQueueManager {
667    capacity: usize,
668    failover_timeout: Duration,
669    metrics: Arc<AdmissionQueueMetrics>,
670    consensus_adapter: Arc<ConsensusAdapter>,
671    slot_freed_notify: Arc<tokio::sync::Notify>,
672}
673
674impl AdmissionQueueManager {
675    pub fn new(
676        consensus_adapter: Arc<ConsensusAdapter>,
677        metrics: Arc<AdmissionQueueMetrics>,
678        capacity_fraction: f64,
679        failover_timeout: Duration,
680        slot_freed_notify: Arc<tokio::sync::Notify>,
681    ) -> Self {
682        let max_pending = consensus_adapter.max_pending_transactions();
683        let capacity = (max_pending as f64 * capacity_fraction) as usize;
684        assert!(
685            capacity > 0,
686            "admission_queue_capacity_fraction ({capacity_fraction}) * max_pending_transactions ({max_pending}) must be > 0"
687        );
688        Self {
689            capacity,
690            failover_timeout,
691            metrics,
692            consensus_adapter,
693            slot_freed_notify,
694        }
695    }
696
697    pub fn new_for_tests(
698        consensus_adapter: Arc<ConsensusAdapter>,
699        slot_freed_notify: Arc<tokio::sync::Notify>,
700    ) -> Self {
701        Self {
702            capacity: 10_000,
703            failover_timeout: Duration::from_secs(30),
704            metrics: Arc::new(AdmissionQueueMetrics::new_for_tests()),
705            consensus_adapter,
706            slot_freed_notify,
707        }
708    }
709
710    pub fn metrics(&self) -> &Arc<AdmissionQueueMetrics> {
711        &self.metrics
712    }
713
714    /// Spawns a new per-epoch admission queue actor and returns a handle.
715    /// The previous actor shuts down when its handle is dropped.
716    pub fn spawn(&self, epoch_store: Arc<AuthorityPerEpochStore>) -> AdmissionQueueHandle {
717        let last_drain = Arc::new(Mutex::new(Instant::now()));
718        let queue_depth = Arc::new(AtomicUsize::new(0));
719
720        let (sender, receiver) = mpsc::channel(self.capacity.max(1024));
721
722        let event_loop = AdmissionQueueEventLoop {
723            receiver,
724            queue: PriorityAdmissionQueue::new(self.capacity, self.metrics.clone()),
725            consensus_adapter: self.consensus_adapter.clone(),
726            slot_freed_notify: self.slot_freed_notify.clone(),
727            epoch_store,
728            last_drain: last_drain.clone(),
729            queue_depth: queue_depth.clone(),
730            last_published_depth: 0,
731        };
732        spawn_monitored_task!(event_loop.run());
733
734        AdmissionQueueHandle {
735            sender,
736            last_drain,
737            queue_depth,
738            failover_timeout: self.failover_timeout,
739        }
740    }
741}
742
743/// Shared handle to a live admission queue. Holds the manager (for spawning a
744/// fresh per-epoch actor on reconfig), the per-epoch `ArcSwap` handle, and the
745/// cached (config-derived) bypass threshold. Cloned cheaply by `Arc`; passed
746/// both to `ValidatorService` (for hot-path routing) and through
747/// `ValidatorComponents` (for epoch rotation).
748#[derive(Clone)]
749pub struct AdmissionQueueContext {
750    manager: Arc<AdmissionQueueManager>,
751    swap: Arc<ArcSwap<AdmissionQueueHandle>>,
752}
753
754impl AdmissionQueueContext {
755    pub fn spawn(
756        manager: Arc<AdmissionQueueManager>,
757        epoch_store: Arc<AuthorityPerEpochStore>,
758    ) -> Self {
759        let initial_handle = manager.spawn(epoch_store);
760        let swap = Arc::new(ArcSwap::new(Arc::new(initial_handle)));
761        Self { manager, swap }
762    }
763
764    /// Spawns a new per-epoch actor and atomically replaces the current handle.
765    /// The old actor shuts down when its handle is dropped.
766    pub fn rotate_for_epoch(&self, epoch_store: Arc<AuthorityPerEpochStore>) {
767        self.swap.store(Arc::new(self.manager.spawn(epoch_store)));
768    }
769
770    pub(crate) fn load(&self) -> arc_swap::Guard<Arc<AdmissionQueueHandle>> {
771        self.swap.load()
772    }
773}
774
775/// Per-epoch event loop that owns the priority queue and drains entries
776/// to consensus as capacity becomes available.
777struct AdmissionQueueEventLoop {
778    receiver: mpsc::Receiver<InsertCommand>,
779    queue: PriorityAdmissionQueue<QueueEntry>,
780    consensus_adapter: Arc<ConsensusAdapter>,
781    slot_freed_notify: Arc<tokio::sync::Notify>,
782    epoch_store: Arc<AuthorityPerEpochStore>,
783    last_drain: Arc<Mutex<Instant>>,
784    queue_depth: Arc<AtomicUsize>,
785    last_published_depth: usize,
786}
787
788impl AdmissionQueueEventLoop {
789    pub async fn run(mut self) {
790        loop {
791            self.process_pending_inserts();
792            self.publish_queue_depth();
793
794            if !handle_fail_point_if("admission_queue_disable_drain")
795                && !self.queue.is_empty()
796                && self.has_consensus_capacity()
797            {
798                self.drain_batch();
799                self.publish_queue_depth();
800                continue;
801            }
802
803            if self.queue.is_empty() {
804                // Nothing to drain — just wait for a new insert.
805                match self.receiver.recv().await {
806                    Some(cmd) => self.handle_insert(cmd),
807                    None => {
808                        debug!("Admission queue actor shutting down");
809                        break;
810                    }
811                }
812                continue;
813            }
814
815            // Queue has entries but consensus is at capacity. Wait for either
816            // a new insert or a freed inflight slot.
817            // Register the notified future BEFORE re-checking capacity to avoid
818            // missing notifications.
819            let notify = self.slot_freed_notify.clone();
820            let slot_freed = notify.notified();
821            tokio::pin!(slot_freed);
822
823            self.process_pending_inserts();
824            if !handle_fail_point_if("admission_queue_disable_drain")
825                && !self.queue.is_empty()
826                && self.has_consensus_capacity()
827            {
828                continue;
829            }
830
831            tokio::select! {
832                biased;
833
834                result = self.receiver.recv() => {
835                    match result {
836                        Some(cmd) => self.handle_insert(cmd),
837                        None => {
838                            debug!("Admission queue actor shutting down");
839                            break;
840                        }
841                    }
842                }
843
844                _ = &mut slot_freed => {}
845            }
846        }
847    }
848
849    fn publish_queue_depth(&mut self) {
850        let len = self.queue.len();
851        if len != self.last_published_depth {
852            self.queue_depth.store(len, Ordering::Relaxed);
853            self.last_published_depth = len;
854        }
855    }
856
857    fn process_pending_inserts(&mut self) {
858        while let Ok(cmd) = self.receiver.try_recv() {
859            self.handle_insert(cmd);
860        }
861    }
862
863    fn has_consensus_capacity(&self) -> bool {
864        self.consensus_adapter.num_inflight_transactions()
865            < u64::try_from(self.consensus_adapter.max_pending_transactions()).unwrap()
866    }
867
868    fn drain_batch(&mut self) {
869        let max_pending = u64::try_from(self.consensus_adapter.max_pending_transactions()).unwrap();
870        let available =
871            max_pending.saturating_sub(self.consensus_adapter.num_inflight_transactions());
872        let entries = self.queue.pop_batch(usize::try_from(available).unwrap());
873        if entries.is_empty() {
874            return;
875        }
876        for entry in entries {
877            let proposers = proposers_metric_label(&entry.transactions, &self.epoch_store);
878            self.queue
879                .metrics
880                .queue_wait_latency
881                .with_label_values(&["user", proposers])
882                .observe(entry.enqueue_time.elapsed().as_secs_f64());
883            let adapter = self.consensus_adapter.clone();
884            let es = self.epoch_store.clone();
885            spawn_monitored_task!(submit_queue_entry(entry, adapter, es));
886        }
887        *self.last_drain.lock().unwrap() = Instant::now();
888    }
889
890    fn handle_insert(&mut self, cmd: InsertCommand) {
891        let _ = cmd.response.send(self.queue.insert(cmd.entry));
892    }
893}
894
895async fn submit_queue_entry(
896    entry: QueueEntry,
897    consensus_adapter: Arc<ConsensusAdapter>,
898    epoch_store: Arc<AuthorityPerEpochStore>,
899) {
900    let _ = entry.position_sender.send(
901        consensus_adapter
902            .submit_and_get_positions(
903                entry.transactions,
904                &epoch_store,
905                entry.submitter_client_addr,
906            )
907            .await
908            .map_err(tonic::Status::from),
909    );
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915    use sui_macros::sim_test;
916
917    fn make_test_entry(
918        gas_price: u64,
919    ) -> (
920        QueueEntry,
921        oneshot::Receiver<Result<Vec<ConsensusPosition>, tonic::Status>>,
922    ) {
923        let (tx, rx) = oneshot::channel();
924        (QueueEntry::new_for_test(gas_price, tx), rx)
925    }
926
927    #[cfg(debug_assertions)]
928    #[test]
929    #[should_panic(expected = "dropped without notify")]
930    fn dropped_insert_outcome_without_notify_panics() {
931        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
932        let mut q = PriorityAdmissionQueue::new(1, metrics);
933        let (entry, _rx) = make_test_entry(100);
934        let _ = q.try_insert(entry);
935    }
936
937    #[test]
938    fn try_insert_notify_matches_insert() {
939        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
940        let mut q = PriorityAdmissionQueue::new(1, metrics);
941        let (low, mut low_rx) = make_test_entry(100);
942        let (high, _) = make_test_entry(200);
943        let (rejected, mut rejected_rx) = make_test_entry(50);
944
945        assert!(q.try_insert(low).notify().unwrap());
946        // Eviction is delivered only by `notify`.
947        let outcome = q.try_insert(high);
948        assert!(low_rx.try_recv().is_err());
949        assert!(outcome.notify().unwrap());
950        assert!(matches!(
951            low_rx.try_recv(),
952            Ok(Err(status)) if status.message().contains("minimum gas price required: 200")
953        ));
954
955        assert!(matches!(
956            q.try_insert(rejected).notify().unwrap_err().as_inner(),
957            SuiErrorKind::TransactionRejectedDueToOutbiddingDuringCongestion { min_gas_price: 200 }
958        ));
959        assert_eq!(q.len(), 1);
960        assert!(rejected_rx.try_recv().is_err());
961    }
962
963    fn entry_at(gas_price: u64, enqueue_time: Instant) -> QueueEntry {
964        let mut entry = make_test_entry(gas_price).0;
965        entry.enqueue_time = enqueue_time;
966        entry
967    }
968
969    fn expired_at(now: Instant, ttl: Duration) -> impl FnMut(&mut QueueEntry) -> bool {
970        move |entry| now.saturating_duration_since(entry.enqueue_time) >= ttl
971    }
972
973    fn remaining(q: &mut PriorityAdmissionQueue<QueueEntry>) -> Vec<(u64, Instant)> {
974        let len = q.len();
975        q.pop_batch(len)
976            .into_iter()
977            .map(|entry| (entry.gas_price, entry.enqueue_time))
978            .collect()
979    }
980
981    #[test]
982    fn pop_front_while_drops_aged_entries_and_their_keys() {
983        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
984        let mut q = PriorityAdmissionQueue::new(10, metrics.clone());
985        let now = Instant::now();
986        let mut keyed = entry_at(100, now);
987        keyed.transactions = vec![ConsensusTransaction::new_end_of_publish(
988            sui_types::base_types::AuthorityName::ZERO,
989        )];
990        q.insert(keyed).unwrap();
991        q.insert(entry_at(200, now)).unwrap();
992
993        let popped = q.pop_front_while(expired_at(
994            now + Duration::from_secs(120),
995            Duration::from_secs(60),
996        ));
997        assert_eq!(popped.len(), 2);
998        assert_eq!(q.len(), 0);
999        assert_eq!(metrics.queue_depth.get(), 0);
1000        assert!(q.queued_keys.is_empty());
1001        assert!(q.map.is_empty());
1002    }
1003
1004    #[test]
1005    fn pop_front_while_keeps_fresh_entries() {
1006        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1007        let mut q = PriorityAdmissionQueue::new(10, metrics);
1008        let now = Instant::now();
1009        q.insert(entry_at(100, now)).unwrap();
1010        q.insert(entry_at(200, now)).unwrap();
1011
1012        let popped = q.pop_front_while(expired_at(now, Duration::from_secs(60)));
1013        assert!(popped.is_empty());
1014        assert_eq!(q.len(), 2);
1015    }
1016
1017    #[test]
1018    fn pop_front_while_pops_the_aged_prefix_of_every_level() {
1019        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1020        let mut q = PriorityAdmissionQueue::new(10, metrics);
1021        let now = Instant::now();
1022        let ttl = Duration::from_secs(60);
1023        let fresh = now + Duration::from_secs(120);
1024        // Level 100 is entirely fresh and sits below level 200, so a sweep that stops at
1025        // the first fresh level never reaches 200.
1026        q.insert(entry_at(100, fresh)).unwrap();
1027        // Level 200 holds two adjacent aged entries ahead of a fresh one.
1028        q.insert(entry_at(200, now)).unwrap();
1029        q.insert(entry_at(200, now + Duration::from_secs(1)))
1030            .unwrap();
1031        q.insert(entry_at(200, fresh)).unwrap();
1032        // Level 300 ends with an aged entry behind a fresh one, which stays queued.
1033        q.insert(entry_at(300, fresh)).unwrap();
1034        q.insert(entry_at(300, now)).unwrap();
1035
1036        let popped = q.pop_front_while(expired_at(now + ttl + Duration::from_secs(1), ttl));
1037        let popped: Vec<_> = popped
1038            .into_iter()
1039            .map(|entry| (entry.gas_price, entry.enqueue_time))
1040            .collect();
1041        assert_eq!(
1042            popped,
1043            vec![(200, now), (200, now + Duration::from_secs(1))]
1044        );
1045        assert_eq!(
1046            remaining(&mut q),
1047            vec![(300, fresh), (300, now), (200, fresh), (100, fresh)]
1048        );
1049    }
1050
1051    fn build_queue(capacity: usize, gas_prices: &[u64]) -> PriorityAdmissionQueue<QueueEntry> {
1052        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1053        let mut q = PriorityAdmissionQueue::new(capacity, metrics);
1054        for &gp in gas_prices {
1055            let (entry, _) = make_test_entry(gp);
1056            q.insert(entry).unwrap();
1057        }
1058        q
1059    }
1060
1061    #[test]
1062    fn test_insert_within_capacity() {
1063        let q = build_queue(3, &[100, 200, 50]);
1064        assert_eq!(q.len(), 3);
1065    }
1066
1067    #[test]
1068    fn test_pop_batch_while_partitions_and_stops() {
1069        let mut q = build_queue(10, &[100, 200, 200, 50, 75]);
1070
1071        let (included, excluded) = q.pop_batch_while(|entry| match entry.gas_price {
1072            price if price < 80 => PopAction::Stop,
1073            price if price < 150 => PopAction::Exclude,
1074            _ => PopAction::Include,
1075        });
1076        assert_eq!(
1077            included.iter().map(|e| e.gas_price).collect::<Vec<_>>(),
1078            vec![200, 200]
1079        );
1080        assert_eq!(
1081            excluded.iter().map(|e| e.gas_price).collect::<Vec<_>>(),
1082            vec![100]
1083        );
1084        // The entry that stopped iteration stays queued, as does everything behind it.
1085        assert_eq!(q.len(), 2);
1086        assert_eq!(q.min_gas_price(), Some(50));
1087        let (rest, _) = q.pop_batch_while(|_| PopAction::Include);
1088        assert_eq!(rest.len(), 2);
1089        assert!(q.is_empty());
1090    }
1091
1092    #[test]
1093    fn test_pop_batch_while_skips_and_retains() {
1094        let mut q = build_queue(10, &[100, 200, 200, 50, 75]);
1095
1096        // Skipping continues past the entry — both within a price level (only one of
1097        // the two 200s is skipped) and into lower levels.
1098        let mut seen_200 = false;
1099        let (included, excluded) = q.pop_batch_while(|entry| match entry.gas_price {
1100            200 if !seen_200 => {
1101                seen_200 = true;
1102                PopAction::Skip
1103            }
1104            100 => PopAction::Skip,
1105            75 => PopAction::Exclude,
1106            _ => PopAction::Include,
1107        });
1108        assert_eq!(
1109            included.iter().map(|e| e.gas_price).collect::<Vec<_>>(),
1110            vec![200, 50]
1111        );
1112        assert_eq!(
1113            excluded.iter().map(|e| e.gas_price).collect::<Vec<_>>(),
1114            vec![75]
1115        );
1116        // Skipped entries stay queued in order and are visible to later pops.
1117        assert_eq!(q.len(), 2);
1118        let (rest, _) = q.pop_batch_while(|_| PopAction::Include);
1119        assert_eq!(
1120            rest.iter().map(|e| e.gas_price).collect::<Vec<_>>(),
1121            vec![200, 100]
1122        );
1123        assert!(q.is_empty());
1124    }
1125
1126    #[test]
1127    fn test_pop_batch_while_skip_then_stop() {
1128        // A skipped entry ahead of the stopping one: both stay queued, in order,
1129        // with bookkeeping intact.
1130        let mut q = build_queue(10, &[300, 200, 100]);
1131
1132        let (included, excluded) = q.pop_batch_while(|entry| match entry.gas_price {
1133            300 => PopAction::Skip,
1134            200 => PopAction::Include,
1135            _ => PopAction::Stop,
1136        });
1137        assert_eq!(
1138            included.iter().map(|e| e.gas_price).collect::<Vec<_>>(),
1139            vec![200]
1140        );
1141        assert!(excluded.is_empty());
1142        assert_eq!(q.len(), 2);
1143        assert_eq!(q.min_gas_price(), Some(100));
1144        let (rest, _) = q.pop_batch_while(|_| PopAction::Include);
1145        assert_eq!(
1146            rest.iter().map(|e| e.gas_price).collect::<Vec<_>>(),
1147            vec![300, 100]
1148        );
1149        assert!(q.is_empty());
1150    }
1151
1152    #[test]
1153    fn test_pop_batch_while_fully_skipped_level_retained() {
1154        let mut q = build_queue(10, &[200, 200]);
1155
1156        let (included, excluded) = q.pop_batch_while(|_| PopAction::Skip);
1157        assert!(included.is_empty() && excluded.is_empty());
1158        // The untouched level survives with its invariants: length, min price, and
1159        // eviction of a full queue still work on it.
1160        assert_eq!(q.len(), 2);
1161        assert_eq!(q.min_gas_price(), Some(200));
1162        let (rest, _) = q.pop_batch_while(|_| PopAction::Include);
1163        assert_eq!(
1164            rest.iter().map(|e| e.gas_price).collect::<Vec<_>>(),
1165            vec![200, 200]
1166        );
1167        assert!(q.is_empty());
1168    }
1169
1170    #[test]
1171    fn test_eviction_when_full() {
1172        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1173        let mut q = PriorityAdmissionQueue::new(2, metrics);
1174
1175        let (e1, mut r1) = make_test_entry(100);
1176        let (e2, _) = make_test_entry(200);
1177        let (e3, _) = make_test_entry(300);
1178
1179        q.insert(e1).unwrap();
1180        q.insert(e2).unwrap();
1181        assert_eq!(q.len(), 2);
1182
1183        assert!(q.insert(e3).is_ok());
1184        assert_eq!(q.len(), 2);
1185        // Evicted entry's caller receives an explicit outbid error.
1186        let r1_result = r1.try_recv().expect("evicted entry must be signalled");
1187        assert!(matches!(r1_result, Err(ref status) if status.message().contains("outbid")));
1188    }
1189
1190    #[test]
1191    fn test_rejection_when_full_and_low_price() {
1192        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1193        let mut q = PriorityAdmissionQueue::new(2, metrics);
1194
1195        let (e1, _) = make_test_entry(100);
1196        let (e2, _) = make_test_entry(200);
1197        let (e3, mut r3) = make_test_entry(50);
1198
1199        q.insert(e1).unwrap();
1200        q.insert(e2).unwrap();
1201
1202        assert!(matches!(
1203            q.insert(e3).unwrap_err().as_inner(),
1204            SuiErrorKind::TransactionRejectedDueToOutbiddingDuringCongestion { min_gas_price: 100 }
1205        ));
1206        assert_eq!(q.len(), 2);
1207        assert!(r3.try_recv().is_err());
1208    }
1209
1210    #[test]
1211    fn test_pop_batch() {
1212        let mut q = build_queue(5, &[100, 300, 200]);
1213        let batch = q.pop_batch(2);
1214        assert_eq!(batch.len(), 2);
1215        assert_eq!(q.len(), 1);
1216    }
1217
1218    #[test]
1219    fn test_min_gas_price() {
1220        let q = build_queue(5, &[200, 100, 300]);
1221        assert_eq!(q.min_gas_price(), Some(100));
1222    }
1223
1224    #[test]
1225    fn test_gasless_tx_evicted_first() {
1226        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1227        let mut q = PriorityAdmissionQueue::new(2, metrics);
1228
1229        let (gasless, mut r_gasless) = make_test_entry(0);
1230        let (normal, _) = make_test_entry(1000);
1231        let (high, _) = make_test_entry(2000);
1232
1233        q.insert(gasless).unwrap();
1234        q.insert(normal).unwrap();
1235
1236        assert!(q.insert(high).is_ok());
1237        let gasless_result = r_gasless
1238            .try_recv()
1239            .expect("evicted gasless entry must be signalled");
1240        assert!(matches!(gasless_result, Err(ref status) if status.message().contains("outbid")));
1241        assert_eq!(q.min_gas_price(), Some(1000));
1242    }
1243
1244    #[test]
1245    fn test_pop_batch_returns_highest_gas_price_first() {
1246        let mut q = build_queue(5, &[100, 500, 200, 400, 300]);
1247        let batch = q.pop_batch(5);
1248        let gas_prices: Vec<u64> = batch.iter().map(|e| e.gas_price).collect();
1249        assert_eq!(gas_prices, vec![500, 400, 300, 200, 100]);
1250    }
1251
1252    #[test]
1253    fn test_equal_gas_price_rejected_when_full() {
1254        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1255        let mut q = PriorityAdmissionQueue::new(1, metrics);
1256
1257        let (e1, _) = make_test_entry(100);
1258        let (e2, _) = make_test_entry(100);
1259
1260        q.insert(e1).unwrap();
1261        assert!(matches!(
1262            q.insert(e2).unwrap_err().as_inner(),
1263            SuiErrorKind::TransactionRejectedDueToOutbiddingDuringCongestion { min_gas_price: 100 }
1264        ));
1265    }
1266
1267    fn make_dup_entry(
1268        gas_price: u64,
1269        tx: ConsensusTransaction,
1270    ) -> (
1271        QueueEntry,
1272        oneshot::Receiver<Result<Vec<ConsensusPosition>, tonic::Status>>,
1273    ) {
1274        let (position_tx, position_rx) = oneshot::channel();
1275        let entry = QueueEntry {
1276            gas_price,
1277            transactions: vec![tx],
1278            position_sender: position_tx,
1279            submitter_client_addr: None,
1280            enqueue_time: Instant::now(),
1281        };
1282        (entry, position_rx)
1283    }
1284
1285    #[test]
1286    fn test_duplicate_transaction_admitted_and_flagged() {
1287        use sui_types::base_types::AuthorityName;
1288
1289        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1290        let mut q = PriorityAdmissionQueue::new(10, metrics);
1291
1292        let tx = ConsensusTransaction::new_end_of_publish(AuthorityName::ZERO);
1293
1294        let (entry1, _rx1) = make_dup_entry(100, tx.clone());
1295        assert!(q.insert(entry1).unwrap());
1296        assert_eq!(q.len(), 1);
1297
1298        // Same transaction again — admitted, but flagged as not-fresh so the
1299        // RPC layer can tally it as spam for DoS protection.
1300        let (entry2, _rx2) = make_dup_entry(100, tx.clone());
1301        assert!(!q.insert(entry2).unwrap());
1302        assert_eq!(q.len(), 2);
1303    }
1304
1305    #[test]
1306    fn test_duplicate_key_counter_decrements_on_pop() {
1307        use sui_types::base_types::AuthorityName;
1308
1309        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1310        let mut q = PriorityAdmissionQueue::new(10, metrics);
1311
1312        let tx = ConsensusTransaction::new_end_of_publish(AuthorityName::ZERO);
1313
1314        // Insert two copies of the same tx.
1315        let (entry1, _rx1) = make_dup_entry(100, tx.clone());
1316        q.insert(entry1).unwrap();
1317        let (entry2, _rx2) = make_dup_entry(100, tx.clone());
1318        assert!(!q.insert(entry2).unwrap());
1319
1320        // Pop one copy. The key's counter should drop to 1 — a fresh insert
1321        // should still be flagged as not-fresh against the remaining copy.
1322        let batch = q.pop_batch(1);
1323        assert_eq!(batch.len(), 1);
1324        let (entry3, _rx3) = make_dup_entry(100, tx.clone());
1325        assert!(!q.insert(entry3).unwrap());
1326        assert_eq!(q.len(), 2);
1327
1328        // Drain both remaining entries. The counter should hit 0 and the key
1329        // should be removed — a subsequent insert is fresh again.
1330        let _ = q.pop_batch(q.len());
1331        assert!(q.is_empty());
1332        let (entry4, _rx4) = make_dup_entry(100, tx);
1333        assert!(q.insert(entry4).unwrap());
1334    }
1335
1336    #[test]
1337    fn test_duplicate_key_counter_decrements_on_evict() {
1338        use sui_types::base_types::AuthorityName;
1339
1340        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1341        let mut q = PriorityAdmissionQueue::new(2, metrics);
1342
1343        let tx = ConsensusTransaction::new_end_of_publish(AuthorityName::ZERO);
1344
1345        // Fill queue with two copies of `tx` at price 100.
1346        let (entry1, _rx1) = make_dup_entry(100, tx.clone());
1347        q.insert(entry1).unwrap();
1348        let (entry2, _rx2) = make_dup_entry(100, tx.clone());
1349        q.insert(entry2).unwrap();
1350        assert_eq!(q.len(), 2);
1351
1352        // Evict one dup with a higher-priced non-dup.
1353        let (filler, _) = make_test_entry(200);
1354        q.insert(filler).unwrap();
1355        assert_eq!(q.len(), 2);
1356
1357        // Evict the remaining dup with another non-dup. After both dups are
1358        // evicted, the counter should hit 0 and re-inserting `tx` is not a
1359        // duplicate.
1360        let (filler2, _) = make_test_entry(300);
1361        q.insert(filler2).unwrap();
1362
1363        let (entry3, _rx3) = make_dup_entry(500, tx);
1364        assert!(q.insert(entry3).unwrap());
1365    }
1366
1367    #[test]
1368    fn test_duplicate_key_counter_restored_on_reinsert_front() {
1369        use sui_types::base_types::AuthorityName;
1370
1371        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1372        let mut q = PriorityAdmissionQueue::new(10, metrics);
1373
1374        let tx = ConsensusTransaction::new_end_of_publish(AuthorityName::ZERO);
1375
1376        let (entry1, _rx1) = make_dup_entry(100, tx.clone());
1377        q.insert(entry1).unwrap();
1378
1379        // Popping removes the key; reinserting the popped entry (the dropped-proposal
1380        // requeue path) must restore it, so a copy of `tx` is flagged as a duplicate.
1381        let popped = q.pop_batch(1).pop().unwrap();
1382        q.reinsert_front(popped);
1383        assert_eq!(q.len(), 1);
1384        let (entry2, _rx2) = make_dup_entry(100, tx.clone());
1385        assert!(!q.insert(entry2).unwrap());
1386
1387        // Draining everything zeroes the counter and the same tx is fresh again.
1388        let _ = q.pop_batch(q.len());
1389        assert!(q.is_empty());
1390        let (entry3, _rx3) = make_dup_entry(100, tx);
1391        assert!(q.insert(entry3).unwrap());
1392    }
1393
1394    #[sim_test]
1395    async fn test_actor_shuts_down_when_handle_dropped() {
1396        use crate::authority::test_authority_builder::TestAuthorityBuilder;
1397        use crate::checkpoints::CheckpointStore;
1398        use crate::consensus_adapter::ConsensusAdapterMetrics;
1399        use crate::mysticeti_adapter::LazyMysticetiClient;
1400        use sui_types::base_types::AuthorityName;
1401
1402        let state = TestAuthorityBuilder::new().build().await;
1403        let epoch_store = state.epoch_store_for_testing().clone();
1404        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1405            Arc::new(LazyMysticetiClient::new()),
1406            CheckpointStore::new_for_tests(),
1407            AuthorityName::ZERO,
1408            100_000,
1409            100_000,
1410            ConsensusAdapterMetrics::new_test(),
1411            Arc::new(tokio::sync::Notify::new()),
1412        ));
1413
1414        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1415        let (sender, receiver) = mpsc::channel(100);
1416        let slot_freed_notify = Arc::new(tokio::sync::Notify::new());
1417
1418        let event_loop = AdmissionQueueEventLoop {
1419            receiver,
1420            queue: PriorityAdmissionQueue::new(100, metrics),
1421            consensus_adapter,
1422            slot_freed_notify,
1423            epoch_store,
1424            last_drain: Arc::new(Mutex::new(Instant::now())),
1425            queue_depth: Arc::new(AtomicUsize::new(0)),
1426            last_published_depth: 0,
1427        };
1428
1429        let handle = tokio::spawn(event_loop.run());
1430
1431        // Drop the sender — this closes the channel.
1432        drop(sender);
1433
1434        // The actor should exit promptly.
1435        tokio::time::timeout(std::time::Duration::from_secs(5), handle)
1436            .await
1437            .expect("actor did not shut down within timeout")
1438            .expect("actor task panicked");
1439    }
1440
1441    async fn build_consensus_adapter(
1442        max_pending_transactions: usize,
1443    ) -> (
1444        Arc<ConsensusAdapter>,
1445        Arc<AuthorityPerEpochStore>,
1446        Arc<tokio::sync::Notify>,
1447    ) {
1448        use crate::authority::test_authority_builder::TestAuthorityBuilder;
1449        use crate::checkpoints::CheckpointStore;
1450        use crate::consensus_adapter::ConsensusAdapterMetrics;
1451        use crate::mysticeti_adapter::LazyMysticetiClient;
1452        use sui_types::base_types::AuthorityName;
1453
1454        let state = TestAuthorityBuilder::new().build().await;
1455        let epoch_store = state.epoch_store_for_testing().clone();
1456        let slot_freed_notify = Arc::new(tokio::sync::Notify::new());
1457        let adapter = Arc::new(ConsensusAdapter::new(
1458            Arc::new(LazyMysticetiClient::new()),
1459            CheckpointStore::new_for_tests(),
1460            AuthorityName::ZERO,
1461            max_pending_transactions,
1462            100_000,
1463            ConsensusAdapterMetrics::new_test(),
1464            slot_freed_notify.clone(),
1465        ));
1466        (adapter, epoch_store, slot_freed_notify)
1467    }
1468
1469    #[tokio::test]
1470    async fn test_failover_tripped_when_actor_stalls() {
1471        // Construct a handle with a tiny failover window and no running actor.
1472        // Failover requires queue_depth > 0, so simulate a non-empty queue.
1473        let handle = AdmissionQueueHandle {
1474            sender: mpsc::channel(1).0,
1475            last_drain: Arc::new(Mutex::new(Instant::now())),
1476            queue_depth: Arc::new(AtomicUsize::new(1)),
1477            failover_timeout: Duration::from_millis(10),
1478        };
1479        assert!(!handle.failover_tripped());
1480        tokio::time::sleep(Duration::from_millis(30)).await;
1481        assert!(handle.failover_tripped());
1482
1483        // An empty queue is never a failover, even if last_drain is stale.
1484        handle.queue_depth.store(0, Ordering::Relaxed);
1485        assert!(!handle.failover_tripped());
1486    }
1487
1488    #[sim_test]
1489    async fn test_idle_actor_does_not_trip_failover() {
1490        // A healthy actor with an empty queue must never trip failover, even
1491        // after long idle periods while blocked on `receiver.recv()`.
1492        let (adapter, epoch_store, notify) = build_consensus_adapter(100_000).await;
1493        let manager = AdmissionQueueManager::new(
1494            adapter,
1495            Arc::new(AdmissionQueueMetrics::new_for_tests()),
1496            0.5,
1497            Duration::from_millis(10),
1498            notify,
1499        );
1500        let handle = manager.spawn(epoch_store);
1501        tokio::time::sleep(Duration::from_millis(50)).await;
1502        assert!(
1503            !handle.failover_tripped(),
1504            "idle actor with empty queue must not trip failover"
1505        );
1506    }
1507
1508    /// If `drain_batch` is entered but consensus has zero slots available
1509    /// (the inflight count raced past `max_pending_transactions` between the
1510    /// `has_consensus_capacity` check and the read inside `drain_batch`), no
1511    /// entries are popped and `last_drain` must NOT advance — otherwise a
1512    /// truly stuck drainer would be hidden from the failover check.
1513    #[sim_test]
1514    async fn test_drain_batch_does_not_bump_last_drain_when_no_slots() {
1515        let (adapter, epoch_store, notify) = build_consensus_adapter(0).await;
1516        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
1517        let (_sender, receiver) = mpsc::channel(10);
1518
1519        let mut queue = PriorityAdmissionQueue::new(10, metrics.clone());
1520        let (entry, _rx) = make_test_entry(100);
1521        assert!(queue.insert(entry).is_ok());
1522        assert_eq!(queue.len(), 1);
1523
1524        let last_drain = Arc::new(Mutex::new(Instant::now()));
1525        let before = *last_drain.lock().unwrap();
1526
1527        let mut event_loop = AdmissionQueueEventLoop {
1528            receiver,
1529            queue,
1530            consensus_adapter: adapter,
1531            slot_freed_notify: notify,
1532            epoch_store,
1533            last_drain: last_drain.clone(),
1534            queue_depth: Arc::new(AtomicUsize::new(0)),
1535            last_published_depth: 0,
1536        };
1537
1538        // Sleep so that if drain_batch erroneously stamps Instant::now() the
1539        // stored value would differ from `before`.
1540        tokio::time::sleep(Duration::from_millis(20)).await;
1541
1542        event_loop.drain_batch();
1543
1544        assert_eq!(event_loop.queue.len(), 1, "no entries should be drained");
1545        assert_eq!(
1546            *last_drain.lock().unwrap(),
1547            before,
1548            "last_drain must not advance when drain_batch drained nothing"
1549        );
1550    }
1551}