Skip to content

perf(subtree data): keep the transaction id that deserialization already computed - #160

Merged
mrz1836 merged 1 commit into
bsv-blockchain:masterfrom
freemans13:stu/cache-txid-on-deserialise
Sep 9, 2026
Merged

perf(subtree data): keep the transaction id that deserialization already computed#160
mrz1836 merged 1 commit into
bsv-blockchain:masterfrom
freemans13:stu/cache-txid-on-deserialise

Conversation

@freemans13

Copy link
Copy Markdown
Contributor

What happened

serializeFromReader hashes every transaction it reads, so it can check the id against the node hash the subtree already holds. That check is necessary. What was wasteful is that the computed id was then thrown away.

bt.Tx.TxIDChainHash reads the transaction's cache but never fills it. Only SetTxHash does, and go-bt documents this in as many words: "TxIDChainHash itself does not populate the cache; only SetTxHash does." So every later caller serialized the whole transaction again and hashed it again.

Measured on a Teranode node reading a mainnet block, the two halves cost almost exactly the same. Of 19.28 core-seconds spent in TxIDChainHash across the whole process, 10.10 were the check here and 9.17 were one downstream stage recomputing the identical values on the identical objects.

The change

One line on each of the two paths: hash once, check it, keep it.

Reading a 4,096-transaction subtree and then asking each transaction for its id, which is what the next stage does:

time bytes allocations
before 4.75 ms 5,734,857 172,037
after 3.80 ms 4,423,872 163,845

The 8,192 allocations saved are exactly two per transaction: the serialization buffer and the hash. What remains is the deserialization itself, which has to happen.

Why storing it is safe

The id has just been verified against the node hash the subtree carries, so it is known correct at the moment it is stored.

A transaction's id is defined over its standard serialization. A caller that goes on to extend these transactions, filling in each input's parent satoshis and locking script, does not change it. TestCachedIDSurvivesExtension holds go-bt to that, and it recomputes from scratch after extending rather than reading the cache back, or it would be checking nothing.

The coinbase

It gets the same treatment on its own branch, where there is no node hash to check against because node zero is the coinbase placeholder.

One transaction in four thousand does not pay for itself. A uniform contract does: "every transaction this returns knows its id" is something a caller can rely on, where "every transaction except the coinbase" is a footnote nobody reads.

That branch needs a hand-built stream to reach, because Serialize deliberately omits the coinbase, so the test builds one.

Test plan

  • TestDeserializedTransactionsCarryTheirID — asking a returned transaction for its id allocates nothing
  • TestDeserializedCoinbaseCarriesItsID — same, on the coinbase branch
  • TestCachedIDSurvivesExtension — extending a transaction does not change its id
  • BenchmarkDeserializeThenIdentify — the numbers above
  • go test -race ./... green, golangci-lint run ./... clean

The property is asserted by allocation count rather than by inspecting the cache, since go-bt exports no predicate for it, and allocations are what the caller feels.

All three tests were mutation-checked. Removing either SetTxHash fails the matching test, and storing a wrong id fails the identity check.

🤖 Generated with Claude Code

…ady computed

serializeFromReader hashes every transaction it reads so it can check the id
against the node hash the subtree already holds. That check is necessary. What
was wasteful is that the computed id was then discarded.

bt.Tx.TxIDChainHash reads the transaction's cache but never fills it; only
SetTxHash does, which go-bt documents in as many words. So every later caller
serialized the whole transaction again and hashed it again.

Measured on a Teranode node reading a mainnet block, the two halves cost almost
exactly the same. Of 19.28 core-seconds spent in TxIDChainHash across the whole
process, 10.10 were the check here and 9.17 were one downstream stage
recomputing the identical values on the identical objects.

Reading a 4,096-transaction subtree and then asking each transaction for its id,
which is what the next stage does:

  before   4.75 ms   5,734,857 B   172,037 allocs
  after    3.80 ms   4,423,872 B   163,845 allocs

The 8,192 allocations are exactly two per transaction: the serialization buffer
and the hash. What is left is the deserialization itself, which has to happen.

Storing the id is safe. It has just been verified against the node hash the
subtree carries, and a transaction's id is defined over its standard
serialization, so a caller that goes on to extend these transactions, filling in
each input's parent satoshis and locking script, does not change it.
TestCachedIDSurvivesExtension holds go-bt to that by recomputing from scratch
after extending rather than reading the cache back.

The coinbase gets the same treatment on its own branch, where there is no node
hash to check against because node zero is the coinbase placeholder. One
transaction in four thousand does not pay for itself; a uniform contract does.
"Every transaction this returns knows its id" is something a caller can rely on,
where "every transaction except the coinbase" is a footnote nobody reads. That
branch needs a hand-built stream to reach, because Serialize deliberately omits
the coinbase, so the test builds one.

The property is asserted by allocation count rather than by inspecting the
cache, since go-bt exports no predicate for it and allocations are what the
caller feels.

All three tests were mutation-checked: removing either SetTxHash fails the
matching test, and storing a wrong id fails the identity check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@freemans13
freemans13 requested a review from mrz1836 as a code owner September 9, 2026 14:05
Copilot AI lite review requested due to automatic review settings September 9, 2026 14:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added fork-pr PR originated from a forked repository requires-manual-review PR or issue requires manual review by a maintainer or security team labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

👋 Thanks, @freemans13!

This pull request comes from a fork. For security, our CI runs in a restricted mode.
A maintainer will triage this shortly and run any additional checks as needed.

  • 🏷️ Labeled: fork-pr, requires-manual-review
  • 👀 We'll review and follow up here if anything else is needed.

Thanks for contributing to bsv-blockchain/go-subtree! 🚀

@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

@mrz1836 mrz1836 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mrz1836
mrz1836 merged commit 04128e2 into bsv-blockchain:master Sep 9, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork-pr PR originated from a forked repository requires-manual-review PR or issue requires manual review by a maintainer or security team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants