Skip to content

Commit 2c148b7

Browse files
apollo_mempool: add nonce-gap metrics (accounts_with_gap, stuck_txs) (#13918)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 57b515d commit 2c148b7

4 files changed

Lines changed: 252 additions & 6 deletions

File tree

crates/apollo_mempool/src/fee_mempool_test.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ impl MempoolTestContentBuilder {
158158
self.gas_price_threshold,
159159
)),
160160
accounts_with_gap: AccountsWithGap::new(),
161+
n_stuck_txs: 0,
161162
state: MempoolState::new(
162163
self.config.static_config.committed_nonce_retention_block_count,
163164
),
@@ -1904,3 +1905,188 @@ fn rejects_or_accepts_tx_based_on_freed_space(
19041905
// We do not revert the eviction attempt even if adding large_tx ultimately fails.
19051906
assert!(!mempool.tx_pool.contains_account(contract_address!("0x1")));
19061907
}
1908+
1909+
// Stuck-tx counter tests.
1910+
1911+
fn assert_gap_metrics(mempool: &Mempool, expected_accounts: usize, expected_stuck_txs: usize) {
1912+
assert_eq!(mempool.accounts_with_gap().len(), expected_accounts, "accounts_with_gap mismatch");
1913+
assert_eq!(mempool.n_stuck_txs(), expected_stuck_txs, "n_stuck_txs mismatch");
1914+
}
1915+
1916+
#[rstest]
1917+
fn stuck_txs_counter_gap_created_and_closed_by_add_tx(mut mempool: Mempool) {
1918+
assert_gap_metrics(&mempool, 0, 0);
1919+
1920+
// Adding nonce 1 with account nonce 0 creates a gap; the single pool tx is stuck.
1921+
let tx_nonce_1 = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 1, account_nonce: 0);
1922+
add_tx(&mut mempool, &tx_nonce_1);
1923+
assert_gap_metrics(&mempool, 1, 1);
1924+
1925+
// Adding nonce 2 to the already-gapped account: both txs are stuck.
1926+
let tx_nonce_2 = add_tx_input!(tx_hash: 2, address: "0x0", tx_nonce: 2, account_nonce: 0);
1927+
add_tx(&mut mempool, &tx_nonce_2);
1928+
assert_gap_metrics(&mempool, 1, 2);
1929+
1930+
// Filling the gap (nonce 0) resolves it; no more stuck txs.
1931+
let tx_nonce_0 = add_tx_input!(tx_hash: 3, address: "0x0", tx_nonce: 0, account_nonce: 0);
1932+
add_tx(&mut mempool, &tx_nonce_0);
1933+
assert_gap_metrics(&mempool, 0, 0);
1934+
}
1935+
1936+
#[rstest]
1937+
fn stuck_txs_counter_tx_inserted_between_existing_stuck_txs(mut mempool: Mempool) {
1938+
// Nonces 1 and 3 in pool; gap at 0. Both are stuck.
1939+
let tx_nonce_1 = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 1, account_nonce: 0);
1940+
let tx_nonce_3 = add_tx_input!(tx_hash: 2, address: "0x0", tx_nonce: 3, account_nonce: 0);
1941+
for tx in [&tx_nonce_1, &tx_nonce_3] {
1942+
add_tx(&mut mempool, tx);
1943+
}
1944+
assert_gap_metrics(&mempool, 1, 2);
1945+
1946+
// Inserting nonce 2 into the gap account (gap at 0 persists): all three txs are stuck.
1947+
let tx_nonce_2 = add_tx_input!(tx_hash: 3, address: "0x0", tx_nonce: 2, account_nonce: 0);
1948+
add_tx(&mut mempool, &tx_nonce_2);
1949+
assert_gap_metrics(&mempool, 1, 3);
1950+
1951+
// Filling the primary gap (nonce 0) resolves everything.
1952+
let tx_nonce_0 = add_tx_input!(tx_hash: 4, address: "0x0", tx_nonce: 0, account_nonce: 0);
1953+
add_tx(&mut mempool, &tx_nonce_0);
1954+
assert_gap_metrics(&mempool, 0, 0);
1955+
}
1956+
1957+
#[rstest]
1958+
fn stuck_txs_counter_account_becomes_unstuck_once_leading_gap_is_filled(mut mempool: Mempool) {
1959+
// Nonces 1 and 3 in pool; gap at 0. Account is fully blocked: lowest pool nonce (1) >
1960+
// account nonce (0), so neither tx can be batched yet.
1961+
let tx_nonce_1 = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 1, account_nonce: 0);
1962+
let tx_nonce_3 = add_tx_input!(tx_hash: 2, address: "0x0", tx_nonce: 3, account_nonce: 0);
1963+
for tx in [&tx_nonce_1, &tx_nonce_3] {
1964+
add_tx(&mut mempool, tx);
1965+
}
1966+
assert_gap_metrics(&mempool, 1, 2);
1967+
1968+
// Adding nonce 0 aligns lowest_pool_nonce with account_nonce: the account can now make
1969+
// progress (nonces 0 and 1 will be batched). It exits accounts_with_gap and the metric
1970+
// drops to 0. Nonce 3 will be re-detected as stuck once nonces 0 and 1 are committed
1971+
// and account_nonce catches up to 2 (which is still missing).
1972+
let tx_nonce_0 = add_tx_input!(tx_hash: 3, address: "0x0", tx_nonce: 0, account_nonce: 0);
1973+
add_tx(&mut mempool, &tx_nonce_0);
1974+
assert_gap_metrics(&mempool, 0, 0);
1975+
1976+
// Committing nonces 0 and 1 (next_nonce=2) removes them from the pool and advances
1977+
// account_nonce to 2. Pool is now [3]; lowest_pool_nonce (3) > account_nonce (2), so the
1978+
// gap is re-detected and nonce 3 is the sole stuck tx.
1979+
commit_block(&mut mempool, [("0x0", 2)], []);
1980+
assert_gap_metrics(&mempool, 1, 1);
1981+
}
1982+
1983+
#[rstest]
1984+
fn stuck_txs_counter_resolved_by_commit_block(mut mempool: Mempool) {
1985+
let tx_nonce_1 = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 1, account_nonce: 0);
1986+
add_tx(&mut mempool, &tx_nonce_1);
1987+
assert_gap_metrics(&mempool, 1, 1);
1988+
1989+
// Committing nonce 1 removes the pool tx and advances account nonce; gap is resolved.
1990+
commit_block(&mut mempool, [("0x0", 2)], []);
1991+
assert_gap_metrics(&mempool, 0, 0);
1992+
}
1993+
1994+
#[rstest]
1995+
fn stuck_txs_counter_commit_block_advances_past_some_stuck_txs(mut mempool: Mempool) {
1996+
// Gap account: nonces 3 and 5 in pool (gap at 0, 1, 2).
1997+
let tx_nonce_3 = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 3, account_nonce: 0);
1998+
let tx_nonce_5 = add_tx_input!(tx_hash: 2, address: "0x0", tx_nonce: 5, account_nonce: 0);
1999+
for tx in [&tx_nonce_3, &tx_nonce_5] {
2000+
add_tx(&mut mempool, tx);
2001+
}
2002+
assert_gap_metrics(&mempool, 1, 2);
2003+
2004+
// Commit next_nonce=4: removes nonce 3 (< 4) from pool; nonce 5 stays.
2005+
// account_nonce=4, lowest_pool_nonce=5 → gap persists with 1 stuck tx.
2006+
commit_block(&mut mempool, [("0x0", 4)], []);
2007+
assert_gap_metrics(&mempool, 1, 1);
2008+
}
2009+
2010+
#[test]
2011+
fn stuck_txs_counter_expiry_decrements_count() {
2012+
let fake_clock = Arc::new(FakeClock::default());
2013+
let mut mempool = Mempool::new(
2014+
MempoolConfig {
2015+
dynamic_config: MempoolDynamicConfig { transaction_ttl: Duration::from_secs(60) },
2016+
..Default::default()
2017+
},
2018+
fake_clock.clone(),
2019+
);
2020+
2021+
// Gap account: nonces 2 and 3 (gap at 0 and 1). Both are stuck.
2022+
let tx_nonce_2 = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 2, account_nonce: 0);
2023+
add_tx(&mut mempool, &tx_nonce_2);
2024+
fake_clock.advance(Duration::from_secs(30));
2025+
let tx_nonce_3 = add_tx_input!(tx_hash: 2, address: "0x0", tx_nonce: 3, account_nonce: 0);
2026+
add_tx(&mut mempool, &tx_nonce_3);
2027+
assert_gap_metrics(&mempool, 1, 2);
2028+
2029+
// Expire nonce 2 (older than TTL) while nonce 3 stays alive.
2030+
fake_clock.advance(Duration::from_secs(35));
2031+
2032+
// Trigger expiry cleanup via a new tx for a different address.
2033+
let trigger = add_tx_input!(tx_hash: 3, address: "0x1", tx_nonce: 0, account_nonce: 0);
2034+
add_tx(&mut mempool, &trigger);
2035+
2036+
// Account remains in gap (nonce 3 still present); stuck count decreased by 1.
2037+
assert_gap_metrics(&mempool, 1, 1);
2038+
}
2039+
2040+
#[test]
2041+
fn stuck_txs_counter_eviction_decrements_count() {
2042+
let tx_gap = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 2, account_nonce: 0);
2043+
let capacity = tx_gap.tx.total_bytes();
2044+
let mut mempool = Mempool::new(
2045+
MempoolConfig {
2046+
static_config: MempoolStaticConfig {
2047+
capacity_in_bytes: capacity,
2048+
..Default::default()
2049+
},
2050+
..Default::default()
2051+
},
2052+
Arc::new(FakeClock::default()),
2053+
);
2054+
2055+
add_tx(&mut mempool, &tx_gap);
2056+
assert_gap_metrics(&mempool, 1, 1);
2057+
2058+
// Adding a tx for a different address triggers eviction of the gap account's tx.
2059+
let new_tx = add_tx_input!(tx_hash: 2, address: "0x1", tx_nonce: 0, account_nonce: 0);
2060+
add_tx(&mut mempool, &new_tx);
2061+
2062+
// Gap account was fully evicted; both counters are zero.
2063+
assert_gap_metrics(&mempool, 0, 0);
2064+
}
2065+
2066+
#[rstest]
2067+
fn stuck_txs_counter_multiple_gap_accounts(mut mempool: Mempool) {
2068+
// Two independent gap accounts.
2069+
// "0x0": nonce 2 in pool, account_nonce=0. One stuck tx.
2070+
// "0x1": nonces 1 and 3 in pool, account_nonce=0. Two stuck txs.
2071+
let tx_0x0_nonce2 = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 2, account_nonce: 0);
2072+
let tx_0x1_nonce1 = add_tx_input!(tx_hash: 2, address: "0x1", tx_nonce: 1, account_nonce: 0);
2073+
let tx_0x1_nonce3 = add_tx_input!(tx_hash: 3, address: "0x1", tx_nonce: 3, account_nonce: 0);
2074+
for tx in [&tx_0x0_nonce2, &tx_0x1_nonce1, &tx_0x1_nonce3] {
2075+
add_tx(&mut mempool, tx);
2076+
}
2077+
assert_gap_metrics(&mempool, 2, 3);
2078+
2079+
// Resolving "0x0" via commit (next_nonce=3 removes nonce 2, pool empty → no gap).
2080+
commit_block(&mut mempool, [("0x0", 3)], []);
2081+
assert_gap_metrics(&mempool, 1, 2);
2082+
2083+
// Partial commit for "0x1" (next_nonce=2 removes nonce 1; nonce 3 stays).
2084+
// account_nonce=2, lowest_pool_nonce=3 → gap persists with one stuck tx.
2085+
commit_block(&mut mempool, [("0x1", 2)], []);
2086+
assert_gap_metrics(&mempool, 1, 1);
2087+
2088+
// Filling the gap (nonce 2) resolves "0x1" entirely.
2089+
let tx_0x1_nonce2 = add_tx_input!(tx_hash: 4, address: "0x1", tx_nonce: 2, account_nonce: 2);
2090+
add_tx(&mut mempool, &tx_0x1_nonce2);
2091+
assert_gap_metrics(&mempool, 0, 0);
2092+
}

crates/apollo_mempool/src/mempool.rs

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ use crate::metrics::{
3737
metric_count_rejected_txs,
3838
metric_set_get_txs_size,
3939
LABEL_NAME_TX_TYPE,
40+
MEMPOOL_ACCOUNTS_WITH_GAP,
4041
MEMPOOL_DELAYED_DECLARES_SIZE,
4142
MEMPOOL_PENDING_QUEUE_SIZE,
4243
MEMPOOL_POOL_SIZE,
4344
MEMPOOL_PRIORITY_QUEUE_SIZE,
45+
MEMPOOL_STUCK_TXS,
4446
MEMPOOL_TOTAL_SIZE_BYTES,
4547
MEMPOOL_TRANSACTIONS_RECEIVED,
4648
};
@@ -254,6 +256,9 @@ pub struct Mempool {
254256
// Accounts whose lowest transaction nonce is greater than the account nonce, which are
255257
// therefore candidates for eviction.
256258
accounts_with_gap: AccountsWithGap,
259+
// Total number of transactions in the pool that belong to accounts in `accounts_with_gap`.
260+
// Maintained incrementally to allow O(1) metric reporting.
261+
n_stuck_txs: usize,
257262
state: MempoolState,
258263
clock: Arc<dyn Clock>,
259264
}
@@ -273,6 +278,7 @@ impl Mempool {
273278
tx_pool: TransactionPool::new(clock.clone()),
274279
tx_queue,
275280
accounts_with_gap: AccountsWithGap::new(),
281+
n_stuck_txs: 0,
276282
state: MempoolState::new(config.static_config.committed_nonce_retention_block_count),
277283
clock,
278284
}
@@ -366,8 +372,8 @@ impl Mempool {
366372
}
367373

368374
metric_set_get_txs_size(n_returned_txs);
369-
self.update_state_metrics();
370375
self.update_accounts_with_gap(account_nonce_updates);
376+
self.update_state_metrics();
371377

372378
Ok(eligible_tx_references
373379
.iter()
@@ -483,8 +489,8 @@ impl Mempool {
483489
self.add_tx_inner(args);
484490
}
485491

486-
self.update_state_metrics();
487492
self.update_accounts_with_gap(account_nonce_updates);
493+
self.update_state_metrics();
488494
Ok(())
489495
}
490496

@@ -541,6 +547,7 @@ impl Mempool {
541547
if let Ok(tx) = self.tx_pool.remove(tx_hash) {
542548
self.tx_queue.remove_by_address(tx.contract_address());
543549
rejected_txs_counter += 1;
550+
self.decrement_stuck_txs_if_gap_account(tx.contract_address(), 1);
544551
account_nonce_updates
545552
.entry(tx.contract_address())
546553
.and_modify(|nonce| *nonce = (*nonce).min(tx.nonce()))
@@ -563,6 +570,12 @@ impl Mempool {
563570

564571
let tx_reference = TransactionReference::new(&tx);
565572

573+
// Pre-count this tx as stuck; update_accounts_with_gap will correct the count if this tx
574+
// resolves the gap.
575+
if self.accounts_with_gap.contains(&account_state.address) {
576+
self.n_stuck_txs += 1;
577+
}
578+
566579
self.tx_pool
567580
.insert(tx)
568581
.expect("Duplicate transactions should cause an error during the validation stage.");
@@ -631,6 +644,7 @@ impl Mempool {
631644
// Remove from pool.
632645
let n_removed_txs = self.tx_pool.remove_up_to_nonce_when_committed(address, next_nonce);
633646
metric_count_committed_txs(n_removed_txs);
647+
self.decrement_stuck_txs_if_gap_account(address, n_removed_txs);
634648

635649
// Close nonce gap, if exists.
636650
// In FIFO mode, we handle gap filling when rewinding transactions.
@@ -657,8 +671,8 @@ impl Mempool {
657671
// Committed nonces should overwrite rejected transactions.
658672
account_nonce_updates.extend(committed_nonce_updates);
659673

660-
self.update_state_metrics();
661674
self.update_accounts_with_gap(account_nonce_updates);
675+
self.update_state_metrics();
662676
}
663677

664678
pub fn account_tx_in_pool_or_recent_block(&self, account_address: ContractAddress) -> bool {
@@ -769,6 +783,7 @@ impl Mempool {
769783
self.tx_pool
770784
.remove(existing_tx_reference.tx_hash)
771785
.expect("Transaction hash from pool must exist.");
786+
self.decrement_stuck_txs_if_gap_account(address, 1);
772787

773788
Ok(())
774789
}
@@ -820,6 +835,11 @@ impl Mempool {
820835
let removed_txs = self
821836
.tx_pool
822837
.remove_txs_older_than(self.config.dynamic_config.transaction_ttl, &self.state.staged);
838+
839+
for tx_ref in &removed_txs {
840+
self.decrement_stuck_txs_if_gap_account(tx_ref.address, 1);
841+
}
842+
823843
let queued_txs = self.tx_queue.remove_txs(&removed_txs);
824844

825845
self.log_and_count_expired_txs(&removed_txs);
@@ -858,6 +878,7 @@ impl Mempool {
858878
self.tx_pool
859879
.remove(tx.tx_hash)
860880
.expect("Transaction hash from queue must appear in pool.");
881+
self.decrement_stuck_txs_if_gap_account(tx.address, 1);
861882
(tx.address, self.state.resolve_nonce(tx.address, tx.nonce))
862883
})
863884
.collect();
@@ -883,6 +904,20 @@ impl Mempool {
883904
self.tx_pool.size_in_bytes() + self.delayed_declares.size_in_bytes()
884905
}
885906

907+
fn decrement_stuck_txs_if_gap_account(&mut self, address: ContractAddress, n: usize) {
908+
if self.accounts_with_gap.contains(&address) {
909+
self.n_stuck_txs -= n;
910+
}
911+
}
912+
913+
// Removes address from the gap-account set and deducts its remaining pool txs from
914+
// n_stuck_txs. No-op if the address was not tracked.
915+
fn remove_from_accounts_with_gap(&mut self, address: ContractAddress) {
916+
if self.accounts_with_gap.swap_remove(&address) {
917+
self.n_stuck_txs -= self.tx_pool.n_txs_for_address(address);
918+
}
919+
}
920+
886921
// Returns true if the mempool will exceeds its capacity by adding the given transaction.
887922
fn exceeds_capacity(&self, tx: &InternalRpcTransaction) -> bool {
888923
self.size_in_bytes() + tx.total_bytes() > self.config.static_config.capacity_in_bytes
@@ -893,7 +928,7 @@ impl Mempool {
893928
// If a delayed declare transaction exists at the account nonce, it is next to execute,
894929
// so no gap exists.
895930
if self.delayed_declares.contains(address, account_nonce) {
896-
self.accounts_with_gap.swap_remove(&address);
931+
self.remove_from_accounts_with_gap(address);
897932
continue;
898933
}
899934

@@ -905,9 +940,14 @@ impl Mempool {
905940

906941
// Update the eviction tracking set accordingly.
907942
if gap_exists {
908-
self.accounts_with_gap.insert(address);
943+
if self.accounts_with_gap.insert(address) {
944+
// Newly entered gap: all current pool txs for this account are now stuck.
945+
self.n_stuck_txs += self.tx_pool.n_txs_for_address(address);
946+
}
947+
// Stayed in gap: per-tx deltas were already applied at add/remove sites.
909948
} else {
910-
self.accounts_with_gap.swap_remove(&address);
949+
// Left gap: remaining pool txs for this account are no longer stuck.
950+
self.remove_from_accounts_with_gap(address);
911951
}
912952
}
913953
}
@@ -939,6 +979,7 @@ impl Mempool {
939979
.expect("Transaction must exist in the pool.");
940980
total_space_freed += tx.total_bytes();
941981
metric_count_evicted_txs(1);
982+
self.decrement_stuck_txs_if_gap_account(address, 1);
942983
if total_space_freed >= required_space {
943984
break;
944985
}
@@ -986,12 +1027,19 @@ impl Mempool {
9861027
&self.accounts_with_gap
9871028
}
9881029

1030+
#[cfg(test)]
1031+
pub(crate) fn n_stuck_txs(&self) -> usize {
1032+
self.n_stuck_txs
1033+
}
1034+
9891035
fn update_state_metrics(&self) {
9901036
MEMPOOL_POOL_SIZE.set_lossy(self.tx_pool.len());
9911037
MEMPOOL_PRIORITY_QUEUE_SIZE.set_lossy(self.tx_queue.priority_queue_len());
9921038
MEMPOOL_PENDING_QUEUE_SIZE.set_lossy(self.tx_queue.pending_queue_len());
9931039
MEMPOOL_DELAYED_DECLARES_SIZE.set_lossy(self.delayed_declares.len());
9941040
MEMPOOL_TOTAL_SIZE_BYTES.set_lossy(self.size_in_bytes());
1041+
MEMPOOL_ACCOUNTS_WITH_GAP.set_lossy(self.accounts_with_gap.len());
1042+
MEMPOOL_STUCK_TXS.set_lossy(self.n_stuck_txs);
9951043
}
9961044
}
9971045

0 commit comments

Comments
 (0)