Skip to content

Commit ae689f5

Browse files
rabbitson87claude
andcommitted
perf(rpc): read the chain-info figures off the block log instead of folding it
`Context::blocks` holds one record per applied block and grows for the life of the process — ~963k entries on a mainnet node. `getblockchaininfo` and `getchaintxstats` both walked all of it to produce five scalars, and `getblockchaininfo` used exactly one of them: it folded 963k records to report `size_on_disk` and discarded the rest. The walk ran under the log read lock, which is the lock `apply_block` takes to append the record for the block it just connected, so the call stalled block application for its duration and got slower with every block. `BlockLog` now maintains what the fold used to compute. `total_body_size` is a running sum and answers `size_on_disk` outright. `cumulative_tx_count[i]` is the sum over `records[..=i]` and answers both transaction counts as differences across two boundaries. It is a type rather than a `Vec<BlockRecord>` with totals kept beside it because the log is appended from `apply`, from `Context::add_block` and from tests; a total any of those could forget to update is a total that will drift. Reads are unchanged - it derefs to `[BlockRecord]`. `chain_stats` replaces the fold for `getchaintxstats`. The log is appended in height order and only ever popped from the tail, so it binary-searches the three boundaries it needs - the same property `Context::block_at_height` already relies on. Only the caller's window is then walked, and only for `earliest_window_time`: block timestamps are not monotonic, so no prefix sum can answer a minimum over them. Measured over one fixture in one process, `before_fold` against `after_indexed`: 10,000 records: 19.32 us -> 3.83 us 5.0x 100,000 records: 552.70 us -> 3.94 us 140x 500,000 records: 4.214 ms -> 4.24 us 993x 963,124 records: 7.450 ms -> 3.77 us 1,977x End to end the dispatch is flat: `getblockchaininfo` 2.79 us and `getchaintxstats` 4.43 us at 963k records, against 2.66 us and 4.88 us at 10k. Prefix sums rather than one more running total because the benchmark caught the cheaper version being a cliff. A single total answers `txcount` only when the applied tip is the log last record; anywhere else it subtracts the records above the tip, and with no applied tip that tail is the whole log. `getchaintxstats` measured 6.23 ms while its own reader measured 4.79 us. Prefix sums cost 8 bytes per record, ~7.7 MB at a mainnet tip against the ~254 MB the records occupy. `fold_block_records` is retained whole as the oracle and as the benchmark before arm. `chain_stats_matches_the_fold_it_replaced` sweeps every applied height against every window length over a log with a duplicate height and a backwards timestamp. Ten mutations, all killed; see docs/benchmarks/chain-info-fold.md, which also records that the first audit run misreported every kill as an invalid mutation because the harness could not tell a red test from a broken build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent dc5dd28 commit ae689f5

14 files changed

Lines changed: 716 additions & 99 deletions

File tree

crates/mempool/benches/pareto.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
use std::hint::black_box;
1717
use std::sync::Arc;
1818

19-
use bitcoin::{Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, absolute, transaction};
19+
use bitcoin::{
20+
Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, absolute, transaction,
21+
};
2022
use bitcoin_rs_mempool::{MempoolEntry, ParetoFront};
2123
use criterion::{Criterion, criterion_group, criterion_main};
2224

crates/node/src/apply.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use bitcoin_rs_chain::{BlockTree, NodeId, TipSnapshot};
1212
use bitcoin_rs_consensus::{MAX_SCRIPT_SIZE, rust_path::UtxoView};
1313
use bitcoin_rs_mempool::Mempool;
1414
use bitcoin_rs_primitives::{Hash256, Network, OutPoint};
15-
use bitcoin_rs_rpc::BlockRecord;
15+
use bitcoin_rs_rpc::{BlockLog, BlockRecord};
1616
use bitcoin_rs_utxo::{
1717
LiveOutput, LiveOutputMeta, UtxoSet,
1818
set::{BorrowedBlockChanges, BorrowedUtxoAdd},
@@ -944,7 +944,7 @@ pub struct ApplyHandles {
944944
/// Shared mempool.
945945
pub mempool: Arc<RwLock<Mempool>>,
946946
/// Shared block records exposed to RPC handlers.
947-
pub blocks: Arc<RwLock<Vec<BlockRecord>>>,
947+
pub blocks: Arc<RwLock<BlockLog>>,
948948
/// Shared transaction map exposed to RPC handlers.
949949
pub transactions: Arc<RwLock<HashMap<Txid, Transaction>>>,
950950
/// Shared ZMQ-event publisher (default: `NoOpZmqPublisher`).
@@ -1010,7 +1010,7 @@ impl ApplyHandles {
10101010
tx_index_runtime: Option<Arc<crate::txindex_worker::TxIndexRuntime>>,
10111011
filter_index: Arc<Box<dyn bitcoin_rs_filters::FilterIndexLike>>,
10121012
mempool: Arc<RwLock<Mempool>>,
1013-
blocks: Arc<RwLock<Vec<BlockRecord>>>,
1013+
blocks: Arc<RwLock<BlockLog>>,
10141014
transactions: Arc<RwLock<HashMap<Txid, Transaction>>>,
10151015
zmq_publisher: Arc<dyn crate::ZmqPublisher>,
10161016
) -> Self {
@@ -8645,7 +8645,7 @@ mod consensus_rule_tests {
86458645
None,
86468646
filter_index,
86478647
Arc::new(RwLock::new(Mempool::new(MempoolLimits::default()))),
8648-
Arc::new(RwLock::new(Vec::new())),
8648+
Arc::new(RwLock::new(BlockLog::new())),
86498649
Arc::new(RwLock::new(HashMap::<bitcoin::Txid, Transaction>::new())),
86508650
Arc::new(crate::NoOpZmqPublisher),
86518651
)
@@ -9659,7 +9659,7 @@ mod consensus_rule_tests {
96599659
None,
96609660
noop_filter_index(),
96619661
Arc::new(RwLock::new(Mempool::new(MempoolLimits::default()))),
9662-
Arc::new(RwLock::new(Vec::new())),
9662+
Arc::new(RwLock::new(BlockLog::new())),
96639663
Arc::new(RwLock::new(HashMap::<bitcoin::Txid, Transaction>::new())),
96649664
Arc::new(crate::NoOpZmqPublisher),
96659665
)

crates/node/src/block_source.rs

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ use bitcoin::hex::FromHex as _;
1414
use bitcoin_rs_chain::{BlockTree, NodeId, TipSnapshot};
1515
use bitcoin_rs_index::BlockSource;
1616
use bitcoin_rs_primitives::Hash256;
17-
use bitcoin_rs_rpc::{BlockBodySource, BlockRecord};
17+
use bitcoin_rs_rpc::{BlockBodySource, BlockLog, BlockRecord};
1818
use parking_lot::RwLock;
1919

2020
/// Reads decoded Bitcoin blocks from the shared in-memory log.
2121
///
2222
/// Cheap-clonable; the inner Arc is shared with `NodeState`'s record store.
2323
#[derive(Clone)]
2424
pub struct NodeBlockSource {
25-
blocks: Arc<RwLock<Vec<BlockRecord>>>,
25+
blocks: Arc<RwLock<BlockLog>>,
2626
block_body_source: Option<Arc<dyn BlockBodySource>>,
2727
block_tree: Option<Arc<RwLock<BlockTree>>>,
2828
applied_tip: Option<Arc<arc_swap::ArcSwapOption<TipSnapshot>>>,
@@ -31,7 +31,7 @@ pub struct NodeBlockSource {
3131
impl NodeBlockSource {
3232
/// Builds a source over the shared block-record vector.
3333
#[must_use]
34-
pub const fn new(blocks: Arc<RwLock<Vec<BlockRecord>>>) -> Self {
34+
pub const fn new(blocks: Arc<RwLock<BlockLog>>) -> Self {
3535
Self {
3636
blocks,
3737
block_body_source: None,
@@ -378,7 +378,7 @@ mod tests {
378378
fn block_at_height_returns_some_after_record_added() {
379379
let genesis = genesis_block(Network::Regtest);
380380
let record = BlockRecord::from_block(0, &genesis);
381-
let blocks = Arc::new(RwLock::new(vec![record]));
381+
let blocks = Arc::new(RwLock::new(BlockLog::from_iter([record])));
382382
let source = NodeBlockSource::new(blocks);
383383
let Some(decoded) = source.block_at_height(0) else {
384384
panic!("expected block at height 0");
@@ -424,7 +424,7 @@ mod tests {
424424
hash: record.hash,
425425
bytes: bytes.clone(),
426426
});
427-
let blocks = Arc::new(RwLock::new(vec![record]));
427+
let blocks = Arc::new(RwLock::new(BlockLog::from_iter([record])));
428428
let source = NodeBlockSource::new(blocks).with_block_body_source(body_source);
429429

430430
for offset in 0..u32::try_from(bytes.len())? {
@@ -455,7 +455,7 @@ mod tests {
455455
// `block_at_height`, which is what it would have done anyway.
456456
let genesis = genesis_block(Network::Regtest);
457457
let record = BlockRecord::from_block(0, &genesis);
458-
let blocks = Arc::new(RwLock::new(vec![record]));
458+
let blocks = Arc::new(RwLock::new(BlockLog::from_iter([record])));
459459
let source = NodeBlockSource::new(blocks);
460460

461461
assert!(source.block_bytes_at_height(0, 0, 4).is_none());
@@ -467,7 +467,7 @@ mod tests {
467467

468468
#[test]
469469
fn block_at_height_returns_none_when_missing() {
470-
let blocks: Arc<RwLock<Vec<BlockRecord>>> = Arc::new(RwLock::new(Vec::new()));
470+
let blocks: Arc<RwLock<BlockLog>> = Arc::new(RwLock::new(BlockLog::new()));
471471
let source = NodeBlockSource::new(blocks);
472472
assert!(source.block_at_height(0).is_none());
473473
}
@@ -493,7 +493,7 @@ mod tests {
493493
hash: record.hash,
494494
bytes: serialize(&genesis),
495495
});
496-
let blocks = Arc::new(RwLock::new(vec![record]));
496+
let blocks = Arc::new(RwLock::new(BlockLog::from_iter([record])));
497497
let source = NodeBlockSource::new(blocks).with_block_body_source(body_source);
498498

499499
let Some(decoded) = source.block_at_height(0) else {
@@ -514,7 +514,9 @@ mod tests {
514514
BlockRecord::from_block(2, &first),
515515
BlockRecord::from_block(2, &second),
516516
];
517-
let source = NodeBlockSource::new(Arc::new(RwLock::new(records)));
517+
let source = NodeBlockSource::new(Arc::new(RwLock::new(
518+
records.into_iter().collect::<BlockLog>(),
519+
)));
518520

519521
let Some(decoded) = source.block_at_height(2) else {
520522
panic!("expected duplicate height record");
@@ -534,7 +536,7 @@ mod tests {
534536
let tree = Arc::new(RwLock::new(tree));
535537

536538
// Empty record vector — simulates post-checkpoint-restore state.
537-
let blocks: Arc<RwLock<Vec<BlockRecord>>> = Arc::new(RwLock::new(Vec::new()));
539+
let blocks: Arc<RwLock<BlockLog>> = Arc::new(RwLock::new(BlockLog::new()));
538540
let source = NodeBlockSource::new(blocks)
539541
.with_block_body_source(Arc::new(FixedBody {
540542
height: 0,
@@ -566,7 +568,7 @@ mod tests {
566568

567569
// Record vector has a STALE entry at height 0 (different hash).
568570
let stale_record = BlockRecord::from_block(0, &stale_block);
569-
let blocks = Arc::new(RwLock::new(vec![stale_record]));
571+
let blocks = Arc::new(RwLock::new(BlockLog::from_iter([stale_record])));
570572

571573
let body_source = Arc::new(CorrectBody {
572574
hash: correct_hash,
@@ -618,7 +620,7 @@ mod tests {
618620
applied_tip.store(Some(Arc::new(applied)));
619621

620622
let record = BlockRecord::from_block(0, &genesis);
621-
let blocks = Arc::new(RwLock::new(vec![record]));
623+
let blocks = Arc::new(RwLock::new(BlockLog::from_iter([record])));
622624
let source = NodeBlockSource::new(blocks)
623625
.with_block_tree(tree)
624626
.with_applied_tip(applied_tip);

crates/node/src/p2p_chain.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,21 @@ use bitcoin::p2p::message_blockdata::Inventory;
1313
use bitcoin_rs_chain::BlockTree;
1414
use bitcoin_rs_p2p::{ChainQuery, InventoryResponse};
1515
use bitcoin_rs_primitives::Hash256;
16-
use bitcoin_rs_rpc::{BlockBodySource, BlockRecord};
16+
use bitcoin_rs_rpc::{BlockBodySource, BlockLog};
1717
use parking_lot::RwLock;
1818

1919
/// Read-only in-memory active-chain view for P2P `getheaders` / `getdata`.
2020
#[derive(Clone)]
2121
pub struct NodeP2pChainQuery {
2222
block_tree: Arc<RwLock<BlockTree>>,
23-
blocks: Arc<RwLock<Vec<BlockRecord>>>,
23+
blocks: Arc<RwLock<BlockLog>>,
2424
block_body_source: Option<Arc<dyn BlockBodySource>>,
2525
}
2626

2727
impl NodeP2pChainQuery {
2828
/// Builds a P2P chain query view over the node's shared active-chain state.
2929
#[must_use]
30-
pub const fn new(
31-
block_tree: Arc<RwLock<BlockTree>>,
32-
blocks: Arc<RwLock<Vec<BlockRecord>>>,
33-
) -> Self {
30+
pub const fn new(block_tree: Arc<RwLock<BlockTree>>, blocks: Arc<RwLock<BlockLog>>) -> Self {
3431
Self {
3532
block_tree,
3633
blocks,
@@ -201,6 +198,7 @@ mod tests {
201198
use bitcoin::pow::CompactTarget;
202199
use bitcoin::{Block, TxMerkleNode, Txid};
203200
use bitcoin_rs_chain::NodeStatus;
201+
use bitcoin_rs_rpc::BlockRecord;
204202

205203
#[test]
206204
fn getheaders_empty_locator_returns_only_active_stop() -> Result<(), Box<dyn std::error::Error>>
@@ -277,7 +275,7 @@ mod tests {
277275
tree.insert_node(Some(genesis_id), fork1, NodeStatus::Stale)?;
278276
let query = NodeP2pChainQuery::new(
279277
Arc::new(RwLock::new(tree)),
280-
Arc::new(RwLock::new(Vec::new())),
278+
Arc::new(RwLock::new(BlockLog::new())),
281279
);
282280

283281
let response = query.headers_after(&[fork1.block_hash()], BlockHash::all_zeros(), 10);
@@ -378,7 +376,7 @@ mod tests {
378376
}
379377
Ok(NodeP2pChainQuery::new(
380378
Arc::new(RwLock::new(tree)),
381-
Arc::new(RwLock::new(records)),
379+
Arc::new(RwLock::new(records.into_iter().collect::<BlockLog>())),
382380
))
383381
}
384382

crates/node/src/state.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use bitcoin::hex::FromHex as _;
1111
use bitcoin::{Transaction, Txid};
1212
use bitcoin_rs_chain::TipSnapshot;
1313
use bitcoin_rs_rpc::{
14-
BlockBodyMetadata, BlockBodySource, BlockRecord, NetworkState, PruneResult, PruneService,
14+
BlockBodyMetadata, BlockBodySource, BlockLog, NetworkState, PruneResult, PruneService,
1515
PruneServiceError, PruneStatus, ZmqNotification,
1616
};
1717
use compact_str::CompactString;
@@ -330,7 +330,7 @@ impl NodeStorage {
330330
&self,
331331
block_files: &Arc<FlatFileBlockStore>,
332332
block_body_store: &Arc<dyn crate::apply::PruneBodyStore>,
333-
blocks: Arc<RwLock<Vec<BlockRecord>>>,
333+
blocks: Arc<RwLock<BlockLog>>,
334334
transactions: Arc<RwLock<HashMap<Txid, Transaction>>>,
335335
durable_tip_height: &Arc<AtomicU32>,
336336
) -> Result<Arc<dyn PruneService>> {
@@ -549,7 +549,7 @@ pub struct NodePruneService<S: KvStore> {
549549
store: Arc<S>,
550550
block_files: Arc<FlatFileBlockStore>,
551551
block_body_store: Arc<dyn crate::apply::PruneBodyStore>,
552-
blocks: Arc<RwLock<Vec<BlockRecord>>>,
552+
blocks: Arc<RwLock<BlockLog>>,
553553
transactions: Arc<RwLock<HashMap<Txid, Transaction>>>,
554554
pruneheight: Mutex<Option<u32>>,
555555
/// Height the last clean checkpoint would restore to, 0 when none exists.
@@ -565,7 +565,7 @@ impl<S: KvStore> NodePruneService<S> {
565565
store: Arc<S>,
566566
block_files: Arc<FlatFileBlockStore>,
567567
block_body_store: Arc<dyn crate::apply::PruneBodyStore>,
568-
blocks: Arc<RwLock<Vec<BlockRecord>>>,
568+
blocks: Arc<RwLock<BlockLog>>,
569569
transactions: Arc<RwLock<HashMap<Txid, Transaction>>>,
570570
durable_tip_height: Arc<AtomicU32>,
571571
) -> Result<Self> {
@@ -659,7 +659,7 @@ impl<S: KvStore> PruneService for NodePruneService<S> {
659659
}
660660
}
661661

662-
for record in blocks.iter_mut() {
662+
for record in blocks.records_mut() {
663663
if record.height < updated_pruneheight {
664664
record.block_hex = String::new();
665665
}
@@ -870,7 +870,7 @@ pub struct NodeState {
870870
chain_tip: Arc<ArcSwapOption<TipSnapshot>>,
871871
applied_tip: Arc<ArcSwapOption<TipSnapshot>>,
872872
block_tree: Arc<RwLock<bitcoin_rs_chain::BlockTree>>,
873-
blocks: Arc<RwLock<Vec<BlockRecord>>>,
873+
blocks: Arc<RwLock<BlockLog>>,
874874
transactions: Arc<RwLock<HashMap<Txid, Transaction>>>,
875875
network: Arc<RwLock<NetworkState>>,
876876
peers: Arc<RwLock<Vec<bitcoin_rs_p2p::PeerInfo>>>,
@@ -1058,7 +1058,7 @@ impl NodeState {
10581058
if let Some(restored_applied_tip) = restored_applied_tip {
10591059
applied_tip.store(Some(Arc::new(restored_applied_tip)));
10601060
}
1061-
let blocks = Arc::new(RwLock::new(Vec::new()));
1061+
let blocks = Arc::new(RwLock::new(BlockLog::new()));
10621062
let transactions = Arc::new(RwLock::new(HashMap::new()));
10631063
let tx_index_open = open_tx_index(&config)?;
10641064
let (tx_index_runtime, tx_index_worker, tx_index_query) = match tx_index_open {
@@ -1374,7 +1374,7 @@ impl NodeState {
13741374

13751375
/// Returns the shared block-records handle exposed to RPC handlers.
13761376
#[must_use]
1377-
pub fn blocks(&self) -> Arc<RwLock<Vec<BlockRecord>>> {
1377+
pub fn blocks(&self) -> Arc<RwLock<BlockLog>> {
13781378
Arc::clone(&self.blocks)
13791379
}
13801380

@@ -1555,6 +1555,7 @@ impl Drop for NodeState {
15551555
mod tests {
15561556
use super::*;
15571557
use bitcoin::hashes::Hash as _;
1558+
use bitcoin_rs_rpc::BlockRecord;
15581559

15591560
#[test]
15601561
fn open_constructs_empty_handles() -> anyhow::Result<()> {

crates/node/src/sync.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7398,7 +7398,7 @@ mod tests {
73987398
None,
73997399
noop_filter_index(),
74007400
Arc::new(RwLock::new(Mempool::new(MempoolLimits::default()))),
7401-
Arc::new(RwLock::new(Vec::new())),
7401+
Arc::new(RwLock::new(bitcoin_rs_rpc::BlockLog::new())),
74027402
Arc::new(RwLock::new(HashMap::<Txid, Transaction>::new())),
74037403
Arc::new(crate::NoOpZmqPublisher),
74047404
)

crates/node/src/txindex_worker_query_tests.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,9 @@ impl QueryFixture {
232232
} else {
233233
Vec::new()
234234
};
235-
let block_source = NodeBlockSource::new(Arc::new(RwLock::new(records)));
235+
let block_source = NodeBlockSource::new(Arc::new(RwLock::new(
236+
records.into_iter().collect::<bitcoin_rs_rpc::BlockLog>(),
237+
)));
236238
let engine =
237239
TxIndexQueryEngine::new(runtime, reader, block_source, tree, applied_tip, None);
238240
Ok(Self { engine })

crates/node/tests/sync_smoke.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,7 @@ fn apply_handles_with_coin_stats_and_utxo(
572572
None,
573573
noop_filter_index(),
574574
Arc::new(RwLock::new(Mempool::new(MempoolLimits::default()))),
575-
Arc::new(RwLock::new(Vec::new())),
575+
Arc::new(RwLock::new(bitcoin_rs_rpc::BlockLog::new())),
576576
Arc::new(RwLock::new(HashMap::<Txid, Transaction>::new())),
577577
Arc::new(bitcoin_rs_node::NoOpZmqPublisher),
578578
);

0 commit comments

Comments
 (0)