Skip to content

fix(blockassembly): stop re-marking the whole mempool on every reorg - #2065

Open
icellan wants to merge 20 commits into
bsv-blockchain:mainfrom
icellan:claude-issue-2051
Open

icellan wants to merge 20 commits into
bsv-blockchain:mainfrom
icellan:claude-issue-2051

Conversation

@icellan

@icellan icellan commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Closes #2051

A 1-back/2-forward reorg cost block assembly 10–16 minutes on the scaling env, with intake stopped the whole time. About half of that was MarkTransactionsOnLongestChain(false) over the entire mempool: 380M records, 7.7 min on scale-1. The sweep was there for a real reason: a tip overtaken before block assembly moved onto it keeps the unmined_since its setTxMined cleared. But sweeping the whole mempool on every reorg was the wrong place to handle it.

What changes

1. reorgBlocks marks only what the reorg moved (services/blockassembly/subtreeprocessor/SubtreeProcessor.go)

  • The mark-false set is the losing txs plus the moved-back txs that are not in a moveForward block and are still in block assembly. Before, it was every tx in block assembly.
  • The invalid-block path (checkMarkNotOnLongestChain, the 992 case) now reads only the invalid block's own txs, not the whole mempool.
  • SubtreeProcessor.reset keeps its whole-assembly sweep. Reset is rare, and the sweep is the catch-all for anything the repair below hasn't reached yet.

2. Block validation repairs overtaken tips (services/blockvalidation/overtaken_tip_repair.go)

  • setTxMined tracks every block it writes as on the longest chain. When one leaves the chain, its txs that still look mined and are not mined on the new chain are re-marked. If a re-marked block comes back onto the chain, its stamped txs are marked mined again.
  • The repair runs on the setMined worker between blocks, in bounded steps: one subtree load or one slice of blockvalidation_tipRepairSliceSize txs (default 250000). Because no setTxMined write of this process can land inside a step, a tx shared with another block ends correct in either order, with no gates or re-checks around the write. The slice size bounds how long a step delays the next block's mined_set.
  • Steps are requested by best-chain notifications and completed writes, plus the periodic tick after a failure.
  • Transient failures retry indefinitely. A block that is gone from the store is dropped after 10 attempts. A subtree that is gone is skipped and the rest of the block is still repaired.

3. MarkTransactionsOnLongestChain gets a variadic WithSkipMissing option (stores/utxo)

  • The repair races the pruner, so with this option a missing record is a logged skip instead of Fatalf.
  • Only the repair passes it. Reorg and reset keep the fatal check. Existing callers are unchanged, and wrappers pass the option through.

4. FilterTxsNotOnLongestChain (model/)

  • Shared by the invalid-block check and the repair.
  • Reads BlockIDs/UnminedSince in batches through BatchDecorate and skips txs that are already unmined.
  • Takes the caller's main-chain id set, and resolves each distinct set of block ids with one CheckBlockIsInCurrentChain call, not one per tx.

5. Pre-existing test and race fixes, in their own commit

  • Data race on currentBlockHeight in the in-memory blob store.
  • Order-dependent TestIntegrationRetryWithMultipleFailures.
  • The commitBlock harness relying on another test to init metrics.
  • TestUnminedSinceReorgScenarios generating 1005 blocks in one RPC call, past the 30s timeout on Aerospike.

Limitations

  • The repair is serialised with this process's setMined worker only. It assumes one block validation instance writes setTxMined for the chain.
  • Repair state is in memory. After a restart, a re-marked block that comes back onto the chain keeps those txs flagged unmined until block assembly's next unmined load fixes them, and fork blocks deeper than 10 below the tip at restart are not re-seeded.
  • GetBestBlockHeader over gRPC carries no block id. The repair resolves the id with GetBlockHeaderIDs instead. e2e caught this; a unit test now pins it.

Verification

  • go build ./..., go vet ./..., golangci-lint --new-from-rev=origin/main: clean.
  • -race: model, services/blockvalidation/..., services/blockassembly/..., services/blockpersister/..., stores/utxo/... (Aerospike included), stores/txmetacache, stores/blockchain/..., stores/blob/memory: all pass. The repair tests also ran 10x under -race.
  • Mutation-checked: each repair behaviour (slicing, flip-back re-mine, re-mine filter, done reset, transient vs permanent failures, skipped subtree, gen check, worker-driven path, gRPC id) fails its test when reverted.
  • Sequential double_spend reorg/reset tests (Postgres and Aerospike) pass per test. In single-package runs, Aerospike tests time out intermittently, a different test each time; each passes alone and on base.
  • e2e TestUnminedSinceReorgScenarios: passed 7 of the last 8 runs. The one failure didn't reproduce in 5 reruns; earlier flakes in this suite were Aerospike test-container network errors.

Follow-ups

… during a reorg

A reorg folded every tx in block assembly into MarkTransactionsOnLongestChain(false),
380M records and ~8 minutes per 1-back/2-forward fork at scale with intake stalled.
The mark-false set is now the losing txs plus moved-back txs not in a moveForward
block that are still in block assembly.

The sweep was the only repair for a tip overtaken before block assembly moved onto
it: setTxMined had already cleared unmined_since on its txs. setTxMined now
re-checks the chain after writing and re-marks the txs that are not mined on the
new longest chain, before mined_set is written.

The per-tx chain filter is extracted to model.FilterTxsNotOnLongestChain and shared
by setTxMined and the invalid-block reorg check, which now reads only the invalid
block's txs instead of the whole mempool.

Refs bsv-blockchain#2051
A tip that is overtaken before block assembly moves onto it keeps the unmined_since
its setTxMined cleared: block assembly reaches the new tip by a pure catch-up and
never sees it as a moveBack block. Block assembly's reorg used to cover this by
re-marking its whole mempool; with that sweep narrowed, block validation repairs it.

Block validation tracks every block setTxMined writes as on the longest chain,
recording it before the write. When one has left the chain, its txs that still look
mined and are not mined on the new chain are re-marked not on the longest chain. A
re-marked block that comes back onto the chain has its txs marked mined again; a
block that comes back with nothing re-marked is simply eligible for repair again.
Invalid blocks and blocks 1000 or more below the tip are dropped.

The repair runs on the setMined worker, between blocks, as bounded steps: one subtree
load or one slice of txs (blockvalidation_tipRepairSliceSize, default 250000) per
step, holding only the current subtree's tx hashes. No setTxMined write of this
process lands inside a step, so a tx the overtaken block shares with another block
ends correct in either order. A new setTxMined write of a block abandons any repair
in progress for it. Steps are requested by best-chain notifications and completed
writes, and by the periodic tick while the last step failed. Transient failures are
retried for as long as it takes; a block whose block or subtrees are gone is dropped
after ten attempts. After a restart the tracker is seeded, once the process marks a
block mined, from the main chain and from mined fork blocks near the tip. The main-
chain id set is extended rather than refetched when the best block only extends the
previous one. A subtree that is gone or unreadable is skipped with an error log so the
rest of the block is still repaired, and marking a block mined again only rewrites the
txs that were actually re-marked.

Limitations: the repair is serialised with this process's setMined worker only, so it
assumes one block validation instance writes setTxMined for the chain. Repair state is
in memory; a re-marked block that comes back onto the chain after a restart keeps its
txs flagged unmined until loadUnminedTransactions' fix pass, and fork blocks deeper
than ten below the tip at restart are not re-seeded.

The repair races the pruner, so its writes use a new MarkTransactionsOnLongestChain
option, WithSkipMissing, that makes a record pruned since it was read a logged skip
instead of fatal. Every other caller keeps the fatal check. The option is variadic:
callers are unchanged, and wrappers pass it through.

SubtreeProcessor.reset keeps its whole-assembly sweep as the catch-all for a reset
that lands before a repair.

FilterTxsNotOnLongestChain reads BlockIDs and UnminedSince in batches through
BatchDecorate, skips txs that are already unmined, takes the caller's main-chain id
set, and resolves each distinct set of block ids with one CheckBlockIsInCurrentChain
call, so a repair writes only what changes and a fork storm costs a handful of chain
lookups, not one per tx.

The test MockStore implements GetLastNBlocks for the seed.

Refs bsv-blockchain#2051
…ing-dependent tests

Surfaced by -race runs and Aerospike runs on this branch; none is caused by it.

- stores/blob/memory: currentBlockHeight was written by SetCurrentBlockHeight and read
  by the TTL cleaner goroutine without synchronisation. It is now an atomic.Uint32.
- TestIntegrationRetryWithMultipleFailures sent all three peer announcements at once.
  When peer3 stored the block first, the failing peers never fetched and their attempt
  counters stayed at 0. Announcements now go out one at a time, each after the previous
  peer has been tried.
- The commitBlock harness relied on another test having initialised the prometheus
  metrics; run on its own, commitBlock dereferenced a nil histogram.
- TestUnminedSinceReorgScenarios generated its 1005 setup blocks in one RPC call, which
  outlasts the 30s RPC timeout on Aerospike. It now generates them in chunks.

Refs bsv-blockchain#2051
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:

The only new commit since the last review is 57de33a40, a merge of origin/main. It brings in the coinbase-finality change (9da65ceaa). That change touches model/Block.go, services/blockassembly/Server.go, candidate_time.go and quick_validate.go, and none of those files are in this PR. The only shared file is CHANGELOG.md, and this PR's entry is still under ## [Unreleased] → Breaking — behaviour changes on upgrade. The merge leaves the PR's own diff as it was. No new issues found.

The backoff trade-off thread is resolved. The author pointed out that the delay that matters runs from the read that recovers to the next pass. That gap is capped at tipRepairPendingMaxBackoff (128 blocks), which is under the default 288-block retention, so I withdrew the note.

Notes (non-blocking, unchanged): the Aerospike skip-missing branch still has no test of its own. Suggested verification: go test -race ./services/blockvalidation/ ./services/blockassembly/subtreeprocessor/ ./stores/utxo/sql/ ./model/ ./settings/.

History:

  • ✅ Withdrawn: the trade-off note on backoff delaying a recovered subtree. The cap per gap is below retention.
  • ✅ Fixed earlier: the remine/job.unreadable comment mismatch and the repeated full re-reads of a corrupt subtree (6bb7935d1). The table-lock retry moved into SetMinedMulti (928aadf7a). A corrupt subtree no longer blocks the block's later subtrees (728aa7353, 8c5f3632c). loadSubtreeNodes no longer maps transient errors to SubtreeNotFound. The utxo_reference.md/utxo.md corrections are in. Both reviewer threads (the unsafe restart direction and the multi-replica race) are covered in the godoc and the CHANGELOG.

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

Approving. I read the three commits at 116eed1 and found no correctness bug. The reorg now marks only what it moved, and the overtaken-tip repair is sound for one block validation instance. The comments below are non-blocking.

What I checked, so you can see where the approval comes from:

  • Repair against setTxMined. The repair's read and write both run on the setMined worker, so no setTxMined write can land between them. If block assembly's reorg mark(true) races an unmark slice, stamped is set before the write, so the next slice starts a remine job and the tx ends mined. I traced this by hand and found no order that leaves a tx looking mined while it is off the chain, short of a restart.
  • Which direction is safe. A mined tx left flagged unmined is the safe failure. The Aerospike pruner keeps any non-conflicting record with unminedSince set (dropUnminedRecords), and fixUnminedSinceInconsistencies clears it on the next load. The unsafe failure is the other one: a tx that looks mined while off the chain. Its parents can then be pruned from under it, which is the 1768 class of bug. My two comments are both about how that state can still arise.
  • FilterTxsNotOnLongestChain keeps the old checkMarkNotOnLongestChain decisions. It adds one skip, for records that are already unmined, and a mark would not change those anyway.
  • Setting wiring. blockvalidation_tipRepairSliceSize appears in settings.conf, settings.go, the struct tag and the reference doc, all with the default of 250000.

I agree with the docs finding in the github-actions review: docs/topics/stores/utxo.md section 10 should list MarkTransactionsOnLongestChain for Block Validation. I have not repeated it inline.

What I ran and what I did not: go test -race -count=1 on ./model/, ./services/blockassembly/subtreeprocessor/, ./stores/utxo/sql/ and ./services/blockvalidation/ at 116eed1, all passing. When I posted this, CI on 116eed1 still had test, legacy-sync, three smoketest and sequential shards, and two benches pending. I did not run the Aerospike-tagged tests or the e2e reorg suite.

Comment thread services/blockvalidation/overtaken_tip_repair.go Outdated
Comment thread services/blockvalidation/overtaken_tip_repair.go
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-2065 (4a6cf24)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 169
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.230µ 1.275µ ~ 0.500
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/memory-4 11.75m 11.74m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_1-4 12.80m 12.81m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_2-4 13.86m 13.86m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/memo... 23.84m 24.37m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 32.81m 31.15m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 33.92m 32.89m ~ 0.200
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/mem... 101.17m 99.22m ~ 0.200
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 227.9m 228.5m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 226.4m 229.4m ~ 1.000
BlockTxMap/txs=16384/nodeindex-4 2.071m 2.086m ~ 0.100
BlockTxMap/txs=16384/swiss-4 15.06m 13.71m ~ 0.200
BlockTxMap/txs=262144/nodeindex-4 15.48m 15.23m ~ 1.000
BlockTxMap/txs=262144/swiss-4 43.49m 42.92m ~ 0.400
BlockTxMap/txs=4194304/nodeindex-4 414.4m 377.9m ~ 0.100
BlockTxMap/txs=4194304/swiss-4 815.1m 780.6m ~ 0.100
TxMapPut_MemoryVsDisk/txs=100000/memory-4 6.569m 6.535m ~ 0.700
TxMapPut_MemoryVsDisk/txs=100000/disk_1-4 10.377m 9.626m ~ 0.100
TxMapPut_MemoryVsDisk/txs=100000/disk_2-4 9.448m 8.902m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/hit-4 3.706m 3.720m ~ 0.700
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/miss-4 2.724m 2.735m ~ 1.000
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/hit-4 1.560m 1.528m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/miss-4 1.593m 1.551m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/hit-4 1.590m 1.520m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/miss-4 1.608m 1.559m ~ 0.100
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 22.39m 20.80m ~ 0.100
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 21.08m 20.31m ~ 0.100
InpointDedup/txs=16K-4 4.183m 4.168m ~ 0.400
InpointDedup/txs=128K-4 15.47m 16.04m ~ 0.200
InpointDedup/txs=1M-4 116.3m 119.5m ~ 0.700
InpointDedup_TinyRuns-4 42.60m 43.48m ~ 1.000
MiningCandidate_Stringify_Short-4 162.6n 160.3n ~ 1.000
MiningCandidate_Stringify_Long-4 1.222µ 1.199µ ~ 1.000
MiningSolution_Stringify-4 621.4n 622.8n ~ 0.200
BlockInfo_MarshalJSON-4 1.672µ 1.677µ ~ 1.000
DecodeSubtreeMeta/go-subtree-4 176.9m 170.4m ~ 0.700
DecodeSubtreeMeta/packed-4 43.11m 40.97m ~ 0.700
DecodeSubtreeMeta/go-subtree/parallel-4 81.50m 82.46m ~ 1.000
DecodeSubtreeMeta/packed/parallel-4 24.21m 24.54m ~ 1.000
ValidateSubtreeNodesMatchKey/leaves=1024-4 235.7µ 238.1µ ~ 0.700
ValidateSubtreeNodesMatchKey/leaves=1048576-4 69.55m 70.01m ~ 1.000
NewFromBytes-4 100.3n 115.6n ~ 0.700
AddTxBatchColumnar_Validation-4 1.766µ 1.699µ ~ 0.100
OffsetValidationLoop-4 516.6n 446.0n ~ 0.100
BuildSubtreeMeta/serialized-4 10.56m 10.80m ~ 1.000
BuildSubtreeMeta/decoded-4 25.37m 25.75m ~ 0.200
Mine_EasyDifficulty-4 61.67µ 61.58µ ~ 1.000
Mine_WithAddress-4 8.326µ 7.409µ ~ 0.700
BlockAssembler_AddTx-4 0.02267n 0.01947n ~ 0.200
AddNode-4 11.65 11.03 ~ 0.400
AddNodeWithMap-4 12.02 11.49 ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 83.56n 86.79n ~ 1.000
DirectSubtreeAdd/64_per_subtree-4 33.47n 34.80n ~ 0.400
DirectSubtreeAdd/256_per_subtree-4 31.91n 32.34n ~ 0.700
DirectSubtreeAdd/1024_per_subtree-4 30.32n 29.98n ~ 0.400
DirectSubtreeAdd/2048_per_subtree-4 30.60n 30.19n ~ 1.000
SubtreeProcessorAdd/4_per_subtree-4 257.1n 278.4n ~ 0.200
SubtreeProcessorAdd/64_per_subtree-4 291.9n 321.6n ~ 0.100
SubtreeProcessorAdd/256_per_subtree-4 308.3n 333.1n ~ 0.200
SubtreeProcessorAdd/1024_per_subtree-4 314.6n 343.2n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 306.7n 324.1n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 310.6n 310.3n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 313.2n 310.3n ~ 0.400
SubtreeProcessorRotate/256_per_subtree-4 309.0n 313.3n ~ 0.400
SubtreeProcessorRotate/1024_per_subtree-4 312.4n 314.8n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 70.99n 72.46n ~ 0.400
SubtreeNodeAddOnly/64_per_subtree-4 43.78n 44.78n ~ 0.700
SubtreeNodeAddOnly/256_per_subtree-4 42.46n 43.71n ~ 0.100
SubtreeNodeAddOnly/1024_per_subtree-4 41.88n 42.83n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 178.0n 176.8n ~ 1.000
SubtreeCreationOnly/64_per_subtree-4 370.5n 384.4n ~ 0.400
SubtreeCreationOnly/256_per_subtree-4 1.226µ 1.260µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 4.215µ 4.331µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 8.263µ 8.408µ ~ 1.000
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 232.0n 232.6n ~ 0.200
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 296.7n 300.9n ~ 0.700
ParallelGetAndSetIfNotExists/1k_nodes-4 754.0µ 823.6µ ~ 0.700
ParallelGetAndSetIfNotExists/10k_nodes-4 5.282m 5.612m ~ 0.200
ParallelGetAndSetIfNotExists/50k_nodes-4 7.773m 8.575m ~ 0.400
ParallelGetAndSetIfNotExists/100k_nodes-4 10.11m 11.29m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 727.2µ 755.5µ ~ 1.000
SequentialGetAndSetIfNotExists/10k_nodes-4 6.716m 7.700m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 22.49m 37.80m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 36.15m 63.13m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 780.3µ 802.2µ ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 7.887m 8.743m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.20m 14.31m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 795.9µ 816.6µ ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 10.09m 13.54m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 55.62m 62.58m ~ 0.100
DiskTxMap_SetIfNotExists-4 486.6n 480.4n ~ 0.700
DiskTxMap_SetIfNotExists_Parallel-4 182.1n 188.2n ~ 0.700
DiskTxMap_ExistenceOnly-4 231.0n 225.6n ~ 0.700
Queue-4 132.3n 130.6n ~ 0.200
AtomicPointer-4 2.798n 2.780n ~ 0.700
TxMapSetIfNotExists-4 38.87n 38.75n ~ 0.700
TxMapSetIfNotExistsDuplicate-4 33.15n 33.16n ~ 1.000
ChannelSendReceive-4 428.5n 429.2n ~ 0.700
CalcBlockWork-4 536.4n 511.3n ~ 1.000
CalculateWork-4 683.6n 681.0n ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/1000-4 43.89µ 45.35µ ~ 0.100
CheckOldBlockIDs/in-memory-chain-check/1000-4 1.153m 1.147m ~ 0.400
CheckOldBlockIDs/on-chain-prefetch/10000-4 345.6µ 340.2µ ~ 0.700
CheckOldBlockIDs/in-memory-chain-check/10000-4 1.708m 1.731m ~ 0.200
BuildBlockLocatorString_Helpers/Size_10-4 1.057µ 1.250µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_100-4 10.12µ 11.68µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 99.17µ 114.79µ ~ 0.100
CatchupWithHeaderCache-4 105.9m 106.0m ~ 0.700
_prepareTxsPerLevel-4 461.2m 466.1m ~ 1.000
_prepareTxsPerLevelOrdered-4 5.597m 4.947m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 457.5m 461.4m ~ 0.400
_prepareTxsPerLevel_Comparison/Optimized-4 4.867m 5.315m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.448m 1.445m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 329.1µ 340.4µ ~ 0.200
SubtreeSizes/10k_tx_64_per_subtree-4 78.73µ 83.11µ ~ 0.100
SubtreeSizes/10k_tx_256_per_subtree-4 20.10µ 19.93µ ~ 0.700
SubtreeSizes/10k_tx_512_per_subtree-4 10.45µ 10.11µ ~ 0.700
SubtreeSizes/10k_tx_1024_per_subtree-4 5.078µ 5.062µ ~ 1.000
SubtreeSizes/10k_tx_2k_per_subtree-4 2.541µ 2.531µ ~ 0.600
BlockSizeScaling/10k_tx_64_per_subtree-4 82.49µ 83.37µ ~ 0.700
BlockSizeScaling/10k_tx_256_per_subtree-4 21.10µ 19.53µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 5.160µ 5.236µ ~ 0.400
BlockSizeScaling/50k_tx_64_per_subtree-4 421.7µ 420.8µ ~ 1.000
BlockSizeScaling/50k_tx_256_per_subtree-4 102.7µ 103.0µ ~ 1.000
BlockSizeScaling/50k_tx_1024_per_subtree-4 24.35µ 27.61µ ~ 0.200
SubtreeAllocations/small_subtrees_exists_check-4 179.6µ 186.4µ ~ 0.200
SubtreeAllocations/small_subtrees_data_fetch-4 200.0µ 198.7µ ~ 0.100
SubtreeAllocations/small_subtrees_full_validation-4 389.7µ 391.3µ ~ 1.000
SubtreeAllocations/medium_subtrees_exists_check-4 10.99µ 11.49µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 12.45µ 12.03µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 25.74µ 24.31µ ~ 0.400
SubtreeAllocations/large_subtrees_exists_check-4 2.873µ 2.926µ ~ 0.100
SubtreeAllocations/large_subtrees_data_fetch-4 3.259µ 3.123µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 6.227µ 6.210µ ~ 0.400
_BufferPoolAllocation/16KB-4 3.304µ 3.259µ ~ 0.100
_BufferPoolAllocation/32KB-4 7.574µ 7.353µ ~ 0.700
_BufferPoolAllocation/64KB-4 19.25µ 14.50µ ~ 0.100
_BufferPoolAllocation/128KB-4 28.99µ 26.44µ ~ 0.100
_BufferPoolAllocation/512KB-4 109.1µ 112.8µ ~ 0.700
_BufferPoolConcurrent/32KB-4 17.05µ 16.47µ ~ 0.200
_BufferPoolConcurrent/64KB-4 26.37µ 26.08µ ~ 0.200
_BufferPoolConcurrent/512KB-4 137.6µ 139.2µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/16KB-4 506.5µ 514.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 501.3µ 510.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 516.7µ 510.8µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/128KB-4 513.4µ 517.9µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/512KB-4 506.4µ 518.3µ ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/16KB-4 27.74m 27.32m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/32KB-4 27.58m 27.69m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/64KB-4 27.54m 27.42m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/128KB-4 27.45m 27.14m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/512KB-4 27.18m 27.05m ~ 1.000
_PooledVsNonPooled/Pooled-4 511.8n 637.3n ~ 0.100
_PooledVsNonPooled/NonPooled-4 6.905µ 7.067µ ~ 0.700
_MemoryFootprint/Current_512KB_32concurrent-4 6.838µ 6.708µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 8.155µ 7.925µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 7.575µ 7.484µ ~ 0.100
StoreBlockForkPath/inMemoryChainCheck=false-4 618.8µ 617.4µ ~ 1.000
StoreBlockForkPath/inMemoryChainCheck=true-4 1.279m 1.279m ~ 1.000
StoreBlock_Sequential/BelowCSVHeight-4 194.3µ 192.9µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 195.1µ 194.5µ ~ 0.700
CheckSpendAndCreateMultiList_FanIn/parents=80000-4 42.07m 40.50m ~ 0.700
CheckSpendAndCreateMultiList_FanIn/parents=160000-4 114.9m 103.5m ~ 0.100
GetUtxoHashes-4 195.4n 196.7n ~ 1.000
GetUtxoHashes_ManyOutputs-4 28.76µ 28.57µ ~ 1.000
MetaBytes-4 40.71n 38.24n ~ 0.100
_NewMetaDataFromBytes-4 123.7n 125.3n ~ 1.000
_Bytes-4 201.5n 198.0n ~ 0.700
_MetaBytes-4 60.26n 59.74n ~ 0.400

Threshold: >10% with p < 0.05 | Generated: 2026-10-29 00:20 UTC

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

Request changes. The new overtaken-tip repair can miss a partially committed mined-status write after a restart, leaving an off-chain transaction flagged mined across restarts. That transaction is invisible to unmined loading and can be pruned with its parents.

  • P1 — services/blockvalidation/BlockValidation.go:1633 (setTxMinedStatus): An early SetMinedMulti batch can clear unmined_since before a later batch fails, leaving the block with MinedSet=false. If a winning block seeds repair before the losing block's off-chain retry, the seed skips it; the retry leaves unmined_since cleared. When block assembly moves back from an earlier block, the narrowed mark-false set no longer catches the transaction. Persist or reconstruct repair eligibility for partially written blocks, or restore their status on an off-chain retry. Add a regression test for that ordering. This needs a partial SetMinedMulti failure, a restart, then that ordering and a move-back reorg; it is reasoned from the code, not reproduced end to end.
  • SonarQube Quality Gate is red at this head.

…t way before a restart

A setTxMined that cleared some of a block's txs and then failed leaves the
block's mined_set unwritten. After a restart the seed skipped such blocks, and
their retry, with onLongestChain=false once overtaken, leaves unmined_since
alone, so the cleared txs kept looking mined while off the chain.

The seed now tracks a nearby fork block whose mined_set is not written as
unwritten, so its retry makes it repairable. Also covers the repair's error
paths with tests, documents the remaining restart limitation and the
multi-replica race, lists MarkTransactionsOnLongestChain for block validation in
the UTXO store write-access docs, and adds the CHANGELOG entry.
@icellan
icellan requested a review from oskarszoon October 27, 2026 16:02
@icellan

icellan commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in aa08cf6. The restart seed now tracks fork blocks within tipRepairSeedForkDepth whose mined_set is unwritten, as unwritten; their off-chain retry completes the write and makes them repairable. Regression test: TestRepairOvertakenTips_PartialWriteSeededBeforeRetry (partial SetMinedMulti failure, overtake, restart, seed before the retry), red before the fix. Retry-before-seed was already covered: the retry writes mined_set and the seed picks the block up as written.

Still open, now in the godoc and CHANGELOG: fork blocks deeper than tipRepairSeedForkDepth when the seed runs. The in-process case where setTxMined exhausts its retries stays unrepaired until a restart, which the seed now covers. Coverage tests added for the Sonar gate.

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

Request changes. The new restart seed and its regression test fix the seed-before-successful-retry failure from my previous review, and CI is green. A separate partial-write path still leaves an off-chain transaction falsely marked mined across restarts while recovery keeps failing.

  • P1 — services/blockvalidation/overtaken_tip_repair.go:428 (tipRepairSlice): After an on-chain SetMinedMulti chunk commits but a later chunk fails, a near-tip fork block is seeded with written=false. If its off-chain retry keeps failing, repair skips that block; periodic retries re-enqueue it but do not restore the committed transactions' unmined_since. The narrower block-assembly reorg sweep no longer catches a still-held transaction from that block in the move-back sequence. Make the committed portion repairable without requiring a complete retry, or restore its status on the failure path. Add a test that keeps the retry failing after restart and overtake. This needs a partial SetMinedMulti failure, an off-chain retry that keeps failing, a restart and an overtake; it is reasoned from the code, not reproduced end to end.

…, stuck jobs and remine

- Read a subtree the way setTxMined does: fall back to the .subtreeToCheck file
  and retry a failed read, so a block marked mined from a .subtreeToCheck file,
  or a read truncated while the file moves, no longer skips its txs for good.
- Set aside a job that fails 10 steps in a row so it stops holding up the
  repair of every other block; it starts over once they have had their turn.
- Remine only txs that still carry the block's id: a tx pruned and created
  again while the block was off the chain is in no block and stays unmined.
- Run the reorg mark-scope tests on a real sqlitememory chain store instead of
  a mocked blockchain client.
@icellan
icellan requested a review from oskarszoon October 27, 2026 16:56
Comment thread services/blockvalidation/overtaken_tip_repair.go Outdated

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

Request changes. CI is green, and the new tests cover their direct guards, but the repair can now treat a temporary blob-store failure as a completed repair. The partial-write P1 from my last review also remains open. Both can leave an off-chain transaction falsely marked mined.

  • P1 — services/blockvalidation/overtaken_tip_repair.go:715 (loadSubtreeNodes): After three transient subtree read errors, this wraps the last error as SubtreeNotFound. jobStep skips the subtree and marks the block done, so recovery of the blob store does not revisit its transactions. A focused repro left an off-chain transaction's unmined_since cleared after the store recovered. If the fork is more than ten heights below the tip before restart, the seed also misses it. Preserve transient error classification after the local retries and add a test that succeeds on a later repair slice. This needs three consecutive transient read errors on one subtree; the wrong state is reproduced in a focused test.
  • P1 — services/blockvalidation/overtaken_tip_repair.go:464 (startNextJob): The !b.written guard still excludes a seeded fork block after an early SetMinedMulti chunk commits and a later chunk fails. If its off-chain retry keeps failing, the committed transactions stay falsely mined; the narrower block-assembly reorg sweep no longer catches a still-held transaction in the move-back sequence. Make the committed portion repairable without requiring the entire retry to succeed, or restore its status safely on the failure path. Add a test that keeps the retry failing after restart and overtake. Still reasoned from the code, not reproduced end to end.

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

The narrower reorg mark set is correct. The overtaken-tip repair handles the case that the old sweep covered, and its serialisation on the setMined worker is sound. The PR documents the restart and single-instance limits.

…t subtree read errors

The overtaken-tip repair now repairs a tracked block whose setTxMined write failed part way,
instead of waiting for a retry to complete it; a retry that keeps failing left the cleared
txs looking mined while off the chain. The restart seed watches main-chain blocks whose
mined_set is unwritten as well. A completed write has a finished repair run again.

loadSubtreeNodes only treats a subtree that cannot be deserialized as gone. A blob-store
error that outlasts the read attempts is returned as is, so the step is retried rather
than the subtree skipped for good.

Updates the MarkTransactionsOnLongestChain signature in the utxo store reference.
# Conflicts:
#	services/blockvalidation/integration_retry_test.go
@icellan
icellan requested a review from oskarszoon October 27, 2026 22:42
@icellan

icellan commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed in d8fb981. loadSubtreeNodes now maps only a subtree that cannot be deserialized to SubtreeNotFound; any other error after the read attempts is returned as is and the step retries (TestRepairOvertakenTips_TransientSubtreeReadErrorRetried). The repair no longer waits for a block's write to complete: a tracked block whose setTxMined failed part way is repaired once it is off the chain, and the restart seed tracks main-chain blocks with mined_set unwritten too. A write that completes later makes a finished repair run again (TestRepairOvertakenTips_PartialWriteRetryKeepsFailing, in process and after restart). main merged in.

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

Request changes. The latest commit fixes the partial-write retry and direct blob-store error paths, but a short subtree stream can still make repair treat a transient read failure as permanent. The resulting off-chain transaction can remain falsely mined after storage recovers.

  • P0 — services/blockvalidation/overtaken_tip_repair.go:718 (loadSubtreeNodes): Three short subtree streams produce ErrProcessing from readSubtreeNodes, which this branch turns into ErrSubtreeNotFound. jobStep then skips the subtree and finishes repair, leaving its off-chain transactions with unmined_since=0. A focused test with three short reads reproduced that state even though the next read was healthy. A later restart outside the ten-height seed window can preserve it. Keep short or failed streams retryable after the local attempts, and add a test that recovers on a later repair slice.

…of skipping it

A short read looks the same as a corrupt subtree file, so mapping a deserialize failure
that outlasts the read attempts to SubtreeNotFound let a run of short reads skip the
subtree for good, leaving its txs looking mined while off the chain. The error is now
returned for the next step to retry; a job that keeps failing is set aside.

Also removes a race in TestRepairOvertakenTips_RunsOnTheSetMinedWorker: the test wrote
the competing block's tx straight to the store after the chain flipped, where the live
worker's repair slice could interleave with it.
@icellan

icellan commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in a4cfefa. loadSubtreeNodes no longer maps a deserialize failure to SubtreeNotFound: a short read looks the same as a corrupt file, so any error that outlasts the read attempts is returned and the next slice retries it. Only a subtree missing under both file types is still skipped; a job that keeps failing is set aside after 10 steps so other blocks go first. Test: TestRepairOvertakenTips_ShortReadsOutlastAttemptsRetried (short reads through every attempt of one slice, recovers on the next).

@icellan
icellan requested a review from oskarszoon October 28, 2026 11:33
Comment thread services/blockvalidation/overtaken_tip_repair.go Outdated

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

Request changes. The new retry path fixes the transient short-read case, but a persistent corrupt subtree can now prevent repair of later intact subtrees in the same overtaken block. Their transactions remain falsely marked mined while off chain, and that status can survive a restart after the fork leaves the startup seed range. The initiating blob fault is internal; the later-subtree regression is new in this commit.

  • P0 — services/blockvalidation/overtaken_tip_repair.go:718 (loadSubtreeNodes): After a successful on-chain mined-status write, a persistently unreadable earlier subtree makes jobStep retry at the same index forever, even when later subtrees are intact. Ten failed steps only defer and restart the block job. A focused two-subtree repro left the intact subtree's transaction with unmined_since=0 after the block was overtaken; restoring the prior error classification repaired it. Let later intact subtrees progress while preserving retries for transient short reads, and add a regression test for both cases. The repro uses a synthetic corrupt subtree; restart survival follows from the ten-height seed bound rather than a separate restart run.

…led repair steps

An unreadable subtree was retried forever: the job failed, was set aside, started again
from subtree 0 and failed at the same subtree until the block left the window, so the
subtrees after it were never repaired. A deserialize failure is now a SubtreeInvalid error;
when it is still failing on the job's tipRepairMaxAttempts-th failed step in a row, the
subtree is skipped with an error log and the rest of the block is repaired. A short run
of short reads still cannot cause a skip. Updates the jobStep godoc.
@icellan
icellan requested a review from oskarszoon October 28, 2026 12:48
@icellan

icellan commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 728aa73, which landed just after your review of a4cfefa. A subtree that still cannot be deserialized on the job's tipRepairMaxAttempts-th failed step in a row is skipped with an error log, and the job goes on to the later subtrees; a short run of short reads is still retried, not skipped. Regression tests for both cases: TestRepairOvertakenTips_CorruptSubtreeDoesNotBlockLaterSubtrees (bbff774, your two-subtree case: corrupt first subtree, the intact second one is repaired; fails with the skip disabled) and TestRepairOvertakenTips_ShortReadsOutlastAttemptsRetried. main merged in.

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

Request changes. The new ten-step skip lets an overtaken block's repair finish after temporary short subtree reads, leaving an off-chain transaction falsely marked mined even when storage recovers. The previous persistent-corruption stall is fixed for an immediately readable later subtree, but the new failure still needs to be resolved.

  • P0 — services/blockvalidation/overtaken_tip_repair.go:575 (jobStep): Ten failed steps caused by finite short reads make this branch skip the subtree and mark the block done. A focused repro returned healthy data after 30 short reads, yet the off-chain transaction still had unmined_since=0; subsequent repair calls did not revisit it. After the fork leaves the ten-height restart seed range, that wrong status can survive restart. The same counter carries across subtrees: after nine failures on corrupt subtree A, one three-short-read step on intact subtree B skips B too. Keep ambiguous read failures eligible for later repair while allowing later subtrees to progress, and count failures per subtree. Add a two-subtree regression test that checks the later transaction's status.

…air, count failures per subtree

A subtree skipped after its tenth failed load was never read again, so a long run of
short reads left its txs looking mined after storage recovered. The skip counted the
job's failed steps, so a subtree right after one that was skipped inherited nine failures
and was skipped after one more.

Failed loads are now counted per subtree. A subtree that still cannot be deserialized after
tipRepairMaxAttempts loads is set aside and the job goes on to the later subtrees; when it
ends, the block is not done, holds the set-aside subtrees as pending and is deferred, and
its next unmark job reads only those. A completed setTxMined write, or the block coming
back onto the chain, clears pending so the whole block is repaired next time.
@icellan
icellan requested a review from oskarszoon October 28, 2026 15:36
@icellan

icellan commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 8c5f363. Failed loads are counted per subtree now, so a subtree right after a skipped one starts at zero. A subtree that still cannot be deserialized after 10 loads is set aside, not skipped: the job goes on to the later subtrees, and when it ends the block is not done, holds the set-aside subtrees as pending and is deferred behind other blocks. Its next unmark job reads only those subtrees, so they are repaired once reads are whole again. A completed setTxMined write, or the block coming back onto the chain, clears pending, so the whole block is repaired next time.

Two-subtree tests that check the later tx's status:

  • TestRepairOvertakenTips_ShortReadsThenRecoveryRepairsSkippedSubtree: 30 short reads on subtree 0, then healthy. Both txs end unmined and the block is done.
  • TestRepairOvertakenTips_SkipFailuresCountedPerSubtree: subtree 0 corrupt, one step of short reads on subtree 1. Subtree 1 is retried and repaired.
  • TestRepairOvertakenTips_CorruptSubtreeDoesNotBlockLaterSubtrees: the later tx is repaired, and the corrupt subtree stays pending.
  • TestRepairOvertakenTips_PendingClearedBackOnChain.
  • TestMinedOnChainTracker_WriteClearsPending.

main merged in.

Comment thread services/blockvalidation/overtaken_tip_repair.go
…a lock error

Under SQLite's shared cache a lock cycle between connections fails one side with
SQLITE_LOCKED. When that side was a SetMinedMulti chunk, the error was returned and
setTxMined failed the whole block, though the spend batch, the rollback and the
parent-output read all retry the same error. Each chunk is its own transaction and is
rolled back on failure, so it is now tried up to three times while isDeadlock matches,
with a short backoff.

Drops the retry the blockvalidation repair tests' store wrapper did for this.
…-aside subtrees

A block whose subtree stays unreadable was read again as soon as no other block needed the
repair, so a corrupt subtree took the repair slot and logged on every pass until the block
left the window. The next pass now waits until the best height has moved on 2, 4, 8, ...
up to 128 blocks from the start of the last one, about a dozen passes over the window; it
still never gives the subtree up. Clearing pending resets the count.

Also corrects the jobStep and loadSubtreeNodes comments: only an unmark job comes back to
a set-aside subtree, and the remine job leaving one is the safe direction. The recovery
test now uses the 30 short reads its description names.
Comment thread services/blockvalidation/overtaken_tip_repair.go

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

Request changes. The pending-subtree change fixes the earlier permanent skip for finite short reads, but its height backoff can still leave a recovered subtree's transaction falsely mined until after the default pruning threshold. The new SQL lock retry also misses PostgreSQL statement deadlocks, and its named test passes with the production retry call removed.

  • P0 — services/blockvalidation/overtaken_tip_repair.go:708 (finishJob): Repeated short reads can set a subtree aside at best height 256 and postpone its next read until 384. If reads recover at 257, an off-chain child still has no unmined_since when the default 288-height pruning deadline arrives; its fully spent parent can be deleted before repair. This is an incomplete fix for the prior false-mined-status finding, not a new failure first introduced by this latest commit. Bound pending retries by the earliest pruning deadline or protect the affected records while repair waits, and test recovery across that boundary.
  • P1 — stores/utxo/sql/sql.go:3636 (SetMinedMulti): PostgreSQL deadlocks from a chunk statement or commit are wrapped before isDeadlock sees them. The wrapper drops *pgconn.PgError, so the new chunk retry makes one attempt and returns the error. This behavior existed before the retry commit, but the new shared SQL retry still misses this supported backend. Preserve or classify the driver error before wrapping, and test a PostgreSQL 40P01 chunk failure.
  • P1 — stores/utxo/sql/setmined_lock_retry_test.go:21 (TestSetMinedMultiRetriesLockError): The test calls retryWriteOnLockError directly. Removing the production retry call from SetMinedMulti left the named test and all four subtests passing. Add a test that injects a lock error through Store.SetMinedMulti and fails when that call-site retry is removed.

@icellan
icellan requested a review from oskarszoon October 29, 2026 00:02
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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.

Block assembly reorg takes 10–16 minutes at scale: every reorg re-marks the whole mempool as not on the longest chain

4 participants