Skip to content

fix: postgres UTXO store parity with aerospike — correctness, performance, and stability - #569

Merged
freemans13 merged 83 commits into
bsv-blockchain:mainfrom
freemans13:fix/postgres-utxo-dah-parity
Apr 10, 2026
Merged

fix: postgres UTXO store parity with aerospike — correctness, performance, and stability#569
freemans13 merged 83 commits into
bsv-blockchain:mainfrom
freemans13:fix/postgres-utxo-dah-parity

Conversation

@freemans13

@freemans13 freemans13 commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Brings the postgres SQL UTXO store to production parity with the aerospike implementation. Discovered through testnet/mainnet sync testing with large datasets (38M+ transactions), these changes fix correctness bugs, prevent connection pool exhaustion, and eliminate startup hangs on large databases.

Changes

Correctness fixes

  • saveAsConflicting control flow — When CreateInUtxoStore returns TxExistsError and GetMeta succeeds, the validator now correctly returns the metadata instead of falling through to a ProcessingError. This caused blessMissingTransaction panics during block sync.
  • Idempotent SQL spend for parallel tx — The bulk spend UPDATE now includes OR spending_data = v.spending_data in the WHERE clause, matching aerospike's idempotent self-spend behavior. Prevents false UtxoSpentError when the same transaction is validated concurrently.
  • SQL parameter limit chunking — Batched inserts (inputs, outputs, block_ids) now chunk to stay under 999 parameters per statement (SQLite limit, also safe for PostgreSQL's 65535). Previously a transaction with 10k+ outputs would exceed PostgreSQL's limit and crash with got 70000 parameters but PostgreSQL only supports 65535.

Performance

  • getBatcher for SQL store — Mirrors aerospike's getBatcher pattern. Individual Get() calls during block validation are batched into bulk WHERE IN queries via BatchDecorate, reducing N×4 sequential queries to 4 queries per batch. Prevents connection pool exhaustion that caused context deadline exceeded errors and panics during mainnet catchup.
  • Schema migration uses pg_constraint — Replaced information_schema.table_constraints (slow view joining multiple catalog tables) with direct pg_constraint/pg_attribute lookups (indexed, instant). On teratestnet with 38M rows, the old queries blocked for 4+ minutes holding locks.

Stability

  • Skip FK recreation when CASCADE exists — The schema migration previously dropped and recreated foreign key constraints on every startup. The ALTER TABLE ADD CONSTRAINT FOREIGN KEY requires a full table scan to validate referential integrity (minutes on large tables). If the process was killed mid-validation, the FK was left dropped, creating a startup loop. Now only modifies FKs that don't already have ON DELETE CASCADE.

Test plan

  • go test -race ./stores/utxo/sql/... — all SQL store tests pass
  • go test -race ./services/validator/... — validator tests pass
  • go test -race ./services/subtreevalidation/... — subtree validation tests pass
  • Deployed to mainnet — syncing and processing blocks
  • Deployed to testnet — syncing with legacy peer
  • Deployed to teratestnet — all services start, syncing with peers

🤖 Generated with Claude Code

…entation

The postgres SQL UTXO store was setting delete_at_height (DAH) prematurely,
causing transactions to be pruned before dependent children were stable. With
low blockHeightRetention values (e.g., 2), this caused TX_MISSING_PARENT errors
during block sync around the retention boundary.

Four mismatches with the aerospike Lua implementation are fixed:

1. setDAH() (called from Spend/Unspend): Was setting DAH when all outputs were
   spent without checking if the tx was mined or on the longest chain. Aerospike
   requires allSpent AND hasBlockIDs AND isOnLongestChain. Now checks all three
   conditions plus preserve_until guard.

2. setMinedMultiBulk/Original: Was not bumping DAH forward when marking txs as
   mined. Now bumps to max(existing, currentHeight + retention), matching
   aerospike's setMined->setDeleteAtHeight behavior.

3. SetLocked(): Was not clearing DAH when locking a transaction. Aerospike
   clears DAH on lock to prevent pruning locked txs.

4. SetConflicting(): Was unconditionally overwriting DAH. Aerospike only sets
   DAH if not already set (COALESCE semantics).
@github-actions

github-actions Bot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

This PR brings the PostgreSQL UTXO store to production parity with Aerospike through correctness fixes, performance optimizations, and stability improvements validated against mainnet/testnet sync testing with 38M+ transactions.

Key Changes Verified

Correctness:

  • Validator control flow fix prevents panic when CreateInUtxoStore returns TxExistsError — now correctly returns metadata instead of falling through to ProcessingError
  • SQL parameter chunking (999 limit) prevents PostgreSQL crashes on transactions with 10k+ outputs
  • Idempotent SQL spend with OR spending_data = v.spending_data prevents false UtxoSpentError during concurrent validation

Performance:

  • getBatcher implementation mirrors Aerospike pattern, reducing N×4 queries to 4 queries per batch via BatchDecorate
  • Schema migration now uses pg_constraint direct lookups instead of information_schema views — instant vs 4+ minute blocking on 38M row tables
  • Connection pool settings updated: maxIdleConns=50 (matches maxOpenConns) to avoid SCRAM-SHA-256 auth churn

Stability:

  • FK CASCADE migration only modifies constraints missing ON DELETE CASCADE — prevents startup loops from killed mid-validation processes
  • Batched SQL operations setting (utxostore_batch_sql_operations) with proper documentation

Review Assessment

The implementation is well-tested and production-proven. Code follows project conventions, includes comprehensive test coverage (sql_test.go, setmined_test.go, tests.go), and mirrors established Aerospike patterns for consistency.

All previously reported issues from Copilot and earlier Claude reviews have been addressed by the PR author with proper fixes committed.

During block processing, s.blockHeight is updated asynchronously via
blockchain notifications and lags behind the actual block being processed.
The aerospike implementation compensates with +1 (set_mined.go:162), but
the SQL store was missing this offset, causing DAH to be 1 block too low.

With retention=2, this meant transactions were pruned 1 block earlier than
intended, which is the direct cause of TX_MISSING_PARENT at block 172.
@freemans13 freemans13 self-assigned this Mar 10, 2026
- Fix Spend 'already blessed' false failure: move errorFound flag after
  the blessed check so transactions don't falsely fail when all spends
  are already blessed (parent pruned but child validated)
- Fix SetBlockHeight to reject blockHeight==0 with InvalidArgumentError,
  matching aerospike validation
- Fix GetSpend not-found to return SpendResponse{Status: NOT_FOUND}, nil
  instead of error, matching aerospike behavior
- Fix Spend error types: use TxNotFoundError, UtxoHashMismatchError, and
  TxCoinbaseImmatureError instead of generic StorageError
- Fix GetSpend hash mismatch to use UtxoHashMismatchError
- Fix SetLocked to recalculate DAH on unlock via DB transaction
- Fix SetMinedMulti to set unmined_since when !OnLongestChain
- Fix SetMinedMulti DAH to handle first-time-set case for fully spent txs
Replace SELECT FOR UPDATE with a non-locking read plus an optimistic
spending_data IS NULL guard on the UPDATE. This eliminates row-level
lock contention when many concurrent goroutines spend different outputs
from the same parent transaction. Mirrors aerospike's lock-free atomic
operations. Handles idempotent re-spends (same tx spending same output)
by checking if the existing spending data matches.
Replace the separate setDAH() call (SELECT + UPDATE = 2 queries) with a
single conditional UPDATE statement that checks unspent outputs, block
membership, and longest-chain status inline. This reduces per-spend
queries from 3 to 2, halving the DB load for large blocks like block 173
with 24k transactions.
During block validation (IgnoreLocked=true), skip per-spend DAH updates
since SetMinedMulti handles DAH after all spends complete. This reduces
per-spend queries from 2 to 1, critical for large blocks (e.g. block 173
with 24k txs was timing out with 49k queries through 50 connections).
Replace direct per-spend DB queries with go-batcher that batches spend
operations into single DB transactions. This controls concurrent DB
connections (from 6400 to ~50) preventing driver: bad connection cascades
during large block validation.

- Add batchSpend struct and spendBatcher field to Store
- Rewrite Spend() to enqueue into batcher with errCh wait pattern
- Add sendSpendBatch() callback processing batches in single DB txn
- Add needsSpendRollback() for Unspend on validation failures
- Revert wrong DAH skip during block validation (f8307b3)
- Same SQL for both PostgreSQL and SQLite (no engine branching)
Comment thread stores/utxo/sql/sql.go
Comment thread stores/utxo/sql/sql.go Outdated
…table bulk IN clauses

Replace 3 separate SetMinedMulti implementations (PostgreSQL-specific bulk,
per-hash original, batched wrapper) with a single portable implementation
using dynamically built IN ($1,$2,...,$N) clauses that work on both
PostgreSQL and SQLite.

- SetMinedMulti: 489 lines (3 functions) -> 236 lines (2 functions)
- MarkTransactionsOnLongestChain: N individual UPDATEs -> chunked bulk UPDATEs
  (24K txs = 60 round trips instead of 24,000)
- Fix PreserveTransactions and ProcessExpiredPreservations using ? placeholders
  that were broken on PostgreSQL (lib/pq sends queries verbatim, no ? -> $N)
- Remove all pq.Array(), array_agg(), ANY($1::bytea[]) PostgreSQL-specific SQL
- Chunk size 400 stays under SQLite's 999 parameter limit
…exceeded on large blocks

BatchDecorate was applying a single DBTimeout (5s) across the entire
batch of sequential Get() calls. Each Get() already applies its own
DBTimeout internally. For large blocks (e.g. 6287 txs at testnet
block 33276), 1024 sequential queries under one 5s deadline causes
context deadline exceeded, which triggers a panic in handleBlockMsg.
…ookup

checkOldBlockIDs was calling GetBlockHeaderIDs with the current block's
hash, but the block hasn't been added to the blockchain store yet at
that point (AddBlock happens later). This caused GetBlockHeaderIDs to
return empty results, forcing every transaction to go through the
expensive CheckBlockIsInCurrentChain gRPC call which runs a recursive
CTE query per transaction.

For block 33276 (6287 txs), this caused 6+ minute processing times and
could hang the node when the recursive CTE query blocked on PostgreSQL.

Fix: use block.Header.HashPrevBlock (the parent block, which is always
already stored) for the initial chain lookup. This populates the fast
in-memory map so most transactions resolve without any gRPC calls.

Also added logging to track fast-path vs slow-path resolution counts.
…per-tx queries

Previously, BatchDecorate iterated over N transactions and called Get()
individually for each one. Each Get() ran 3 separate SQL queries
(transactions, inputs, block_ids), resulting in ~3*N round trips to the
database. For a batch of 400 transactions, this was 1200 queries.

Now, BatchDecorate processes chunks of up to 400 transactions with just
3 bulk queries using IN clauses:
1. Bulk SELECT from transactions WHERE hash IN (...)
2. Bulk SELECT from inputs WHERE transaction_id IN (...)
3. Bulk SELECT from block_ids WHERE transaction_id IN (...)

This reduces database round trips from ~1200 to ~3 per chunk, which is
critical for block validation performance on PostgreSQL where each query
has network latency overhead.

The implementation handles all field combinations (inputs, outputs,
block_ids, tx inpoints) and properly assembles results back into the
per-transaction UnresolvedMetaData structures.
… in checkpoint sync

Replace per-tx PreviousOutputsDecorate calls in quickValidateBlockAsync with a
single bulk BatchPreviousOutputsDecorate that collects all unique parent tx
hashes and queries them in chunks using IN clauses. For a 12K-tx block, this
reduces ~18,000 individual SQL queries to ~50 chunked queries.
…avioral contracts

Add 8 new shared test functions to stores/utxo/tests/tests.go that verify
functional parity between SQL and Aerospike UTXO store implementations:

- SpendErrorTypes: ErrTxNotFound, ErrUtxoHashMismatch, ErrTxCoinbaseImmature, ErrSpent
- GetSpendNotFound: NOT_FOUND status (not error) for missing UTXOs
- SetBlockHeightZero: InvalidArgumentError for zero block height
- SetLockedBehavior: lock/unlock lifecycle with GetSpend status verification
- SetConflictingBehavior: conflicting status, error types, parent tracking
- SetMinedUnminedSince: UnminedSince lifecycle across chain status changes
- SpendIdempotent: re-spending with same tx is a no-op
- SetMinedWithSpent: full create->spend all->mine->accumulate blocks lifecycle

Also expands TestSetTTL in sql_test.go to verify DAH +1 offset, DAH bump on
block height advance, DAH clear/restore on chain status changes, and conflicting
COALESCE behavior.
The go-batcher was running with background=true (default), dispatching
each batch callback as a new goroutine with no concurrency limit.
Concurrent batch transactions could deadlock when locking overlapping
output rows (same transaction_id, different idx) in different orders.

Set background=false so batch callbacks execute serially on the worker
goroutine. Aerospike doesn't need this because it uses optimistic
single-key operations without DB-level row locking.
@github-actions

github-actions Bot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


This PR correctly aligns PostgreSQL UTXO store DAH (delete_at_height) logic with Aerospike implementation to fix premature transaction pruning. The core fixes address a real bug that caused sync failures at low retention values.

Key Changes Verified:

  • setDAH(): Now checks has_blocks, is_on_longest_chain, and preserve_until before setting DAH
  • setMinedMultiChunk(): Bumps DAH forward when mining to create rolling protection window
  • SetLocked(): Clears DAH when locking (prevents pruning locked transactions)
  • SetConflicting(): Uses COALESCE to avoid overwriting existing DAH values
  • BatchPreviousOutputsDecorate(): New bulk lookup method reduces database round-trips

Existing Issues (from previous review):
Three previously-identified issues remain unresolved and should be addressed before merge:

The validation logic changes are sound and tests are comprehensive. Good work mirroring Aerospike behavior exactly.

During catchup sync from genesis, s.blockHeight.Load() lags behind the
block being validated due to async subscription updates. This caused DAH
values to be computed too low, leading to premature pruning of parent
transactions that descendants still needed for decoration.

Fix: use the spend item's blockHeight (from the block being validated)
instead of the global store height, matching Aerospike's spend.go:530
which passes bItem.blockHeight to the Lua script. Also removes the
incorrect +1 offset that only applies in SetMined/setDAH contexts, not
spend (Aerospike Lua: newDeleteHeight = currentBlockHeight +
blockHeightRetention, no +1).
Wrap sendSpendBatch in retry loop (3 attempts with exponential backoff)
to handle PostgreSQL deadlocks caused by concurrent DAH updates from
SetMinedMulti/MarkTransactionsOnLongestChain during legacy sync.

- Extract trySendSpendBatch returning retryable bool on deadlock
- Add isDeadlock() helper detecting PostgreSQL and SQLite lock errors
- Check for deadlock at SELECT, UPDATE outputs, DAH update, and commit
Comment thread stores/utxo/sql/sql.go

s.logger.Debugf("setting block height to %d", blockHeight)
s.blockHeight.Store(blockHeight)

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.

Update (2026-03-11): My previous concern was incorrect. The PR author properly added tests to validate the zero-height rejection behavior. The remaining SetBlockHeight(0) calls in test files are deliberately ignoring the error, which is appropriate. The validation is working as intended with proper test coverage.

freemans13 and others added 4 commits March 11, 2026 11:56
Refactor the logic in checkParentExistsOnChain to handle cases where a parent transaction's block ID is not found in the cached set of recent block IDs. The updated implementation now defers to the validator's checkOldBlockIDs for a broader lookup, allowing for gaps in block IDs due to orphaned or invalid blocks. This change improves error handling and ensures that valid parent transactions are correctly identified even when they fall outside the cached range.
… crash loop

When a block is stored as invalid, GetBlockExists still returns true,
causing the sync manager to skip it and move to the next block. The next
block then fails because its parent (the invalid block) was never
properly processed, triggering a panic in handleBlockMsg.

Check block validity via GetBlockHeader after GetBlockExists returns true.
Invalid blocks are now treated as not having them, so they get
re-requested from peers and reprocessed.
In aerospike, when a non-paginated record (99% of txs) has all outputs
spent, setDeleteAtHeight returns ALLSPENT signal but does NOT set DAH
directly. Go then calls incrementSpentExtraRecs on the master record,
which returns ERROR because totalExtraRecs is nil for non-paginated
records. The error is logged and swallowed — so DAH is never set on
parent transactions during the Spend path.

Postgres's sendSpendBatch Phase 2 was directly setting delete_at_height
on parent transactions when their last output was spent. This caused
premature parent deletion: 144 blocks later the pruner would delete the
parent, and when a child tx's block arrived, PreviousOutputsDecorate
couldn't find the parent's outputs — causing a panic.

Remove Phase 2 (DAH updates) from trySendSpendBatch entirely. Parent
tx cleanup now relies on SetMinedMulti (when the parent's own block is
processed) which correctly sets DAH if all outputs are spent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@freemans13 freemans13 changed the title fix: align postgres UTXO delete_at_height logic with aerospike implementation fix: remove premature parent DAH-on-spend in postgres UTXO store Mar 17, 2026

Copilot AI 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.

Pull request overview

Copilot reviewed 24 out of 25 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread util/usql/retry.go
Comment on lines +407 to +420
// asPgError unwraps err looking for a *pgconn.PgError (pgx driver).
// Equivalent to errors.As without importing the standard errors package.
func asPgError(err error) *pgconn.PgError {
for err != nil {
if pgErr, ok := err.(*pgconn.PgError); ok {
return pgErr
}
u, ok := err.(interface{ Unwrap() error })
if !ok {
return nil
}
err = u.Unwrap()
}
return nil

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

asPgError only unwraps single-error chains via Unwrap() error. It won’t find a *pgconn.PgError inside multi-errors produced by errors.Join (which is used elsewhere in the repo), so isRetriable may incorrectly treat joined pgx errors as non-retriable. Consider switching to errors.As (stdlib) or extending the unwrap loop to also handle Unwrap() []error recursively.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. Replaced the manual unwrap loop with errors.As which handles both single-error chains and multi-errors from errors.Join. Fixed.

Comment on lines 156 to 168
for rows.Next() {
input := &bt.Input{}
var previousTxIdx int64

if err = rows.Scan(&previousTxHashBytes, &input.PreviousTxOutIndex, &input.PreviousTxSatoshis, &input.PreviousTxScript, &input.UnlockingScript, &input.SequenceNumber); err != nil {
if err = rows.Scan(&previousTxHashBytes, &previousTxIdx, &input.PreviousTxSatoshis, &input.PreviousTxScript, &input.UnlockingScript, &input.SequenceNumber); err != nil {
if err = it.Close(); err != nil {
it.store.logger.Warnf("failed to close iterator: %v", err)
}

return nil, err
}
input.PreviousTxOutIndex = uint32(previousTxIdx)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

PreviousTxOutIndex is assigned via uint32(previousTxIdx) without validating bounds. If previous_tx_idx is negative or > MaxUint32 (corrupt data / unexpected schema values), this will wrap silently and produce an incorrect outpoint. Consider validating previousTxIdx is within [0, math.MaxUint32] and returning a processing error if not.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The previous_tx_idx column is defined as INTEGER in the schema and populated from Bitcoin transaction data where output indices are always uint32. A negative or >MaxUint32 value would indicate corrupt data that should have been caught during transaction validation. Adding bounds checking here would mask upstream corruption rather than fail fast. The existing error handling for the hash bytes on the next line provides a similar pattern — if the data is fundamentally corrupt, we surface it through the hash parse error.

Comment on lines +53 to +55
sh.m[h] = struct{}{}
sh.mu.Unlock()
s.count.Add(1)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

PrunedTxSet.Add increments count unconditionally, even when the TXID is already present in the shard map. This can cause Len() to drift upward over time (and never fully return to 0), breaking any metrics/logic that relies on it. Increment count only when the map insert is new (i.e., when the key was not already present).

Suggested change
sh.m[h] = struct{}{}
sh.mu.Unlock()
s.count.Add(1)
if _, exists := sh.m[h]; !exists {
sh.m[h] = struct{}{}
s.count.Add(1)
}
sh.mu.Unlock()

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — fixed. Now only increments the count when the key is actually new to the shard map.

Comment thread services/p2p/server_helpers.go Outdated
Comment on lines +103 to +110
}
// Note: we intentionally do NOT filter blocks from unhealthy peers here.
// Block validation handles bad blocks safely, and filtering blocks from
// low-reputation peers prevents catchup when the node is behind.

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

This change removes filtering of block announcements from unhealthy peers, which is a behavior change in P2P gossip handling but isn’t mentioned in the PR description (which focuses on SQL UTXO store parity). Please either document this behavioral change in the PR description/release notes, or move it to a dedicated PR so it can be reviewed and rolled out independently.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This change was part of a separate commit (036e64c) that preceded the UTXO store work. It removes filtering of block messages from low-reputation peers because block validation handles bad blocks safely, and filtering was preventing catchup when the node was behind. The comment at line 108-110 documents the rationale. Agreed it should be called out — will update the PR description.

- Use errors.As in asPgError to handle multi-error chains from errors.Join
- Fix PrunedTxSet.Add to only increment count for new entries (prevents drift)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 24 out of 25 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

util/usql/retry.go:60

  • isRetriable unwraps pgx errors via asPgError, but the lib/pq fallback uses a direct type assertion (err.(*pq.Error)), so wrapped/joined pq errors won’t be detected as retriable. Consider switching this block to errors.As(err, &pqErr) (same as pgx) to keep retry/circuit-breaker behavior consistent when errors are wrapped.
	// PostgreSQL errors (lib/pq fallback)
	if pqErr, ok := err.(*pq.Error); ok {
		code := string(pqErr.Code)
		return strings.HasPrefix(code, "08") || // Connection errors
			code == "40001" || // Serialization failure
			code == "40P01" || // Deadlock
			code == "55P03" || // Lock not available
			code == "57P03" // Cannot connect now
	}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread util/sql.go
Comment on lines +77 to +82
// Use pgx/stdlib with QueryExecModeExec to skip prepared statement overhead.
// QueryExecModeExec uses the extended protocol but skips Parse, sending inline params.
// Testing CTE+UNNEST with ExecModeExec to isolate CTE execution cost from
// CacheStatement's generic plan overhead. Previous CTE test used CacheStatement
// which may have caused slow batch times via generic plan degradation.
connConfig, err := pgx.ParseConfig(dbInfo)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

The comment describing QueryExecModeExec appears inaccurate/misleading: with the pgx extended protocol, parameters are not “sent inline”; they’re encoded as separate bind parameters. Consider adjusting the comment to accurately reflect what this exec mode changes (e.g., statement caching/prepare behavior) to avoid future confusion when tuning query performance.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good point — updated the comment to accurately describe QueryExecModeExec behavior: it skips the Prepare step (no Parse/Describe round-trip) rather than sending params inline.

Comment thread stores/utxo/tests/tests.go Outdated
Satoshis: Tx.Inputs[0].PreviousTxSatoshis,
}))
tx.Inputs[0].UnlockingScript = dummyUnlockingScript
_ = tx.PayToAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", satoshis)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

newTestTx ignores the error from tx.PayToAddress(...). If PayToAddress fails (e.g., invalid address, insufficient funds/fee logic), the returned tx may be incomplete and lead to misleading failures in downstream tests. Please assert require.NoError (or otherwise handle the error) so test failures point to the real cause.

Suggested change
_ = tx.PayToAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", satoshis)
require.NoError(t, tx.PayToAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", satoshis))

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, applied the suggested require.NoError to surface the real failure if PayToAddress ever fails.

Comment on lines 610 to 614
if err = v.utxoStore.GetMeta(decoupledCtx, tx.TxIDChainHash(), txMetaData); err != nil {
err = errors.NewProcessingError("[Validate][%s] CreateInUtxoStore failed - tx exists but unable to get meta data", txID, utxoMapErr)
span.RecordError(err)

return nil, err

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

In the ErrTxExists branch, if GetMeta fails the new ProcessingError wraps utxoMapErr (TxExists) rather than the GetMeta error that actually explains why metadata couldn’t be loaded. Wrap the GetMeta error instead (and optionally include utxoMapErr in the message) to make diagnostics accurate.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — the ProcessingError was wrapping utxoMapErr (TxExists) instead of the actual GetMeta error. Fixed to wrap err (the GetMeta failure) so diagnostics are accurate.

Comment thread stores/utxo/sql/setmined_test.go Outdated
Comment on lines 47 to 49
// Begin will return error since context is cancelled
mock.ExpectBegin().WillReturnError(context.Canceled)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

setMinedMultiChunk begins the transaction with s.db.Begin() (no context), so a cancelled context won’t cause Begin to return context.Canceled. The failure will occur on the first QueryContext/ExecContext call instead (and Rollback should be expected). Adjust this test’s sqlmock expectations accordingly, or change the implementation to use BeginTx(ctx, ...) if you want cancellation to short-circuit before opening a transaction.

Suggested change
// Begin will return error since context is cancelled
mock.ExpectBegin().WillReturnError(context.Canceled)

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. Changed s.db.Begin() to s.db.BeginTx(ctx, nil) so context cancellation is respected at the transaction-open stage, consistent with other methods in this file.

- Use BeginTx(ctx, nil) in setMinedMultiChunk for context cancellation
- Fix error wrapping in Validator to use GetMeta error instead of TxExists
- Add require.NoError for PayToAddress in test helper
- Fix inaccurate QueryExecModeExec comment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 24 out of 25 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

util/usql/retry.go:60

  • isRetriable checks for *pq.Error via direct type assertion (err.(*pq.Error)), which misses wrapped/joined pq errors (e.g., fmt.Errorf("...: %w", err) or errors.Join). Since this file already uses errors.As for pgx errors, consider switching the lib/pq branch to also use errors.As(err, &pqErr) so retriable Postgres errors aren’t misclassified as non-retriable when wrapped.
	// PostgreSQL errors (lib/pq fallback)
	if pqErr, ok := err.(*pq.Error); ok {
		code := string(pqErr.Code)
		return strings.HasPrefix(code, "08") || // Connection errors
			code == "40001" || // Serialization failure
			code == "40P01" || // Deadlock
			code == "55P03" || // Lock not available
			code == "57P03" // Cannot connect now
	}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +36 to +40
Satoshis: Tx.Inputs[0].PreviousTxSatoshis,
}))
tx.Inputs[0].UnlockingScript = dummyUnlockingScript
require.NoError(t, tx.PayToAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", satoshis))
return tx

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

newTestTx ignores the error from tx.PayToAddress(...). Other tests in the repo typically assert this call succeeds, and ignoring it could produce a tx with missing/incorrect outputs if address parsing or script generation fails, causing misleading test results. Please check and assert the returned error (e.g., require.NoError).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Already fixed in the previous commit (line 39 now uses require.NoError). This comment appears to have been generated against the prior revision.

Comment on lines +458 to +462
nonExistentHash, _ := chainhash.NewHashFromStr("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
nonExistentSpendTx := bt.NewTx()
_ = nonExistentSpendTx.From(nonExistentHash.String(), 0, Tx.Outputs[0].LockingScript.String(), Tx.Outputs[0].Satoshis)
_ = nonExistentSpendTx.PayToAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", 1000)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

Several new test cases build transactions while discarding errors from helpers like Tx.From(...) / PayToAddress(...). If these helpers fail, the resulting tx may be malformed and the test may assert on downstream errors for the wrong reason. Prefer asserting construction errors at the source (e.g., require.NoError on From/PayToAddress) to keep the tests reliable.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good point. Replaced all _ = tx.From(...) and _ = tx.PayToAddress(...) calls in test functions with require.NoError(t, ...) so construction errors surface immediately. Left the Benchmark function unchanged since it uses b *testing.B.

Replace _ = tx.From/PayToAddress/ChangeToAddress with require.NoError
across all test functions in stores/utxo/tests/tests.go so construction
failures surface immediately instead of causing misleading downstream errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 24 out of 25 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread stores/utxo/sql/setmined_test.go Outdated
Comment on lines +47 to +50
// Begin will return error since context is cancelled
mock.ExpectBegin().WillReturnError(context.Canceled)

// Execute the function with cancelled context
result, err := store.setMinedMultiBulk(ctx, hashes, minedInfo)
result, err := store.setMinedMultiChunk(ctx, hashes, minedInfo)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

These expectations assume BeginTx will hit the sqlmock driver and return context.Canceled, but database/sql.(*DB).BeginTx returns ctx.Err() immediately when the context is already canceled (without calling the driver). With the current implementation (s.db.BeginTx(ctx, nil)), ExpectBegin() is likely never consumed and the test will fail with unmet expectations. Consider removing the Begin expectation and asserting err is context.Canceled, or restructure the test to cancel after BeginTx succeeds (e.g., cancel during the first Query/Exec).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. Fixed. Removed the ExpectBegin since database/sql.BeginTx returns ctx.Err() immediately when the context is already cancelled without calling the driver. The test now simply asserts context.Canceled with no mock expectations needed.

Comment thread stores/utxo/sql/setmined_test.go Outdated
Comment on lines 67 to 71
mock.ExpectBegin()
mock.ExpectRollback()

// Wait for context to be cancelled
time.Sleep(2 * time.Millisecond)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

This test cancels the context before calling setMinedMultiChunk, but BeginTx should short-circuit with ctx.Err() before any DB calls. Even if BeginTx did succeed, setMinedMultiChunk issues a QueryContext before rollback, so expecting only Begin+Rollback is incomplete for sqlmock. Suggest rewriting the test to: (1) allow BeginTx to succeed, (2) set an expectation for the first SELECT hash ... query to block/delay until the context deadline, and (3) expect rollback.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. Restructured the test: Begin now succeeds, then the first SELECT query returns context.Canceled, followed by Rollback. This properly tests cancellation during execution rather than relying on timing.

MaxMinedRoutines int `key:"utxostore_maxMinedRoutines" desc:"Maximum concurrent routines for mined operations" default:"128" category:"UtxoStore" usage:"Parallel mined status updates" type:"int" longdesc:"### Purpose\nControls the number of concurrent goroutines updating mined status when a block is validated.\n\n### How It Works\nBlock with 10,000 transactions split across 128 parallel workers (approximately 78 transactions per worker).\n\n### Values\n- **Default 128** - Optimized for typical block sizes, provides faster block validation completion\n- **256** - For very large blocks during catchup\n- **32** - For low-CPU systems (slower block validation, less resource usage)\n\n### Trade-offs\n| Setting | Benefit | Drawback |\n|---------|---------|----------|\n| Higher | Faster block validation | More CPU usage and Aerospike writes |\n| Lower | Reduced resource usage | Slower block validation |\n\n### Related Settings\nWorks with **MaxMinedBatchSize** to optimize mined status update throughput."`
MaxMinedBatchSize int `key:"utxostore_maxMinedBatchSize" desc:"Maximum batch size for mined operations" default:"1024" category:"UtxoStore" usage:"Number of transactions per mined batch" type:"int" longdesc:"### Purpose\nControls the maximum number of transactions per mined status batch update operation.\n\n### How It Works\nBlock with 10,000 transactions split into batches of 1024 (10 batch operations to Aerospike). Each batch is single network round trip.\n\n### Values\n- **Default 1024** - Balanced for typical block sizes\n- **2048-4096** - For catchup mode processing old blocks (fewer network calls, higher throughput)\n- **512** - For real-time validation requiring lower latency per batch\n- **256-512** - If Aerospike shows signs of overload from large batches\n\n### Related Settings\nWorks with **MaxMinedRoutines** for parallel batch processing."`
BlockHeightRetentionAdjustment int32 `key:"utxostore_blockHeightRetentionAdjustment" desc:"Adjustment to global block height retention" default:"0" category:"UtxoStore" usage:"Can be positive or negative" type:"int32" longdesc:"### Purpose\nFine-tunes retention per service without changing global GlobalBlockHeightRetention setting.\n\n### How It Works\n- Positive values extend retention\n- Negative values reduce retention\n\n**Calculation:** effectiveRetention = GlobalBlockHeightRetention + BlockHeightRetentionAdjustment\n\n### Values\n- **Default 0** - Uses global value\n- **Common range** - -100 to +500\n\n### Example Scenarios\n- **Asset Server +200** - Longer history for API queries\n- **Validator 0** - Use global default\n- **Block Assembly -50** - Aggressive cleanup\n\n### Warning\nNegative adjustments below -100 **NOT RECOMMENDED** due to reorg safety concerns."`
BatchSQLOperations bool `key:"utxostore_batch_sql_operations" desc:"Batch SQL operations in UTXO store" default:"true" category:"UtxoStore" usage:"Multi-value INSERTs and bulk SELECT/UPDATE" type:"bool" longdesc:"### Purpose\nEnables batched SQL operations in the UTXO store for Create() and Spend() paths.\n\n### How It Works\nWhen enabled, Create() uses multi-value INSERT statements instead of per-row loops, and Spend() uses bulk SELECT/UPDATE instead of per-input queries. This reduces SQL round trips from ~15,000 to ~4,300 per 1000-tx block.\n\n### Values\n- **Default true** - Recommended for production\n- **false** - Falls back to per-row SQL operations (original behavior)\n\n### Warning\nDisabling increases block processing time significantly for SQL-backed UTXO stores."`

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

This PR introduces a new runtime setting key (utxostore_batch_sql_operations) that changes default behavior (batched SQL ops enabled). The PR description doesn’t mention the new setting/rollout knob; consider calling it out explicitly so operators know how to disable batching if needed during incident mitigation.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good point. Will update the PR description to call out the utxostore_batch_sql_operations setting explicitly, including how to disable it (utxostore_batch_sql_operations=false) for incident mitigation.

Comment thread stores/utxo/sql/mock.go Outdated
Comment on lines 222 to 225
// 6. Ensure CASCADE FK on inputs (combined DROP if non-CASCADE + ADD if missing)
mockDB.On("Exec", mock.MatchedBy(func(query string) bool {
return strings.Contains(query, "DO $$") && strings.Contains(query, "inputs_transaction_id_fkey") && strings.Contains(query, "ADD CONSTRAINT")
return strings.Contains(query, "DO $$") && strings.Contains(query, "inputs_transaction_id_fkey") && strings.Contains(query, "confdeltype")
}), mock.Anything).Return(sqlmock.NewResult(0, 0), nil)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

The step numbering in these comments appears out of sync (e.g., this block is labeled step 6 and later blocks repeat/skip numbers). Consider renumbering to match the stepMatchers order so it’s easier to correlate errorAtStep values with the intended DDL operation.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. Renumbered all step comments to use 0-based indices matching the stepMatchers array in SetupCreatePostgresSchemaErrorMocks, so errorAtStep values now directly correspond to the comment numbers.

Comment on lines +123 to 127
// Convert []chainhash.Hash to pgtype.FlatArray[[]byte] for pgx driver
hashBytes := make(pgtype.FlatArray[[]byte], len(blockLocator))
for i, hash := range blockLocator {
hashBytes[i] = hash[:]
}

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

In the for i, hash := range blockLocator loop, hashBytes[i] = hash[:] captures a slice backed by the range variable (hash), which is reused each iteration. This can result in every entry in hashBytes pointing at the same underlying array (ending up with duplicates of the last locator hash). Use the slice element directly (e.g., iterate by index and use blockLocator[i][:]) or copy each hash into a new byte slice before appending.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not a bug in Go 1.22+. This project uses Go 1.26 (see go.mod), where range variables are per-iteration, so hash is not reused across iterations. Each hash[:] slice points to its own backing array.

- Fix context cancellation tests: BeginTx checks ctx.Err() before
  calling driver, so remove unreachable mock expectations and restructure
  DuringExecution test to cancel after Begin succeeds
- Renumber step comments in mock.go to match stepMatchers indices

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 24 out of 25 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread stores/utxo/sql/setmined_test.go Outdated
Comment on lines +69 to +72
// Cancel after Begin succeeds but before Query completes
cancel()

// Execute the function with cancelled context
result, err := store.setMinedMultiBulk(ctx, hashes, minedInfo)
result, err := store.setMinedMultiChunk(ctx, hashes, minedInfo)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

In this test, cancel() is called before invoking setMinedMultiChunk. Since the implementation uses BeginTx(ctx, ...), database/sql will return context.Canceled immediately without calling the driver, so the ExpectBegin/ExpectQuery/ExpectRollback expectations will not be consumed and the test will fail. Cancel the context after BeginTx has succeeded (e.g., cancel from a goroutine after the call starts, or use a delayed query to create a deterministic cancellation point).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. Moved cancel() to a defer so the context is not pre-cancelled before BeginTx. The mock's WillReturnError(context.Canceled) now simulates the cancellation effect during the query, and BeginTx proceeds normally through the driver. Fixed.

freemans13 and others added 8 commits April 1, 2026 09:09
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…o fix/postgres-utxo-dah-parity

# Conflicts:
#	services/validator/Validator.go
…tion

- Revert server_helpers.go change (split to separate PR bsv-blockchain#661)
- Restore accidentally deleted docs/p2p-silent-mode.md
- Remove AGENTS.md from tracking (personal config)
- Remove batcher design spec and gitignore docs/superpowers/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Set UseInMemoryChainCheck to false in block_timestamp_cache_test.go for accurate testing.
- Refactor context cancellation handling in setmined_test.go to clarify mock expectations.
- Enhance deadlock detection in sql.go by checking PostgreSQL error codes for better reliability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add util/usql/pgerr.go with constants for all postgres error codes
used across the codebase. Replace magic strings in stores/utxo/sql,
stores/blockchain/sql, and util/usql/retry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@freemans13
freemans13 requested a review from icellan April 7, 2026 18:26
@sonarqubecloud

sonarqubecloud Bot commented Apr 9, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
3 Security Hotspots
76.6% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@freemans13
freemans13 merged commit f8838af into bsv-blockchain:main Apr 10, 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