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 arc_swap::ArcSwap;
7use mysten_common::debug_fatal;
8use mysten_metrics::spawn_monitored_task;
9use prometheus::{
10    Histogram, IntCounter, IntGauge, Registry, register_histogram_with_registry,
11    register_int_counter_with_registry, register_int_gauge_with_registry,
12};
13use std::collections::{BTreeMap, HashMap, VecDeque};
14use std::net::IpAddr;
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18use sui_macros::handle_fail_point_if;
19use sui_network::tonic;
20use sui_types::error::{SuiError, SuiErrorKind, SuiResult};
21use sui_types::messages_consensus::{
22    ConsensusPosition, ConsensusTransaction, ConsensusTransactionKey,
23};
24use tokio::sync::{mpsc, oneshot};
25use tracing::debug;
26
27/// A transaction (or soft bundle) waiting in the admission queue for consensus submission.
28pub struct QueueEntry {
29    pub gas_price: u64,
30    pub transactions: Vec<ConsensusTransaction>,
31    pub position_sender: oneshot::Sender<Result<Vec<ConsensusPosition>, tonic::Status>>,
32    pub submitter_client_addr: Option<IpAddr>,
33    pub enqueue_time: Instant,
34}
35
36impl QueueEntry {
37    #[cfg(test)]
38    pub fn new_for_test(
39        gas_price: u64,
40        position_sender: oneshot::Sender<Result<Vec<ConsensusPosition>, tonic::Status>>,
41    ) -> Self {
42        Self {
43            gas_price,
44            transactions: vec![],
45            position_sender,
46            submitter_client_addr: None,
47            enqueue_time: Instant::now(),
48        }
49    }
50}
51
52/// Prometheus metrics for the admission queue.
53pub struct AdmissionQueueMetrics {
54    pub queue_depth: IntGauge,
55    pub queue_wait_latency: Histogram,
56    pub evictions: IntCounter,
57    pub rejections: IntCounter,
58    pub duplicate_inserts: IntCounter,
59}
60
61impl AdmissionQueueMetrics {
62    pub fn new(registry: &Registry) -> Self {
63        Self {
64            queue_depth: register_int_gauge_with_registry!(
65                "admission_queue_depth",
66                "Current number of entries in the admission priority queue",
67                registry,
68            )
69            .unwrap(),
70            queue_wait_latency: register_histogram_with_registry!(
71                "admission_queue_wait_latency",
72                "Time a transaction spends waiting in the admission queue before being drained",
73                mysten_metrics::SUBSECOND_LATENCY_SEC_BUCKETS.to_vec(),
74                registry,
75            )
76            .unwrap(),
77            evictions: register_int_counter_with_registry!(
78                "admission_queue_evictions",
79                "Number of entries evicted from the admission queue by higher gas price transactions",
80                registry,
81            )
82            .unwrap(),
83            rejections: register_int_counter_with_registry!(
84                "admission_queue_rejections",
85                "Number of transactions rejected because the queue was full and their gas price was too low",
86                registry,
87            )
88            .unwrap(),
89            duplicate_inserts: register_int_counter_with_registry!(
90                "admission_queue_duplicate_inserts",
91                "Transactions admitted to the queue whose ConsensusTransactionKey duplicated an entry already present. Tallied as spam for DoS protection.",
92                registry,
93            )
94            .unwrap(),
95        }
96    }
97
98    pub fn new_for_tests() -> Self {
99        Self::new(&Registry::new())
100    }
101}
102
103/// Bounded priority queue that orders transactions by gas price. Uses a BTreeMap
104/// for efficient access at both ends: lowest gas price (for eviction) and highest
105/// gas price (for draining to consensus). Entries at the same gas price are FIFO.
106pub struct PriorityAdmissionQueue {
107    capacity: usize,
108    map: BTreeMap<u64, VecDeque<QueueEntry>>,
109    /// Number of queue entries per transaction key, for duplicate detection.
110    queued_keys: HashMap<ConsensusTransactionKey, u32>,
111    total_len: usize,
112    metrics: Arc<AdmissionQueueMetrics>,
113}
114
115impl PriorityAdmissionQueue {
116    pub fn new(capacity: usize, metrics: Arc<AdmissionQueueMetrics>) -> Self {
117        Self {
118            capacity,
119            map: BTreeMap::new(),
120            queued_keys: HashMap::new(),
121            total_len: 0,
122            metrics,
123        }
124    }
125
126    pub fn len(&self) -> usize {
127        self.total_len
128    }
129
130    pub fn min_gas_price(&self) -> Option<u64> {
131        self.map.first_key_value().map(|(&k, _)| k)
132    }
133
134    /// On success, returns `Ok(true)` or `Ok(false)` to indicate whether the
135    /// value was newly inserted. Returns `Err` if the queue was full and the
136    /// tx's gas price was not high enough to evict an existing entry.
137    pub fn insert(&mut self, entry: QueueEntry) -> SuiResult<bool> {
138        let keys: Vec<_> = entry.transactions.iter().map(|t| t.key()).collect();
139        let newly_inserted = !keys.iter().any(|k| self.queued_keys.contains_key(k));
140        if !newly_inserted {
141            self.metrics.duplicate_inserts.inc();
142        }
143
144        if self.total_len < self.capacity {
145            self.push_entry(entry, keys);
146            self.metrics.queue_depth.set(self.total_len as i64);
147            return Ok(newly_inserted);
148        }
149
150        let min_price = self.min_gas_price().unwrap();
151        if entry.gas_price > min_price {
152            let evicter_price = entry.gas_price;
153            let evicted = self.evict_lowest();
154            self.push_entry(entry, keys);
155            self.metrics.evictions.inc();
156            // Signal the evicted entry's caller so `position_rx.await` returns
157            // a distinct outbid error rather than a generic RecvError.
158            let _ = evicted
159                .position_sender
160                .send(Err(tonic::Status::from(SuiError::from(
161                    SuiErrorKind::TransactionRejectedDueToOutbiddingDuringCongestion {
162                        min_gas_price: evicter_price,
163                    },
164                ))));
165            return Ok(newly_inserted);
166        }
167
168        self.metrics.rejections.inc();
169        Err(
170            SuiErrorKind::TransactionRejectedDueToOutbiddingDuringCongestion {
171                min_gas_price: min_price,
172            }
173            .into(),
174        )
175    }
176
177    /// Pop up to `count` entries, highest gas price first.
178    /// Within the same gas price, entries are returned in FIFO order.
179    pub fn pop_batch(&mut self, count: usize) -> Vec<QueueEntry> {
180        let mut remaining = count.min(self.total_len);
181        let mut entries = Vec::with_capacity(remaining);
182        while remaining > 0 {
183            let Some(mut last) = self.map.last_entry() else {
184                break;
185            };
186            let deque = last.get_mut();
187            if deque.len() <= remaining {
188                // Drain the entire price level at once.
189                remaining -= deque.len();
190                self.total_len -= deque.len();
191                entries.extend(last.remove());
192            } else {
193                // Partial drain from this price level.
194                self.total_len -= remaining;
195                entries.extend(deque.drain(..remaining));
196                remaining = 0;
197            }
198        }
199        for entry in &entries {
200            self.remove_keys(entry);
201        }
202        self.metrics.queue_depth.set(self.total_len as i64);
203        entries
204    }
205
206    pub fn is_empty(&self) -> bool {
207        self.total_len == 0
208    }
209
210    fn push_entry(&mut self, entry: QueueEntry, keys: Vec<ConsensusTransactionKey>) {
211        for key in keys {
212            *self.queued_keys.entry(key).or_insert(0) += 1;
213        }
214        self.map
215            .entry(entry.gas_price)
216            .or_default()
217            .push_back(entry);
218        self.total_len += 1;
219    }
220
221    fn evict_lowest(&mut self) -> QueueEntry {
222        let evicted = {
223            let mut first = self
224                .map
225                .first_entry()
226                .expect("evict_lowest called on empty queue");
227            let deque = first.get_mut();
228            let evicted = deque.pop_front().unwrap();
229            if deque.is_empty() {
230                first.remove();
231            }
232            evicted
233        };
234        self.remove_keys(&evicted);
235        self.total_len -= 1;
236        evicted
237    }
238
239    fn remove_keys(&mut self, entry: &QueueEntry) {
240        for tx in &entry.transactions {
241            let key = tx.key();
242            let std::collections::hash_map::Entry::Occupied(mut slot) = self.queued_keys.entry(key)
243            else {
244                debug_fatal!("remove_keys on absent key");
245                continue;
246            };
247            *slot.get_mut() -= 1;
248            if *slot.get() == 0 {
249                slot.remove();
250            }
251        }
252    }
253}
254
255/// Command sent from RPC handlers to the admission queue actor via mpsc channel.
256struct InsertCommand {
257    entry: QueueEntry,
258    response: oneshot::Sender<SuiResult<bool>>,
259}
260
261/// Cloneable handle for submitting transactions to the admission queue actor.
262/// Held by RPC handlers; the actor runs in a separate spawned task.
263#[derive(Clone)]
264pub struct AdmissionQueueHandle {
265    sender: mpsc::Sender<InsertCommand>,
266    /// The moment the queue last submitted an entry to consensus.
267    last_drain: Arc<Mutex<Instant>>,
268    queue_depth: Arc<AtomicUsize>,
269    failover_timeout: Duration,
270}
271
272impl AdmissionQueueHandle {
273    /// Returns true if the queue has been non-empty for longer than
274    /// `failover_timeout` without any drain to consensus. Callers should
275    /// bypass the queue entirely when this is true.
276    pub fn failover_tripped(&self) -> bool {
277        if self.queue_depth.load(Ordering::Relaxed) == 0 {
278            return false;
279        }
280        self.last_drain.lock().unwrap().elapsed() > self.failover_timeout
281    }
282
283    /// Returns `(position_receiver, newly_inserted)` on admission. Returns `Err` on outbid
284    /// rejection.
285    pub async fn try_insert(
286        &self,
287        gas_price: u64,
288        transactions: Vec<ConsensusTransaction>,
289        submitter_client_addr: Option<IpAddr>,
290    ) -> SuiResult<(
291        oneshot::Receiver<Result<Vec<ConsensusPosition>, tonic::Status>>,
292        bool,
293    )> {
294        let (position_tx, position_rx) = oneshot::channel();
295        let entry = QueueEntry {
296            gas_price,
297            transactions,
298            position_sender: position_tx,
299            submitter_client_addr,
300            enqueue_time: Instant::now(),
301        };
302
303        let (resp_tx, resp_rx) = oneshot::channel();
304        let cmd = InsertCommand {
305            entry,
306            response: resp_tx,
307        };
308
309        self.sender
310            .send(cmd)
311            .await
312            .map_err(|_| SuiError::from(SuiErrorKind::TooManyTransactionsPendingConsensus))?;
313
314        let newly_inserted = resp_rx
315            .await
316            .map_err(|_| SuiError::from(SuiErrorKind::TooManyTransactionsPendingConsensus))??;
317
318        Ok((position_rx, newly_inserted))
319    }
320}
321
322/// Manages the lifecycle of per-epoch admission queue actors.
323/// Holds immutable config and shared metrics; call `spawn()` each epoch
324/// with the new epoch store to create a fresh actor and handle.
325pub struct AdmissionQueueManager {
326    capacity: usize,
327    failover_timeout: Duration,
328    metrics: Arc<AdmissionQueueMetrics>,
329    consensus_adapter: Arc<ConsensusAdapter>,
330    slot_freed_notify: Arc<tokio::sync::Notify>,
331}
332
333impl AdmissionQueueManager {
334    pub fn new(
335        consensus_adapter: Arc<ConsensusAdapter>,
336        metrics: Arc<AdmissionQueueMetrics>,
337        capacity_fraction: f64,
338        failover_timeout: Duration,
339        slot_freed_notify: Arc<tokio::sync::Notify>,
340    ) -> Self {
341        let max_pending = consensus_adapter.max_pending_transactions();
342        let capacity = (max_pending as f64 * capacity_fraction) as usize;
343        assert!(
344            capacity > 0,
345            "admission_queue_capacity_fraction ({capacity_fraction}) * max_pending_transactions ({max_pending}) must be > 0"
346        );
347        Self {
348            capacity,
349            failover_timeout,
350            metrics,
351            consensus_adapter,
352            slot_freed_notify,
353        }
354    }
355
356    pub fn new_for_tests(
357        consensus_adapter: Arc<ConsensusAdapter>,
358        slot_freed_notify: Arc<tokio::sync::Notify>,
359    ) -> Self {
360        Self {
361            capacity: 10_000,
362            failover_timeout: Duration::from_secs(30),
363            metrics: Arc::new(AdmissionQueueMetrics::new_for_tests()),
364            consensus_adapter,
365            slot_freed_notify,
366        }
367    }
368
369    pub fn metrics(&self) -> &Arc<AdmissionQueueMetrics> {
370        &self.metrics
371    }
372
373    /// Spawns a new per-epoch admission queue actor and returns a handle.
374    /// The previous actor shuts down when its handle is dropped.
375    pub fn spawn(&self, epoch_store: Arc<AuthorityPerEpochStore>) -> AdmissionQueueHandle {
376        let last_drain = Arc::new(Mutex::new(Instant::now()));
377        let queue_depth = Arc::new(AtomicUsize::new(0));
378
379        let (sender, receiver) = mpsc::channel(self.capacity.max(1024));
380
381        let event_loop = AdmissionQueueEventLoop {
382            receiver,
383            queue: PriorityAdmissionQueue::new(self.capacity, self.metrics.clone()),
384            consensus_adapter: self.consensus_adapter.clone(),
385            slot_freed_notify: self.slot_freed_notify.clone(),
386            epoch_store,
387            last_drain: last_drain.clone(),
388            queue_depth: queue_depth.clone(),
389            last_published_depth: 0,
390        };
391        spawn_monitored_task!(event_loop.run());
392
393        AdmissionQueueHandle {
394            sender,
395            last_drain,
396            queue_depth,
397            failover_timeout: self.failover_timeout,
398        }
399    }
400}
401
402/// Shared handle to a live admission queue. Holds the manager (for spawning a
403/// fresh per-epoch actor on reconfig), the per-epoch `ArcSwap` handle, and the
404/// cached (config-derived) bypass threshold. Cloned cheaply by `Arc`; passed
405/// both to `ValidatorService` (for hot-path routing) and through
406/// `ValidatorComponents` (for epoch rotation).
407#[derive(Clone)]
408pub struct AdmissionQueueContext {
409    manager: Arc<AdmissionQueueManager>,
410    swap: Arc<ArcSwap<AdmissionQueueHandle>>,
411}
412
413impl AdmissionQueueContext {
414    pub fn spawn(
415        manager: Arc<AdmissionQueueManager>,
416        epoch_store: Arc<AuthorityPerEpochStore>,
417    ) -> Self {
418        let initial_handle = manager.spawn(epoch_store);
419        let swap = Arc::new(ArcSwap::new(Arc::new(initial_handle)));
420        Self { manager, swap }
421    }
422
423    /// Spawns a new per-epoch actor and atomically replaces the current handle.
424    /// The old actor shuts down when its handle is dropped.
425    pub fn rotate_for_epoch(&self, epoch_store: Arc<AuthorityPerEpochStore>) {
426        self.swap.store(Arc::new(self.manager.spawn(epoch_store)));
427    }
428
429    pub(crate) fn load(&self) -> arc_swap::Guard<Arc<AdmissionQueueHandle>> {
430        self.swap.load()
431    }
432}
433
434/// Per-epoch event loop that owns the priority queue and drains entries
435/// to consensus as capacity becomes available.
436struct AdmissionQueueEventLoop {
437    receiver: mpsc::Receiver<InsertCommand>,
438    queue: PriorityAdmissionQueue,
439    consensus_adapter: Arc<ConsensusAdapter>,
440    slot_freed_notify: Arc<tokio::sync::Notify>,
441    epoch_store: Arc<AuthorityPerEpochStore>,
442    last_drain: Arc<Mutex<Instant>>,
443    queue_depth: Arc<AtomicUsize>,
444    last_published_depth: usize,
445}
446
447impl AdmissionQueueEventLoop {
448    pub async fn run(mut self) {
449        loop {
450            self.process_pending_inserts();
451            self.publish_queue_depth();
452
453            if !handle_fail_point_if("admission_queue_disable_drain")
454                && !self.queue.is_empty()
455                && self.has_consensus_capacity()
456            {
457                self.drain_batch();
458                self.publish_queue_depth();
459                continue;
460            }
461
462            if self.queue.is_empty() {
463                // Nothing to drain — just wait for a new insert.
464                match self.receiver.recv().await {
465                    Some(cmd) => self.handle_insert(cmd),
466                    None => {
467                        debug!("Admission queue actor shutting down");
468                        break;
469                    }
470                }
471                continue;
472            }
473
474            // Queue has entries but consensus is at capacity. Wait for either
475            // a new insert or a freed inflight slot.
476            // Register the notified future BEFORE re-checking capacity to avoid
477            // missing notifications.
478            let notify = self.slot_freed_notify.clone();
479            let slot_freed = notify.notified();
480            tokio::pin!(slot_freed);
481
482            self.process_pending_inserts();
483            if !handle_fail_point_if("admission_queue_disable_drain")
484                && !self.queue.is_empty()
485                && self.has_consensus_capacity()
486            {
487                continue;
488            }
489
490            tokio::select! {
491                biased;
492
493                result = self.receiver.recv() => {
494                    match result {
495                        Some(cmd) => self.handle_insert(cmd),
496                        None => {
497                            debug!("Admission queue actor shutting down");
498                            break;
499                        }
500                    }
501                }
502
503                _ = &mut slot_freed => {}
504            }
505        }
506    }
507
508    fn publish_queue_depth(&mut self) {
509        let len = self.queue.len();
510        if len != self.last_published_depth {
511            self.queue_depth.store(len, Ordering::Relaxed);
512            self.last_published_depth = len;
513        }
514    }
515
516    fn process_pending_inserts(&mut self) {
517        while let Ok(cmd) = self.receiver.try_recv() {
518            self.handle_insert(cmd);
519        }
520    }
521
522    fn has_consensus_capacity(&self) -> bool {
523        self.consensus_adapter.num_inflight_transactions()
524            < u64::try_from(self.consensus_adapter.max_pending_transactions()).unwrap()
525    }
526
527    fn drain_batch(&mut self) {
528        let max_pending = u64::try_from(self.consensus_adapter.max_pending_transactions()).unwrap();
529        let available =
530            max_pending.saturating_sub(self.consensus_adapter.num_inflight_transactions());
531        let entries = self.queue.pop_batch(usize::try_from(available).unwrap());
532        if entries.is_empty() {
533            return;
534        }
535        for entry in entries {
536            self.queue
537                .metrics
538                .queue_wait_latency
539                .observe(entry.enqueue_time.elapsed().as_secs_f64());
540            let adapter = self.consensus_adapter.clone();
541            let es = self.epoch_store.clone();
542            spawn_monitored_task!(submit_queue_entry(entry, adapter, es));
543        }
544        *self.last_drain.lock().unwrap() = Instant::now();
545    }
546
547    fn handle_insert(&mut self, cmd: InsertCommand) {
548        let _ = cmd.response.send(self.queue.insert(cmd.entry));
549    }
550}
551
552async fn submit_queue_entry(
553    entry: QueueEntry,
554    consensus_adapter: Arc<ConsensusAdapter>,
555    epoch_store: Arc<AuthorityPerEpochStore>,
556) {
557    let _ = entry.position_sender.send(
558        consensus_adapter
559            .submit_and_get_positions(
560                entry.transactions,
561                &epoch_store,
562                entry.submitter_client_addr,
563            )
564            .await
565            .map_err(tonic::Status::from),
566    );
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    fn make_test_entry(
574        gas_price: u64,
575    ) -> (
576        QueueEntry,
577        oneshot::Receiver<Result<Vec<ConsensusPosition>, tonic::Status>>,
578    ) {
579        let (tx, rx) = oneshot::channel();
580        (QueueEntry::new_for_test(gas_price, tx), rx)
581    }
582
583    fn build_queue(capacity: usize, gas_prices: &[u64]) -> PriorityAdmissionQueue {
584        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
585        let mut q = PriorityAdmissionQueue::new(capacity, metrics);
586        for &gp in gas_prices {
587            let (entry, _) = make_test_entry(gp);
588            q.insert(entry).unwrap();
589        }
590        q
591    }
592
593    #[test]
594    fn test_insert_within_capacity() {
595        let q = build_queue(3, &[100, 200, 50]);
596        assert_eq!(q.len(), 3);
597    }
598
599    #[test]
600    fn test_eviction_when_full() {
601        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
602        let mut q = PriorityAdmissionQueue::new(2, metrics);
603
604        let (e1, mut r1) = make_test_entry(100);
605        let (e2, _) = make_test_entry(200);
606        let (e3, _) = make_test_entry(300);
607
608        q.insert(e1).unwrap();
609        q.insert(e2).unwrap();
610        assert_eq!(q.len(), 2);
611
612        assert!(q.insert(e3).is_ok());
613        assert_eq!(q.len(), 2);
614        // Evicted entry's caller receives an explicit outbid error.
615        let r1_result = r1.try_recv().expect("evicted entry must be signalled");
616        assert!(matches!(r1_result, Err(ref status) if status.message().contains("outbid")));
617    }
618
619    #[test]
620    fn test_rejection_when_full_and_low_price() {
621        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
622        let mut q = PriorityAdmissionQueue::new(2, metrics);
623
624        let (e1, _) = make_test_entry(100);
625        let (e2, _) = make_test_entry(200);
626        let (e3, mut r3) = make_test_entry(50);
627
628        q.insert(e1).unwrap();
629        q.insert(e2).unwrap();
630
631        assert!(matches!(
632            q.insert(e3).unwrap_err().as_inner(),
633            SuiErrorKind::TransactionRejectedDueToOutbiddingDuringCongestion { min_gas_price: 100 }
634        ));
635        assert_eq!(q.len(), 2);
636        assert!(r3.try_recv().is_err());
637    }
638
639    #[test]
640    fn test_pop_batch() {
641        let mut q = build_queue(5, &[100, 300, 200]);
642        let batch = q.pop_batch(2);
643        assert_eq!(batch.len(), 2);
644        assert_eq!(q.len(), 1);
645    }
646
647    #[test]
648    fn test_min_gas_price() {
649        let q = build_queue(5, &[200, 100, 300]);
650        assert_eq!(q.min_gas_price(), Some(100));
651    }
652
653    #[test]
654    fn test_gasless_tx_evicted_first() {
655        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
656        let mut q = PriorityAdmissionQueue::new(2, metrics);
657
658        let (gasless, mut r_gasless) = make_test_entry(0);
659        let (normal, _) = make_test_entry(1000);
660        let (high, _) = make_test_entry(2000);
661
662        q.insert(gasless).unwrap();
663        q.insert(normal).unwrap();
664
665        assert!(q.insert(high).is_ok());
666        let gasless_result = r_gasless
667            .try_recv()
668            .expect("evicted gasless entry must be signalled");
669        assert!(matches!(gasless_result, Err(ref status) if status.message().contains("outbid")));
670        assert_eq!(q.min_gas_price(), Some(1000));
671    }
672
673    #[test]
674    fn test_pop_batch_returns_highest_gas_price_first() {
675        let mut q = build_queue(5, &[100, 500, 200, 400, 300]);
676        let batch = q.pop_batch(5);
677        let gas_prices: Vec<u64> = batch.iter().map(|e| e.gas_price).collect();
678        assert_eq!(gas_prices, vec![500, 400, 300, 200, 100]);
679    }
680
681    #[test]
682    fn test_equal_gas_price_rejected_when_full() {
683        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
684        let mut q = PriorityAdmissionQueue::new(1, metrics);
685
686        let (e1, _) = make_test_entry(100);
687        let (e2, _) = make_test_entry(100);
688
689        q.insert(e1).unwrap();
690        assert!(matches!(
691            q.insert(e2).unwrap_err().as_inner(),
692            SuiErrorKind::TransactionRejectedDueToOutbiddingDuringCongestion { min_gas_price: 100 }
693        ));
694    }
695
696    fn make_dup_entry(
697        gas_price: u64,
698        tx: ConsensusTransaction,
699    ) -> (
700        QueueEntry,
701        oneshot::Receiver<Result<Vec<ConsensusPosition>, tonic::Status>>,
702    ) {
703        let (position_tx, position_rx) = oneshot::channel();
704        let entry = QueueEntry {
705            gas_price,
706            transactions: vec![tx],
707            position_sender: position_tx,
708            submitter_client_addr: None,
709            enqueue_time: Instant::now(),
710        };
711        (entry, position_rx)
712    }
713
714    #[test]
715    fn test_duplicate_transaction_admitted_and_flagged() {
716        use sui_types::base_types::AuthorityName;
717
718        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
719        let mut q = PriorityAdmissionQueue::new(10, metrics);
720
721        let tx = ConsensusTransaction::new_end_of_publish(AuthorityName::ZERO);
722
723        let (entry1, _rx1) = make_dup_entry(100, tx.clone());
724        assert!(q.insert(entry1).unwrap());
725        assert_eq!(q.len(), 1);
726
727        // Same transaction again — admitted, but flagged as not-fresh so the
728        // RPC layer can tally it as spam for DoS protection.
729        let (entry2, _rx2) = make_dup_entry(100, tx.clone());
730        assert!(!q.insert(entry2).unwrap());
731        assert_eq!(q.len(), 2);
732    }
733
734    #[test]
735    fn test_duplicate_key_counter_decrements_on_pop() {
736        use sui_types::base_types::AuthorityName;
737
738        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
739        let mut q = PriorityAdmissionQueue::new(10, metrics);
740
741        let tx = ConsensusTransaction::new_end_of_publish(AuthorityName::ZERO);
742
743        // Insert two copies of the same tx.
744        let (entry1, _rx1) = make_dup_entry(100, tx.clone());
745        q.insert(entry1).unwrap();
746        let (entry2, _rx2) = make_dup_entry(100, tx.clone());
747        assert!(!q.insert(entry2).unwrap());
748
749        // Pop one copy. The key's counter should drop to 1 — a fresh insert
750        // should still be flagged as not-fresh against the remaining copy.
751        let batch = q.pop_batch(1);
752        assert_eq!(batch.len(), 1);
753        let (entry3, _rx3) = make_dup_entry(100, tx.clone());
754        assert!(!q.insert(entry3).unwrap());
755        assert_eq!(q.len(), 2);
756
757        // Drain both remaining entries. The counter should hit 0 and the key
758        // should be removed — a subsequent insert is fresh again.
759        let _ = q.pop_batch(q.len());
760        assert!(q.is_empty());
761        let (entry4, _rx4) = make_dup_entry(100, tx);
762        assert!(q.insert(entry4).unwrap());
763    }
764
765    #[test]
766    fn test_duplicate_key_counter_decrements_on_evict() {
767        use sui_types::base_types::AuthorityName;
768
769        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
770        let mut q = PriorityAdmissionQueue::new(2, metrics);
771
772        let tx = ConsensusTransaction::new_end_of_publish(AuthorityName::ZERO);
773
774        // Fill queue with two copies of `tx` at price 100.
775        let (entry1, _rx1) = make_dup_entry(100, tx.clone());
776        q.insert(entry1).unwrap();
777        let (entry2, _rx2) = make_dup_entry(100, tx.clone());
778        q.insert(entry2).unwrap();
779        assert_eq!(q.len(), 2);
780
781        // Evict one dup with a higher-priced non-dup.
782        let (filler, _) = make_test_entry(200);
783        q.insert(filler).unwrap();
784        assert_eq!(q.len(), 2);
785
786        // Evict the remaining dup with another non-dup. After both dups are
787        // evicted, the counter should hit 0 and re-inserting `tx` is not a
788        // duplicate.
789        let (filler2, _) = make_test_entry(300);
790        q.insert(filler2).unwrap();
791
792        let (entry3, _rx3) = make_dup_entry(500, tx);
793        assert!(q.insert(entry3).unwrap());
794    }
795
796    #[tokio::test]
797    async fn test_actor_shuts_down_when_handle_dropped() {
798        use crate::authority::test_authority_builder::TestAuthorityBuilder;
799        use crate::checkpoints::CheckpointStore;
800        use crate::consensus_adapter::ConsensusAdapterMetrics;
801        use crate::mysticeti_adapter::LazyMysticetiClient;
802        use sui_types::base_types::AuthorityName;
803
804        let state = TestAuthorityBuilder::new().build().await;
805        let epoch_store = state.epoch_store_for_testing().clone();
806        let consensus_adapter = Arc::new(ConsensusAdapter::new(
807            Arc::new(LazyMysticetiClient::new()),
808            CheckpointStore::new_for_tests(),
809            AuthorityName::ZERO,
810            100_000,
811            100_000,
812            ConsensusAdapterMetrics::new_test(),
813            Arc::new(tokio::sync::Notify::new()),
814        ));
815
816        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
817        let (sender, receiver) = mpsc::channel(100);
818        let slot_freed_notify = Arc::new(tokio::sync::Notify::new());
819
820        let event_loop = AdmissionQueueEventLoop {
821            receiver,
822            queue: PriorityAdmissionQueue::new(100, metrics),
823            consensus_adapter,
824            slot_freed_notify,
825            epoch_store,
826            last_drain: Arc::new(Mutex::new(Instant::now())),
827            queue_depth: Arc::new(AtomicUsize::new(0)),
828            last_published_depth: 0,
829        };
830
831        let handle = tokio::spawn(event_loop.run());
832
833        // Drop the sender — this closes the channel.
834        drop(sender);
835
836        // The actor should exit promptly.
837        tokio::time::timeout(std::time::Duration::from_secs(5), handle)
838            .await
839            .expect("actor did not shut down within timeout")
840            .expect("actor task panicked");
841    }
842
843    async fn build_consensus_adapter(
844        max_pending_transactions: usize,
845    ) -> (
846        Arc<ConsensusAdapter>,
847        Arc<AuthorityPerEpochStore>,
848        Arc<tokio::sync::Notify>,
849    ) {
850        use crate::authority::test_authority_builder::TestAuthorityBuilder;
851        use crate::checkpoints::CheckpointStore;
852        use crate::consensus_adapter::ConsensusAdapterMetrics;
853        use crate::mysticeti_adapter::LazyMysticetiClient;
854        use sui_types::base_types::AuthorityName;
855
856        let state = TestAuthorityBuilder::new().build().await;
857        let epoch_store = state.epoch_store_for_testing().clone();
858        let slot_freed_notify = Arc::new(tokio::sync::Notify::new());
859        let adapter = Arc::new(ConsensusAdapter::new(
860            Arc::new(LazyMysticetiClient::new()),
861            CheckpointStore::new_for_tests(),
862            AuthorityName::ZERO,
863            max_pending_transactions,
864            100_000,
865            ConsensusAdapterMetrics::new_test(),
866            slot_freed_notify.clone(),
867        ));
868        (adapter, epoch_store, slot_freed_notify)
869    }
870
871    #[tokio::test]
872    async fn test_failover_tripped_when_actor_stalls() {
873        // Construct a handle with a tiny failover window and no running actor.
874        // Failover requires queue_depth > 0, so simulate a non-empty queue.
875        let handle = AdmissionQueueHandle {
876            sender: mpsc::channel(1).0,
877            last_drain: Arc::new(Mutex::new(Instant::now())),
878            queue_depth: Arc::new(AtomicUsize::new(1)),
879            failover_timeout: Duration::from_millis(10),
880        };
881        assert!(!handle.failover_tripped());
882        tokio::time::sleep(Duration::from_millis(30)).await;
883        assert!(handle.failover_tripped());
884
885        // An empty queue is never a failover, even if last_drain is stale.
886        handle.queue_depth.store(0, Ordering::Relaxed);
887        assert!(!handle.failover_tripped());
888    }
889
890    #[tokio::test]
891    async fn test_idle_actor_does_not_trip_failover() {
892        // A healthy actor with an empty queue must never trip failover, even
893        // after long idle periods while blocked on `receiver.recv()`.
894        let (adapter, epoch_store, notify) = build_consensus_adapter(100_000).await;
895        let manager = AdmissionQueueManager::new(
896            adapter,
897            Arc::new(AdmissionQueueMetrics::new_for_tests()),
898            0.5,
899            Duration::from_millis(10),
900            notify,
901        );
902        let handle = manager.spawn(epoch_store);
903        tokio::time::sleep(Duration::from_millis(50)).await;
904        assert!(
905            !handle.failover_tripped(),
906            "idle actor with empty queue must not trip failover"
907        );
908    }
909
910    /// If `drain_batch` is entered but consensus has zero slots available
911    /// (the inflight count raced past `max_pending_transactions` between the
912    /// `has_consensus_capacity` check and the read inside `drain_batch`), no
913    /// entries are popped and `last_drain` must NOT advance — otherwise a
914    /// truly stuck drainer would be hidden from the failover check.
915    #[tokio::test]
916    async fn test_drain_batch_does_not_bump_last_drain_when_no_slots() {
917        let (adapter, epoch_store, notify) = build_consensus_adapter(0).await;
918        let metrics = Arc::new(AdmissionQueueMetrics::new_for_tests());
919        let (_sender, receiver) = mpsc::channel(10);
920
921        let mut queue = PriorityAdmissionQueue::new(10, metrics.clone());
922        let (entry, _rx) = make_test_entry(100);
923        assert!(queue.insert(entry).is_ok());
924        assert_eq!(queue.len(), 1);
925
926        let last_drain = Arc::new(Mutex::new(Instant::now()));
927        let before = *last_drain.lock().unwrap();
928
929        let mut event_loop = AdmissionQueueEventLoop {
930            receiver,
931            queue,
932            consensus_adapter: adapter,
933            slot_freed_notify: notify,
934            epoch_store,
935            last_drain: last_drain.clone(),
936            queue_depth: Arc::new(AtomicUsize::new(0)),
937            last_published_depth: 0,
938        };
939
940        // Sleep so that if drain_batch erroneously stamps Instant::now() the
941        // stored value would differ from `before`.
942        tokio::time::sleep(Duration::from_millis(20)).await;
943
944        event_loop.drain_batch();
945
946        assert_eq!(event_loop.queue.len(), 1, "no entries should be drained");
947        assert_eq!(
948            *last_drain.lock().unwrap(),
949            before,
950            "last_drain must not advance when drain_batch drained nothing"
951        );
952    }
953}