Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion services/blockassembly/Server.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,8 +659,16 @@ func (ba *BlockAssembly) storeSubtreeData(ctx context.Context, subtreeRequest su
for idx, node := range subtreeRequest.Subtree.Nodes {
if !node.Hash.Equal(subtreepkg.CoinbasePlaceholderHashValue) {
txInpoints, found := subtreeRequest.ParentTxMap.Get(node.Hash)
if !found && subtreeRequest.DeletedTxs != nil {
// Fallback: check if transaction was deleted during async storage
var deletedTxInpoints subtreepkg.TxInpoints
deletedTxInpoints, found = subtreeRequest.DeletedTxs.Get(node.Hash)
if found {
txInpoints = &deletedTxInpoints
}
}
if !found {
ba.logger.Errorf("[BlockAssembly:storeSubtreeData][%s] failed to find parent tx hashes for node %s: parent transaction not found in ParentTxMap", subtreeRequest.Subtree.RootHash().String(), node.Hash.String())
ba.logger.Errorf("[BlockAssembly:storeSubtreeData][%s] failed to find parent tx hashes for node %s: parent transaction not found in ParentTxMap or DeletedTxs", subtreeRequest.Subtree.RootHash().String(), node.Hash.String())
return
}

Expand Down Expand Up @@ -733,6 +741,10 @@ func (ba *BlockAssembly) storeSubtreeData(ctx context.Context, subtreeRequest su
defer close(allDoneCh)
<-subtreeStorageDone
<-metaDoneCh
// Trigger cleanup of soft-deleted transactions
if subtreeRequest.OnStorageComplete != nil {
subtreeRequest.OnStorageComplete()
}
Comment on lines +744 to +747
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

OnStorageComplete is only invoked by the coordinator goroutine after subtree/meta workers complete. If storeSubtreeData returns early (e.g., subtree already exists / setup error before goroutines start), the callback never runs, which can leave deletedTxs entries around longer than intended. Consider ensuring OnStorageComplete is invoked (or explicitly skipped) on all return paths where the subtree is already stored or storage is aborted, so cleanup semantics are predictable.

Copilot uses AI. Check for mistakes.
}()

return subtreeDoneCh, allDoneCh, nil
Expand Down
128 changes: 111 additions & 17 deletions services/blockassembly/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/bsv-blockchain/go-bt/v2/chainhash"
subtreepkg "github.com/bsv-blockchain/go-subtree"
txmap "github.com/bsv-blockchain/go-tx-map"
"github.com/bsv-blockchain/teranode/errors"
"github.com/bsv-blockchain/teranode/model"
"github.com/bsv-blockchain/teranode/pkg/fileformat"
Expand All @@ -31,9 +32,11 @@ func Test_storeSubtreeData(t *testing.T) {
subtreeRetryChan := make(chan *subtreeRetrySend, 1_000)

subtreeDone, allDone, err := server.storeSubtreeData(t.Context(), subtreeprocessor.NewSubtreeRequest{
Subtree: subtree,
ParentTxMap: txMap,
ErrChan: nil,
Subtree: subtree,
ParentTxMap: txMap,
DeletedTxs: nil,
ErrChan: nil,
OnStorageComplete: nil,
}, subtreeRetryChan)
require.NoError(t, err)

Expand Down Expand Up @@ -83,9 +86,11 @@ func Test_storeSubtreeData(t *testing.T) {
subtreeRetryChan := make(chan *subtreeRetrySend, 1_000)

subtreeDone, allDone, err := server.storeSubtreeData(t.Context(), subtreeprocessor.NewSubtreeRequest{
Subtree: subtree,
ParentTxMap: txMap,
ErrChan: nil,
Subtree: subtree,
ParentTxMap: txMap,
DeletedTxs: nil,
ErrChan: nil,
OnStorageComplete: nil,
}, subtreeRetryChan)
require.NoError(t, err)

Expand Down Expand Up @@ -924,10 +929,12 @@ func TestStoreSubtreeDataIntensive(t *testing.T) {
subtreeRetryChan := make(chan *subtreeRetrySend, 1000)

subtreeDone, allDone, err := server.storeSubtreeData(context.Background(), subtreeprocessor.NewSubtreeRequest{
Subtree: subtree,
ParentTxMap: txMap,
SkipNotification: true,
ErrChan: nil,
Subtree: subtree,
ParentTxMap: txMap,
DeletedTxs: nil,
SkipNotification: true,
ErrChan: nil,
OnStorageComplete: nil,
}, subtreeRetryChan)

assert.NoError(t, err)
Expand All @@ -941,7 +948,7 @@ func TestStoreSubtreeDataIntensive(t *testing.T) {
assert.NotNil(t, subtreeBytes)
})

t.Run("storeSubtreeData with store already exists", func(t *testing.T) {
t.Run("storeSubtreeData already exists", func(t *testing.T) {
server, subtreeStore, subtree, txMap := setup(t)

// Pre-store the subtree to trigger "already exists" path
Expand All @@ -953,9 +960,11 @@ func TestStoreSubtreeDataIntensive(t *testing.T) {
subtreeRetryChan := make(chan *subtreeRetrySend, 1000)

_, _, err = server.storeSubtreeData(context.Background(), subtreeprocessor.NewSubtreeRequest{
Subtree: subtree,
ParentTxMap: txMap,
ErrChan: nil,
Subtree: subtree,
ParentTxMap: txMap,
DeletedTxs: nil,
ErrChan: nil,
OnStorageComplete: nil,
}, subtreeRetryChan)

// Should return ErrBlobAlreadyExists
Expand All @@ -969,9 +978,11 @@ func TestStoreSubtreeDataIntensive(t *testing.T) {

// Test with valid scenario - this just ensures the path is covered
subtreeDone, allDone, err := server.storeSubtreeData(context.Background(), subtreeprocessor.NewSubtreeRequest{
Subtree: subtree,
ParentTxMap: txMap,
ErrChan: nil,
Subtree: subtree,
ParentTxMap: txMap,
DeletedTxs: nil,
ErrChan: nil,
OnStorageComplete: nil,
}, subtreeRetryChan)

// Should succeed in most cases
Expand Down Expand Up @@ -1029,6 +1040,89 @@ func TestSendSubtreeNotification(t *testing.T) {
})
}

// TestStoreSubtree_RaceConditionFix tests that the deletedTxs backup map prevents
// race condition errors when transactions are deleted during async subtree storage.
// This test would FAIL without the fix (missing DeletedTxs fallback in Server.go).
func TestStoreSubtree_RaceConditionFix(t *testing.T) {
t.Run("deletedTxs fallback prevents serialization errors", func(t *testing.T) {
server, subtreeStore, subtree, txMap := setup(t)
subtreeRetryChan := make(chan *subtreeRetrySend, 1000)

// Start with a full txMap (from setup) then simulate race condition
deletedTxsMap := txmap.NewSyncedMap[chainhash.Hash, subtreepkg.TxInpoints]()

// Simulate race: One transaction is deleted from ParentTxMap during async storage
// but saved to deletedTxsMap (this is what the fix does)
tx1Hash := subtree.Nodes[1].Hash // Use second node (first is coinbase)
tx1Inpoints, found := txMap.Get(tx1Hash)
require.True(t, found, "transaction should exist in txMap from setup")

// Move transaction from ParentTxMap to DeletedTxs (simulating deletion during storage)
deletedTxsMap.Set(tx1Hash, *tx1Inpoints)
txMap.Delete(tx1Hash)

// Now tx1 is NOT in ParentTxMap but IS in DeletedTxs
// Without the fix, this would fail serialization
// With the fix, Server falls back to DeletedTxs and succeeds

subtreeDone, allDone, err := server.storeSubtreeData(t.Context(), subtreeprocessor.NewSubtreeRequest{
Subtree: subtree,
ParentTxMap: txMap, // Missing tx1!
DeletedTxs: deletedTxsMap, // Has tx1 as backup
ErrChan: nil,
OnStorageComplete: nil,
}, subtreeRetryChan)

// Should succeed because Server falls back to DeletedTxs
require.NoError(t, err)
storedOK := <-subtreeDone
require.True(t, storedOK)
<-allDone

// Verify subtree was stored
subtreeBytes, err := subtreeStore.Get(t.Context(), subtree.RootHash()[:], fileformat.FileTypeSubtree)
require.NoError(t, err)
require.NotNil(t, subtreeBytes)

// Verify subtree meta was created successfully (using DeletedTxs fallback)
subtreeMetaBytes, err := subtreeStore.Get(t.Context(), subtree.RootHash()[:], fileformat.FileTypeSubtreeMeta)
require.NoError(t, err)
require.NotNil(t, subtreeMetaBytes)
})

t.Run("without DeletedTxs fallback would fail", func(t *testing.T) {
server, subtreeStore, subtree, _ := setup(t)
subtreeRetryChan := make(chan *subtreeRetrySend, 1000)

// Create map without the deleted transaction
parentTxMap := subtreeprocessor.NewSplitTxInpointsMap(256)
// Don't add tx1 to parentTxMap - simulating it was deleted

subtreeDone, allDone, err := server.storeSubtreeData(t.Context(), subtreeprocessor.NewSubtreeRequest{
Subtree: subtree,
ParentTxMap: parentTxMap, // Missing tx1
DeletedTxs: nil, // No backup!
ErrChan: nil,
OnStorageComplete: nil,
}, subtreeRetryChan)

// Should succeed but subtree meta won't be created (missing parent info)
require.NoError(t, err)
storedOK := <-subtreeDone
require.True(t, storedOK)
<-allDone

// Subtree data should still be stored
subtreeBytes, err := subtreeStore.Get(t.Context(), subtree.RootHash()[:], fileformat.FileTypeSubtree)
require.NoError(t, err)
require.NotNil(t, subtreeBytes)

// But subtree meta should NOT exist (couldn't create without parent info)
_, err = subtreeStore.Get(t.Context(), subtree.RootHash()[:], fileformat.FileTypeSubtreeMeta)
require.Error(t, err) // Expect error - meta wasn't created
})
}

// TestStartStopIntensive tests the Start and Stop methods comprehensively
func TestStartStopIntensive(t *testing.T) {
t.Skip("Skipping intensive test") // It is questionable if this test should be part of our unit tests
Expand Down
81 changes: 72 additions & 9 deletions services/blockassembly/subtreeprocessor/SubtreeProcessor.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ type Job struct {
// and the subtree processor, including an error channel for asynchronous result reporting.

type NewSubtreeRequest struct {
Subtree *subtreepkg.Subtree // The subtree to process
ParentTxMap TxInpointsMap // Map of parent transactions
SkipNotification bool // Whether to skip notification to the network
ErrChan chan error // Channel for error reporting
Subtree *subtreepkg.Subtree // The subtree to process
ParentTxMap TxInpointsMap // Map of parent transactions
DeletedTxs *txmap.SyncedMap[chainhash.Hash, subtreepkg.TxInpoints] // Backup map for deleted transactions
SkipNotification bool // Whether to skip notification to the network
ErrChan chan error // Channel for error reporting
OnStorageComplete func() // Called when storage completes to trigger cleanup
}

// moveBlockRequest represents a request to move a block in the chain.
Expand Down Expand Up @@ -252,6 +254,10 @@ type SubtreeProcessor struct {
// currentTxMap tracks transactions currently held in the subtree processor
currentTxMap TxInpointsMap

// deletedTxs stores transaction parent info for recently deleted transactions
// This provides a backup/fallback for Server when transactions are deleted during async storage
deletedTxs *txmap.SyncedMap[chainhash.Hash, subtreepkg.TxInpoints]

// removeMap tracks transactions marked for removal
removeMap txmap.TxMap

Expand Down Expand Up @@ -428,6 +434,7 @@ func NewSubtreeProcessor(_ context.Context, logger ulogger.Logger, tSettings *se
chainedSubtreeCount: atomic.Int32{},
queue: queue,
currentTxMap: NewSplitTxInpointsMap(splitMapBuckets),
deletedTxs: txmap.NewSyncedMap[chainhash.Hash, subtreepkg.TxInpoints](),
removeMap: txmap.NewSplitSwissMap(256, 16),
blockchainClient: blockchainClient,
subtreeStore: subtreeStore,
Expand Down Expand Up @@ -553,7 +560,11 @@ func (stp *SubtreeProcessor) Start(ctx context.Context) {
send := NewSubtreeRequest{
Subtree: incompleteSubtree,
ParentTxMap: stp.currentTxMap,
DeletedTxs: stp.deletedTxs,
ErrChan: make(chan error),
OnStorageComplete: func() {
stp.cleanupDeletedTxs(incompleteSubtree)
},
}

// Send announcement, respecting context cancellation
Expand Down Expand Up @@ -751,7 +762,11 @@ func (stp *SubtreeProcessor) Start(ctx context.Context) {
send := NewSubtreeRequest{
Subtree: incompleteSubtree,
ParentTxMap: stp.currentTxMap,
DeletedTxs: stp.deletedTxs,
ErrChan: make(chan error),
OnStorageComplete: func() {
stp.cleanupDeletedTxs(incompleteSubtree)
},
}

// Send announcement, but respect context cancellation
Expand Down Expand Up @@ -977,6 +992,27 @@ func (stp *SubtreeProcessor) createIncompleteSubtreeCopy() (*subtreepkg.Subtree,
return incompleteSubtree, nil
}

// cleanupDeletedTxs performs actual deletion from currentTxMap for transactions
// that were previously soft-deleted. Called after subtree storage completes.
// Only deletes if the transaction is still marked as deleted (not re-added).
//
// This function is called via the OnStorageComplete callback to safely remove
// transactions that were marked for deletion while the subtree was being stored.
//
Comment on lines +995 to +1001
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The comment for cleanupDeletedTxs says it "performs actual deletion from currentTxMap", but the function only deletes entries from stp.deletedTxs. Please update the comment to match the implementation (or adjust the implementation if currentTxMap cleanup is intended).

Copilot uses AI. Check for mistakes.
// Parameters:
// - subtree: The subtree whose soft-deleted transactions should be cleaned up
func (stp *SubtreeProcessor) cleanupDeletedTxs(subtree *subtreepkg.Subtree) {
if stp.deletedTxs == nil {
return
}
for _, node := range subtree.Nodes {
if !node.Hash.Equal(subtreepkg.CoinbasePlaceholderHashValue) {
// Remove from deletedTxs backup map (transaction data no longer needed after storage)
stp.deletedTxs.Delete(node.Hash)
}
}
}

// GetCurrentRunningState returns the current operational state of the processor.
//
// Returns:
Expand Down Expand Up @@ -1758,8 +1794,12 @@ func (stp *SubtreeProcessor) processCompleteSubtree(skipNotification bool) (err
stp.newSubtreeChan <- NewSubtreeRequest{
Subtree: oldSubtree,
ParentTxMap: stp.currentTxMap,
DeletedTxs: stp.deletedTxs,
SkipNotification: skipNotification,
ErrChan: errCh,
OnStorageComplete: func() {
stp.cleanupDeletedTxs(oldSubtree)
},
}

// wait for the writing of the subtree to complete in a separate goroutine
Expand Down Expand Up @@ -1979,7 +2019,10 @@ func (stp *SubtreeProcessor) removeTxFromSubtrees(ctx context.Context, hash chai
}

if foundIndex >= 0 {
// remove tx from the currentTxMap
// Save to deleted backup map before removing (for Server fallback during async storage)
if txInpoints, found := stp.currentTxMap.Get(hash); found {
stp.deletedTxs.Set(hash, *txInpoints)
}
stp.currentTxMap.Delete(hash)
Comment on lines +2022 to 2026
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

deletedTxs entries added here are only removed when cleanupDeletedTxs is invoked for a stored subtree containing the tx hash. If a tx is removed after its subtree was already stored (e.g., removing from chainedSubtrees during rechain), the entry can remain in deletedTxs indefinitely, causing unbounded growth over time. Consider adding a TTL/size limit for deletedTxs, or ensuring entries are removed once it's known no in-flight storage will reference them.

Copilot uses AI. Check for mistakes.

// we found the transaction in a subtree
Expand Down Expand Up @@ -2048,7 +2091,10 @@ func (stp *SubtreeProcessor) removeTxsFromSubtrees(ctx context.Context, hashes [
}

if foundIndex >= 0 {
// remove tx from the currentTxMap
// Save to deleted backup map before removing (for Server fallback during async storage)
if txInpoints, found := stp.currentTxMap.Get(hash); found {
stp.deletedTxs.Set(hash, *txInpoints)
}
stp.currentTxMap.Delete(hash)

// we found the transaction in a subtree
Expand Down Expand Up @@ -2150,13 +2196,22 @@ func (stp *SubtreeProcessor) reChainSubtrees(fromIndex int) error {
return errors.NewProcessingError("error getting txInpoints from currentTxMap for %s", node.Hash.String())
}

// Remove from currentTxMap so addNode won't skip it as a duplicate
// Save to deleted backup before removing (protects brief window during delete-and-readd)
stp.deletedTxs.Set(node.Hash, *parents)

// Delete from currentTxMap so addNode won't skip it as a duplicate
stp.currentTxMap.Delete(node.Hash)

// Immediately re-add the node
// Re-add the node (adds back to currentTxMap)
if err = stp.addNode(node, parents, true); err != nil {
// Restore to currentTxMap to avoid inconsistent state
stp.currentTxMap.Set(node.Hash, parents)
stp.deletedTxs.Delete(node.Hash)
return errors.NewProcessingError("error adding node to subtree", err)
}

// Clear from deletedTxs (transaction successfully re-added, no longer deleted)
stp.deletedTxs.Delete(node.Hash)
}
}

Expand Down Expand Up @@ -2658,7 +2713,15 @@ func (stp *SubtreeProcessor) reorgBlocks(ctx context.Context, moveBackBlocks []*
// this will also store it by the Server in the subtree store
for _, subtree := range stp.chainedSubtrees {
errCh := make(chan error)
stp.newSubtreeChan <- NewSubtreeRequest{Subtree: subtree, ParentTxMap: stp.currentTxMap, ErrChan: errCh}
stp.newSubtreeChan <- NewSubtreeRequest{
Subtree: subtree,
ParentTxMap: stp.currentTxMap,
DeletedTxs: stp.deletedTxs,
ErrChan: errCh,
OnStorageComplete: func() {
stp.cleanupDeletedTxs(subtree)
},
}

if err = <-errCh; err != nil {
return errors.NewProcessingError("[reorgBlocks] error sending subtree to newSubtreeChan", err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,10 +526,12 @@ func BenchmarkChannelSendReceive(b *testing.B) {
for i := 0; i < b.N; i++ {
errCh := make(chan error)
ch <- NewSubtreeRequest{
Subtree: subtree,
ParentTxMap: txMap,
SkipNotification: true,
ErrChan: errCh,
Subtree: subtree,
ParentTxMap: txMap,
DeletedTxs: nil,
SkipNotification: true,
ErrChan: errCh,
OnStorageComplete: nil,
}
<-errCh
}
Expand Down
Loading
Loading