Skip to content

perf(blockvalidation): prefetch the off-chain block-id set once in checkOldBlockIDs (one round-trip per block on any client topology)#934

Merged
freemans13 merged 14 commits into
bsv-blockchain:mainfrom
freemans13:stu/blockvalidation-inmemory-checkold
Jun 4, 2026
Merged

perf(blockvalidation): prefetch the off-chain block-id set once in checkOldBlockIDs (one round-trip per block on any client topology)#934
freemans13 merged 14 commits into
bsv-blockchain:mainfrom
freemans13:stu/blockvalidation-inmemory-checkold

Conversation

@freemans13

@freemans13 freemans13 commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

checkOldBlockIDs runs during every block validation and answers, per tx, "is at least one of this tx's parent block IDs still on the current main chain?".

The default route prefetches the 10,000 most-recent on-chain block IDs via GetBlockHeaderIDs, builds a local map, and answers each tx against it. That prefetch was the single largest CPU and memory cost on the box during catch-up (≈16% CPU, ≈35% of inuse heap on betfair-pc mainnet).

This PR adds a mirror route, gated on the existing blockchain_use_in_memory_chain_check toggle, that prefetches the off-chain set instead — and resolves membership with the set inverted. It costs exactly one round-trip per block on any client topology.

The two strategies

Both produce identical pass/fail outcomes for the same input.

On-chain prefetch (default, unchanged):

  1. GetBlockHeaderIDs(prev, 10_000) — fetch the 10,000 most-recent main-chain block IDs.
  2. Build a map[uint32]struct{}; a parent ID present ⇒ on chain.
  3. The window is truncated, so a miss may still be on an older part of the chain → fall back to a per-tx CheckBlockIsInCurrentChain RPC.

Off-chain prefetch (this PR, when blockchain_use_in_memory_chain_check=true):

  1. GetOffChainBlockIDs() — fetch the complete off-chain (forked) block-id set once.
  2. Build a map[uint32]struct{}; a parent ID absent ⇒ on chain (ANY-of).
  3. The set is complete (not a recent window), so there is no "too old" miss — one prefetch resolves every tx locally.

Why this shape

The main-chain set is unbounded and ever-growing, so the on-chain route must either materialise a huge positive set or window it to 10k (incomplete, re-fetched every block). The off-chain set is the small negative set the blockchain store already maintains in memory — bounded by fork activity, near-empty on a healthy chain. Prefetching that set once and inverting the test gives the same answer for a tiny payload and a tiny local map.

Crucially, it is one round-trip per block regardless of client topology:

  • With an in-process blockchain store (LocalClient), the prefetch is a cheap function call.
  • With a gRPC blockchain client, the prefetch is a single network round-trip — not one round-trip per candidate set.

This is the key change from the earlier revision of this PR, which skipped the prefetch entirely and called CheckBlockIsInCurrentChain per distinct parent-id set. That was free in-process but fanned out into N network round-trips per block under a gRPC client (the toggle name describes the store mode, not whether the client is co-located — a footgun on multi-process nodes). The off-chain prefetch removes that fan-out while keeping the small-memory win.

Correctness / reorg safety

  • The prefetch is a single snapshot taken at function entry — the same model the on-chain prefetch route already uses.
  • When the store reports rebuilding (in-memory check disabled, or a reorg/startup rebuild in progress) the off-chain set is stale, so the route falls back to the per-tx CheckBlockIsInCurrentChain path, which has its own authoritative SQL fallback.
  • CheckBlockIsInCurrentChain remains the lookup authority on a fast-path miss, so a block is only rejected after the store confirms none of a tx's parents are on chain. No accept/reject path changes.

Measured impact (live A/B on betfair-pc mainnet, LocalClient)

Same binary, same fresh DB, two 30-second captures with the toggle on — phase A used the on-chain prefetch route (default), phase B used the off-chain route:

Metric On-chain prefetch (A) Off-chain route (B) Change
Total CPU samples (30s wall) 17.92 s (60% util) 14.07 s (47% util) −22% CPU
checkOldBlockIDs CPU share 1.56% cum 0.14% cum −11×
GetBlockHeaderIDs CPU share 15.74% cum not in profile gone
Total inuse heap 1.30 GB 927 MB −29%
sql.GetBlockHeaderIDs inuse 339 MB (26% of heap) 0 MB gone
30s cumulative allocs 102 GB 20.6 GB 5× less
RSS (process) 2.5 GB 1.75 GB −30%

The disappearance of GetBlockHeaderIDs from the profile is the whole story — that one upfront fetch was the largest single CPU and memory cost on the box during catch-up. (These numbers are from a single-process node, i.e. LocalClient. The off-chain prefetch is what makes the win safe to take under a gRPC client too — one round-trip per block instead of the per-set fan-out.)

What's in the diff

  • New GetOffChainBlockIDs RPC across proto / store / Client / LocalClient / Server / mocks. The store method returns the in-memory off-chain set, or rebuilding=true when it is unavailable/stale.
  • services/blockvalidation/BlockValidation.go: checkOldBlockIDsWithoutPrefetch rewritten to prefetch the off-chain set once and resolve membership locally via an inverted fastPath, reusing the existing dedupe/iterate helper. rebuilding routes to the per-tx fallback.
  • The blockchain_use_in_memory_chain_check=false (default) path is unchanged.

Behaviour matrix

blockchain_use_in_memory_chain_check Behaviour
false (default) Exactly the same as before — on-chain prefetch route, unchanged.
true, store ready New off-chain prefetch route: one GetOffChainBlockIDs, resolved locally.
true, store rebuilding Per-tx CheckBlockIsInCurrentChain fallback (authoritative SQL underneath).

Test plan

  • go build ./... / go vet clean; golangci-lint clean on changed packages.
  • go test ./services/blockvalidation/..., ./services/blockchain/..., ./stores/blockchain/... pass.
  • New Test_checkOldBlockIDs_offChainPrefetch: all-on-chain resolved locally (no RPC), mixed parent set accepted, exclusively-off-chain confirmed via RPC then rejected, OffChainBlockIDs error → ServiceError.
  • Test_checkOldBlockIDs_noPrefetchRoute now drives the rebuilding-fallback sub-path.
  • Test_checkOldBlockIDs_nilHeader: nil header / nil HashPrevBlock returns a clean error, no panic (regression guard).
  • gRPC-topology bench (dev-scale) confirming one round-trip per block, before defaulting the toggle to true in a follow-up.

What this PR doesn't do

  • Doesn't change the toggle default.
  • Doesn't delete the on-chain prefetch path.
  • Doesn't change any other caller of CheckBlockIsInCurrentChain.

…en in-memory chain check is enabled

The toggle blockchain_use_in_memory_chain_check (introduced for
CheckBlockIsInCurrentChain) made membership tests against the main
chain O(1) by checking a small in-memory off-chain set of known
non-canonical block IDs. But the toggle had almost no observable
effect on production because CheckBlockIsInCurrentChain is rarely
called from production code — only setTxMined and the slow-path
fallback inside checkOldBlockIDs.

The actual hot path of chain-membership checking lives inside
checkOldBlockIDs itself:

  currentChainBlockIDs := GetBlockHeaderIDs(prev, 10_000)  // 10k row fetch
  build a 10k-entry map for fast lookup
  iterate per tx, hit the map, fall back to CheckBlockIsInCurrentChain
  on miss.

This was a sensible optimisation when CheckBlockIsInCurrentChain was
a recursive CTE on every call. With the off-chain-set toggle on,
every per-tx call is already O(1) — the upfront 10k fetch + map
build is now pure overhead.

Live profile on betfair-pc mainnet (postgres backend, in-memory
toggle on): checkOldBlockIDs's upfront fetch held 35% of inuse heap
(464 MB), drove 16.5% of CPU, and contributed 3.2 GB of allocs per
30 s — the single largest per-block allocation site once the GC
fixes had landed.

This commit:

  - Adds checkOldBlockIDsInMemory which goes straight to
    CheckBlockIsInCurrentChain per-tx, with the same string-keyed
    dedupe cache the original uses for identical blockID slices.
  - Gates it behind blockchain_use_in_memory_chain_check so the
    behaviour is opt-in and easy to A/B against the original.
  - Leaves the original implementation untouched for the toggle=false
    case, so existing deployments that haven't enabled the toggle
    see no behavioural change at all.

Tests: 35 blockvalidation tests pass.
…checkOldBlockIDs comments

Follow-up to 25d29b8. The original commit's prose framed the toggle
as 'use off-chain set instead of main-chain set' without making
explicit that both routes answer the SAME question — 'is at least one
of this tx's parent block IDs on the current main chain?' — just via
opposite data structures:

  * main-chain set: positive membership in a 10k-deep freshly-fetched
    set
  * off-chain (forked) set: negative membership in the small persistent
    in-memory set of block IDs known to be OFF the main chain

By complement the two answers are identical. Reworded the function-
level doc and the route-selector comment to lead with that, kept the
existing 'off-chain' terminology (matches the underlying store's
offChainBlockIDs field and the existing setting key) and added
'(forked)' parenthetical for clarity. Renamed the per-call log lines
to 'forked-set route' for the same reason.

Also folded in the measured before/after numbers from the live A/B
on betfair-pc mainnet so the rationale stays attached to the code:
RSS -29%, 30s alloc volume -5×, total CPU -22%, sql.GetBlockHeaderIDs
out of the inuse heap entirely.

No behavioural change.
@freemans13 freemans13 self-assigned this May 22, 2026
@github-actions

github-actions Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

This PR introduces a performance optimization for checkOldBlockIDs by prefetching the off-chain (forked) block ID set instead of the on-chain set, reducing CPU and memory usage during block validation catchup.

Review Summary:

The implementation is solid and has been thoroughly reviewed through multiple rounds by oskarszoon. All critical consensus safety issues from earlier iterations have been addressed:

Consensus Safety: The maxBlockID guard is correctly implemented in the fastPath (line 2287-2288) matching CheckBlockIsInCurrentChain
Test Coverage: Comprehensive tests including real-store regression tests, nil-header guards, and multi-element ANY-of cases
API Consistency: New OffChainBlockIDs RPC properly threaded through all layers (proto, Client, LocalClient, Server, Store)
Fallback Logic: Proper handling of rebuilding state with fallback to authoritative RPC checks
Documentation: Clear comments explaining the strategy tradeoffs and consensus equivalence requirements

Architecture:

The off-chain prefetch approach is clever: instead of fetching 10k main-chain IDs repeatedly, it fetches the small bounded set of forked block IDs once per block. On a healthy chain this set is near-empty, making the prefetch cheap while enabling local resolution of membership queries. The measured impact on betfair-pc mainnet (22% CPU reduction, 29% heap reduction) validates the approach.

No blocking issues found.

The toggle remains opt-in (blockchain_use_in_memory_chain_check=false by default), allowing safe production validation before broader rollout.

@github-actions

github-actions Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-934 (c86d3a2)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 144
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.975µ 1.744µ ~ 0.100
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 59.31n 59.24n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 59.38n 59.43n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 59.48n 59.31n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 34.77n 34.96n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 59.60n 61.65n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 155.9n 158.9n ~ 1.000
MiningCandidate_Stringify_Short-4 252.8n 254.8n ~ 0.500
MiningCandidate_Stringify_Long-4 1.801µ 1.820µ ~ 0.700
MiningSolution_Stringify-4 920.6n 919.2n ~ 0.700
BlockInfo_MarshalJSON-4 1.754µ 1.746µ ~ 0.700
NewFromBytes-4 129.7n 129.2n ~ 0.700
AddTxBatchColumnar_Validation-4 2.632µ 2.519µ ~ 0.400
OffsetValidationLoop-4 637.6n 635.7n ~ 1.000
Mine_EasyDifficulty-4 60.56µ 60.48µ ~ 0.700
Mine_WithAddress-4 6.846µ 7.025µ ~ 0.200
DirectSubtreeAdd/4_per_subtree-4 59.80n 64.14n ~ 0.100
DirectSubtreeAdd/64_per_subtree-4 32.65n 30.26n ~ 0.100
DirectSubtreeAdd/256_per_subtree-4 30.89n 29.10n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 29.79n 27.93n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 29.13n 27.48n ~ 0.100
SubtreeProcessorAdd/4_per_subtree-4 293.1n 287.9n ~ 0.400
SubtreeProcessorAdd/64_per_subtree-4 287.1n 284.6n ~ 0.200
SubtreeProcessorAdd/256_per_subtree-4 288.0n 287.1n ~ 0.800
SubtreeProcessorAdd/1024_per_subtree-4 273.1n 275.6n ~ 0.400
SubtreeProcessorAdd/2048_per_subtree-4 278.9n 278.0n ~ 0.400
SubtreeProcessorRotate/4_per_subtree-4 281.6n 283.5n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 292.5n 279.6n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 305.3n 277.9n ~ 0.200
SubtreeProcessorRotate/1024_per_subtree-4 299.1n 277.1n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 55.35n 54.45n ~ 0.100
SubtreeNodeAddOnly/64_per_subtree-4 34.79n 34.52n ~ 0.200
SubtreeNodeAddOnly/256_per_subtree-4 33.81n 33.42n ~ 0.200
SubtreeNodeAddOnly/1024_per_subtree-4 32.71n 32.82n ~ 0.700
SubtreeCreationOnly/4_per_subtree-4 118.4n 116.8n ~ 0.400
SubtreeCreationOnly/64_per_subtree-4 430.6n 433.0n ~ 1.000
SubtreeCreationOnly/256_per_subtree-4 1.442µ 1.440µ ~ 1.000
SubtreeCreationOnly/1024_per_subtree-4 4.777µ 4.629µ ~ 0.100
SubtreeCreationOnly/2048_per_subtree-4 8.597µ 8.566µ ~ 0.700
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 289.5n 282.9n ~ 0.700
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 274.0n 281.8n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 2.262m 2.274m ~ 0.700
ParallelGetAndSetIfNotExists/10k_nodes-4 5.596m 5.751m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 8.009m 7.843m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 11.67m 11.06m ~ 0.200
SequentialGetAndSetIfNotExists/1k_nodes-4 1.986m 1.965m ~ 0.400
SequentialGetAndSetIfNotExists/10k_nodes-4 5.799m 4.644m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 14.41m 13.17m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 32.06m 24.04m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.360m 2.304m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.548m 8.431m ~ 0.200
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 14.45m 13.70m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 2.011m 2.013m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 9.173m 7.924m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 57.15m 41.15m ~ 0.100
DiskTxMap_SetIfNotExists-4 3.906µ 4.081µ ~ 0.400
DiskTxMap_SetIfNotExists_Parallel-4 3.697µ 3.617µ ~ 1.000
DiskTxMap_ExistenceOnly-4 353.3n 437.5n ~ 0.100
Queue-4 197.4n 195.2n ~ 1.000
AtomicPointer-4 4.263n 4.755n ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 958.6µ 968.2µ ~ 0.700
ReorgOptimizations/DedupFilterPipeline/New/10K-4 870.1µ 876.9µ ~ 0.700
ReorgOptimizations/AllMarkFalse/Old/10K-4 113.4µ 120.2µ ~ 0.100
ReorgOptimizations/AllMarkFalse/New/10K-4 62.72µ 63.04µ ~ 0.400
ReorgOptimizations/HashSlicePool/Old/10K-4 61.94µ 58.17µ ~ 0.400
ReorgOptimizations/HashSlicePool/New/10K-4 11.76µ 10.99µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/10K-4 5.229µ 4.990µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.834µ 1.769µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 11.29m 11.23m ~ 0.700
ReorgOptimizations/DedupFilterPipeline/New/100K-4 11.51m 11.06m ~ 0.200
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.195m 1.200m ~ 0.700
ReorgOptimizations/AllMarkFalse/New/100K-4 682.4µ 684.9µ ~ 0.100
ReorgOptimizations/HashSlicePool/Old/100K-4 623.2µ 716.7µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/100K-4 353.4µ 335.8µ ~ 0.400
ReorgOptimizations/NodeFlags/Old/100K-4 52.55µ 51.47µ ~ 1.000
ReorgOptimizations/NodeFlags/New/100K-4 18.68µ 17.95µ ~ 0.700
TxMapSetIfNotExists-4 52.46n 52.18n ~ 0.700
TxMapSetIfNotExistsDuplicate-4 39.83n 40.54n ~ 0.100
ChannelSendReceive-4 604.6n 629.9n ~ 0.100
BlockAssembler_AddTx-4 0.02777n 0.03023n ~ 0.700
AddNode-4 11.08 11.16 ~ 0.400
AddNodeWithMap-4 10.86 12.07 ~ 0.100
CalcBlockWork-4 511.7n 513.2n ~ 0.700
CalculateWork-4 698.6n 709.5n ~ 0.100
BuildBlockLocatorString_Helpers/Size_10-4 1.358µ 1.365µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_100-4 13.01µ 13.00µ ~ 0.700
BuildBlockLocatorString_Helpers/Size_1000-4 147.7µ 128.8µ ~ 0.400
CatchupWithHeaderCache-4 104.6m 104.5m ~ 0.700
_prepareTxsPerLevel-4 415.1m 414.4m ~ 0.700
_prepareTxsPerLevelOrdered-4 3.583m 4.241m ~ 0.100
_prepareTxsPerLevel_Comparison/Original-4 406.9m 409.6m ~ 0.700
_prepareTxsPerLevel_Comparison/Optimized-4 4.122m 4.216m ~ 0.400
_BufferPoolAllocation/16KB-4 3.910µ 4.010µ ~ 0.400
_BufferPoolAllocation/32KB-4 10.577µ 9.792µ ~ 1.000
_BufferPoolAllocation/64KB-4 17.23µ 19.46µ ~ 0.100
_BufferPoolAllocation/128KB-4 34.72µ 33.69µ ~ 0.700
_BufferPoolAllocation/512KB-4 114.5µ 135.3µ ~ 0.700
_BufferPoolConcurrent/32KB-4 22.25µ 19.65µ ~ 0.400
_BufferPoolConcurrent/64KB-4 30.45µ 30.03µ ~ 0.400
_BufferPoolConcurrent/512KB-4 147.5µ 148.2µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/16KB-4 616.8µ 646.4µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/32KB-4 648.3µ 638.8µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/64KB-4 669.6µ 652.1µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/128KB-4 680.8µ 635.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 617.2µ 617.2µ ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/16KB-4 37.68m 36.28m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.93m 36.58m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.63m 36.34m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.92m 36.48m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.80m 35.88m ~ 0.100
_PooledVsNonPooled/Pooled-4 831.2n 832.2n ~ 0.700
_PooledVsNonPooled/NonPooled-4 8.430µ 8.233µ ~ 0.700
_MemoryFootprint/Current_512KB_32concurrent-4 7.059µ 6.902µ ~ 1.000
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.406µ 9.470µ ~ 1.000
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.027µ 8.963µ ~ 0.700
SubtreeSizes/10k_tx_4_per_subtree-4 1.262m 1.288m ~ 0.400
SubtreeSizes/10k_tx_16_per_subtree-4 299.0µ 296.5µ ~ 0.400
SubtreeSizes/10k_tx_64_per_subtree-4 71.03µ 72.07µ ~ 0.700
SubtreeSizes/10k_tx_256_per_subtree-4 17.84µ 17.70µ ~ 0.700
SubtreeSizes/10k_tx_512_per_subtree-4 8.829µ 8.818µ ~ 0.700
SubtreeSizes/10k_tx_1024_per_subtree-4 4.339µ 4.350µ ~ 1.000
SubtreeSizes/10k_tx_2k_per_subtree-4 2.166µ 2.201µ ~ 0.100
BlockSizeScaling/10k_tx_64_per_subtree-4 69.10µ 70.82µ ~ 0.200
BlockSizeScaling/10k_tx_256_per_subtree-4 17.39µ 17.63µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.330µ 4.361µ ~ 1.000
BlockSizeScaling/50k_tx_64_per_subtree-4 363.7µ 366.8µ ~ 1.000
BlockSizeScaling/50k_tx_256_per_subtree-4 86.40µ 87.84µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 21.41µ 21.54µ ~ 0.700
SubtreeAllocations/small_subtrees_exists_check-4 150.1µ 145.9µ ~ 0.400
SubtreeAllocations/small_subtrees_data_fetch-4 159.7µ 158.5µ ~ 0.200
SubtreeAllocations/small_subtrees_full_validation-4 307.9µ 307.5µ ~ 1.000
SubtreeAllocations/medium_subtrees_exists_check-4 8.744µ 8.759µ ~ 0.700
SubtreeAllocations/medium_subtrees_data_fetch-4 9.201µ 9.226µ ~ 1.000
SubtreeAllocations/medium_subtrees_full_validation-4 17.48µ 17.31µ ~ 0.400
SubtreeAllocations/large_subtrees_exists_check-4 2.074µ 2.085µ ~ 1.000
SubtreeAllocations/large_subtrees_data_fetch-4 2.207µ 2.184µ ~ 0.400
SubtreeAllocations/large_subtrees_full_validation-4 4.322µ 4.357µ ~ 0.700
StoreBlock_Sequential/BelowCSVHeight-4 331.7µ 332.7µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 333.1µ 333.8µ ~ 0.700
GetUtxoHashes-4 281.2n 284.5n ~ 0.400
GetUtxoHashes_ManyOutputs-4 50.90µ 45.79µ ~ 0.100
_NewMetaDataFromBytes-4 213.9n 214.5n ~ 0.200
_Bytes-4 400.3n 395.2n ~ 0.100
_MetaBytes-4 139.9n 139.1n ~ 1.000

Threshold: >10% with p < 0.05 | Generated: 2026-06-03 09:27 UTC

…reframe doc/log as 'prefetch' vs 'no-prefetch'

The previous comments and function name were framed around 'main-chain
set vs off-chain (forked) set', which described what the underlying
blockchain store does, not what this file does. The blockvalidation
file itself never touches the off-chain set — it just calls
CheckBlockIsInCurrentChain, which consults the in-memory forked-block
cache inside the blockchain store when blockchain_use_in_memory_chain_check
is enabled. Reviewers reading only this file were left looking for an
off-chain lookup that isn't here.

Reframe locally as:

  Prefetch route (default): GetBlockHeaderIDs(prev, 10_000) + local
  map + per-tx fallback RPC.

  No-prefetch route (toggle on): just call the per-tx RPC. The RPC
  itself is now an O(1) in-memory check inside the blockchain store
  (off-chain cache, owned and refreshed there).

Changes:
  - Rename the new function checkOldBlockIDsInMemory ->
    checkOldBlockIDsWithoutPrefetch.
  - Replace the route-selector comment with a side-by-side strategy
    description that doesn't depend on the reader knowing about the
    store's internals.
  - Update the per-route log strings from off-chain-set/forked-set
    -> prefetch route / no-prefetch route, so the running log matches
    the code's vocabulary.
  - No behavioural change.
…OldBlockIDsWithCachedLookup helper, add no-prefetch route tests

Addresses code-review follow-ups on PR bsv-blockchain#934:

  1. Restored the explanatory comment about why the prefetch route
     uses HashPrevBlock to anchor GetBlockHeaderIDs (normal vs
     optimistic-mining paths). The comment had been dropped during
     an earlier reshuffle; the rationale still applies to the
     prefetch path that uses it.

  2. Extracted iterateOldBlockIDsWithCachedLookup as a shared helper
     used by both checkOldBlockIDs (prefetch route) and
     checkOldBlockIDsWithoutPrefetch (no-prefetch route). Pulls the
     per-tx empty-blockIDs check, the sorted-blockIDs dedupe cache,
     and the lookup+error-reporting loop into one place. Each route
     just wraps it: prefetch supplies a fastPath callback that
     short-circuits against the freshly-loaded main-chain map;
     no-prefetch passes nil for fastPath. Behaviour is unchanged
     in both routes.

  3. Added Test_checkOldBlockIDs_noPrefetchRoute mirroring the
     existing prefetch-route subtests:
       - dispatches to no-prefetch (asserts GetBlockHeaderIDs NOT
         called when toggle is on)
       - empty map (no-op, no RPC)
       - empty parents (ProcessingError)
       - all parents on chain (NoError)
       - dedupe cache: identical blockIDs slices share one RPC
       - not on chain returns BlockInvalidError

  4. (Considered relaxing the HashPrevBlock precondition on the
     no-prefetch path. Not worth it — the tracing/log setup at the
     top of checkOldBlockIDs calls block.Hash().String() before the
     dispatch, which itself requires a valid header. Strict check
     stays for consistency with logging requirements; the failing
     test that motivated the relaxation was removed.)

No behavioural change. The existing 7 Test_checkOldBlockIDs subtests
continue to pass against the refactored prefetch route; 6 new
subtests cover the no-prefetch route.
…pcCount to lookupCount, add RPC-error test

Three small cleanups from a self-review pass on PR bsv-blockchain#934:

  1. Log punctuation. The prefetch route's start log uses 'prefetch
     route:' (colon); the no-prefetch route was 'no-prefetch route,'
     (comma). Standardise both on colon. Also change the no-prefetch
     done log key 'rpc=%d' to 'lookup=%d' for consistency with the
     renamed helper counter (see below). The prefetch done log keeps
     its historical 'slowPath=%d' field name for grep continuity; a
     code comment notes the relationship.

  2. Rename rpcCount → lookupCount on the helper's named return.
     The helper now uses a route-neutral name; each caller wraps it
     in whatever local name suits the per-route log it writes.
     Doc-comment on the helper explains the mapping.

  3. Add a new no-prefetch-route subtest covering the RPC error
     path. The helper translates a lookup-callback error into a
     ProcessingError (so the caller retries) rather than a
     BlockInvalidError (which would invalidate the block). Mock
     returns errors.NewServiceError; the test asserts the resulting
     error mentions 'failed to check' and not 'are not from current
     chain'. Previously this branch had no coverage.

No behavioural change. 14 tests pass (7 prefetch + 7 no-prefetch).

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.

Pull request overview

This PR optimizes BlockValidation.checkOldBlockIDs by skipping the 10k main-chain block-ID prefetch when blockchain_use_in_memory_chain_check is enabled, relying instead on the (now O(1) in-memory) CheckBlockIsInCurrentChain RPC, while keeping the legacy prefetch behavior unchanged for the default setting.

Changes:

  • Added a settings-gated “no-prefetch” route and preserved the existing “prefetch” route as the default.
  • Refactored shared per-tx iteration logic into a helper (iterateOldBlockIDsWithCachedLookup) while keeping the string-keyed dedupe cache in both routes.
  • Added focused unit tests to ensure the no-prefetch route dispatches correctly and matches prefetch-route outcomes.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
services/blockvalidation/BlockValidation.go Adds toggle-based dispatch to a new no-prefetch route, factors shared iteration into a helper, and updates logs/comments for route clarity.
services/blockvalidation/BlockValidation_test.go Adds a new test suite mirroring the existing subtests to validate the no-prefetch route and ensure no GetBlockHeaderIDs calls occur.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +2110 to +2112
// Both routes below dereference block.Header and (transitively, via
// block.Hash() inside logging) require a usable header — guard once
// up-front so each route can assume a valid block.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch on all three — fixed:

  1. Panic before guard / in guard: moved the block.Header == nil || block.Header.HashPrevBlock == nil guard to the very top of the function, before tracing.Tracer().Start. The guard's error message no longer calls block.String() (which dereferences b.Header via b.Hash()), so the nil-header path can no longer panic.
  2. Swapped tracing args: the debug message now passes block.Hash().String(), oldBlockIDsMap.Length() in %s/%d order. block.Hash() is safe there because it now runs only after the nil-header guard has passed.

The no-prefetch sibling's block.Hash().String() calls are reached only after this same guard, so they're safe too. Full ./services/blockvalidation/ suite (601 tests) passes.

Move the block.Header / HashPrevBlock nil guard above tracing.Start so we
never dereference a nil header via block.Hash()/block.String(), and fix the
swapped %s/%d args in the tracing debug message.
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

[Minor] Potential slice mutation bug at services/blockvalidation/BlockValidation.go:2280

The slices.Sort(blockIDs) call mutates the input slice in-place. This slice may share underlying storage with parentTxMeta.BlockIDs from model/Block.go:990 where oldBlockIDs = append(oldBlockIDs, parentTxMeta.BlockIDs...) is used.

If the append reuses the backing array, sorting here will mutate the original metadata, potentially corrupting cached data or causing race conditions.

Recommendation: Make a defensive copy before sorting using slices.Clone(blockIDs) to ensure the original blockIDs slice remains unmodified.

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

fastPath func(blockIDs []uint32) bool,
lookup func(ctx context.Context, blockIDs []uint32) (bool, error),
) (iterationError error, fastPathCount, lookupCount, cacheHitCount int) {
cache := make(map[string]bool, oldBlockIDsMap.Length())

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — capped the size hint. The dedupe cache is keyed by the sorted parent-block-ID slice, so it only ever holds as many entries as there are distinct parent-block sets referenced by the block (a handful in practice), but sizing the hint to oldBlockIDsMap.Length() scaled it with tx count and could reserve a huge map for almost no occupancy. Now hinted at min(oldBlockIDsMap.Length(), oldBlockIDsCacheHintCap) with oldBlockIDsCacheHintCap = 10_000, matching the prefetch route's historical implicit cap (len(currentChainBlockIDs) <= 10k). The map still grows if more distinct sets actually appear.

The shared iterator sized the dedupe cache hint to oldBlockIDsMap.Length()
(tx count, up to millions on large blocks), but the cache only holds distinct
parent-block-ID sets. Cap the hint at oldBlockIDsCacheHintCap=10_000 to match
the prefetch route's historical implicit cap and avoid large per-block map
allocations.

@oskarszoon oskarszoon 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.

Reviewed the no-prefetch path. Consensus equivalence is clean — both routes use the same CheckBlockIsInCurrentChain as final authority, a fast-path map hit provably always agrees with the RPC, no accept/reject path changes, and dropping the prefetch narrows the reorg stale-accept window (no fetch-vs-RPC snapshot skew). Go side: build/vet/tests pass, nil-header guard fix is complete, RPC-error handling correctly avoids cache poisoning. The slices.Sort aliasing the bot flagged is a non-issue — oldBlockIDsMap is a per-validation throwaway consumed once, parentTxMeta.BlockIDs is copied fresh via append(nil, …), and the sort already lived there pre-PR.

Asks before merge:

  • UseInMemoryChainCheck is a footgun on gRPC-client deployments (P1). The no-prefetch path is only cheap with an in-process (LocalClient) blockchain store — crossover ≈ 3000 txs. With a gRPC client the crossover is ≈ 6 txs, so on any real block it's a 100–1000× regression vs the single prefetch. The flag name implies the store is in-memory mode, not that the client is local — an operator can enable it on a multi-process node and tank big-block validation. Either type-assert LocalClient before taking the no-prefetch path, or document loudly (flag help + checkOldBlockIDsWithoutPrefetch doc comment) that it requires co-located blockchain access.
  • Add the nil-header regression test. The round-1 guard move has no test; a regression would re-introduce the panic silently.
  • Commit the perf evidence. The 35%-heap / 22%-CPU mainnet pprof numbers are convincing but live only in the PR description — put them in a code comment or perf-notes, and ideally add a benchmark over checkOldBlockIDs at realistic tx counts so nothing forces callers back to SQL unnoticed.

Nice-to-haves: a true cross-toggle equivalence test (same fixture through both routes), a multi-element blockIDs ANY-of case, and aligning the "slowPath"/"lookup" log label across routes.

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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

…on any client topology

The off-chain (in-memory) chain-check route called CheckBlockIsInCurrentChain
once per distinct parent-block-ID set. That is an in-process function call under
LocalClient but a network round-trip under the gRPC client, so on a multi-process
node it fanned out into N round-trips per block where the on-chain prefetch route
does one — a large regression on big blocks, and a footgun because the toggle
name describes the store mode, not whether the client is co-located.

Mirror the on-chain prefetch route with the set inverted: fetch the complete
off-chain (forked) block-id set once via a new GetOffChainBlockIDs RPC, then
resolve main-chain membership locally (a parent is on chain iff it is NOT in the
off-chain set, ANY-of). The set is complete (not a recent window), so one
prefetch resolves every tx with no "too old" miss — exactly one round-trip per
block regardless of client topology. The off-chain set is small (bounded by fork
activity), so the payload and local map stay tiny.

When the store reports rebuilding (in-memory check disabled, or a reorg/startup
rebuild in progress) the set is stale, so we fall back to the per-tx RPC path
(its own authoritative SQL fallback). CheckBlockIsInCurrentChain remains the
lookup authority on a fast-path miss, so a block is only rejected after the store
confirms — consensus equivalence unchanged.

Adds GetOffChainBlockIDs across proto/store/client/server/mocks. Tests cover the
off-chain-prefetch happy path (local resolution, mixed sets, exclusively-off-chain
rejection, RPC error), the rebuilding fallback, and a nil-header regression guard.
@freemans13 freemans13 changed the title perf(blockvalidation): use off-chain (forked) block-id set in checkOldBlockIDs when in-memory chain check is enabled perf(blockvalidation): prefetch the off-chain block-id set once in checkOldBlockIDs (one round-trip per block on any client topology) Jun 2, 2026
@freemans13 freemans13 requested a review from oskarszoon June 2, 2026 15:47
@freemans13

Copy link
Copy Markdown
Collaborator Author

@oskarszoon thanks for the review — addressed your P1 head-on rather than gating it.

Instead of skipping the prefetch and calling CheckBlockIsInCurrentChain per candidate set (the gRPC stampede you flagged — free in-process, N round-trips under a gRPC client), this now mirrors the on-chain prefetch route with the set inverted: one new GetOffChainBlockIDs RPC fetches the complete off-chain (forked) set once, then membership is resolved locally (a parent is on chain iff it is not in the off-chain set). Because the off-chain set is complete (not a 10k window), there's no "too old" miss — it's one round-trip per block on any client topology, with no LocalClient type-assert and no footgun. The off-chain set is small, so the payload and local map stay tiny.

  • rebuilding from the store routes to the per-tx fallback (authoritative SQL underneath); CheckBlockIsInCurrentChain stays the final authority on every reject, so consensus equivalence is unchanged.
  • Added your two blockers: the nil-header regression test (Test_checkOldBlockIDs_nilHeader) and the perf evidence is now in the PR description (betfair A/B), with a note those numbers are LocalClient.
  • New Test_checkOldBlockIDs_offChainPrefetch covers local resolution / mixed sets / exclusively-off-chain rejection / RPC error; Test_checkOldBlockIDs_noPrefetchRoute now drives the rebuilding fallback.

Still owed (noted as unchecked in the test plan): a gRPC-topology bench on dev-scale before we default the toggle to true in a follow-up. PTAL.

@freemans13

Copy link
Copy Markdown
Collaborator Author

@oskarszoon
I've implemented a prefetch RPC for offhcain block ids just like the unchain version - this will alleviate the gRPC stampede

freemans13 added a commit to freemans13/teranode that referenced this pull request Jun 2, 2026
…tegration

Merge stu/blockvalidation-inmemory-checkold (ca7ef31) — off-chain prefetch
for checkOldBlockIDs plus the upstream/main it carried (bsv-blockchain#1005, bsv-blockchain#993, bsv-blockchain#1022).

Conflict resolutions:
- services/legacy/netsync/manager.go: kept integration's gap-watchdog
  self-heal (requestBlocksFromTip / maybeResyncOnMissingParent /
  missingParentResyncInterval); the incoming side had no content there.
- stores/utxo/aerospike/batch_create.go: pass nil for the new GetBinsToStore
  arena param (main added *bt.Arena; integration-only BatchCreate caller
  predates it). nil = non-arena route, output-equivalent per create_arena_test.

@oskarszoon oskarszoon 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.

Re-reviewed after the rework. The pivot to a single OffChainBlockIDs prefetch resolves the v1 gRPC-deployment footgun — one round-trip per block on any topology, heap win preserved, and the nil-header regression test + multi-element ANY-of case are now covered. But the rework introduced a consensus divergence that blocks merge:

P1 — the off-chain fastPath drops the id > maxBlockID guard the authoritative path enforces (BlockValidation.go:2271-2279 vs CheckBlockIsInCurrentChain.go:42-60). The store skips candidate IDs above maxBlockID (its own comment, sql.go:95-98: "prevent non-existent/garbage block IDs from being incorrectly treated as on-chain"). The new local fastPath has no such guard and short-circuits before the RPC, so a tx whose parent block IDs are all > maxBlockID is accepted locally but rejected by the RPC — contradicting the store's own tested invariant (CheckBlockIsInCurrentChain_test.go:86,263,294). Reachable in normal operation, not just adversarially: GetNextBlockID advances the sequence and the id is written into tx-meta BlockIDs (quick_validate.go:1130-1147) before AddBlock bumps maxBlockID, with mainChainRebuilding==0rebuilding=false → the unguarded fastPath runs; and the StoreBlock CAS (StoreBlock.go:187) can lag on the normal path too. A toggled node accepting what an untoggled node rejects is a chain-split risk. Fix: carry max_block_id in the OffChainBlockIDs response (the current ([]uint32, bool, error) has no channel for it) and apply the same id > maxBlockID skip in the fastPath — or fall through to the RPC for above-max IDs.

P1 — add a sqlitememory-backed regression test for the all-ids-above-max case. The new Test_checkOldBlockIDs_* use blockchain.Mock, which the repo testing rules say not to do (use sqlitememory + LocalClient); a real-store test would have caught this divergence directly. No test currently covers a parent ID above maxBlockID through the toggle route.

P1 — rename checkOldBlockIDsWithoutPrefetch. It does prefetch (the off-chain set); within 110 lines it calls itself both "off-chain-prefetch route" and "no-prefetch route".

Non-blocking: OffChainBlockIDs.go holds RLock across the full set copy where the rest of the package uses the O(1) pointer-swap (OffChainBlockIDs.go:35-40); and it has no direct unit tests for its rebuilding/normal branches.

Consensus reorg/rebuild gating, API consistency across all layers, and the regenerated proto all check out. Fix the guard + add the real-store test and this is good to go.

…n prefetch fastPath

Oli's re-review caught a consensus divergence in the off-chain-prefetch rework:
the local fastPath dropped the `id > maxBlockID` guard that the authoritative
CheckBlockIsInCurrentChain enforces. An ID can be allocated (GetNextBlockID) and
written into a tx's BlockIDs before AddBlock bumps maxBlockID, with
mainChainRebuilding==0; such an above-max ID is not in the off-chain set, so the
unguarded fastPath short-circuited to "accepted" while the authoritative RPC
rejects it — a toggled node accepting what an untoggled node rejects (chain split).

Fix: OffChainBlockIDs now also returns maxBlockID (new proto field max_block_id,
threaded through store/interface/LocalClient/gRPC Client/Server/mocks). The
fastPath applies the same id > maxBlockID skip, so an above-max ID falls through
to the authoritative RPC instead of being accepted locally. A block is rejected
identically by both paths.

Also:
- Add Test_checkOldBlockIDs_offChainPrefetch_realStore — a sqlitememory +
  LocalClient differential test (no blockchain-store mock, per testing.md): for
  each candidate ID the off-chain route's accept/reject must equal
  CheckBlockIsInCurrentChain. Fails if the guard is missing. Plus a maxBlockID
  guard unit case and Test_OffChainBlockIDs_StoreBranches for the store method.
- Rename checkOldBlockIDsWithoutPrefetch -> checkOldBlockIDsOffChainPrefetch (it
  prefetches the off-chain set); fix the stale "no-prefetch route" doc label.
- OffChainBlockIDs reads the set via the package's copy-on-write pointer grab
  (RLock, release, then iterate) instead of holding RLock across the full copy.
@freemans13 freemans13 requested a review from oskarszoon June 2, 2026 18:21
@freemans13

Copy link
Copy Markdown
Collaborator Author

@oskarszoon good catch on the consensus divergence — fixed in 6559dd9.

P1 — maxBlockID guard (the blocker). OffChainBlockIDs now also returns maxBlockID (new proto field max_block_id, threaded through store → interface → LocalClient → gRPC Client → Server → all mocks). The off-chain fastPath applies the same id > maxBlockID skip as CheckBlockIsInCurrentChain, so an above-max id falls through to the authoritative RPC instead of being short-circuited to accepted. Both paths now reject it identically — divergence closed.

P1 — real-store regression test. Added Test_checkOldBlockIDs_offChainPrefetch_realStore: a sqlitememory + LocalClient differential test (no blockchain-store mock, per the testing rules). It waits out the async startup rebuild, then for each candidate id (maxBlockID+1000, +1, and maxBlockID) asserts the off-chain route's accept/reject equals CheckBlockIsInCurrentChain — which fails if the guard is missing. Also added a maxBlockID guard unit case and Test_OffChainBlockIDs_StoreBranches for the store method's two branches.

P1 — rename. checkOldBlockIDsWithoutPrefetchcheckOldBlockIDsOffChainPrefetch; fixed the stale "no-prefetch route" doc label.

Non-blocking — locking. OffChainBlockIDs.go now grabs the map pointer under RLock and releases before iterating (the package's copy-on-write read pattern, matching CheckBlockIsInCurrentChain) instead of holding RLock across the full copy. The store method now has direct unit coverage too.

Verified: go build ./..., vet, golangci-lint clean; blockvalidation / blockchain / stores-blockchain(/sql) / blockpersister suites pass (the differential consensus test among them). PTAL.

@oskarszoon oskarszoon 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.

All three round-2 P1s are fixed and the consensus guard is verified:

  • maxBlockID guardOffChainBlockIDs now returns maxBlockID (new max_block_id proto field, threaded through every layer), and the fastPath applies the same id > maxBlockID skip as CheckBlockIsInCurrentChain, so an above-max id falls through to the authoritative RPC instead of being short-circuited to accept. The two routes are now identical on this input.
  • Regression testTest_checkOldBlockIDs_offChainPrefetch_realStore is a proper sqlitememory+LocalClient differential (asserts the off-chain route's accept/reject equals CheckBlockIsInCurrentChain for ids at/above maxBlockID). I confirmed it does its job: with the guard removed it fails with consensus divergence at id 1000: RPC on-chain=false but route accepted=true; with the guard it passes. The store-branch test and the rename are in too.
  • LockOffChainBlockIDs now uses the package's copy-on-write pointer-grab instead of holding RLock across the full copy.

Build/vet/race-tests green on my end. The red SonarQube gate isn't a bug: the new_reliability hit is the filename-convention rule flagging OffChainBlockIDs.go as non-snake_case — but the whole sql/ package is PascalCase, so the file matches its siblings; plus two go:S3776 cognitive-complexity smells (the guard pushed checkOldBlockIDsOffChainPrefetch to 17). Worth a glance, not blocking.

Approving.

…og labels

Addresses the two remaining items from the first review:

- Add BenchmarkCheckOldBlockIDs covering both the on-chain and off-chain prefetch
  routes at realistic tx counts (1k, 10k) with parents referencing a small set of
  recent on-chain blocks, so every tx resolves via the local fastPath (the
  production happy path). It is the guard against a silent regression to the per-tx
  CheckBlockIsInCurrentChain lookup: if a change stops the fastPath resolving
  locally, ns/op and allocs/op spike here instead of callers quietly falling back
  to the RPC/SQL path. Flat low allocs/op (~86-115) confirm no per-tx fan-out.

- Align the per-tx lookup log label across routes: the on-chain route logged
  "slowPath=%d" while the off-chain route logged "lookup=%d" for the same counter.
  Both now use "lookup" so the logs are directly comparable; doc comments updated.
@freemans13

freemans13 commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

@oskarszoon closing out the last two items from your first review (8ed7786, test-only):

Benchmark — added BenchmarkCheckOldBlockIDs over both routes at realistic tx counts (1k, 10k), parents referencing a small set of recent on-chain blocks so every tx resolves via the local fastPath. It's the regression guard you asked for: a change that stops the fastPath resolving locally spikes ns/op + allocs/op here instead of callers silently falling back to the RPC/SQL path.

goos: darwin  goarch: arm64  cpu: Apple M3 Max
BenchmarkCheckOldBlockIDs/on-chain-prefetch/1000-16      35163     32633 ns/op    63130 B/op     86 allocs/op
BenchmarkCheckOldBlockIDs/off-chain-prefetch/1000-16     40040     28802 ns/op    60942 B/op     72 allocs/op
BenchmarkCheckOldBlockIDs/on-chain-prefetch/10000-16      5655    252051 ns/op   447525 B/op    114 allocs/op
BenchmarkCheckOldBlockIDs/off-chain-prefetch/10000-16     6849    190709 ns/op   445664 B/op    100 allocs/op

Flat, low allocs/op (~72–114, not scaling with tx count) confirm no per-tx fan-out — every tx resolves on the fastPath. The off-chain route is also a touch cheaper (no 10k positive-map build): ~190µs vs ~252µs and 100 vs 114 allocs at 10k txs.

Log label — aligned the per-tx counter label: on-chain route logged slowPath=%d, off-chain logged lookup=%d; both now use lookup so the logs are directly comparable. Doc comments updated.

No production-logic change (benchmark + log string only). Sorry to bounce your approval on the SHA — re-requesting. PTAL.

@freemans13 freemans13 requested a review from oskarszoon June 3, 2026 09:12
@sonarqubecloud

sonarqubecloud Bot commented Jun 3, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@freemans13 freemans13 requested a review from ordishs June 3, 2026 09:25

@ordishs ordishs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approve.

Consensus-equivalence with CheckBlockIsInCurrentChain is the right invariant and it is both reasoned and tested. Verified the central claims:

  • The new OffChainBlockIDs store method mirrors the authoritative in-memory branch exactly — same gating (!useInMemoryChainCheck || mainChainRebuilding > 0), same maxID-before-set read ordering, same copy-on-write snapshot.
  • The id > maxBlockID guard in the fastPath is present and is the linchpin against a toggled/untoggled chain split (allocated-but-uncommitted IDs fall through to the authoritative RPC). Covered by both a mock test and a real sqlitememory differential test.
  • Single-snapshot reorg model matches the existing on-chain route; rebuilding=true routes to the per-tx RPC fallback; rejects only ever happen after the authoritative RPC.
  • nil-header guard moved up-front (round-1 panic fix) with a regression test.

Local checks: go build of the three affected packages clean; go test -tags testtxmetacache for the checkOldBlockIDs/OffChainBlockIDs suites passes.

Non-blocking follow-ups:

  • The protoc-gen-go-grpc v1.6.1 → v1.6.2 bump in generated code is incidental to local toolchain — worth confirming the repo's pinned codegen version to avoid churn.
  • GetOffChainBlockIDs response is unbounded over the wire (vs the 10k window) — fine in practice (near-empty on a healthy chain); a doc note on expected size would help.
  • Test_checkOldBlockIDs_noPrefetchRoute actually exercises the off-chain route's rebuilding fallback; the name reads slightly off.

Default path is unchanged; leaving the toggle default off and deferring the gRPC-topology bench is the correct sequencing.

@oskarszoon oskarszoon 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.

Re-approving on 8ed7786c. Confirmed the only change since the prior approval is test + log-string: the BlockValidation.go diff is purely the slowPathlookup label rename (same lookupCount value, comments updated), and the maxBlockID guard is untouched (still at the fastPath, line 2287). The consensus guard + its differential regression test are intact and green on my end.

BenchmarkCheckOldBlockIDs reproduces here — flat allocs/op (86/72 at 1k, 115/100 at 10k, not scaling with tx count) confirms every tx resolves on the fastPath with no per-tx fan-out, which is exactly the silent-regression guard. Off-chain route a touch cheaper (no 10k positive-map build). build/vet/tests/bench all pass.

The red SonarQube gate isn't a bug: new_reliability=C is the filename-convention rule flagging OffChainBlockIDs.go as non-snake_case (the whole stores/blockchain/sql/ package is PascalCase, so it matches its siblings) plus two go:S3776 cognitive-complexity smells. Not blocking.

Approving.

@freemans13 freemans13 merged commit 9b2d923 into bsv-blockchain:main Jun 4, 2026
34 checks passed
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.

4 participants