Skip to content

Commit 66c5e48

Browse files
committed
fix(server): back off, retry, and de-race the EVM indexer fetch loop
This PR set out to adopt cosmos/evm's EVMIndexerService for its wait-before-retry on Block/BlockResults fetch errors; review (phenix) caught that upstream's version (still on their main) latches the fetch error and never clears it — after the first transient failure the loop only sleeps and indexing stalls until restart. The old in-tree copy had the opposite defect: it busy-looped the failing fetch with no backoff. Writing the regression test also surfaced a third defect present in both: the latest-height variable is written by the new-block subscription goroutine and read by the indexing loop unsynchronized (go test -race fails on both prior versions). Keep the service in-tree on upstream's structure with all three fixed: back off on fetch errors (upstream's improvement), clear the error before retrying (the missing line), and make latestBlock an atomic. The regression test drives the loop with a scripted RPC client and fails on each historical defect: it times out on the latched-stall version, exceeds the bounded fetch-attempt count on the busy-loop version, and trips -race on the unsynchronized version. Signed-off-by: puneetmahajan <59960662+puneet2019@users.noreply.github.com>
1 parent 0c916f9 commit 66c5e48

3 files changed

Lines changed: 170 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
7070

7171
### Bug Fixes
7272

73+
- (server) [#311](https://github.com/mocachain/moca/pull/311) Harden the EVM tx indexer service's fetch loop: back off on transient `Block`/`BlockResults` fetch errors instead of busy-looping, clear the error before retrying so indexing cannot stall until restart (upstream cosmos/evm as of v0.6.0 latches it), and make the shared latest-height access atomic (data race between the new-block subscription goroutine and the indexing loop). Guarded by a mock-driven regression test that fails on all three defects.
7374
- (virtualgroup,storage) [#306](https://github.com/mocachain/moca/pull/306) Tighten storage provider exit preconditions and make discontinued-resource cleanup resolve its primary SP defensively.
7475
- (storage) [#298](https://github.com/mocachain/moca/pull/298) Close the object iterator in `isNonEmptyBucket` to fix a per-call store-iterator leak (MOCA-413)
7576
- (sp) [#299](https://github.com/mocachain/moca/pull/299) Close the iterator in `GetAllStorageProviders` to fix a store-iterator leak

server/indexer_service.go

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package server
22

33
import (
44
"context"
5+
"sync/atomic"
56
"time"
67

78
"github.com/cometbft/cometbft/libs/service"
89
rpcclient "github.com/cometbft/cometbft/rpc/client"
10+
coretypes "github.com/cometbft/cometbft/rpc/core/types"
911
"github.com/cometbft/cometbft/types"
12+
1013
servertypes "github.com/cosmos/evm/server/types"
1114
)
1215

@@ -42,7 +45,11 @@ func (eis *EVMIndexerService) OnStart() error {
4245
if err != nil {
4346
return err
4447
}
45-
latestBlock := status.SyncInfo.LatestBlockHeight
48+
// latestBlock is written by the new-block subscription goroutine and read
49+
// by the indexing loop below — keep the access atomic (data race in the
50+
// pre-fix copy and in upstream cosmos/evm as of v0.6.0).
51+
var latestBlock atomic.Int64
52+
latestBlock.Store(status.SyncInfo.LatestBlockHeight)
4653
newBlockSignal := make(chan struct{}, 1)
4754

4855
// Use SubscribeUnbuffered here to ensure both subscriptions does not get
@@ -61,8 +68,8 @@ func (eis *EVMIndexerService) OnStart() error {
6168
for {
6269
msg := <-blockHeadersChan
6370
eventDataHeader := msg.Data.(types.EventDataNewBlockHeader)
64-
if eventDataHeader.Header.Height > latestBlock {
65-
latestBlock = eventDataHeader.Header.Height
71+
if eventDataHeader.Header.Height > latestBlock.Load() {
72+
latestBlock.Store(eventDataHeader.Header.Height)
6673
// notify
6774
select {
6875
case newBlockSignal <- struct{}{}:
@@ -77,26 +84,45 @@ func (eis *EVMIndexerService) OnStart() error {
7784
return err
7885
}
7986
if lastBlock == -1 {
80-
lastBlock = latestBlock
87+
lastBlock = latestBlock.Load()
8188
}
89+
90+
// blockErr indicates an error fetching an expected block or its results
91+
var blockErr error
92+
8293
for {
83-
if latestBlock <= lastBlock {
84-
// nothing to index. wait for signal of new block
94+
if latestBlock.Load() <= lastBlock || blockErr != nil {
95+
// two cases to enter this block:
96+
// 1. nothing to index (indexer is caught up). wait for signal of new block.
97+
// 2. previous attempt to index errored (failed to fetch the Block or BlockResults).
98+
// in this case, wait before retrying the data fetching, rather than infinite looping
99+
// a failing fetch. this can occur due to drive latency between the block existing and its
100+
// block_results getting saved.
85101
select {
86102
case <-newBlockSignal:
87103
case <-time.After(NewBlockWaitTimeout):
88104
}
105+
// Clear the latched fetch error so the loop actually re-attempts
106+
// the fetch after backing off. Without this (upstream cosmos/evm
107+
// as of v0.6.0), the first transient Block/BlockResults failure
108+
// gates the loop forever and indexing stalls until restart.
109+
blockErr = nil
89110
continue
90111
}
91-
for i := lastBlock + 1; i <= latestBlock; i++ {
92-
block, err := eis.client.Block(ctx, &i)
93-
if err != nil {
94-
eis.Logger.Error("failed to fetch block", "height", i, "err", err)
112+
for i := lastBlock + 1; i <= latestBlock.Load(); i++ {
113+
var (
114+
block *coretypes.ResultBlock
115+
blockResult *coretypes.ResultBlockResults
116+
)
117+
118+
block, blockErr = eis.client.Block(ctx, &i)
119+
if blockErr != nil {
120+
eis.Logger.Error("failed to fetch block", "height", i, "err", blockErr)
95121
break
96122
}
97-
blockResult, err := eis.client.BlockResults(ctx, &i)
98-
if err != nil {
99-
eis.Logger.Error("failed to fetch block result", "height", i, "err", err)
123+
blockResult, blockErr = eis.client.BlockResults(ctx, &i)
124+
if blockErr != nil {
125+
eis.Logger.Error("failed to fetch block result", "height", i, "err", blockErr)
100126
break
101127
}
102128
if err := eis.txIdxr.IndexBlock(block.Block, blockResult.TxsResults); err != nil {

server/indexer_service_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"sync/atomic"
6+
"testing"
7+
"time"
8+
9+
abci "github.com/cometbft/cometbft/abci/types"
10+
rpcclient "github.com/cometbft/cometbft/rpc/client"
11+
coretypes "github.com/cometbft/cometbft/rpc/core/types"
12+
cmttypes "github.com/cometbft/cometbft/types"
13+
servertypes "github.com/cosmos/evm/server/types"
14+
"github.com/ethereum/go-ethereum/common"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// mockRPCClient scripts the only four rpcclient.Client methods the indexer
19+
// service uses. BlockResults fails until failuresLeft drains, tracking the
20+
// attempt count — the seam for both indexer-loop regressions.
21+
type mockRPCClient struct {
22+
rpcclient.Client // nil-embedded: any unscripted call panics loudly
23+
24+
height int64
25+
headerCh chan coretypes.ResultEvent
26+
failuresLeft atomic.Int64
27+
attempts atomic.Int64
28+
}
29+
30+
func (m *mockRPCClient) Status(context.Context) (*coretypes.ResultStatus, error) {
31+
return &coretypes.ResultStatus{
32+
SyncInfo: coretypes.SyncInfo{LatestBlockHeight: m.height},
33+
}, nil
34+
}
35+
36+
func (m *mockRPCClient) Subscribe(_ context.Context, _, _ string, _ ...int) (<-chan coretypes.ResultEvent, error) {
37+
return m.headerCh, nil
38+
}
39+
40+
func (m *mockRPCClient) Block(_ context.Context, height *int64) (*coretypes.ResultBlock, error) {
41+
return &coretypes.ResultBlock{
42+
Block: &cmttypes.Block{Header: cmttypes.Header{Height: *height}},
43+
}, nil
44+
}
45+
46+
func (m *mockRPCClient) BlockResults(_ context.Context, height *int64) (*coretypes.ResultBlockResults, error) {
47+
m.attempts.Add(1)
48+
if m.failuresLeft.Add(-1) >= 0 {
49+
return nil, context.DeadlineExceeded
50+
}
51+
return &coretypes.ResultBlockResults{Height: *height}, nil
52+
}
53+
54+
// mockIndexer records indexed heights; the query methods are never used by
55+
// the service loop.
56+
type mockIndexer struct {
57+
indexed chan int64
58+
}
59+
60+
func (m *mockIndexer) LastIndexedBlock() (int64, error) { return 0, nil }
61+
func (m *mockIndexer) FirstIndexedBlock() (int64, error) { return 0, nil }
62+
func (m *mockIndexer) IndexBlock(block *cmttypes.Block, _ []*abci.ExecTxResult) error {
63+
m.indexed <- block.Height
64+
return nil
65+
}
66+
67+
func (m *mockIndexer) GetByTxHash(common.Hash) (*servertypes.TxResult, error) {
68+
panic("not used")
69+
}
70+
71+
func (m *mockIndexer) GetByBlockAndIndex(int64, int32) (*servertypes.TxResult, error) {
72+
panic("not used")
73+
}
74+
75+
// TestIndexerServiceRetriesAfterFetchError guards both historical failure
76+
// modes of the indexing loop around transient Block/BlockResults errors:
77+
//
78+
// - stall (upstream cosmos/evm as of v0.6.0): the fetch error is latched and
79+
// never cleared, so after the first failure the loop only sleeps and the
80+
// indexer never progresses again — this test times out on that code;
81+
// - busy-loop (moca's pre-fix copy): the loop hammered the failing fetch
82+
// with no backoff — the bounded attempt count below fails on that code.
83+
//
84+
// Correct behavior: back off on error, then re-attempt on the next new-block
85+
// signal (or timeout) and recover.
86+
func TestIndexerServiceRetriesAfterFetchError(t *testing.T) {
87+
client := &mockRPCClient{
88+
height: 1,
89+
headerCh: make(chan coretypes.ResultEvent, 4),
90+
}
91+
client.failuresLeft.Store(2) // first two BlockResults calls fail, then succeed
92+
idxr := &mockIndexer{indexed: make(chan int64, 8)}
93+
94+
svc := NewEVMIndexerService(idxr, client)
95+
go func() {
96+
_ = svc.OnStart() // loops forever; test asserts via channels
97+
}()
98+
99+
signal := func(height int64) {
100+
client.headerCh <- coretypes.ResultEvent{
101+
Data: cmttypes.EventDataNewBlockHeader{
102+
Header: cmttypes.Header{Height: height},
103+
},
104+
}
105+
}
106+
107+
// Attempt 1 fails on startup catch-up (height 1). Each signal wakes the
108+
// wait branch: attempt 2 fails, attempt 3 succeeds and must recover.
109+
signal(2)
110+
signal(3)
111+
112+
deadline := time.After(10 * time.Second)
113+
got := map[int64]bool{}
114+
for len(got) < 2 {
115+
select {
116+
case h := <-idxr.indexed:
117+
got[h] = true
118+
case <-deadline:
119+
t.Fatalf("indexer stalled after transient fetch error; indexed so far: %v (attempts=%d)",
120+
got, client.attempts.Load())
121+
}
122+
}
123+
require.True(t, got[1] && got[2], "heights 1 and 2 must be indexed after recovery, got %v", got)
124+
125+
// No busy-loop: while erroring, exactly one fetch attempt per wake-up.
126+
// Catch-up + two signaled retries + the follow-up block ≈ 4 attempts;
127+
// leave slack for scheduling, but orders of magnitude below a busy loop.
128+
require.LessOrEqual(t, client.attempts.Load(), int64(6),
129+
"fetch attempts should be bounded by wake-ups (busy-loop regression)")
130+
}

0 commit comments

Comments
 (0)