Skip to content

fix(utxo): pick in-batch duplicate spend winners from persisted state - #2036

Open
ordishs wants to merge 10 commits into
bsv-blockchain:mainfrom
ordishs:repro/utxo-respend-4934
Open

ordishs wants to merge 10 commits into
bsv-blockchain:mainfrom
ordishs:repro/utxo-respend-4934

Conversation

@ordishs

@ordishs ordishs commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

An idempotent re-spend could be rejected because a competing claim arrived earlier in the same UTXO batch. The error named a transaction that never owned the output and could trigger rollback of legitimate spends. This fixes bitcoin-sv/teranode#4934.

Changes

  • ProcessSpendBatch submits competing claims in ordered backend parts. Success or authoritative UTXO_SPENT data identifies the stored owner; later competing claims are rejected locally only when their UTXO hash, height and validation flags match. Owner re-spends still reach the backend. Errors naming the claim itself do not establish ownership.
  • Alternating claims with matching checks require two backend calls rather than one per item. Claims with different checks, or without a known owner, still reach the backend. A nil SameChecks callback safely disables local rejection.
  • SQL delivers each result once and recovers batch panics, answering outstanding callers even when their part never ran.
  • Aerospike records expression outcomes before Lua retries. Repeated expression outcomes are coalesced across parts until an opposite outcome occurs: uninterrupted timeouts count once per batcher batch, but a success allows a subsequent failure to count again. This also applies when recovery comes from Lua. Earlier outcomes are recorded before a later part or retry can panic.
  • Every Lua success and infrastructure failure counts in processing order, including repeated record keys in later parts. There is no record-key deduplication that can hide failures. Split Lua batches can therefore trip the breaker sooner during an outage because they perform more backend operations; unsplit event ordering is preserved.
  • Both spend batch histograms observe one batcher batch, including expression-to-Lua retries. The CHANGELOG documents the metric and breaker behavior and the replacement of FilterConflictingDuplicateSpendClaims.
  • Comments explain why unspecified PostgreSQL and Aerospike batch ordering cannot replace the split.

Rebased onto upstream/main, preserving both the upstream and PR changelog entries.

Validation

Passed the focused race-enabled regression run across stores/utxo, stores/utxo/sql and stores/utxo/aerospike, including real PostgreSQL and Aerospike containers (no skipped tests). Coverage includes persisted-owner re-spends, absent owners, storage failures, alternating claims, SQL panic completion, different validation flags, nil SameChecks, repeated Lua keys, mixed expression/Lua outcomes in closed and half-open states, split batches, retry panics, and failure after expression or Lua recovery. The new failure cases were reproduced before their fixes.

Passed UTXO go vet, Staticcheck and changed-code golangci-lint checks.

Broader go test -short -tags testtxmetacache ./stores/utxo/... runs, both with and without -race, failed only at TestNativeTeranodeOpProbe_Container/executeTeranodeOp_native_path: Aerospike returned FAIL_FORBIDDEN during startup. The same failure was reproduced on upstream/main. Other UTXO packages passed.

Security scans match the base branch: Gosec reports the same 105 findings, and govulncheck reports the same GO-2026-6809 and GO-2026-6253 vulnerabilities. This PR does not change dependencies or the Go toolchain.

Limits

A claim whose earlier competitor failed without a known owner still costs its own backend call. Local rejection reflects a stored state observed while the claim was waiting; a concurrent rollback can subsequently unspend that owner. General cross-batch rollback and timeout behavior is unchanged.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:
No new issues found in 5fc0482.

The expression path now calls recordExpressions before the FILTERED_OUT Lua retry, and the deferred record is gone. An unsplit batch records breaker events in the same order as before this PR: expression success, then expression failure, then Lua retry events. The Lua path no longer dedups by record key. Every record goes through outcome.record inline, so failures in later parts count, and a panic in a later part cannot drop earlier outcomes. On the expression path, a run of same-kind outcomes across parts counts once, and the opposite outcome from either path re-arms it. The CHANGELOG entry now describes this accurately, including that a split Lua batch can reach the threshold sooner. A nil SameChecks now disables local rejection instead of panicking.

History:

  • ✅ Fixed: the expression-path breaker recorded after the Lua FILTERED_OUT retry, so the order changed (5fc0482)
  • ✅ Fixed: a nil SameChecks panicked on the first competing claim with a known owner (5fc0482)
  • ✅ Fixed: the Lua value dedup dropped later-part failures in split batches (5fc0482)
  • ✅ Fixed: the CHANGELOG claimed "exactly as before" breaker counting (5fc0482)
  • ✅ Fixed earlier: the order of Lua breaker events and merging in unsplit batches, UTXO_SPENT for claims with different checks (SameChecks), loss of earlier-part failures on panic, wrong spender attribution, K serial backend calls, SQL batch panic recovery, and per-part metric counting

Comment thread stores/utxo/duplicate_spend.go Outdated
Comment thread stores/utxo/sql/respend_same_batch_4934_test.go
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-2036 (a574e85)

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.735µ 1.728µ ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/memory-4 12.04m 11.97m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_1-4 13.03m 14.00m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_2-4 14.87m 15.14m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/memo... 27.31m 27.55m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 37.65m 40.41m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 37.52m 40.37m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/mem... 121.7m 124.1m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 259.4m 264.6m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 267.2m 260.2m ~ 0.700
BlockTxMap/txs=16384/nodeindex-4 2.551m 2.550m ~ 1.000
BlockTxMap/txs=16384/swiss-4 19.97m 19.18m ~ 0.400
BlockTxMap/txs=262144/nodeindex-4 19.12m 18.41m ~ 0.100
BlockTxMap/txs=262144/swiss-4 56.76m 55.35m ~ 0.700
BlockTxMap/txs=4194304/nodeindex-4 481.1m 476.8m ~ 1.000
BlockTxMap/txs=4194304/swiss-4 1003.1m 981.7m ~ 0.200
TxMapPut_MemoryVsDisk/txs=100000/memory-4 8.569m 7.920m ~ 0.100
TxMapPut_MemoryVsDisk/txs=100000/disk_1-4 13.32m 13.28m ~ 1.000
TxMapPut_MemoryVsDisk/txs=100000/disk_2-4 12.37m 12.18m ~ 0.700
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/hit-4 5.329m 4.883m ~ 0.400
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/miss-4 3.634m 3.805m ~ 0.200
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/hit-4 2.478m 2.323m ~ 1.000
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/miss-4 2.158m 2.572m ~ 0.400
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/hit-4 2.102m 2.248m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/miss-4 2.232m 2.338m ~ 1.000
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 27.16m 27.08m ~ 0.700
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 24.86m 24.23m ~ 0.100
InpointDedup/txs=16K-4 4.960m 4.987m ~ 0.700
InpointDedup/txs=128K-4 19.93m 20.00m ~ 1.000
InpointDedup/txs=1M-4 149.7m 138.2m ~ 0.100
InpointDedup_TinyRuns-4 49.73m 50.28m ~ 1.000
MiningCandidate_Stringify_Short-4 233.0n 229.5n ~ 0.700
MiningCandidate_Stringify_Long-4 1.706µ 1.666µ ~ 0.400
MiningSolution_Stringify-4 865.5n 851.3n ~ 0.100
BlockInfo_MarshalJSON-4 2.230µ 2.232µ ~ 0.700
DecodeSubtreeMeta/go-subtree-4 221.5m 220.9m ~ 1.000
DecodeSubtreeMeta/packed-4 60.26m 60.28m ~ 0.700
DecodeSubtreeMeta/go-subtree/parallel-4 108.2m 106.7m ~ 0.400
DecodeSubtreeMeta/packed/parallel-4 38.33m 37.24m ~ 1.000
ValidateSubtreeNodesMatchKey/leaves=1024-4 264.1µ 264.0µ ~ 1.000
ValidateSubtreeNodesMatchKey/leaves=1048576-4 80.46m 80.27m ~ 0.700
NewFromBytes-4 153.6n 125.6n ~ 0.700
AddTxBatchColumnar_Validation-4 2.114µ 2.117µ ~ 1.000
OffsetValidationLoop-4 645.5n 639.3n ~ 0.700
BuildSubtreeMeta/serialized-4 11.04m 11.00m ~ 0.400
BuildSubtreeMeta/decoded-4 22.21m 20.73m ~ 0.200
Mine_EasyDifficulty-4 60.86µ 61.50µ ~ 0.700
Mine_WithAddress-4 7.227µ 7.208µ ~ 1.000
DirectSubtreeAdd/4_per_subtree-4 86.82n 81.85n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 33.43n 33.64n ~ 0.800
DirectSubtreeAdd/256_per_subtree-4 31.44n 32.41n ~ 0.200
DirectSubtreeAdd/1024_per_subtree-4 30.05n 30.42n ~ 0.400
DirectSubtreeAdd/2048_per_subtree-4 30.04n 30.02n ~ 1.000
SubtreeProcessorAdd/4_per_subtree-4 251.5n 250.2n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 283.2n 279.9n ~ 1.000
SubtreeProcessorAdd/256_per_subtree-4 298.7n 300.6n ~ 0.400
SubtreeProcessorAdd/1024_per_subtree-4 296.4n 297.4n ~ 1.000
SubtreeProcessorAdd/2048_per_subtree-4 293.5n 295.4n ~ 0.400
SubtreeProcessorRotate/4_per_subtree-4 301.5n 297.1n ~ 0.100
SubtreeProcessorRotate/64_per_subtree-4 297.6n 298.6n ~ 0.700
SubtreeProcessorRotate/256_per_subtree-4 300.8n 301.8n ~ 1.000
SubtreeProcessorRotate/1024_per_subtree-4 298.5n 307.2n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 66.47n 68.74n ~ 0.400
SubtreeNodeAddOnly/64_per_subtree-4 40.12n 41.04n ~ 0.700
SubtreeNodeAddOnly/256_per_subtree-4 39.11n 40.49n ~ 0.100
SubtreeNodeAddOnly/1024_per_subtree-4 38.93n 39.56n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 162.8n 165.9n ~ 1.000
SubtreeCreationOnly/64_per_subtree-4 415.0n 427.1n ~ 1.000
SubtreeCreationOnly/256_per_subtree-4 1.389µ 1.397µ ~ 0.400
SubtreeCreationOnly/1024_per_subtree-4 5.485µ 5.314µ ~ 0.400
SubtreeCreationOnly/2048_per_subtree-4 9.747µ 10.292µ ~ 0.400
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 237.8n 247.3n ~ 0.400
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 290.8n 291.2n ~ 1.000
ParallelGetAndSetIfNotExists/1k_nodes-4 796.5µ 806.6µ ~ 1.000
ParallelGetAndSetIfNotExists/10k_nodes-4 5.079m 4.910m ~ 0.400
ParallelGetAndSetIfNotExists/50k_nodes-4 7.691m 7.332m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 10.096m 9.930m ~ 0.700
SequentialGetAndSetIfNotExists/1k_nodes-4 692.7µ 697.8µ ~ 1.000
SequentialGetAndSetIfNotExists/10k_nodes-4 6.862m 6.752m ~ 0.400
SequentialGetAndSetIfNotExists/50k_nodes-4 22.42m 20.93m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 37.82m 36.25m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 812.3µ 842.9µ ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 7.742m 7.614m ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.41m 13.34m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 784.5µ 802.3µ ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 9.748m 10.194m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 60.50m 58.67m ~ 0.700
DiskTxMap_SetIfNotExists-4 631.4n 577.3n ~ 0.200
DiskTxMap_SetIfNotExists_Parallel-4 202.6n 209.6n ~ 1.000
DiskTxMap_ExistenceOnly-4 341.1n 324.3n ~ 0.800
Queue-4 175.3n 171.0n ~ 0.100
AtomicPointer-4 4.415n 4.158n ~ 0.400
TxMapSetIfNotExists-4 63.49n 63.36n ~ 0.700
TxMapSetIfNotExistsDuplicate-4 47.62n 46.99n ~ 0.200
ChannelSendReceive-4 594.0n 597.5n ~ 1.000
BlockAssembler_AddTx-4 0.01779n 0.01798n ~ 1.000
AddNode-4 11.86 11.71 ~ 0.700
AddNodeWithMap-4 11.71 11.92 ~ 0.400
CalcBlockWork-4 359.1n 356.9n ~ 0.400
CalculateWork-4 499.7n 486.5n ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/1000-4 63.86µ 62.82µ ~ 0.400
CheckOldBlockIDs/in-memory-chain-check/1000-4 1.586m 1.703m ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/10000-4 559.2µ 501.1µ ~ 0.700
CheckOldBlockIDs/in-memory-chain-check/10000-4 2.304m 2.409m ~ 0.100
BuildBlockLocatorString_Helpers/Size_10-4 1.430µ 1.525µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_100-4 13.19µ 14.32µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 131.6µ 137.0µ ~ 0.100
CatchupWithHeaderCache-4 107.6m 107.8m ~ 1.000
_prepareTxsPerLevel-4 466.1m 462.5m ~ 1.000
_prepareTxsPerLevelOrdered-4 5.022m 4.863m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 462.4m 466.2m ~ 1.000
_prepareTxsPerLevel_Comparison/Optimized-4 4.497m 4.681m ~ 0.200
SubtreeSizes/10k_tx_4_per_subtree-4 1.372m 1.426m ~ 0.700
SubtreeSizes/10k_tx_16_per_subtree-4 323.2µ 335.3µ ~ 0.100
SubtreeSizes/10k_tx_64_per_subtree-4 76.43µ 76.96µ ~ 0.700
SubtreeSizes/10k_tx_256_per_subtree-4 19.09µ 19.31µ ~ 0.400
SubtreeSizes/10k_tx_512_per_subtree-4 9.699µ 9.614µ ~ 0.400
SubtreeSizes/10k_tx_1024_per_subtree-4 4.857µ 4.887µ ~ 1.000
SubtreeSizes/10k_tx_2k_per_subtree-4 2.425µ 2.460µ ~ 0.200
BlockSizeScaling/10k_tx_64_per_subtree-4 78.84µ 79.57µ ~ 0.700
BlockSizeScaling/10k_tx_256_per_subtree-4 20.36µ 19.47µ ~ 0.700
BlockSizeScaling/10k_tx_1024_per_subtree-4 5.049µ 5.071µ ~ 1.000
BlockSizeScaling/50k_tx_64_per_subtree-4 415.0µ 414.7µ ~ 0.700
BlockSizeScaling/50k_tx_256_per_subtree-4 102.4µ 101.6µ ~ 1.000
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.97µ 26.53µ ~ 1.000
SubtreeAllocations/small_subtrees_exists_check-4 179.9µ 180.5µ ~ 1.000
SubtreeAllocations/small_subtrees_data_fetch-4 197.6µ 195.0µ ~ 0.200
SubtreeAllocations/small_subtrees_full_validation-4 385.2µ 384.3µ ~ 0.700
SubtreeAllocations/medium_subtrees_exists_check-4 10.88µ 11.31µ ~ 0.200
SubtreeAllocations/medium_subtrees_data_fetch-4 12.21µ 11.87µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 24.97µ 24.46µ ~ 1.000
SubtreeAllocations/large_subtrees_exists_check-4 2.846µ 2.874µ ~ 0.400
SubtreeAllocations/large_subtrees_data_fetch-4 3.208µ 3.122µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 6.177µ 6.130µ ~ 0.400
_BufferPoolAllocation/16KB-4 4.805µ 3.266µ ~ 0.100
_BufferPoolAllocation/32KB-4 7.104µ 6.872µ ~ 0.100
_BufferPoolAllocation/64KB-4 14.11µ 17.67µ ~ 0.400
_BufferPoolAllocation/128KB-4 28.46µ 27.34µ ~ 0.100
_BufferPoolAllocation/512KB-4 116.5µ 111.5µ ~ 0.100
_BufferPoolConcurrent/32KB-4 16.76µ 16.40µ ~ 0.100
_BufferPoolConcurrent/64KB-4 27.24µ 26.56µ ~ 0.100
_BufferPoolConcurrent/512KB-4 145.6µ 134.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 522.2µ 507.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 511.8µ 504.3µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/64KB-4 510.5µ 511.9µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/128KB-4 500.2µ 510.4µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/512KB-4 517.3µ 516.3µ ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/16KB-4 29.42m 27.87m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 27.90m 27.93m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/64KB-4 28.07m 28.04m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/128KB-4 27.61m 27.60m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/512KB-4 27.42m 27.75m ~ 0.700
_PooledVsNonPooled/Pooled-4 638.3n 642.0n ~ 0.700
_PooledVsNonPooled/NonPooled-4 6.763µ 6.303µ ~ 0.200
_MemoryFootprint/Current_512KB_32concurrent-4 6.477µ 6.977µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 7.642µ 8.041µ ~ 0.200
_MemoryFootprint/Alternative_64KB_32concurrent-4 7.060µ 7.644µ ~ 0.100
StoreBlockForkPath/inMemoryChainCheck=false-4 425.3µ 424.7µ ~ 0.700
StoreBlockForkPath/inMemoryChainCheck=true-4 1.052m 1.060m ~ 0.700
StoreBlock_Sequential/BelowCSVHeight-4 135.6µ 137.1µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 136.2µ 136.8µ ~ 0.700
CheckSpendAndCreateMultiList_FanIn/parents=80000-4 50.23m 62.01m ~ 0.100
CheckSpendAndCreateMultiList_FanIn/parents=160000-4 124.7m 148.8m ~ 0.100
GetUtxoHashes-4 297.8n 300.3n ~ 0.400
GetUtxoHashes_ManyOutputs-4 43.04µ 43.48µ ~ 0.700
MetaBytes-4 69.11n 69.60n ~ 0.400
_NewMetaDataFromBytes-4 226.8n 228.2n ~ 0.400
_Bytes-4 347.3n 347.2n ~ 0.700
_MetaBytes-4 109.4n 108.8n ~ 0.700

Threshold: >10% with p < 0.05 | Generated: 2026-10-28 19:14 UTC

@ordishs
ordishs force-pushed the repro/utxo-respend-4934 branch from fc8414f to 2d8d13b Compare October 22, 2026 17:08
Comment thread stores/utxo/duplicate_spend.go Outdated
@ordishs
ordishs force-pushed the repro/utxo-respend-4934 branch from 2d8d13b to 8ff6d94 Compare October 27, 2026 10:21

@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. The code fixes bitcoin-sv/teranode#4934. One thing to do before merge: rewrite the PR description, which still describes the first design.

The description is stale. Commit 8ff6d94 replaced the persisted-spender lookup with ordered backend writes, but the description still documents the lookup. It describes the PersistedSpender callback, the memoised pool read, Aerospike calling GetSpend, and a residual risk about a read outside the batch transaction. None of those exist any more. It also says the CHANGELOG entry sits under "Not breaking, but worth knowing", but the entry is under "Breaking — behaviour changes on upgrade". That placement is the right one, since FilterConflictingDuplicateSpendClaims is removed. The test table is missing TestSpendBatchReportsPersistedOwnerOutsideBatch, TestSpendBatchStorageFailureDoesNotInventConflict and TestSpendSplitCompletesAllClaimsOnBackendFailure. If this is squash-merged, that text becomes the commit message.

What I checked on e71688a:

  • Every process callback finishes before returning. SQL goes through retrySpendBatch. Aerospike runs either executeLuaSpendBatch or SpendMultiWithExpressions, and the expressions path retries through Lua inline (spend_expressions.go:534, no goroutine). So each part really is decided against what the earlier parts wrote.
  • The panic sweep in sendSpendBatchLua cannot double-complete an item from an earlier part, because batchSpend.complete is guarded by a compare-and-swap.
  • Losing claims now reach the backend instead of being refused in memory. I checked that this cannot hurt a sibling: the Lua spendMulti reports errors per item and applies the other claims in the call, so a loser sharing a parent record with an honest spend does not drag it down. Refused spends have no side effects beyond the error.
  • Locally, these all pass: TestProcessSpendBatch, the four SQLite tests, TestRespendBehindLoserInSameBatchPostgresBulk and #825's Postgres bulk test (Postgres testcontainer), and on an Aerospike testcontainer TestRespendBehindLoserInSameBatch, TestSpendSplitCompletesAllClaimsOnBackendFailure, TestStore_SpendBatchRejectsDuplicateDifferentSpenders and TestStore_SpendExpressions_CrossBatchDoubleSpend, all on both lua and expressions.

What I did not check: CI on this head (a merge of main) was still queued when I looked. The SonarQube comment predates this head, though the PR's own code has not changed since 6f1ccb3. I ran only the tests above, not the full suite.

On the bot's open thread about cost: I agree it is minor. The worst case is one backend call per item, and a batch holds at most utxostore_spendBatcherSize items (1024 by default), so the cost is bounded. One inline note below, non-blocking.

Comment thread stores/utxo/duplicate_spend.go Outdated
// share a part. Every item reaches process exactly once, including invalid items
// whose validation belongs to the backend.
//
// process must finish the backend operation before returning. The backend then

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.

Non-blocking. I disabled the split (made the conflict check always false) in a scratch copy, and every backend test still passed. That covers the four SQLite tests, both Postgres bulk tests including #825's TestSpendBatchRejectsDuplicateDifferentSpendersPostgresBulk, and the Aerospike tests on lua and expressions. Only TestProcessSpendBatch notices.

The split is still worth keeping. There are two reasons, and neither engine guarantees an order here:

  • Postgres says that when UPDATE ... FROM joins two source rows to one target row, which row it uses "is not readily predictable". It happens to follow the order of the VALUES list today.
  • On Aerospike, two batch sub-operations on one key also have no promised order. Two Lua calls end up in the same batch when claims differ only in block height or ignore flags, and expressions always put each claim in its own sub-operation.

The split therefore protects against unspecified behaviour that no test can provoke. Could the comment say so, naming those two cases? Otherwise someone measuring the extra calls the bot flagged will find nothing fails without the split and remove it.

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

Splitting at the first conflicting claim fixes the same-batch respend, but every conflicting claim now costs its own serial backend round trip instead of a free local reject. Four inline points below.

Comment thread stores/utxo/duplicate_spend.go Outdated
Comment thread stores/utxo/sql/sql.go Outdated
Comment thread stores/utxo/sql/sql.go
Comment thread stores/utxo/aerospike/spend.go
@ordishs
ordishs requested a review from icellan October 27, 2026 22:55
@ordishs

ordishs commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in 147424c.

Thanks, all four points were correct. 147424c fixes them:

  1. After a part runs, ProcessSpendBatch reads each answer and learns the stored owner. A success names the claim itself. UTXO_SPENT names the owner in its data, unless that data is the claim's own. Later competing claims are then refused locally with UTXO_SPENT naming that owner. A claim that matches the owner still goes to the backend as an idempotent re-spend. A,B,A,B over a full batch now costs two backend calls.
  2. The same change removes most of the extra SQL transactions. A split now happens only when the owner is still unknown.
  3. SQL answers each caller through a once-only deliver and recovers a panic with util.SignalBatchPanic. The stale doc comment is fixed.
  4. On the expression path, the breaker result is collected over all parts and recorded once per batcher batch. The batch histograms now observe the batcher batch once, on both paths.

One limit remains. If the earlier claim fails without a known owner (storage error, missing parent, frozen), the next competing claim still costs its own call. I kept that on purpose: without a known owner, a local decision could invent a conflict, which is the bug this PR fixes.

Comment thread CHANGELOG.md Outdated
Comment thread stores/utxo/aerospike/circuit_breaker.go Outdated

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

Previous four points are addressed and resolved. The new breaker accounting has its own problem, plus two questions on the local reject.

Comment thread stores/utxo/aerospike/circuit_breaker.go Outdated
Comment thread stores/utxo/duplicate_spend.go Outdated
Comment thread stores/utxo/duplicate_spend.go Outdated
Comment thread stores/utxo/aerospike/circuit_breaker.go Outdated
Comment thread stores/utxo/aerospike/spend.go Outdated
Comment thread stores/utxo/aerospike/spend.go Outdated
// batch without a split. The deferred record keeps the expression-path
// result of earlier parts when a later part panics.
var outcome spendBreakerOutcome
defer outcome.record(s.spendCircuitBreaker)

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.

[Minor] On the expression path, this still changes the order of breaker events for a batch that is never split. Before this PR, processSpendBatchResultsExpressions called RecordSuccess/RecordFailure before it ran the FILTERED_OUT retry through executeLuaSpendBatch. Now the retry records inline through countLuaRecord, which returns true whenever split is false, and the expression flags are applied later in this defer. So the Lua retry events now come first.

Example: the breaker is half-open with halfOpenMax=1. One expression record times out, and one FILTERED_OUT record is retried through Lua and returns OK. Before: RecordFailure trips the breaker back to open, and the Lua success is then ignored. Now: the Lua RecordSuccess resets the breaker to closed, and the deferred RecordFailure only sets consecutiveFailures=1. The breaker closes while the backend is still failing. In the closed state the leftover count also differs (1 instead of 0).

This conflicts with the CHANGELOG line "counts a batch that is not split exactly as before". One option is to apply the expression flags right away when !outcome.split, before the retry, and use the defer only for split batches. A test like TestSpendLuaBreakerRecordsInOrder, but on the expression path with a timeout plus a FILTERED_OUT retry, would cover it.

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.

+1, still applies at 3380ac4.

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.

Confirmed fixed in 5fc0482. processSpendBatchResultsExpressions now calls outcome.recordExpressions before it dispatches the FILTERED_OUT retry through executeLuaSpendBatch, and the deferred record is gone. An unsplit batch therefore records expression success, then expression failure, then the Lua retry events, which is the order before this PR. TestSpendExpressionBreakerBeforeLuaRetry covers this. This thread can be resolved.

Comment thread stores/utxo/duplicate_spend.go Outdated

rejectedByOwner := func(item T, spend *Spend, key spendOutpointKey) bool {
owner, known := owners[key]
if !known || sameSpendingData(owner.data, spend.SpendingData) || !h.SameChecks(owner.decider, item) {

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.

[Minor] ProcessSpendBatch is exported, and the CHANGELOG tells Go integrations to migrate to it. A handler that leaves SameChecks nil works for batches without competing claims. It then panics with a nil func call the first time a competing claim meets a known owner, so the failure only shows up when a double-spend arrives. Both in-tree handlers set it. Consider treating nil as "never reject locally" (h.SameChecks == nil || !h.SameChecks(...)), or say in the field doc that it is required.

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.

+1, still applies at 3380ac4.

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.

Confirmed fixed in 5fc0482. A nil SameChecks now means "never reject locally", the field doc says so, and TestProcessSpendBatchWithoutSameChecks covers it. This thread can be resolved.

@ordishs
ordishs requested a review from icellan October 28, 2026 11:08

@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: one Aerospike spend-breaker regression. An unsplit expression batch can count a successful Lua retry before the expression failure that caused it, leaving the breaker closed during a partial backend failure where the previous code opened it.

  • P1 — stores/utxo/aerospike/spend.go:1019 and stores/utxo/aerospike/spend_expressions.go:481-526: When one expression record times out and another returns FILTERED_OUT then succeeds on Lua retry, the Lua success resets the breaker before the deferred expression failure is recorded. With one prior failure and threshold two, the breaker ends closed instead of opening as it did before this change. Please preserve expression-before-retry breaker order, including when a batch is not split, and add a regression test for this mixed result.

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

Spend fix looks right now; resolved the threads it covers. What's left is breaker accounting: split batches still drop record outcomes, the expression-path ordering (bot thread on spend.go:1019) is open, and the CHANGELOG overstates both.

Comment thread stores/utxo/aerospike/circuit_breaker.go Outdated
Comment thread CHANGELOG.md Outdated
…lter rejects an idempotent re-spend

Spend a double-spend attempt ahead of an idempotent re-spend of the
legitimate spender in one batch. FilterConflictingDuplicateSpendClaims
awards the outpoint to the first claim in the batch without consulting
the persisted spender, so the re-spend is rejected with ErrSpent naming
a tx that never spent anything.

Fails on 5021b54 at duplicate_spend.go:48.
FilterConflictingDuplicateSpendClaims awarded a colliding outpoint to the
first claim in the batch. Batch arrival order has no consensus meaning, so
an idempotent re-spend by the tx that already owns the outpoint lost to a
double-spend attempt that arrived a moment earlier. The rejected re-spend
then made Store.Spend un-spend the inputs it had just re-applied.

Take the winner from the persisted spender instead. Both backends answer a
new PersistedSpender callback, consulted only for outpoints that actually
collide, so an ordinary batch pays nothing extra. With nothing persisted the
first claim still wins, which is the guarantee bsv-blockchain#825 added the filter for:
two genuinely conflicting claims never both reach the backend.

Refs bitcoin-sv/teranode#4934.
…ier claim in its batch

Also restores the sendSpendBatchLua doc comment, which the new Aerospike
helper had been inserted into.
… Aerospike paths

Drive the SQL case through sendSpendBatch with an explicit batch so it no
longer depends on batcher timing, and pin the persisted spender out of the
spent-error rather than matching the message. The end-to-end SQLite test
stays: the rollback that loses the spend lives in Store.Spend, not in the
batch callback.

Verified each test fails with the resolver disabled and passes with it on:
SQLite, Postgres 13 bulk, Aerospike Lua and Aerospike expressions.
…r is known

A batch that alternated two valid spends of one outpoint cost one serial
backend call per claim. ProcessSpendBatch now reads each part's answers,
learns the stored owner from a success or from UTXO_SPENT data, and
refuses later competing claims with UTXO_SPENT naming that owner. Claims
that match the owner still reach the backend as idempotent re-spends.

The SQL spend batcher answers each caller through a once-only deliver and
recovers a panic, so claims in parts that never ran get an error instead
of waiting for the spend timeout.

The Aerospike expression-path circuit breaker and the spend batch
histograms now count one batcher batch, however many parts it needs.
The Lua path recorded the spend circuit breaker once per batch record in
each part, so a split batch counted the same record key more than once.
The Lua handlers now collect results in spendBreakerOutcome, and
sendSpendBatchLua records each record key once. A split batch counts like
the same batch without a split; distinct keys still count separately.
…a local reject

The Lua spend breaker merged records with equal values, so an unsplit
batch with two inputs from one parent counted once instead of twice, and
it applied all successes before all failures. The Lua path now records
each batch record inline, in order, as before; a split batch only skips a
record key that an earlier part already counted. The expression-path
result is recorded in a defer, so a panic in a later part keeps the
failures of earlier parts.

ProcessSpendBatch now refuses a claim locally only when SameChecks holds
against the claim that showed the owner: the same UTXO hash, block height
and ignore flags. Other claims go to the store, which can answer
CONFLICTING, LOCKED or a hash mismatch.
@ordishs
ordishs force-pushed the repro/utxo-respend-4934 branch from 3380ac4 to 5fc0482 Compare October 28, 2026 18:55
@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.

4 participants