fix(legacy): merge blockID into pre-existing tx in createUtxos#854
Conversation
The legacy quickValidation fast path stores blocks with mined_set=true upfront and skips the async setTxMinedStatus step, relying on createUtxos to bake BlockIDs into each tx at creation time. When utxoStore.Create returned ErrTxExists for a tx that pre-existed (propagation arrival, prior crashed attempt, or the slow-path subtreeValidation route), the previous code logged at Debug and returned nil — the new block's ID was never merged into the existing tx's BlockIDs. Subsequent blocks then failed validation in model/Block.go getParentTxMetaBlockIDs with "parent transaction X of tx Y has no block IDs" (BLOCK_INVALID), cascading to mark every descendant block invalid. Observed on mainnet eu-3 (height 302681) and testnet ttn-eu-4 (height 1512911) after upgrade to v0.15.0-beta-10. Mirror the pattern from services/blockvalidation/quick_validate.go createAndSpendUTXOsForBatch: collect pre-existing tx hashes during the parallel Create pass, then issue one SetMinedMulti to merge the blockID into all of them. v0.14.x performed this merge implicitly via the async setTxMined → SetMinedMulti path; the fast path short-circuited that and lost the merge. Regression introduced by PR bsv-blockchain#711 (commit 95dbe32) which added the pre-assigned blockID + mined_set=true fast path.
|
🤖 Claude Code Review Status: Complete Current Review: The fix correctly addresses the root cause where pre-existing transactions were not getting the new blockID merged into their BlockIDs field. The implementation mirrors the pattern from quick_validate.go and includes comprehensive test coverage. One minor observation: The code at handle_block.go:671 appends &txHash where txHash is a goroutine-captured value. While this should be safe due to Go escape analysis (the variable will be heap-allocated), it differs slightly from the reference implementation which calls tx.TxIDChainHash() to get a fresh pointer. Both approaches should work correctly. No blocking issues found. The fix follows project patterns and includes proper error handling, logging, and test coverage. |
|
Benchmark Comparison ReportBaseline: Current: Summary
All benchmark results (sec/op)
Threshold: >10% with p < 0.05 | Generated: 2026-05-12 17:26 UTC |
There was a problem hiding this comment.
Pull request overview
This PR fixes a legacy netsync quick-validation regression where createUtxos could silently skip applying the current block ID to transactions that already existed in the UTXO store (returned ErrTxExists). That left those txs with empty/stale BlockIDs, causing descendant blocks to fail validation (“parent transaction … has no block IDs”) and triggering cascading invalidation.
Changes:
- In
createUtxos, collect tx hashes that returnErrTxExistsduring parallelCreatecalls. - After the parallel create pass completes, call
SetMinedMultionce to merge the current block’s mined info into those pre-existing tx records. - Add a unit test that pre-creates a tx with empty
BlockIDs, runscreateUtxos, and asserts the block ID was merged.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| services/legacy/netsync/handle_block.go | Tracks ErrTxExists txs during UTXO creation and batch-merges the blockID via SetMinedMulti to prevent empty/stale BlockIDs. |
| services/legacy/netsync/handle_block_test.go | Adds a targeted regression test ensuring createUtxos merges the block ID for pre-existing tx records. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
createUtxos was calling SetMinedMulti with one unbounded slice containing every pre-existing tx in the block. On fat mainnet blocks (e.g. 755880 = 2.87M txs) this monolithic batch exhausts the aerospike client connection pool and stalls sync in a MAX_RETRIES_EXCEEDED retry loop. Regression from #854. Split the merge across a worker pool bounded by UtxoStore.MaxMinedRoutines, each worker iterating its range in MaxMinedBatchSize chunks. Mirrors the proven MarkTransactionsOnLongestChain pattern. Fail-fast errgroup semantics match the existing createUtxos Create() loop: first chunk error cancels siblings via gCtx and propagates a wrapped ProcessingError. Fixes #936



Summary
Fixes
BLOCK_INVALID (11): parent transaction X of tx Y has no block IDsthat stalled bothbsva-ovh-teranode-eu-3(mainnet, height 302,681) andbsva-ovh-teranode-ttn-eu-4(testnet, height 1,512,911) within ~8 minutes of each other on 2026-05-12 after upgrade to v0.15.0-beta-10.Root cause
PR #711 (commit 95dbe32) added a quickValidation fast path: legacy netsync pre-assigns a block ID, bakes it into UTXO records during
createUtxos, and skips the asyncsetTxMinedStatusstep by callingAddBlock(WithID, WithMinedSet(true)).The fast path relies on
createUtxosputting the block ID into every tx'sBlockIDsat creation time. But whenutxoStore.CreatereturnedErrTxExistsfor a tx that pre-existed (propagation arrival, prior crashed attempt, or the pre-fast-path subtreeValidation route), the previous code logged at Debug and returnednil:The new block's ID was never merged into the existing tx's
BlockIDs. Subsequent blocks failedvalidOrderAndBlessedinmodel/Block.go:1070-1072(getParentTxMetaBlockIDs) and cascaded — every descendant block marked invalid, and thesetTxMinedrecovery path withunsetMined=truethen cleared BlockIDs on txs in those invalid blocks, deepening the corruption.Pre-v0.15 the async
setTxMinedStatus→SetMinedMultipath performed this merge implicitly after block accept. The fast path short-circuited that.Fix
Mirror the pattern from
services/blockvalidation/quick_validate.go:1106-1161createAndSpendUTXOsForBatch:Createpass, collect tx hashes that returnedErrTxExists.SetMinedMultito merge the new blockID into all pre-existing records.Reproduction timeline (eu-3, from Coralogix archive)
14:56:19Zlegacy on pre-upgrade version starts processing block 302680 via the slow path (extendTransactions→subtreeValidation); subtreeValidation creates txs in the utxo store with empty BlockIDs.14:56:24Zlegacy errors:SUBTREE_ERROR (29): failed to check subtree → rpc Canceled(user-initiated upgrade restart).14:56:45Zv0.15.0-beta-10 starts.14:56:55Zlegacy fast pathcreateUtxosruns for block 302680. Tx54ea96a0…already exists in store with empty BlockIDs →ErrTxExists→ silently dropped.14:56:56ZblockvalidationAddBlockstores 302680 with mined_set=true; setMinedChan worker logs "block already has mined_set true, skipping setTxMined".14:56:58Zblockvalidation processes 302681; child tx543094cd…has parent54ea96a0…(which lives in 302680's subtree);getParentTxMetaBlockIDsfinds it in store with empty BlockIDs →BLOCK_INVALID. Cascade follows.Test plan
go test -race ./services/legacy/netsync/...— 50 passed, including newTestSyncManager_createUtxos_MergesBlockIDsForExistingTxswhich pre-creates a tx in a sqlitememory utxo store with empty BlockIDs, runscreateUtxos, and asserts the new blockID was merged in.go vet ./services/legacy/netsync/... ./model/...golangci-lint run ./services/legacy/netsync/...staticcheck ./services/legacy/netsync/...reconsiderblockon the invalid chain tip. Validation will re-execute, this time merging block IDs into the orphaned txs.Notes
utxoStore.CreatewithWithMinedBlockInfoshould be audited for the sameErrTxExistssilent-drop pattern; the equivalent path inservices/blockvalidation/quick_validate.goalready handles it correctly via a Phase 1.5 batch update.