From 9c2cd202a0f71bce545eb79c2362c294849f6942 Mon Sep 17 00:00:00 2001 From: Matan Lior Date: Sat, 11 Jul 2026 15:15:01 +0300 Subject: [PATCH] apollo_mempool: drop emptied gap accounts on tx expiry to fix accounts_with_gap leak remove_expired_txs decremented n_stuck_txs for expired stuck txs but only fed the queued txs into update_accounts_with_gap. Stuck txs are never queued (fee mode enqueues only nonce == account_nonce), so a gap account whose stuck txs all expire was never removed from accounts_with_gap, leaving mempool_accounts_with_gap > 0 while mempool_stuck_txs == 0 (observed on mainnet). Add a cleanup loop, after the decrement loop, that removes any account expiry left with no pool txs (mirrors try_make_space). Runs separately so per-tx stuck decrements aren't skipped by early set removal. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/apollo_mempool/src/fee_mempool_test.rs | 60 +++++++++++++++++++ crates/apollo_mempool/src/mempool.rs | 11 ++++ 2 files changed, 71 insertions(+) diff --git a/crates/apollo_mempool/src/fee_mempool_test.rs b/crates/apollo_mempool/src/fee_mempool_test.rs index 300e4be5229..dc35a16fda8 100644 --- a/crates/apollo_mempool/src/fee_mempool_test.rs +++ b/crates/apollo_mempool/src/fee_mempool_test.rs @@ -2037,6 +2037,66 @@ fn stuck_txs_counter_expiry_decrements_count() { assert_gap_metrics(&mempool, 1, 1); } +#[test] +fn stuck_txs_counter_expiry_removes_emptied_gap_account() { + let fake_clock = Arc::new(FakeClock::default()); + let mut mempool = Mempool::new( + MempoolConfig { + dynamic_config: MempoolDynamicConfig { transaction_ttl: Duration::from_secs(60) }, + ..Default::default() + }, + fake_clock.clone(), + ); + + // Gap account with a single stuck tx (nonce 2, gap at 0 and 1). + let tx_nonce_2 = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 2, account_nonce: 0); + add_tx(&mut mempool, &tx_nonce_2); + assert_gap_metrics(&mempool, 1, 1); + + // Expire the account's only stuck tx, then trigger the expiry sweep via a tx for another + // address. + fake_clock.advance(Duration::from_secs(61)); + let trigger = add_tx_input!(tx_hash: 2, address: "0x1", tx_nonce: 0, account_nonce: 0); + add_tx(&mut mempool, &trigger); + + // The stuck tx is non-queued, so it never re-enters update_accounts_with_gap; the emptied gap + // account must still leave accounts_with_gap so both counters return to zero. + assert_gap_metrics(&mempool, 0, 0); +} + +#[test] +fn stuck_txs_counter_expiry_removes_gap_account_with_multiple_stuck_txs() { + let fake_clock = Arc::new(FakeClock::default()); + let mut mempool = Mempool::new( + MempoolConfig { + dynamic_config: MempoolDynamicConfig { transaction_ttl: Duration::from_secs(60) }, + ..Default::default() + }, + fake_clock.clone(), + ); + + // Gap account with two stuck txs (nonces 2 and 3, gap at 0 and 1), submitted at the same time + // so they expire together in a single sweep. + let tx_nonce_2 = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 2, account_nonce: 0); + let tx_nonce_3 = add_tx_input!(tx_hash: 2, address: "0x0", tx_nonce: 3, account_nonce: 0); + for tx in [&tx_nonce_2, &tx_nonce_3] { + add_tx(&mut mempool, tx); + } + assert_gap_metrics(&mempool, 1, 2); + + // Expire both stuck txs, then trigger the sweep via a tx for another address. + fake_clock.advance(Duration::from_secs(61)); + let trigger = add_tx_input!(tx_hash: 3, address: "0x1", tx_nonce: 0, account_nonce: 0); + add_tx(&mut mempool, &trigger); + + // All of the account's stuck txs expired in one sweep, so both counters must reach zero. This + // guards the two-phase structure of remove_expired_txs: the account must stay in + // accounts_with_gap until every stuck tx is decremented (decrement_stuck_txs_if_gap_account + // only acts while the account is tracked), and only then be removed. Merging decrement and + // removal into one loop would no-op the second tx's decrement and leave stuck_txs == 1. + assert_gap_metrics(&mempool, 0, 0); +} + #[test] fn stuck_txs_counter_eviction_decrements_count() { let tx_gap = add_tx_input!(tx_hash: 1, address: "0x0", tx_nonce: 2, account_nonce: 0); diff --git a/crates/apollo_mempool/src/mempool.rs b/crates/apollo_mempool/src/mempool.rs index b1ae9321a6b..c9a40cb9bf5 100644 --- a/crates/apollo_mempool/src/mempool.rs +++ b/crates/apollo_mempool/src/mempool.rs @@ -840,6 +840,17 @@ impl Mempool { self.decrement_stuck_txs_if_gap_account(tx_ref.address, 1); } + // Drop gap accounts that expiry left with no pool txs. Their stuck txs are non-queued, so + // the queued-tx map returned below never re-evaluates them; without this they + // linger in `accounts_with_gap` (accounts_with_gap > 0 while stuck_txs == 0). Runs + // after the decrement loop above: removing an account from the set early would make + // its per-tx stuck decrements no-op. + for tx_ref in &removed_txs { + if !self.tx_pool.contains_account(tx_ref.address) { + self.remove_from_accounts_with_gap(tx_ref.address); + } + } + let queued_txs = self.tx_queue.remove_txs(&removed_txs); self.log_and_count_expired_txs(&removed_txs);