mempool: fixes

This commit is contained in:
nym21
2026-05-10 16:23:06 +02:00
parent 774580ee11
commit dd6eca138b
10 changed files with 227 additions and 134 deletions

View File

@@ -4,9 +4,10 @@
//!
//! 1. [`steps::fetcher::Fetcher`] - one mixed batched RPC for
//! `getblocktemplate` + `getrawmempool false` + `getmempoolinfo`,
//! then a `getmempoolentry` batch and a `getrawtransaction` batch
//! on new txids only. The GBT is validated to be a subset of the
//! txid listing; on mismatch the cycle is skipped.
//! then a single mixed `getmempoolentry`+`getrawtransaction` batch
//! on new txids only. GBT-only txs are synthesized inline from the
//! GBT payload so block 0 matches Core's selection exactly without
//! a follow-up entry fetch that could race the listing.
//! 2. [`steps::preparer::Preparer`] - decode and classify into
//! `TxsPulled { added, removed }`. Pure CPU.
//! 3. [`steps::applier::Applier`] - apply the diff to
@@ -345,20 +346,17 @@ impl Mempool {
..
} = &*self.0;
let Some(Fetched {
let Fetched {
live_txids,
new_entries,
new_txs,
gbt,
gbt_txids,
min_fee,
}) = Fetcher::fetch(client, state)?
else {
return Ok(());
};
} = Fetcher::fetch(client, state)?;
let pulled = Preparer::prepare(&live_txids, new_entries, new_txs, state);
let changed = Applier::apply(state, rebuilder, pulled);
Prevouts::fill(state, resolver);
rebuilder.tick(state, changed, &gbt, min_fee);
rebuilder.tick(state, changed, &gbt_txids, min_fee);
Ok(())
}

View File

@@ -1,4 +1,3 @@
use brk_rpc::BlockTemplateTx;
use brk_types::{FeeRate, MempoolEntryInfo, Txid};
use rustc_hash::FxHashMap;
@@ -10,6 +9,10 @@ pub struct Fetched {
/// keep their first-sight entry on the live store).
pub new_entries: Vec<MempoolEntryInfo>,
pub new_txs: FxHashMap<Txid, bitcoin::Transaction>,
pub gbt: Vec<BlockTemplateTx>,
/// Block 0 ordering from `getblocktemplate`. Bodies and stats have
/// already been folded into `new_entries`/`new_txs` (or were already
/// in the pool); the Rebuilder only needs the txid sequence to
/// project Core's exact selection.
pub gbt_txids: Vec<Txid>,
pub min_fee: FeeRate,
}

View File

@@ -4,64 +4,106 @@ pub use fetched::Fetched;
use brk_error::Result;
use brk_rpc::{Client, MempoolState};
use brk_types::Txid;
use brk_types::{MempoolEntryInfo, Timestamp, Txid, VSize};
use parking_lot::RwLock;
use rustc_hash::FxHashSet;
use crate::{State, stores::TxStore};
use crate::State;
/// Cap before the batch RPC so we never hand bitcoind an unbounded batch.
/// GBT-synthesized entries are not subject to this cap; they're bounded
/// by the block weight limit Core enforces on its own template.
const MAX_TX_FETCHES_PER_CYCLE: usize = 10_000;
/// Three batched round-trips per cycle, scaling with churn rather than
/// Two batched round-trips per cycle, scaling with churn rather than
/// mempool size: `getblocktemplate` + `getrawmempool false` +
/// `getmempoolinfo` in one mixed batch; then `getmempoolentry` and
/// `getrawtransaction` per *new* txid only.
/// `getmempoolinfo` in one mixed batch; then `getmempoolentry` +
/// `getrawtransaction` for *new* non-GBT txids in a second mixed batch.
///
/// `getblocktemplate` is validated to be a subset of the txid listing
/// inside the RPC layer; mismatches return `Ok(None)` so the cycle is
/// skipped without polluting downstream state.
/// GBT entries already carry the full tx body and stats, so any GBT tx
/// not yet in the local pool is materialized inline from the GBT
/// payload instead of being refetched. That removes the GBT/listing
/// race that used to skip cycles when a tx vanished from the mempool
/// between the GBT and `getrawmempool` calls: block 0 always reflects
/// Core's exact selection because we never ask for that data twice.
///
/// Confirmed prevouts are resolved post-apply by the caller-supplied
/// resolver passed to `Mempool::update_with`, so the in-crate path no
/// longer issues a fourth batch for parents.
/// longer issues a third batch for parents.
pub struct Fetcher;
impl Fetcher {
pub fn fetch(client: &Client, lock: &RwLock<State>) -> Result<Option<Fetched>> {
let Some(MempoolState {
pub fn fetch(client: &Client, lock: &RwLock<State>) -> Result<Fetched> {
let MempoolState {
live_txids,
gbt,
min_fee,
}) = client.fetch_mempool_state()?
else {
return Ok(None);
};
let new_txids = {
} = client.fetch_mempool_state()?;
// One read snapshot decides both the RPC fetch list and the
// GBT-synthesis set, so they agree on what's "already known".
// Graveyard txs are treated as known so a re-broadcast still
// flows through `Preparer::classify_addition` and lands as
// [`crate::TxAddition::Revived`].
let (new_txids, gbt_synth_set) = {
let state = lock.read();
Self::new_txids(&live_txids, &state.txs)
let mut gbt_txids: FxHashSet<Txid> =
FxHashSet::with_capacity_and_hasher(gbt.len(), Default::default());
let mut gbt_synth_set: FxHashSet<Txid> = FxHashSet::default();
for g in &gbt {
gbt_txids.insert(g.txid);
if !state.txs.contains(&g.txid) {
gbt_synth_set.insert(g.txid);
}
}
let new_txids: Vec<Txid> = live_txids
.iter()
.filter(|t| !state.txs.contains(t) && !gbt_txids.contains(t))
.take(MAX_TX_FETCHES_PER_CYCLE)
.copied()
.collect();
(new_txids, gbt_synth_set)
};
let new_entries = client.fetch_mempool_entries(&new_txids)?;
let new_txs = client.get_raw_transactions(&new_txids)?;
Ok(Some(Fetched {
let (mut new_entries, mut new_txs) = client.fetch_new_pool_data(&new_txids)?;
new_entries.reserve(gbt_synth_set.len());
new_txs.reserve(gbt_synth_set.len());
// Consume `gbt` by value: GBT-only txs move their body and
// depends into the synthesis path (no clones), and the GBT
// ordering is captured as a `Vec<Txid>` for the Rebuilder, which
// is the only downstream consumer and only reads txids.
//
// GBT carries no per-tx arrival timestamp. `now` is correct to
// within ~1 cycle for a tx that just entered Core's mempool
// (the only kind that triggers synthesis: not in our pool yet
// means it just appeared this cycle).
let now = Timestamp::now();
let gbt_txids: Vec<Txid> = gbt
.into_iter()
.map(|g| {
let txid = g.txid;
if gbt_synth_set.contains(&txid) {
new_entries.push(MempoolEntryInfo {
txid,
vsize: VSize::from(g.weight),
weight: g.weight,
fee: g.fee,
first_seen: now,
depends: g.depends,
});
new_txs.insert(txid, g.tx);
}
txid
})
.collect();
Ok(Fetched {
live_txids,
new_entries,
new_txs,
gbt,
gbt_txids,
min_fee,
}))
}
/// Live txids the local store hasn't seen yet. Graveyard txs are
/// included so a re-broadcast (post-reorg or a peer republishing)
/// flows through `Preparer::classify_addition` and lands as
/// [`crate::TxAddition::Revived`] instead of sitting orphaned for
/// the full graveyard retention.
fn new_txids(live_txids: &[Txid], known: &TxStore) -> Vec<Txid> {
live_txids
.iter()
.filter(|txid| !known.contains(txid))
.take(MAX_TX_FETCHES_PER_CYCLE)
.copied()
.collect()
})
}
}

View File

@@ -6,7 +6,6 @@ use std::{
},
};
use brk_rpc::BlockTemplateTx;
use brk_types::{FeeRate, NextBlockHash, Txid, TxidPrefix};
use parking_lot::RwLock;
use rustc_hash::FxHashSet;
@@ -45,7 +44,7 @@ impl Rebuilder {
&self,
lock: &RwLock<State>,
changed: bool,
gbt: &[BlockTemplateTx],
gbt_txids: &[Txid],
min_fee: FeeRate,
) {
if changed {
@@ -55,7 +54,7 @@ impl Rebuilder {
self.skip_clean.fetch_add(1, Ordering::Relaxed);
return;
}
let snap = Self::build_snapshot(lock, gbt, min_fee);
let snap = Self::build_snapshot(lock, gbt_txids, min_fee);
let block0_set: FxHashSet<Txid> = snap.block0_txids().collect();
let next_hash = snap.next_block_hash;
*self.snapshot.write() = Arc::new(snap);
@@ -93,7 +92,7 @@ impl Rebuilder {
fn build_snapshot(
lock: &RwLock<State>,
gbt: &[BlockTemplateTx],
gbt_txids: &[Txid],
min_fee: FeeRate,
) -> Snapshot {
let (txs, prefix_to_idx) = {
@@ -102,12 +101,15 @@ impl Rebuilder {
};
// Block 0 from `getblocktemplate`: Core's actual selection.
// Fetcher already validated GBT ⊆ live txid listing, so any
// drop here is a same-cycle race and the partitioner picks up
// the slack so callers always see NUM_BLOCKS blocks.
let block0: Vec<TxIndex> = gbt
// The Fetcher synthesizes pool entries for GBT txs that aren't
// already present (using GBT's inline body + stats), so this
// lookup always resolves and block 0 matches Core exactly.
// The `filter_map` only drops if a tx was concurrently evicted
// from `txs` between `build_txs` and the rebuild, which the
// partitioner backfills so callers still see `NUM_BLOCKS`.
let block0: Vec<TxIndex> = gbt_txids
.iter()
.filter_map(|t| prefix_to_idx.get(&TxidPrefix::from(&t.txid)).copied())
.filter_map(|txid| prefix_to_idx.get(&TxidPrefix::from(txid)).copied())
.collect();
let excluded: FxHashSet<TxIndex> = block0.iter().copied().collect();
let rest = Partitioner::partition(&txs, &excluded, NUM_BLOCKS.saturating_sub(1));