Skip to content

Commit d9ebe05

Browse files
committed
fix: address PR review feedback on batch queue durability
- fail Load on datastore read errors instead of dropping txs - detach rollback WAL cleanup from drain context so it runs on shutdown - raise queue load timeout for large WAL backlogs - merge postponed-tx collection into the filter loop - document DropIncluded aliasing, SetPostponed contract, txSeen bound - add tests: executor ack retry, reconcile crash recovery, bulk-prepend rollback
1 parent dda0c60 commit d9ebe05

5 files changed

Lines changed: 150 additions & 16 deletions

File tree

block/internal/executing/executor_logic_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,61 @@ func TestPendingLimit_SkipsProduction(t *testing.T) {
122122
assert.Equal(t, h1, h2, "height should not change when production is skipped")
123123
}
124124

125+
func TestProduceBlock_RetriesFailedAckBeforeNextBlock(t *testing.T) {
126+
fx := setupTestExecutor(t, 1000)
127+
defer fx.Cancel()
128+
129+
var ackCalls int
130+
fx.Exec.SetOnBatchCommitted(func(ctx context.Context) error {
131+
ackCalls++
132+
if ackCalls <= 2 {
133+
return assert.AnError
134+
}
135+
return nil
136+
})
137+
138+
fx.MockSeq.EXPECT().GetNextBatch(mock.Anything, mock.AnythingOfType("sequencer.GetNextBatchRequest")).
139+
RunAndReturn(func(ctx context.Context, req coreseq.GetNextBatchRequest) (*coreseq.GetNextBatchResponse, error) {
140+
return &coreseq.GetNextBatchResponse{Batch: &coreseq.Batch{Transactions: nil}, Timestamp: time.Now()}, nil
141+
}).Once()
142+
fx.MockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(1), mock.AnythingOfType("time.Time"), fx.InitStateRoot).
143+
Return([]byte("root1"), nil).Once()
144+
fx.MockSeq.EXPECT().GetDAHeight().Return(uint64(0)).Once()
145+
146+
// block 1 commits, but the post-commit ack fails (non-fatal)
147+
require.NoError(t, fx.Exec.ProduceBlock(fx.Exec.ctx))
148+
assert.Equal(t, 1, ackCalls)
149+
assert.True(t, fx.Exec.pendingBatchAck.Load())
150+
151+
// the pending ack is retried before draining a new batch; if the retry
152+
// fails again block production aborts without calling GetNextBatch
153+
err := fx.Exec.ProduceBlock(fx.Exec.ctx)
154+
require.Error(t, err)
155+
assert.Equal(t, 2, ackCalls)
156+
assert.True(t, fx.Exec.pendingBatchAck.Load())
157+
158+
h, err := fx.MemStore.Height(context.Background())
159+
require.NoError(t, err)
160+
assert.Equal(t, uint64(1), h, "no block should be produced while the ack retry fails")
161+
162+
// once the retry succeeds production resumes: retry ack + post-commit ack
163+
fx.MockSeq.EXPECT().GetNextBatch(mock.Anything, mock.AnythingOfType("sequencer.GetNextBatchRequest")).
164+
RunAndReturn(func(ctx context.Context, req coreseq.GetNextBatchRequest) (*coreseq.GetNextBatchResponse, error) {
165+
return &coreseq.GetNextBatchResponse{Batch: &coreseq.Batch{Transactions: nil}, Timestamp: time.Now()}, nil
166+
}).Once()
167+
fx.MockExec.EXPECT().ExecuteTxs(mock.Anything, mock.Anything, uint64(2), mock.AnythingOfType("time.Time"), []byte("root1")).
168+
Return([]byte("root2"), nil).Once()
169+
fx.MockSeq.EXPECT().GetDAHeight().Return(uint64(0)).Once()
170+
171+
require.NoError(t, fx.Exec.ProduceBlock(fx.Exec.ctx))
172+
assert.Equal(t, 4, ackCalls)
173+
assert.False(t, fx.Exec.pendingBatchAck.Load())
174+
175+
h, err = fx.MemStore.Height(context.Background())
176+
require.NoError(t, err)
177+
assert.Equal(t, uint64(2), h)
178+
}
179+
125180
func TestExecutor_executeTxsWithRetry(t *testing.T) {
126181
t.Parallel()
127182

pkg/sequencers/single/queue.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"strconv"
1010
"sync"
11+
"time"
1112

1213
ds "github.com/ipfs/go-datastore"
1314
"github.com/ipfs/go-datastore/query"
@@ -56,6 +57,8 @@ type BatchQueue struct {
5657
// txSeen is an in-memory dedup set keyed by sha256 hash of each tx.
5758
// hashes are added in AddBatch and removed on successful Ack.
5859
// prevents the reaper from enqueuing the same tx multiple scrape cycles.
60+
// its size tracks queued + in-flight txs, so it is bounded indirectly
61+
// by maxQueueSize (unbounded when maxQueueSize is 0).
5962
txSeen map[[32]byte]struct{}
6063

6164
// totalEnqueued counts batches ever enqueued via AddBatch. Monotonic,
@@ -134,7 +137,7 @@ func (bq *BatchQueue) AddBatch(ctx context.Context, batch coresequencer.Batch) e
134137
key := seqToKey(bq.nextAddSeq)
135138
if err := bq.persistBatch(ctx, batch, key); err != nil {
136139
bq.rollbackSeenLocked(hashes)
137-
return err
140+
return fmt.Errorf("failed to persist batch %s to WAL: %w", key, err)
138141
}
139142
bq.nextAddSeq++
140143

@@ -198,8 +201,11 @@ func (bq *BatchQueue) Drain(ctx context.Context, maxBytes uint64) (*coresequence
198201
}
199202

200203
// SetPostponed records txs that should be requeued on the next Ack.
201-
// Must be called between Drain and Ack. The queue owns this state so
202-
// it is only cleared on successful Ack — no data loss on failure.
204+
// Must be called at most once between a Drain and its Ack — a later call in
205+
// the same cycle would overwrite the recorded txs. The postponedItem guard
206+
// makes a repeated call after a failed Ack a no-op, so the already-persisted
207+
// entry is not replaced. The queue owns this state so it is only cleared on
208+
// successful Ack — no data loss on failure.
203209
func (bq *BatchQueue) SetPostponed(txs [][]byte) {
204210
bq.mu.Lock()
205211
defer bq.mu.Unlock()
@@ -305,7 +311,11 @@ func (bq *BatchQueue) rollbackInFlightLocked(ctx context.Context) {
305311
}
306312

307313
if bq.postponedItem != nil {
308-
if err := bq.db.Delete(ctx, ds.NewKey(bq.postponedItem.Key)); err != nil {
314+
// detach from the caller's context so this best-effort cleanup still
315+
// runs during graceful shutdown when the drain context is cancelled
316+
delCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
317+
defer cancel()
318+
if err := bq.db.Delete(delCtx, ds.NewKey(bq.postponedItem.Key)); err != nil {
309319
bq.logger.Warn().Err(err).Str("key", bq.postponedItem.Key).
310320
Msg("failed to delete rolled-back postponed WAL entry")
311321
}
@@ -395,6 +405,9 @@ func (bq *BatchQueue) DropIncluded(ctx context.Context, included [][]byte) (int,
395405
}
396406

397407
var dropped int
408+
// in-place compaction: kept aliases bq.queue's backing array. This is safe
409+
// because range evaluates the slice header once and writes always trail
410+
// reads (at most one item is appended per item iterated).
398411
kept := bq.queue[:bq.head]
399412
for _, item := range bq.queue[bq.head:] {
400413
remaining := make([][]byte, 0, len(item.Batch.Transactions))
@@ -456,8 +469,9 @@ func (bq *BatchQueue) Load(ctx context.Context) error {
456469
var legacyItems []queuedItem
457470
for result := range results.Next() {
458471
if result.Error != nil {
459-
bq.logger.Error().Err(result.Error).Msg("failed to read entry from datastore")
460-
continue
472+
// a datastore read failure means the WAL cannot be trusted as
473+
// loaded — fail startup rather than silently dropping txs.
474+
return fmt.Errorf("failed to read WAL entry from datastore: %w", result.Error)
461475
}
462476
// We care about the last part of the key (the sequence number)
463477
// ds.Key usually has a leading slash.

pkg/sequencers/single/queue_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,38 @@ func TestBatchQueue_Drain_RollsBackUnackedInFlight(t *testing.T) {
735735
assert.Equal(t, []byte("tx2"), batch2.Transactions[1])
736736
}
737737

738+
func TestBatchQueue_Drain_RollbackBulkPrependAfterCompact(t *testing.T) {
739+
ctx := context.Background()
740+
db := ds.NewMapDatastore()
741+
queue := NewBatchQueue(db, "test-drain-rollback-bulk", 0, zerolog.Nop())
742+
require.NoError(t, queue.Load(ctx))
743+
744+
// drain >100 entries so compactLocked resets head to 0 while they
745+
// are in flight, forcing the bulk-prepend rollback path on next drain
746+
const n = 150
747+
for i := range n {
748+
tx := []byte(fmt.Sprintf("tx-%03d", i))
749+
require.NoError(t, queue.AddBatch(ctx, coresequencer.Batch{Transactions: [][]byte{tx}}))
750+
}
751+
752+
batch1, err := queue.Drain(ctx, 0)
753+
require.NoError(t, err)
754+
require.Len(t, batch1.Transactions, n)
755+
require.Equal(t, 0, queue.head, "compact should reset head while entries are in flight")
756+
757+
// enqueue one more so the rollback has a tail to prepend in front of
758+
require.NoError(t, queue.AddBatch(ctx, coresequencer.Batch{Transactions: [][]byte{[]byte("tx-new")}}))
759+
760+
// second drain without ack rolls back via the bulk-prepend path
761+
batch2, err := queue.Drain(ctx, 0)
762+
require.NoError(t, err)
763+
require.Len(t, batch2.Transactions, n+1)
764+
for i := range n {
765+
assert.Equal(t, []byte(fmt.Sprintf("tx-%03d", i)), batch2.Transactions[i])
766+
}
767+
assert.Equal(t, []byte("tx-new"), batch2.Transactions[n])
768+
}
769+
738770
func TestBatchQueue_Ack_DeletesWALEntries(t *testing.T) {
739771
ctx := context.Background()
740772
db := ds.NewMapDatastore()

pkg/sequencers/single/sequencer.go

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ func NewSequencer(
9494
s.SetDAHeight(genesis.DAStartHeight) // default value, will be overridden by executor or submitter
9595
s.daStartHeight.Store(genesis.DAStartHeight)
9696

97-
loadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
97+
// generous timeout: loading and reconciling a large WAL backlog
98+
// (e.g. after a burst followed by a crash) can take a while
99+
loadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
98100
defer cancel()
99101

100102
// Load batch queue from DB
@@ -321,6 +323,7 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB
321323
// TxIndex tracks consumed txs from the start of the epoch, so we must process in order.
322324
var validForcedTxs [][]byte
323325
var validMempoolTxs [][]byte
326+
var postponedMempoolTxs [][]byte
324327
var forcedTxConsumedCount uint64
325328
var forcedTxPostponed bool
326329

@@ -347,19 +350,15 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB
347350
switch status {
348351
case execution.FilterOK:
349352
validMempoolTxs = append(validMempoolTxs, allTxs[i])
350-
case execution.FilterPostpone, execution.FilterRemove:
351-
// Mempool txs that are postponed/removed are handled separately
353+
case execution.FilterPostpone:
354+
// requeued at ack time (after block commit)
355+
postponedMempoolTxs = append(postponedMempoolTxs, allTxs[i])
356+
case execution.FilterRemove:
357+
// dropped permanently
352358
}
353359
}
354360
}
355361

356-
// collect postponed mempool txs to be requeued at ack time (after block commit)
357-
var postponedMempoolTxs [][]byte
358-
for i, status := range filterStatuses {
359-
if i >= forcedTxCount && status == execution.FilterPostpone {
360-
postponedMempoolTxs = append(postponedMempoolTxs, allTxs[i])
361-
}
362-
}
363362
c.queue.SetPostponed(postponedMempoolTxs)
364363

365364
// Update checkpoint after consuming forced inclusion transactions.

pkg/sequencers/single/sequencer_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import (
1919
"github.com/evstack/ev-node/pkg/config"
2020
datypes "github.com/evstack/ev-node/pkg/da/types"
2121
"github.com/evstack/ev-node/pkg/genesis"
22+
"github.com/evstack/ev-node/pkg/store"
2223
"github.com/evstack/ev-node/test/mocks"
2324
"github.com/evstack/ev-node/test/testda"
25+
"github.com/evstack/ev-node/types"
2426
)
2527

2628
// MockFullDAClient combines MockClient and MockVerifier to implement FullDAClient
@@ -2309,3 +2311,35 @@ func TestSequencer_GetNextBatch_GasFilteringPreservesUnprocessedTxs(t *testing.T
23092311
assert.True(t, txFound[string(tx2)], "tx2 should have been processed (must not be lost)")
23102312
assert.True(t, txFound[string(tx3)], "tx3 should have been processed (must not be lost)")
23112313
}
2314+
2315+
func TestSequencer_ReconcileQueueWithLastBlock_CrashRecovery(t *testing.T) {
2316+
ctx := context.Background()
2317+
db := ds.NewMapDatastore()
2318+
2319+
// commit a block at height 1 containing two txs
2320+
header, data := types.GetRandomBlock(1, 2, "test")
2321+
evStore := store.New(store.NewEvNodeKVStore(db))
2322+
batch, err := evStore.NewBatch(ctx)
2323+
require.NoError(t, err)
2324+
require.NoError(t, batch.SaveBlockData(header, data, &header.Signature))
2325+
require.NoError(t, batch.SetHeight(1))
2326+
require.NoError(t, batch.Commit())
2327+
2328+
committed1 := []byte(data.Txs[0])
2329+
committed2 := []byte(data.Txs[1])
2330+
pending := []byte("tx-still-pending")
2331+
2332+
// simulate a crash between block commit and queue ack: WAL still holds
2333+
// the committed txs alongside a tx that was never included
2334+
queue := NewBatchQueue(db, "batches", 0, zerolog.Nop())
2335+
require.NoError(t, queue.AddBatch(ctx, coresequencer.Batch{Transactions: [][]byte{committed1, committed2}}))
2336+
require.NoError(t, queue.AddBatch(ctx, coresequencer.Batch{Transactions: [][]byte{pending}}))
2337+
2338+
// restart: NewSequencer loads the queue and reconciles against the last block
2339+
seq := newTestSequencer(t, db, newDummyDA(100_000_000))
2340+
2341+
drained, err := seq.queue.Drain(ctx, 0)
2342+
require.NoError(t, err)
2343+
require.Len(t, drained.Transactions, 1, "committed txs should have been dropped on reconcile")
2344+
assert.Equal(t, pending, drained.Transactions[0])
2345+
}

0 commit comments

Comments
 (0)