Skip to content

perf(rpc): store the block record header as raw bytes, not hex - #86

Open
rabbitson87 wants to merge 3 commits into
mainfrom
perf/rpc-block-record-header
Open

perf(rpc): store the block record header as raw bytes, not hex#86
rabbitson87 wants to merge 3 commits into
mainfrom
perf/rpc-block-record-header

Conversation

@rabbitson87

Copy link
Copy Markdown
Member

Candidate B (first half) from the optimization-candidates survey (#84), and a correction to the number that survey published.

The cost

Context::add_block pushes a BlockRecord for every applied block and nothing removes one. The record's own footprint is therefore paid for the life of the process. header_hex held the 80-byte header rendered as a 160-byte hex String.

Measured, not derived:

struct heap per block
before 104 B 160 B (one allocation) 264 B
after 168 B 0 168 B

96 bytes per block, about 88 MiB at a mainnet-sized chain, plus roughly a million fewer heap allocations over a full sync.

Correcting the survey

The survey put this at 160 B/block (241 MiB at tip). That figure counted the heap String as removed without counting the 64 bytes the inline [u8; 80] adds to the struct. The real saving is 96 B/block and 88 MiB — worth having, but 1.7x smaller than advertised. A test pins size_of::<BlockRecord>() so the claim cannot drift back.

Option<Box<[u8; 80]>> was considered and rejected: it lands at the same 168 bytes total while keeping the per-block allocation.

Two of three readers get simpler

header_hex had three readers. Two were parsing the hex straight back into bytes:

  • crates/rpc/src/handlers/chain.rs decode_header loses its hex-decode step and the entire error branch that went with it
  • crates/rpc/src/rest.rs decode_header goes from two lines to one
  • FromHex is no longer imported by either file

Only getblockheader still wants hex, and it now encodes on demand — one RPC call, against a cost paid per block forever.

Behaviour

Unchanged. header_hex() renders byte-identically to what the field held, a record without a header renders the empty string exactly as the empty String did, and no error code or message moved.

Verification

  • bitcoin-rs-rpc lib tests: 177 passed, unfiltered — header_hex was a public field, so the full suite was run rather than a filtered subset
  • bitcoin-rs-node lib tests: 442 passed
  • bitcoin-rs-electrum builds
  • cargo clippy -D warnings on both changed crates: clean
  • cargo fmt --check: clean

Three new tests pin the footprint, the hex output, and the empty-header rendering.

Scope

This is the half with no behaviour change. The other half — bounding the log, or dropping records below the prune height the way block_hex already is — is a behaviour change, because crates/rpc/src/handlers/chain.rs:45 and :171 scan the log. It is not in this PR.

For context on where this sits: a drained memory attribution taken during a mainnet sync at height 412,169 puts the whole non-UTXO residual at 2.24 GiB, of which this log is a small part. The dominant term is index memory, which no candidate covers yet.

🤖 Generated with Claude Code

A BlockRecord is pushed for every applied block and nothing removes one, so the
record's own footprint is paid for the life of the process. header_hex held the
80-byte header rendered as a 160-byte hex String: 104 bytes inline plus a heap
allocation, 264 bytes per block.

Storing the raw header instead makes that 168 bytes inline and no allocation --
96 bytes per block, about 88 MiB at a mainnet-sized chain, and roughly a million
fewer heap allocations over a full sync.

Two of the three readers get simpler rather than slower. Both decode_header
implementations were parsing the hex straight back into bytes; the rpc one loses
its hex-decode step and the whole error branch that went with it, the rest one
goes from two lines to one, and FromHex is no longer imported by either. Only
getblockheader still wants hex, and it now encodes on demand -- one RPC call
against a cost paid per block forever.

The saving is measured, not asserted: a test pins size_of::<BlockRecord>() so
the claim cannot drift, and the accompanying comment records why the survey's
figure of 160 bytes per block was wrong. It counted the heap String as removed
without counting the 64 bytes the inline array adds to the struct. The real
figure is 96, and Option<Box<[u8; 80]>> was considered and rejected: it lands at
the same 168 bytes total while keeping the per-block allocation.

Behaviour is unchanged. header_hex() renders byte-identically to what the field
held, a record without a header renders the empty string exactly as the empty
String did, and no error code or message moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0deffad4-8bc5-4126-9651-41986dca8d0c


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

A mutation audit of the header change found that dropping the header from
`Context::header_record` entirely failed no test. That path builds a BlockRecord
from the block tree rather than from applied-block bytes, and this change gave
it a `serialize(&node.header).try_into().ok()` whose failure mode is a silently
absent header -- where the old String field would at least have carried
something. It was the one part of the change nothing exercised.

`tree_derived_record_carries_the_header` seeds a tree node, resolves the record
through `record_for_hash` without pushing anything into `blocks`, and asserts
both the raw bytes and the rendered hex match the header the tree holds.

The rest of the change is well covered, and the audit says so rather than
assuming it: neutering `header_from_block_bytes` fails five tests, three of them
pre-existing `getblock` tests, and emptying `header_hex()` fails the round-trip
test. The new test now fails under the mutation that found the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rabbitson87

Copy link
Copy Markdown
Member Author

Mutation audit found one gap

Pushed as test(rpc): cover the block-tree derived record, which had no test at all.

Context::header_record had no test. Dropping the header from it entirely failed nothing — 177 tests still passed. That path builds a BlockRecord from the block tree rather than from applied-block bytes, and this PR gave it a serialize(&node.header).try_into().ok() whose failure mode is a silently absent header, where the old String field would at least have carried something. It was the one part of the change nothing exercised.

tree_derived_record_carries_the_header seeds a tree node, resolves the record through record_for_hash without pushing anything into blocks, and asserts both the raw bytes and the rendered hex match what the tree holds.

Full audit

Mutation Expected Result
header_from_block_bytes yields None red 5 failed — the 2 new tests plus 3 pre-existing getblock tests
header_hex() returns the empty string red header_hex_is_unchanged_by_storing_raw_bytes failed
header_record drops its header red nothing failed before the new test; the new test fails under it now
restored green 178 passed

The first row is the useful one for reviewers: that path was already well covered by tests this PR did not write, which is why the type change was safe to make mechanically.

Two earlier mutation attempts are not in that table because they were invalid, not because they passed — one deleted lines and broke the parse, another tripped a compiler ICE. Neither proved anything and both were reformulated.

Also checked

  • bitcoin-rs-electrum does not reference BlockRecord at all (its block_header_returns_synthetic_header_hex test uses electrum's own IndexHandle, unrelated to this change)
  • bitcoin-rs-rpc lib: 178 passed, unfiltered
  • bitcoin-rs-node lib: 442 passed
  • cargo clippy -D warnings on both changed crates: clean; cargo fmt --check: clean

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant