Skip to content

Commit 0881ee0

Browse files
rabbitson87claude
andcommitted
perf(rpc): resolve block records by binary search, not by walking the chain
Context::record_for_hash walked every record in the block log to find one. The log holds one entry per applied block and nothing removes it, so at a mainnet tip that is ~963k comparisons per call -- and getblock, getblockheader and the index read path all land there. It is the same shape as the gettxoutproof scan, on a hotter path. Step 1 already knows the height, because the block tree gave it. That makes the lookup a binary search over a height-ordered log. The log is append-only in height order -- add_block pushes, and the only removal is the tail pop a disconnect performs on the applied tip -- and the codebase already relied on that: crates/node had record_at_height and record_at_height_hash doing exactly this. Rather than copy them, they move to crates/rpc beside BlockRecord and node imports them, so one implementation serves both and forty lines of duplicate go away. Context::block_by_height and Context::block_hash_at_height were scanning the same way and now use them too. Step 2 stays linear on purpose. Without the tree there is no height to search on, and a hash-keyed index would have to be maintained for every block to serve a path only legacy state reaches. The comment says so. Three tests cover the parts the rpc side never had: that a hash is matched within a duplicate-height run rather than the run's first record being assumed, that a hash absent from the run does not resolve to a sibling, and that a log which does not start at height zero still resolves. The duplicate-height test is shaped deliberately. Heights [1, 1, 2] put the dense fast path's index straight onto the second duplicate, where the height check alone would accept it and only the preceding-record guard rejects it. An earlier version used heights starting at zero, passed, and did not touch the guard at all -- the mutation audit caught that the test was not testing what its name claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a157e09 commit 0881ee0

3 files changed

Lines changed: 145 additions & 58 deletions

File tree

crates/node/src/block_source.rs

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use bitcoin::hex::FromHex as _;
1414
use bitcoin_rs_chain::BlockTree;
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, BlockRecord, record_at_height, record_at_height_hash};
1818
use parking_lot::RwLock;
1919

2020
/// Reads decoded Bitcoin blocks from the shared in-memory log.
@@ -122,47 +122,6 @@ impl NodeBlockSource {
122122
}
123123
}
124124

125-
fn record_at_height(records: &[BlockRecord], height: u32) -> Option<&BlockRecord> {
126-
if let Ok(index) = usize::try_from(height)
127-
&& let Some(record) = records.get(index)
128-
&& record.height == height
129-
&& index
130-
.checked_sub(1)
131-
.and_then(|previous| records.get(previous))
132-
.is_none_or(|previous| previous.height < height)
133-
{
134-
return Some(record);
135-
}
136-
137-
let mut index = records
138-
.binary_search_by_key(&height, |record| record.height)
139-
.ok()?;
140-
while index > 0 && records[index.saturating_sub(1)].height == height {
141-
index = index.saturating_sub(1);
142-
}
143-
records.get(index)
144-
}
145-
146-
fn record_at_height_hash(
147-
records: &[BlockRecord],
148-
height: u32,
149-
hash: Hash256,
150-
) -> Option<&BlockRecord> {
151-
let mut index = records
152-
.binary_search_by_key(&height, |record| record.height)
153-
.ok()?;
154-
while index > 0 && records[index.saturating_sub(1)].height == height {
155-
index = index.saturating_sub(1);
156-
}
157-
while index < records.len() && records[index].height == height {
158-
if records[index].hash == hash {
159-
return Some(&records[index]);
160-
}
161-
index += 1;
162-
}
163-
None
164-
}
165-
166125
#[cfg(test)]
167126
mod tests {
168127
use super::*;

crates/rpc/src/context.rs

Lines changed: 143 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,66 @@ pub struct BlockRecord {
3535
pub time: u32,
3636
}
3737

38+
/// Finds the record at `height`, or `None` when the log holds no such height.
39+
///
40+
/// The log is append-only in height order — `Context::add_block` pushes, and the
41+
/// only removal is the tail `pop` a disconnect performs on the applied tip — so
42+
/// it is non-decreasing by height and binary-searchable. Where several records
43+
/// share a height, this returns the first.
44+
///
45+
/// The direct index is tried first because the log is usually dense from height
46+
/// zero, which makes the common case one bounds check instead of a search. The
47+
/// guard on the preceding record is what keeps that fast path honest when it is
48+
/// not dense.
49+
#[must_use]
50+
pub fn record_at_height(records: &[BlockRecord], height: u32) -> Option<&BlockRecord> {
51+
if let Ok(index) = usize::try_from(height)
52+
&& let Some(record) = records.get(index)
53+
&& record.height == height
54+
&& index
55+
.checked_sub(1)
56+
.and_then(|previous| records.get(previous))
57+
.is_none_or(|previous| previous.height < height)
58+
{
59+
return Some(record);
60+
}
61+
62+
let mut index = records
63+
.binary_search_by_key(&height, |record| record.height)
64+
.ok()?;
65+
while index > 0 && records[index.saturating_sub(1)].height == height {
66+
index = index.saturating_sub(1);
67+
}
68+
records.get(index)
69+
}
70+
71+
/// Finds the record with both `height` and `hash`, or `None`.
72+
///
73+
/// Several records can share a height — a reorg leaves the losing block in the
74+
/// log beside the winner — so the binary search lands anywhere in that run and
75+
/// this walks it in both directions before comparing hashes. Returning the first
76+
/// record at the height without checking the hash would hand back the wrong
77+
/// block on exactly the chain shape this exists to handle.
78+
#[must_use]
79+
pub fn record_at_height_hash(
80+
records: &[BlockRecord],
81+
height: u32,
82+
hash: Hash256,
83+
) -> Option<&BlockRecord> {
84+
let mut index = records
85+
.binary_search_by_key(&height, |record| record.height)
86+
.ok()?;
87+
while index > 0 && records[index.saturating_sub(1)].height == height {
88+
index = index.saturating_sub(1);
89+
}
90+
while index < records.len() && records[index].height == height {
91+
if records[index].hash == hash {
92+
return Some(&records[index]);
93+
}
94+
index += 1;
95+
}
96+
None
97+
}
3898
/// Block payload facts available without materializing a full block body.
3999
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40100
pub struct BlockBodyMetadata {
@@ -655,12 +715,10 @@ impl Context {
655715
// identity wins; enrich with a height-matched cached payload, else
656716
// with durable body metadata.
657717
if let Some(mut record) = self.header_record(hash) {
658-
if let Some(cached) = self
659-
.blocks
660-
.read()
661-
.iter()
662-
.find(|candidate| candidate.hash == hash && candidate.height == record.height)
663-
{
718+
// The tree already gave us the height, so this is a binary search
719+
// over a height-ordered log rather than a walk of every record on
720+
// the chain. `getblock` and `getblockheader` both land here.
721+
if let Some(cached) = record_at_height_hash(&self.blocks.read(), record.height, hash) {
664722
return Some(cached.clone());
665723
}
666724
if let Some(metadata) = self
@@ -676,6 +734,10 @@ impl Context {
676734
// 2. Legacy/cache-only fallback. The tree cannot resolve this identity,
677735
// so accept a vector record by exact hash. Metadata-only records and
678736
// pruned-body payloads pass through unchanged via their own fields.
737+
//
738+
// This one stays linear on purpose: without the tree there is no
739+
// height to search on, and a hash-keyed index would have to be kept
740+
// for every block to serve a path that only legacy state reaches.
679741
self.blocks
680742
.read()
681743
.iter()
@@ -701,11 +763,7 @@ impl Context {
701763
.as_byte_array(),
702764
));
703765
}
704-
self.blocks
705-
.read()
706-
.iter()
707-
.find(|candidate| candidate.height == height)
708-
.map(|candidate| candidate.hash)
766+
record_at_height(&self.blocks.read(), height).map(|candidate| candidate.hash)
709767
}
710768

711769
/// Returns a known block by hash.
@@ -724,11 +782,7 @@ impl Context {
724782
let hash = self.hash_at_height_from_tip(&tip, height)?;
725783
return self.record_for_hash(hash);
726784
}
727-
self.blocks
728-
.read()
729-
.iter()
730-
.find(|candidate| candidate.height == height)
731-
.cloned()
785+
record_at_height(&self.blocks.read(), height).cloned()
732786
}
733787

734788
/// Returns serialized block bytes from the record or durable storage.
@@ -815,6 +869,79 @@ fn bitcoin_network(network: Network) -> bitcoin::Network {
815869
mod tests {
816870
use super::*;
817871

872+
/// A reorg leaves the losing block in the log beside the winner, so a height
873+
/// can address two records. The binary search lands anywhere in that run,
874+
/// which is why the lookup walks it and compares hashes; returning the first
875+
/// record at the height would hand back the wrong block on exactly the shape
876+
/// this exists for.
877+
#[test]
878+
fn record_at_height_hash_picks_the_matching_hash_within_a_duplicate_height() {
879+
let first = Hash256::from_le_bytes(&[0x11_u8; 32]);
880+
let second = Hash256::from_le_bytes(&[0x22_u8; 32]);
881+
let records = vec![
882+
BlockRecord::synthetic(0, Hash256::from_le_bytes(&[0x00_u8; 32])),
883+
BlockRecord::synthetic(1, first),
884+
BlockRecord::synthetic(1, second),
885+
BlockRecord::synthetic(2, Hash256::from_le_bytes(&[0x33_u8; 32])),
886+
];
887+
888+
assert_eq!(
889+
record_at_height_hash(&records, 1, second).map(|record| record.hash),
890+
Some(second),
891+
"the second record at the height must be reachable, not just the first"
892+
);
893+
assert_eq!(
894+
record_at_height_hash(&records, 1, first).map(|record| record.hash),
895+
Some(first)
896+
);
897+
assert!(
898+
record_at_height_hash(&records, 1, Hash256::from_le_bytes(&[0x99_u8; 32])).is_none(),
899+
"a hash absent from the height run must not resolve to a sibling"
900+
);
901+
}
902+
903+
/// Heights `[1, 1, 2]` are chosen so the dense fast path indexes straight
904+
/// onto the *second* of the duplicates: `records[1]` has height 1, so the
905+
/// height check alone would accept it. Only the guard on the preceding
906+
/// record rejects it and sends the lookup to the search that finds the run
907+
/// start. A log starting at height 0 never exercises that, which is how an
908+
/// earlier version of this test passed while the guard was removed.
909+
#[test]
910+
fn record_at_height_returns_the_first_record_of_a_duplicate_height() {
911+
let first = Hash256::from_le_bytes(&[0x11_u8; 32]);
912+
let records = vec![
913+
BlockRecord::synthetic(1, first),
914+
BlockRecord::synthetic(1, Hash256::from_le_bytes(&[0x22_u8; 32])),
915+
BlockRecord::synthetic(2, Hash256::from_le_bytes(&[0x33_u8; 32])),
916+
];
917+
918+
assert_eq!(
919+
record_at_height(&records, 1).map(|record| record.hash),
920+
Some(first),
921+
"the dense index lands on the second duplicate; the first must win"
922+
);
923+
assert!(record_at_height(&records, 7).is_none());
924+
}
925+
926+
/// The dense fast path indexes straight into the log. It must not fire when
927+
/// the log does not start at height zero, or it would answer with whatever
928+
/// record happens to sit at that index.
929+
#[test]
930+
fn record_at_height_does_not_trust_the_index_on_a_sparse_log() {
931+
let wanted = Hash256::from_le_bytes(&[0x44_u8; 32]);
932+
let records = vec![
933+
BlockRecord::synthetic(10, Hash256::from_le_bytes(&[0x0a_u8; 32])),
934+
BlockRecord::synthetic(11, wanted),
935+
BlockRecord::synthetic(12, Hash256::from_le_bytes(&[0x0c_u8; 32])),
936+
];
937+
938+
assert_eq!(
939+
record_at_height(&records, 11).map(|record| record.hash),
940+
Some(wanted),
941+
"a log that does not start at zero must still resolve by search"
942+
);
943+
assert!(record_at_height(&records, 1).is_none());
944+
}
818945
#[test]
819946
#[allow(clippy::arc_with_non_send_sync)]
820947
fn from_handles_shares_tip_handles_with_caller() {

crates/rpc/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub use auth::Auth;
2828
pub use context::{
2929
BlockBodyMetadata, BlockBodySource, BlockRecord, ChainControl, ChainControlError, Context,
3030
NetworkState, PruneResult, PruneService, PruneServiceError, PruneStatus, ZmqNotification,
31+
record_at_height, record_at_height_hash,
3132
};
3233
pub use error::RpcError;
3334
pub use handlers::Handler;

0 commit comments

Comments
 (0)