mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-20 06:44:47 -07:00
global: fixes
This commit is contained in:
@@ -15,58 +15,52 @@ fn main() -> Result<()> {
|
||||
|
||||
let mempool = Mempool::new(&client);
|
||||
|
||||
// Start mempool sync in background thread
|
||||
let mempool_clone = mempool.clone();
|
||||
thread::spawn(move || {
|
||||
mempool_clone.start();
|
||||
});
|
||||
|
||||
// Poll and display stats every 5 seconds
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
|
||||
// Basic mempool info
|
||||
let info = mempool.info();
|
||||
let block_stats = mempool.block_stats();
|
||||
let total_fees: u64 = block_stats.iter().map(|s| u64::from(s.total_fee)).sum();
|
||||
println!("\n=== Mempool Info ===");
|
||||
println!(" Transactions: {}", info.count);
|
||||
println!(" Total vsize: {} vB", info.vsize);
|
||||
println!(
|
||||
" Total fees: {:.4} BTC",
|
||||
total_fees as f64 / 100_000_000.0
|
||||
);
|
||||
|
||||
// Fee recommendations (like mempool.space)
|
||||
let fees = mempool.fees();
|
||||
println!("\n=== Recommended Fees (sat/vB) ===");
|
||||
println!(" No Priority {:.4}", f64::from(fees.economy_fee));
|
||||
println!(" Low Priority {:.4}", f64::from(fees.hour_fee));
|
||||
println!(" Medium Priority {:.4}", f64::from(fees.half_hour_fee));
|
||||
println!(" High Priority {:.4}", f64::from(fees.fastest_fee));
|
||||
|
||||
// Projected blocks (like mempool.space)
|
||||
if !block_stats.is_empty() {
|
||||
println!("\n=== Projected Blocks ===");
|
||||
for (i, stats) in block_stats.iter().enumerate() {
|
||||
let total_fee_btc = u64::from(stats.total_fee) as f64 / 100_000_000.0;
|
||||
println!(
|
||||
" Block {}: ~{:.4} sat/vB, {:.4}-{:.4} sat/vB, {:.3} BTC, {} txs",
|
||||
i + 1,
|
||||
f64::from(stats.median_fee_rate()),
|
||||
f64::from(stats.min_fee_rate()),
|
||||
f64::from(stats.max_fee_rate()),
|
||||
total_fee_btc,
|
||||
stats.tx_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Address tracking stats
|
||||
let entries = mempool.entries();
|
||||
let txs = mempool.txs();
|
||||
let addrs = mempool.addrs();
|
||||
println!("\n=== Address Tracking ===");
|
||||
println!(" Addresses with pending txs: {}", addrs.len());
|
||||
let graveyard = mempool.graveyard();
|
||||
let outpoint_spends = mempool.state().outpoint_spends.read();
|
||||
let snapshot = mempool.snapshot();
|
||||
|
||||
println!("\n----------------------------------------");
|
||||
let cluster_nodes_total: usize = snapshot.clusters.iter().map(|c| c.nodes.len()).sum();
|
||||
let blocks_tx_total: usize = snapshot.blocks.iter().map(|b| b.len()).sum();
|
||||
let (skip_clean, skip_throttled) = mempool.skip_counts();
|
||||
|
||||
println!(
|
||||
"info.count={} entries.slots={} entries.active={} entries.free={} \
|
||||
txs={} unresolved={} addrs={} outpoints={} \
|
||||
graveyard.tombstones={} graveyard.order={} \
|
||||
snap.clusters={} snap.cluster_nodes={} snap.cluster_of.len={} snap.cluster_of.active={} \
|
||||
snap.blocks={} snap.blocks_txs={} \
|
||||
rebuilds={} skip.clean={} skip.throttled={}",
|
||||
info.count,
|
||||
entries.entries().len(),
|
||||
entries.active_count(),
|
||||
entries.free_slots_count(),
|
||||
txs.len(),
|
||||
txs.unresolved().len(),
|
||||
addrs.len(),
|
||||
outpoint_spends.len(),
|
||||
graveyard.tombstones_len(),
|
||||
graveyard.order_len(),
|
||||
snapshot.clusters.len(),
|
||||
cluster_nodes_total,
|
||||
snapshot.cluster_of_len(),
|
||||
snapshot.cluster_of_active(),
|
||||
snapshot.blocks.len(),
|
||||
blocks_tx_total,
|
||||
mempool.rebuild_count(),
|
||||
skip_clean,
|
||||
skip_throttled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
31
crates/brk_mempool/src/cluster/chunk.rs
Normal file
31
crates/brk_mempool/src/cluster/chunk.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use brk_types::{CpfpClusterChunk, CpfpClusterTxIndex, FeeRate, Sats, VSize};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::LocalIdx;
|
||||
|
||||
pub struct Chunk {
|
||||
/// Cluster-local positions of the txs in this chunk, in topological
|
||||
/// order (parents before children). Populated by `Cluster::new`.
|
||||
pub txs: SmallVec<[LocalIdx; 4]>,
|
||||
pub fee: Sats,
|
||||
pub vsize: VSize,
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
pub fn fee_rate(&self) -> FeeRate {
|
||||
FeeRate::from((self.fee, self.vsize))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Chunk> for CpfpClusterChunk {
|
||||
fn from(chunk: &Chunk) -> Self {
|
||||
Self {
|
||||
txs: chunk
|
||||
.txs
|
||||
.iter()
|
||||
.map(|&local| CpfpClusterTxIndex::from(local.inner()))
|
||||
.collect(),
|
||||
feerate: chunk.fee_rate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
33
crates/brk_mempool/src/cluster/chunk_id.rs
Normal file
33
crates/brk_mempool/src/cluster/chunk_id.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
/// Index of a `Chunk` inside a `Cluster.chunks`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(transparent)]
|
||||
pub struct ChunkId(u32);
|
||||
|
||||
impl ChunkId {
|
||||
pub const ZERO: Self = Self(0);
|
||||
|
||||
#[inline]
|
||||
pub fn as_usize(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn inner(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u32> for ChunkId {
|
||||
#[inline]
|
||||
fn from(v: u32) -> Self {
|
||||
Self(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for ChunkId {
|
||||
#[inline]
|
||||
fn from(v: usize) -> Self {
|
||||
debug_assert!(v <= u32::MAX as usize, "ChunkId overflow: {v}");
|
||||
Self(v as u32)
|
||||
}
|
||||
}
|
||||
31
crates/brk_mempool/src/cluster/cluster_id.rs
Normal file
31
crates/brk_mempool/src/cluster/cluster_id.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
/// Index of a `Cluster` inside `Snapshot::clusters`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(transparent)]
|
||||
pub struct ClusterId(u32);
|
||||
|
||||
impl ClusterId {
|
||||
#[inline]
|
||||
pub fn as_usize(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn inner(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u32> for ClusterId {
|
||||
#[inline]
|
||||
fn from(v: u32) -> Self {
|
||||
Self(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for ClusterId {
|
||||
#[inline]
|
||||
fn from(v: usize) -> Self {
|
||||
debug_assert!(v <= u32::MAX as usize, "ClusterId overflow: {v}");
|
||||
Self(v as u32)
|
||||
}
|
||||
}
|
||||
48
crates/brk_mempool/src/cluster/cluster_node.rs
Normal file
48
crates/brk_mempool/src/cluster/cluster_node.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use brk_types::{CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, Sats, Txid, VSize, Weight};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::LocalIdx;
|
||||
|
||||
/// A node inside a `Cluster<I>`. The `id` carries whatever the caller
|
||||
/// uses to refer back to the source tx: `brk_mempool::stores::TxIndex`
|
||||
/// (live pool slot) on the mempool path, `brk_types::TxIndex` (global
|
||||
/// indexer position) on the confirmed path. `Cluster::new` and the SFL
|
||||
/// algorithm don't read it.
|
||||
///
|
||||
/// All fields are `pub` and callers construct directly with struct
|
||||
/// literals; `parents` are always supplied at construction (no
|
||||
/// post-init mutation pattern).
|
||||
pub struct ClusterNode<I> {
|
||||
pub id: I,
|
||||
pub txid: Txid,
|
||||
pub fee: Sats,
|
||||
pub vsize: VSize,
|
||||
pub weight: Weight,
|
||||
/// Direct parents in the cluster. Caller-supplied.
|
||||
pub parents: SmallVec<[LocalIdx; 2]>,
|
||||
}
|
||||
|
||||
impl<I> From<&ClusterNode<I>> for CpfpEntry {
|
||||
fn from(node: &ClusterNode<I>) -> Self {
|
||||
Self {
|
||||
txid: node.txid,
|
||||
weight: node.weight,
|
||||
fee: node.fee,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> From<&ClusterNode<I>> for CpfpClusterTx {
|
||||
fn from(node: &ClusterNode<I>) -> Self {
|
||||
Self {
|
||||
txid: node.txid,
|
||||
weight: node.weight,
|
||||
fee: node.fee,
|
||||
parents: node
|
||||
.parents
|
||||
.iter()
|
||||
.map(|&p| CpfpClusterTxIndex::from(p.inner()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/brk_mempool/src/cluster/cluster_ref.rs
Normal file
9
crates/brk_mempool/src/cluster/cluster_ref.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use super::{ClusterId, LocalIdx};
|
||||
|
||||
/// Locates a node within the cluster forest: which cluster it lives in,
|
||||
/// and its `LocalIdx` inside that cluster.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ClusterRef {
|
||||
pub cluster_id: ClusterId,
|
||||
pub local: LocalIdx,
|
||||
}
|
||||
34
crates/brk_mempool/src/cluster/local_idx.rs
Normal file
34
crates/brk_mempool/src/cluster/local_idx.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
/// Index of a node within a single `Cluster`. Cluster-local; meaningless
|
||||
/// across clusters.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(transparent)]
|
||||
pub struct LocalIdx(u32);
|
||||
|
||||
impl LocalIdx {
|
||||
pub const ZERO: Self = Self(0);
|
||||
|
||||
#[inline]
|
||||
pub fn as_usize(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn inner(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u32> for LocalIdx {
|
||||
#[inline]
|
||||
fn from(v: u32) -> Self {
|
||||
Self(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for LocalIdx {
|
||||
#[inline]
|
||||
fn from(v: usize) -> Self {
|
||||
debug_assert!(v <= u32::MAX as usize, "LocalIdx overflow: {v}");
|
||||
Self(v as u32)
|
||||
}
|
||||
}
|
||||
145
crates/brk_mempool/src/cluster/mod.rs
Normal file
145
crates/brk_mempool/src/cluster/mod.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! Cluster primitive shared by the live mempool snapshot rebuilder
|
||||
//! and the per-request CPFP path. A `Cluster` is a connected component
|
||||
//! of the mempool dependency graph, locally re-indexed in topological
|
||||
//! order and SFL-linearized into chunks ordered by descending feerate.
|
||||
//!
|
||||
//! Callers supply `ClusterNode`s with parent edges only; `Cluster::new`
|
||||
//! permutes them into Kahn topological order (so `LocalIdx == position
|
||||
//! in `nodes` == topological position`), then runs SFL.
|
||||
|
||||
mod chunk;
|
||||
mod chunk_id;
|
||||
mod cluster_id;
|
||||
mod cluster_node;
|
||||
mod cluster_ref;
|
||||
mod local_idx;
|
||||
mod sfl;
|
||||
|
||||
pub use chunk::Chunk;
|
||||
pub use chunk_id::ChunkId;
|
||||
pub use cluster_id::ClusterId;
|
||||
pub use cluster_node::ClusterNode;
|
||||
pub use cluster_ref::ClusterRef;
|
||||
pub use local_idx::LocalIdx;
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// A connected component of the mempool graph, stored in topological
|
||||
/// order (parents before children) and SFL-linearized into chunks.
|
||||
///
|
||||
/// `I` is the caller's identifier for each node: `brk_mempool::stores::TxIndex`
|
||||
/// (live pool slot) on the mempool path, `brk_types::TxIndex` (global indexer
|
||||
/// position) on the confirmed path. The SFL algorithm doesn't touch it; only
|
||||
/// consumers that need to map a `LocalIdx` back to source-tx state read it.
|
||||
///
|
||||
/// Because nodes are stored topologically, every `LocalIdx` is also
|
||||
/// its topological position: parent edges always point to lower
|
||||
/// indices, and a forward iteration over `nodes` is a valid topo
|
||||
/// sweep.
|
||||
pub struct Cluster<I> {
|
||||
pub nodes: Vec<ClusterNode<I>>,
|
||||
/// SFL-emitted chunks, ordered by descending feerate.
|
||||
pub chunks: Vec<Chunk>,
|
||||
/// `node_to_chunk[local]` is the `ChunkId` that contains the node.
|
||||
pub node_to_chunk: Vec<ChunkId>,
|
||||
}
|
||||
|
||||
impl<I> Cluster<I> {
|
||||
pub fn new(nodes: Vec<ClusterNode<I>>) -> Self {
|
||||
let nodes = Self::permute_to_topo_order(nodes);
|
||||
let chunk_masks = sfl::linearize(&nodes);
|
||||
let (chunks, node_to_chunk) = Self::materialize_chunks(&chunk_masks, nodes.len());
|
||||
Self {
|
||||
nodes,
|
||||
chunks,
|
||||
node_to_chunk,
|
||||
}
|
||||
}
|
||||
|
||||
/// O(1) chunk lookup for a node.
|
||||
#[inline]
|
||||
pub fn chunk_of(&self, local: LocalIdx) -> &Chunk {
|
||||
&self.chunks[self.node_to_chunk[local.as_usize()].as_usize()]
|
||||
}
|
||||
|
||||
/// Reorder `nodes` into Kahn topological order and remap every
|
||||
/// parent edge into the new index space. Single pass: build the
|
||||
/// child adjacency and in-degrees, then Kahn-pop directly into the
|
||||
/// output Vec while remapping each node's parents through the
|
||||
/// `new_pos[old] -> new` map populated as we pop. Post-condition:
|
||||
/// for every `i`, every parent of `nodes[i]` has a `LocalIdx`
|
||||
/// strictly less than `i`.
|
||||
fn permute_to_topo_order(mut nodes: Vec<ClusterNode<I>>) -> Vec<ClusterNode<I>> {
|
||||
let n = nodes.len();
|
||||
let mut children: Vec<SmallVec<[LocalIdx; 2]>> =
|
||||
(0..n).map(|_| SmallVec::new()).collect();
|
||||
let mut indegree: Vec<u32> = vec![0; n];
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
indegree[i] = node.parents.len() as u32;
|
||||
for &p in &node.parents {
|
||||
children[p.as_usize()].push(LocalIdx::from(i));
|
||||
}
|
||||
}
|
||||
|
||||
// Sources (in-degree 0) seed the queue. We hold them as `LocalIdx`
|
||||
// pointing at the *old* slot; `out` drains nodes out as it pops.
|
||||
let mut queue: Vec<LocalIdx> = (0..n)
|
||||
.filter(|&i| indegree[i] == 0)
|
||||
.map(LocalIdx::from)
|
||||
.collect();
|
||||
let mut new_pos = vec![LocalIdx::ZERO; n];
|
||||
let mut out: Vec<ClusterNode<I>> = Vec::with_capacity(n);
|
||||
let mut taken: Vec<Option<ClusterNode<I>>> = nodes.drain(..).map(Some).collect();
|
||||
|
||||
let mut head = 0;
|
||||
while head < queue.len() {
|
||||
let v = queue[head];
|
||||
head += 1;
|
||||
new_pos[v.as_usize()] = LocalIdx::from(out.len());
|
||||
let mut node = taken[v.as_usize()].take().unwrap();
|
||||
for p in node.parents.iter_mut() {
|
||||
*p = new_pos[p.as_usize()];
|
||||
}
|
||||
out.push(node);
|
||||
for &c in &children[v.as_usize()] {
|
||||
indegree[c.as_usize()] -= 1;
|
||||
if indegree[c.as_usize()] == 0 {
|
||||
queue.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug_assert_eq!(out.len(), n, "cluster contained a cycle");
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert SFL's raw bit-masks into final `Chunk`s with topo-ordered
|
||||
/// `txs` and a `tx → ChunkId` reverse map. Bit iteration via
|
||||
/// `trailing_zeros` visits each chunk's bits in ascending order, and
|
||||
/// nodes are stored in topo order (`LocalIdx == position`), so each
|
||||
/// pushed `LocalIdx` lands parents-first in `chunk.txs`.
|
||||
fn materialize_chunks(
|
||||
chunk_masks: &[sfl::ChunkMask],
|
||||
n: usize,
|
||||
) -> (Vec<Chunk>, Vec<ChunkId>) {
|
||||
let mut chunks: Vec<Chunk> = Vec::with_capacity(chunk_masks.len());
|
||||
let mut node_to_chunk = vec![ChunkId::ZERO; n];
|
||||
for (cid, cm) in chunk_masks.iter().enumerate() {
|
||||
let chunk_id = ChunkId::from(cid);
|
||||
let mut chunk = Chunk {
|
||||
txs: SmallVec::new(),
|
||||
fee: cm.fee,
|
||||
vsize: cm.vsize,
|
||||
};
|
||||
let mut bits = cm.mask;
|
||||
while bits != 0 {
|
||||
let i = bits.trailing_zeros() as usize;
|
||||
node_to_chunk[i] = chunk_id;
|
||||
chunk.txs.push(LocalIdx::from(i));
|
||||
bits &= bits - 1;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
(chunks, node_to_chunk)
|
||||
}
|
||||
}
|
||||
283
crates/brk_mempool/src/cluster/sfl.rs
Normal file
283
crates/brk_mempool/src/cluster/sfl.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
//! Cluster linearizer.
|
||||
//!
|
||||
//! Two-branch dispatch by cluster size:
|
||||
//! - **n ≤ 18**: recursive enumeration of topologically-closed subsets.
|
||||
//! Provably optimal. Visits only valid subsets (skips non-closed ones
|
||||
//! without filtering) and maintains running fee/vsize incrementally.
|
||||
//! - **n > 18**: "greedy-union" ancestor-set search. Seeds with each
|
||||
//! node's ancestor closure, then greedily adds any other ancestor
|
||||
//! closure whose inclusion raises the combined feerate. Strict
|
||||
//! superset of ancestor-set-sort's candidate space, catching the
|
||||
//! sibling-union shapes that pure ASS misses.
|
||||
//!
|
||||
//! A final stack-based `canonicalize` pass merges adjacent chunks when
|
||||
//! the later one's feerate beats the earlier's, restoring the
|
||||
//! non-increasing-rate invariant.
|
||||
//!
|
||||
//! Everything runs on `u128` bitmasks (covers Bitcoin Core 31's cluster
|
||||
//! cap of 100). Rate comparisons go through `FeeRate`. The caller is
|
||||
//! `Cluster::new`, which has already permuted nodes into topological
|
||||
//! order — so `LocalIdx == position == topological rank`, and this
|
||||
//! module never has to take a `topo_order` permutation.
|
||||
|
||||
use brk_types::{FeeRate, Sats, VSize};
|
||||
|
||||
use super::ClusterNode;
|
||||
|
||||
const BRUTE_FORCE_LIMIT: usize = 18;
|
||||
/// Cluster nodes are indexed by `u128` bitmask, so `n < 128`. Bitcoin
|
||||
/// Core's cluster cap is 100, so this leaves comfortable margin.
|
||||
const BITMASK_LIMIT: usize = 128;
|
||||
|
||||
/// Raw SFL output: a chunk's bitmask plus its totals. `Cluster::new`
|
||||
/// converts these into final `Chunk`s with topo-ordered `txs`, so the
|
||||
/// algorithm doesn't have to materialize them itself.
|
||||
pub(super) struct ChunkMask {
|
||||
pub mask: u128,
|
||||
pub fee: Sats,
|
||||
pub vsize: VSize,
|
||||
}
|
||||
|
||||
impl ChunkMask {
|
||||
fn fee_rate(&self) -> FeeRate {
|
||||
FeeRate::from((self.fee, self.vsize))
|
||||
}
|
||||
}
|
||||
|
||||
/// Linearize a cluster into SFL chunks.
|
||||
pub(super) fn linearize<I>(nodes: &[ClusterNode<I>]) -> Vec<ChunkMask> {
|
||||
assert!(
|
||||
nodes.len() < BITMASK_LIMIT,
|
||||
"cluster size {} exceeds u128 capacity",
|
||||
nodes.len()
|
||||
);
|
||||
let tables = Tables::build(nodes);
|
||||
let chunks = extract_chunks(&tables);
|
||||
canonicalize(chunks)
|
||||
}
|
||||
|
||||
/// Peel the cluster one chunk at a time. Each iteration picks the
|
||||
/// highest-feerate topologically-closed subset of `remaining` and
|
||||
/// removes it. Loop terminates because every iteration removes at
|
||||
/// least one node.
|
||||
fn extract_chunks(t: &Tables) -> Vec<ChunkMask> {
|
||||
let pick: fn(&Tables, u128) -> (u128, Sats, VSize) = if t.n <= BRUTE_FORCE_LIMIT {
|
||||
best_subset
|
||||
} else {
|
||||
best_ancestor_union
|
||||
};
|
||||
let mut chunks: Vec<ChunkMask> = Vec::new();
|
||||
let mut remaining: u128 = t.all;
|
||||
while remaining != 0 {
|
||||
let (mask, fee, vsize) = pick(t, remaining);
|
||||
chunks.push(ChunkMask { mask, fee, vsize });
|
||||
remaining &= !mask;
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
/// Recursive enumeration of topologically-closed subsets of
|
||||
/// `remaining`. Returns the (mask, fee, vsize) with the highest rate;
|
||||
/// when `remaining` is all zero-fee (e.g. a CPFP-parent leftover after
|
||||
/// the paying chunk was extracted), the first non-empty subset wins so
|
||||
/// `extract_chunks` always makes progress. Iterates nodes by index
|
||||
/// `0..n`; since the cluster is stored in topological order, that *is*
|
||||
/// a topological sweep.
|
||||
fn best_subset(t: &Tables, remaining: u128) -> (u128, Sats, VSize) {
|
||||
let ctx = Ctx { tables: t, remaining };
|
||||
let mut best = (0u128, Sats::ZERO, VSize::default());
|
||||
recurse(&ctx, 0, 0, Sats::ZERO, VSize::default(), &mut best);
|
||||
best
|
||||
}
|
||||
|
||||
fn recurse(
|
||||
ctx: &Ctx,
|
||||
idx: usize,
|
||||
included: u128,
|
||||
f: Sats,
|
||||
v: VSize,
|
||||
best: &mut (u128, Sats, VSize),
|
||||
) {
|
||||
if idx == ctx.tables.n {
|
||||
if included != 0
|
||||
&& (best.0 == 0 || FeeRate::from((f, v)) > FeeRate::from((best.1, best.2)))
|
||||
{
|
||||
*best = (included, f, v);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let bit = 1u128 << idx;
|
||||
|
||||
// Not in remaining, or a parent (within remaining) is excluded:
|
||||
// this node is forced-excluded, no branching.
|
||||
if (bit & ctx.remaining) == 0
|
||||
|| (ctx.tables.parents_mask[idx] & ctx.remaining & !included) != 0
|
||||
{
|
||||
recurse(ctx, idx + 1, included, f, v, best);
|
||||
return;
|
||||
}
|
||||
|
||||
recurse(ctx, idx + 1, included, f, v, best);
|
||||
recurse(
|
||||
ctx,
|
||||
idx + 1,
|
||||
included | bit,
|
||||
f + ctx.tables.fee_of[idx],
|
||||
v + ctx.tables.vsize_of[idx],
|
||||
best,
|
||||
);
|
||||
}
|
||||
|
||||
/// For each node v in `remaining`, seed with anc(v) ∩ remaining, then
|
||||
/// greedily extend by adding any anc(u) whose inclusion raises the
|
||||
/// feerate. Pick the best result across all seeds; when every seed has
|
||||
/// rate 0 (e.g. a CPFP-parent leftover after the paying chunk was
|
||||
/// extracted), the first seed wins so `extract_chunks` always makes
|
||||
/// progress.
|
||||
///
|
||||
/// Every candidate evaluated is a union of ancestor closures, so it
|
||||
/// is topologically closed by construction. Strictly explores more
|
||||
/// candidates than pure ancestor-set-sort, at O(n³) per chunk step.
|
||||
fn best_ancestor_union(t: &Tables, remaining: u128) -> (u128, Sats, VSize) {
|
||||
let mut best = (0u128, Sats::ZERO, VSize::default());
|
||||
let mut best_rate = FeeRate::default();
|
||||
let mut seeds = remaining;
|
||||
while seeds != 0 {
|
||||
let i = seeds.trailing_zeros() as usize;
|
||||
seeds &= seeds - 1;
|
||||
|
||||
let mut s = t.ancestor_incl[i] & remaining;
|
||||
let (mut f, mut v) = totals(s, &t.fee_of, &t.vsize_of);
|
||||
let mut rate = FeeRate::from((f, v));
|
||||
|
||||
// Greedy extension to fixed point: pick the ancestor-closure
|
||||
// addition that yields the highest resulting feerate, if any.
|
||||
loop {
|
||||
let mut picked: Option<(u128, Sats, VSize, FeeRate)> = None;
|
||||
let mut cands = remaining & !s;
|
||||
while cands != 0 {
|
||||
let j = cands.trailing_zeros() as usize;
|
||||
cands &= cands - 1;
|
||||
let add = t.ancestor_incl[j] & remaining & !s;
|
||||
if add == 0 {
|
||||
continue;
|
||||
}
|
||||
let (df, dv) = totals(add, &t.fee_of, &t.vsize_of);
|
||||
let nf = f + df;
|
||||
let nv = v + dv;
|
||||
let nrate = FeeRate::from((nf, nv));
|
||||
if nrate <= rate {
|
||||
continue;
|
||||
}
|
||||
if picked.is_none_or(|(_, _, _, prate)| nrate > prate) {
|
||||
picked = Some((add, nf, nv, nrate));
|
||||
}
|
||||
}
|
||||
match picked {
|
||||
Some((add, nf, nv, nrate)) => {
|
||||
s |= add;
|
||||
f = nf;
|
||||
v = nv;
|
||||
rate = nrate;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
if best.0 == 0 || rate > best_rate {
|
||||
best = (s, f, v);
|
||||
best_rate = rate;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Single-pass stack merge: for each incoming chunk, merge it into
|
||||
/// the stack top while the merge would raise the top's feerate, then
|
||||
/// push. O(n) total regardless of how many merges cascade.
|
||||
fn canonicalize(chunks: Vec<ChunkMask>) -> Vec<ChunkMask> {
|
||||
let mut out: Vec<ChunkMask> = Vec::with_capacity(chunks.len());
|
||||
for mut cur in chunks {
|
||||
while let Some(top) = out.last() {
|
||||
if cur.fee_rate() <= top.fee_rate() {
|
||||
break;
|
||||
}
|
||||
let prev = out.pop().unwrap();
|
||||
cur = ChunkMask {
|
||||
mask: prev.mask | cur.mask,
|
||||
fee: prev.fee + cur.fee,
|
||||
vsize: prev.vsize + cur.vsize,
|
||||
};
|
||||
}
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn totals(mask: u128, fee_of: &[Sats], vsize_of: &[VSize]) -> (Sats, VSize) {
|
||||
let mut f = Sats::ZERO;
|
||||
let mut v = VSize::default();
|
||||
let mut bits = mask;
|
||||
while bits != 0 {
|
||||
let i = bits.trailing_zeros() as usize;
|
||||
f += fee_of[i];
|
||||
v += vsize_of[i];
|
||||
bits &= bits - 1;
|
||||
}
|
||||
(f, v)
|
||||
}
|
||||
|
||||
/// Per-cluster precomputed bitmasks and lookups, shared across every
|
||||
/// chunk-extraction iteration. Built once in `linearize`.
|
||||
struct Tables {
|
||||
n: usize,
|
||||
/// Bitmask with one bit set per node (i.e. `(1 << n) - 1`).
|
||||
all: u128,
|
||||
/// `parents_mask[i]` = bits set for direct parents of node `i`.
|
||||
parents_mask: Vec<u128>,
|
||||
/// `ancestor_incl[i]` = bits set for `i` and all ancestors.
|
||||
ancestor_incl: Vec<u128>,
|
||||
fee_of: Vec<Sats>,
|
||||
vsize_of: Vec<VSize>,
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
/// Single pass over nodes (in topological order, so each parent's
|
||||
/// `ancestor_incl` is ready before the child reads it): build
|
||||
/// parent-bit masks, ancestor closures, and pick out fee/vsize.
|
||||
fn build<I>(nodes: &[ClusterNode<I>]) -> Self {
|
||||
let n = nodes.len();
|
||||
let mut parents_mask: Vec<u128> = vec![0; n];
|
||||
let mut ancestor_incl: Vec<u128> = vec![0; n];
|
||||
let mut fee_of: Vec<Sats> = Vec::with_capacity(n);
|
||||
let mut vsize_of: Vec<VSize> = Vec::with_capacity(n);
|
||||
for (vi, node) in nodes.iter().enumerate() {
|
||||
let mut par = 0u128;
|
||||
let mut acc = 1u128 << vi;
|
||||
for &p in &node.parents {
|
||||
par |= 1u128 << p.inner();
|
||||
acc |= ancestor_incl[p.as_usize()];
|
||||
}
|
||||
parents_mask[vi] = par;
|
||||
ancestor_incl[vi] = acc;
|
||||
fee_of.push(node.fee);
|
||||
vsize_of.push(node.vsize);
|
||||
}
|
||||
Self {
|
||||
n,
|
||||
all: (1u128 << n) - 1,
|
||||
parents_mask,
|
||||
ancestor_incl,
|
||||
fee_of,
|
||||
vsize_of,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-iteration immutable bundle for the brute-force recursion.
|
||||
/// Keeping it small lets `recurse` stay at four moving args.
|
||||
struct Ctx<'a> {
|
||||
tables: &'a Tables,
|
||||
remaining: u128,
|
||||
}
|
||||
@@ -1,246 +1,123 @@
|
||||
//! CPFP (Child Pays For Parent) cluster reasoning for live mempool
|
||||
//! transactions. Cluster scope is the seed's projected block: txs in
|
||||
//! other projected blocks share no mining fate with the seed, so
|
||||
//! including them in `effectiveFeePerVsize` would be misleading.
|
||||
//! CPFP (Child Pays For Parent) cluster reasoning.
|
||||
//!
|
||||
//! Confirmed-tx CPFP (the same-block connected component on the
|
||||
//! chain) lives in `brk_query`, since it reads indexer/computer vecs.
|
||||
//! Two consumers, one shared converter:
|
||||
//!
|
||||
//! - **Mempool path** (`Mempool::cpfp_info`): looks up the seed in the
|
||||
//! `Snapshot.cluster_of` map, which already contains the SFL-linearized
|
||||
//! connected component built once per snapshot cycle. No graph walk,
|
||||
//! no SFL recomputation.
|
||||
//! - **Confirmed path** (`brk_query::Query::confirmed_cpfp`): builds a
|
||||
//! `Cluster` from same-block parent/child edges on demand.
|
||||
//!
|
||||
//! Both feed `Cluster::to_cpfp_info`, which walks the cluster from the
|
||||
//! seed (parents → ancestors, topo-sweep → descendants), reads the seed's
|
||||
//! chunk feerate as `effectiveFeePerVsize`, and emits the wire shape.
|
||||
//!
|
||||
//! The cluster spans the full connected component (matches mempool.space);
|
||||
//! we don't scope to the seed's projected block, which would drop info
|
||||
//! when a cluster crosses the projection floor.
|
||||
|
||||
use brk_types::{
|
||||
CpfpCluster, CpfpClusterChunk, CpfpClusterTx, CpfpClusterTxIndex, CpfpEntry, CpfpInfo, FeeRate,
|
||||
TxidPrefix, VSize, Weight,
|
||||
CpfpCluster, CpfpClusterChunk, CpfpClusterTx, CpfpEntry, CpfpInfo, FeeRate, SigOps, TxidPrefix,
|
||||
VSize,
|
||||
};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::steps::rebuilder::linearize::{
|
||||
LocalIdx, cluster::Cluster, cluster_node::ClusterNode, sfl::Sfl,
|
||||
};
|
||||
use crate::stores::{EntryPool, TxIndex};
|
||||
use crate::{Mempool, TxEntry};
|
||||
|
||||
/// Cap matches Bitcoin Core's default mempool ancestor/descendant
|
||||
/// chain limits and `confirmed_cpfp`'s cap.
|
||||
const MAX: usize = 25;
|
||||
|
||||
impl Mempool {
|
||||
/// CPFP info for a live mempool tx, scoped to the seed's projected
|
||||
/// block. Returns `None` if the tx is not in the mempool, so
|
||||
/// callers can fall through to the confirmed path. Returns `Some`
|
||||
/// with empty arms if the tx is in the mempool but below the
|
||||
/// projection floor (no projected block to share fate with).
|
||||
pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option<CpfpInfo> {
|
||||
let snapshot = self.snapshot();
|
||||
let entries = self.entries();
|
||||
let txs = self.txs();
|
||||
let seed_idx = entries.idx_of(prefix)?;
|
||||
let seed = entries.slot(seed_idx)?;
|
||||
|
||||
let mut ancestor_idxs: Vec<TxIndex> = Vec::new();
|
||||
let mut descendant_idxs: Vec<TxIndex> = Vec::new();
|
||||
let mut ancestors: Vec<CpfpEntry> = Vec::new();
|
||||
let mut descendants: Vec<CpfpEntry> = Vec::new();
|
||||
|
||||
if let Some(seed_block) = snapshot.block_of(seed_idx) {
|
||||
let mut visited: FxHashSet<TxidPrefix> = FxHashSet::default();
|
||||
visited.insert(*prefix);
|
||||
let mut stack: Vec<TxidPrefix> = seed.depends.iter().copied().collect();
|
||||
while let Some(p) = stack.pop() {
|
||||
if ancestors.len() >= MAX {
|
||||
break;
|
||||
}
|
||||
if !visited.insert(p) {
|
||||
continue;
|
||||
}
|
||||
let Some(idx) = entries.idx_of(&p) else { continue };
|
||||
if snapshot.block_of(idx) != Some(seed_block) {
|
||||
continue;
|
||||
}
|
||||
let Some(anc) = entries.slot(idx) else { continue };
|
||||
ancestor_idxs.push(idx);
|
||||
ancestors.push(to_entry(anc));
|
||||
stack.extend(anc.depends.iter().copied());
|
||||
}
|
||||
|
||||
let mut desc_set: FxHashSet<TxidPrefix> = FxHashSet::default();
|
||||
desc_set.insert(*prefix);
|
||||
for &i in &snapshot.blocks[seed_block.as_usize()] {
|
||||
if descendants.len() >= MAX {
|
||||
break;
|
||||
}
|
||||
let Some(e) = entries.slot(i) else { continue };
|
||||
if !e.depends.iter().any(|d| desc_set.contains(d)) {
|
||||
continue;
|
||||
}
|
||||
desc_set.insert(e.txid_prefix());
|
||||
descendant_idxs.push(i);
|
||||
descendants.push(to_entry(e));
|
||||
}
|
||||
}
|
||||
use crate::Mempool;
|
||||
use crate::cluster::{Cluster, ClusterRef, LocalIdx};
|
||||
|
||||
impl<I> Cluster<I> {
|
||||
/// Wire-shape `CpfpInfo` for `seed` inside this cluster. `txid` and
|
||||
/// `weight` come straight off each `ClusterNode`, so the converter
|
||||
/// is self-contained — no parallel `members` slice required.
|
||||
pub fn to_cpfp_info(&self, seed: LocalIdx, sigops: SigOps) -> CpfpInfo {
|
||||
let descendants = self.walk_descendants(seed);
|
||||
let best_descendant = descendants
|
||||
.iter()
|
||||
.max_by_key(|e| FeeRate::from((e.fee, e.weight)))
|
||||
.cloned();
|
||||
let seed_node = &self.nodes[seed.as_usize()];
|
||||
|
||||
let sigops = txs.get(&seed.txid).map(|tx| {
|
||||
// Bitcoin Core's `total_sigop_cost` is the segwit-weighted sigop
|
||||
// count (legacy * 4 + segwit * 1), divided by 5 to match
|
||||
// mempool.space's reported `sigops`. Mempool.space converts
|
||||
// back to count via `sigopcost / 5`.
|
||||
u32::try_from(tx.total_sigop_cost / 5).unwrap_or(u32::MAX)
|
||||
});
|
||||
let vsize = VSize::from(seed_node.weight);
|
||||
let adjusted_vsize = sigops.adjust_vsize(vsize);
|
||||
|
||||
// mempool.space's adjustedVsize = max(vsize, sigops * 5).
|
||||
let adjusted_vsize = match sigops {
|
||||
Some(s) => VSize::from(u64::from(seed.vsize).max(u64::from(s) * 5)),
|
||||
None => seed.vsize,
|
||||
};
|
||||
|
||||
let cluster = build_cluster(seed_idx, &ancestor_idxs, &descendant_idxs, &entries);
|
||||
|
||||
// mempool.space sets effectiveFeePerVsize to the seed's chunk feerate
|
||||
// when the cluster is known, falls back to the seed's own rate.
|
||||
let effective = cluster
|
||||
.as_ref()
|
||||
.and_then(|c| c.chunks.get(c.chunk_index as usize))
|
||||
.map(|chunk| chunk.feerate)
|
||||
.unwrap_or_else(|| seed.fee_rate());
|
||||
|
||||
Some(CpfpInfo {
|
||||
ancestors,
|
||||
CpfpInfo {
|
||||
ancestors: self.walk_ancestors(seed),
|
||||
best_descendant,
|
||||
descendants,
|
||||
effective_fee_per_vsize: Some(effective),
|
||||
effective_fee_per_vsize: self.chunk_of(seed).fee_rate(),
|
||||
sigops,
|
||||
fee: Some(seed.fee),
|
||||
adjusted_vsize: Some(adjusted_vsize),
|
||||
cluster,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn to_entry(e: &TxEntry) -> CpfpEntry {
|
||||
CpfpEntry {
|
||||
txid: e.txid.clone(),
|
||||
weight: Weight::from(e.vsize),
|
||||
fee: e.fee,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the cluster output: seed + ancestors + descendants in topological
|
||||
/// order, with parent indexes inside the cluster, plus SFL-linearized chunks.
|
||||
fn build_cluster(
|
||||
seed_idx: TxIndex,
|
||||
ancestor_idxs: &[TxIndex],
|
||||
descendant_idxs: &[TxIndex],
|
||||
entries: &EntryPool,
|
||||
) -> Option<CpfpCluster> {
|
||||
let mut ordered: Vec<TxIndex> = Vec::with_capacity(ancestor_idxs.len() + 1 + descendant_idxs.len());
|
||||
ordered.extend(ancestor_idxs.iter().copied());
|
||||
ordered.push(seed_idx);
|
||||
ordered.extend(descendant_idxs.iter().copied());
|
||||
|
||||
let pool: Vec<&TxEntry> = ordered.iter().filter_map(|&i| entries.slot(i)).collect();
|
||||
if pool.len() != ordered.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix_to_local: FxHashMap<TxidPrefix, LocalIdx> = pool
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (e.txid_prefix(), i as LocalIdx))
|
||||
.collect();
|
||||
|
||||
let mut children_of: Vec<SmallVec<[LocalIdx; 2]>> = vec![SmallVec::new(); pool.len()];
|
||||
let parents_of: Vec<SmallVec<[LocalIdx; 2]>> = pool
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| {
|
||||
let parents: SmallVec<[LocalIdx; 2]> = e
|
||||
.depends
|
||||
.iter()
|
||||
.filter_map(|p| prefix_to_local.get(p).copied())
|
||||
.collect();
|
||||
for &p in &parents {
|
||||
children_of[p as usize].push(i as LocalIdx);
|
||||
}
|
||||
parents
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cluster_nodes: Vec<ClusterNode> = pool
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| ClusterNode {
|
||||
tx_index: ordered[i],
|
||||
fee: e.fee,
|
||||
vsize: e.vsize,
|
||||
parents: parents_of[i].clone(),
|
||||
children: children_of[i].clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cluster = Cluster::new(cluster_nodes);
|
||||
|
||||
// Re-order pool so parents come before children (mempool.space convention).
|
||||
// `topo_rank[i]` gives the position of local index `i` in topological order.
|
||||
let mut local_to_topo: Vec<usize> = (0..pool.len()).collect();
|
||||
local_to_topo.sort_unstable_by_key(|&i| cluster.topo_rank[i]);
|
||||
let topo_to_local: Vec<usize> = {
|
||||
let mut v = vec![0usize; pool.len()];
|
||||
for (topo_pos, &local) in local_to_topo.iter().enumerate() {
|
||||
v[local] = topo_pos;
|
||||
fee: seed_node.fee,
|
||||
vsize,
|
||||
adjusted_vsize,
|
||||
cluster: self.cluster_view(seed),
|
||||
}
|
||||
v
|
||||
};
|
||||
}
|
||||
|
||||
let topo_idx = |local: usize| CpfpClusterTxIndex::from(topo_to_local[local] as u32);
|
||||
|
||||
let txs: Vec<CpfpClusterTx> = local_to_topo
|
||||
.iter()
|
||||
.map(|&local| {
|
||||
let e = pool[local];
|
||||
let parents: Vec<CpfpClusterTxIndex> = parents_of[local]
|
||||
.iter()
|
||||
.map(|&p| topo_idx(p as usize))
|
||||
.collect();
|
||||
CpfpClusterTx {
|
||||
txid: e.txid.clone(),
|
||||
fee: e.fee,
|
||||
weight: Weight::from(e.vsize),
|
||||
parents,
|
||||
/// DFS up the parent edges from `seed`, exclusive. Cluster size is
|
||||
/// capped at 128 by SFL, so a `u128` covers the visited set.
|
||||
fn walk_ancestors(&self, seed: LocalIdx) -> Vec<CpfpEntry> {
|
||||
let mut visited = 1u128 << seed.inner();
|
||||
let mut out: Vec<CpfpEntry> = Vec::new();
|
||||
let mut stack: Vec<LocalIdx> = self.nodes[seed.as_usize()].parents.to_vec();
|
||||
while let Some(idx) = stack.pop() {
|
||||
let b = 1u128 << idx.inner();
|
||||
if visited & b != 0 {
|
||||
continue;
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
visited |= b;
|
||||
let node = &self.nodes[idx.as_usize()];
|
||||
out.push(CpfpEntry::from(node));
|
||||
stack.extend(node.parents.iter().copied());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
let raw_chunks = Sfl::linearize(&cluster);
|
||||
let chunks: Vec<CpfpClusterChunk> = raw_chunks
|
||||
.iter()
|
||||
.map(|chunk| {
|
||||
let mut chunk_txs: Vec<CpfpClusterTxIndex> = chunk
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|&local| topo_idx(local as usize))
|
||||
.collect();
|
||||
chunk_txs.sort_unstable();
|
||||
CpfpClusterChunk {
|
||||
txs: chunk_txs,
|
||||
feerate: chunk.fee_rate(),
|
||||
/// Forward sweep over the topo-ordered tail after `seed`. A node is
|
||||
/// a descendant iff any of its parents is `seed` or already-reached.
|
||||
/// Nodes before `seed` can't reach it, so they're skipped entirely.
|
||||
fn walk_descendants(&self, seed: LocalIdx) -> Vec<CpfpEntry> {
|
||||
let seed_pos = seed.as_usize();
|
||||
let mut reachable = 1u128 << seed.inner();
|
||||
let mut out: Vec<CpfpEntry> = Vec::new();
|
||||
for (i, node) in self.nodes.iter().enumerate().skip(seed_pos + 1) {
|
||||
if node.parents.iter().any(|&p| reachable & (1u128 << p.inner()) != 0) {
|
||||
reachable |= 1u128 << i;
|
||||
out.push(CpfpEntry::from(node));
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
let seed_local = *prefix_to_local.get(&entries.slot(seed_idx)?.txid_prefix())?;
|
||||
let seed_topo = topo_idx(seed_local as usize);
|
||||
let chunk_index = chunks
|
||||
.iter()
|
||||
.position(|c| c.txs.contains(&seed_topo))
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
Some(CpfpCluster {
|
||||
txs,
|
||||
chunks,
|
||||
chunk_index,
|
||||
})
|
||||
/// Wire-shape `CpfpCluster`. Cluster nodes are stored in topological
|
||||
/// order, so `LocalIdx` maps directly onto `CpfpClusterTxIndex`
|
||||
/// without a permutation lookup.
|
||||
fn cluster_view(&self, seed: LocalIdx) -> CpfpCluster {
|
||||
CpfpCluster {
|
||||
txs: self.nodes.iter().map(CpfpClusterTx::from).collect(),
|
||||
chunks: self.chunks.iter().map(CpfpClusterChunk::from).collect(),
|
||||
chunk_index: self.node_to_chunk[seed.as_usize()].inner(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mempool {
|
||||
/// CPFP info for a live mempool tx. Returns `None` only when the
|
||||
/// tx isn't in the mempool, so callers can fall through to the
|
||||
/// confirmed path.
|
||||
pub fn cpfp_info(&self, prefix: &TxidPrefix) -> Option<CpfpInfo> {
|
||||
let snapshot = self.snapshot();
|
||||
let seed_idx = self.entries().idx_of(prefix)?;
|
||||
let ClusterRef { cluster_id, local: seed_local } = snapshot.cluster_of(seed_idx)?;
|
||||
let cluster = &snapshot.clusters[cluster_id.as_usize()];
|
||||
let seed_txid = &cluster.nodes[seed_local.as_usize()].txid;
|
||||
|
||||
let sigops = self
|
||||
.txs()
|
||||
.get(seed_txid)
|
||||
.map(|tx| tx.total_sigop_cost)
|
||||
.unwrap_or(SigOps::ZERO);
|
||||
|
||||
Some(cluster.to_cpfp_info(seed_local, sigops))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use brk_types::{AddrBytes, MempoolInfo, OutpointPrefix, TxOut, Txid, TxidPrefix,
|
||||
use parking_lot::RwLockReadGuard;
|
||||
use tracing::error;
|
||||
|
||||
pub mod cluster;
|
||||
mod cpfp;
|
||||
pub(crate) mod steps;
|
||||
pub(crate) mod stores;
|
||||
@@ -28,7 +29,7 @@ pub(crate) mod stores;
|
||||
mod tests;
|
||||
|
||||
use steps::{Applier, Fetcher, Preparer, Rebuilder, Resolver};
|
||||
pub use steps::{BlkIndex, BlockStats, RecommendedFees, Snapshot, TxEntry, TxRemoval};
|
||||
pub use steps::{BlockStats, RecommendedFees, Snapshot, TxEntry, TxRemoval};
|
||||
use stores::{AddrTracker, MempoolState};
|
||||
pub use stores::{EntryPool, TxGraveyard, TxStore, TxTombstone};
|
||||
|
||||
@@ -59,6 +60,14 @@ impl Mempool {
|
||||
self.0.rebuilder.snapshot()
|
||||
}
|
||||
|
||||
pub fn rebuild_count(&self) -> u64 {
|
||||
self.0.rebuilder.rebuild_count()
|
||||
}
|
||||
|
||||
pub fn skip_counts(&self) -> (u64, u64) {
|
||||
self.0.rebuilder.skip_counts()
|
||||
}
|
||||
|
||||
pub fn fees(&self) -> RecommendedFees {
|
||||
self.snapshot().fees.clone()
|
||||
}
|
||||
@@ -85,7 +94,7 @@ impl Mempool {
|
||||
let entries = self.0.state.entries.read();
|
||||
let outpoint_spends = self.0.state.outpoint_spends.read();
|
||||
let idx = outpoint_spends.get(&key)?;
|
||||
let spender_txid = entries.slot(idx)?.txid.clone();
|
||||
let spender_txid = entries.slot(idx)?.txid;
|
||||
let spender_tx = txs.get(&spender_txid)?;
|
||||
let vin_pos = spender_tx
|
||||
.input
|
||||
@@ -139,7 +148,11 @@ impl Mempool {
|
||||
|
||||
/// One sync cycle: fetch, prepare, apply, resolve, maybe rebuild.
|
||||
pub fn update(&self) -> Result<()> {
|
||||
let Inner { client, state, rebuilder } = &*self.0;
|
||||
let Inner {
|
||||
client,
|
||||
state,
|
||||
rebuilder,
|
||||
} = &*self.0;
|
||||
|
||||
let fetched = Fetcher::fetch(client, state)?;
|
||||
let pulled = Preparer::prepare(fetched, state);
|
||||
@@ -149,4 +162,8 @@ impl Mempool {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &MempoolState {
|
||||
&self.0.state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Applier {
|
||||
let Some((idx, entry)) = s.entries.remove(prefix) else {
|
||||
return;
|
||||
};
|
||||
let txid = entry.txid.clone();
|
||||
let txid = entry.txid;
|
||||
let Some(tx) = s.txs.remove(&txid) else {
|
||||
return;
|
||||
};
|
||||
@@ -71,7 +71,7 @@ impl Applier {
|
||||
fn publish_one(s: &mut LockedState, tx: Transaction, entry: TxEntry) -> (Txid, Transaction) {
|
||||
s.info.add(&tx, entry.fee);
|
||||
s.addrs.add_tx(&tx, &entry.txid);
|
||||
let txid = entry.txid.clone();
|
||||
let txid = entry.txid;
|
||||
let idx = s.entries.insert(entry);
|
||||
s.outpoint_spends.insert_spends(&tx, idx);
|
||||
(txid, tx)
|
||||
|
||||
@@ -72,7 +72,7 @@ impl Fetcher {
|
||||
.iter()
|
||||
.filter(|info| !known.contains(&info.txid) && !graveyard.contains(&info.txid))
|
||||
.take(MAX_TX_FETCHES_PER_CYCLE)
|
||||
.map(|info| info.txid.clone())
|
||||
.map(|info| info.txid)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@ mod resolver;
|
||||
pub use applier::Applier;
|
||||
pub use fetcher::Fetcher;
|
||||
pub use preparer::{Preparer, TxEntry, TxRemoval};
|
||||
pub use rebuilder::{BlkIndex, BlockStats, Rebuilder, RecommendedFees, Snapshot};
|
||||
pub use rebuilder::{BlockStats, Rebuilder, RecommendedFees, Snapshot};
|
||||
pub use resolver::Resolver;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use std::mem;
|
||||
|
||||
use brk_rpc::RawTx;
|
||||
use brk_types::{MempoolEntryInfo, Transaction, TxIn, TxOut, TxStatus, Txid, Vout};
|
||||
use brk_types::{MempoolEntryInfo, SigOps, Transaction, TxIn, TxOut, TxStatus, Txid, Vout};
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::{TxTombstone, stores::TxStore};
|
||||
@@ -52,10 +52,10 @@ impl TxAddition {
|
||||
.collect();
|
||||
let mut tx = Transaction {
|
||||
index: None,
|
||||
txid: info.txid.clone(),
|
||||
txid: info.txid,
|
||||
version: raw.tx.version.into(),
|
||||
total_sigop_cost: 0,
|
||||
weight: info.weight.into(),
|
||||
total_sigop_cost: SigOps::ZERO,
|
||||
weight: info.weight,
|
||||
lock_time: raw.tx.lock_time.into(),
|
||||
total_size,
|
||||
fee: info.fee,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use brk_types::{FeeRate, MempoolEntryInfo, Sats, Timestamp, Txid, TxidPrefix, VSize};
|
||||
use brk_types::{FeeRate, MempoolEntryInfo, Sats, Timestamp, Txid, TxidPrefix, VSize, Weight};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// A mempool transaction entry.
|
||||
@@ -12,6 +12,7 @@ pub struct TxEntry {
|
||||
pub txid: Txid,
|
||||
pub fee: Sats,
|
||||
pub vsize: VSize,
|
||||
pub weight: Weight,
|
||||
/// Serialized tx size in bytes (witness + non-witness), from the raw tx.
|
||||
pub size: u64,
|
||||
/// Parent txid prefixes (most txs have 0-2 parents).
|
||||
@@ -28,9 +29,10 @@ pub struct TxEntry {
|
||||
impl TxEntry {
|
||||
pub(super) fn new(info: &MempoolEntryInfo, size: u64, rbf: bool) -> Self {
|
||||
Self {
|
||||
txid: info.txid.clone(),
|
||||
txid: info.txid,
|
||||
fee: info.fee,
|
||||
vsize: VSize::from(info.vsize),
|
||||
vsize: info.vsize,
|
||||
weight: info.weight,
|
||||
size,
|
||||
depends: info.depends.iter().map(TxidPrefix::from).collect(),
|
||||
first_seen: info.first_seen,
|
||||
|
||||
@@ -45,7 +45,7 @@ impl TxRemoval {
|
||||
fn find_removal(tx: &Transaction, spent_by: &SpentBy) -> Self {
|
||||
tx.input
|
||||
.iter()
|
||||
.find_map(|i| spent_by.get(&(i.txid.clone(), i.vout)).cloned())
|
||||
.find_map(|i| spent_by.get(&(i.txid, i.vout)).cloned())
|
||||
.map_or(Self::Vanished, |by| Self::Replaced { by })
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ impl TxRemoval {
|
||||
for addition in added {
|
||||
if let TxAddition::Fresh { tx, .. } = addition {
|
||||
for txin in &tx.input {
|
||||
spent_by.insert((txin.txid.clone(), txin.vout), tx.txid.clone());
|
||||
spent_by.insert((txin.txid, txin.vout), tx.txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
157
crates/brk_mempool/src/steps/rebuilder/clusters.rs
Normal file
157
crates/brk_mempool/src/steps/rebuilder/clusters.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! Build the cluster forest for a snapshot directly from the live
|
||||
//! `EntryPool`. One traversal indexes live entries, builds parent
|
||||
//! edges, floods the connected components, and constructs each
|
||||
//! `Cluster<TxIndex>` (which mirrors child edges and runs SFL
|
||||
//! internally).
|
||||
//!
|
||||
//! Returns the cluster forest plus a `tx_index → ClusterRef` reverse
|
||||
//! map for O(1) lookup back from `EntryPool` slot to cluster position.
|
||||
|
||||
use brk_types::TxidPrefix;
|
||||
use rustc_hash::{FxBuildHasher, FxHashMap};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::TxEntry;
|
||||
use crate::cluster::{Cluster, ClusterId, ClusterNode, ClusterRef, LocalIdx};
|
||||
use crate::stores::TxIndex;
|
||||
|
||||
/// Per-live-entry indexing position in the parents/children adjacency
|
||||
/// arrays below. Local to this module; not exposed.
|
||||
type Pos = u32;
|
||||
|
||||
pub fn build_clusters(
|
||||
entries: &[Option<TxEntry>],
|
||||
) -> (Vec<Cluster<TxIndex>>, Vec<Option<ClusterRef>>) {
|
||||
let live = index_live(entries);
|
||||
if live.is_empty() {
|
||||
return (Vec::new(), vec![None; entries.len()]);
|
||||
}
|
||||
|
||||
let parents = build_parent_edges(&live);
|
||||
let children = mirror_children(&parents);
|
||||
|
||||
let mut seen = vec![false; live.len()];
|
||||
let mut clusters: Vec<Cluster<TxIndex>> = Vec::new();
|
||||
let mut cluster_of: Vec<Option<ClusterRef>> = vec![None; entries.len()];
|
||||
let mut stack: Vec<Pos> = Vec::new();
|
||||
// Reused across components: `local_of[pos]` is `Some(local)` while
|
||||
// we're building the current cluster, `None` otherwise. Cleared by
|
||||
// walking each cluster's members at the end of its iteration.
|
||||
let mut local_of: Vec<Option<LocalIdx>> = vec![None; live.len()];
|
||||
|
||||
for start in 0..live.len() {
|
||||
if seen[start] {
|
||||
continue;
|
||||
}
|
||||
let members = flood_component(start as Pos, &parents, &children, &mut seen, &mut stack);
|
||||
for (i, &pos) in members.iter().enumerate() {
|
||||
local_of[pos as usize] = Some(LocalIdx::from(i));
|
||||
}
|
||||
|
||||
let cluster_id = ClusterId::from(clusters.len());
|
||||
let cluster = build_cluster(&live, &parents, &members, &local_of);
|
||||
for (local_pos, node) in cluster.nodes.iter().enumerate() {
|
||||
cluster_of[node.id.as_usize()] = Some(ClusterRef {
|
||||
cluster_id,
|
||||
local: LocalIdx::from(local_pos),
|
||||
});
|
||||
}
|
||||
clusters.push(cluster);
|
||||
|
||||
for &pos in &members {
|
||||
local_of[pos as usize] = None;
|
||||
}
|
||||
}
|
||||
|
||||
(clusters, cluster_of)
|
||||
}
|
||||
|
||||
fn flood_component(
|
||||
start: Pos,
|
||||
parents: &[SmallVec<[Pos; 4]>],
|
||||
children: &[SmallVec<[Pos; 8]>],
|
||||
seen: &mut [bool],
|
||||
stack: &mut Vec<Pos>,
|
||||
) -> Vec<Pos> {
|
||||
let mut members: Vec<Pos> = Vec::new();
|
||||
stack.clear();
|
||||
stack.push(start);
|
||||
seen[start as usize] = true;
|
||||
|
||||
while let Some(pos) = stack.pop() {
|
||||
members.push(pos);
|
||||
for &n in parents[pos as usize].iter().chain(children[pos as usize].iter()) {
|
||||
if !seen[n as usize] {
|
||||
seen[n as usize] = true;
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
members
|
||||
}
|
||||
|
||||
/// `local_of` is set only for `Pos`es in this cluster, so each parent's
|
||||
/// `LocalIdx` is one direct lookup (cross-cluster parents return `None`
|
||||
/// and get filtered).
|
||||
fn build_cluster(
|
||||
live: &[(TxIndex, &TxEntry)],
|
||||
parents: &[SmallVec<[Pos; 4]>],
|
||||
members: &[Pos],
|
||||
local_of: &[Option<LocalIdx>],
|
||||
) -> Cluster<TxIndex> {
|
||||
let cluster_nodes: Vec<ClusterNode<TxIndex>> = members
|
||||
.iter()
|
||||
.map(|&pos| {
|
||||
let (tx_index, entry) = live[pos as usize];
|
||||
ClusterNode {
|
||||
id: tx_index,
|
||||
txid: entry.txid,
|
||||
fee: entry.fee,
|
||||
vsize: entry.vsize,
|
||||
weight: entry.weight,
|
||||
parents: parents[pos as usize]
|
||||
.iter()
|
||||
.filter_map(|&p| local_of[p as usize])
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Cluster::new(cluster_nodes)
|
||||
}
|
||||
|
||||
fn index_live(entries: &[Option<TxEntry>]) -> Vec<(TxIndex, &TxEntry)> {
|
||||
entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, opt)| opt.as_ref().map(|e| (TxIndex::from(i), e)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_parent_edges(live: &[(TxIndex, &TxEntry)]) -> Vec<SmallVec<[Pos; 4]>> {
|
||||
let mut prefix_to_pos: FxHashMap<TxidPrefix, Pos> =
|
||||
FxHashMap::with_capacity_and_hasher(live.len(), FxBuildHasher);
|
||||
for (i, (_, entry)) in live.iter().enumerate() {
|
||||
prefix_to_pos.insert(entry.txid_prefix(), i as Pos);
|
||||
}
|
||||
live.iter()
|
||||
.map(|(_, entry)| {
|
||||
entry
|
||||
.depends
|
||||
.iter()
|
||||
.filter_map(|p| prefix_to_pos.get(p).copied())
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mirror_children(parents: &[SmallVec<[Pos; 4]>]) -> Vec<SmallVec<[Pos; 8]>> {
|
||||
let mut children: Vec<SmallVec<[Pos; 8]>> =
|
||||
(0..parents.len()).map(|_| SmallVec::new()).collect();
|
||||
for (child_pos, ps) in parents.iter().enumerate() {
|
||||
for &p in ps {
|
||||
children[p as usize].push(child_pos as Pos);
|
||||
}
|
||||
}
|
||||
children
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
mod pool_index;
|
||||
mod tx_node;
|
||||
|
||||
pub use pool_index::PoolIndex;
|
||||
pub use tx_node::TxNode;
|
||||
|
||||
use brk_types::TxidPrefix;
|
||||
use rustc_hash::{FxBuildHasher, FxHashMap};
|
||||
|
||||
use crate::{TxEntry, stores::TxIndex};
|
||||
|
||||
pub struct Graph;
|
||||
|
||||
impl Graph {
|
||||
/// Build the dependency graph for the live mempool.
|
||||
///
|
||||
/// Nodes are indexed by `PoolIndex`; the caller indexes with
|
||||
/// `idx.as_usize()`.
|
||||
pub fn build(entries: &[Option<TxEntry>]) -> Vec<TxNode> {
|
||||
let (live, prefix_to_pool) = Self::index_live(entries);
|
||||
if live.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut nodes = Self::build_parent_edges(&live, &prefix_to_pool);
|
||||
Self::mirror_child_edges(&mut nodes);
|
||||
nodes
|
||||
}
|
||||
|
||||
/// First pass: collect live entries and map their prefixes to pool
|
||||
/// indexes. Done before parent edges so a parent appearing later in
|
||||
/// slot order than its child is still resolvable.
|
||||
fn index_live(
|
||||
entries: &[Option<TxEntry>],
|
||||
) -> (Vec<(TxIndex, &TxEntry)>, FxHashMap<TxidPrefix, PoolIndex>) {
|
||||
let mut live: Vec<(TxIndex, &TxEntry)> = Vec::with_capacity(entries.len());
|
||||
let mut prefix_to_pool: FxHashMap<TxidPrefix, PoolIndex> =
|
||||
FxHashMap::with_capacity_and_hasher(entries.len(), FxBuildHasher);
|
||||
for (i, opt) in entries.iter().enumerate() {
|
||||
if let Some(e) = opt.as_ref() {
|
||||
prefix_to_pool.insert(e.txid_prefix(), PoolIndex::from(live.len()));
|
||||
live.push((TxIndex::from(i), e));
|
||||
}
|
||||
}
|
||||
(live, prefix_to_pool)
|
||||
}
|
||||
|
||||
fn build_parent_edges(
|
||||
live: &[(TxIndex, &TxEntry)],
|
||||
prefix_to_pool: &FxHashMap<TxidPrefix, PoolIndex>,
|
||||
) -> Vec<TxNode> {
|
||||
live.iter()
|
||||
.map(|(tx_index, entry)| {
|
||||
let mut node = TxNode::new(*tx_index, entry.fee, entry.vsize);
|
||||
for parent_prefix in &entry.depends {
|
||||
if let Some(&parent_pool_idx) = prefix_to_pool.get(parent_prefix) {
|
||||
node.parents.push(parent_pool_idx);
|
||||
}
|
||||
}
|
||||
node
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mirror_child_edges(nodes: &mut [TxNode]) {
|
||||
for i in 0..nodes.len() {
|
||||
let plen = nodes[i].parents.len();
|
||||
for j in 0..plen {
|
||||
let parent_idx = nodes[i].parents[j].as_usize();
|
||||
nodes[parent_idx].children.push(PoolIndex::from(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/// Index into the temporary pool used during block building.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct PoolIndex(u32);
|
||||
|
||||
impl PoolIndex {
|
||||
#[inline]
|
||||
pub fn as_usize(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for PoolIndex {
|
||||
#[inline]
|
||||
fn from(value: usize) -> Self {
|
||||
Self(value as u32)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
use brk_types::{Sats, VSize};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::PoolIndex;
|
||||
use crate::stores::TxIndex;
|
||||
|
||||
/// Built fresh per block-building cycle, then discarded.
|
||||
pub struct TxNode {
|
||||
pub tx_index: TxIndex,
|
||||
pub fee: Sats,
|
||||
pub vsize: VSize,
|
||||
pub parents: SmallVec<[PoolIndex; 4]>,
|
||||
pub children: SmallVec<[PoolIndex; 8]>,
|
||||
}
|
||||
|
||||
impl TxNode {
|
||||
pub fn new(tx_index: TxIndex, fee: Sats, vsize: VSize) -> Self {
|
||||
Self {
|
||||
tx_index,
|
||||
fee,
|
||||
vsize,
|
||||
parents: SmallVec::new(),
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
use brk_types::{FeeRate, Sats, VSize};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::LocalIdx;
|
||||
|
||||
pub(crate) struct Chunk {
|
||||
pub(crate) nodes: SmallVec<[LocalIdx; 4]>,
|
||||
pub(crate) fee: Sats,
|
||||
pub(crate) vsize: VSize,
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
pub(super) fn from_mask(mask: u128, fee: Sats, vsize: VSize) -> Self {
|
||||
let mut nodes: SmallVec<[LocalIdx; 4]> = SmallVec::new();
|
||||
let mut bits = mask;
|
||||
while bits != 0 {
|
||||
nodes.push(bits.trailing_zeros() as LocalIdx);
|
||||
bits &= bits - 1;
|
||||
}
|
||||
Self { nodes, fee, vsize }
|
||||
}
|
||||
|
||||
pub(crate) fn fee_rate(&self) -> FeeRate {
|
||||
FeeRate::from((self.fee, self.vsize))
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use super::{ClusterNode, LocalIdx};
|
||||
|
||||
/// A connected component of the mempool graph, re-indexed locally.
|
||||
pub(crate) struct Cluster {
|
||||
pub(crate) nodes: Vec<ClusterNode>,
|
||||
/// Used during chunk emission to print txs parents-first.
|
||||
pub(crate) topo_rank: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Cluster {
|
||||
pub(crate) fn new(nodes: Vec<ClusterNode>) -> Self {
|
||||
let topo_rank = Self::kahn_topo_rank(&nodes);
|
||||
Self { nodes, topo_rank }
|
||||
}
|
||||
|
||||
fn kahn_topo_rank(nodes: &[ClusterNode]) -> Vec<u32> {
|
||||
let n = nodes.len();
|
||||
let mut indegree: Vec<u32> = nodes.iter().map(|n| n.parents.len() as u32).collect();
|
||||
let mut ready: Vec<LocalIdx> = (0..n as LocalIdx)
|
||||
.filter(|&i| indegree[i as usize] == 0)
|
||||
.collect();
|
||||
|
||||
let mut rank: Vec<u32> = vec![0; n];
|
||||
let mut position: u32 = 0;
|
||||
let mut head = 0;
|
||||
|
||||
while head < ready.len() {
|
||||
let v = ready[head];
|
||||
head += 1;
|
||||
rank[v as usize] = position;
|
||||
position += 1;
|
||||
for &c in &nodes[v as usize].children {
|
||||
indegree[c as usize] -= 1;
|
||||
if indegree[c as usize] == 0 {
|
||||
ready.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug_assert_eq!(position as usize, n, "cluster contained a cycle");
|
||||
rank
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
use brk_types::{Sats, VSize};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::stores::TxIndex;
|
||||
|
||||
use super::LocalIdx;
|
||||
|
||||
pub(crate) struct ClusterNode {
|
||||
pub(crate) tx_index: TxIndex,
|
||||
pub(crate) fee: Sats,
|
||||
pub(crate) vsize: VSize,
|
||||
pub(crate) parents: SmallVec<[LocalIdx; 2]>,
|
||||
pub(crate) children: SmallVec<[LocalIdx; 2]>,
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
//! Cluster-mempool linearization.
|
||||
//!
|
||||
//! Partitions the mempool dependency graph into connected components
|
||||
//! ("clusters"), linearizes each into chunks ordered by descending
|
||||
//! feerate, and emits the resulting chunks as `Package`s. The inner
|
||||
//! algorithm (see `sfl.rs`) is a topologically-closed-subset search,
|
||||
//! optimal for clusters up to 18 txs and near-optimal beyond that.
|
||||
|
||||
pub(crate) mod chunk;
|
||||
pub(crate) mod cluster;
|
||||
pub(crate) mod cluster_node;
|
||||
pub(crate) mod package;
|
||||
pub(crate) mod sfl;
|
||||
|
||||
pub use package::Package;
|
||||
|
||||
use rustc_hash::{FxBuildHasher, FxHashMap};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use cluster::Cluster;
|
||||
use cluster_node::ClusterNode;
|
||||
use sfl::Sfl;
|
||||
|
||||
use super::graph::{PoolIndex, TxNode};
|
||||
|
||||
pub(crate) type LocalIdx = u32;
|
||||
|
||||
pub struct Linearizer;
|
||||
|
||||
impl Linearizer {
|
||||
/// Order across clusters is unspecified: the partitioner re-sorts by
|
||||
/// fee rate downstream.
|
||||
pub fn linearize(nodes: &[TxNode]) -> Vec<Package> {
|
||||
let clusters = Self::find_components(nodes);
|
||||
Self::pack_clusters(clusters)
|
||||
}
|
||||
|
||||
fn pack_clusters(clusters: Vec<Cluster>) -> Vec<Package> {
|
||||
clusters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(cluster_id, cluster)| Self::pack_cluster(cluster, cluster_id as u32))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pack_cluster(cluster: &Cluster, cluster_id: u32) -> Vec<Package> {
|
||||
if cluster.nodes.len() == 1 {
|
||||
return vec![Package::singleton(cluster, cluster_id)];
|
||||
}
|
||||
Sfl::linearize(cluster)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(chunk_order, chunk)| {
|
||||
Package::from_chunk(cluster, chunk, cluster_id, chunk_order as u32)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_components(nodes: &[TxNode]) -> Vec<Cluster> {
|
||||
let n = nodes.len();
|
||||
let mut seen: Vec<bool> = vec![false; n];
|
||||
let mut clusters: Vec<Cluster> = Vec::new();
|
||||
let mut stack: Vec<PoolIndex> = Vec::new();
|
||||
|
||||
for start in 0..n {
|
||||
if seen[start] {
|
||||
continue;
|
||||
}
|
||||
let mut members = Self::flood_component(start, nodes, &mut seen, &mut stack);
|
||||
// Deterministic LocalIdx assignment keeps SFL output stable
|
||||
// across sync ticks.
|
||||
members.sort_unstable();
|
||||
clusters.push(Self::build_cluster(nodes, &members));
|
||||
}
|
||||
|
||||
clusters
|
||||
}
|
||||
|
||||
fn flood_component(
|
||||
start: usize,
|
||||
nodes: &[TxNode],
|
||||
seen: &mut [bool],
|
||||
stack: &mut Vec<PoolIndex>,
|
||||
) -> Vec<PoolIndex> {
|
||||
let mut members: Vec<PoolIndex> = Vec::new();
|
||||
stack.clear();
|
||||
stack.push(PoolIndex::from(start));
|
||||
seen[start] = true;
|
||||
|
||||
while let Some(idx) = stack.pop() {
|
||||
members.push(idx);
|
||||
let node = &nodes[idx.as_usize()];
|
||||
for &n in node.parents.iter().chain(node.children.iter()) {
|
||||
if !seen[n.as_usize()] {
|
||||
seen[n.as_usize()] = true;
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
members
|
||||
}
|
||||
|
||||
fn build_cluster(nodes: &[TxNode], members: &[PoolIndex]) -> Cluster {
|
||||
let mut pool_to_local: FxHashMap<PoolIndex, LocalIdx> =
|
||||
FxHashMap::with_capacity_and_hasher(members.len(), FxBuildHasher);
|
||||
for (i, &p) in members.iter().enumerate() {
|
||||
pool_to_local.insert(p, i as LocalIdx);
|
||||
}
|
||||
|
||||
let cluster_nodes: Vec<ClusterNode> = members
|
||||
.iter()
|
||||
.map(|&pool_idx| {
|
||||
let node = &nodes[pool_idx.as_usize()];
|
||||
ClusterNode {
|
||||
tx_index: node.tx_index,
|
||||
fee: node.fee,
|
||||
vsize: node.vsize,
|
||||
parents: Self::local_neighbors(&node.parents, &pool_to_local),
|
||||
children: Self::local_neighbors(&node.children, &pool_to_local),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Cluster::new(cluster_nodes)
|
||||
}
|
||||
|
||||
fn local_neighbors(
|
||||
pool_neighbors: &[PoolIndex],
|
||||
pool_to_local: &FxHashMap<PoolIndex, LocalIdx>,
|
||||
) -> SmallVec<[LocalIdx; 2]> {
|
||||
pool_neighbors
|
||||
.iter()
|
||||
.filter_map(|p| pool_to_local.get(p).copied())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
use brk_types::{FeeRate, VSize};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::{LocalIdx, chunk::Chunk, cluster::Cluster};
|
||||
use crate::stores::TxIndex;
|
||||
|
||||
/// A CPFP package: transactions mined together because a child pays
|
||||
/// for its parent. Atomic (all-or-nothing) at mining time.
|
||||
///
|
||||
/// `fee_rate` is the package's combined rate (sum of fees / sum of
|
||||
/// vsizes). SFL emits packages in descending-`fee_rate` order within
|
||||
/// a cluster.
|
||||
///
|
||||
/// `cluster_id` + `chunk_order` let the partitioner enforce
|
||||
/// intra-cluster ordering when its look-ahead would otherwise pull a
|
||||
/// child chunk into an earlier block than its parent chunk.
|
||||
pub struct Package {
|
||||
/// Transactions in topological order (parents before children).
|
||||
pub txs: Vec<TxIndex>,
|
||||
pub vsize: VSize,
|
||||
pub fee_rate: FeeRate,
|
||||
pub cluster_id: u32,
|
||||
pub chunk_order: u32,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
pub(super) fn singleton(cluster: &Cluster, cluster_id: u32) -> Self {
|
||||
let node = &cluster.nodes[0];
|
||||
let mut package = Self::empty(FeeRate::from((node.fee, node.vsize)), cluster_id, 0);
|
||||
package.add_tx(node.tx_index, node.vsize);
|
||||
package
|
||||
}
|
||||
|
||||
/// Txs inside the package are ordered parents-first by `topo_rank`.
|
||||
pub(super) fn from_chunk(
|
||||
cluster: &Cluster,
|
||||
chunk: Chunk,
|
||||
cluster_id: u32,
|
||||
chunk_order: u32,
|
||||
) -> Self {
|
||||
let mut package = Self::empty(chunk.fee_rate(), cluster_id, chunk_order);
|
||||
|
||||
let mut ordered: SmallVec<[LocalIdx; 8]> = chunk.nodes.into_iter().collect();
|
||||
ordered.sort_by_key(|&local| cluster.topo_rank[local as usize]);
|
||||
|
||||
for local in ordered {
|
||||
let node = &cluster.nodes[local as usize];
|
||||
package.add_tx(node.tx_index, node.vsize);
|
||||
}
|
||||
package
|
||||
}
|
||||
|
||||
fn empty(fee_rate: FeeRate, cluster_id: u32, chunk_order: u32) -> Self {
|
||||
Self {
|
||||
txs: Vec::new(),
|
||||
vsize: VSize::default(),
|
||||
fee_rate,
|
||||
cluster_id,
|
||||
chunk_order,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_tx(&mut self, tx_index: TxIndex, vsize: VSize) {
|
||||
self.txs.push(tx_index);
|
||||
self.vsize += vsize;
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
//! Cluster linearizer.
|
||||
//!
|
||||
//! Two-branch dispatch by cluster size:
|
||||
//! - **n ≤ 18**: recursive enumeration of topologically-closed subsets.
|
||||
//! Provably optimal. Visits only valid subsets (skips non-closed ones
|
||||
//! without filtering) and maintains running fee/vsize incrementally.
|
||||
//! - **n > 18**: "greedy-union" ancestor-set search. Seeds with each
|
||||
//! node's ancestor closure, then greedily adds any other ancestor
|
||||
//! closure whose inclusion raises the combined feerate. Strict
|
||||
//! superset of ancestor-set-sort's candidate space, catching the
|
||||
//! sibling-union shapes that pure ASS misses.
|
||||
//!
|
||||
//! A final stack-based `canonicalize` pass merges adjacent chunks when
|
||||
//! the later one's feerate beats the earlier's, restoring the
|
||||
//! non-increasing-rate invariant.
|
||||
//!
|
||||
//! Everything runs on `u128` bitmasks (covers Bitcoin Core 31's cluster
|
||||
//! cap of 100). Rate comparisons go through `FeeRate`.
|
||||
|
||||
use brk_types::{FeeRate, Sats, VSize};
|
||||
|
||||
use super::LocalIdx;
|
||||
use super::chunk::Chunk;
|
||||
use super::cluster::Cluster;
|
||||
|
||||
const BRUTE_FORCE_LIMIT: usize = 18;
|
||||
const BITMASK_LIMIT: usize = 128;
|
||||
|
||||
pub struct Sfl;
|
||||
|
||||
impl Sfl {
|
||||
pub fn linearize(cluster: &Cluster) -> Vec<Chunk> {
|
||||
assert!(
|
||||
cluster.nodes.len() <= BITMASK_LIMIT,
|
||||
"cluster size {} exceeds u128 capacity",
|
||||
cluster.nodes.len()
|
||||
);
|
||||
let tables = Tables::build(cluster);
|
||||
let chunks = Self::extract_chunks(&tables);
|
||||
Self::canonicalize(chunks)
|
||||
}
|
||||
|
||||
/// Peel the cluster one chunk at a time. Each iteration picks the
|
||||
/// highest-feerate topologically-closed subset of `remaining` and
|
||||
/// removes it. Loop terminates because every iteration removes at
|
||||
/// least one node.
|
||||
fn extract_chunks(t: &Tables) -> Vec<Chunk> {
|
||||
let mut chunks: Vec<Chunk> = Vec::new();
|
||||
let mut remaining: u128 = t.all;
|
||||
while remaining != 0 {
|
||||
let (mask, fee, vsize) = if t.n <= BRUTE_FORCE_LIMIT {
|
||||
Self::best_subset(t, remaining)
|
||||
} else {
|
||||
Self::best_ancestor_union(t, remaining)
|
||||
};
|
||||
chunks.push(Chunk::from_mask(mask, fee, vsize));
|
||||
remaining &= !mask;
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
/// Recursive enumeration of topologically-closed subsets of
|
||||
/// `remaining`. Returns the (mask, fee, vsize) with the highest rate.
|
||||
fn best_subset(t: &Tables, remaining: u128) -> (u128, Sats, VSize) {
|
||||
let ctx = Ctx { tables: t, remaining };
|
||||
let mut best = (0u128, Sats::ZERO, VSize::default());
|
||||
Self::recurse(&ctx, 0, 0, Sats::ZERO, VSize::default(), &mut best);
|
||||
best
|
||||
}
|
||||
|
||||
fn recurse(
|
||||
ctx: &Ctx,
|
||||
idx: usize,
|
||||
included: u128,
|
||||
f: Sats,
|
||||
v: VSize,
|
||||
best: &mut (u128, Sats, VSize),
|
||||
) {
|
||||
if idx == ctx.tables.topo_order.len() {
|
||||
if included != 0 && FeeRate::from((f, v)) > FeeRate::from((best.1, best.2)) {
|
||||
*best = (included, f, v);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let node = ctx.tables.topo_order[idx];
|
||||
let bit = 1u128 << node;
|
||||
|
||||
// Not in remaining, or a parent (within remaining) is excluded:
|
||||
// this node is forced-excluded, no branching.
|
||||
if (bit & ctx.remaining) == 0
|
||||
|| (ctx.tables.parents_mask[node as usize] & ctx.remaining & !included) != 0
|
||||
{
|
||||
Self::recurse(ctx, idx + 1, included, f, v, best);
|
||||
return;
|
||||
}
|
||||
|
||||
Self::recurse(ctx, idx + 1, included, f, v, best);
|
||||
Self::recurse(
|
||||
ctx,
|
||||
idx + 1,
|
||||
included | bit,
|
||||
f + ctx.tables.fee_of[node as usize],
|
||||
v + ctx.tables.vsize_of[node as usize],
|
||||
best,
|
||||
);
|
||||
}
|
||||
|
||||
/// For each node v in `remaining`, seed with anc(v) ∩ remaining, then
|
||||
/// greedily extend by adding any anc(u) whose inclusion raises the
|
||||
/// feerate. Pick the best result across all seeds.
|
||||
///
|
||||
/// Every candidate evaluated is a union of ancestor closures, so it
|
||||
/// is topologically closed by construction. Strictly explores more
|
||||
/// candidates than pure ancestor-set-sort, at O(n³) per chunk step.
|
||||
fn best_ancestor_union(t: &Tables, remaining: u128) -> (u128, Sats, VSize) {
|
||||
let mut best = (0u128, Sats::ZERO, VSize::default());
|
||||
let mut best_rate = FeeRate::default();
|
||||
let mut seeds = remaining;
|
||||
while seeds != 0 {
|
||||
let i = seeds.trailing_zeros() as usize;
|
||||
seeds &= seeds - 1;
|
||||
|
||||
let mut s = t.ancestor_incl[i] & remaining;
|
||||
let (mut f, mut v) = Self::totals(s, &t.fee_of, &t.vsize_of);
|
||||
let mut rate = FeeRate::from((f, v));
|
||||
|
||||
// Greedy extension to fixed point: pick the ancestor-closure
|
||||
// addition that yields the highest resulting feerate, if any.
|
||||
loop {
|
||||
let mut picked: Option<(u128, Sats, VSize, FeeRate)> = None;
|
||||
let mut cands = remaining & !s;
|
||||
while cands != 0 {
|
||||
let j = cands.trailing_zeros() as usize;
|
||||
cands &= cands - 1;
|
||||
let add = t.ancestor_incl[j] & remaining & !s;
|
||||
if add == 0 {
|
||||
continue;
|
||||
}
|
||||
let (df, dv) = Self::totals(add, &t.fee_of, &t.vsize_of);
|
||||
let nf = f + df;
|
||||
let nv = v + dv;
|
||||
let nrate = FeeRate::from((nf, nv));
|
||||
if nrate <= rate {
|
||||
continue;
|
||||
}
|
||||
if picked.is_none_or(|(_, _, _, prate)| nrate > prate) {
|
||||
picked = Some((add, nf, nv, nrate));
|
||||
}
|
||||
}
|
||||
match picked {
|
||||
Some((add, nf, nv, nrate)) => {
|
||||
s |= add;
|
||||
f = nf;
|
||||
v = nv;
|
||||
rate = nrate;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
if rate > best_rate {
|
||||
best = (s, f, v);
|
||||
best_rate = rate;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Single-pass stack merge: for each incoming chunk, merge it into
|
||||
/// the stack top while the merge would raise the top's feerate, then
|
||||
/// push. O(n) total regardless of how many merges cascade.
|
||||
fn canonicalize(chunks: Vec<Chunk>) -> Vec<Chunk> {
|
||||
let mut out: Vec<Chunk> = Vec::with_capacity(chunks.len());
|
||||
for mut cur in chunks {
|
||||
while let Some(top) = out.last() {
|
||||
if cur.fee_rate() <= top.fee_rate() {
|
||||
break;
|
||||
}
|
||||
let mut prev = out.pop().unwrap();
|
||||
prev.fee += cur.fee;
|
||||
prev.vsize += cur.vsize;
|
||||
prev.nodes.extend(cur.nodes);
|
||||
cur = prev;
|
||||
}
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn totals(mask: u128, fee_of: &[Sats], vsize_of: &[VSize]) -> (Sats, VSize) {
|
||||
let mut f = Sats::ZERO;
|
||||
let mut v = VSize::default();
|
||||
let mut bits = mask;
|
||||
while bits != 0 {
|
||||
let i = bits.trailing_zeros() as usize;
|
||||
f += fee_of[i];
|
||||
v += vsize_of[i];
|
||||
bits &= bits - 1;
|
||||
}
|
||||
(f, v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-cluster precomputed bitmasks and lookups, shared across every
|
||||
/// chunk-extraction iteration. Built once in `Sfl::linearize`.
|
||||
struct Tables {
|
||||
n: usize,
|
||||
/// Bitmask with one bit set per node (i.e. `(1 << n) - 1`).
|
||||
all: u128,
|
||||
/// `parents_mask[i]` = bits set for direct parents of node `i`.
|
||||
parents_mask: Vec<u128>,
|
||||
/// `ancestor_incl[i]` = bits set for `i` and all ancestors.
|
||||
ancestor_incl: Vec<u128>,
|
||||
/// LocalIdx order respecting `cluster.topo_rank`.
|
||||
topo_order: Vec<LocalIdx>,
|
||||
fee_of: Vec<Sats>,
|
||||
vsize_of: Vec<VSize>,
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
fn build(cluster: &Cluster) -> Self {
|
||||
let n = cluster.nodes.len();
|
||||
let topo_order = Self::build_topo_order(cluster);
|
||||
let (parents_mask, ancestor_incl) = Self::build_ancestor_masks(cluster, &topo_order);
|
||||
let fee_of: Vec<Sats> = cluster.nodes.iter().map(|node| node.fee).collect();
|
||||
let vsize_of: Vec<VSize> = cluster.nodes.iter().map(|node| node.vsize).collect();
|
||||
let all: u128 = if n == 128 { !0 } else { (1u128 << n) - 1 };
|
||||
Self {
|
||||
n,
|
||||
all,
|
||||
parents_mask,
|
||||
ancestor_incl,
|
||||
topo_order,
|
||||
fee_of,
|
||||
vsize_of,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_topo_order(cluster: &Cluster) -> Vec<LocalIdx> {
|
||||
let mut topo_order: Vec<LocalIdx> = (0..cluster.nodes.len() as LocalIdx).collect();
|
||||
topo_order.sort_by_key(|&i| cluster.topo_rank[i as usize]);
|
||||
topo_order
|
||||
}
|
||||
|
||||
/// For each node `v`, compute its direct-parent bitmask and the
|
||||
/// closure of all its ancestors (including itself). Visits nodes
|
||||
/// in topological order so a parent's `ancestor_incl` is ready
|
||||
/// before any child reads it.
|
||||
fn build_ancestor_masks(
|
||||
cluster: &Cluster,
|
||||
topo_order: &[LocalIdx],
|
||||
) -> (Vec<u128>, Vec<u128>) {
|
||||
let n = cluster.nodes.len();
|
||||
let mut parents_mask: Vec<u128> = vec![0; n];
|
||||
let mut ancestor_incl: Vec<u128> = vec![0; n];
|
||||
for &v in topo_order {
|
||||
let mut par = 0u128;
|
||||
let mut acc = 1u128 << v;
|
||||
for &p in &cluster.nodes[v as usize].parents {
|
||||
par |= 1u128 << p;
|
||||
acc |= ancestor_incl[p as usize];
|
||||
}
|
||||
parents_mask[v as usize] = par;
|
||||
ancestor_incl[v as usize] = acc;
|
||||
}
|
||||
(parents_mask, ancestor_incl)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-iteration immutable bundle for the brute-force recursion.
|
||||
/// Keeping it small lets `recurse` stay at four moving args.
|
||||
struct Ctx<'a> {
|
||||
tables: &'a Tables,
|
||||
remaining: u128,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -11,22 +11,20 @@ use brk_types::FeeRate;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use tracing::warn;
|
||||
|
||||
use graph::Graph;
|
||||
use linearize::Linearizer;
|
||||
use clusters::build_clusters;
|
||||
use partition::Partitioner;
|
||||
#[cfg(debug_assertions)]
|
||||
use verify::Verifier;
|
||||
use crate::stores::MempoolState;
|
||||
|
||||
pub(crate) mod graph;
|
||||
pub(crate) mod linearize;
|
||||
pub(crate) mod clusters;
|
||||
mod partition;
|
||||
mod snapshot;
|
||||
#[cfg(debug_assertions)]
|
||||
mod verify;
|
||||
|
||||
pub use brk_types::RecommendedFees;
|
||||
pub use snapshot::{BlkIndex, BlockStats, Snapshot};
|
||||
pub use snapshot::{BlockStats, Snapshot};
|
||||
|
||||
const MIN_REBUILD_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const NUM_BLOCKS: usize = 8;
|
||||
@@ -36,6 +34,9 @@ pub struct Rebuilder {
|
||||
snapshot: RwLock<Arc<Snapshot>>,
|
||||
dirty: AtomicBool,
|
||||
last_rebuild: Mutex<Option<Instant>>,
|
||||
rebuild_count: AtomicU64,
|
||||
skip_throttled: AtomicU64,
|
||||
skip_clean: AtomicU64,
|
||||
}
|
||||
|
||||
impl Rebuilder {
|
||||
@@ -49,6 +50,18 @@ impl Rebuilder {
|
||||
return;
|
||||
}
|
||||
self.publish(Self::build_snapshot(client, state));
|
||||
self.rebuild_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn rebuild_count(&self) -> u64 {
|
||||
self.rebuild_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn skip_counts(&self) -> (u64, u64) {
|
||||
(
|
||||
self.skip_clean.load(Ordering::Relaxed),
|
||||
self.skip_throttled.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_snapshot(client: &Client, state: &MempoolState) -> Snapshot {
|
||||
@@ -56,14 +69,13 @@ impl Rebuilder {
|
||||
let entries = state.entries.read();
|
||||
let entries_slice = entries.entries();
|
||||
|
||||
let nodes = Graph::build(entries_slice);
|
||||
let packages = Linearizer::linearize(&nodes);
|
||||
let blocks = Partitioner::partition(packages, NUM_BLOCKS);
|
||||
let (clusters, cluster_of) = build_clusters(entries_slice);
|
||||
let blocks = Partitioner::partition(&clusters, NUM_BLOCKS);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
Verifier::check(client, &blocks, entries_slice);
|
||||
Verifier::check(client, &blocks, &clusters, &cluster_of, entries_slice);
|
||||
|
||||
Snapshot::build(blocks, entries_slice, min_fee)
|
||||
Snapshot::build(clusters, cluster_of, blocks, entries_slice, min_fee)
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Arc<Snapshot> {
|
||||
@@ -82,10 +94,12 @@ impl Rebuilder {
|
||||
/// retry.
|
||||
fn try_claim_rebuild(&self) -> bool {
|
||||
if !self.dirty.load(Ordering::Acquire) {
|
||||
self.skip_clean.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
}
|
||||
let mut last = self.last_rebuild.lock();
|
||||
if last.is_some_and(|t| t.elapsed() < MIN_REBUILD_INTERVAL) {
|
||||
self.skip_throttled.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
}
|
||||
*last = Some(Instant::now());
|
||||
|
||||
@@ -1,50 +1,78 @@
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use brk_types::VSize;
|
||||
use brk_types::{FeeRate, VSize};
|
||||
|
||||
use super::linearize::Package;
|
||||
use crate::cluster::{ChunkId, Cluster, ClusterId};
|
||||
use crate::stores::TxIndex;
|
||||
|
||||
const LOOK_AHEAD_COUNT: usize = 100;
|
||||
|
||||
/// Packs ranked packages into `num_blocks` blocks. The first
|
||||
/// `num_blocks - 1` are filled greedily up to `VSize::MAX_BLOCK`; the last
|
||||
/// is a catch-all so no low-rate tx is silently dropped (matches
|
||||
/// mempool.space).
|
||||
/// Packs SFL chunks (referenced by `(ClusterId, ChunkId)`) into
|
||||
/// `num_blocks` blocks. The first `num_blocks - 1` are filled greedily
|
||||
/// up to `VSize::MAX_BLOCK`; the last is a catch-all so no low-rate tx
|
||||
/// is silently dropped (matches mempool.space).
|
||||
///
|
||||
/// Look-ahead respects intra-cluster order: a chunk is only taken once
|
||||
/// every earlier-rate chunk of the same cluster has been placed, so a
|
||||
/// child chunk never lands in an earlier block than its parent chunk.
|
||||
pub struct Partitioner {
|
||||
slots: Vec<Option<Package>>,
|
||||
blocks: Vec<Vec<Package>>,
|
||||
cluster_next: Vec<u32>,
|
||||
current: Vec<Package>,
|
||||
///
|
||||
/// Output is the flat tx-list per block, parents-first within each
|
||||
/// chunk via the cluster's `topo_order`.
|
||||
pub struct Partitioner<'a> {
|
||||
clusters: &'a [Cluster<TxIndex>],
|
||||
/// Candidate chunks sorted by descending feerate. Slots are taken
|
||||
/// (set to `None`) as they're placed.
|
||||
slots: Vec<Option<Candidate>>,
|
||||
/// Per-cluster cursor: the next `ChunkId` that must be taken next.
|
||||
cluster_next: Vec<ChunkId>,
|
||||
blocks: Vec<Vec<TxIndex>>,
|
||||
current: Vec<Candidate>,
|
||||
current_vsize: VSize,
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
impl Partitioner {
|
||||
pub fn partition(mut packages: Vec<Package>, num_blocks: usize) -> Vec<Vec<Package>> {
|
||||
// Stable sort preserves SFL's per-cluster non-increasing-rate
|
||||
// emission order in the global list, which is what `cluster_next`
|
||||
// relies on.
|
||||
packages.sort_by_key(|p| Reverse(p.fee_rate));
|
||||
#[derive(Clone, Copy)]
|
||||
struct Candidate {
|
||||
cluster_id: ClusterId,
|
||||
chunk_id: ChunkId,
|
||||
fee_rate: FeeRate,
|
||||
vsize: VSize,
|
||||
}
|
||||
|
||||
let mut p = Self::new(packages, num_blocks);
|
||||
impl<'a> Partitioner<'a> {
|
||||
pub fn partition(clusters: &'a [Cluster<TxIndex>], num_blocks: usize) -> Vec<Vec<TxIndex>> {
|
||||
let mut p = Self::new(clusters, num_blocks);
|
||||
p.fill_normal_blocks(num_blocks.saturating_sub(1));
|
||||
p.flush_overflow(num_blocks);
|
||||
p.blocks
|
||||
}
|
||||
|
||||
fn new(packages: Vec<Package>, num_blocks: usize) -> Self {
|
||||
let num_clusters = packages
|
||||
fn new(clusters: &'a [Cluster<TxIndex>], num_blocks: usize) -> Self {
|
||||
let mut candidates: Vec<Candidate> = clusters
|
||||
.iter()
|
||||
.map(|p| p.cluster_id as usize + 1)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
.enumerate()
|
||||
.flat_map(|(cid, cluster)| {
|
||||
let cluster_id = ClusterId::from(cid);
|
||||
cluster
|
||||
.chunks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(move |(chid, chunk)| Candidate {
|
||||
cluster_id,
|
||||
chunk_id: ChunkId::from(chid),
|
||||
fee_rate: chunk.fee_rate(),
|
||||
vsize: chunk.vsize,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Stable sort preserves SFL's per-cluster non-increasing-rate
|
||||
// order, which is what `cluster_next` relies on.
|
||||
candidates.sort_by_key(|c| Reverse(c.fee_rate));
|
||||
|
||||
Self {
|
||||
cluster_next: vec![0; num_clusters],
|
||||
slots: packages.into_iter().map(Some).collect(),
|
||||
clusters,
|
||||
slots: candidates.into_iter().map(Some).collect(),
|
||||
cluster_next: vec![ChunkId::ZERO; clusters.len()],
|
||||
blocks: Vec::with_capacity(num_blocks),
|
||||
current: Vec::new(),
|
||||
current_vsize: VSize::default(),
|
||||
@@ -54,7 +82,7 @@ impl Partitioner {
|
||||
|
||||
fn fill_normal_blocks(&mut self, target_blocks: usize) {
|
||||
while self.idx < self.slots.len() && self.blocks.len() < target_blocks {
|
||||
let Some(pkg) = &self.slots[self.idx] else {
|
||||
let Some(cand) = self.slots[self.idx] else {
|
||||
self.idx += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -62,8 +90,8 @@ impl Partitioner {
|
||||
let remaining_space = VSize::MAX_BLOCK.saturating_sub(self.current_vsize);
|
||||
|
||||
// Take if it fits, or if the current block is empty (avoids
|
||||
// stalling on an oversized package larger than MAX_BLOCK).
|
||||
if pkg.vsize <= remaining_space || self.current.is_empty() {
|
||||
// stalling on an oversized chunk larger than MAX_BLOCK).
|
||||
if cand.vsize <= remaining_space || self.current.is_empty() {
|
||||
self.take(self.idx);
|
||||
self.idx += 1;
|
||||
continue;
|
||||
@@ -86,11 +114,11 @@ impl Partitioner {
|
||||
fn try_fill_with_smaller(&mut self, start: usize, remaining_space: VSize) -> bool {
|
||||
let end = (start + LOOK_AHEAD_COUNT).min(self.slots.len());
|
||||
for idx in (start + 1)..end {
|
||||
let Some(pkg) = &self.slots[idx] else { continue };
|
||||
if pkg.vsize > remaining_space {
|
||||
let Some(cand) = self.slots[idx] else { continue };
|
||||
if cand.vsize > remaining_space {
|
||||
continue;
|
||||
}
|
||||
if pkg.chunk_order != self.cluster_next[pkg.cluster_id as usize] {
|
||||
if cand.chunk_id != self.cluster_next[cand.cluster_id.as_usize()] {
|
||||
continue;
|
||||
}
|
||||
self.take(idx);
|
||||
@@ -100,18 +128,21 @@ impl Partitioner {
|
||||
}
|
||||
|
||||
fn take(&mut self, idx: usize) {
|
||||
let pkg = self.slots[idx].take().unwrap();
|
||||
let cand = self.slots[idx].take().unwrap();
|
||||
debug_assert_eq!(
|
||||
pkg.chunk_order, self.cluster_next[pkg.cluster_id as usize],
|
||||
cand.chunk_id,
|
||||
self.cluster_next[cand.cluster_id.as_usize()],
|
||||
"partitioner took a chunk out of cluster order"
|
||||
);
|
||||
self.cluster_next[pkg.cluster_id as usize] = pkg.chunk_order + 1;
|
||||
self.current_vsize += pkg.vsize;
|
||||
self.current.push(pkg);
|
||||
self.cluster_next[cand.cluster_id.as_usize()] = ChunkId::from(cand.chunk_id.inner() + 1);
|
||||
self.current_vsize += cand.vsize;
|
||||
self.current.push(cand);
|
||||
}
|
||||
|
||||
fn flush_block(&mut self) {
|
||||
self.blocks.push(std::mem::take(&mut self.current));
|
||||
let candidates = std::mem::take(&mut self.current);
|
||||
let block = Self::materialize(self.clusters, candidates);
|
||||
self.blocks.push(block);
|
||||
self.current_vsize = VSize::default();
|
||||
}
|
||||
|
||||
@@ -119,12 +150,27 @@ impl Partitioner {
|
||||
if self.blocks.len() >= num_blocks {
|
||||
return;
|
||||
}
|
||||
let overflow: Vec<Package> = self.slots[self.idx..]
|
||||
let overflow: Vec<Candidate> = self.slots[self.idx..]
|
||||
.iter_mut()
|
||||
.filter_map(Option::take)
|
||||
.collect();
|
||||
if !overflow.is_empty() {
|
||||
self.blocks.push(overflow);
|
||||
let block = Self::materialize(self.clusters, overflow);
|
||||
self.blocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand each chunk into its txs. `chunk.txs` is already topo-ordered
|
||||
/// (parents-first) by `Cluster::new`, so we iterate it directly.
|
||||
fn materialize(clusters: &[Cluster<TxIndex>], candidates: Vec<Candidate>) -> Vec<TxIndex> {
|
||||
let mut out: Vec<TxIndex> = Vec::new();
|
||||
for cand in candidates {
|
||||
let cluster = &clusters[cand.cluster_id.as_usize()];
|
||||
let chunk = &cluster.chunks[cand.chunk_id.as_usize()];
|
||||
for &local in &chunk.txs {
|
||||
out.push(cluster.nodes[local.as_usize()].id);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/// Projected-block index in a mempool snapshot. `u8` because the
|
||||
/// projection horizon is ~8 blocks at typical loads; `BlkIndex::MAX`
|
||||
/// is reserved as the "not in any projected block" sentinel used by
|
||||
/// `Snapshot::block_of` for txs below the mempool floor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct BlkIndex(u8);
|
||||
|
||||
impl BlkIndex {
|
||||
/// Sentinel for "not in any projected block".
|
||||
pub const MAX: BlkIndex = BlkIndex(u8::MAX);
|
||||
|
||||
pub fn is_not_in_projected(self) -> bool {
|
||||
self == Self::MAX
|
||||
}
|
||||
|
||||
pub fn as_usize(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for BlkIndex {
|
||||
fn from(v: usize) -> Self {
|
||||
debug_assert!(v < u8::MAX as usize, "BlkIndex overflow: {v}");
|
||||
Self(v as u8)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,13 @@ const MIN_INCREMENT: FeeRate = FeeRate::new(0.001);
|
||||
const PRIORITY_FACTOR: FeeRate = FeeRate::new(0.5);
|
||||
const MIN_FASTEST_FEE: FeeRate = FeeRate::new(1.0);
|
||||
const MIN_HALF_HOUR_FEE: FeeRate = FeeRate::new(0.5);
|
||||
/// At or below this projected-block vsize, the block carries no fee
|
||||
/// signal and the tier collapses to `min_fee`.
|
||||
const EMPTY_BLOCK_VSIZE: u64 = 500_000;
|
||||
/// Above this projected-block vsize, no taper applies. Between
|
||||
/// `EMPTY_BLOCK_VSIZE` and this threshold, the final-block fee is
|
||||
/// scaled linearly by `(vsize - EMPTY_BLOCK_VSIZE) / EMPTY_BLOCK_VSIZE`.
|
||||
const FULL_BLOCK_VSIZE: u64 = 950_000;
|
||||
|
||||
pub struct Fees;
|
||||
|
||||
@@ -70,11 +77,11 @@ impl Fees {
|
||||
let median = block.median_fee_rate();
|
||||
let use_fee = previous_fee.map_or(median, |prev| FeeRate::mean(median, prev));
|
||||
let vsize = u64::from(block.total_vsize);
|
||||
if vsize <= 500_000 || median < min_fee {
|
||||
if vsize <= EMPTY_BLOCK_VSIZE || median < min_fee {
|
||||
return min_fee;
|
||||
}
|
||||
if vsize <= 950_000 && next_block.is_none() {
|
||||
let multiplier = (vsize - 500_000) as f64 / 500_000.0;
|
||||
if vsize <= FULL_BLOCK_VSIZE && next_block.is_none() {
|
||||
let multiplier = (vsize - EMPTY_BLOCK_VSIZE) as f64 / EMPTY_BLOCK_VSIZE as f64;
|
||||
return (use_fee * multiplier).round_to(MIN_INCREMENT).max(min_fee);
|
||||
}
|
||||
use_fee.ceil_to(MIN_INCREMENT).max(min_fee)
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
mod blk_index;
|
||||
mod fees;
|
||||
mod stats;
|
||||
|
||||
pub use blk_index::BlkIndex;
|
||||
pub use stats::BlockStats;
|
||||
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use brk_types::{FeeRate, RecommendedFees};
|
||||
|
||||
use super::linearize::Package;
|
||||
use crate::{TxEntry, stores::TxIndex};
|
||||
use crate::TxEntry;
|
||||
use crate::cluster::{Cluster, ClusterRef};
|
||||
use crate::stores::TxIndex;
|
||||
|
||||
use fees::Fees;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Default)]
|
||||
pub struct Snapshot {
|
||||
/// SFL-linearized cluster forest. Snapshot is `Arc`'d, so consumers
|
||||
/// share the cluster data without cloning. Each `ClusterNode.id`
|
||||
/// is the live `TxIndex` (pool slot) of that node.
|
||||
pub clusters: Vec<Cluster<TxIndex>>,
|
||||
/// Reverse of `clusters`: indexed by `TxIndex.as_usize()`. `None`
|
||||
/// means the slot is empty (between two cycles a tx confirmed/was
|
||||
/// evicted) or never made it into the live pool. Read via
|
||||
/// `cluster_of(idx)` from outside the snapshot.
|
||||
cluster_of: Vec<Option<ClusterRef>>,
|
||||
pub blocks: Vec<Vec<TxIndex>>,
|
||||
/// Reverse of `blocks`: indexed by `TxIndex.as_usize()`. Slots that
|
||||
/// hold no entry, or hold an entry that didn't make any projected
|
||||
/// block, store `BlkIndex::MAX`. Read via the `block_of` accessor.
|
||||
block_of: Vec<BlkIndex>,
|
||||
pub block_stats: Vec<BlockStats>,
|
||||
pub fees: RecommendedFees,
|
||||
/// ETag-like cache key for the first projected block. A hash of
|
||||
@@ -32,56 +36,30 @@ pub struct Snapshot {
|
||||
impl Snapshot {
|
||||
/// `min_fee` is bitcoind's live `mempoolminfee`, used as the floor
|
||||
/// for every recommended-fee tier.
|
||||
pub fn build(blocks: Vec<Vec<Package>>, entries: &[Option<TxEntry>], min_fee: FeeRate) -> Self {
|
||||
let block_stats = Self::compute_block_stats(&blocks, entries);
|
||||
pub fn build(
|
||||
clusters: Vec<Cluster<TxIndex>>,
|
||||
cluster_of: Vec<Option<ClusterRef>>,
|
||||
blocks: Vec<Vec<TxIndex>>,
|
||||
entries: &[Option<TxEntry>],
|
||||
min_fee: FeeRate,
|
||||
) -> Self {
|
||||
let block_stats: Vec<BlockStats> = blocks
|
||||
.iter()
|
||||
.map(|block| BlockStats::compute(block, &clusters, &cluster_of, entries))
|
||||
.collect();
|
||||
let fees = Fees::compute(&block_stats, min_fee);
|
||||
let blocks = Self::flatten_blocks(blocks);
|
||||
let block_of = Self::build_block_of(&blocks, entries.len());
|
||||
let next_block_hash = Self::hash_next_block(&blocks);
|
||||
|
||||
Self {
|
||||
clusters,
|
||||
cluster_of,
|
||||
blocks,
|
||||
block_of,
|
||||
block_stats,
|
||||
fees,
|
||||
next_block_hash,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_block_stats(
|
||||
blocks: &[Vec<Package>],
|
||||
entries: &[Option<TxEntry>],
|
||||
) -> Vec<BlockStats> {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|block| BlockStats::compute(block, entries))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop the package grouping, keep only the linearized tx order.
|
||||
/// Packages were a vehicle for chunk-level fee accounting; once
|
||||
/// `compute_block_stats` is done, they're noise to API consumers.
|
||||
fn flatten_blocks(blocks: Vec<Vec<Package>>) -> Vec<Vec<TxIndex>> {
|
||||
blocks
|
||||
.into_iter()
|
||||
.map(|block| block.into_iter().flat_map(|pkg| pkg.txs).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One pass over `blocks` to invert the mapping. `BlkIndex::MAX`
|
||||
/// stays as the sentinel for slots that aren't in any projected
|
||||
/// block (empty slots and below-floor txs alike).
|
||||
fn build_block_of(blocks: &[Vec<TxIndex>], entry_count: usize) -> Vec<BlkIndex> {
|
||||
let mut block_of = vec![BlkIndex::MAX; entry_count];
|
||||
for (b, txs) in blocks.iter().enumerate() {
|
||||
let blk = BlkIndex::from(b);
|
||||
for &idx in txs {
|
||||
block_of[idx.as_usize()] = blk;
|
||||
}
|
||||
}
|
||||
block_of
|
||||
}
|
||||
|
||||
fn hash_next_block(blocks: &[Vec<TxIndex>]) -> u64 {
|
||||
let Some(block) = blocks.first() else {
|
||||
return 0;
|
||||
@@ -91,12 +69,25 @@ impl Snapshot {
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Projected block that holds `idx`, or `None` if the tx is below
|
||||
/// the mempool floor (or `idx` is out of range).
|
||||
pub fn block_of(&self, idx: TxIndex) -> Option<BlkIndex> {
|
||||
self.block_of
|
||||
.get(idx.as_usize())
|
||||
.copied()
|
||||
.filter(|b| !b.is_not_in_projected())
|
||||
/// Cluster + local position for a live tx, or `None` if the slot
|
||||
/// is empty or `idx` is out of range.
|
||||
pub fn cluster_of(&self, idx: TxIndex) -> Option<ClusterRef> {
|
||||
self.cluster_of.get(idx.as_usize()).copied().flatten()
|
||||
}
|
||||
|
||||
pub fn cluster_of_len(&self) -> usize {
|
||||
self.cluster_of.len()
|
||||
}
|
||||
|
||||
pub fn cluster_of_active(&self) -> usize {
|
||||
self.cluster_of.iter().filter(|c| c.is_some()).count()
|
||||
}
|
||||
|
||||
/// SFL chunk feerate for a live tx, or `None` if it isn't in any
|
||||
/// cluster. Cheap shortcut for callers that need the rate but not
|
||||
/// the full `CpfpInfo`.
|
||||
pub fn chunk_rate_of(&self, idx: TxIndex) -> Option<FeeRate> {
|
||||
let ClusterRef { cluster_id, local } = self.cluster_of(idx)?;
|
||||
Some(self.clusters[cluster_id.as_usize()].chunk_of(local).fee_rate())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use brk_types::{FeeRate, Sats, VSize};
|
||||
|
||||
use crate::TxEntry;
|
||||
|
||||
use super::super::linearize::Package;
|
||||
use crate::cluster::{Cluster, ClusterRef};
|
||||
use crate::stores::TxIndex;
|
||||
|
||||
/// Percentile points reported in [`BlockStats::fee_range`], in the
|
||||
/// same order: 0% (min), 10%, 25%, median, 75%, 90%, 100% (max).
|
||||
@@ -20,24 +20,31 @@ pub struct BlockStats {
|
||||
}
|
||||
|
||||
impl BlockStats {
|
||||
/// Each tx contributes its containing package's `fee_rate` to the
|
||||
/// Each tx contributes its containing chunk's `fee_rate` to the
|
||||
/// percentile distribution, since that's the rate the miner
|
||||
/// collects per vsize.
|
||||
pub fn compute(block: &[Package], entries: &[Option<TxEntry>]) -> Self {
|
||||
pub fn compute(
|
||||
block: &[TxIndex],
|
||||
clusters: &[Cluster<TxIndex>],
|
||||
cluster_of: &[Option<ClusterRef>],
|
||||
entries: &[Option<TxEntry>],
|
||||
) -> Self {
|
||||
let mut total_fee = Sats::default();
|
||||
let mut total_vsize = VSize::default();
|
||||
let mut total_size: u64 = 0;
|
||||
let mut fee_rates: Vec<FeeRate> = Vec::new();
|
||||
|
||||
for pkg in block {
|
||||
for &tx_index in &pkg.txs {
|
||||
if let Some(entry) = &entries[tx_index.as_usize()] {
|
||||
total_fee += entry.fee;
|
||||
total_vsize += entry.vsize;
|
||||
total_size += entry.size;
|
||||
fee_rates.push(pkg.fee_rate);
|
||||
}
|
||||
}
|
||||
for &tx_index in block {
|
||||
let Some(entry) = &entries[tx_index.as_usize()] else {
|
||||
continue;
|
||||
};
|
||||
let Some(cref) = cluster_of[tx_index.as_usize()] else {
|
||||
continue;
|
||||
};
|
||||
total_fee += entry.fee;
|
||||
total_vsize += entry.vsize;
|
||||
total_size += entry.size;
|
||||
fee_rates.push(clusters[cref.cluster_id.as_usize()].chunk_of(cref.local).fee_rate());
|
||||
}
|
||||
|
||||
let tx_count = fee_rates.len() as u32;
|
||||
|
||||
@@ -3,8 +3,9 @@ use brk_types::{Sats, SatsSigned, TxidPrefix, VSize};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::linearize::Package;
|
||||
use crate::{TxEntry, stores::TxIndex};
|
||||
use crate::TxEntry;
|
||||
use crate::cluster::{Cluster, ClusterRef};
|
||||
use crate::stores::TxIndex;
|
||||
|
||||
type PrefixSet = FxHashSet<TxidPrefix>;
|
||||
type FeeByPrefix = FxHashMap<TxidPrefix, Sats>;
|
||||
@@ -12,12 +13,23 @@ type FeeByPrefix = FxHashMap<TxidPrefix, Sats>;
|
||||
pub struct Verifier;
|
||||
|
||||
impl Verifier {
|
||||
pub fn check(client: &Client, blocks: &[Vec<Package>], entries: &[Option<TxEntry>]) {
|
||||
Self::check_structure(blocks, entries);
|
||||
pub fn check(
|
||||
client: &Client,
|
||||
blocks: &[Vec<TxIndex>],
|
||||
clusters: &[Cluster<TxIndex>],
|
||||
cluster_of: &[Option<ClusterRef>],
|
||||
entries: &[Option<TxEntry>],
|
||||
) {
|
||||
Self::check_structure(blocks, clusters, cluster_of, entries);
|
||||
Self::compare_to_core(client, blocks, entries);
|
||||
}
|
||||
|
||||
fn check_structure(blocks: &[Vec<Package>], entries: &[Option<TxEntry>]) {
|
||||
fn check_structure(
|
||||
blocks: &[Vec<TxIndex>],
|
||||
clusters: &[Cluster<TxIndex>],
|
||||
cluster_of: &[Option<ClusterRef>],
|
||||
entries: &[Option<TxEntry>],
|
||||
) {
|
||||
let in_pool: PrefixSet = entries
|
||||
.iter()
|
||||
.filter_map(|e| e.as_ref().map(TxEntry::txid_prefix))
|
||||
@@ -25,30 +37,35 @@ impl Verifier {
|
||||
let mut placed = PrefixSet::default();
|
||||
|
||||
for (b, block) in blocks.iter().enumerate() {
|
||||
for (p, pkg) in block.iter().enumerate() {
|
||||
let mut summed_vsize = VSize::default();
|
||||
for &tx_index in &pkg.txs {
|
||||
let entry = Self::live_entry(entries, tx_index, b, p);
|
||||
Self::assert_parents_placed_first(entry, &in_pool, &placed, b, p);
|
||||
Self::place(entry, &mut placed, b, p);
|
||||
summed_vsize += entry.vsize;
|
||||
}
|
||||
assert_eq!(
|
||||
pkg.vsize, summed_vsize,
|
||||
"block {b} pkg {p}: pkg.vsize {} != sum {summed_vsize}",
|
||||
pkg.vsize
|
||||
);
|
||||
let mut block_vsize = VSize::default();
|
||||
for &tx_index in block {
|
||||
let entry = Self::live_entry(entries, tx_index, b);
|
||||
Self::assert_parents_placed_first(entry, &in_pool, &placed, b);
|
||||
Self::place(entry, &mut placed, b);
|
||||
Self::assert_in_a_chunk(clusters, cluster_of, tx_index, b);
|
||||
block_vsize += entry.vsize;
|
||||
}
|
||||
if b + 1 < blocks.len() {
|
||||
Self::assert_block_fits_budget(block, b);
|
||||
Self::assert_block_fits_budget(block_vsize, block.len(), b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn live_entry(entries: &[Option<TxEntry>], tx_index: TxIndex, b: usize, p: usize) -> &TxEntry {
|
||||
fn assert_in_a_chunk(
|
||||
clusters: &[Cluster<TxIndex>],
|
||||
cluster_of: &[Option<ClusterRef>],
|
||||
tx_index: TxIndex,
|
||||
b: usize,
|
||||
) {
|
||||
let cref = cluster_of[tx_index.as_usize()]
|
||||
.unwrap_or_else(|| panic!("block {b}: tx_index {tx_index:?} has no cluster"));
|
||||
let _ = clusters[cref.cluster_id.as_usize()].chunk_of(cref.local);
|
||||
}
|
||||
|
||||
fn live_entry(entries: &[Option<TxEntry>], tx_index: TxIndex, b: usize) -> &TxEntry {
|
||||
entries[tx_index.as_usize()]
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("block {b} pkg {p}: dead tx_index {tx_index:?}"))
|
||||
.unwrap_or_else(|| panic!("block {b}: dead tx_index {tx_index:?}"))
|
||||
}
|
||||
|
||||
fn assert_parents_placed_first(
|
||||
@@ -56,28 +73,26 @@ impl Verifier {
|
||||
in_pool: &PrefixSet,
|
||||
placed: &PrefixSet,
|
||||
b: usize,
|
||||
p: usize,
|
||||
) {
|
||||
for parent in &entry.depends {
|
||||
assert!(
|
||||
!in_pool.contains(parent) || placed.contains(parent),
|
||||
"block {b} pkg {p}: {} placed before its parent",
|
||||
"block {b}: {} placed before its parent",
|
||||
entry.txid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn place(entry: &TxEntry, placed: &mut PrefixSet, b: usize, p: usize) {
|
||||
fn place(entry: &TxEntry, placed: &mut PrefixSet, b: usize) {
|
||||
assert!(
|
||||
placed.insert(entry.txid_prefix()),
|
||||
"block {b} pkg {p}: duplicate txid {}",
|
||||
"block {b}: duplicate txid {}",
|
||||
entry.txid
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_block_fits_budget(block: &[Package], b: usize) {
|
||||
let total: VSize = block.iter().map(|pkg| pkg.vsize).sum();
|
||||
let is_oversized_singleton = block.len() == 1 && total > VSize::MAX_BLOCK;
|
||||
fn assert_block_fits_budget(total: VSize, tx_count: usize, b: usize) {
|
||||
let is_oversized_singleton = tx_count == 1 && total > VSize::MAX_BLOCK;
|
||||
if is_oversized_singleton {
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +103,7 @@ impl Verifier {
|
||||
);
|
||||
}
|
||||
|
||||
fn compare_to_core(client: &Client, blocks: &[Vec<Package>], entries: &[Option<TxEntry>]) {
|
||||
fn compare_to_core(client: &Client, blocks: &[Vec<TxIndex>], entries: &[Option<TxEntry>]) {
|
||||
let Some(next_block) = blocks.first() else {
|
||||
return;
|
||||
};
|
||||
@@ -104,7 +119,6 @@ impl Verifier {
|
||||
};
|
||||
let ours: FeeByPrefix = next_block
|
||||
.iter()
|
||||
.flat_map(|pkg| &pkg.txs)
|
||||
.filter_map(|&i| entries[i.as_usize()].as_ref())
|
||||
.map(|e| (e.txid_prefix(), e.fee))
|
||||
.collect();
|
||||
|
||||
@@ -90,7 +90,7 @@ impl Resolver {
|
||||
Some((Vin::from(i), out.clone()))
|
||||
})
|
||||
.collect();
|
||||
(!fills.is_empty()).then_some((txid.clone(), fills))
|
||||
(!fills.is_empty()).then_some((*txid, fills))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -108,9 +108,9 @@ impl Resolver {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, txin)| txin.prevout.is_none())
|
||||
.map(|(i, txin)| (Vin::from(i), txin.txid.clone(), txin.vout))
|
||||
.map(|(i, txin)| (Vin::from(i), txin.txid, txin.vout))
|
||||
.collect();
|
||||
(!holes.is_empty()).then_some((txid.clone(), holes))
|
||||
(!holes.is_empty()).then_some((*txid, holes))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ impl AddrTracker {
|
||||
update_stats: impl FnOnce(&mut AddrMempoolStats),
|
||||
) {
|
||||
let entry = self.0.entry(bytes).or_default();
|
||||
entry.txids.insert(txid.clone());
|
||||
entry.txids.insert(*txid);
|
||||
update_stats(&mut entry.stats);
|
||||
entry.stats.update_tx_count(entry.txids.len() as u32);
|
||||
}
|
||||
|
||||
@@ -64,4 +64,12 @@ impl EntryPool {
|
||||
pub fn entries(&self) -> &[Option<TxEntry>] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.prefix_to_idx.len()
|
||||
}
|
||||
|
||||
pub fn free_slots_count(&self) -> usize {
|
||||
self.free_slots.len()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ pub struct MempoolState {
|
||||
pub(crate) txs: RwLock<TxStore>,
|
||||
pub(crate) addrs: RwLock<AddrTracker>,
|
||||
pub(crate) entries: RwLock<EntryPool>,
|
||||
pub(crate) outpoint_spends: RwLock<OutpointSpends>,
|
||||
pub outpoint_spends: RwLock<OutpointSpends>,
|
||||
pub(crate) graveyard: RwLock<TxGraveyard>,
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ impl TxGraveyard {
|
||||
self.tombstones.contains_key(txid)
|
||||
}
|
||||
|
||||
pub fn tombstones_len(&self) -> usize {
|
||||
self.tombstones.len()
|
||||
}
|
||||
|
||||
pub fn order_len(&self) -> usize {
|
||||
self.order.len()
|
||||
}
|
||||
|
||||
pub fn get(&self, txid: &Txid) -> Option<&TxTombstone> {
|
||||
self.tombstones.get(txid)
|
||||
}
|
||||
@@ -63,7 +71,7 @@ impl TxGraveyard {
|
||||
pub fn bury(&mut self, txid: Txid, tx: Transaction, entry: TxEntry, removal: TxRemoval) {
|
||||
let now = Instant::now();
|
||||
self.tombstones
|
||||
.insert(txid.clone(), TxTombstone::new(tx, entry, removal, now));
|
||||
.insert(txid, TxTombstone::new(tx, entry, removal, now));
|
||||
self.order.push_back((now, txid));
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ impl TxStore {
|
||||
|
||||
fn track_unresolved(&mut self, txid: &Txid, tx: &Transaction) {
|
||||
if tx.input.iter().any(|i| i.prevout.is_none()) {
|
||||
self.unresolved.insert(txid.clone());
|
||||
self.unresolved.insert(*txid);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use bitcoin::hashes::Hash;
|
||||
use brk_types::{Sats, Timestamp, Txid, TxidPrefix, VSize};
|
||||
use brk_types::{Sats, Timestamp, Txid, TxidPrefix, VSize, Weight};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{TxEntry, steps::rebuilder::graph::Graph};
|
||||
use crate::TxEntry;
|
||||
|
||||
fn synthetic_mempool(n: usize) -> Vec<Option<TxEntry>> {
|
||||
let make_txid = |i: usize| -> Txid {
|
||||
@@ -18,7 +18,7 @@ fn synthetic_mempool(n: usize) -> Vec<Option<TxEntry>> {
|
||||
let mut txids: Vec<Txid> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let txid = make_txid(i);
|
||||
txids.push(txid.clone());
|
||||
txids.push(txid);
|
||||
|
||||
let depends: SmallVec<[TxidPrefix; 2]> = match i % 100 {
|
||||
0..=94 => SmallVec::new(),
|
||||
@@ -40,6 +40,7 @@ fn synthetic_mempool(n: usize) -> Vec<Option<TxEntry>> {
|
||||
txid,
|
||||
fee: Sats::from((i as u64).wrapping_mul(137) % 10_000 + 1),
|
||||
vsize: VSize::from(250u64),
|
||||
weight: Weight::from(1000u64),
|
||||
size: 250,
|
||||
depends,
|
||||
first_seen: Timestamp::now(),
|
||||
@@ -51,18 +52,20 @@ fn synthetic_mempool(n: usize) -> Vec<Option<TxEntry>> {
|
||||
|
||||
#[test]
|
||||
#[ignore = "perf benchmark; run with --ignored --nocapture"]
|
||||
fn perf_build_graph() {
|
||||
fn perf_build_clusters() {
|
||||
use crate::steps::rebuilder::clusters::build_clusters;
|
||||
|
||||
let sizes = [1_000usize, 10_000, 50_000, 100_000, 300_000];
|
||||
eprintln!();
|
||||
eprintln!("Graph::build perf (release, single call):");
|
||||
eprintln!("build_clusters perf (release, single call):");
|
||||
eprintln!(" n build");
|
||||
eprintln!(" ------------------------");
|
||||
for &n in &sizes {
|
||||
let entries = synthetic_mempool(n);
|
||||
let _ = Graph::build(&entries);
|
||||
let _ = build_clusters(&entries);
|
||||
|
||||
let t = Instant::now();
|
||||
let g = Graph::build(&entries);
|
||||
let (clusters, _) = build_clusters(&entries);
|
||||
let dt = t.elapsed();
|
||||
let ns = dt.as_nanos();
|
||||
let pretty = if ns >= 1_000_000 {
|
||||
@@ -70,7 +73,7 @@ fn perf_build_graph() {
|
||||
} else {
|
||||
format!("{:.2} µs", ns as f64 / 1_000.0)
|
||||
};
|
||||
eprintln!(" {:<10} {:<10} ({} nodes)", n, pretty, g.len());
|
||||
eprintln!(" {:<10} {:<10} ({} clusters)", n, pretty, clusters.len());
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use brk_types::{Sats, VSize};
|
||||
|
||||
use super::{Chunk, chunk_shapes, make_cluster, run};
|
||||
use crate::cluster::LocalIdx;
|
||||
|
||||
#[test]
|
||||
fn singleton() {
|
||||
let cluster = make_cluster(&[(100, 10)], &[]);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].nodes.len(), 1);
|
||||
assert_eq!(chunks[0].txs.len(), 1);
|
||||
assert_eq!(chunks[0].fee, Sats::from(100u64));
|
||||
assert_eq!(chunks[0].vsize, VSize::from(10u64));
|
||||
}
|
||||
@@ -17,9 +18,9 @@ fn two_chain_parent_richer() {
|
||||
let cluster = make_cluster(&[(100, 10), (1, 1)], &[(0, 1)]);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks.len(), 2);
|
||||
assert!(chunks[0].nodes.contains(&0));
|
||||
assert!(chunks[0].txs.contains(&LocalIdx::from(0u32)));
|
||||
assert_eq!(chunks[0].vsize, VSize::from(10u64));
|
||||
assert!(chunks[1].nodes.contains(&1));
|
||||
assert!(chunks[1].txs.contains(&LocalIdx::from(1u32)));
|
||||
assert_eq!(chunks[1].vsize, VSize::from(1u64));
|
||||
}
|
||||
|
||||
@@ -28,7 +29,7 @@ fn two_chain_child_pays_parent_cpfp() {
|
||||
let cluster = make_cluster(&[(1, 10), (100, 1)], &[(0, 1)]);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].nodes.len(), 2);
|
||||
assert_eq!(chunks[0].txs.len(), 2);
|
||||
assert_eq!(chunks[0].fee, Sats::from(101u64));
|
||||
assert_eq!(chunks[0].vsize, VSize::from(11u64));
|
||||
}
|
||||
@@ -38,7 +39,7 @@ fn v_shape_two_parents_one_child() {
|
||||
let cluster = make_cluster(&[(1, 1), (1, 1), (100, 1)], &[(0, 2), (1, 2)]);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].nodes.len(), 3);
|
||||
assert_eq!(chunks[0].txs.len(), 3);
|
||||
assert_eq!(chunks[0].fee, Sats::from(102u64));
|
||||
assert_eq!(chunks[0].vsize, VSize::from(3u64));
|
||||
}
|
||||
@@ -60,7 +61,7 @@ fn diamond() {
|
||||
);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].nodes.len(), 4);
|
||||
assert_eq!(chunks[0].txs.len(), 4);
|
||||
assert_eq!(chunks[0].fee, Sats::from(103u64));
|
||||
assert_eq!(chunks[0].vsize, VSize::from(4u64));
|
||||
}
|
||||
@@ -72,9 +73,9 @@ fn chain_alternating_high_low() {
|
||||
&[(0, 1), (1, 2), (2, 3)],
|
||||
);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks_total_fee(&chunks), Sats::from(22u64));
|
||||
assert_eq!(chunks_total_vsize(&chunks), VSize::from(4u64));
|
||||
assert_non_increasing(&chunks);
|
||||
assert_eq!(chunks_total_fee(chunks), Sats::from(22u64));
|
||||
assert_eq!(chunks_total_vsize(chunks), VSize::from(4u64));
|
||||
assert_non_increasing(chunks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,9 +85,9 @@ fn chain_starts_low_ends_high() {
|
||||
&[(0, 1), (1, 2), (2, 3)],
|
||||
);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks_total_fee(&chunks), Sats::from(202u64));
|
||||
assert_eq!(chunks_total_vsize(&chunks), VSize::from(4u64));
|
||||
assert_non_increasing(&chunks);
|
||||
assert_eq!(chunks_total_fee(chunks), Sats::from(202u64));
|
||||
assert_eq!(chunks_total_vsize(chunks), VSize::from(4u64));
|
||||
assert_non_increasing(chunks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -96,13 +97,13 @@ fn two_disconnected_clusters_would_each_be_separate() {
|
||||
&[(0, 1), (0, 2), (0, 3), (0, 4), (0, 5)],
|
||||
);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks_total_fee(&chunks), Sats::from(151u64));
|
||||
assert_eq!(chunks_total_vsize(&chunks), VSize::from(6u64));
|
||||
assert_non_increasing(&chunks);
|
||||
assert_eq!(chunks_total_fee(chunks), Sats::from(151u64));
|
||||
assert_eq!(chunks_total_vsize(chunks), VSize::from(6u64));
|
||||
assert_non_increasing(chunks);
|
||||
let mut seen: Vec<usize> = Vec::new();
|
||||
for ch in &chunks {
|
||||
for &n in &ch.nodes {
|
||||
seen.push(n as usize);
|
||||
for ch in chunks {
|
||||
for &local in &ch.txs {
|
||||
seen.push(local.as_usize());
|
||||
}
|
||||
}
|
||||
seen.sort_unstable();
|
||||
@@ -127,11 +128,44 @@ fn shapes_are_stable_on_identical_input() {
|
||||
&[(1, 1), (100, 1), (1, 1), (100, 1)],
|
||||
&[(0, 1), (1, 2), (2, 3)],
|
||||
);
|
||||
let a = chunk_shapes(&run(&cluster));
|
||||
let b = chunk_shapes(&run(&cluster));
|
||||
let a = chunk_shapes(run(&cluster));
|
||||
let b = chunk_shapes(run(&cluster));
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_zero_fee() {
|
||||
let cluster = make_cluster(&[(0, 10)], &[]);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].txs.len(), 1);
|
||||
assert_eq!(chunks[0].fee, Sats::from(0u64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_fee_leftover_after_paying_chunk() {
|
||||
let cluster = make_cluster(&[(0, 1), (10, 1), (0, 1)], &[(0, 1), (1, 2)]);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks_total_vsize(chunks), VSize::from(3u64));
|
||||
assert_eq!(chunks_total_fee(chunks), Sats::from(10u64));
|
||||
let mut seen: Vec<usize> = Vec::new();
|
||||
for ch in chunks {
|
||||
for &local in &ch.txs {
|
||||
seen.push(local.as_usize());
|
||||
}
|
||||
}
|
||||
seen.sort_unstable();
|
||||
assert_eq!(seen, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_zero_fee_chain() {
|
||||
let cluster = make_cluster(&[(0, 1), (0, 1), (0, 1)], &[(0, 1), (1, 2)]);
|
||||
let chunks = run(&cluster);
|
||||
assert_eq!(chunks_total_vsize(chunks), VSize::from(3u64));
|
||||
assert_eq!(chunks_total_fee(chunks), Sats::from(0u64));
|
||||
}
|
||||
|
||||
fn chunks_total_fee(chunks: &[Chunk]) -> Sats {
|
||||
chunks.iter().map(|c| c.fee).sum()
|
||||
}
|
||||
|
||||
@@ -2,44 +2,47 @@ mod basic;
|
||||
mod oracle;
|
||||
mod stress;
|
||||
|
||||
use brk_types::{Sats, VSize};
|
||||
use brk_types::{Sats, Txid, VSize, Weight};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{
|
||||
steps::rebuilder::linearize::{
|
||||
LocalIdx, chunk::Chunk, cluster::Cluster, cluster_node::ClusterNode, sfl::Sfl,
|
||||
},
|
||||
stores::TxIndex,
|
||||
};
|
||||
use crate::cluster::{Chunk, Cluster, ClusterNode, LocalIdx};
|
||||
|
||||
pub(super) fn make_cluster(fees_vsizes: &[(u64, u64)], edges: &[(LocalIdx, LocalIdx)]) -> Cluster {
|
||||
let mut nodes: Vec<ClusterNode> = fees_vsizes
|
||||
/// Test cluster: each node carries its input position as `id`, so
|
||||
/// invariant checks can map `LocalIdx` (post-permutation) back to the
|
||||
/// caller's `fees_vsizes` / `edges` index space.
|
||||
pub(super) type TestCluster = Cluster<u32>;
|
||||
|
||||
pub(super) fn make_cluster(fees_vsizes: &[(u64, u64)], edges: &[(u32, u32)]) -> TestCluster {
|
||||
let mut parents: Vec<SmallVec<[LocalIdx; 2]>> =
|
||||
(0..fees_vsizes.len()).map(|_| SmallVec::new()).collect();
|
||||
for &(p, c) in edges {
|
||||
parents[c as usize].push(LocalIdx::from(p));
|
||||
}
|
||||
|
||||
let nodes: Vec<ClusterNode<u32>> = fees_vsizes
|
||||
.iter()
|
||||
.zip(parents)
|
||||
.enumerate()
|
||||
.map(|(i, &(fee, vsize))| ClusterNode {
|
||||
tx_index: TxIndex::from(i),
|
||||
.map(|(i, (&(fee, vsize), parents))| ClusterNode {
|
||||
id: i as u32,
|
||||
txid: Txid::COINBASE,
|
||||
fee: Sats::from(fee),
|
||||
vsize: VSize::from(vsize),
|
||||
parents: SmallVec::new(),
|
||||
children: SmallVec::new(),
|
||||
weight: Weight::from(vsize * 4),
|
||||
parents,
|
||||
})
|
||||
.collect();
|
||||
|
||||
for &(p, c) in edges {
|
||||
nodes[c as usize].parents.push(p);
|
||||
nodes[p as usize].children.push(c);
|
||||
}
|
||||
|
||||
Cluster::new(nodes)
|
||||
}
|
||||
|
||||
pub(super) fn run(cluster: &Cluster) -> Vec<Chunk> {
|
||||
Sfl::linearize(cluster)
|
||||
pub(super) fn run(cluster: &TestCluster) -> &[Chunk] {
|
||||
&cluster.chunks
|
||||
}
|
||||
|
||||
pub(super) fn chunk_shapes(chunks: &[Chunk]) -> Vec<(usize, Sats, VSize)> {
|
||||
chunks
|
||||
.iter()
|
||||
.map(|c| (c.nodes.len(), c.fee, c.vsize))
|
||||
.map(|c| (c.txs.len(), c.fee, c.vsize))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use brk_types::{FeeRate, Sats, VSize};
|
||||
|
||||
use super::{Chunk, LocalIdx, Sfl, make_cluster, run};
|
||||
use super::{Chunk, make_cluster, run};
|
||||
|
||||
fn to_typed(fv: &[(u64, u64)]) -> Vec<(Sats, VSize)> {
|
||||
fv.iter()
|
||||
@@ -29,37 +29,37 @@ fn canonical_chunking(path: &[(Sats, VSize)]) -> Vec<(Sats, VSize)> {
|
||||
chunks
|
||||
}
|
||||
|
||||
fn all_topo_orders(parents: &[Vec<LocalIdx>]) -> Vec<Vec<LocalIdx>> {
|
||||
fn all_topo_orders(parents: &[Vec<u32>]) -> Vec<Vec<u32>> {
|
||||
let n = parents.len();
|
||||
let indegree: Vec<u32> = parents.iter().map(|p| p.len() as u32).collect();
|
||||
let children: Vec<Vec<LocalIdx>> = {
|
||||
let children: Vec<Vec<u32>> = {
|
||||
let mut out = vec![Vec::new(); n];
|
||||
for (c, ps) in parents.iter().enumerate() {
|
||||
for &p in ps {
|
||||
out[p as usize].push(c as LocalIdx);
|
||||
out[p as usize].push(c as u32);
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut current: Vec<LocalIdx> = Vec::new();
|
||||
let mut current: Vec<u32> = Vec::new();
|
||||
let mut indeg = indegree.clone();
|
||||
walk(&children, &mut indeg, &mut current, n, &mut results);
|
||||
return results;
|
||||
|
||||
fn walk(
|
||||
children: &[Vec<LocalIdx>],
|
||||
children: &[Vec<u32>],
|
||||
indeg: &mut [u32],
|
||||
current: &mut Vec<LocalIdx>,
|
||||
current: &mut Vec<u32>,
|
||||
n: usize,
|
||||
out: &mut Vec<Vec<LocalIdx>>,
|
||||
out: &mut Vec<Vec<u32>>,
|
||||
) {
|
||||
if current.len() == n {
|
||||
out.push(current.clone());
|
||||
return;
|
||||
}
|
||||
let ready: Vec<LocalIdx> = (0..n as LocalIdx)
|
||||
let ready: Vec<u32> = (0..n as u32)
|
||||
.filter(|&i| indeg[i as usize] == 0)
|
||||
.collect();
|
||||
for v in ready {
|
||||
@@ -78,10 +78,7 @@ fn all_topo_orders(parents: &[Vec<LocalIdx>]) -> Vec<Vec<LocalIdx>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn oracle_best(
|
||||
fees_vsizes: &[(Sats, VSize)],
|
||||
edges: &[(LocalIdx, LocalIdx)],
|
||||
) -> Vec<(Sats, VSize)> {
|
||||
fn oracle_best(fees_vsizes: &[(Sats, VSize)], edges: &[(u32, u32)]) -> Vec<(Sats, VSize)> {
|
||||
let n = fees_vsizes.len();
|
||||
let mut parents = vec![Vec::new(); n];
|
||||
for &(p, c) in edges {
|
||||
@@ -166,10 +163,10 @@ fn chunk_rate(chunks: &[Chunk]) -> Vec<(Sats, VSize)> {
|
||||
chunks.iter().map(|c| (c.fee, c.vsize)).collect()
|
||||
}
|
||||
|
||||
fn assert_matches_oracle(fees_vsizes: &[(u64, u64)], edges: &[(LocalIdx, LocalIdx)]) {
|
||||
fn assert_matches_oracle(fees_vsizes: &[(u64, u64)], edges: &[(u32, u32)]) {
|
||||
let cluster = make_cluster(fees_vsizes, edges);
|
||||
let chunks = run(&cluster);
|
||||
let got = chunk_rate(&chunks);
|
||||
let got = chunk_rate(chunks);
|
||||
let want = oracle_best(&to_typed(fees_vsizes), edges);
|
||||
|
||||
let got_cum = cumulative(&got);
|
||||
@@ -265,7 +262,7 @@ impl DagRng {
|
||||
}
|
||||
}
|
||||
|
||||
type FvAndEdges = (Vec<(u64, u64)>, Vec<(LocalIdx, LocalIdx)>);
|
||||
type FvAndEdges = (Vec<(u64, u64)>, Vec<(u32, u32)>);
|
||||
|
||||
fn random_dag(n: usize, seed: u64) -> FvAndEdges {
|
||||
let mut rng = DagRng::new(seed);
|
||||
@@ -279,15 +276,15 @@ fn random_dag(n: usize, seed: u64) -> FvAndEdges {
|
||||
let mut edges = Vec::new();
|
||||
for i in 1..n {
|
||||
let k = rng.range(4) as usize;
|
||||
let mut picks: Vec<LocalIdx> = Vec::new();
|
||||
let mut picks: Vec<u32> = Vec::new();
|
||||
for _ in 0..k {
|
||||
let p = rng.range(i as u64) as LocalIdx;
|
||||
let p = rng.range(i as u64) as u32;
|
||||
if !picks.contains(&p) {
|
||||
picks.push(p);
|
||||
}
|
||||
}
|
||||
for p in picks {
|
||||
edges.push((p, i as LocalIdx));
|
||||
edges.push((p, i as u32));
|
||||
}
|
||||
}
|
||||
(fees_vsizes, edges)
|
||||
@@ -301,7 +298,7 @@ fn assert_optimal_on_random(n: usize, seed: u64) {
|
||||
let (fv, edges) = random_dag(n, seed);
|
||||
let cluster = make_cluster(&fv, &edges);
|
||||
let chunks = run(&cluster);
|
||||
let got = chunk_rate(&chunks);
|
||||
let got = chunk_rate(chunks);
|
||||
|
||||
let want = oracle_best(&to_typed(&fv), &edges);
|
||||
|
||||
@@ -355,7 +352,7 @@ fn optimality_gap_of(got: &[(Sats, VSize)], want: &[(Sats, VSize)]) -> Option<u1
|
||||
fn optimality_gap(n: usize, seed: u64) -> Option<u128> {
|
||||
let (fv, edges) = random_dag(n, seed);
|
||||
let cluster = make_cluster(&fv, &edges);
|
||||
let chunks = Sfl::linearize(&cluster);
|
||||
let chunks = run(&cluster);
|
||||
let got: Vec<(Sats, VSize)> = chunks.iter().map(|c| (c.fee, c.vsize)).collect();
|
||||
let want = oracle_best(&to_typed(&fv), &edges);
|
||||
optimality_gap_of(&got, &want)
|
||||
@@ -433,7 +430,7 @@ fn perf_linearize() {
|
||||
let t = Instant::now();
|
||||
let mut sink = 0u64;
|
||||
for c in &clusters {
|
||||
for chunk in Sfl::linearize(c) {
|
||||
for chunk in &c.chunks {
|
||||
sink = sink.wrapping_add(u64::from(chunk.fee));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use brk_types::{Sats, VSize};
|
||||
|
||||
use super::{Chunk, LocalIdx, make_cluster, run};
|
||||
use super::{TestCluster, make_cluster, run};
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
@@ -20,7 +20,7 @@ impl Rng {
|
||||
}
|
||||
}
|
||||
|
||||
type FvAndEdges = (Vec<(u64, u64)>, Vec<(LocalIdx, LocalIdx)>);
|
||||
type FvAndEdges = (Vec<(u64, u64)>, Vec<(u32, u32)>);
|
||||
|
||||
fn random_cluster(n: usize, seed: u64) -> FvAndEdges {
|
||||
let mut rng = Rng::new(seed);
|
||||
@@ -34,58 +34,70 @@ fn random_cluster(n: usize, seed: u64) -> FvAndEdges {
|
||||
let mut edges = Vec::new();
|
||||
for i in 1..n {
|
||||
let k = rng.range(4) as usize;
|
||||
let mut picks: Vec<LocalIdx> = Vec::new();
|
||||
let mut picks: Vec<u32> = Vec::new();
|
||||
for _ in 0..k {
|
||||
let p = rng.range(i as u64) as LocalIdx;
|
||||
let p = rng.range(i as u64) as u32;
|
||||
if !picks.contains(&p) {
|
||||
picks.push(p);
|
||||
}
|
||||
}
|
||||
for p in picks {
|
||||
edges.push((p, i as LocalIdx));
|
||||
edges.push((p, i as u32));
|
||||
}
|
||||
}
|
||||
|
||||
(fees_vsizes, edges)
|
||||
}
|
||||
|
||||
fn check_invariants(fees_vsizes: &[(u64, u64)], edges: &[(LocalIdx, LocalIdx)], chunks: &[Chunk]) {
|
||||
/// `cluster.nodes` is in topological order, so each node's `LocalIdx`
|
||||
/// may differ from the caller's input position. The cluster's `id`
|
||||
/// field carries the input index, and we use it to map back when the
|
||||
/// invariant being checked is expressed in input space (fees/vsizes
|
||||
/// table, edges list).
|
||||
fn check_invariants(fees_vsizes: &[(u64, u64)], edges: &[(u32, u32)], cluster: &TestCluster) {
|
||||
let n = fees_vsizes.len();
|
||||
let chunks = &cluster.chunks;
|
||||
let input_of = |l: crate::cluster::LocalIdx| cluster.nodes[l.as_usize()].id as usize;
|
||||
|
||||
let mut seen = vec![false; n];
|
||||
for chunk in chunks {
|
||||
for &local in &chunk.nodes {
|
||||
assert!(
|
||||
!seen[local as usize],
|
||||
"node {} appears in multiple chunks",
|
||||
local
|
||||
);
|
||||
seen[local as usize] = true;
|
||||
for &local in &chunk.txs {
|
||||
let i = input_of(local);
|
||||
assert!(!seen[i], "input node {} appears in multiple chunks", i);
|
||||
seen[i] = true;
|
||||
}
|
||||
}
|
||||
for (i, s) in seen.iter().enumerate() {
|
||||
assert!(*s, "node {} missing from all chunks", i);
|
||||
assert!(*s, "input node {} missing from all chunks", i);
|
||||
}
|
||||
|
||||
for chunk in chunks {
|
||||
let fee: u64 = chunk.nodes.iter().map(|&l| fees_vsizes[l as usize].0).sum();
|
||||
let vsize: u64 = chunk.nodes.iter().map(|&l| fees_vsizes[l as usize].1).sum();
|
||||
let fee: u64 = chunk
|
||||
.txs
|
||||
.iter()
|
||||
.map(|&l| fees_vsizes[input_of(l)].0)
|
||||
.sum();
|
||||
let vsize: u64 = chunk
|
||||
.txs
|
||||
.iter()
|
||||
.map(|&l| fees_vsizes[input_of(l)].1)
|
||||
.sum();
|
||||
assert_eq!(chunk.fee, Sats::from(fee), "chunk fee mismatch");
|
||||
assert_eq!(chunk.vsize, VSize::from(vsize), "chunk vsize mismatch");
|
||||
}
|
||||
|
||||
let chunk_of: Vec<usize> = {
|
||||
let chunk_of_input: Vec<usize> = {
|
||||
let mut out = vec![usize::MAX; n];
|
||||
for (ci, chunk) in chunks.iter().enumerate() {
|
||||
for &local in &chunk.nodes {
|
||||
out[local as usize] = ci;
|
||||
for &local in &chunk.txs {
|
||||
out[input_of(local)] = ci;
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
for &(p, c) in edges {
|
||||
let cp = chunk_of[p as usize];
|
||||
let cc = chunk_of[c as usize];
|
||||
let cp = chunk_of_input[p as usize];
|
||||
let cc = chunk_of_input[c as usize];
|
||||
assert!(
|
||||
cp <= cc,
|
||||
"parent {} in chunk {} but child {} in earlier chunk {}",
|
||||
@@ -114,8 +126,7 @@ fn random_small_clusters() {
|
||||
let n = 2 + (seed % 10) as usize;
|
||||
let (fv, edges) = random_cluster(n, seed.wrapping_add(1));
|
||||
let cluster = make_cluster(&fv, &edges);
|
||||
let chunks = run(&cluster);
|
||||
check_invariants(&fv, &edges, &chunks);
|
||||
check_invariants(&fv, &edges, &cluster);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,8 +136,7 @@ fn random_medium_clusters() {
|
||||
let n = 10 + (seed % 20) as usize;
|
||||
let (fv, edges) = random_cluster(n, seed.wrapping_add(100));
|
||||
let cluster = make_cluster(&fv, &edges);
|
||||
let chunks = run(&cluster);
|
||||
check_invariants(&fv, &edges, &chunks);
|
||||
check_invariants(&fv, &edges, &cluster);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +145,7 @@ fn random_large_clusters() {
|
||||
for seed in 0..10u64 {
|
||||
let (fv, edges) = random_cluster(30, seed.wrapping_add(1000));
|
||||
let cluster = make_cluster(&fv, &edges);
|
||||
let chunks = run(&cluster);
|
||||
check_invariants(&fv, &edges, &chunks);
|
||||
check_invariants(&fv, &edges, &cluster);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +163,6 @@ fn random_cluster_at_policy_limit() {
|
||||
for seed in 0..5u64 {
|
||||
let (fv, edges) = random_cluster(100, seed.wrapping_add(9000));
|
||||
let cluster = make_cluster(&fv, &edges);
|
||||
let chunks = run(&cluster);
|
||||
check_invariants(&fv, &edges, &chunks);
|
||||
check_invariants(&fv, &edges, &cluster);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user