fix: postgres UTXO store parity with aerospike — correctness, performance, and stability - #569
Conversation
…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).
|
🤖 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 VerifiedCorrectness:
Performance:
Stability:
Review AssessmentThe 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.
- 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)
…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.
|
🤖 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:
Existing Issues (from previous review):
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
|
|
||
| s.logger.Debugf("setting block height to %d", blockHeight) | ||
| s.blockHeight.Store(blockHeight) | ||
|
|
There was a problem hiding this comment.
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.
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.
…o fix/postgres-utxo-dah-parity
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>
There was a problem hiding this comment.
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.
| // 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Good catch. Replaced the manual unwrap loop with errors.As which handles both single-error chains and multi-errors from errors.Join. Fixed.
| 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) | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| sh.m[h] = struct{}{} | ||
| sh.mu.Unlock() | ||
| s.count.Add(1) |
There was a problem hiding this comment.
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).
| 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() |
There was a problem hiding this comment.
Good catch — fixed. Now only increments the count when the key is actually new to the shard map.
| } | ||
| // 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
isRetriableunwraps pgx errors viaasPgError, 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 toerrors.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.
| // 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| Satoshis: Tx.Inputs[0].PreviousTxSatoshis, | ||
| })) | ||
| tx.Inputs[0].UnlockingScript = dummyUnlockingScript | ||
| _ = tx.PayToAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", satoshis) |
There was a problem hiding this comment.
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.
| _ = tx.PayToAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", satoshis) | |
| require.NoError(t, tx.PayToAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", satoshis)) |
There was a problem hiding this comment.
Agreed, applied the suggested require.NoError to surface the real failure if PayToAddress ever fails.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // Begin will return error since context is cancelled | ||
| mock.ExpectBegin().WillReturnError(context.Canceled) | ||
|
|
There was a problem hiding this comment.
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.
| // Begin will return error since context is cancelled | |
| mock.ExpectBegin().WillReturnError(context.Canceled) |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
isRetriablechecks for*pq.Errorvia direct type assertion (err.(*pq.Error)), which misses wrapped/joined pq errors (e.g.,fmt.Errorf("...: %w", err)orerrors.Join). Since this file already useserrors.Asfor pgx errors, consider switching the lib/pq branch to also useerrors.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.
| Satoshis: Tx.Inputs[0].PreviousTxSatoshis, | ||
| })) | ||
| tx.Inputs[0].UnlockingScript = dummyUnlockingScript | ||
| require.NoError(t, tx.PayToAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", satoshis)) | ||
| return tx |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Already fixed in the previous commit (line 39 now uses require.NoError). This comment appears to have been generated against the prior revision.
| 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) | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
| // 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) |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
| mock.ExpectBegin() | ||
| mock.ExpectRollback() | ||
|
|
||
| // Wait for context to be cancelled | ||
| time.Sleep(2 * time.Millisecond) | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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."` |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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[:] | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
| // 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) |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
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>
|


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
CreateInUtxoStorereturnsTxExistsErrorandGetMetasucceeds, the validator now correctly returns the metadata instead of falling through to aProcessingError. This causedblessMissingTransactionpanics during block sync.OR spending_data = v.spending_datain the WHERE clause, matching aerospike's idempotent self-spend behavior. Prevents falseUtxoSpentErrorwhen the same transaction is validated concurrently.got 70000 parameters but PostgreSQL only supports 65535.Performance
getBatcherpattern. IndividualGet()calls during block validation are batched into bulkWHERE INqueries viaBatchDecorate, reducing N×4 sequential queries to 4 queries per batch. Prevents connection pool exhaustion that causedcontext deadline exceedederrors and panics during mainnet catchup.information_schema.table_constraints(slow view joining multiple catalog tables) with directpg_constraint/pg_attributelookups (indexed, instant). On teratestnet with 38M rows, the old queries blocked for 4+ minutes holding locks.Stability
ALTER TABLE ADD CONSTRAINT FOREIGN KEYrequires 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 haveON DELETE CASCADE.Test plan
go test -race ./stores/utxo/sql/...— all SQL store tests passgo test -race ./services/validator/...— validator tests passgo test -race ./services/subtreevalidation/...— subtree validation tests pass🤖 Generated with Claude Code