Skip to content

fix(subtreevalidation): follow-ups to the conflict-check verdict: cancellation wrap, over-budget walk, ctx guard - #2066

Merged
oskarszoon merged 11 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/2052-settle-error-classification
Oct 29, 2026
Merged

oskarszoon merged 11 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/2052-settle-error-classification

Conversation

@oskarszoon

@oskarszoon oskarszoon commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Three follow-ups from icellan's approval of #2052. The two doc items from that review are already merged. Merges to main; intended for v0.16.1.

In short:

  • On the fresh path, conflict-check failures that are not a proven conflict were all wrapped as a context cancellation. Now:
    • a missing record and a TX_INVALID under another code keep the wrap, because block validation needs it to treat them as this node's own failure;
    • the rest (store faults, the walk limit, a frozen output, an input out of range, the fail-fast threshold) leave without it. They are still retried and never charged to the peer; only their log level and catch-up label change.
  • After a walk stops on its node budget, the counter cone it reached stays mined-checked, as fix(subtreevalidation): a proven conflict rejects the block even when the check failed elsewhere #2052 merged it. This PR first narrowed that; review showed the narrowing was wrong, and it is gone. What is left is two tests, one per pass of the walk, and doc fixes.
  • On the fresh path, a proven conflict is TX_INVALID even once ctx is done, as it already was on the cached path and in bless.

Production code: services/subtreevalidation (processTxMetaUsingStore.go, check_counter_conflicting_batch.go), plus the setting's description in settings/ and docs/. Tests in services/subtreevalidation and services/blockvalidation.

Terms

From #2052, all in services/subtreevalidation:

  • The batch: checkCounterConflictingOnCurrentChainBatch. It checks whether any of a block's conflicting transactions (ones that lost a double spend on this node) conflicts with a transaction mined on the candidate chain. Phase 1 reads each transaction's counter-spenders (the transactions recorded as spending the same outputs). Phase 2 walks their descendants, the cone, from those roots, under the node budget utxostore_conflictingChildrenMaxNodes. Phase 3 reads each member (GetMeta) and looks for one mined on the candidate chain.
  • Proven conflict: phase 3 read a member that is mined on the candidate chain. The block is invalid (TX_INVALID).
  • Fresh path: processTxMetaUsingStore, for a subtree this node has not validated. Its last step, settle (settleCollectedConflictingTxs), runs the batch. Cached path: CheckBlockSubtrees reusing a subtree already validated. Bless: blessMissingTransaction, for a transaction fetched because the node did not have it.
  • Corrupt verdict: ValidateBlock returns BLOCK_CORRUPT for an invalid transaction in a subtree list not yet bound to the header. The peer is struck and the block re-downloaded rather than remembered as invalid.

1. The cancellation wrap, only where block validation needs it

Why

processTxMetaUsingStore wrapped every settle failure except a proven conflict in CONTEXT_CANCELED. icellan pointed out that most of them are retried and never charged to the peer without it, so the wrap only mislabels them. On the live path a store fault was logged as "Context canceled, skipping", and catch-up recorded it as local_context_cancelled.

What I checked

I traced every settle failure through each caller that reads CONTEXT_CANCELED:

  • the Kafka retry (isRecoverableBlockMessageError), at validateBlockSubtrees and at ValidateBlock;
  • ValidateBlock's branches;
  • catch-up: isLocalCatchupFault, which gates the route in processCatchupChItem, the validation_failure metric and the malicious report, and releaseCatchupLock's label;
  • the live peer-subtree path, subtreeMessageHandler.

A processing error with no peer code in its chain is local at every one of them since #2022.

Both settle branches, batched and unbatched, now use the same [processTxMetaUsingStore] message; the unbatched one used to say "context done" even when ctx was live. Every settle failure leaves with that prefix on top: under the cancellation wrap where it keeps one, otherwise under its own code (prefixed), so errors.Is and every caller's classification are what they would be without it.

Two classes would change verdict without the wrap, so they keep it:

  • A missing record (TX_NOT_FOUND). icellan's reading holds at validateBlockSubtrees. But ValidateBlock then turns TX_NOT_FOUND into a transient BLOCK_INCOMPLETE, and that code is in carriesPeerFaultCode. Without the wrap, isLocalCatchupFault is false. Catch-up would then do three things for a record this node pruned:
    • send ReportPeerFailure, which rotates the sync peer;
    • count validation_failure against the peer;
    • fetch the block again from the alternative peers.
  • A TX_INVALID under another code. The aerospike store returns one for a record it read but could not decode (classifyRecordError). The wrap keeps the Kafka path retrying the block (isRecoverableBlockMessageError) and keeps catch-up from charging the peer (isLocalCatchupFault) or reporting it malicious (shouldReportConsensusMalicious). It does not keep the peer from being struck: ValidateBlock's infrastructure guard matches context.Canceled, not the CONTEXT_CANCELED code, so the decode fault still gets a corrupt verdict and penalizeCorruptBlockPeer, on the Kafka path and in catch-up alike. That is pre-existing, and this PR does not change it.

A cancelled ctx, or a failure that is itself a cancellation (CONTEXT_CANCELED), keeps the wrap too. Nothing else does (keepsCancellation). It reads codes only: IsContextError is gone from it, because its DeadlineExceeded check falls back to matching error text, which wrapped a store fault whose message named a deadline while ctx was live. The settle path derives no contexts of its own, so ctx.Err() and CONTEXT_CANCELED cover every real cancellation.

Result, per class

Before → after. "Same" means that cell did not change.

Failure CONTEXT_CANCELED Kafka ValidateBlock Catch-up route releaseCatchupLock label Live path log
Missing record (TX_NOT_FOUND) kept retried transient incomplete local local_context_cancelled (same) Warn (same)
Walk limit dropped retried as is local local_context_cancelled → local_processing_fault Warn → Error
Store fault in the check (STORAGE_ERROR) dropped retried as is local local_storage_fault (same) Warn → Error
Store fault whose text names a deadline, ctx live (check or decorate) dropped retried as is local local_storage_fault (same) Warn → Error
Frozen output dropped retried as is local local_context_cancelled → local_processing_fault Warn → Error
Input out of range dropped retried as is local local_context_cancelled → local_processing_fault Warn → Error
Store fault in a decorate batch (g.Wait()) dropped retried as is local local_storage_fault (same) Warn → Error
Fail-fast threshold (g.Wait()) dropped retried not reached local not reached not reached
TX_INVALID under another code kept retried corrupt, peer struck (same) local local_context_cancelled (same) Warn (same)
Proven conflict none (same) rejected corrupt TX_INVALID consensus rejection validation_failure Error

Notes on the table:

  • Kafka: processBlockFound passes a corrupt or invalid verdict on as it is, and wraps everything else in SERVICE_ERROR, which is retried. So every row except the corrupt ones is retried on Kafka whatever the wrap. The test checks the stricter thing: isRecoverableBlockMessageError is true on the raw error, at both validateBlockSubtrees and ValidateBlock.
  • Labels: the releaseCatchupLock label is the error type of catch-up's last failed attempt, shown on the dashboard. Nothing scores on it.
  • Log level: the live path logs and drops a failed peer subtree either way; only the level of that line changes.
  • Store faults: releaseCatchupLock tests storage before context, so their label was already local_storage_fault. A store's SERVICE_UNAVAILABLE (the aerospike batch timeout) sits under the same processing wrap, and its local_service_unavailable label also comes before context. It is not driven in a test.
  • Threshold: the block path does not fail fast, and ValidateSubtreeInternal retries the threshold by its code, so the threshold does not reach ValidateBlock. The test checks the shape it would arrive in.
  • Already there before this PR: for a TX_INVALID under another code, ValidateBlock already returns a corrupt verdict and strikes the peer (penalizeCorruptBlockPeer), wrap or not, because its infrastructure guard tests context.Canceled, not the CONTEXT_CANCELED code. The wrap covers only the Kafka retry and catch-up's classification. The test pins today's behaviour; this PR does not change it.

2. The counter cone stays checked after an over-budget walk

The first version of this PR mined-checked only the roots after a walk over its budget. Review (freemans13, icellan) showed that was wrong:

  • The walk runs two passes on one budget: the counter-spenders' cone, then the conflicting transactions' own cone. When only the second pass ran out, the counter cone was complete and was still dropped, so a block proven invalid by reads that all succeeded retried until the budget was raised.
  • A partial counter cone is proof too: every member the walk returns descends from a counter-spender root.
  • The narrowing saved only phase-3 reads bounded by what the walk had already read, and the walk reruns on every retry anyway.

So the narrowing is gone. Phase 3 mined-checks the transactions, their counter-spenders and whatever counter cone the walk reached, whichever error stopped it, as #2052 merged it. The setting's longdesc, utxo_settings.md and the #2052 CHANGELOG entry keep their merged meaning, but now say that only a mined counter-conflicting transaction (a conflicting transaction, a counter-spender or a counter-spender's descendant) is proof. The conflicting transactions' own descendants are only checked for frozen outputs. This matches the batch's Go doc.

What remains against main:

  • The BUDGET doc says the budget can stop either pass, and that a mined counter-conflicting member it reached is proof. Own-cone members are frozen-checked only, never mined-checked.
  • A comment at the mined check says why the reached counter cone is checked.
  • The firstMinedOnChain godoc says why the other reads are not cancelled after one fails: that would lose a mined member not read yet, and with it a proven conflict. icellan's review of fix(subtreevalidation): a proven conflict rejects the block even when the check failed elsewhere #2052 noted the cancellation was removed; it stays removed, and the wider read set makes that more important, not less.
  • Two tests, below.

3. A proven conflict on the fresh path ignores ctx

Only the fresh path checked ctx.Err() before letting a proven conflict through. The cached path and bless returned TX_INVALID whatever the ctx state, so a block was rejected or retried depending on which path reached its transaction. The guard is gone.

This cannot let a partial read through as proof. Each piece of evidence comes from a read that succeeded:

  • firstMinedOnChain sets the mined member only after a GetMeta that succeeded and lists a block ID of the candidate chain. A failed read, cancelled or not, is recorded as an error and never counted as mined.
  • The members come from records that were read: a transaction's own record, its counter-spenders from its parents' records, the cone members from the walk's reads.

So a cancellation can only remove evidence.

Tests

All on the real sqlitememory UTXO store.

TestValidateBlock_ProvenConflictIsNotACancellation (blockvalidation), extended. It drives each failure in the table through ValidateBlock with a real subtree validation server, and checks for each:

  • validateBlockSubtrees: the class's code, whether CONTEXT_CANCELED is there, and isRecoverableBlockMessageError;
  • ValidateBlock: whether it is corrupt, not BLOCK_INVALID, isRecoverableBlockMessageError, isLocalCatchupFault, not shouldReportConsensusMalicious, not isUnboundTxInvalidVerdict, and the label releaseCatchupLock records.

The new rows are a store fault in the check, a store fault in a decorate batch, the same two with a store fault whose text names a deadline (NewStorageError("store down", context.DeadlineExceeded), ctx live), a frozen output, an input out of range, and a TX_INVALID under another code. The store faults go through a store wrapper given to the subtree validation server (setupWithSubtreeValidationStore). Against the code before this PR, every verdict assertion passed; only the wrap and the label assertions differed, as in the table.

Test_checkCounterConflictingOnCurrentChainBatch_overBudgetWalkChecksTheCounterConeItReached: two roots (the conflicting tx and its counter-spender). Each subtest first checks its premise on GetConflictingChildrenFromRoots directly: the walk stops on its budget, the mined member is in the counter cone it returned, and that cone is complete or cut short. Then the batch must return TX_INVALID naming the mined member.

  • own cone overflows after a complete counter cone: the reviewers' case. Budget 4 per root; a counter cone of 3 whose third member is mined (not a root, not a direct counter-spender); an own cone of 12.
  • counter cone overflows after reaching a mined member: icellan's question. Budget 2 per root; a counter cone of 10 whose second member is mined; the walk stops in the counter cone.

Both fail at 972f9d00a (the narrowing): the premise checks pass, the batch returns UTXO_WALK_LIMIT_EXCEEDED instead of TX_INVALID.

Test_provenConflictWithCancelledCtxIsTxInvalidOnEveryPath: a store wrapper cancels the caller's ctx on the check's first read and serves that read and every later one anyway. A mined counter-spender gives TX_INVALID, without CONTEXT_CANCELED, on the fresh path (batched and unbatched), the cached path and bless. The fresh-path subtests fail at 7b9037272.

Changed tests:

  • provenTxInvalidWins/"a mined member the walk reached before its budget ran out is proof": its budget case is now overBudgetWalkChecksTheCounterConeItReached/"counter cone overflows after reaching a mined member". In its place, "a mined member the walk reached before a store fault stopped it is proof" covers the other way a walk stops. To support it, faultyGetStore now fails the walk's read of its hash too, not only the phase-1 read.
  • provenConflictIsNotACancellation/"once ctx is done a proven conflict keeps the wrap": item 3 reverses it. The new three-path test replaces it. Its second half became "a TX_INVALID under another code is not a proven conflict".
  • passedConflictCheckKeepsEntries: the threshold no longer carries CONTEXT_CANCELED.
  • provenConflictIsNotACancellation gains two settledError subtests: a store fault whose text names a deadline stays unwrapped while ctx is live, and a failure that is not wrapped (a proven conflict, a store fault, the walk limit, the threshold) keeps its code with [processTxMetaUsingStore] on top.
  • TestGetConflictingChildrenFromRoots_StoppedWalkReturnsPartialCones and the other walk-limit tests ("a walk over its budget does not hide a mined direct counter-spender", budgetScalesWithRoots, the blockvalidation walk-limit row) are unchanged, and pass.
Mutation results

Each mutation was applied on its own to this branch. The 972f9d00a row puts back that commit's check_counter_conflicting_batch.go.

Mutation Caught by
keepsCancellation drops TX_NOT_FOUND (icellan's proposal as written) TestValidateBlock_ProvenConflictIsNotACancellation/a pruned parent. With the wrap assertion off, isRecoverableBlockMessageError on the ValidateBlock error is false.
keepsCancellation drops the TX_INVALID under another code …/a TX_INVALID under another code. With the wrap assertion off, isRecoverableBlockMessageError at validateBlockSubtrees is false.
missingRecordsError codes a missing record NOT_FOUND (a peer code) …/a pruned parent
settledError wraps every failure again (the code before this PR) …/a walk over its budget, a store fault in the conflict check, a store fault in a decorate batch, a frozen output…, an input out of range…
settledError keeps the ctx guard (the code before this PR) Test_provenConflictWithCancelledCtxIsTxInvalidOnEveryPath fresh path, both branches
Phase 3 checks only the roots after the budget (this PR's first version, 972f9d00a) overBudgetWalkChecksTheCounterConeItReached, both subtests
Phase 3 drops the partial cone after a store fault too provenTxInvalidWins/"…before a store fault stopped it is proof"
The walk returns no cones on error the same
keepsCancellation gets its IsContextError arm back …/a store fault naming a deadline in the conflict check, …/in a decorate batch, and provenConflictIsNotACancellation/"a store fault whose text names a deadline…"
settledError returns an unwrapped failure without the prefix provenConflictIsNotACancellation/"a failure that is not wrapped keeps the prefix and its code". The blockvalidation table passes either way: the prefix changes no classification.
Commands
go test -timeout 10m -run 'Conflict|conflict|CheckBlockSubtrees|ProcessTxMeta|processTxMeta|Bless|bless|Cached|FrozenSentinel|provenTxInvalid|passedConflictCheck|perInput|SubtreeValidation|provenConflict|Retry|retry|Cancel|cancel|Context|Probe|probe|overBudget|budget|Budget' ./services/subtreevalidation/ ./stores/utxo/
go test -timeout 10m -run 'TestValidateBlock_ProvenConflictIsNotACancellation|TestBlockValidationValidateBlockSmall|TestReleaseCatchupLock|TestConsensusSpendRejection|Conflict|ProcessingError|Recoverable' ./services/blockvalidation/
go test -race -timeout 10m -run 'Test_checkCounterConflictingOnCurrentChainBatch|Test_processTxMetaUsingStore_|Test_blessMissingTransaction_provenTxInvalidWins|Test_provenConflictWithCancelledCtxIsTxInvalidOnEveryPath|TestGetConflictingChildrenFromRoots|TestValidateBlock_ProvenConflictIsNotACancellation' ./services/subtreevalidation/ ./stores/utxo/ ./services/blockvalidation/
go test -race -timeout 10m -run 'Test_checkCounterConflicting|Test_provenConflictWithCancelledCtx|Test_processTxMetaUsingStore|TestValidateBlock_ProvenConflictIsNotACancellation|TestGetConflictingChildrenFromRoots' ./services/subtreevalidation/ ./stores/utxo/ ./services/blockvalidation/   # after review, on the merge of upstream/main
golangci-lint run --new-from-rev upstream/main ./services/subtreevalidation/... ./services/blockvalidation/... ./stores/utxo/ ./settings/   # v2.13.2: 0 issues
go test -count=1 -timeout 10m -run 'TestValidateBlock_ProvenConflictIsNotACancellation|Test_provenConflictWithCancelledCtx|Test_checkCounterConflicting|Test_processTxMetaUsingStore|Test_blessMissingTransaction|TestValidateSubtreeInternal' ./services/subtreevalidation/ ./services/blockvalidation/   # second review, on the merge of upstream/main

All pass. Not run locally: the full package suites, which CI runs.

Breaking

CHANGELOG.md, under "Breaking — behaviour changes on upgrade" (both entries are unreleased):

No setting or deployment change is needed. Nothing here is consensus logic: each change decides whether a block is rejected now or retried, never whether an invalid block is accepted.

…path even once ctx is done

The cached path and blessMissingTransaction already returned it whatever
the ctx state. The verdict needs a member that was read and is mined on
the candidate chain, so a cancellation can only remove evidence, never
make it up.
…n needs as a cancellation

A store fault, the walk limit, a frozen output, an input out of range and
the fail-fast threshold are retried and never charged to the peer without
the CONTEXT_CANCELED wrap, so they now leave without it. A missing record
and a TX_INVALID under another code keep it: without it catch-up signals
a peer failure for the first, and the peer is reported for the second.
A cancelled ctx keeps it too.
…y the walk's roots

The partial cone the walk reached was read again in phase 3, up to the
budget in store reads on every retry of a runaway cone, for the same
UTXO_WALK_LIMIT_EXCEEDED. The conflicting transactions and their direct
counter-spenders are still checked, and a mined one still rejects the
block. A walk stopped by a store fault keeps its partial cone checked.
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:
No new issues found in 9f975ec. keepsCancellation now reads only codes (ctx.Err(), ERR_CONTEXT_CANCELED, ERR_TX_NOT_FOUND, ERR_TX_INVALID). A store fault whose text names a deadline is no longer wrapped as a cancellation while ctx is live. prefixed() keeps [processTxMetaUsingStore] on top under the failure's own code, so errors.Is matches the same errors as before. A proven conflict still has TX_INVALID on top. The new tests cover both changes, in subtreevalidation and through ValidateBlock. The CHANGELOG entry still matches the code.

One point from an earlier human review is still open (icellan, on keepsCancellation). settledError decides the wrap from the single error fail() kept. If a phase-1 missing record and a walk-limit overrun happen together, the TX_NOT_FOUND wins, so catch-up records local_context_cancelled rather than the local_processing_fault the CHANGELOG lists for the walk limit. The broader "CONTEXT_CANCELED as a local marker" point is deferred to #2072.

Suggest running go test -race ./services/subtreevalidation/... ./services/blockvalidation/... before merge.

History:

  • ✅ Fixed: keepsCancellation matched store faults by error text via IsContextError (9f975ec)
  • ✅ Fixed: an unwrapped settle failure lost the [processTxMetaUsingStore] prefix (9f975ec)
  • ✅ Fixed: the over-budget narrowing dropped a complete counter cone, and a partial cone with a mined member was retried instead of rejected (14d5eb3)
  • ✅ Fixed: the keepsCancellation comment overstated peer protection for decode faults (a45eb58)

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

Requesting changes for one case in item 2. The rest of the PR holds up. When the walk overruns its budget in its second pass, which walks the conflicting transactions' own descendants, the counter-spender cone is already complete. The PR still drops it from the mined check. So a block proven invalid by reads that all succeeded is retried until an operator raises utxostore_conflictingChildrenMaxNodes. Details and two ways out are inline.

What I checked and agree with:

  • Item 1, the cancellation wrap. I traced every consumer of CONTEXT_CANCELED and IsContextError that a settle failure can reach. That covers the Kafka retry, ValidateBlock, catch-up's route and releaseCatchupLock, subtreeMessageHandler, and the legacy park policy (parkCommitFailure, which reaches it through processBlockFound's SERVICE_ERROR wrap). None of them turns an unwrapped store fault, walk limit, frozen output or out-of-range input into a peer fault. ValidateSubtreeInternal still retries the threshold by errors.Is(err, ErrThresholdExceeded). The two exceptions in keepsCancellation match what the table says they need.
  • Item 3, no ctx guard on a proven conflict. This is sound. firstMinedOnChain only counts a read that succeeded. The walk reads level by level, so a store fault in a level always surfaces before a budget error from adding that level's children. Which error stops the walk therefore does not depend on timing.
  • The bot's nit on the unwrapped firstMinedOnChain godoc line stands. I won't repeat it.

Tests: I ran the PR's race-enabled command locally on 972f9d0 (go test -race over the named tests in services/subtreevalidation, stores/utxo and services/blockvalidation), and all 77 passed. I did not run the full package suites. CI was still running when I posted.

// fault still has what it reached checked.
checked := [][]chainhash.Hash{txs, counterSpenders, counterCone}
if errors.Is(walkErr, errors.ErrUtxoWalkLimitExceeded) {
checked = checked[:2]

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.

This drops the counter-spender cone whenever the walk stops on its budget, but the walk runs two passes on one shared budget: pass 0 walks the counter-spenders' cone, pass 1 walks the conflicting transactions' own cone ([][]chainhash.Hash{counterSpenders, txs} a few lines up). If the budget runs out in pass 1, counterCone is complete, not partial.

Concrete case: a conflicting tx with a large descendant chain of its own (over the shared budget), and a counter-spender with a small cone whose third member is mined on the candidate chain. Pass 0 finishes and reaches the mined member; pass 1 overruns. Before this PR the block is rejected with TX_INVALID. With it, the block gets UTXO_WALK_LIMIT_EXCEEDED and is retried until an operator raises utxostore_conflictingChildrenMaxNodes, although every read the proof needs already succeeded.

The comment's reasoning doesn't cover this case. The cone that was "too large to check" is the own cone, which phase 3 never mined-checks anyway (own-cone members are frozen-checked only). And the counter cone's cost is bounded by what pass 0 already read.

Suggestion: drop counterCone only when the walk stopped in pass 0. GetConflictingChildrenFromRoots doesn't say which pass it stopped in. An empty cones[1] is ambiguous, because pass 1 can stop before adding any child. So it would need to return the pass index, or the batch could run the two passes as two calls sharing a budget. Then a test with budget overrun in the own cone and a mined member in a complete counter cone would pin the case. The existing overBudgetWalkChecksOnlyRoots only overruns in pass 0.

If you'd rather keep it simple and accept the retry, then please widen the comment, the longdesc and the CHANGELOG entry to say the full counter cone is skipped too when it was walked completely. As written they say only "the descendants the walk reached", which reads as the partial cone.

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.

Looks addressed in 14d5eb3: the counter cone is mined-checked again whichever pass ran out of budget. The new subtest "own cone overflows after a complete counter cone" covers the pass-1 overflow case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 14d5eb3 by dropping the narrowing: phase 3 again mined-checks whatever counter cone the walk reached, whichever pass ran out, so a complete counter cone is never skipped. overBudgetWalkChecksTheCounterConeItReached/"own cone overflows after a complete counter cone" is your case (counter cone of 3 with its third member mined, own cone over the budget, TX_INVALID); it fails at 972f9d0.

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.

Confirmed on a032644. With the 972f9d0 version of this file put back, both subtests of overBudgetWalkChecksTheCounterConeItReached fail, and on the head they pass. Resolved from my side.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-2066 (47bcaf8)

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.759µ 1.704µ ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/memory-4 11.90m 11.87m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_1-4 12.96m 12.96m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_2-4 14.07m 14.01m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/memo... 26.31m 25.99m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 33.25m 32.95m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 33.24m 33.44m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/mem... 114.7m 115.7m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 243.0m 256.1m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 244.5m 253.2m ~ 0.200
BlockTxMap/txs=16384/nodeindex-4 2.511m 2.531m ~ 0.100
BlockTxMap/txs=16384/swiss-4 18.36m 18.95m ~ 1.000
BlockTxMap/txs=262144/nodeindex-4 18.32m 18.82m ~ 0.100
BlockTxMap/txs=262144/swiss-4 53.10m 53.22m ~ 0.400
BlockTxMap/txs=4194304/nodeindex-4 451.5m 467.7m ~ 0.100
BlockTxMap/txs=4194304/swiss-4 870.4m 900.7m ~ 0.700
TxMapPut_MemoryVsDisk/txs=100000/memory-4 7.463m 7.558m ~ 1.000
TxMapPut_MemoryVsDisk/txs=100000/disk_1-4 11.65m 13.14m ~ 0.100
TxMapPut_MemoryVsDisk/txs=100000/disk_2-4 10.73m 12.15m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/hit-4 4.340m 4.363m ~ 1.000
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/miss-4 3.402m 3.343m ~ 0.400
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/hit-4 1.942m 1.951m ~ 0.700
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/miss-4 1.859m 1.910m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/hit-4 1.882m 1.978m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/miss-4 1.885m 1.970m ~ 0.100
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 24.53m 26.29m ~ 0.100
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 23.16m 25.21m ~ 0.700
InpointDedup/txs=16K-4 4.873m 4.920m ~ 0.400
InpointDedup/txs=128K-4 19.25m 20.44m ~ 0.400
InpointDedup/txs=1M-4 134.7m 135.2m ~ 1.000
InpointDedup_TinyRuns-4 50.09m 49.36m ~ 1.000
MiningCandidate_Stringify_Short-4 232.1n 233.1n ~ 1.000
MiningCandidate_Stringify_Long-4 1.713µ 1.696µ ~ 1.000
MiningSolution_Stringify-4 827.6n 852.6n ~ 0.200
BlockInfo_MarshalJSON-4 2.256µ 2.218µ ~ 0.300
DecodeSubtreeMeta/go-subtree-4 217.0m 219.6m ~ 1.000
DecodeSubtreeMeta/packed-4 58.68m 58.82m ~ 1.000
DecodeSubtreeMeta/go-subtree/parallel-4 111.1m 106.0m ~ 0.200
DecodeSubtreeMeta/packed/parallel-4 37.30m 40.68m ~ 0.700
ValidateSubtreeNodesMatchKey/leaves=1024-4 260.9µ 260.9µ ~ 1.000
ValidateSubtreeNodesMatchKey/leaves=1048576-4 81.13m 81.16m ~ 0.700
NewFromBytes-4 125.6n 157.4n ~ 0.100
AddTxBatchColumnar_Validation-4 2.122µ 2.132µ ~ 0.700
OffsetValidationLoop-4 647.1n 639.5n ~ 0.700
BuildSubtreeMeta/serialized-4 17.06m 14.31m ~ 0.100
BuildSubtreeMeta/decoded-4 33.47m 28.65m ~ 0.100
Mine_EasyDifficulty-4 60.27µ 60.31µ ~ 1.000
Mine_WithAddress-4 6.603µ 6.775µ ~ 0.100
BlockAssembler_AddTx-4 0.01875n 0.02111n ~ 0.100
AddNode-4 12.83 12.11 ~ 0.400
AddNodeWithMap-4 12.42 12.96 ~ 0.100
DiskTxMap_SetIfNotExists-4 608.0n 600.0n ~ 1.000
DiskTxMap_SetIfNotExists_Parallel-4 195.0n 235.2n ~ 0.100
DiskTxMap_ExistenceOnly-4 324.0n 313.9n ~ 1.000
Queue-4 174.9n 182.1n ~ 1.000
AtomicPointer-4 3.940n 3.945n ~ 1.000
TxMapSetIfNotExists-4 62.48n 62.89n ~ 0.700
TxMapSetIfNotExistsDuplicate-4 49.17n 47.06n ~ 0.100
ChannelSendReceive-4 609.3n 601.0n ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 82.17n 85.93n ~ 1.000
DirectSubtreeAdd/64_per_subtree-4 34.09n 34.30n ~ 0.800
DirectSubtreeAdd/256_per_subtree-4 31.39n 31.86n ~ 0.700
DirectSubtreeAdd/1024_per_subtree-4 31.00n 30.81n ~ 0.700
DirectSubtreeAdd/2048_per_subtree-4 29.54n 29.80n ~ 0.700
SubtreeProcessorAdd/4_per_subtree-4 247.7n 247.6n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 286.2n 285.8n ~ 1.000
SubtreeProcessorAdd/256_per_subtree-4 308.1n 308.8n ~ 0.700
SubtreeProcessorAdd/1024_per_subtree-4 311.0n 311.2n ~ 0.700
SubtreeProcessorAdd/2048_per_subtree-4 299.4n 303.6n ~ 0.300
SubtreeProcessorRotate/4_per_subtree-4 307.9n 311.8n ~ 0.400
SubtreeProcessorRotate/64_per_subtree-4 309.0n 309.2n ~ 1.000
SubtreeProcessorRotate/256_per_subtree-4 311.4n 308.4n ~ 0.700
SubtreeProcessorRotate/1024_per_subtree-4 309.1n 311.7n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 72.16n 73.16n ~ 0.700
SubtreeNodeAddOnly/64_per_subtree-4 44.03n 44.79n ~ 0.700
SubtreeNodeAddOnly/256_per_subtree-4 42.64n 43.60n ~ 0.200
SubtreeNodeAddOnly/1024_per_subtree-4 41.74n 42.90n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 173.6n 175.7n ~ 1.000
SubtreeCreationOnly/64_per_subtree-4 371.9n 374.6n ~ 0.400
SubtreeCreationOnly/256_per_subtree-4 1.235µ 1.245µ ~ 0.200
SubtreeCreationOnly/1024_per_subtree-4 4.250µ 4.330µ ~ 0.400
SubtreeCreationOnly/2048_per_subtree-4 8.455µ 8.405µ ~ 1.000
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 233.3n 234.2n ~ 0.700
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 299.0n 298.3n ~ 1.000
ParallelGetAndSetIfNotExists/1k_nodes-4 831.9µ 917.7µ ~ 1.000
ParallelGetAndSetIfNotExists/10k_nodes-4 4.998m 5.060m ~ 0.400
ParallelGetAndSetIfNotExists/50k_nodes-4 7.543m 7.741m ~ 0.400
ParallelGetAndSetIfNotExists/100k_nodes-4 10.21m 10.19m ~ 1.000
SequentialGetAndSetIfNotExists/1k_nodes-4 699.8µ 696.9µ ~ 1.000
SequentialGetAndSetIfNotExists/10k_nodes-4 6.688m 6.537m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 22.09m 22.96m ~ 0.400
SequentialGetAndSetIfNotExists/100k_nodes-4 39.58m 38.38m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 797.4µ 774.0µ ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.039m 7.887m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.63m 13.37m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 789.9µ 744.3µ ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 10.47m 11.14m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 68.57m 66.43m ~ 0.400
CalcBlockWork-4 470.0n 480.2n ~ 1.000
CalculateWork-4 624.1n 623.7n ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/1000-4 61.71µ 54.55µ ~ 0.200
CheckOldBlockIDs/in-memory-chain-check/1000-4 1.354m 1.468m ~ 0.200
CheckOldBlockIDs/on-chain-prefetch/10000-4 392.9µ 373.4µ ~ 0.700
CheckOldBlockIDs/in-memory-chain-check/10000-4 2.029m 2.024m ~ 1.000
BuildBlockLocatorString_Helpers/Size_10-4 1.508µ 1.373µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_100-4 14.00µ 13.02µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 141.5µ 127.5µ ~ 0.100
CatchupWithHeaderCache-4 107.0m 106.9m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.488m 1.460m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 340.8µ 346.9µ ~ 0.200
SubtreeSizes/10k_tx_64_per_subtree-4 80.98µ 83.16µ ~ 0.700
SubtreeSizes/10k_tx_256_per_subtree-4 20.18µ 20.17µ ~ 1.000
SubtreeSizes/10k_tx_512_per_subtree-4 10.03µ 10.15µ ~ 0.400
SubtreeSizes/10k_tx_1024_per_subtree-4 5.086µ 5.159µ ~ 0.400
SubtreeSizes/10k_tx_2k_per_subtree-4 2.532µ 2.556µ ~ 0.700
BlockSizeScaling/10k_tx_64_per_subtree-4 84.09µ 83.67µ ~ 0.700
BlockSizeScaling/10k_tx_256_per_subtree-4 20.37µ 19.60µ ~ 0.700
BlockSizeScaling/10k_tx_1024_per_subtree-4 5.173µ 5.265µ ~ 0.700
BlockSizeScaling/50k_tx_64_per_subtree-4 422.1µ 422.2µ ~ 0.700
BlockSizeScaling/50k_tx_256_per_subtree-4 106.3µ 105.1µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 24.21µ 27.50µ ~ 0.200
SubtreeAllocations/small_subtrees_exists_check-4 182.2µ 184.2µ ~ 1.000
SubtreeAllocations/small_subtrees_data_fetch-4 197.7µ 199.4µ ~ 0.700
SubtreeAllocations/small_subtrees_full_validation-4 393.1µ 386.7µ ~ 1.000
SubtreeAllocations/medium_subtrees_exists_check-4 11.20µ 11.17µ ~ 0.400
SubtreeAllocations/medium_subtrees_data_fetch-4 12.28µ 12.34µ ~ 0.700
SubtreeAllocations/medium_subtrees_full_validation-4 25.46µ 25.64µ ~ 0.700
SubtreeAllocations/large_subtrees_exists_check-4 2.909µ 2.934µ ~ 1.000
SubtreeAllocations/large_subtrees_data_fetch-4 3.205µ 3.250µ ~ 0.700
SubtreeAllocations/large_subtrees_full_validation-4 6.385µ 6.321µ ~ 0.100
_prepareTxsPerLevel-4 258.0m 275.6m ~ 0.100
_prepareTxsPerLevelOrdered-4 2.556m 2.674m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 254.2m 253.3m ~ 0.700
_prepareTxsPerLevel_Comparison/Optimized-4 2.496m 2.648m ~ 0.100
_BufferPoolAllocation/16KB-4 3.478µ 4.804µ ~ 0.100
_BufferPoolAllocation/32KB-4 9.528µ 7.635µ ~ 0.700
_BufferPoolAllocation/64KB-4 17.92µ 15.38µ ~ 0.100
_BufferPoolAllocation/128KB-4 30.09µ 31.63µ ~ 0.200
_BufferPoolAllocation/512KB-4 121.4µ 120.1µ ~ 0.700
_BufferPoolConcurrent/32KB-4 18.45µ 18.17µ ~ 1.000
_BufferPoolConcurrent/64KB-4 28.32µ 28.05µ ~ 0.700
_BufferPoolConcurrent/512KB-4 147.1µ 149.1µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/16KB-4 550.4µ 526.8µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 534.3µ 518.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 526.2µ 519.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 527.6µ 515.8µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 531.5µ 526.3µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 28.26m 28.14m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/32KB-4 28.23m 27.88m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/64KB-4 28.12m 28.24m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/128KB-4 28.19m 27.72m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/512KB-4 28.30m 27.74m ~ 0.400
_PooledVsNonPooled/Pooled-4 779.9n 786.5n ~ 0.400
_PooledVsNonPooled/NonPooled-4 7.256µ 7.567µ ~ 0.200
_MemoryFootprint/Current_512KB_32concurrent-4 6.611µ 6.800µ ~ 1.000
_MemoryFootprint/Proposed_32KB_32concurrent-4 8.735µ 8.850µ ~ 0.200
_MemoryFootprint/Alternative_64KB_32concurrent-4 8.211µ 8.386µ ~ 0.100
StoreBlockForkPath/inMemoryChainCheck=false-4 617.6µ 617.3µ ~ 0.700
StoreBlockForkPath/inMemoryChainCheck=true-4 1.262m 1.263m ~ 0.700
StoreBlock_Sequential/BelowCSVHeight-4 198.1µ 196.2µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 198.3µ 196.4µ ~ 1.000
CheckSpendAndCreateMultiList_FanIn/parents=80000-4 62.78m 65.94m ~ 0.200
CheckSpendAndCreateMultiList_FanIn/parents=160000-4 148.3m 161.9m ~ 0.100
GetUtxoHashes-4 295.4n 290.4n ~ 0.100
GetUtxoHashes_ManyOutputs-4 44.07µ 43.40µ ~ 0.100
MetaBytes-4 68.76n 68.56n ~ 1.000
_NewMetaDataFromBytes-4 226.9n 226.7n ~ 1.000
_Bytes-4 345.5n 345.6n ~ 1.000
_MetaBytes-4 107.4n 108.0n ~ 0.400

Threshold: >10% with p < 0.05 | Generated: 2026-10-29 13:51 UTC

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

One blocker: the over-budget narrowing at check_counter_conflicting_batch.go:208 also drops a counter cone that was walked in full. The other two comments are a question and a doc fix.

// direct counter-spenders, are mined-checked then. A walk stopped by a store
// fault still has what it reached checked.
checked := [][]chainhash.Hash{txs, counterSpenders, counterCone}
if errors.Is(walkErr, errors.ErrUtxoWalkLimitExceeded) {

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.

This narrows whenever the walk hit the limit, whichever pass it stopped in. GetConflictingChildrenFromRoots walks [counterSpenders, txs] in order, with one shared visited set and budget (stores/utxo/process_conflicting.go:1336-1353). If pass 0 finishes and only pass 1 (the own cone) overflows, cones[0] is the complete counter cone, not a partial or oversized one. checked[:2] still drops it, so a mined counter-descendant is never read and the block retries with UTXO_WALK_LIMIT_EXCEEDED instead of failing TX_INVALID. The comment above doesn't cover this case either.

overBudgetWalkChecksOnlyRoots only overflows in pass 0.

Fix: have the walk return the index of the pass it stopped in (nil vs empty can't signal it, because walkPass returns nil on both) and narrow only when it stopped in pass 0. Add a test where the counter cone fits, the own cone overflows, and a counter-cone member is mined; assert TX_INVALID.

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.

Looks addressed in 14d5eb3: the narrowing is gone, so counterCone (cones[0]) always goes into the mined check. Both overflow orders are covered in Test_checkCounterConflictingOnCurrentChainBatch_overBudgetWalkChecksTheCounterConeItReached.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 14d5eb3 by removing the narrowing instead of returning the pass index: phase 3 checks the counter cone the walk reached, complete or partial, with no change to the walk's API. overBudgetWalkChecksTheCounterConeItReached/"own cone overflows after a complete counter cone" pins the case where the counter cone fits and the own cone overflows; it fails at 972f9d0.

// fault still has what it reached checked.
checked := [][]chainhash.Hash{txs, counterSpenders, counterCone}
if errors.Is(walkErr, errors.ErrUtxoWalkLimitExceeded) {
checked = checked[:2]

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.

Question, not blocking: a mined member inside the partial counter cone is still proof, since every hash the walk returns descends from a counter-spender root. Skipping it trades a definite rejection for retries until an operator raises the budget, and saves only the phase-3 GetMeta reads, because the walk itself runs again on every retry either way. Is that the trade you want? Checking the partial cone would end the retry loop whenever it holds a mined member.

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.

The latest code settles this the way you suggested: a mined member in a partial counter cone is still treated as proof (subtest "counter cone overflows after reaching a mined member").

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, the partial cone is now checked (14d5eb3). Saving phase-3 reads bounded by what the walk already read isn't worth retrying a provably invalid block until someone raises the budget. overBudgetWalkChecksTheCounterConeItReached/"counter cone overflows after reaching a mined member" pins it: the walk stops inside the counter cone, and the mined member it reached gives TX_INVALID. The test fails at 972f9d0.

// fetch the block from other peers, for a record this node pruned.
// - A TX_INVALID under another code, such as a record the aerospike store read
// but could not decode. Without the wrap the Kafka path would report the
// announcing peer, and catch-up would report it malicious.

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.

This overclaims. The wrap doesn't stop the announcing peer being reported. ValidateBlock's infrastructure guard (BlockValidation.go:~2270) matches context.Canceled, not the ERR_CONTEXT_CANCELED code, so a local decode fault still gets a corrupt verdict and penalizeCorruptBlockPeer. That predates this PR, and proven_conflict_classification_test.go:312-327 already says so. Either fix the comment here, or add errors.Is(err, errors.ErrContextCanceled) to that guard and flip the test case to corrupt: false.

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.

Looks addressed in a45eb58: the keepsCancellation doc comment now says the wrap does not stop the peer being struck, because the guard matches context.Canceled, not ERR_CONTEXT_CANCELED. It also marks this as pre-existing behaviour.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed the comment in a45eb58; the guard is unchanged. It now says the wrap keeps the Kafka retry and keeps catch-up from charging the peer or reporting it malicious, and that the decode fault still gets a corrupt verdict and penalizeCorruptBlockPeer, from the Kafka path and from catch-up, because ValidateBlock's guard matches context.Canceled, not ERR_CONTEXT_CANCELED. That is pre-existing. TestValidateBlock_ProvenConflictIsNotACancellation/"a TX_INVALID under another code" pins both halves: corrupt: true, plus the retry and catch-up classification.

…budget walk reached

Reverts the narrowing from b098750. When only the walk's second pass, the
conflicting transactions' own cone, overran the budget, the counter cone was
complete and was still dropped, so a block proven invalid by reads that all
succeeded retried until the budget was raised. A partial counter cone is proof
too: every member descends from a counter-spender. Phase 3 again mined-checks
the transactions, their counter-spenders and whatever counter cone the walk
reached, whichever error stopped it.

The setting's longdesc, the settings doc and the CHANGELOG go back to the
wording bsv-blockchain#2052 merged. Adds a case for each pass running
out with a mined counter-cone member; both fail with the narrowing.
…code fault

The wrap keeps the Kafka path retrying a TX_INVALID under another code and
keeps catch-up from charging the peer or reporting it malicious. It does not
stop the strike: ValidateBlock's infrastructure guard matches context.Canceled,
not ERR_CONTEXT_CANCELED, so the block still gets a corrupt verdict and
penalizeCorruptBlockPeer. That is pre-existing.

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

The revert in 14d5eb3 fixes the over-budget blocker, and a45eb58 fixes the doc comment.

Nits, on lines this PR doesn't change:

  • processTxMetaUsingStore.go:443: the unbatched branch still passes errProcessTxMetaContextDone to settledError. Now that the wrap also covers a pruned record or a decode fault with ctx still live, "context done" sends whoever reads the log after a cancellation that never happened. The batched branch uses a different message for the same thing.
  • CHANGELOG.md:732 (the 2052 entry) and the utxostore_conflictingChildrenMaxNodes longdesc say an over-budget walk rejects once it "reached a mined member". Only counter-conflicting members get the mined check; an own-cone member gets only the frozen check. The Go doc gets this right; the operator text should match.

Follow-ups, already wrong before this PR:

  • BlockValidation.go:~2368: the infrastructure guard matches context.Canceled, not ERR_CONTEXT_CANCELED, so a local decode fault still strikes the peer, once per Kafka retry.
  • BlockValidation.go:2419: NewBlockIncompleteTransientError("... subtree validation: %s", hash, err) takes err as the wrapped error, so the message renders %!s(MISSING).
  • isLocalCatchupFault doesn't check IsTransientBlockIncomplete, so a transient missing-data verdict without the cancellation wrap still reaches ReportPeerFailure.

… error

The unbatched branch passed errProcessTxMetaContextDone to settledError, so a
pruned record or a decode fault wrapped while ctx was live logged as "context
done". Both branches now share one neutral message; no classification changes.

The operator text for utxostore_conflictingChildrenMaxNodes (the setting's
longdesc, the settings doc and the bsv-blockchain#2052 CHANGELOG entry)
now says only a mined counter-conflicting transaction is proof after an
over-budget walk, as the batch's Go doc does: the conflicting transactions' own
descendants are only checked for frozen outputs.

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

Re-review at a032644: nothing blocking, and my approval stands. The three comments below are low severity and can be done here or in a follow-up. Opened #2072 for the root cause behind them: wrap the conflict check's store reads as StorageError so the decode-fault peer strike and the legacy-route park go away.

return ctx.Err() != nil ||
errors.Is(err, errors.ErrContextCanceled) ||
errors.IsContextError(err) ||
errors.Is(err, errors.ErrTxNotFound) ||

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.

With the ErrTxNotFound and ErrTxInvalid arms, CONTEXT_CANCELED doubles as a 'local, not the peer's' marker. A pruned record then shows up as a 'Context canceled, skipping' warning on the live path and as local_context_cancelled in catch-up. Anyone reading either will look for a cancellation that never happened. A local code is better (see #2072).

The wrap is also decided from the single error fail() kept. If a phase-1 record is missing and the walk also exceeds utxostore_conflictingChildrenMaxNodes, the TxNotFound wins and gets wrapped. Catch-up then records local_context_cancelled instead of the local_processing_fault the CHANGELOG promises for the walk limit, and the operator gets no hint to raise the budget.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No change in 9f975ec; this moved to #2072. Replacing CONTEXT_CANCELED as the "not the peer's" marker needs a local error code, and that fixes both halves: the "Context canceled" label on a pruned record, and a TxNotFound outranking the walk limit in one batch, which hides local_processing_fault and the hint to raise utxostore_conflictingChildrenMaxNodes. TestValidateBlock_ProvenConflictIsNotACancellation/a pruned parent pins today's labelling until then.

func keepsCancellation(ctx context.Context, err error) bool {
return ctx.Err() != nil ||
errors.Is(err, errors.ErrContextCanceled) ||
errors.IsContextError(err) ||

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.

This arm matches on text: (*Error).Is falls back to strings.Contains for a stdlib target. So a StorageError whose text contains 'context deadline exceeded' is still wrapped as a cancellation while ctx is live, which goes against the CHANGELOG line saying store faults are no longer wrapped. ctx.Err() and errors.Is(err, ErrContextCanceled) already cover real cancellations. The arms also overlap, so it isn't clear which one is load-bearing. Suggest ctx.Err() != nil || errors.Is(err, ErrContextCanceled) || errors.Is(err, ErrTxNotFound) || errors.Is(err, ErrTxInvalid), a one-line reason per arm, and a test that a live ctx plus NewStorageError(..., context.DeadlineExceeded) stays unwrapped.

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.

Looks addressed in 9f975ec: keepsCancellation no longer calls IsContextError, so it reads codes only (ctx.Err(), ERR_CONTEXT_CANCELED, ERR_TX_NOT_FOUND, ERR_TX_INVALID), and each arm has a one-line reason. New tests pin a live ctx with NewStorageError(..., context.DeadlineExceeded) staying ERR_STORAGE_ERROR, in subtreevalidation and through ValidateBlock (local_storage_fault). A raw stdlib DeadlineExceeded that reaches ValidateBlock is still matched by its infrastructure guard, so it is retried.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9f975ec: keepsCancellation is now ctx.Err() != nil || Is(ErrContextCanceled) || Is(ErrTxNotFound) || Is(ErrTxInvalid), one line of reason per arm; the settle path derives no contexts of its own, so the IsContextError arm caught nothing real. Pinned by the settledError subtest "a store fault whose text names a deadline is not a cancellation while ctx is live" and the two new "store fault naming a deadline" rows in TestValidateBlock_ProvenConflictIsNotACancellation, which all fail with the arm restored.

return errors.NewContextCanceledError(message, err)
}

return err

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.

An unwrapped failure now leaves without the [processTxMetaUsingStore] prefix. ValidateSubtreeInternal still says "Don't wrap the error again, processTxMetaUsingStore returns the correctly formatted error", which is no longer true. A batch-decorate StorageError or a walk-limit error then reaches the log and the gRPC response untagged. Either keep a non-cancellation wrap that carries the prefix and the original code, or update that 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.

Looks addressed in 9f975ec: prefixed() puts [processTxMetaUsingStore] on top under the failure's own code, so errors.Is and code-based classification are unchanged, and the "returns the correctly formatted error" comment in ValidateSubtreeInternal holds again. A proven conflict now leaves prefixed too, still with TX_INVALID on top, so provenConflict and ValidateBlock see the same code. Only a non-*errors.Error passes through without the prefix, as the doc comment says.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9f975ec: a failure that isn't wrapped as a cancellation now leaves with [processTxMetaUsingStore] on top under its own code (prefixed), so ValidateSubtreeInternal's comment holds again and no errors.Is result changes. Pinned by the settledError subtest "a failure that is not wrapped keeps the prefix and its code"; the class × classifier table passes unchanged.

@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 at a032644. My blocker from 972f9d0 is fixed. The other two commits since then are text fixes and are correct.

The blocker. 14d5eb3 removes the narrowing, so phase 3 mined-checks whatever counter cone the walk reached, in whichever pass it stopped. That is safe on an early stop: GetConflictingChildrenFromRoots sizes cones to both passes before walking, so cones[1] exists even when pass 0 overflows, and a mined member still wins over the walk-limit error. I checked the new test is not a rubber stamp. With check_counter_conflicting_batch.go put back to its 972f9d0 version, both subtests of Test_checkCounterConflictingOnCurrentChainBatch_overBudgetWalkChecksTheCounterConeItReached fail ("Target error should be in err chain"). On a032644 they pass under -race.

The text commits. a45eb58 makes the keepsCancellation doc say what the wrap does and does not do for a decode fault. a032644 replaces "context done" with a neutral prefix on both settle branches. It also brings the CHANGELOG entry, the setting's longdesc and utxo_settings.md in line with the Go doc: only counter-conflicting members get the mined check, and own-cone members get only the frozen check.

icellan's three comments at a032644. I agree with all three and won't repeat them. I checked the text-match one myself. IsContextError ends in Is(err, context.DeadlineExceeded), which on an *Error falls back to strings.Contains on the message (errors/errors.go:177). So a live-ctx StorageError wrapping an Aerospike deadline is still wrapped as a cancellation. None of the three blocks merge. They fit here or in #2072.

What I did not check. I ran only the new test, not the full subtreevalidation and blockvalidation suites. CI ran them: 37 checks pass on a032644. The Aerospike native-ops job skipped, as it does on every PR.

…or-classification

# Conflicts:
#	services/blockvalidation/proven_conflict_classification_test.go
…r prefix

keepsCancellation no longer calls IsContextError. Its DeadlineExceeded check
falls back to matching error text, so a store fault whose message named a
deadline was wrapped as a cancellation while ctx was live. ctx.Err() and
ERR_CONTEXT_CANCELED already cover every real cancellation on the settle path,
which derives no contexts of its own.

A failure settledError does not wrap as a cancellation now leaves with the
[processTxMetaUsingStore] prefix under its own code, so errors.Is and every
caller's classification are unchanged, and ValidateSubtreeInternal's "returns
the correctly formatted error" holds again.
@sonarqubecloud

Copy link
Copy Markdown

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