Repository: ethereum/go-ethereum
Pull request #31680: core/filtermaps: clone cached slices, fix tempRange
Author: login: zsfelfoldi
Created: 2025-04-20T08:21:32Z | Merged: 2025-04-21T07:27:24Z

This PR ensures that caching a slice or a slice of slices will never affect the original version by always cloning a slice fetched from cache if it is not used in a guaranteed read only way.


Diff:
diff --git a/core/filtermaps/filtermaps.go b/core/filtermaps/filtermaps.go
index a617de8968f7..920167ca8d59 100644
--- a/core/filtermaps/filtermaps.go
+++ b/core/filtermaps/filtermaps.go
@@ -128,6 +128,7 @@ type FilterMaps struct {
 
 	// test hooks
 	testDisableSnapshots, testSnapshotUsed bool
+	testProcessEventsHook                  func()
 }
 
 // filterMap is a full or partial in-memory representation of a filter map where
@@ -573,7 +574,7 @@ func (f *FilterMaps) getFilterMapRow(mapIndex, rowIndex uint32, baseLayerOnly bo
 		}
 		f.baseRowsCache.Add(baseMapRowIndex, baseRows)
 	}
-	baseRow := baseRows[mapIndex&(f.baseRowGroupLength-1)]
+	baseRow := slices.Clone(baseRows[mapIndex&(f.baseRowGroupLength-1)])
 	if baseLayerOnly {
 		return baseRow, nil
 	}
@@ -610,7 +611,9 @@ func (f *FilterMaps) storeFilterMapRowsOfGroup(batch ethdb.Batch, mapIndices []u
 	if uint32(len(mapIndices)) != f.baseRowGroupLength { // skip base rows read if all rows are replaced
 		var ok bool
 		baseRows, ok = f.baseRowsCache.Get(baseMapRowIndex)
-		if !ok {
+		if ok {
+			baseRows = slices.Clone(baseRows)
+		} else {
 			var err error
 			baseRows, err = rawdb.ReadFilterMapBaseRows(f.db, baseMapRowIndex, f.baseRowGroupLength, f.logMapWidth)
 			if err != nil {
@@ -656,7 +659,7 @@ func (f *FilterMaps) mapRowIndex(mapIndex, rowIndex uint32) uint64 {
 // called from outside the indexerLoop goroutine.
 func (f *FilterMaps) getBlockLvPointer(blockNumber uint64) (uint64, error) {
 	if blockNumber >= f.indexedRange.blocks.AfterLast() && f.indexedRange.headIndexed {
-		return f.indexedRange.headDelimiter, nil
+		return f.indexedRange.headDelimiter + 1, nil
 	}
 	if lvPointer, ok := f.lvPointerCache.Get(blockNumber); ok {
 		return lvPointer, nil
diff --git a/core/filtermaps/indexer.go b/core/filtermaps/indexer.go
index 383ec078c965..787197345a09 100644
--- a/core/filtermaps/indexer.go
+++ b/core/filtermaps/indexer.go
@@ -165,6 +165,9 @@ func (f *FilterMaps) waitForNewHead() {
 // processEvents processes all events, blocking only if a block processing is
 // happening and indexing should be suspended.
 func (f *FilterMaps) processEvents() {
+	if f.testProcessEventsHook != nil {
+		f.testProcessEventsHook()
+	}
 	for f.processSingleEvent(f.blockProcessing) {
 	}
 }
diff --git a/core/filtermaps/indexer_test.go b/core/filtermaps/indexer_test.go
index 4dddd27087e0..e60130ba4bd5 100644
--- a/core/filtermaps/indexer_test.go
+++ b/core/filtermaps/indexer_test.go
@@ -219,6 +219,58 @@ func testIndexerMatcherView(t *testing.T, concurrentRead bool) {
 	}
 }
 
+func TestLogsByIndex(t *testing.T) {
+	ts := newTestSetup(t)
+	defer func() {
+		ts.fm.testProcessEventsHook = nil
+		ts.close()
+	}()
+
+	ts.chain.addBlocks(1000, 10, 3, 4, true)
+	ts.setHistory(0, false)
+	ts.fm.WaitIdle()
+	firstLog := make([]uint64, 1001) // first valid log position per block
+	lastLog := make([]uint64, 1001)  // last valid log position per block
+	for i := uint64(0); i <= ts.fm.indexedRange.headDelimiter; i++ {
+		log, err := ts.fm.getLogByLvIndex(i)
+		if err != nil {
+			t.Fatalf("Error getting log by index %d: %v", i, err)
+		}
+		if log != nil {
+			if firstLog[log.BlockNumber] == 0 {
+				firstLog[log.BlockNumber] = i
+			}
+			lastLog[log.BlockNumber] = i
+		}
+	}
+	var failed bool
+	ts.fm.testProcessEventsHook = func() {
+		if ts.fm.indexedRange.blocks.IsEmpty() {
+			return
+		}
+		if lvi := firstLog[ts.fm.indexedRange.blocks.First()]; lvi != 0 {
+			log, err := ts.fm.getLogByLvIndex(lvi)
+			if log == nil || err != nil {
+				t.Errorf("Error getting first log of indexed block range: %v", err)
+				failed = true
+			}
+		}
+		if lvi := lastLog[ts.fm.indexedRange.blocks.Last()]; lvi != 0 {
+			log, err := ts.fm.getLogByLvIndex(lvi)
+			if log == nil || err != nil {
+				t.Errorf("Error getting last log of indexed block range: %v", err)
+				failed = true
+			}
+		}
+	}
+	chain := ts.chain.getCanonicalChain()
+	for i := 0; i < 1000 && !failed; i++ {
+		head := rand.Intn(len(chain))
+		ts.chain.setCanonicalChain(chain[:head+1])
+		ts.fm.WaitIdle()
+	}
+}
+
 func TestIndexerCompareDb(t *testing.T) {
 	ts := newTestSetup(t)
 	defer ts.close()
diff --git a/core/filtermaps/map_renderer.go b/core/filtermaps/map_renderer.go
index 7c2aa8dc32cd..f59a01c032ae 100644
--- a/core/filtermaps/map_renderer.go
+++ b/core/filtermaps/map_renderer.go
@@ -20,6 +20,7 @@ import (
 	"errors"
 	"fmt"
 	"math"
+	"slices"
 	"sort"
 	"time"
 
@@ -107,7 +108,7 @@ func (f *FilterMaps) renderMapsFromSnapshot(cp *renderedMap) (*mapRenderer, erro
 			filterMap:   cp.filterMap.fullCopy(),
 			mapIndex:    cp.mapIndex,
 			lastBlock:   cp.lastBlock,
-			blockLvPtrs: cp.blockLvPtrs,
+			blockLvPtrs: slices.Clone(cp.blockLvPtrs),
 		},
 		finishedMaps: make(map[uint32]*renderedMap),
 		finished:     common.NewRange(cp.mapIndex, 0),
@@ -244,7 +245,7 @@ func (f *FilterMaps) loadHeadSnapshot() error {
 		}
 	}
 	f.renderSnapshots.Add(f.indexedRange.blocks.Last(), &renderedMap{
-		filterMap:     fm,
+		filterMap:     fm.fullCopy(),
 		mapIndex:      f.indexedRange.maps.Last(),
 		lastBlock:     f.indexedRange.blocks.Last(),
 		lastBlockId:   f.indexedView.BlockId(f.indexedRange.blocks.Last()),
@@ -536,6 +537,7 @@ func (r *mapRenderer) getTempRange() (filterMapsRange, error) {
 		} else {
 			tempRange.blocks.SetAfterLast(0)
 		}
+		tempRange.headIndexed = false
 		tempRange.headDelimiter = 0
 	}
 	return tempRange, nil


Reviews:
user: login: rjl493456442
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-20T14:24:15Z
user: login: rjl493456442
body: LGTM
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-20T14:24:57Z
user: login: zsfelfoldi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-20T22:59:22Z
user: login: rjl493456442
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T03:28:24Z
user: login: rjl493456442
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T06:15:34Z
user: login: zsfelfoldi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T06:19:05Z
user: login: zsfelfoldi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T06:32:02Z
user: login: rjl493456442
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-21T07:27:04Z

Review comments:
diff_hunk: @@ -610,7 +610,9 @@ func (f *FilterMaps) storeFilterMapRowsOfGroup(batch ethdb.Batch, mapIndices []u
 	if uint32(len(mapIndices)) != f.baseRowGroupLength { // skip base rows read if all rows are replaced
 		var ok bool
 		baseRows, ok = f.baseRowsCache.Get(baseMapRowIndex)
-		if !ok {
+		if ok {
path: core/filtermaps/filtermaps.go
user: login: rjl493456442
body: Should we also clone the slice in `getFilterMapRow`?
created_at: 2025-04-20T14:24:14Z
author_association: MEMBER
diff_hunk: @@ -610,7 +610,9 @@ func (f *FilterMaps) storeFilterMapRowsOfGroup(batch ethdb.Batch, mapIndices []u
 	if uint32(len(mapIndices)) != f.baseRowGroupLength { // skip base rows read if all rows are replaced
 		var ok bool
 		baseRows, ok = f.baseRowsCache.Get(baseMapRowIndex)
-		if !ok {
+		if ok {
path: core/filtermaps/filtermaps.go
user: login: zsfelfoldi
body: We do clone `baseRow` and if required then we append `extRow` which is always retrieved from the database so it's guaranteed that the returned result does not share an underlying array with anything.
created_at: 2025-04-20T22:59:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -656,7 +659,7 @@ func (f *FilterMaps) mapRowIndex(mapIndex, rowIndex uint32) uint64 {
 // called from outside the indexerLoop goroutine.
 func (f *FilterMaps) getBlockLvPointer(blockNumber uint64) (uint64, error) {
 	if blockNumber >= f.indexedRange.blocks.AfterLast() && f.indexedRange.headIndexed {
-		return f.indexedRange.headDelimiter, nil
+		return f.indexedRange.headDelimiter + 1, nil
path: core/filtermaps/filtermaps.go
user: login: rjl493456442
body: Should we error out if blockNumber > f.indexedRange.blocks.AfterLast()?

Theoretically, the returned log index is for the next block and it must be consecutive with the latest indexed one.
created_at: 2025-04-21T03:28:24Z
author_association: MEMBER
diff_hunk: @@ -536,6 +536,7 @@ func (r *mapRenderer) getTempRange() (filterMapsRange, error) {
 		} else {
 			tempRange.blocks.SetAfterLast(0)
 		}
+		tempRange.headIndexed = false
path: core/filtermaps/map_renderer.go
user: login: rjl493456442
body: is it the real root cause?
created_at: 2025-04-21T06:15:34Z
author_association: MEMBER
diff_hunk: @@ -536,6 +536,7 @@ func (r *mapRenderer) getTempRange() (filterMapsRange, error) {
 		} else {
 			tempRange.blocks.SetAfterLast(0)
 		}
+		tempRange.headIndexed = false
path: core/filtermaps/map_renderer.go
user: login: zsfelfoldi
body: yes
created_at: 2025-04-21T06:19:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -656,7 +659,7 @@ func (f *FilterMaps) mapRowIndex(mapIndex, rowIndex uint32) uint64 {
 // called from outside the indexerLoop goroutine.
 func (f *FilterMaps) getBlockLvPointer(blockNumber uint64) (uint64, error) {
 	if blockNumber >= f.indexedRange.blocks.AfterLast() && f.indexedRange.headIndexed {
-		return f.indexedRange.headDelimiter, nil
+		return f.indexedRange.headDelimiter + 1, nil
path: core/filtermaps/filtermaps.go
user: login: zsfelfoldi
body: Since the search works async (range might change any time during the process) the underlying functions of the matcher backend do not fail when they are queried out of range, they just return results so that the search will not find any additional items (the head part of the results will be invalidated anyways in this case). `getLogByLvIndex` also silently returns with no result end no errors.
`getBlockLvPointer` also just returns the beginning of the next future block, even if queried further in the future, so that the search will just search until the end of the indexed range. The point is that if it's a long search then the majority of results will still be valuable and the head part will be searched again anyways so just do something that lets the operation finish safely.
We could have a different mechanism but this seems to work safely for now and we have to do the fork release today so I would not change it fundamentally right now.
created_at: 2025-04-21T06:32:02Z
author_association: CONTRIBUTOR

PR comments:
user: login: zsfelfoldi
created_at: 2025-04-20T23:22:13Z
body: The slice cloning, though definitely necessary to be safe, did not solve the issue so I added a lot of debug prints and finally conclusively traced the error back to its origin. When I add a new block and overwrite the last map with a new rendered one, I temporarily use `tempRange` as the indexed range which excludes the last map that can be inconsistent during the write (unfortunately it can take too long to make it atomic). During this time, even if the matcher did find some potential matches, `getLogByLvIndex` will not return results for this temporarily invalidated map (and even if it did, the filter logic would trim them as potentially invalid). This is fine, but what I noticed in my debug prints is that the logs of the last fully rendered block in the previous, untouched map are also unavailable during this period, and the mismatches exactly correspond with these blocks.
I added a unit test that reproduces this error by trying to fetch the first and last valid logs of the temporary range from the map renderer callback. It always and easily reproduced the error which traced back to `tempRange` being invalid. `headDelimiter` was set to `0` as the indexed view's head is not indexed in this temporary state, but `headIndexed` was still `true`. This was an inconsistent state which caused `getBlockLvPointer` to return `0` for the last, partially indexed block which caused the binary search if `getLogByLvIndex` to find the wrong block and not find the logs at the requested position.
I believe the cause of this issue has been conclusively determined now, and it was a different issue from the previously fixed one related to the render snapshots. That one caused indexing errors and permanent incorrect results while this one only affected the search results and only happened once if the search coincided with the map write.
author_association: CONTRIBUTOR
user: login: zsfelfoldi
created_at: 2025-04-20T23:27:26Z
body: I also changed `getBlockLvPointer` to return `headDelimiter + 1` instead of `headDelimiter` for future blocks if the head is rendered; this difference currently does not affect the operation at all but `headDelimiter + 1` is the position where the next block will start once added so this is more logically consistent.
author_association: CONTRIBUTOR