Skip to content

fix(stores/utxo/aerospike): align frozen UTXO checks with SQL and bitcoin-sv#949

Merged
ordishs merged 3 commits into
bsv-blockchain:mainfrom
ordishs:fix/4640-aerospike-utxo-frozen-checks
May 27, 2026
Merged

fix(stores/utxo/aerospike): align frozen UTXO checks with SQL and bitcoin-sv#949
ordishs merged 3 commits into
bsv-blockchain:mainfrom
ordishs:fix/4640-aerospike-utxo-frozen-checks

Conversation

@ordishs

@ordishs ordishs commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

The Aerospike UTXO store's two spend paths (Lua and expression-based) diverged from the SQL store and bitcoin-sv at the freeze-expiry boundary. An Aerospike-backed node was strictly stricter than the rest of the network: it refused to mine or accept transactions that SQL/svnode considered valid.

Reference behaviour comes from svnode's CFrozenTXOCheck (bitcoin-sv/src/frozentxo_db.h): enforceAtHeight intervals are evaluated half-open with nHeight < i.stop — the freeze window is closed at start, open at stop. A UTXO becomes spendable AT stop.

Two related fixes ship together so all three storage paths (SQL, Aerospike Lua, Aerospike expression) make identical accept/reject decisions.

Fix 1 — Aerospike Lua boundary off-by-one (T5a)

stores/utxo/aerospike/teranode.lua:373 used >= which kept the UTXO frozen at currentBlockHeight == spendableIn. The SQL store uses strict < in all three call sites (sql.go:2071, 2557, 3346). Flipped to > so the UTXO becomes spendable AT spendableIn, matching SQL/svnode.

Fix 2 — Aerospike expression-path strictness (T25)

stores/utxo/aerospike/spend_expressions.go rejected ANY record with utxoSpendableIn set, regardless of the per-offset value, because Aerospike filter expressions cannot inspect map values — only check bin existence.

Picked Option B2a from the issue (over-retry through Lua):

  • Kept the conservative ExpNot(ExpBinExists(UtxoSpendableIn)) guard as defense in depth — a record needing per-offset comparison must never reach ListSetOp through the expression path.
  • Added retry-through-Lua: processSpendBatchResultsExpressions now collects FILTERED_OUT records and re-dispatches them via a single batched Lua call, which compares per-offset and produces the correct accept/reject decision with the precise rejection reason (ErrFrozen, ErrSpent, ErrTxConflicting, etc.) — rather than the previous generic "spend validation failed".
  • Extracted the Lua execution from sendSpendBatchLua into executeLuaSpendBatch so both the primary Lua route and the expression retry share the same implementation.

Cost: one additional batched Lua call per expression batch that has any FILTERED_OUT records. The cost is independent of how many records were filtered out — they all go through in the same batch — and is only paid when at least one record needed re-evaluation. In steady state most spends pass the expression filter, so this is paid only on already-failing records. The conservative guard means the safety property holds even if the retry logic has a bug.

Observability

Two new Prometheus counters give operators visibility into how often the expression path is falling back to Lua — the only quantitative signal that the conservative utxoSpendableIn guard is hot:

  • teranode_aerospike_utxo_spend_expression_lua_retry — counter incremented per expression batch that triggered a retry (rate divided by utxo_spend_batch gives the batch-level retry fraction).
  • teranode_aerospike_utxo_spend_expression_lua_retry_records — counter incremented per record re-dispatched through Lua (rate divided by utxo_spend gives the per-record retry fraction).

Test plan

New tests in stores/utxo/aerospike/spend_spendable_in_boundary_test.go, all run against a real Aerospike container via testcontainers-go:

  • TestStore_SpendableInBoundary — boundary at spendableHeight - 1 (reject), == spendableHeight (accept after fix), + 1 (accept). Runs against both Lua and expression paths.
  • TestStore_SpendableInExpressionParity_PastValueAccepted — record with utxoSpendableIn set to a long-past value is spendable through both paths.
  • TestStore_SpendableInExpressionParity_FutureValueRejected — record with utxoSpendableIn strictly in the future is rejected through both paths with ErrFrozen (the safety property the guard enforces).
  • Full go test ./stores/utxo/aerospike/... suite passes (178s with containers).
  • No regression in go test ./stores/utxo/sql/....
  • No regression in go test -tags testtxmetacache ./services/validator/....

Acceptance criteria (from bitcoin-sv/teranode#4640)

  • At blockHeight == spendableIn, all three store paths accept the spend.
  • At blockHeight == spendableIn - 1, all three reject.
  • A record with UtxoSpendableIn set whose values are all past is spendable through both Aerospike paths.
  • The Aerospike expression path never spends a record whose UtxoSpendableIn map contains a value > currentBlockHeight for the output being spent.
  • No regression in SQL or validator tests.

Closes bitcoin-sv/teranode#4640

…coin-sv

The Aerospike UTXO store's two spend paths (Lua and expression-based)
diverged from the SQL store and bitcoin-sv at the freeze-expiry boundary.
Reference behaviour: the freeze window is closed at start, open at stop —
a UTXO becomes spendable AT spendableIn (see svnode CFrozenTXOCheck).

Two related fixes:

1. Lua boundary off-by-one (T5a)

   teranode.lua used `>=` which kept the UTXO frozen at
   currentBlockHeight == spendableIn. SQL uses strict `<` consistently
   across all three call sites (sql.go:2071, 2557, 3346). Flipped to `>`
   so the UTXO becomes spendable AT spendableIn, matching SQL/svnode.

2. Expression-path strictness (T25)

   The expression filter rejected any record with utxoSpendableIn set,
   regardless of the per-offset value, because Aerospike expressions
   cannot inspect map values. Kept the conservative ExpBinExists guard
   for safety (a record needing per-offset comparison must never reach
   ListSetOp through the expression path) and added retry-through-Lua
   for FILTERED_OUT records: processSpendBatchResultsExpressions now
   collects filtered items and re-dispatches them via the Lua UDF, which
   compares per-offset and produces the correct accept/reject decision
   along with the precise rejection reason.

   Extracted the Lua execution from sendSpendBatchLua into
   executeLuaSpendBatch so both the primary Lua route and the expression
   retry share the same implementation.

Tests cover the boundary (at, below, above spendableIn) and parity
between Lua and expression paths for both past and future
spendableHeight values; all run against a real Aerospike container via
testcontainers-go.

Closes #4640
@github-actions

github-actions Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


Current Review:

No issues found. This PR implements a careful fix for the frozen UTXO boundary checks with strong safeguards:

Strengths:

  • Correctness: The Lua boundary change from >= to > correctly aligns with SQL store (stores/utxo/sql/sql.go:2071, 2557) and bitcoin-sv reference behavior
  • Conservative approach: Expression path keeps the strict guard and retries FILTERED_OUT records through Lua rather than weakening the filter
  • Comprehensive tests: Three test functions cover boundary cases, past values, and future values across both Lua and expression paths
  • Observability: New Prometheus metrics provide visibility into retry frequency
  • Code reuse: executeLuaSpendBatch extraction eliminates duplication between primary Lua route and expression retry

Design tradeoffs acknowledged:

  • Expression retry adds one batched Lua call per batch with FILTERED_OUT records (cost is batch-level, not per-record)
  • Conservative guard may over-filter but ensures safety property holds even if retry logic has bugs

The PR follows AGENTS.md principles: minimal changes, test-first approach, clear tradeoffs stated upfront.

@github-actions

github-actions Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-949 (fd451df)

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.709µ 1.714µ ~ 0.700
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 59.21n 59.26n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 59.11n 59.36n ~ 0.200
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 59.21n 59.26n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 33.29n 32.82n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 60.31n 56.90n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 148.3n 145.7n ~ 0.300
MiningCandidate_Stringify_Short-4 248.0n 248.0n ~ 0.800
MiningCandidate_Stringify_Long-4 1.751µ 1.735µ ~ 0.100
MiningSolution_Stringify-4 897.2n 892.6n ~ 0.200
BlockInfo_MarshalJSON-4 1.724µ 1.718µ ~ 0.200
NewFromBytes-4 134.7n 174.3n ~ 0.400
AddTxBatchColumnar_Validation-4 2.632µ 2.500µ ~ 0.100
OffsetValidationLoop-4 592.7n 598.8n ~ 0.100
Mine_EasyDifficulty-4 61.08µ 61.51µ ~ 0.700
Mine_WithAddress-4 7.252µ 7.099µ ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 59.53n 58.81n ~ 1.000
DirectSubtreeAdd/64_per_subtree-4 30.84n 30.37n ~ 0.700
DirectSubtreeAdd/256_per_subtree-4 29.19n 29.28n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 28.21n 28.23n ~ 0.600
DirectSubtreeAdd/2048_per_subtree-4 27.90n 27.99n ~ 0.200
SubtreeProcessorAdd/4_per_subtree-4 292.4n 292.1n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 287.1n 284.1n ~ 0.200
SubtreeProcessorAdd/256_per_subtree-4 291.4n 289.0n ~ 0.700
SubtreeProcessorAdd/1024_per_subtree-4 275.6n 280.6n ~ 0.300
SubtreeProcessorAdd/2048_per_subtree-4 276.4n 282.0n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 285.2n 283.1n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 283.6n 282.1n ~ 0.700
SubtreeProcessorRotate/256_per_subtree-4 282.4n 280.9n ~ 0.700
SubtreeProcessorRotate/1024_per_subtree-4 279.6n 280.4n ~ 0.200
SubtreeNodeAddOnly/4_per_subtree-4 54.50n 54.59n ~ 1.000
SubtreeNodeAddOnly/64_per_subtree-4 34.33n 34.39n ~ 0.300
SubtreeNodeAddOnly/256_per_subtree-4 33.40n 33.43n ~ 0.200
SubtreeNodeAddOnly/1024_per_subtree-4 32.89n 32.84n ~ 1.000
SubtreeCreationOnly/4_per_subtree-4 116.2n 117.0n ~ 0.100
SubtreeCreationOnly/64_per_subtree-4 416.9n 417.9n ~ 1.000
SubtreeCreationOnly/256_per_subtree-4 1.391µ 1.397µ ~ 1.000
SubtreeCreationOnly/1024_per_subtree-4 4.472µ 4.510µ ~ 0.400
SubtreeCreationOnly/2048_per_subtree-4 8.570µ 8.573µ ~ 1.000
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 279.6n 277.6n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 279.9n 277.3n ~ 0.200
ParallelGetAndSetIfNotExists/1k_nodes-4 2.253m 2.194m ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 5.355m 5.255m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 7.433m 7.189m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 10.169m 9.775m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 1.956m 1.957m ~ 0.700
SequentialGetAndSetIfNotExists/10k_nodes-4 4.356m 4.357m ~ 1.000
SequentialGetAndSetIfNotExists/50k_nodes-4 12.20m 12.42m ~ 0.400
SequentialGetAndSetIfNotExists/100k_nodes-4 22.20m 22.11m ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.240m 2.245m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.128m 8.126m ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.12m 13.07m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.983m 1.991m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 7.422m 7.418m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 41.42m 39.92m ~ 0.100
DiskTxMap_SetIfNotExists-4 4.054µ 3.961µ ~ 0.400
DiskTxMap_SetIfNotExists_Parallel-4 3.879µ 3.690µ ~ 0.100
DiskTxMap_ExistenceOnly-4 419.4n 339.5n ~ 0.100
Queue-4 189.1n 183.9n ~ 0.200
AtomicPointer-4 3.690n 3.630n ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 836.2µ 839.4µ ~ 1.000
ReorgOptimizations/DedupFilterPipeline/New/10K-4 838.2µ 755.0µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 109.3µ 103.1µ ~ 0.100
ReorgOptimizations/AllMarkFalse/New/10K-4 64.44µ 64.54µ ~ 1.000
ReorgOptimizations/HashSlicePool/Old/10K-4 54.07µ 65.57µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/10K-4 11.01µ 11.01µ ~ 1.000
ReorgOptimizations/NodeFlags/Old/10K-4 4.505µ 5.169µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.546µ 2.116µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 9.061m 9.996m ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/100K-4 9.399m 10.050m ~ 0.200
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.067m 1.084m ~ 0.100
ReorgOptimizations/AllMarkFalse/New/100K-4 706.7µ 713.4µ ~ 0.100
ReorgOptimizations/HashSlicePool/Old/100K-4 586.8µ 587.3µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/100K-4 213.8µ 215.4µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/100K-4 43.11µ 46.87µ ~ 0.100
ReorgOptimizations/NodeFlags/New/100K-4 15.01µ 15.67µ ~ 0.100
TxMapSetIfNotExists-4 49.43n 49.86n ~ 0.100
TxMapSetIfNotExistsDuplicate-4 41.27n 42.21n ~ 0.200
ChannelSendReceive-4 575.4n 603.8n ~ 0.100
BlockAssembler_AddTx-4 0.02901n 0.03000n ~ 1.000
AddNode-4 13.03 12.75 ~ 1.000
AddNodeWithMap-4 12.82 13.07 ~ 0.700
CalcBlockWork-4 468.4n 478.8n ~ 0.100
CalculateWork-4 658.2n 650.0n ~ 0.700
BuildBlockLocatorString_Helpers/Size_10-4 1.355µ 1.358µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_100-4 15.02µ 12.83µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_1000-4 128.7µ 127.2µ ~ 0.700
CatchupWithHeaderCache-4 104.9m 104.7m ~ 0.700
_prepareTxsPerLevel-4 416.1m 416.1m ~ 1.000
_prepareTxsPerLevelOrdered-4 4.111m 3.829m ~ 0.100
_prepareTxsPerLevel_Comparison/Original-4 448.1m 410.2m ~ 0.100
_prepareTxsPerLevel_Comparison/Optimized-4 4.355m 3.819m ~ 0.100
_BufferPoolAllocation/16KB-4 3.889µ 4.075µ ~ 0.100
_BufferPoolAllocation/32KB-4 11.110µ 8.164µ ~ 0.100
_BufferPoolAllocation/64KB-4 18.25µ 21.51µ ~ 0.700
_BufferPoolAllocation/128KB-4 36.31µ 35.23µ ~ 0.700
_BufferPoolAllocation/512KB-4 138.7µ 140.3µ ~ 1.000
_BufferPoolConcurrent/32KB-4 19.93µ 23.92µ ~ 0.100
_BufferPoolConcurrent/64KB-4 31.84µ 32.75µ ~ 0.400
_BufferPoolConcurrent/512KB-4 179.3µ 176.6µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/16KB-4 715.8µ 717.7µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/32KB-4 703.1µ 705.1µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/64KB-4 696.4µ 715.2µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 689.5µ 716.1µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 701.0µ 717.3µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 38.73m 39.78m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 39.02m 39.29m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/64KB-4 39.12m 40.04m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 38.74m 39.97m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 38.92m 39.35m ~ 0.400
_PooledVsNonPooled/Pooled-4 827.0n 826.3n ~ 0.700
_PooledVsNonPooled/NonPooled-4 8.656µ 8.251µ ~ 0.700
_MemoryFootprint/Current_512KB_32concurrent-4 8.636µ 8.108µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 11.36µ 11.34µ ~ 1.000
_MemoryFootprint/Alternative_64KB_32concurrent-4 11.02µ 11.01µ ~ 1.000
SubtreeSizes/10k_tx_4_per_subtree-4 1.459m 1.452m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 332.4µ 331.2µ ~ 0.400
SubtreeSizes/10k_tx_64_per_subtree-4 81.50µ 80.30µ ~ 0.700
SubtreeSizes/10k_tx_256_per_subtree-4 20.14µ 20.05µ ~ 0.400
SubtreeSizes/10k_tx_512_per_subtree-4 9.988µ 9.977µ ~ 0.400
SubtreeSizes/10k_tx_1024_per_subtree-4 4.942µ 4.917µ ~ 1.000
SubtreeSizes/10k_tx_2k_per_subtree-4 2.467µ 2.446µ ~ 1.000
BlockSizeScaling/10k_tx_64_per_subtree-4 78.49µ 77.56µ ~ 1.000
BlockSizeScaling/10k_tx_256_per_subtree-4 19.72µ 20.03µ ~ 0.200
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.859µ 5.003µ ~ 0.100
BlockSizeScaling/50k_tx_64_per_subtree-4 404.7µ 395.8µ ~ 1.000
BlockSizeScaling/50k_tx_256_per_subtree-4 97.36µ 97.83µ ~ 0.400
BlockSizeScaling/50k_tx_1024_per_subtree-4 24.43µ 24.20µ ~ 0.700
SubtreeAllocations/small_subtrees_exists_check-4 161.4µ 160.1µ ~ 0.400
SubtreeAllocations/small_subtrees_data_fetch-4 170.5µ 171.1µ ~ 0.100
SubtreeAllocations/small_subtrees_full_validation-4 323.7µ 328.5µ ~ 0.100
SubtreeAllocations/medium_subtrees_exists_check-4 9.712µ 9.859µ ~ 0.400
SubtreeAllocations/medium_subtrees_data_fetch-4 10.66µ 10.69µ ~ 1.000
SubtreeAllocations/medium_subtrees_full_validation-4 20.15µ 20.25µ ~ 0.400
SubtreeAllocations/large_subtrees_exists_check-4 2.391µ 2.382µ ~ 1.000
SubtreeAllocations/large_subtrees_data_fetch-4 2.608µ 2.618µ ~ 0.400
SubtreeAllocations/large_subtrees_full_validation-4 5.117µ 5.117µ ~ 1.000
StoreBlock_Sequential/BelowCSVHeight-4 332.7µ 336.1µ ~ 0.200
StoreBlock_Sequential/AboveCSVHeight-4 333.2µ 343.6µ ~ 0.100
GetUtxoHashes-4 265.5n 268.3n ~ 1.000
GetUtxoHashes_ManyOutputs-4 42.33µ 47.97µ ~ 0.100
_NewMetaDataFromBytes-4 227.7n 239.2n ~ 0.400
_Bytes-4 398.4n 449.9n ~ 0.100
_MetaBytes-4 147.5n 145.2n ~ 0.400

Threshold: >10% with p < 0.05 | Generated: 2026-05-26 15:13 UTC

…h Lua retry

Operators monitoring the expression-path-vs-Lua fallback rate had no
quantitative signal that the conservative utxoSpendableIn guard was hot.
Add two counters:

  - utxo_spend_expression_lua_retry: batches that triggered any retry
    (rate divided by utxo_spend_batch gives the batch-level retry
    fraction).
  - utxo_spend_expression_lua_retry_records: individual records
    re-dispatched through Lua (rate divided by utxo_spend gives the
    per-record retry fraction).

Tighten the comment in processSpendBatchResultsExpressions to reflect
that the retry is one batched Lua call per expression batch, not one
per record.
@ordishs ordishs requested review from icellan and oskarszoon May 26, 2026 14:56
@sonarqubecloud

Copy link
Copy Markdown

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

Approve. Consensus-safe.

Consensus check

Verified against svnode src/frozentxo_db.h:226-262, 367-371 + frozentxo.cpp:37-83. HeightInterval is half-open [start, stop) (frozentxo_db.h:224 explicit). IsFrozenOnConsensus returns true iff nHeight >= start && nHeight < stop. TXO spendable AT stop.

The Lua >=> change at teranode.lua:373 aligns with svnode and the SQL store (sql.go:2071, 2557, 3346, all strict <). Boundary directionality:

height vs spendableHeight svnode SQL Aerospike pre Aerospike post
below reject reject reject reject
at accept accept reject accept
above accept accept accept accept

No height at which the fix could flip Aerospike to looser-than-network. The only change is closing the one-block false-reject at the boundary.

Expression path (T25): defense-in-depth ExpNot(ExpBinExists(UtxoSpendableIn)) retained; FILTERED_OUT records dispatch to executeLuaSpendBatch which does the now-correct per-offset comparison. Lua is authoritative for any record the expression can't evaluate. Error classification end-to-end verified: LuaErrorCodeFrozen / LuaErrorCodeFrozenUntil both satisfy errors.Is(_, errors.ErrFrozen); Spent/Conflicting/Locked/Creating/CoinbaseImmature/TxNotFound/InvalidSpend/UtxoNotFound/UtxoHashMismatch/UtxoInvalidSize all classify correctly. Replaces the pre-fix generic "spend validation failed".

Go review

  • executeLuaSpendBatch extraction is clean — single source of truth, both primary and retry paths share policy, circuit-breaker, logging.
  • FILTERED_OUT retry collection and re-dispatch: exactly one response per item; empty case handled; mixed-result batches preserve genuine failures while retried records get precise reasons; index mapping in prepareSpendBatches is self-consistent against the slice passed.
  • No new race window — Lua UDF is atomic per record and re-checks freeze state itself, so the expression → Lua hand-off has no TOCTOU.
  • Metrics: two counters (batch + record), no label cardinality issues, promauto + sync.Once.
  • Boundary test covers both paths (lua / expressions), three heights at spendableHeight ± 1 and ==, plus two parity tests; asserts errors.ErrFrozen classification.

Follow-ups (not blockers)

  1. Pre-existing P1, more reachable now. IncrementSpentRecordsMulti at spend.go:151 does .(map[interface{}]interface{}) without an ok-check. Process panics if Lua returns an unexpected type on error. The new expression-to-Lua retry path makes this more reachable. Add an ok-check or wrap in a defensive switch — same PR or a follow-up.
  2. Guard comment in spend_expressions.go could cross-reference the retry dispatch at lines 523-527 — cosmetic.
  3. No mixed-offset test (utxoBatchSize > 1 with one offset spendable + one frozen in the same record). The Lua per-offset iteration at teranode.lua:371-383 isn't exercised that way.
  4. Tests are self-consistency between Aerospike paths, not directly svnode-anchored. Optional belt-and-braces pin against svnode reference behaviour at the same heights.
  5. utxoSpendableIn is single-interval today; svnode's enforceAtHeight is an array of intervals. Pre-existing limitation, worth a tracking issue but out of scope for this PR.

@ordishs ordishs merged commit e5fa361 into bsv-blockchain:main May 27, 2026
25 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.

3 participants