From dab3be3bdaaeb49f1b8ede4c2f3cc09957bdf956 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 11:08:03 +0200 Subject: [PATCH 01/23] feat: solo sequencer --- apps/testapp/go.mod | 2 +- apps/testapp/go.sum | 2 - pkg/sequencers/solo/README.md | 90 +++++++++ pkg/sequencers/solo/sequencer.go | 156 +++++++++++++++ pkg/sequencers/solo/sequencer_test.go | 270 ++++++++++++++++++++++++++ test/e2e/failover_e2e_test.go | 2 - 6 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 pkg/sequencers/solo/README.md create mode 100644 pkg/sequencers/solo/sequencer.go create mode 100644 pkg/sequencers/solo/sequencer_test.go diff --git a/apps/testapp/go.mod b/apps/testapp/go.mod index d80110b3fc..039ba01b9f 100644 --- a/apps/testapp/go.mod +++ b/apps/testapp/go.mod @@ -2,7 +2,7 @@ module github.com/evstack/ev-node/apps/testapp go 1.25.7 -// replace github.com/evstack/ev-node => ../../. +replace github.com/evstack/ev-node => ../../. require ( github.com/evstack/ev-node v1.1.0-rc.2 diff --git a/apps/testapp/go.sum b/apps/testapp/go.sum index 8350f001ff..2895f9598a 100644 --- a/apps/testapp/go.sum +++ b/apps/testapp/go.sum @@ -432,8 +432,6 @@ github.com/envoyproxy/protoc-gen-validate v1.0.1/go.mod h1:0vj8bNkYbSTNS2PIyH87K github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= -github.com/evstack/ev-node v1.1.0-rc.2 h1:7fdGpzjJwtNLtLJJ/Fyj2yFv5ARKnMAPh22Z5cRd1r0= -github.com/evstack/ev-node v1.1.0-rc.2/go.mod h1:5lIACV0hQGO5Btdb1b3fSw2Vz7Jvrg2yvMefalfWguA= github.com/evstack/ev-node/core v1.0.0 h1:s0Tx0uWHme7SJn/ZNEtee4qNM8UO6PIxXnHhPbbKTz8= github.com/evstack/ev-node/core v1.0.0/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= diff --git a/pkg/sequencers/solo/README.md b/pkg/sequencers/solo/README.md new file mode 100644 index 0000000000..7f2670d51a --- /dev/null +++ b/pkg/sequencers/solo/README.md @@ -0,0 +1,90 @@ +# Solo Sequencer + +A minimal single-leader sequencer without forced inclusion support. It accepts mempool transactions via an in-memory queue and produces batches on demand. + +## Overview + +The solo sequencer is the simplest sequencer implementation. It has no DA-layer interaction for transaction ordering and no crash-recovery persistence. Transactions are held in memory and lost on restart. + +Use it when you need a single node that orders transactions without the overhead of forced inclusion checkpoints or DA-based sequencing. + +```mermaid +flowchart LR + Client["Client"] -->|SubmitBatchTxs| Sequencer["SoloSequencer"] + Sequencer -->|GetNextBatch| BlockManager["Block Manager"] +``` + +## Design Decisions + +| Decision | Rationale | +|---|---| +| In-memory queue | No persistence overhead; suitable for trusted single-operator setups | +| No forced inclusion | Avoids DA epoch tracking, checkpoint storage, and catch-up logic | +| No DA client dependency | `VerifyBatch` returns true unconditionally | +| Configurable queue limit | Provides backpressure when blocks can't be produced fast enough | + +## Flow + +### SubmitBatchTxs + +```mermaid +flowchart TD + A["SubmitBatchTxs()"] --> B{"Valid ID?"} + B -->|No| C["Return ErrInvalidID"] + B -->|Yes| D{"Empty batch?"} + D -->|Yes| E["Return OK"] + D -->|No| F{"Queue full?"} + F -->|Yes| G["Return ErrQueueFull"] + F -->|No| H["Append txs to queue"] + H --> E +``` + +### GetNextBatch + +```mermaid +flowchart TD + A["GetNextBatch()"] --> B{"Valid ID?"} + B -->|No| C["Return ErrInvalidID"] + B -->|Yes| D["Drain queue"] + D --> E{"Queue was empty?"} + E -->|Yes| F["Return empty batch"] + E -->|No| G["FilterTxs via executor"] + G --> H["Re-queue postponed txs"] + H --> I["Return valid txs"] +``` + +## Usage + +```go +seq := solo.NewSoloSequencer( + logger, + cfg, + []byte("chain-id"), + 1000, // maxQueueSize (0 = unlimited) + genesis, + executor, +) + +// Submit transactions from the mempool +seq.SubmitBatchTxs(ctx, coresequencer.SubmitBatchTxsRequest{ + Id: []byte("chain-id"), + Batch: &coresequencer.Batch{Transactions: txs}, +}) + +// Produce the next block +resp, err := seq.GetNextBatch(ctx, coresequencer.GetNextBatchRequest{ + Id: []byte("chain-id"), + MaxBytes: 500_000, +}) +``` + +## Comparison with Other Sequencers + +| Aspect | Solo | Single | Based | +|---|---|---|---| +| Mempool transactions | Yes | Yes | No | +| Forced inclusion | No | Yes | Yes | +| Persistence | None | DB-backed queue + checkpoints | Checkpoints only | +| Crash recovery | Lost on restart | Full recovery | Checkpoint-based | +| Catch-up mode | N/A | Yes | N/A | +| DA client required | No | Yes | Yes | diff --git a/pkg/sequencers/solo/sequencer.go b/pkg/sequencers/solo/sequencer.go new file mode 100644 index 0000000000..633466905d --- /dev/null +++ b/pkg/sequencers/solo/sequencer.go @@ -0,0 +1,156 @@ +package solo + +import ( + "bytes" + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + + "github.com/evstack/ev-node/core/execution" + coresequencer "github.com/evstack/ev-node/core/sequencer" + "github.com/evstack/ev-node/pkg/config" + "github.com/evstack/ev-node/pkg/genesis" +) + +var ( + ErrInvalidID = errors.New("invalid chain id") + ErrQueueFull = errors.New("transaction queue is full") +) + +var _ coresequencer.Sequencer = (*SoloSequencer)(nil) + +// SoloSequencer is a single-leader sequencer without forced inclusion +// support. It maintains a simple in-memory queue of mempool transactions and +// produces batches on demand. +type SoloSequencer struct { + logger zerolog.Logger + genesis genesis.Genesis + id []byte + executor execution.Executor + + daHeight atomic.Uint64 + + mu sync.Mutex + queue [][]byte + maxQueueSize int +} + +func NewSoloSequencer( + logger zerolog.Logger, + cfg config.Config, + id []byte, + maxQueueSize int, + genesis genesis.Genesis, + executor execution.Executor, +) *SoloSequencer { + return &SoloSequencer{ + logger: logger, + genesis: genesis, + id: id, + executor: executor, + queue: make([][]byte, 0), + maxQueueSize: maxQueueSize, + } +} + +func (s *SoloSequencer) isValid(id []byte) bool { + return bytes.Equal(s.id, id) +} + +func (s *SoloSequencer) SubmitBatchTxs(ctx context.Context, req coresequencer.SubmitBatchTxsRequest) (*coresequencer.SubmitBatchTxsResponse, error) { + if !s.isValid(req.Id) { + return nil, ErrInvalidID + } + + if req.Batch == nil || len(req.Batch.Transactions) == 0 { + return &coresequencer.SubmitBatchTxsResponse{}, nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.maxQueueSize > 0 && len(s.queue)+len(req.Batch.Transactions) > s.maxQueueSize { + return nil, fmt.Errorf("%w: queue has %d txs, batch has %d txs, limit is %d", + ErrQueueFull, len(s.queue), len(req.Batch.Transactions), s.maxQueueSize) + } + + s.queue = append(s.queue, req.Batch.Transactions...) + return &coresequencer.SubmitBatchTxsResponse{}, nil +} + +func (s *SoloSequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextBatchRequest) (*coresequencer.GetNextBatchResponse, error) { + if !s.isValid(req.Id) { + return nil, ErrInvalidID + } + + s.mu.Lock() + txs := s.queue + s.queue = nil + s.mu.Unlock() + + if len(txs) == 0 { + return &coresequencer.GetNextBatchResponse{ + Batch: &coresequencer.Batch{}, + Timestamp: time.Now().UTC(), + BatchData: req.LastBatchData, + }, nil + } + + var maxGas uint64 + info, err := s.executor.GetExecutionInfo(ctx) + if err != nil { + s.logger.Warn().Err(err).Msg("failed to get execution info") + } else { + maxGas = info.MaxGas + } + + filterStatuses, err := s.executor.FilterTxs(ctx, txs, req.MaxBytes, maxGas, false) + if err != nil { + s.logger.Warn().Err(err).Msg("failed to filter transactions, proceeding with unfiltered") + filterStatuses = make([]execution.FilterStatus, len(txs)) + for i := range filterStatuses { + filterStatuses[i] = execution.FilterOK + } + } + + var validTxs [][]byte + var postponedTxs [][]byte + for i, status := range filterStatuses { + switch status { + case execution.FilterOK: + validTxs = append(validTxs, txs[i]) + case execution.FilterPostpone: + postponedTxs = append(postponedTxs, txs[i]) + case execution.FilterRemove: + } + } + + if len(postponedTxs) > 0 { + s.mu.Lock() + s.queue = append(postponedTxs, s.queue...) + s.mu.Unlock() + } + + return &coresequencer.GetNextBatchResponse{ + Batch: &coresequencer.Batch{Transactions: validTxs}, + Timestamp: time.Now().UTC(), + BatchData: req.LastBatchData, + }, nil +} + +func (s *SoloSequencer) VerifyBatch(ctx context.Context, req coresequencer.VerifyBatchRequest) (*coresequencer.VerifyBatchResponse, error) { + return &coresequencer.VerifyBatchResponse{Status: true}, nil +} + +func (s *SoloSequencer) SetDAHeight(height uint64) { + s.daHeight.Store(height) +} + +func (s *SoloSequencer) GetDAHeight() uint64 { + return s.daHeight.Load() +} diff --git a/pkg/sequencers/solo/sequencer_test.go b/pkg/sequencers/solo/sequencer_test.go new file mode 100644 index 0000000000..fb5f4a4a6e --- /dev/null +++ b/pkg/sequencers/solo/sequencer_test.go @@ -0,0 +1,270 @@ +package solo + +import ( + "context" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/core/execution" + coresequencer "github.com/evstack/ev-node/core/sequencer" + "github.com/evstack/ev-node/pkg/config" + "github.com/evstack/ev-node/pkg/genesis" + "github.com/evstack/ev-node/test/mocks" +) + +func createDefaultMockExecutor(t *testing.T) *mocks.MockExecutor { + mockExec := mocks.NewMockExecutor(t) + mockExec.On("GetExecutionInfo", mock.Anything).Return(execution.ExecutionInfo{MaxGas: 1000000}, nil).Maybe() + mockExec.On("FilterTxs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + func(ctx context.Context, txs [][]byte, maxBytes, maxGas uint64, hasForceIncludedTransaction bool) []execution.FilterStatus { + result := make([]execution.FilterStatus, len(txs)) + var cumulativeBytes uint64 + for i, tx := range txs { + txBytes := uint64(len(tx)) + if maxBytes > 0 && cumulativeBytes+txBytes > maxBytes { + result[i] = execution.FilterPostpone + continue + } + cumulativeBytes += txBytes + result[i] = execution.FilterOK + } + return result + }, + nil, + ).Maybe() + return mockExec +} + +func newTestSequencer(t *testing.T, maxQueueSize int) *SoloSequencer { + return NewSoloSequencer( + zerolog.Nop(), + config.DefaultConfig(), + []byte("test"), + maxQueueSize, + genesis.Genesis{ChainID: "test"}, + createDefaultMockExecutor(t), + ) +} + +func TestSoloSequencer_SubmitBatchTxs(t *testing.T) { + seq := newTestSequencer(t, 0) + + tx := []byte("transaction1") + res, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &coresequencer.Batch{Transactions: [][]byte{tx}}, + }) + require.NoError(t, err) + require.NotNil(t, res) + + nextResp, err := seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("test")}) + require.NoError(t, err) + require.Len(t, nextResp.Batch.Transactions, 1) + assert.Equal(t, tx, nextResp.Batch.Transactions[0]) +} + +func TestSoloSequencer_SubmitBatchTxs_InvalidID(t *testing.T) { + seq := newTestSequencer(t, 0) + + res, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("wrong"), + Batch: &coresequencer.Batch{Transactions: [][]byte{{1}}}, + }) + assert.ErrorIs(t, err, ErrInvalidID) + assert.Nil(t, res) +} + +func TestSoloSequencer_SubmitBatchTxs_EmptyBatch(t *testing.T) { + seq := newTestSequencer(t, 0) + + res, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &coresequencer.Batch{}, + }) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Empty(t, seq.queue) + + res, err = seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: nil, + }) + require.NoError(t, err) + require.NotNil(t, res) + assert.Empty(t, seq.queue) +} + +func TestSoloSequencer_SubmitBatchTxs_QueueFull(t *testing.T) { + seq := newTestSequencer(t, 3) // max 3 txs + + batch := coresequencer.Batch{Transactions: [][]byte{{1}, {2}, {3}}} + _, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &batch, + }) + require.NoError(t, err) + + res, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &coresequencer.Batch{Transactions: [][]byte{{4}}}, + }) + assert.ErrorIs(t, err, ErrQueueFull) + assert.Nil(t, res) +} + +func TestSoloSequencer_GetNextBatch_EmptyQueue(t *testing.T) { + seq := newTestSequencer(t, 0) + + resp, err := seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("test")}) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Empty(t, resp.Batch.Transactions) + assert.WithinDuration(t, time.Now(), resp.Timestamp, time.Second) +} + +func TestSoloSequencer_GetNextBatch_InvalidID(t *testing.T) { + seq := newTestSequencer(t, 0) + + res, err := seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("wrong")}) + assert.ErrorIs(t, err, ErrInvalidID) + assert.Nil(t, res) +} + +func TestSoloSequencer_GetNextBatch_DrainsAndFilters(t *testing.T) { + seq := newTestSequencer(t, 0) + + batch := coresequencer.Batch{Transactions: [][]byte{{1}, {2}, {3}}} + _, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &batch, + }) + require.NoError(t, err) + + resp, err := seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("test")}) + require.NoError(t, err) + assert.Len(t, resp.Batch.Transactions, 3) + + assert.Empty(t, seq.queue, "queue should be drained after GetNextBatch") +} + +func TestSoloSequencer_GetNextBatch_PostponedTxsRequeued(t *testing.T) { + mockExec := mocks.NewMockExecutor(t) + mockExec.On("GetExecutionInfo", mock.Anything).Return(execution.ExecutionInfo{MaxGas: 1000000}, nil).Maybe() + mockExec.On("FilterTxs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + func(ctx context.Context, txs [][]byte, maxBytes, maxGas uint64, hasForceIncludedTransaction bool) []execution.FilterStatus { + result := make([]execution.FilterStatus, len(txs)) + for i := range txs { + if i < 2 { + result[i] = execution.FilterOK + } else { + result[i] = execution.FilterPostpone + } + } + return result + }, + nil, + ).Maybe() + + seq := NewSoloSequencer( + zerolog.Nop(), + config.DefaultConfig(), + []byte("test"), + 0, + genesis.Genesis{ChainID: "test"}, + mockExec, + ) + + batch := coresequencer.Batch{Transactions: [][]byte{{1}, {2}, {3}, {4}}} + _, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &batch, + }) + require.NoError(t, err) + + resp, err := seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("test")}) + require.NoError(t, err) + assert.Len(t, resp.Batch.Transactions, 2, "first 2 txs should pass filter") + + assert.Len(t, seq.queue, 2, "postponed txs should be re-queued") + assert.Equal(t, []byte{3}, seq.queue[0]) + assert.Equal(t, []byte{4}, seq.queue[1]) +} + +func TestSoloSequencer_GetNextBatch_SubmitDuringProcessing(t *testing.T) { + seq := newTestSequencer(t, 0) + + batch := coresequencer.Batch{Transactions: [][]byte{{1}, {2}}} + _, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &batch, + }) + require.NoError(t, err) + + resp, err := seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("test")}) + require.NoError(t, err) + assert.Len(t, resp.Batch.Transactions, 2) + + _, err = seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &coresequencer.Batch{Transactions: [][]byte{{3}}}, + }) + require.NoError(t, err) + + resp, err = seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("test")}) + require.NoError(t, err) + assert.Len(t, resp.Batch.Transactions, 1) + assert.Equal(t, []byte{3}, resp.Batch.Transactions[0]) +} + +func TestSoloSequencer_VerifyBatch(t *testing.T) { + seq := newTestSequencer(t, 0) + + batchData := [][]byte{[]byte("batch1"), []byte("batch2")} + + res, err := seq.VerifyBatch(context.Background(), coresequencer.VerifyBatchRequest{ + Id: []byte("test"), + BatchData: batchData, + }) + assert.NoError(t, err) + assert.True(t, res.Status) +} + +func TestSoloSequencer_DAHeight(t *testing.T) { + seq := newTestSequencer(t, 0) + + assert.Equal(t, uint64(0), seq.GetDAHeight()) + + seq.SetDAHeight(42) + assert.Equal(t, uint64(42), seq.GetDAHeight()) +} + +func TestSoloSequencer_QueueFullThenFreed(t *testing.T) { + seq := newTestSequencer(t, 2) // max 2 txs + + _, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &coresequencer.Batch{Transactions: [][]byte{{1}, {2}}}, + }) + require.NoError(t, err) + + _, err = seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &coresequencer.Batch{Transactions: [][]byte{{3}}}, + }) + assert.ErrorIs(t, err, ErrQueueFull) + + _, err = seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("test")}) + require.NoError(t, err) + + _, err = seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test"), + Batch: &coresequencer.Batch{Transactions: [][]byte{{3}}}, + }) + assert.NoError(t, err, "submission should succeed after queue is drained") +} diff --git a/test/e2e/failover_e2e_test.go b/test/e2e/failover_e2e_test.go index 1e1f53dfd1..2fc84e1b34 100644 --- a/test/e2e/failover_e2e_test.go +++ b/test/e2e/failover_e2e_test.go @@ -837,8 +837,6 @@ func submitTxToURL(t *testing.T, client *ethclient.Client) (common.Hash, uint64) return tx.Hash(), blk } -const defaultMaxBlobSize = 2 * 1024 * 1024 // 2MB - func queryLastDAHeight(t *testing.T, jwtSecret string, daAddress string) uint64 { t.Helper() blobClient, err := blobrpc.NewClient(t.Context(), daAddress, jwtSecret, "") From 1fc86a53783153f9cf5dce79eb0e40b55b61a2e6 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 11:26:31 +0200 Subject: [PATCH 02/23] refactor: set max blob size as ldflag --- block/internal/common/consts.go | 6 +++- block/internal/da/client.go | 2 +- .../internal/submitting/batching_strategy.go | 16 ++++----- .../submitting/batching_strategy_test.go | 36 +++++++++---------- block/internal/submitting/da_submitter.go | 12 +++---- block/internal/submitting/submitter.go | 13 ++++--- 6 files changed, 43 insertions(+), 42 deletions(-) diff --git a/block/internal/common/consts.go b/block/internal/common/consts.go index 6f881d55a2..c6c2bcd7d4 100644 --- a/block/internal/common/consts.go +++ b/block/internal/common/consts.go @@ -1,3 +1,7 @@ package common -const DefaultMaxBlobSize = 5 * 1024 * 1024 // 5MB fallback blob size limit +// DefaultMaxBlobSize is the fallback blob size limit used when the DA layer +// does not report one. Override at build time with: +// +// go build -ldflags "-X github.com/evstack/ev-node/block/internal/common.DefaultMaxBlobSize=10485760" +var DefaultMaxBlobSize uint64 = 5 * 1024 * 1024 // 5MB diff --git a/block/internal/da/client.go b/block/internal/da/client.go index 4db8e7716a..35fd50b91d 100644 --- a/block/internal/da/client.go +++ b/block/internal/da/client.go @@ -559,7 +559,7 @@ func extractBlobData(resp *blobrpc.SubscriptionResponse) [][]byte { continue } data := blob.Data() - if len(data) == 0 || len(data) > common.DefaultMaxBlobSize { + if len(data) == 0 || uint64(len(data)) > common.DefaultMaxBlobSize { continue } blobs = append(blobs, data) diff --git a/block/internal/submitting/batching_strategy.go b/block/internal/submitting/batching_strategy.go index 72eccdc4d9..b71ab78fdc 100644 --- a/block/internal/submitting/batching_strategy.go +++ b/block/internal/submitting/batching_strategy.go @@ -12,7 +12,7 @@ import ( type BatchingStrategy interface { // ShouldSubmit determines if a batch should be submitted based on the strategy // Returns true if submission should happen now - ShouldSubmit(pendingCount uint64, totalSizeBeforeSig int, maxBlobSize int, timeSinceLastSubmit time.Duration) bool + ShouldSubmit(pendingCount uint64, totalSizeBeforeSig uint64, maxBlobSize uint64, timeSinceLastSubmit time.Duration) bool } // NewBatchingStrategy creates a batching strategy based on configuration @@ -34,7 +34,7 @@ func NewBatchingStrategy(cfg config.DAConfig) (BatchingStrategy, error) { // ImmediateStrategy submits as soon as any items are available type ImmediateStrategy struct{} -func (s *ImmediateStrategy) ShouldSubmit(pendingCount uint64, totalSize int, maxBlobSize int, timeSinceLastSubmit time.Duration) bool { +func (s *ImmediateStrategy) ShouldSubmit(pendingCount uint64, totalSize uint64, maxBlobSize uint64, timeSinceLastSubmit time.Duration) bool { return pendingCount > 0 } @@ -57,12 +57,12 @@ func NewSizeBasedStrategy(sizeThreshold float64, minItems uint64) *SizeBasedStra } } -func (s *SizeBasedStrategy) ShouldSubmit(pendingCount uint64, totalSize int, maxBlobSize int, timeSinceLastSubmit time.Duration) bool { +func (s *SizeBasedStrategy) ShouldSubmit(pendingCount uint64, totalSize uint64, maxBlobSize uint64, timeSinceLastSubmit time.Duration) bool { if pendingCount < s.minItems { return false } - threshold := int(float64(maxBlobSize) * s.sizeThreshold) + threshold := uint64(float64(maxBlobSize) * s.sizeThreshold) return totalSize >= threshold } @@ -85,7 +85,7 @@ func NewTimeBasedStrategy(daBlockTime time.Duration, maxDelay time.Duration, min } } -func (s *TimeBasedStrategy) ShouldSubmit(pendingCount uint64, totalSize int, maxBlobSize int, timeSinceLastSubmit time.Duration) bool { +func (s *TimeBasedStrategy) ShouldSubmit(pendingCount uint64, totalSize uint64, maxBlobSize uint64, timeSinceLastSubmit time.Duration) bool { if pendingCount < s.minItems { return false } @@ -120,18 +120,16 @@ func NewAdaptiveStrategy(daBlockTime time.Duration, sizeThreshold float64, maxDe } } -func (s *AdaptiveStrategy) ShouldSubmit(pendingCount uint64, totalSize int, maxBlobSize int, timeSinceLastSubmit time.Duration) bool { +func (s *AdaptiveStrategy) ShouldSubmit(pendingCount uint64, totalSize uint64, maxBlobSize uint64, timeSinceLastSubmit time.Duration) bool { if pendingCount < s.minItems { return false } - // Submit if we've reached the size threshold - threshold := int(float64(maxBlobSize) * s.sizeThreshold) + threshold := uint64(float64(maxBlobSize) * s.sizeThreshold) if totalSize >= threshold { return true } - // Submit if max delay has been reached if timeSinceLastSubmit >= s.maxDelay { return true } diff --git a/block/internal/submitting/batching_strategy_test.go b/block/internal/submitting/batching_strategy_test.go index f29eff0287..2246291d12 100644 --- a/block/internal/submitting/batching_strategy_test.go +++ b/block/internal/submitting/batching_strategy_test.go @@ -13,11 +13,12 @@ import ( func TestImmediateStrategy(t *testing.T) { strategy := &ImmediateStrategy{} + maxBlobSize := common.DefaultMaxBlobSize tests := []struct { name string pendingCount uint64 - totalSize int + totalSize uint64 expected bool }{ { @@ -42,7 +43,7 @@ func TestImmediateStrategy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := strategy.ShouldSubmit(tt.pendingCount, tt.totalSize, common.DefaultMaxBlobSize, 0) + result := strategy.ShouldSubmit(tt.pendingCount, tt.totalSize, maxBlobSize, 0) assert.Equal(t, tt.expected, result) }) } @@ -56,7 +57,7 @@ func TestSizeBasedStrategy(t *testing.T) { sizeThreshold float64 minItems uint64 pendingCount uint64 - totalSize int + totalSize uint64 expectedSubmit bool }{ { @@ -80,7 +81,7 @@ func TestSizeBasedStrategy(t *testing.T) { sizeThreshold: 0.8, minItems: 1, pendingCount: 10, - totalSize: int(float64(maxBlobSize) * 0.8), // 80% of max + totalSize: uint64(float64(maxBlobSize) * 0.8), // 80% of max expectedSubmit: true, }, { @@ -88,7 +89,7 @@ func TestSizeBasedStrategy(t *testing.T) { sizeThreshold: 0.8, minItems: 1, pendingCount: 20, - totalSize: int(float64(maxBlobSize) * 0.875), // 87.5% + totalSize: uint64(float64(maxBlobSize) * 0.875), // 87.5% expectedSubmit: true, }, { @@ -125,7 +126,7 @@ func TestTimeBasedStrategy(t *testing.T) { name string minItems uint64 pendingCount uint64 - totalSize int + totalSize uint64 timeSinceLastSubmit time.Duration expectedSubmit bool }{ @@ -133,7 +134,7 @@ func TestTimeBasedStrategy(t *testing.T) { name: "below min items", minItems: 2, pendingCount: 1, - totalSize: int(float64(maxBlobSize) * 0.2), + totalSize: uint64(float64(maxBlobSize) * 0.2), timeSinceLastSubmit: 10 * time.Second, expectedSubmit: false, }, @@ -141,7 +142,7 @@ func TestTimeBasedStrategy(t *testing.T) { name: "before max delay", minItems: 1, pendingCount: 5, - totalSize: int(float64(maxBlobSize) * 0.5), + totalSize: uint64(float64(maxBlobSize) * 0.5), timeSinceLastSubmit: 3 * time.Second, expectedSubmit: false, }, @@ -149,7 +150,7 @@ func TestTimeBasedStrategy(t *testing.T) { name: "at max delay", minItems: 1, pendingCount: 3, - totalSize: int(float64(maxBlobSize) * 0.4), + totalSize: uint64(float64(maxBlobSize) * 0.4), timeSinceLastSubmit: 6 * time.Second, expectedSubmit: true, }, @@ -157,7 +158,7 @@ func TestTimeBasedStrategy(t *testing.T) { name: "after max delay", minItems: 1, pendingCount: 2, - totalSize: int(float64(maxBlobSize) * 0.2), + totalSize: uint64(float64(maxBlobSize) * 0.2), timeSinceLastSubmit: 10 * time.Second, expectedSubmit: true, }, @@ -181,7 +182,7 @@ func TestAdaptiveStrategy(t *testing.T) { name string minItems uint64 pendingCount uint64 - totalSize int + totalSize uint64 timeSinceLastSubmit time.Duration expectedSubmit bool reason string @@ -190,7 +191,7 @@ func TestAdaptiveStrategy(t *testing.T) { name: "below min items", minItems: 3, pendingCount: 2, - totalSize: int(float64(maxBlobSize) * 0.875), + totalSize: uint64(float64(maxBlobSize) * 0.875), timeSinceLastSubmit: 10 * time.Second, expectedSubmit: false, reason: "not enough items", @@ -199,7 +200,7 @@ func TestAdaptiveStrategy(t *testing.T) { name: "size threshold reached", minItems: 1, pendingCount: 10, - totalSize: int(float64(maxBlobSize) * 0.85), // 85% + totalSize: uint64(float64(maxBlobSize) * 0.85), // 85% timeSinceLastSubmit: 1 * time.Second, expectedSubmit: true, reason: "size threshold met", @@ -208,7 +209,7 @@ func TestAdaptiveStrategy(t *testing.T) { name: "time threshold reached", minItems: 1, pendingCount: 2, - totalSize: int(float64(maxBlobSize) * 0.2), // Only 20% + totalSize: uint64(float64(maxBlobSize) * 0.2), // Only 20% timeSinceLastSubmit: 7 * time.Second, expectedSubmit: true, reason: "time threshold met", @@ -217,7 +218,7 @@ func TestAdaptiveStrategy(t *testing.T) { name: "neither threshold reached", minItems: 1, pendingCount: 5, - totalSize: int(float64(maxBlobSize) * 0.5), // 50% + totalSize: uint64(float64(maxBlobSize) * 0.5), // 50% timeSinceLastSubmit: 3 * time.Second, expectedSubmit: false, reason: "waiting for threshold", @@ -226,7 +227,7 @@ func TestAdaptiveStrategy(t *testing.T) { name: "both thresholds reached", minItems: 1, pendingCount: 20, - totalSize: int(float64(maxBlobSize) * 0.875), // 87.5% + totalSize: uint64(float64(maxBlobSize) * 0.875), // 87.5% timeSinceLastSubmit: 10 * time.Second, expectedSubmit: true, reason: "both thresholds met", @@ -305,10 +306,9 @@ func TestNewBatchingStrategy(t *testing.T) { } func TestBatchingStrategiesComparison(t *testing.T) { - // This test demonstrates how different strategies behave with the same input maxBlobSize := common.DefaultMaxBlobSize pendingCount := uint64(10) - totalSize := maxBlobSize / 2 // 50% full + totalSize := maxBlobSize / 2 timeSinceLastSubmit := 3 * time.Second immediate := &ImmediateStrategy{} diff --git a/block/internal/submitting/da_submitter.go b/block/internal/submitting/da_submitter.go index 1f7edbed8d..83f56d9cb5 100644 --- a/block/internal/submitting/da_submitter.go +++ b/block/internal/submitting/da_submitter.go @@ -42,7 +42,7 @@ type retryPolicy struct { MaxAttempts int MinBackoff time.Duration MaxBackoff time.Duration - MaxBlobBytes int + MaxBlobBytes uint64 } func defaultRetryPolicy(maxAttempts int, maxDuration time.Duration) retryPolicy { @@ -581,7 +581,7 @@ func submitToDA[T any]( if err != nil { s.logger.Error(). Str("itemType", itemType). - Int("maxBlobBytes", pol.MaxBlobBytes). + Uint64("maxBlobBytes", pol.MaxBlobBytes). Err(err). Msg("CRITICAL: Unrecoverable error - item exceeds maximum blob size") return fmt.Errorf("unrecoverable error: no %s items fit within max blob size: %w", itemType, err) @@ -644,7 +644,7 @@ func submitToDA[T any]( if len(items) == 1 { s.logger.Error(). Str("itemType", itemType). - Int("maxBlobBytes", pol.MaxBlobBytes). + Uint64("maxBlobBytes", pol.MaxBlobBytes). Msg("CRITICAL: Unrecoverable error - single item exceeds DA blob size limit") return fmt.Errorf("unrecoverable error: %w: single %s item exceeds DA blob size limit", common.ErrOversizedItem, itemType) } @@ -690,11 +690,11 @@ func submitToDA[T any]( // limitBatchBySize returns a prefix of items whose total marshaled size does not exceed maxBytes. // If the first item exceeds maxBytes, it returns ErrOversizedItem which is unrecoverable. -func limitBatchBySize[T any](items []T, marshaled [][]byte, maxBytes int) ([]T, [][]byte, error) { - total := 0 +func limitBatchBySize[T any](items []T, marshaled [][]byte, maxBytes uint64) ([]T, [][]byte, error) { + total := uint64(0) count := 0 for i := range items { - sz := len(marshaled[i]) + sz := uint64(len(marshaled[i])) if sz > maxBytes { if i == 0 { return nil, nil, fmt.Errorf("%w: item size %d exceeds max %d", common.ErrOversizedItem, sz, maxBytes) diff --git a/block/internal/submitting/submitter.go b/block/internal/submitting/submitter.go index 34fab216de..da181ec1cc 100644 --- a/block/internal/submitting/submitter.go +++ b/block/internal/submitting/submitter.go @@ -201,9 +201,9 @@ func (s *Submitter) daSubmissionLoop() { } // Calculate total size (excluding signature) - totalSize := 0 + totalSize := uint64(0) for _, marshalled := range marshalledHeaders { - totalSize += len(marshalled) + totalSize += uint64(len(marshalled)) } shouldSubmit := s.batchingStrategy.ShouldSubmit( @@ -217,7 +217,7 @@ func (s *Submitter) daSubmissionLoop() { s.logger.Debug(). Time("t", time.Now()). Uint64("headers", headersNb). - Int("total_size_kb", totalSize/1024). + Uint64("total_size_kb", totalSize/1024). Dur("time_since_last", timeSinceLastSubmit). Msg("batching strategy triggered header submission") @@ -260,10 +260,9 @@ func (s *Submitter) daSubmissionLoop() { return } - // Calculate total size (excluding signature) - totalSize := 0 + totalSize := uint64(0) for _, marshalled := range marshalledData { - totalSize += len(marshalled) + totalSize += uint64(len(marshalled)) } shouldSubmit := s.batchingStrategy.ShouldSubmit( @@ -277,7 +276,7 @@ func (s *Submitter) daSubmissionLoop() { s.logger.Debug(). Time("t", time.Now()). Uint64("data", dataNb). - Int("total_size_kb", totalSize/1024). + Uint64("total_size_kb", totalSize/1024). Dur("time_since_last", timeSinceLastSubmit). Msg("batching strategy triggered data submission") From 3d84f81296735d78c570bc98741f0f2518fa384e Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 11:38:39 +0200 Subject: [PATCH 03/23] refactor: wire solo sequencer in testapp --- apps/testapp/Dockerfile | 3 +- apps/testapp/cmd/root.go | 6 ++- apps/testapp/cmd/run.go | 9 +++- block/internal/common/consts.go | 3 +- pkg/sequencers/solo/sequencer.go | 30 +++--------- pkg/sequencers/solo/sequencer_test.go | 68 ++++----------------------- 6 files changed, 33 insertions(+), 86 deletions(-) diff --git a/apps/testapp/Dockerfile b/apps/testapp/Dockerfile index d2be1823cb..b3d9cdc021 100644 --- a/apps/testapp/Dockerfile +++ b/apps/testapp/Dockerfile @@ -27,7 +27,8 @@ RUN go mod download && (cd apps/testapp && go mod download) COPY . . WORKDIR /ev-node/apps/testapp -RUN go build -o /go/bin/testapp . + +RUN go build -ldflags "-X github.com/evstack/ev-node/block/internal/common.DefaultMaxBlobSize=125829120" -o /go/bin/testapp . ## prep the final image. # diff --git a/apps/testapp/cmd/root.go b/apps/testapp/cmd/root.go index 8e58cc794d..17db405566 100644 --- a/apps/testapp/cmd/root.go +++ b/apps/testapp/cmd/root.go @@ -12,13 +12,17 @@ const ( // flagKVEndpoint is the flag for the KV endpoint flagKVEndpoint = "kv-endpoint" + // flagSoloSequencer is the flag to enable a solo sequencer + flagSoloSequencer = "solo-sequencer" ) func init() { config.AddGlobalFlags(RootCmd, AppName) config.AddFlags(RunCmd) - // Add the KV endpoint flag specifically to the RunCmd + + // add more flags to RunCmd RunCmd.Flags().String(flagKVEndpoint, "", "Address and port for the KV executor HTTP server") + RunCmd.Flags().Bool(flagSoloSequencer, true, "Enable Solo sequencer (instead of based sequencer or single sequencer)") } // RootCmd is the root command for Evolve diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index ea4b0c51d0..52b01d230f 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -22,6 +22,7 @@ import ( "github.com/evstack/ev-node/pkg/p2p/key" "github.com/evstack/ev-node/pkg/sequencers/based" "github.com/evstack/ev-node/pkg/sequencers/single" + "github.com/evstack/ev-node/pkg/sequencers/solo" "github.com/evstack/ev-node/pkg/store" ) @@ -91,7 +92,7 @@ var RunCmd = &cobra.Command{ } // Create sequencer based on configuration - sequencer, err := createSequencer(ctx, logger, datastore, nodeConfig, genesis, executor) + sequencer, err := createSequencer(ctx, command, logger, datastore, nodeConfig, genesis, executor) if err != nil { return err } @@ -105,12 +106,18 @@ var RunCmd = &cobra.Command{ // Otherwise, it creates a single (traditional) sequencer. func createSequencer( ctx context.Context, + cmd *cobra.Command, logger zerolog.Logger, datastore datastore.Batching, nodeConfig config.Config, genesis genesis.Genesis, executor execution.Executor, ) (coresequencer.Sequencer, error) { + if enabled, _ := cmd.Flags().GetBool(flagSoloSequencer); enabled { + logger.Info().Msg("using solo sequencer") + return solo.NewSoloSequencer(logger, nodeConfig, []byte(genesis.ChainID), executor), nil + } + blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") if err != nil { return nil, fmt.Errorf("failed to create blob client: %w", err) diff --git a/block/internal/common/consts.go b/block/internal/common/consts.go index c6c2bcd7d4..857ee87690 100644 --- a/block/internal/common/consts.go +++ b/block/internal/common/consts.go @@ -1,7 +1,6 @@ package common -// DefaultMaxBlobSize is the fallback blob size limit used when the DA layer -// does not report one. Override at build time with: +// DefaultMaxBlobSize is the fallback blob size limit used with the DA layer. // // go build -ldflags "-X github.com/evstack/ev-node/block/internal/common.DefaultMaxBlobSize=10485760" var DefaultMaxBlobSize uint64 = 5 * 1024 * 1024 // 5MB diff --git a/pkg/sequencers/solo/sequencer.go b/pkg/sequencers/solo/sequencer.go index 633466905d..6f56e9ef8f 100644 --- a/pkg/sequencers/solo/sequencer.go +++ b/pkg/sequencers/solo/sequencer.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "errors" - "fmt" "sync" "sync/atomic" "time" @@ -14,13 +13,9 @@ import ( "github.com/evstack/ev-node/core/execution" coresequencer "github.com/evstack/ev-node/core/sequencer" "github.com/evstack/ev-node/pkg/config" - "github.com/evstack/ev-node/pkg/genesis" ) -var ( - ErrInvalidID = errors.New("invalid chain id") - ErrQueueFull = errors.New("transaction queue is full") -) +var ErrInvalidID = errors.New("invalid chain id") var _ coresequencer.Sequencer = (*SoloSequencer)(nil) @@ -29,32 +24,26 @@ var _ coresequencer.Sequencer = (*SoloSequencer)(nil) // produces batches on demand. type SoloSequencer struct { logger zerolog.Logger - genesis genesis.Genesis id []byte executor execution.Executor daHeight atomic.Uint64 - mu sync.Mutex - queue [][]byte - maxQueueSize int + mu sync.Mutex + queue [][]byte } func NewSoloSequencer( logger zerolog.Logger, cfg config.Config, id []byte, - maxQueueSize int, - genesis genesis.Genesis, executor execution.Executor, ) *SoloSequencer { return &SoloSequencer{ - logger: logger, - genesis: genesis, - id: id, - executor: executor, - queue: make([][]byte, 0), - maxQueueSize: maxQueueSize, + logger: logger, + id: id, + executor: executor, + queue: make([][]byte, 0), } } @@ -74,11 +63,6 @@ func (s *SoloSequencer) SubmitBatchTxs(ctx context.Context, req coresequencer.Su s.mu.Lock() defer s.mu.Unlock() - if s.maxQueueSize > 0 && len(s.queue)+len(req.Batch.Transactions) > s.maxQueueSize { - return nil, fmt.Errorf("%w: queue has %d txs, batch has %d txs, limit is %d", - ErrQueueFull, len(s.queue), len(req.Batch.Transactions), s.maxQueueSize) - } - s.queue = append(s.queue, req.Batch.Transactions...) return &coresequencer.SubmitBatchTxsResponse{}, nil } diff --git a/pkg/sequencers/solo/sequencer_test.go b/pkg/sequencers/solo/sequencer_test.go index fb5f4a4a6e..f7ae0e1518 100644 --- a/pkg/sequencers/solo/sequencer_test.go +++ b/pkg/sequencers/solo/sequencer_test.go @@ -13,7 +13,6 @@ import ( "github.com/evstack/ev-node/core/execution" coresequencer "github.com/evstack/ev-node/core/sequencer" "github.com/evstack/ev-node/pkg/config" - "github.com/evstack/ev-node/pkg/genesis" "github.com/evstack/ev-node/test/mocks" ) @@ -40,19 +39,17 @@ func createDefaultMockExecutor(t *testing.T) *mocks.MockExecutor { return mockExec } -func newTestSequencer(t *testing.T, maxQueueSize int) *SoloSequencer { +func newTestSequencer(t *testing.T) *SoloSequencer { return NewSoloSequencer( zerolog.Nop(), config.DefaultConfig(), []byte("test"), - maxQueueSize, - genesis.Genesis{ChainID: "test"}, createDefaultMockExecutor(t), ) } func TestSoloSequencer_SubmitBatchTxs(t *testing.T) { - seq := newTestSequencer(t, 0) + seq := newTestSequencer(t) tx := []byte("transaction1") res, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ @@ -69,7 +66,7 @@ func TestSoloSequencer_SubmitBatchTxs(t *testing.T) { } func TestSoloSequencer_SubmitBatchTxs_InvalidID(t *testing.T) { - seq := newTestSequencer(t, 0) + seq := newTestSequencer(t) res, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ Id: []byte("wrong"), @@ -80,7 +77,7 @@ func TestSoloSequencer_SubmitBatchTxs_InvalidID(t *testing.T) { } func TestSoloSequencer_SubmitBatchTxs_EmptyBatch(t *testing.T) { - seq := newTestSequencer(t, 0) + seq := newTestSequencer(t) res, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ Id: []byte("test"), @@ -100,26 +97,8 @@ func TestSoloSequencer_SubmitBatchTxs_EmptyBatch(t *testing.T) { assert.Empty(t, seq.queue) } -func TestSoloSequencer_SubmitBatchTxs_QueueFull(t *testing.T) { - seq := newTestSequencer(t, 3) // max 3 txs - - batch := coresequencer.Batch{Transactions: [][]byte{{1}, {2}, {3}}} - _, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ - Id: []byte("test"), - Batch: &batch, - }) - require.NoError(t, err) - - res, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ - Id: []byte("test"), - Batch: &coresequencer.Batch{Transactions: [][]byte{{4}}}, - }) - assert.ErrorIs(t, err, ErrQueueFull) - assert.Nil(t, res) -} - func TestSoloSequencer_GetNextBatch_EmptyQueue(t *testing.T) { - seq := newTestSequencer(t, 0) + seq := newTestSequencer(t) resp, err := seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("test")}) require.NoError(t, err) @@ -129,7 +108,7 @@ func TestSoloSequencer_GetNextBatch_EmptyQueue(t *testing.T) { } func TestSoloSequencer_GetNextBatch_InvalidID(t *testing.T) { - seq := newTestSequencer(t, 0) + seq := newTestSequencer(t) res, err := seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("wrong")}) assert.ErrorIs(t, err, ErrInvalidID) @@ -137,7 +116,7 @@ func TestSoloSequencer_GetNextBatch_InvalidID(t *testing.T) { } func TestSoloSequencer_GetNextBatch_DrainsAndFilters(t *testing.T) { - seq := newTestSequencer(t, 0) + seq := newTestSequencer(t) batch := coresequencer.Batch{Transactions: [][]byte{{1}, {2}, {3}}} _, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ @@ -175,8 +154,6 @@ func TestSoloSequencer_GetNextBatch_PostponedTxsRequeued(t *testing.T) { zerolog.Nop(), config.DefaultConfig(), []byte("test"), - 0, - genesis.Genesis{ChainID: "test"}, mockExec, ) @@ -197,7 +174,7 @@ func TestSoloSequencer_GetNextBatch_PostponedTxsRequeued(t *testing.T) { } func TestSoloSequencer_GetNextBatch_SubmitDuringProcessing(t *testing.T) { - seq := newTestSequencer(t, 0) + seq := newTestSequencer(t) batch := coresequencer.Batch{Transactions: [][]byte{{1}, {2}}} _, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ @@ -223,7 +200,7 @@ func TestSoloSequencer_GetNextBatch_SubmitDuringProcessing(t *testing.T) { } func TestSoloSequencer_VerifyBatch(t *testing.T) { - seq := newTestSequencer(t, 0) + seq := newTestSequencer(t) batchData := [][]byte{[]byte("batch1"), []byte("batch2")} @@ -236,35 +213,10 @@ func TestSoloSequencer_VerifyBatch(t *testing.T) { } func TestSoloSequencer_DAHeight(t *testing.T) { - seq := newTestSequencer(t, 0) + seq := newTestSequencer(t) assert.Equal(t, uint64(0), seq.GetDAHeight()) seq.SetDAHeight(42) assert.Equal(t, uint64(42), seq.GetDAHeight()) } - -func TestSoloSequencer_QueueFullThenFreed(t *testing.T) { - seq := newTestSequencer(t, 2) // max 2 txs - - _, err := seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ - Id: []byte("test"), - Batch: &coresequencer.Batch{Transactions: [][]byte{{1}, {2}}}, - }) - require.NoError(t, err) - - _, err = seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ - Id: []byte("test"), - Batch: &coresequencer.Batch{Transactions: [][]byte{{3}}}, - }) - assert.ErrorIs(t, err, ErrQueueFull) - - _, err = seq.GetNextBatch(context.Background(), coresequencer.GetNextBatchRequest{Id: []byte("test")}) - require.NoError(t, err) - - _, err = seq.SubmitBatchTxs(context.Background(), coresequencer.SubmitBatchTxsRequest{ - Id: []byte("test"), - Batch: &coresequencer.Batch{Transactions: [][]byte{{3}}}, - }) - assert.NoError(t, err, "submission should succeed after queue is drained") -} From 77b58a6bfb270ea5242a4dcc61586352c39bf10b Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 12:28:03 +0200 Subject: [PATCH 04/23] updates --- CHANGELOG.md | 149 +++++++++--------- apps/testapp/cmd/run.go | 4 + block/internal/submitting/submitter.go | 1 + .../syncing/syncer_forced_inclusion_test.go | 4 +- pkg/sequencers/solo/README.md | 30 ++-- 5 files changed, 98 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b960ed27a..6513355c78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,47 +9,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changes + +- Make easier to override `DefaultMaxBlobSize` by ldflags [#3235](https://github.com/evstack/ev-node/pull/3235) +- Add solo sequencer (simple in memory single sequencer without force inclusion) [#3235](https://github.com/evstack/ev-node/pull/3235) + ## v1.1.0-rc.2 ### Changes -* Added publisher-mode synchronization option for failover scenarios with early P2P infrastructure readiness [#3222](https://github.com/evstack/ev-node/pull/3222) -* Improve P2P transient network failure [#3212](https://github.com/evstack/ev-node/pull/3212) -* Improve execution/evm check for stored meta not stale [#3221](https://github.com/evstack/ev-node/pull/3221) +- Added publisher-mode synchronization option for failover scenarios with early P2P infrastructure readiness [#3222](https://github.com/evstack/ev-node/pull/3222) +- Improve P2P transient network failure [#3212](https://github.com/evstack/ev-node/pull/3212) +- Improve execution/evm check for stored meta not stale [#3221](https://github.com/evstack/ev-node/pull/3221) ## v1.1.0-rc.1 ### Added -* Add AWS & GCP KMS signer backend [#3171](https://github.com/evstack/ev-node/pull/3171) -* Subscribe to forced inclusion namespace events [#3146](https://github.com/evstack/ev-node/pull/3146) -* Display block source in sync log [#3193](https://github.com/evstack/ev-node/pull/3193) +- Add AWS & GCP KMS signer backend [#3171](https://github.com/evstack/ev-node/pull/3171) +- Subscribe to forced inclusion namespace events [#3146](https://github.com/evstack/ev-node/pull/3146) +- Display block source in sync log [#3193](https://github.com/evstack/ev-node/pull/3193) ### Fixed -* Avoid evicting yet to be processed heights [#3204](https://github.com/evstack/ev-node/pull/3204) -* Bound Badger index cache memory to prevent growth with chain length [3209](https://github.com/evstack/ev-node/pull/3209) -* Refetch latest da height instead of da height +1 when P2P is offline [#3201](https://github.com/evstack/ev-node/pull/3201) -* Fix race on startup sync. [#3162](https://github.com/evstack/ev-node/pull/3162) -* Strict raft state. [#3167](https://github.com/evstack/ev-node/pull/3167) -* Retry fetching the timestamp on error in da-client [#3166](https://github.com/evstack/ev-node/pull/3166) +- Avoid evicting yet to be processed heights [#3204](https://github.com/evstack/ev-node/pull/3204) +- Bound Badger index cache memory to prevent growth with chain length [3209](https://github.com/evstack/ev-node/pull/3209) +- Refetch latest da height instead of da height +1 when P2P is offline [#3201](https://github.com/evstack/ev-node/pull/3201) +- Fix race on startup sync. [#3162](https://github.com/evstack/ev-node/pull/3162) +- Strict raft state. [#3167](https://github.com/evstack/ev-node/pull/3167) +- Retry fetching the timestamp on error in da-client [#3166](https://github.com/evstack/ev-node/pull/3166) ## v1.0.0 ### Fixed -* Persist cache snapshot only once at shutdown to avoid Badger vlog +- Persist cache snapshot only once at shutdown to avoid Badger vlog increase. [#3153](https://github.com/evstack/ev-node/pull/3153) ## v1.0.0-rc.5 ### Added -* Add disaster recovery for sequencer - * Catch up possible DA-only blocks when restarting. [#3057](https://github.com/evstack/ev-node/pull/3057) - * Verify DA and P2P state on restart (prevent double-signing). [#3061](https://github.com/evstack/ev-node/pull/3061) -* Node pruning support. [#2984](https://github.com/evstack/ev-node/pull/2984) - * Two different sort of pruning implemented: +- Add disaster recovery for sequencer + - Catch up possible DA-only blocks when restarting. [#3057](https://github.com/evstack/ev-node/pull/3057) + - Verify DA and P2P state on restart (prevent double-signing). [#3061](https://github.com/evstack/ev-node/pull/3061) +- Node pruning support. [#2984](https://github.com/evstack/ev-node/pull/2984) + - Two different sort of pruning implemented: _Classic pruning_ (`all`): prunes given `HEAD-n` blocks from the databases, including store metadatas. _Auto Storage Optimization_ (`metadata`): prunes only the state metadatas, keeps all blocks. By using one or the other, you are losing the ability to rollback or replay transactions earlier than `HEAD-n`. @@ -57,56 +62,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -* Fix block timer to account for execution time. Previously, the block timer reset to the full `block_time` duration +- Fix block timer to account for execution time. Previously, the block timer reset to the full `block_time` duration after `ProduceBlock` completed, making the effective interval `block_time + execution_time`. Now the timer subtracts elapsed execution time so blocks are produced at the configured cadence. ### Changes -* Store pending blocks separately from executed blocks key. [#3073](https://github.com/evstack/ev-node/pull/3073) -* Fixes issues with force inclusion verification on sync nodes. [#3057](https://github.com/evstack/ev-node/pull/3057) -* Add flag to `local-da` to produce empty DA blocks (closer to the real +- Store pending blocks separately from executed blocks key. [#3073](https://github.com/evstack/ev-node/pull/3073) +- Fixes issues with force inclusion verification on sync nodes. [#3057](https://github.com/evstack/ev-node/pull/3057) +- Add flag to `local-da` to produce empty DA blocks (closer to the real system). [#3057](https://github.com/evstack/ev-node/pull/3057) -* Validate P2P DA height hints against the latest known DA height to prevent malicious peers from triggering runaway +- Validate P2P DA height hints against the latest known DA height to prevent malicious peers from triggering runaway catchup. [#3128](https://github.com/evstack/ev-node/pull/3128) -* Replace syncer DA polling system by DA subscription via +- Replace syncer DA polling system by DA subscription via websockets. [#3131](https://github.com/evstack/ev-node/pull/3131) ## v1.0.0-rc.4 ### Changes -* Skip draining when exec client unavailable. [#3060](https://github.com/evstack/ev-node/pull/3060) +- Skip draining when exec client unavailable. [#3060](https://github.com/evstack/ev-node/pull/3060) ## v1.0.0-rc.3 ### Added -* Add DA Hints for P2P transactions. This allows a catching up node to be on sync with both DA and +- Add DA Hints for P2P transactions. This allows a catching up node to be on sync with both DA and P2P. ([#2891](https://github.com/evstack/ev-node/pull/2891)) ### Changes -* Improve `cache.NumPendingData` to not return empty data. Automatically bumps `LastSubmittedHeight` to reflect +- Improve `cache.NumPendingData` to not return empty data. Automatically bumps `LastSubmittedHeight` to reflect that. ([#3046](https://github.com/evstack/ev-node/pull/3046)) -* **BREAKING** Make pending events cache and tx cache fully ephemeral. Those will be re-fetched on restart. DA Inclusion +- **BREAKING** Make pending events cache and tx cache fully ephemeral. Those will be re-fetched on restart. DA Inclusion cache persists until cleared up after DA inclusion has been processed. Persist accross restart using store metadata. ([#3047](https://github.com/evstack/ev-node/pull/3047)) -* Replace LRU cache by standard mem cache with manual eviction in `store_adapter`. When P2P blocks were fetched too +- Replace LRU cache by standard mem cache with manual eviction in `store_adapter`. When P2P blocks were fetched too fast, they would be evicted before being executed [#3051](https://github.com/evstack/ev-node/pull/3051) -* Fix replay logic leading to app hashes by verifying against the wrong +- Fix replay logic leading to app hashes by verifying against the wrong block [#3053](https://github.com/evstack/ev-node/pull/3053). ## v1.0.0-rc.2 ### Changes -* Improve cache handling when there is a significant backlog of pending headers and +- Improve cache handling when there is a significant backlog of pending headers and data. ([#3030](https://github.com/evstack/ev-node/pull/3030)) -* Decrease MaxBytesSize to `5MB` to increase compatibility with public +- Decrease MaxBytesSize to `5MB` to increase compatibility with public nodes. ([#3030](https://github.com/evstack/ev-node/pull/3030)) -* Proper counting of `DASubmitterPendingBlobs` metrics. [#3038](https://github.com/evstack/ev-node/pull/3038) -* Replace `go-header` store by `ev-node` store. This avoid duplication of all blocks in `go-header` and `ev-node` store. +- Proper counting of `DASubmitterPendingBlobs` metrics. [#3038](https://github.com/evstack/ev-node/pull/3038) +- Replace `go-header` store by `ev-node` store. This avoid duplication of all blocks in `go-header` and `ev-node` store. Thanks to the cached store from #3030, this should improve p2p performance as well. [#3036](https://github.com/evstack/ev-node/pull/3036) @@ -114,45 +119,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -* Added OpenTelemetry tracing support with OTLP export for distributed tracing across ev-node components including block +- Added OpenTelemetry tracing support with OTLP export for distributed tracing across ev-node components including block production, syncing, DA submission/retrieval, sequencer, store operations, and RPC layer. Configurable via `instrumentation.tracing`, `instrumentation.tracing_endpoint`, `instrumentation.tracing_service_name`, and `instrumentation.tracing_sample_rate` settings. ([#2956](https://github.com/evstack/ev-node/issues/2956)) -* **BREAKING:** Implement forced inclusion and batch sequencing ([#2797](https://github.com/evstack/ev-node/pull/2797)) +- **BREAKING:** Implement forced inclusion and batch sequencing ([#2797](https://github.com/evstack/ev-node/pull/2797)) **This change requires adding a `da_epoch_forced_inclusion` field to the node's `genesis.json` file.** The recommended value is `100`. Full support for this feature will be available in a future release. -* Added `post-tx` command and force inclusion server to submit transactions directly to the DA +- Added `post-tx` command and force inclusion server to submit transactions directly to the DA layer. ([#2888](https://github.com/evstack/ev-node/pull/2888)) Additionally, modified the core package to support marking transactions as forced included transactions. The execution client ought to perform basic validation on those transactions as they have skipped the execution client's mempool. -* Added batching strategies (default stay time-based, unchanged from previous betas). Currently available strategies are +- Added batching strategies (default stay time-based, unchanged from previous betas). Currently available strategies are `time`, `size`, `immediate` and `adaptive`. [Full documentation can be found here](https://github.com/evstack/ev-node/blob/122486de98d09ecd37d792b88814dcf07238f28a/docs/learn/config.md?plain=1#L521-L597). -* Added `FilterTxs` method to the execution interface. This method is meant to filter txs by size and if the execution +- Added `FilterTxs` method to the execution interface. This method is meant to filter txs by size and if the execution clients allows it, by gas. This is useful for force included transactions, as those aren't filtered by the sequencer's mempool. -* Added `GetExecutionInfo` method to the execution interface. This method returns some execution information, such as +- Added `GetExecutionInfo` method to the execution interface. This method returns some execution information, such as the maximum gas per block. ### Changed -* **BREAKING:** Renamed `evm-single` to `evm` and `grpc-single` to `evgrpc` for +- **BREAKING:** Renamed `evm-single` to `evm` and `grpc-single` to `evgrpc` for clarity. [#2839](https://github.com/evstack/ev-node/pull/2839). You may need to manually modify your evnode.yaml `signer.signer_path` if your $HOME folder is changed. -* Split cache interface into `CacheManager` and `PendingManager` and created `da` client to easy DA +- Split cache interface into `CacheManager` and `PendingManager` and created `da` client to easy DA handling. [#2878](https://github.com/evstack/ev-node/pull/2878) -* Improved startup DA retrieval height when cache is cleared or +- Improved startup DA retrieval height when cache is cleared or empty. [#2880](https://github.com/evstack/ev-node/pull/2880) ### Removed -* **BREAKING:** Removed unused and confusing metrics from sequencers and block processing, including sequencer-specific +- **BREAKING:** Removed unused and confusing metrics from sequencers and block processing, including sequencer-specific metrics (gas price, blob size, transaction status, pending blocks), channel buffer metrics, overly granular error metrics, block production categorization metrics, and sync lag metrics. Essential metrics for DA submission health, block production, and performance monitoring are retained. [#2904](https://github.com/evstack/ev-node/pull/2904) -* **BREAKING**: Removed `core/da` package and replaced DAClient with internal implementation. The DA client is exposed +- **BREAKING**: Removed `core/da` package and replaced DAClient with internal implementation. The DA client is exposed as `block.FullDAClient`, `block.DAClient`, `block.DAVerifier` without leaking implementation details. [#2910](https://github.com/evstack/ev-node/pull/2910) @@ -160,53 +165,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Improvements -* Loosen syncer validation for allowing swapping sequencer and full node +- Loosen syncer validation for allowing swapping sequencer and full node state [#2925](https://github.com/evstack/ev-node/pull/2925) ## v1.0.0-beta.10 ### Added -* Enhanced health check system with separate liveness (`/health/live`) and readiness (`/health/ready`) HTTP endpoints. +- Enhanced health check system with separate liveness (`/health/live`) and readiness (`/health/ready`) HTTP endpoints. Readiness endpoint includes P2P listening check and aggregator block production rate validation (5x block time threshold). ([#2800](https://github.com/evstack/ev-node/pull/2800)) -* Added `GetP2PStoreInfo` RPC method to retrieve head/tail metadata for go-header stores used by P2P +- Added `GetP2PStoreInfo` RPC method to retrieve head/tail metadata for go-header stores used by P2P sync ([#2835](https://github.com/evstack/ev-node/pull/2835)) -* Added protobuf definitions for `P2PStoreEntry` and `P2PStoreSnapshot` messages to support P2P store inspection +- Added protobuf definitions for `P2PStoreEntry` and `P2PStoreSnapshot` messages to support P2P store inspection ### Changed -* Improved EVM execution client payload status validation with proper retry logic for SYNCING states in `InitChain`, +- Improved EVM execution client payload status validation with proper retry logic for SYNCING states in `InitChain`, `ExecuteTxs`, and `SetFinal` methods. The implementation now follows Engine API specification by retrying SYNCING/ACCEPTED status with exponential backoff and failing immediately on INVALID status, preventing unnecessary node shutdowns during transient execution engine sync operations. ([#2863](https://github.com/evstack/ev-node/pull/2863)) -* Remove GasPrice and GasMultiplier from DA interface and configuration to use celestia-node's native fee +- Remove GasPrice and GasMultiplier from DA interface and configuration to use celestia-node's native fee estimation. ([#2822](https://github.com/evstack/ev-node/pull/2822)) -* Use cache instead of in memory store for reaper. Persist cache on reload. Autoclean after 24 +- Use cache instead of in memory store for reaper. Persist cache on reload. Autoclean after 24 hours. ([#2811](https://github.com/evstack/ev-node/pull/2811)) -* Improved P2P sync service store initialization to be atomic and prevent race +- Improved P2P sync service store initialization to be atomic and prevent race conditions ([#2838](https://github.com/evstack/ev-node/pull/2838)) -* Enhanced P2P bootstrap behavior to intelligently detect starting height from local store instead of requiring trusted +- Enhanced P2P bootstrap behavior to intelligently detect starting height from local store instead of requiring trusted hash -* Relaxed execution layer height validation in block replay to allow execution to be ahead of target height, enabling +- Relaxed execution layer height validation in block replay to allow execution to be ahead of target height, enabling recovery from manual intervention scenarios ### Removed -* **BREAKING:** Removed `evnode.v1.HealthService` gRPC endpoint. Use HTTP endpoints: `GET /health/live` and +- **BREAKING:** Removed `evnode.v1.HealthService` gRPC endpoint. Use HTTP endpoints: `GET /health/live` and `GET /health/ready`. ([#2800](https://github.com/evstack/ev-node/pull/2800)) -* **BREAKING:** Removed `TrustedHash` configuration option and `--evnode.node.trusted_hash` flag. Sync service now +- **BREAKING:** Removed `TrustedHash` configuration option and `--evnode.node.trusted_hash` flag. Sync service now automatically determines starting height from local store state ([#2838](https://github.com/evstack/ev-node/pull/2838)) -* **BREAKING:** Removed unused and confusing metrics from sequencers and block processing, including sequencer-specific +- **BREAKING:** Removed unused and confusing metrics from sequencers and block processing, including sequencer-specific metrics (gas price, blob size, transaction status, pending blocks), channel buffer metrics, overly granular error metrics, block production categorization metrics, and sync lag metrics. Essential metrics for DA submission health, block production, and performance monitoring are retained. [#2904](https://github.com/evstack/ev-node/pull/2904) ### Fixed -* Fixed sync service initialization issue when node is not on genesis but has an empty store +- Fixed sync service initialization issue when node is not on genesis but has an empty store ## v1.0.0-beta.9 @@ -214,34 +219,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -* Added automated upgrade test for the `evm` app that verifies compatibility when moving from v1.0.0-beta.8 to HEAD in +- Added automated upgrade test for the `evm` app that verifies compatibility when moving from v1.0.0-beta.8 to HEAD in CI ([#2780](https://github.com/evstack/ev-node/pull/2780)) -* Added execution-layer replay mechanism so nodes can resynchronize by replaying missed batches against the +- Added execution-layer replay mechanism so nodes can resynchronize by replaying missed batches against the executor ([#2771](https://github.com/evstack/ev-node/pull/2771)) -* Added cache-pruning logic that evicts entries once heights are finalized to keep node memory usage +- Added cache-pruning logic that evicts entries once heights are finalized to keep node memory usage bounded ([#2761](https://github.com/evstack/ev-node/pull/2761)) -* Added Prometheus gauges and counters that surface DA submission failures, pending blobs, and resend attempts for +- Added Prometheus gauges and counters that surface DA submission failures, pending blobs, and resend attempts for easier operational monitoring ([#2756](https://github.com/evstack/ev-node/pull/2756)) -* Added gRPC execution client implementation for remote execution services using Connect-RPC +- Added gRPC execution client implementation for remote execution services using Connect-RPC protocol ([#2490](https://github.com/evstack/ev-node/pull/2490)) -* Added `ExecutorService` protobuf definition with InitChain, GetTxs, ExecuteTxs, and SetFinal +- Added `ExecutorService` protobuf definition with InitChain, GetTxs, ExecuteTxs, and SetFinal RPCs ([#2490](https://github.com/evstack/ev-node/pull/2490)) -* Added new `grpc` app for running EVNode with a remote execution layer via +- Added new `grpc` app for running EVNode with a remote execution layer via gRPC ([#2490](https://github.com/evstack/ev-node/pull/2490)) ### Changed -* Hardened signer CLI and block pipeline per security audit: passphrases must be provided via +- Hardened signer CLI and block pipeline per security audit: passphrases must be provided via `--evnode.signer.passphrase_file`, JWT secrets must be provided via `--evm.jwt-secret-file`, data/header validation enforces metadata and timestamp checks, and the reaper backs off on failures ( BREAKING) ([#2764](https://github.com/evstack/ev-node/pull/2764)) -* Added retries around executor `ExecuteTxs` calls to better tolerate transient execution +- Added retries around executor `ExecuteTxs` calls to better tolerate transient execution errors ([#2784](https://github.com/evstack/ev-node/pull/2784)) -* Increased default `ReadinessMaxBlocksBehind` from 3 to 30 blocks so `/health/ready` stays true during normal batch +- Increased default `ReadinessMaxBlocksBehind` from 3 to 30 blocks so `/health/ready` stays true during normal batch sync ([#2779](https://github.com/evstack/ev-node/pull/2779)) -* Updated EVM execution client to use new `txpoolExt_getTxs` RPC API for retrieving pending transactions as RLP-encoded +- Updated EVM execution client to use new `txpoolExt_getTxs` RPC API for retrieving pending transactions as RLP-encoded bytes ### Deprecated @@ -252,7 +257,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -* Removed `LastCommitHash`, `ConsensusHash`, and `LastResultsHash` from the canonical header representation in favor of +- Removed `LastCommitHash`, `ConsensusHash`, and `LastResultsHash` from the canonical header representation in favor of slim headers (BREAKING; legacy hashes now live under `Header.Legacy`) ([#2766](https://github.com/evstack/ev-node/pull/2766)) @@ -334,6 +339,6 @@ Pre-release versions: 0.x.y (anything may change) -* +- [Unreleased]: https://github.com/evstack/ev-node/compare/v1.0.0-beta.1...HEAD diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index 52b01d230f..a3c79b21ed 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -114,6 +114,10 @@ func createSequencer( executor execution.Executor, ) (coresequencer.Sequencer, error) { if enabled, _ := cmd.Flags().GetBool(flagSoloSequencer); enabled { + if nodeConfig.Node.BasedSequencer { + return nil, fmt.Errorf("solo sequencer cannot be used with based") + } + logger.Info().Msg("using solo sequencer") return solo.NewSoloSequencer(logger, nodeConfig, []byte(genesis.ChainID), executor), nil } diff --git a/block/internal/submitting/submitter.go b/block/internal/submitting/submitter.go index da181ec1cc..7a0ff721d1 100644 --- a/block/internal/submitting/submitter.go +++ b/block/internal/submitting/submitter.go @@ -260,6 +260,7 @@ func (s *Submitter) daSubmissionLoop() { return } + // Calculate total size (excluding signature) totalSize := uint64(0) for _, marshalled := range marshalledData { totalSize += uint64(len(marshalled)) diff --git a/block/internal/syncing/syncer_forced_inclusion_test.go b/block/internal/syncing/syncer_forced_inclusion_test.go index 7e43e55cc0..3c15fde125 100644 --- a/block/internal/syncing/syncer_forced_inclusion_test.go +++ b/block/internal/syncing/syncer_forced_inclusion_test.go @@ -480,7 +480,7 @@ func TestGracePeriodForEpoch_LightBlocks(t *testing.T) { func TestGracePeriodForEpoch_FullBlocks(t *testing.T) { s := &Syncer{daBlockBytes: make(map[uint64]uint64)} for h := uint64(0); h <= 4; h++ { - s.daBlockBytes[h] = uint64(common.DefaultMaxBlobSize) + s.daBlockBytes[h] = common.DefaultMaxBlobSize } grace := s.gracePeriodForEpoch(0, 4) require.GreaterOrEqual(t, grace, baseGracePeriodEpochs) @@ -501,7 +501,7 @@ func TestGracePeriodForEpoch_ExtendedUnderHighCongestion(t *testing.T) { // TestGracePeriodForEpoch_CappedAtMax verifies the grace period never exceeds maxGracePeriodEpochs. func TestGracePeriodForEpoch_CappedAtMax(t *testing.T) { s := &Syncer{daBlockBytes: make(map[uint64]uint64)} - huge := uint64(common.DefaultMaxBlobSize) * 100 + huge := common.DefaultMaxBlobSize * 100 for h := uint64(0); h <= 4; h++ { s.daBlockBytes[h] = huge } diff --git a/pkg/sequencers/solo/README.md b/pkg/sequencers/solo/README.md index 7f2670d51a..1e99213bb1 100644 --- a/pkg/sequencers/solo/README.md +++ b/pkg/sequencers/solo/README.md @@ -16,12 +16,12 @@ flowchart LR ## Design Decisions -| Decision | Rationale | -|---|---| -| In-memory queue | No persistence overhead; suitable for trusted single-operator setups | -| No forced inclusion | Avoids DA epoch tracking, checkpoint storage, and catch-up logic | -| No DA client dependency | `VerifyBatch` returns true unconditionally | -| Configurable queue limit | Provides backpressure when blocks can't be produced fast enough | +| Decision | Rationale | +| ------------------------ | -------------------------------------------------------------------- | +| In-memory queue | No persistence overhead; suitable for trusted single-operator setups | +| No forced inclusion | Avoids DA epoch tracking, checkpoint storage, and catch-up logic | +| No DA client dependency | `VerifyBatch` returns true unconditionally | +| Configurable queue limit | Provides backpressure when blocks can't be produced fast enough | ## Flow @@ -33,8 +33,6 @@ flowchart TD B -->|No| C["Return ErrInvalidID"] B -->|Yes| D{"Empty batch?"} D -->|Yes| E["Return OK"] - D -->|No| F{"Queue full?"} - F -->|Yes| G["Return ErrQueueFull"] F -->|No| H["Append txs to queue"] H --> E ``` @@ -80,11 +78,11 @@ resp, err := seq.GetNextBatch(ctx, coresequencer.GetNextBatchRequest{ ## Comparison with Other Sequencers -| Aspect | Solo | Single | Based | -|---|---|---|---| -| Mempool transactions | Yes | Yes | No | -| Forced inclusion | No | Yes | Yes | -| Persistence | None | DB-backed queue + checkpoints | Checkpoints only | -| Crash recovery | Lost on restart | Full recovery | Checkpoint-based | -| Catch-up mode | N/A | Yes | N/A | -| DA client required | No | Yes | Yes | +| Aspect | Solo | Single | Based | +| -------------------- | --------------- | ----------------------------- | ---------------- | +| Mempool transactions | Yes | Yes | No | +| Forced inclusion | No | Yes | Yes | +| Persistence | None | DB-backed queue + checkpoints | Checkpoints only | +| Crash recovery | Lost on restart | Full recovery | Checkpoint-based | +| Catch-up mode | N/A | Yes | N/A | +| DA client required | No | Yes | Yes | From 066544ce4197e31d4f4981844d06f4177097e650 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 14:08:38 +0200 Subject: [PATCH 05/23] refactor: reaper to drain mempool --- block/components.go | 2 +- block/internal/cache/manager.go | 9 + block/internal/reaping/reaper.go | 206 +++++++++--------- block/internal/reaping/reaper_test.go | 291 ++++++++++++++------------ docs/adr/adr-021-lazy-aggregation.md | 1 - 5 files changed, 275 insertions(+), 234 deletions(-) diff --git a/block/components.go b/block/components.go index 71b6f60523..ac5f782cdd 100644 --- a/block/components.go +++ b/block/components.go @@ -278,9 +278,9 @@ func newAggregatorComponents( sequencer, genesis, logger, - executor, cacheManager, config.Node.ScrapeInterval.Duration, + executor.NotifyNewTransactions, ) if err != nil { return nil, fmt.Errorf("failed to create reaper: %w", err) diff --git a/block/internal/cache/manager.go b/block/internal/cache/manager.go index 6f1b9d9cf3..e021c5ca44 100644 --- a/block/internal/cache/manager.go +++ b/block/internal/cache/manager.go @@ -53,6 +53,7 @@ type CacheManager interface { // Transaction operations IsTxSeen(hash string) bool SetTxSeen(hash string) + SetTxsSeen(hashes []string) CleanupOldTxs(olderThan time.Duration) int // Pending events syncing coordination @@ -210,6 +211,14 @@ func (m *implementation) SetTxSeen(hash string) { m.txTimestamps.Store(hash, time.Now()) } +func (m *implementation) SetTxsSeen(hashes []string) { + now := time.Now() + for _, hash := range hashes { + m.txCache.setSeen(hash, 0) + m.txTimestamps.Store(hash, now) + } +} + // CleanupOldTxs removes transaction hashes older than olderThan and returns // the count removed. Defaults to DefaultTxCacheRetention if olderThan <= 0. func (m *implementation) CleanupOldTxs(olderThan time.Duration) int { diff --git a/block/internal/reaping/reaper.go b/block/internal/reaping/reaper.go index 67b2020216..436727fdf2 100644 --- a/block/internal/reaping/reaper.go +++ b/block/internal/reaping/reaper.go @@ -12,7 +12,6 @@ import ( "github.com/rs/zerolog" "github.com/evstack/ev-node/block/internal/cache" - "github.com/evstack/ev-node/block/internal/executing" coreexecutor "github.com/evstack/ev-node/core/execution" coresequencer "github.com/evstack/ev-node/core/sequencer" "github.com/evstack/ev-node/pkg/genesis" @@ -21,40 +20,35 @@ import ( const ( // MaxBackoffInterval is the maximum backoff interval for retries MaxBackoffInterval = 30 * time.Second + CleanupInterval = 1 * time.Hour ) // Reaper is responsible for periodically retrieving transactions from the executor, // filtering out already seen transactions, and submitting new transactions to the sequencer. type Reaper struct { - exec coreexecutor.Executor - sequencer coresequencer.Sequencer - chainID string - interval time.Duration - cache cache.CacheManager - executor *executing.Executor - - // shared components + exec coreexecutor.Executor + sequencer coresequencer.Sequencer + chainID string + interval time.Duration + cache cache.CacheManager + onTxsSubmitted func() + logger zerolog.Logger - // Lifecycle ctx context.Context cancel context.CancelFunc wg sync.WaitGroup } -// NewReaper creates a new Reaper instance. func NewReaper( exec coreexecutor.Executor, sequencer coresequencer.Sequencer, genesis genesis.Genesis, logger zerolog.Logger, - executor *executing.Executor, cache cache.CacheManager, scrapeInterval time.Duration, + onTxsSubmitted func(), ) (*Reaper, error) { - if executor == nil { - return nil, errors.New("executor cannot be nil") - } if cache == nil { return nil, errors.New("cache cannot be nil") } @@ -63,13 +57,13 @@ func NewReaper( } return &Reaper{ - exec: exec, - sequencer: sequencer, - chainID: genesis.ChainID, - interval: scrapeInterval, - logger: logger.With().Str("component", "reaper").Logger(), - cache: cache, - executor: executor, + exec: exec, + sequencer: sequencer, + chainID: genesis.ChainID, + interval: scrapeInterval, + logger: logger.With().Str("component", "reaper").Logger(), + cache: cache, + onTxsSubmitted: onTxsSubmitted, }, nil } @@ -80,54 +74,56 @@ func (r *Reaper) Start(ctx context.Context) error { // Start reaper loop r.wg.Go(r.reaperLoop) - r.logger.Info().Dur("interval", r.interval).Msg("reaper started") + r.logger.Info().Dur("idle_interval", r.interval).Msg("reaper started") return nil } func (r *Reaper) reaperLoop() { - ticker := time.NewTicker(r.interval) - defer ticker.Stop() - - cleanupTicker := time.NewTicker(1 * time.Hour) + cleanupTicker := time.NewTicker(CleanupInterval) defer cleanupTicker.Stop() consecutiveFailures := 0 for { - select { - case <-r.ctx.Done(): - return - case <-ticker.C: - err := r.SubmitTxs() - if err != nil { - // Increment failure counter and apply exponential backoff - consecutiveFailures++ - backoff := r.interval * time.Duration(1< 0 { - r.logger.Info().Msg("reaper recovered from errors, resetting backoff") - consecutiveFailures = 0 - ticker.Reset(r.interval) - } - } - case <-cleanupTicker.C: - // Clean up transaction hashes older than 24 hours - // This prevents unbounded growth of the transaction seen cache - removed := r.cache.CleanupOldTxs(cache.DefaultTxCacheRetention) - if removed > 0 { - r.logger.Info().Int("removed", removed).Msg("cleaned up old transaction hashes") - } + submitted, err := r.drainMempool() + + if err != nil { + consecutiveFailures++ + backoff := r.interval * time.Duration(1< 0 { + r.logger.Info().Msg("reaper recovered from errors") + consecutiveFailures = 0 + } + + if submitted { + continue + } + + r.wait(r.interval, cleanupTicker.C) + } +} + +func (r *Reaper) wait(d time.Duration, cleanupCh <-chan time.Time) { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-r.ctx.Done(): + case <-cleanupCh: + removed := r.cache.CleanupOldTxs(cache.DefaultTxCacheRetention) + if removed > 0 { + r.logger.Info().Int("removed", removed).Msg("cleaned up old transaction hashes") } + case <-timer.C: } } @@ -137,60 +133,78 @@ func (r *Reaper) Stop() error { r.cancel() } r.wg.Wait() - r.logger.Info().Msg("reaper stopped") return nil } -// SubmitTxs retrieves transactions from the executor and submits them to the sequencer. -// Returns an error if any critical operation fails. -func (r *Reaper) SubmitTxs() error { - txs, err := r.exec.GetTxs(r.ctx) - if err != nil { - r.logger.Error().Err(err).Msg("failed to get txs from executor") - return fmt.Errorf("failed to get txs from executor: %w", err) +type pendingTx struct { + tx []byte + hash string +} + +func (r *Reaper) drainMempool() (bool, error) { + var totalSubmitted int + + for { + txs, err := r.exec.GetTxs(r.ctx) + if err != nil { + return totalSubmitted > 0, fmt.Errorf("failed to get txs from executor: %w", err) + } + if len(txs) == 0 { + break + } + + filtered := r.filterNewTxs(txs) + if len(filtered) == 0 { + continue + } + + n, err := r.submitFiltered(filtered) + if err != nil { + return totalSubmitted > 0, err + } + totalSubmitted += n } - if len(txs) == 0 { - r.logger.Debug().Msg("no new txs") - return nil + + if totalSubmitted > 0 { + r.logger.Debug().Int("total_txs", totalSubmitted).Msg("drained mempool") + if r.onTxsSubmitted != nil { + r.onTxsSubmitted() + } } - var newTxs [][]byte + return totalSubmitted > 0, nil +} + +func (r *Reaper) filterNewTxs(txs [][]byte) []pendingTx { + pending := make([]pendingTx, 0, len(txs)) for _, tx := range txs { - txHash := hashTx(tx) - if !r.cache.IsTxSeen(txHash) { - newTxs = append(newTxs, tx) + h := hashTx(tx) + if !r.cache.IsTxSeen(h) { + pending = append(pending, pendingTx{tx: tx, hash: h}) } } + return pending +} - if len(newTxs) == 0 { - r.logger.Debug().Msg("no new txs to submit") - return nil +func (r *Reaper) submitFiltered(batch []pendingTx) (int, error) { + txs := make([][]byte, len(batch)) + hashes := make([]string, len(batch)) + for i, p := range batch { + txs[i] = p.tx + hashes[i] = p.hash } - r.logger.Debug().Int("txCount", len(newTxs)).Msg("submitting txs to sequencer") - - _, err = r.sequencer.SubmitBatchTxs(r.ctx, coresequencer.SubmitBatchTxsRequest{ + _, err := r.sequencer.SubmitBatchTxs(r.ctx, coresequencer.SubmitBatchTxsRequest{ Id: []byte(r.chainID), - Batch: &coresequencer.Batch{Transactions: newTxs}, + Batch: &coresequencer.Batch{Transactions: txs}, }) if err != nil { - return fmt.Errorf("failed to submit txs to sequencer: %w", err) - } - - for _, tx := range newTxs { - txHash := hashTx(tx) - r.cache.SetTxSeen(txHash) - } - - // Notify the executor that new transactions are available - if len(newTxs) > 0 { - r.logger.Debug().Msg("notifying executor of new transactions") - r.executor.NotifyNewTransactions() + return 0, fmt.Errorf("failed to submit txs to sequencer: %w", err) } - r.logger.Debug().Msg("successfully submitted txs") - return nil + r.cache.SetTxsSeen(hashes) + return len(txs), nil } func hashTx(tx []byte) string { diff --git a/block/internal/reaping/reaper_test.go b/block/internal/reaping/reaper_test.go index 5700882e8a..cc8d71a748 100644 --- a/block/internal/reaping/reaper_test.go +++ b/block/internal/reaping/reaper_test.go @@ -2,214 +2,233 @@ package reaping import ( "context" - crand "crypto/rand" + "sync/atomic" "testing" "time" ds "github.com/ipfs/go-datastore" dssync "github.com/ipfs/go-datastore/sync" - "github.com/libp2p/go-libp2p/core/crypto" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/evstack/ev-node/block/internal/cache" - "github.com/evstack/ev-node/block/internal/common" - "github.com/evstack/ev-node/block/internal/executing" coresequencer "github.com/evstack/ev-node/core/sequencer" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" - "github.com/evstack/ev-node/pkg/signer/noop" "github.com/evstack/ev-node/pkg/store" testmocks "github.com/evstack/ev-node/test/mocks" ) -// helper to create a minimal executor to capture notifications -func newTestExecutor(t *testing.T) *executing.Executor { +func newTestCache(t *testing.T) cache.CacheManager { t.Helper() - - // signer is required by NewExecutor - priv, _, err := crypto.GenerateEd25519Key(crand.Reader) - require.NoError(t, err) - s, err := noop.NewNoopSigner(priv) - require.NoError(t, err) - - // Get the signer's address to use as proposer - signerAddr, err := s.GetAddress() - require.NoError(t, err) - - exec, err := executing.NewExecutor( - nil, // store (unused) - nil, // core executor (unused) - nil, // sequencer (unused) - s, // signer (required) - nil, // cache (unused) - nil, // metrics (unused) - config.DefaultConfig(), - genesis.Genesis{ // minimal genesis - ChainID: "test-chain", - InitialHeight: 1, - StartTime: time.Now(), - ProposerAddress: signerAddr, - }, - nil, // header broadcaster - nil, // data broadcaster - zerolog.Nop(), - common.DefaultBlockOptions(), - make(chan error, 1), // error channel - nil, - ) + cfg := config.Config{RootDir: t.TempDir()} + memDS := dssync.MutexWrap(ds.NewMapDatastore()) + st := store.New(memDS) + cm, err := cache.NewManager(cfg, st, zerolog.Nop()) require.NoError(t, err) + return cm +} - return exec +type testEnv struct { + execMock *testmocks.MockExecutor + seqMock *testmocks.MockSequencer + cache cache.CacheManager + reaper *Reaper + notified atomic.Bool } -// helper to create a cache manager for tests -func newTestCache(t *testing.T) cache.CacheManager { +func newTestEnv(t *testing.T) *testEnv { t.Helper() + mockExec := testmocks.NewMockExecutor(t) + mockSeq := testmocks.NewMockSequencer(t) + cm := newTestCache(t) - cfg := config.Config{ - RootDir: t.TempDir(), + env := &testEnv{ + execMock: mockExec, + seqMock: mockSeq, + cache: cm, } - // Create an in-memory store for the cache - memDS := dssync.MutexWrap(ds.NewMapDatastore()) - st := store.New(memDS) - - cacheManager, err := cache.NewManager(cfg, st, zerolog.Nop()) + r, err := NewReaper( + mockExec, mockSeq, + genesis.Genesis{ChainID: "test-chain"}, + zerolog.Nop(), cm, + 100*time.Millisecond, + env.notify, + ) require.NoError(t, err) + env.reaper = r - return cacheManager + return env } -// reaper with mocks and cache manager -func newTestReaper(t *testing.T, chainID string, execMock *testmocks.MockExecutor, seqMock *testmocks.MockSequencer, e *executing.Executor, cm cache.CacheManager) *Reaper { - t.Helper() - - r, err := NewReaper(execMock, seqMock, genesis.Genesis{ChainID: chainID}, zerolog.Nop(), e, cm, 100*time.Millisecond) - require.NoError(t, err) +func (e *testEnv) notify() { + e.notified.Store(true) +} - return r +func (e *testEnv) wasNotified() bool { + return e.notified.Load() } -func TestReaper_SubmitTxs_NewTxs_SubmitsAndPersistsAndNotifies(t *testing.T) { - mockExec := testmocks.NewMockExecutor(t) - mockSeq := testmocks.NewMockSequencer(t) +func TestReaper_NewTxs_SubmitsAndPersistsAndNotifies(t *testing.T) { + env := newTestEnv(t) - // Two new transactions tx1 := []byte("tx1") tx2 := []byte("tx2") - mockExec.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx1, tx2}, nil).Once() - // Expect a single SubmitBatchTxs with both txs - mockSeq.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). + env.execMock.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx1, tx2}, nil).Once() + env.execMock.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Once() + + env.seqMock.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). RunAndReturn(func(ctx context.Context, req coresequencer.SubmitBatchTxsRequest) (*coresequencer.SubmitBatchTxsResponse, error) { - require.Equal(t, []byte("chain-A"), req.Id) - require.NotNil(t, req.Batch) assert.Equal(t, [][]byte{tx1, tx2}, req.Batch.Transactions) return &coresequencer.SubmitBatchTxsResponse{}, nil }).Once() - // Minimal executor to capture NotifyNewTransactions - e := newTestExecutor(t) - cm := newTestCache(t) - - r := newTestReaper(t, "chain-A", mockExec, mockSeq, e, cm) - - assert.NoError(t, r.SubmitTxs()) - - // Verify transactions are marked as seen in cache - assert.True(t, cm.IsTxSeen(hashTx(tx1))) - assert.True(t, cm.IsTxSeen(hashTx(tx2))) - - // Executor notified - check using test helper - if !e.HasPendingTxNotification() { - t.Fatal("expected NotifyNewTransactions to signal txNotifyCh") - } + submitted, err := env.reaper.drainMempool() + assert.NoError(t, err) + assert.True(t, submitted) + assert.True(t, env.cache.IsTxSeen(hashTx(tx1))) + assert.True(t, env.cache.IsTxSeen(hashTx(tx2))) + assert.True(t, env.wasNotified()) } -func TestReaper_SubmitTxs_AllSeen_NoSubmit(t *testing.T) { - mockExec := testmocks.NewMockExecutor(t) - mockSeq := testmocks.NewMockSequencer(t) +func TestReaper_AllSeen_NoSubmit(t *testing.T) { + env := newTestEnv(t) tx1 := []byte("tx1") tx2 := []byte("tx2") - // Pre-populate cache with seen transactions - e := newTestExecutor(t) - cm := newTestCache(t) - cm.SetTxSeen(hashTx(tx1)) - cm.SetTxSeen(hashTx(tx2)) - - r := newTestReaper(t, "chain-B", mockExec, mockSeq, e, cm) - - mockExec.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx1, tx2}, nil).Once() - // No SubmitBatchTxs expected + env.cache.SetTxSeen(hashTx(tx1)) + env.cache.SetTxSeen(hashTx(tx2)) - assert.NoError(t, r.SubmitTxs()) + env.execMock.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx1, tx2}, nil).Once() + env.execMock.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Once() - // Ensure no notification occurred - if e.HasPendingTxNotification() { - t.Fatal("did not expect notification when all txs are seen") - } + submitted, err := env.reaper.drainMempool() + assert.NoError(t, err) + assert.False(t, submitted) + assert.False(t, env.wasNotified()) } -func TestReaper_SubmitTxs_PartialSeen_FiltersAndPersists(t *testing.T) { - mockExec := testmocks.NewMockExecutor(t) - mockSeq := testmocks.NewMockSequencer(t) +func TestReaper_PartialSeen_FiltersAndPersists(t *testing.T) { + env := newTestEnv(t) txOld := []byte("old") txNew := []byte("new") - e := newTestExecutor(t) - cm := newTestCache(t) - - // Mark txOld as seen - cm.SetTxSeen(hashTx(txOld)) + env.cache.SetTxSeen(hashTx(txOld)) - r := newTestReaper(t, "chain-C", mockExec, mockSeq, e, cm) + env.execMock.EXPECT().GetTxs(mock.Anything).Return([][]byte{txOld, txNew}, nil).Once() + env.execMock.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Once() - mockExec.EXPECT().GetTxs(mock.Anything).Return([][]byte{txOld, txNew}, nil).Once() - mockSeq.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). + env.seqMock.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). RunAndReturn(func(ctx context.Context, req coresequencer.SubmitBatchTxsRequest) (*coresequencer.SubmitBatchTxsResponse, error) { - // Should only include txNew assert.Equal(t, [][]byte{txNew}, req.Batch.Transactions) return &coresequencer.SubmitBatchTxsResponse{}, nil }).Once() - assert.NoError(t, r.SubmitTxs()) - - // Both should be seen after successful submit - assert.True(t, cm.IsTxSeen(hashTx(txOld))) - assert.True(t, cm.IsTxSeen(hashTx(txNew))) - - // Notification should occur since a new tx was submitted - if !e.HasPendingTxNotification() { - t.Fatal("expected notification when new tx submitted") - } + submitted, err := env.reaper.drainMempool() + assert.NoError(t, err) + assert.True(t, submitted) + assert.True(t, env.cache.IsTxSeen(hashTx(txOld))) + assert.True(t, env.cache.IsTxSeen(hashTx(txNew))) + assert.True(t, env.wasNotified()) } -func TestReaper_SubmitTxs_SequencerError_NoPersistence_NoNotify(t *testing.T) { - mockExec := testmocks.NewMockExecutor(t) - mockSeq := testmocks.NewMockSequencer(t) +func TestReaper_SequencerError_NoPersistence_NoNotify(t *testing.T) { + env := newTestEnv(t) tx := []byte("oops") - mockExec.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx}, nil).Once() - mockSeq.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). + + env.execMock.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx}, nil).Once() + + env.seqMock.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). Return((*coresequencer.SubmitBatchTxsResponse)(nil), assert.AnError).Once() - e := newTestExecutor(t) + _, err := env.reaper.drainMempool() + assert.Error(t, err) + assert.False(t, env.cache.IsTxSeen(hashTx(tx))) + assert.False(t, env.wasNotified()) +} + +func TestReaper_DrainsMempoolInMultipleRounds(t *testing.T) { + env := newTestEnv(t) + + tx1 := []byte("tx1") + tx2 := []byte("tx2") + tx3 := []byte("tx3") + + env.execMock.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx1, tx2}, nil).Once() + env.execMock.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx3}, nil).Once() + env.execMock.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Once() + + env.seqMock.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). + RunAndReturn(func(ctx context.Context, req coresequencer.SubmitBatchTxsRequest) (*coresequencer.SubmitBatchTxsResponse, error) { + return &coresequencer.SubmitBatchTxsResponse{}, nil + }).Twice() + + submitted, err := env.reaper.drainMempool() + assert.NoError(t, err) + assert.True(t, submitted) + assert.True(t, env.cache.IsTxSeen(hashTx(tx1))) + assert.True(t, env.cache.IsTxSeen(hashTx(tx2))) + assert.True(t, env.cache.IsTxSeen(hashTx(tx3))) + assert.True(t, env.wasNotified()) +} + +func TestReaper_EmptyMempool_NoAction(t *testing.T) { + env := newTestEnv(t) + + env.execMock.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Once() + + submitted, err := env.reaper.drainMempool() + assert.NoError(t, err) + assert.False(t, submitted) + assert.False(t, env.wasNotified()) +} + +func TestReaper_HashComputedOnce(t *testing.T) { + env := newTestEnv(t) + + tx := []byte("unique-tx") + expectedHash := hashTx(tx) + + env.execMock.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx}, nil).Once() + env.execMock.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Once() + + env.seqMock.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). + Return(&coresequencer.SubmitBatchTxsResponse{}, nil).Once() + + submitted, err := env.reaper.drainMempool() + assert.NoError(t, err) + assert.True(t, submitted) + assert.True(t, env.cache.IsTxSeen(expectedHash)) +} + +func TestReaper_NilCallback_NoPanic(t *testing.T) { + mockExec := testmocks.NewMockExecutor(t) + mockSeq := testmocks.NewMockSequencer(t) cm := newTestCache(t) - r := newTestReaper(t, "chain-D", mockExec, mockSeq, e, cm) - assert.Error(t, r.SubmitTxs()) + r, err := NewReaper( + mockExec, mockSeq, + genesis.Genesis{ChainID: "test-chain"}, + zerolog.Nop(), cm, + 100*time.Millisecond, + nil, + ) + require.NoError(t, err) - // Should not be marked seen - assert.False(t, cm.IsTxSeen(hashTx(tx))) + tx := []byte("tx") + mockExec.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx}, nil).Once() + mockExec.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Once() + mockSeq.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). + Return(&coresequencer.SubmitBatchTxsResponse{}, nil).Once() - // Should not notify - if e.HasPendingTxNotification() { - t.Fatal("did not expect notification on sequencer error") - } + submitted, err := r.drainMempool() + assert.NoError(t, err) + assert.True(t, submitted) } diff --git a/docs/adr/adr-021-lazy-aggregation.md b/docs/adr/adr-021-lazy-aggregation.md index 37ece2c65c..152c9fa7c4 100644 --- a/docs/adr/adr-021-lazy-aggregation.md +++ b/docs/adr/adr-021-lazy-aggregation.md @@ -92,7 +92,6 @@ Leverage the existing empty batch mechanism and `dataHashForEmptyTxs` to maintai A dedicated lazy aggregation loop has been implemented with dual timer mechanisms. The `lazyTimer` ensures blocks are produced at regular intervals even during network inactivity, while the `blockTimer` handles normal block production when transactions are available. Transaction notifications from the `Reaper` to the `Manager` are now handled via the `txNotifyCh` channel: when the `Reaper` detects new transactions, it calls `Manager.NotifyNewTransactions()`, which performs a non-blocking signal on this channel. See the tests in `block/lazy_aggregation_test.go` for verification of this behavior. ```go - // In Reaper.SubmitTxs if r.manager != nil && len(newTxs) > 0 { r.logger.Debug("Notifying manager of new transactions") r.manager.NotifyNewTransactions() // Signals txNotifyCh From bc016bda2555e058331ae40a560224f8258d6d60 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 14:24:41 +0200 Subject: [PATCH 06/23] feedback --- block/internal/reaping/reaper.go | 17 +++++++++++++---- block/internal/reaping/reaper_test.go | 20 +++++++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/block/internal/reaping/reaper.go b/block/internal/reaping/reaper.go index 436727fdf2..f9e1690394 100644 --- a/block/internal/reaping/reaper.go +++ b/block/internal/reaping/reaper.go @@ -96,7 +96,9 @@ func (r *Reaper) reaperLoop() { Int("consecutive_failures", consecutiveFailures). Dur("backoff", backoff). Msg("reaper error, backing off") - r.wait(backoff, cleanupTicker.C) + if r.wait(backoff, nil) { + return + } continue } @@ -109,21 +111,28 @@ func (r *Reaper) reaperLoop() { continue } - r.wait(r.interval, cleanupTicker.C) + if r.wait(r.interval, cleanupTicker.C) { + return + } } } -func (r *Reaper) wait(d time.Duration, cleanupCh <-chan time.Time) { +// wait blocks for the given duration. Returns true if the context was cancelled. +// When cleanupCh is non-nil, processes cache cleanup if that channel fires first. +func (r *Reaper) wait(d time.Duration, cleanupCh <-chan time.Time) bool { timer := time.NewTimer(d) defer timer.Stop() select { case <-r.ctx.Done(): + return true case <-cleanupCh: removed := r.cache.CleanupOldTxs(cache.DefaultTxCacheRetention) if removed > 0 { r.logger.Info().Int("removed", removed).Msg("cleaned up old transaction hashes") } + return false case <-timer.C: + return false } } @@ -156,7 +165,7 @@ func (r *Reaper) drainMempool() (bool, error) { filtered := r.filterNewTxs(txs) if len(filtered) == 0 { - continue + break } n, err := r.submitFiltered(filtered) diff --git a/block/internal/reaping/reaper_test.go b/block/internal/reaping/reaper_test.go index cc8d71a748..2cff6bdfa3 100644 --- a/block/internal/reaping/reaper_test.go +++ b/block/internal/reaping/reaper_test.go @@ -105,7 +105,6 @@ func TestReaper_AllSeen_NoSubmit(t *testing.T) { env.cache.SetTxSeen(hashTx(tx2)) env.execMock.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx1, tx2}, nil).Once() - env.execMock.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Once() submitted, err := env.reaper.drainMempool() assert.NoError(t, err) @@ -232,3 +231,22 @@ func TestReaper_NilCallback_NoPanic(t *testing.T) { assert.NoError(t, err) assert.True(t, submitted) } + +func TestReaper_StopTerminates(t *testing.T) { + env := newTestEnv(t) + env.execMock.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Maybe() + + require.NoError(t, env.reaper.Start(context.Background())) + + done := make(chan struct{}) + go func() { + env.reaper.Stop() + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Stop() did not return in time") + } +} From c26c0909b0f17f6bdf1788ef856c253c88d55737 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 14:59:01 +0200 Subject: [PATCH 07/23] fix ldflag --- apps/testapp/Dockerfile | 2 +- block/internal/common/consts.go | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/testapp/Dockerfile b/apps/testapp/Dockerfile index b3d9cdc021..ed4f2f2b73 100644 --- a/apps/testapp/Dockerfile +++ b/apps/testapp/Dockerfile @@ -28,7 +28,7 @@ COPY . . WORKDIR /ev-node/apps/testapp -RUN go build -ldflags "-X github.com/evstack/ev-node/block/internal/common.DefaultMaxBlobSize=125829120" -o /go/bin/testapp . +RUN go build -ldflags "-X github.com/evstack/ev-node/block/internal/common.defaultMaxBlobSizeStr=125829120" -o /go/bin/testapp . ## prep the final image. # diff --git a/block/internal/common/consts.go b/block/internal/common/consts.go index 857ee87690..0ac92a9655 100644 --- a/block/internal/common/consts.go +++ b/block/internal/common/consts.go @@ -1,6 +1,21 @@ package common -// DefaultMaxBlobSize is the fallback blob size limit used with the DA layer. +import "strconv" + +// defaultMaxBlobSizeStr holds the string representation of the default blob +// size limit. Override at link time via: // -// go build -ldflags "-X github.com/evstack/ev-node/block/internal/common.DefaultMaxBlobSize=10485760" -var DefaultMaxBlobSize uint64 = 5 * 1024 * 1024 // 5MB +// go build -ldflags "-X github.com/evstack/ev-node/block/internal/common.defaultMaxBlobSizeStr=125829120" +var defaultMaxBlobSizeStr = "5242880" // 5 MB + +// DefaultMaxBlobSize is the max blob size limit used for blob submission. +var DefaultMaxBlobSize uint64 = 5 * 1024 * 1024 + +func init() { + v, err := strconv.ParseUint(defaultMaxBlobSizeStr, 10, 64) + if err != nil || v == 0 { + DefaultMaxBlobSize = 5 * 1024 * 1024 + return + } + DefaultMaxBlobSize = v +} From a1c2f87e9f1054fc4be5cd9777b57977653ca4c7 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 15:00:28 +0200 Subject: [PATCH 08/23] cleanup api --- pkg/sequencers/solo/README.md | 25 ------------------------- pkg/sequencers/solo/sequencer.go | 6 ++++-- pkg/sequencers/solo/sequencer_test.go | 3 --- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/pkg/sequencers/solo/README.md b/pkg/sequencers/solo/README.md index 1e99213bb1..dc68241f41 100644 --- a/pkg/sequencers/solo/README.md +++ b/pkg/sequencers/solo/README.md @@ -51,31 +51,6 @@ flowchart TD H --> I["Return valid txs"] ``` -## Usage - -```go -seq := solo.NewSoloSequencer( - logger, - cfg, - []byte("chain-id"), - 1000, // maxQueueSize (0 = unlimited) - genesis, - executor, -) - -// Submit transactions from the mempool -seq.SubmitBatchTxs(ctx, coresequencer.SubmitBatchTxsRequest{ - Id: []byte("chain-id"), - Batch: &coresequencer.Batch{Transactions: txs}, -}) - -// Produce the next block -resp, err := seq.GetNextBatch(ctx, coresequencer.GetNextBatchRequest{ - Id: []byte("chain-id"), - MaxBytes: 500_000, -}) -``` - ## Comparison with Other Sequencers | Aspect | Solo | Single | Based | diff --git a/pkg/sequencers/solo/sequencer.go b/pkg/sequencers/solo/sequencer.go index 6f56e9ef8f..0fcae9f31c 100644 --- a/pkg/sequencers/solo/sequencer.go +++ b/pkg/sequencers/solo/sequencer.go @@ -12,7 +12,6 @@ import ( "github.com/evstack/ev-node/core/execution" coresequencer "github.com/evstack/ev-node/core/sequencer" - "github.com/evstack/ev-node/pkg/config" ) var ErrInvalidID = errors.New("invalid chain id") @@ -35,7 +34,6 @@ type SoloSequencer struct { func NewSoloSequencer( logger zerolog.Logger, - cfg config.Config, id []byte, executor execution.Executor, ) *SoloSequencer { @@ -128,6 +126,10 @@ func (s *SoloSequencer) GetNextBatch(ctx context.Context, req coresequencer.GetN } func (s *SoloSequencer) VerifyBatch(ctx context.Context, req coresequencer.VerifyBatchRequest) (*coresequencer.VerifyBatchResponse, error) { + if !s.isValid(req.Id) { + return nil, ErrInvalidID + } + return &coresequencer.VerifyBatchResponse{Status: true}, nil } diff --git a/pkg/sequencers/solo/sequencer_test.go b/pkg/sequencers/solo/sequencer_test.go index f7ae0e1518..7f3bc9e196 100644 --- a/pkg/sequencers/solo/sequencer_test.go +++ b/pkg/sequencers/solo/sequencer_test.go @@ -12,7 +12,6 @@ import ( "github.com/evstack/ev-node/core/execution" coresequencer "github.com/evstack/ev-node/core/sequencer" - "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/test/mocks" ) @@ -42,7 +41,6 @@ func createDefaultMockExecutor(t *testing.T) *mocks.MockExecutor { func newTestSequencer(t *testing.T) *SoloSequencer { return NewSoloSequencer( zerolog.Nop(), - config.DefaultConfig(), []byte("test"), createDefaultMockExecutor(t), ) @@ -152,7 +150,6 @@ func TestSoloSequencer_GetNextBatch_PostponedTxsRequeued(t *testing.T) { seq := NewSoloSequencer( zerolog.Nop(), - config.DefaultConfig(), []byte("test"), mockExec, ) From 4d1b531493aba930c147c924ec6f818f9961b9e9 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 15:01:11 +0200 Subject: [PATCH 09/23] better defaults --- apps/testapp/cmd/root.go | 2 +- apps/testapp/cmd/run.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/testapp/cmd/root.go b/apps/testapp/cmd/root.go index 17db405566..1a1108e2a0 100644 --- a/apps/testapp/cmd/root.go +++ b/apps/testapp/cmd/root.go @@ -22,7 +22,7 @@ func init() { // add more flags to RunCmd RunCmd.Flags().String(flagKVEndpoint, "", "Address and port for the KV executor HTTP server") - RunCmd.Flags().Bool(flagSoloSequencer, true, "Enable Solo sequencer (instead of based sequencer or single sequencer)") + RunCmd.Flags().Bool(flagSoloSequencer, false, "Enable Solo sequencer (instead of based sequencer or single sequencer)") } // RootCmd is the root command for Evolve diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index a3c79b21ed..75e5a49019 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -118,8 +118,7 @@ func createSequencer( return nil, fmt.Errorf("solo sequencer cannot be used with based") } - logger.Info().Msg("using solo sequencer") - return solo.NewSoloSequencer(logger, nodeConfig, []byte(genesis.ChainID), executor), nil + return solo.NewSoloSequencer(logger, []byte(genesis.ChainID), executor), nil } blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") From 6815a246bf9a14a98d7b4127a0081a0c14390093 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 15:07:23 +0200 Subject: [PATCH 10/23] fix partial drain --- block/internal/reaping/reaper.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/block/internal/reaping/reaper.go b/block/internal/reaping/reaper.go index f9e1690394..a0fb642874 100644 --- a/block/internal/reaping/reaper.go +++ b/block/internal/reaping/reaper.go @@ -170,6 +170,10 @@ func (r *Reaper) drainMempool() (bool, error) { n, err := r.submitFiltered(filtered) if err != nil { + // partial drain, still submit + if totalSubmitted > 0 && r.onTxsSubmitted != nil { + r.onTxsSubmitted() + } return totalSubmitted > 0, err } totalSubmitted += n From 57cd3ef9c0d62d1659216dc8e92dcdc556b81913 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 15:11:59 +0200 Subject: [PATCH 11/23] cleanup readme --- pkg/sequencers/solo/README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pkg/sequencers/solo/README.md b/pkg/sequencers/solo/README.md index dc68241f41..bdeb3cd7dc 100644 --- a/pkg/sequencers/solo/README.md +++ b/pkg/sequencers/solo/README.md @@ -16,12 +16,11 @@ flowchart LR ## Design Decisions -| Decision | Rationale | -| ------------------------ | -------------------------------------------------------------------- | -| In-memory queue | No persistence overhead; suitable for trusted single-operator setups | -| No forced inclusion | Avoids DA epoch tracking, checkpoint storage, and catch-up logic | -| No DA client dependency | `VerifyBatch` returns true unconditionally | -| Configurable queue limit | Provides backpressure when blocks can't be produced fast enough | +| Decision | Rationale | +| ----------------------- | -------------------------------------------------------------------- | +| In-memory queue | No persistence overhead; suitable for trusted single-operator setups | +| No forced inclusion | Avoids DA epoch tracking, checkpoint storage, and catch-up logic | +| No DA client dependency | `VerifyBatch` returns true unconditionally | ## Flow From 0940b1b20dd58894c4bab6a6e7037988b6715971 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 15:14:05 +0200 Subject: [PATCH 12/23] cleanup old readme --- docs/adr/adr-021-lazy-aggregation.md | 211 +++++++++++---------------- 1 file changed, 83 insertions(+), 128 deletions(-) diff --git a/docs/adr/adr-021-lazy-aggregation.md b/docs/adr/adr-021-lazy-aggregation.md index 152c9fa7c4..988116db70 100644 --- a/docs/adr/adr-021-lazy-aggregation.md +++ b/docs/adr/adr-021-lazy-aggregation.md @@ -20,143 +20,98 @@ Leverage the existing empty batch mechanism and `dataHashForEmptyTxs` to maintai 1. **Modified Batch Retrieval**: - The batch retrieval mechanism has been modified to handle empty batches differently. Instead of discarding empty batches, we now return them with the ErrNoBatch error, allowing the caller to create empty blocks with proper timestamps. This ensures that block timing remains consistent even during periods of inactivity. - - ```go - func (m *Manager) retrieveBatch(ctx context.Context) (*BatchData, error) { - res, err := m.sequencer.GetNextBatch(ctx, req) - if err != nil { - return nil, err - } - - if res != nil && res.Batch != nil { - m.logger.Debug("Retrieved batch", - "txCount", len(res.Batch.Transactions), - "timestamp", res.Timestamp) - - var errRetrieveBatch error - // Even if there are no transactions, return the batch with timestamp - // This allows empty blocks to maintain proper timing - if len(res.Batch.Transactions) == 0 { - errRetrieveBatch = ErrNoBatch - } - // Even if there are no transactions, update lastBatchData so we don't - // repeatedly emit the same empty batch, and persist it to metadata. - if err := m.store.SetMetadata(ctx, LastBatchDataKey, convertBatchDataToBytes(res.BatchData)); err != nil { - m.logger.Error("error while setting last batch hash", "error", err) - } - m.lastBatchData = res.BatchData - return &BatchData{Batch: res.Batch, Time: res.Timestamp, Data: res.BatchData}, errRetrieveBatch - } - return nil, ErrNoBatch - } - ``` + The batch retrieval mechanism has been modified to handle empty batches differently. Instead of discarding empty batches, we now return them with the ErrNoBatch error, allowing the caller to create empty blocks with proper timestamps. This ensures that block timing remains consistent even during periods of inactivity. + + ```go + func (m *Manager) retrieveBatch(ctx context.Context) (*BatchData, error) { + res, err := m.sequencer.GetNextBatch(ctx, req) + if err != nil { + return nil, err + } + + if res != nil && res.Batch != nil { + m.logger.Debug("Retrieved batch", + "txCount", len(res.Batch.Transactions), + "timestamp", res.Timestamp) + + var errRetrieveBatch error + // Even if there are no transactions, return the batch with timestamp + // This allows empty blocks to maintain proper timing + if len(res.Batch.Transactions) == 0 { + errRetrieveBatch = ErrNoBatch + } + // Even if there are no transactions, update lastBatchData so we don't + // repeatedly emit the same empty batch, and persist it to metadata. + if err := m.store.SetMetadata(ctx, LastBatchDataKey, convertBatchDataToBytes(res.BatchData)); err != nil { + m.logger.Error("error while setting last batch hash", "error", err) + } + m.lastBatchData = res.BatchData + return &BatchData{Batch: res.Batch, Time: res.Timestamp, Data: res.BatchData}, errRetrieveBatch + } + return nil, ErrNoBatch + } + ``` 2. **Empty Block Creation**: - The block publishing logic has been enhanced to create empty blocks when a batch with no transactions is received. This uses the special `dataHashForEmptyTxs` value to indicate an empty batch, maintaining the block height consistency with the DA layer while minimizing overhead. - - ```go - // In publishBlock method - batchData, err := m.retrieveBatch(ctx) - if err != nil { - if errors.Is(err, ErrNoBatch) { - if batchData == nil { - m.logger.Info("No batch retrieved from sequencer, skipping block production") - return nil - } - m.logger.Info("Creating empty block, height: ", newHeight) - } else { - return fmt.Errorf("failed to get transactions from batch: %w", err) - } - } else { - if batchData.Before(lastHeaderTime) { - return fmt.Errorf("timestamp is not monotonically increasing: %s < %s", batchData.Time, m.getLastBlockTime()) - } - m.logger.Info("Creating and publishing block, height: ", newHeight) - m.logger.Debug("block info", "num_tx", len(batchData.Batch.Transactions)) - } - - header, data, err = m.createBlock(ctx, newHeight, lastSignature, lastHeaderHash, batchData) - if err != nil { - return err - } - - if err = m.store.SaveBlockData(ctx, header, data, &signature); err != nil { - return SaveBlockError{err} - } - ``` + The block publishing logic has been enhanced to create empty blocks when a batch with no transactions is received. This uses the special `dataHashForEmptyTxs` value to indicate an empty batch, maintaining the block height consistency with the DA layer while minimizing overhead. + + ```go + // In publishBlock method + batchData, err := m.retrieveBatch(ctx) + if err != nil { + if errors.Is(err, ErrNoBatch) { + if batchData == nil { + m.logger.Info("No batch retrieved from sequencer, skipping block production") + return nil + } + m.logger.Info("Creating empty block, height: ", newHeight) + } else { + return fmt.Errorf("failed to get transactions from batch: %w", err) + } + } else { + if batchData.Before(lastHeaderTime) { + return fmt.Errorf("timestamp is not monotonically increasing: %s < %s", batchData.Time, m.getLastBlockTime()) + } + m.logger.Info("Creating and publishing block, height: ", newHeight) + m.logger.Debug("block info", "num_tx", len(batchData.Batch.Transactions)) + } + + header, data, err = m.createBlock(ctx, newHeight, lastSignature, lastHeaderHash, batchData) + if err != nil { + return err + } + + if err = m.store.SaveBlockData(ctx, header, data, &signature); err != nil { + return SaveBlockError{err} + } + ``` 3. **Lazy Aggregation Loop**: - A dedicated lazy aggregation loop has been implemented with dual timer mechanisms. The `lazyTimer` ensures blocks are produced at regular intervals even during network inactivity, while the `blockTimer` handles normal block production when transactions are available. Transaction notifications from the `Reaper` to the `Manager` are now handled via the `txNotifyCh` channel: when the `Reaper` detects new transactions, it calls `Manager.NotifyNewTransactions()`, which performs a non-blocking signal on this channel. See the tests in `block/lazy_aggregation_test.go` for verification of this behavior. - - ```go - if r.manager != nil && len(newTxs) > 0 { - r.logger.Debug("Notifying manager of new transactions") - r.manager.NotifyNewTransactions() // Signals txNotifyCh - } - - // In Manager.NotifyNewTransactions - func (m *Manager) NotifyNewTransactions() { - select { - case m.txNotifyCh <- struct{}{}: - // Successfully sent notification - default: - // Channel buffer is full, notification already pending - } - } - // Modified lazyAggregationLoop - func (m *Manager) lazyAggregationLoop(ctx context.Context, blockTimer *time.Timer) { - // lazyTimer triggers block publication even during inactivity - lazyTimer := time.NewTimer(0) - defer lazyTimer.Stop() - - for { - select { - case <-ctx.Done(): - return - - case <-lazyTimer.C: - m.logger.Debug("Lazy timer triggered block production") - m.produceBlock(ctx, "lazy_timer", lazyTimer, blockTimer) - - case <-blockTimer.C: - if m.txsAvailable { - m.produceBlock(ctx, "block_timer", lazyTimer, blockTimer) - m.txsAvailable = false - } else { - // Ensure we keep ticking even when there are no txs - blockTimer.Reset(m.config.Node.BlockTime.Duration) - } - case <-m.txNotifyCh: - m.txsAvailable = true - } - } - } - ``` + A dedicated lazy aggregation loop has been implemented with dual timer mechanisms. The `lazyTimer` ensures blocks are produced at regular intervals even during network inactivity, while the `blockTimer` handles normal block production when transactions are available. Transaction notifications from the `Reaper` to the `Manager` are now handled via the `txNotifyCh` channel: when the `Reaper` detects new transactions, it calls `Manager.NotifyNewTransactions()`, which performs a non-blocking signal on this channel. See the tests in `block/lazy_aggregation_test.go` for verification of this behavior. 4. **Block Production**: - The block production function centralizes the logic for publishing blocks and resetting timers. It records the start time, attempts to publish a block, and then intelligently resets both timers based on the elapsed time. This ensures that block production remains on schedule even if the block creation process takes significant time. - - ```go - func (m *Manager) produceBlock(ctx context.Context, trigger string, lazyTimer, blockTimer *time.Timer) { - // Record the start time - start := time.Now() - - // Attempt to publish the block - if err := m.publishBlock(ctx); err != nil && ctx.Err() == nil { - m.logger.Error("error while publishing block", "trigger", trigger, "error", err) - } else { - m.logger.Debug("Successfully published block", "trigger", trigger) - } - - // Reset both timers for the next aggregation window - lazyTimer.Reset(getRemainingSleep(start, m.config.Node.LazyBlockInterval.Duration)) - blockTimer.Reset(getRemainingSleep(start, m.config.Node.BlockTime.Duration)) - } - ``` + The block production function centralizes the logic for publishing blocks and resetting timers. It records the start time, attempts to publish a block, and then intelligently resets both timers based on the elapsed time. This ensures that block production remains on schedule even if the block creation process takes significant time. + + ```go + func (m *Manager) produceBlock(ctx context.Context, trigger string, lazyTimer, blockTimer *time.Timer) { + // Record the start time + start := time.Now() + + // Attempt to publish the block + if err := m.publishBlock(ctx); err != nil && ctx.Err() == nil { + m.logger.Error("error while publishing block", "trigger", trigger, "error", err) + } else { + m.logger.Debug("Successfully published block", "trigger", trigger) + } + + // Reset both timers for the next aggregation window + lazyTimer.Reset(getRemainingSleep(start, m.config.Node.LazyBlockInterval.Duration)) + blockTimer.Reset(getRemainingSleep(start, m.config.Node.BlockTime.Duration)) + } + ``` ### Key Changes From 28033e80921f0ca5518082afe4ca9ce5e1a0767e Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 15:33:15 +0200 Subject: [PATCH 13/23] Prevent multiple Start() calls across components --- block/internal/cache/manager.go | 7 ------- block/internal/executing/executor.go | 3 +++ block/internal/pruner/pruner.go | 3 +++ block/internal/reaping/reaper.go | 4 +++- block/internal/submitting/submitter.go | 3 +++ block/internal/syncing/syncer.go | 3 +++ 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/block/internal/cache/manager.go b/block/internal/cache/manager.go index e021c5ca44..a91b1410d7 100644 --- a/block/internal/cache/manager.go +++ b/block/internal/cache/manager.go @@ -246,13 +246,6 @@ func (m *implementation) CleanupOldTxs(olderThan time.Duration) int { return true }) - if removed > 0 { - m.logger.Debug(). - Int("removed", removed). - Dur("older_than", olderThan). - Msg("cleaned up old transaction hashes from cache") - } - return removed } diff --git a/block/internal/executing/executor.go b/block/internal/executing/executor.go index 3ddc90211a..4cdf94a885 100644 --- a/block/internal/executing/executor.go +++ b/block/internal/executing/executor.go @@ -163,6 +163,9 @@ func (e *Executor) SetBlockProducer(bp BlockProducer) { // Start begins the execution component func (e *Executor) Start(ctx context.Context) error { + if e.cancel != nil { + return errors.New("executor already started") + } e.ctx, e.cancel = context.WithCancel(ctx) // Initialize state diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go index 797f911d50..e56d9c2849 100644 --- a/block/internal/pruner/pruner.go +++ b/block/internal/pruner/pruner.go @@ -52,6 +52,9 @@ func New( // Start begins the pruning loop. func (p *Pruner) Start(ctx context.Context) error { + if p.cancel != nil { + return errors.New("pruner already started") + } if !p.cfg.IsPruningEnabled() { p.logger.Info().Msg("pruning is disabled, not starting pruner") return nil diff --git a/block/internal/reaping/reaper.go b/block/internal/reaping/reaper.go index a0fb642874..818772ed72 100644 --- a/block/internal/reaping/reaper.go +++ b/block/internal/reaping/reaper.go @@ -69,9 +69,11 @@ func NewReaper( // Start begins the execution component func (r *Reaper) Start(ctx context.Context) error { + if r.cancel != nil { + return errors.New("reaper already started") + } r.ctx, r.cancel = context.WithCancel(ctx) - // Start reaper loop r.wg.Go(r.reaperLoop) r.logger.Info().Dur("idle_interval", r.interval).Msg("reaper started") diff --git a/block/internal/submitting/submitter.go b/block/internal/submitting/submitter.go index 34fab216de..73a05602fc 100644 --- a/block/internal/submitting/submitter.go +++ b/block/internal/submitting/submitter.go @@ -119,6 +119,9 @@ func NewSubmitter( // Start begins the submitting component func (s *Submitter) Start(ctx context.Context) error { + if s.cancel != nil { + return errors.New("submitter already started") + } s.ctx, s.cancel = context.WithCancel(ctx) // Initialize DA included height diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index 802c1b243d..d9546d076c 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -163,6 +163,9 @@ func (s *Syncer) SetBlockSyncer(bs BlockSyncer) { // Start begins the syncing component func (s *Syncer) Start(ctx context.Context) (err error) { + if s.cancel != nil { + return errors.New("syncer already started") + } ctx, cancel := context.WithCancel(ctx) s.ctx, s.cancel = ctx, cancel From 2e58f04256f7f84944842ab8a5e0ede47c37a59f Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 16:14:03 +0200 Subject: [PATCH 14/23] fix unwanted log --- block/internal/reaping/reaper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/block/internal/reaping/reaper.go b/block/internal/reaping/reaper.go index 818772ed72..ab9c2045bf 100644 --- a/block/internal/reaping/reaper.go +++ b/block/internal/reaping/reaper.go @@ -89,7 +89,7 @@ func (r *Reaper) reaperLoop() { for { submitted, err := r.drainMempool() - if err != nil { + if err != nil && !errors.Is(err, context.Canceled) { consecutiveFailures++ backoff := r.interval * time.Duration(1< Date: Thu, 9 Apr 2026 16:28:24 +0200 Subject: [PATCH 15/23] lock --- block/internal/submitting/submitter_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/block/internal/submitting/submitter_test.go b/block/internal/submitting/submitter_test.go index 946fba0ab6..f74beddf96 100644 --- a/block/internal/submitting/submitter_test.go +++ b/block/internal/submitting/submitter_test.go @@ -289,7 +289,6 @@ func TestSubmitter_processDAInclusionLoop_advances(t *testing.T) { cm.SetHeaderDAIncluded(h2.Hash().String(), 101, 2) cm.SetDataDAIncluded(d2.DACommitment().String(), 101, 2) - s.ctx, s.cancel = ctx, cancel require.NoError(t, s.initializeDAIncludedHeight(ctx)) require.Equal(t, uint64(0), s.GetDAIncludedHeight()) @@ -518,7 +517,6 @@ func TestSubmitter_CacheClearedOnHeightInclusion(t *testing.T) { cm.SetHeaderDAIncluded(h2.Hash().String(), 101, 2) cm.SetDataDAIncluded(d2.DACommitment().String(), 101, 2) - s.ctx, s.cancel = ctx, cancel require.NoError(t, s.initializeDAIncludedHeight(ctx)) require.Equal(t, uint64(0), s.GetDAIncludedHeight()) From 6909b3099c30676864963d425bfc641843187274 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 16:50:26 +0200 Subject: [PATCH 16/23] updates --- block/internal/reaping/reaper.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/block/internal/reaping/reaper.go b/block/internal/reaping/reaper.go index ab9c2045bf..7a38155bfe 100644 --- a/block/internal/reaping/reaper.go +++ b/block/internal/reaping/reaper.go @@ -155,6 +155,13 @@ type pendingTx struct { func (r *Reaper) drainMempool() (bool, error) { var totalSubmitted int + submitted := false + + defer func() { + if submitted && r.onTxsSubmitted != nil { + r.onTxsSubmitted() + } + }() for { txs, err := r.exec.GetTxs(r.ctx) @@ -172,20 +179,14 @@ func (r *Reaper) drainMempool() (bool, error) { n, err := r.submitFiltered(filtered) if err != nil { - // partial drain, still submit - if totalSubmitted > 0 && r.onTxsSubmitted != nil { - r.onTxsSubmitted() - } return totalSubmitted > 0, err } totalSubmitted += n + submitted = true } if totalSubmitted > 0 { r.logger.Debug().Int("total_txs", totalSubmitted).Msg("drained mempool") - if r.onTxsSubmitted != nil { - r.onTxsSubmitted() - } } return totalSubmitted > 0, nil From 1e24477549882b56641f74d08510bbd407cea47b Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 21:14:54 +0200 Subject: [PATCH 17/23] refactor: nerf testapp --- apps/testapp/cmd/init.go | 2 +- apps/testapp/cmd/rollback.go | 5 +- apps/testapp/cmd/run.go | 5 +- apps/testapp/kv/README.md | 35 -- apps/testapp/kv/bench/main.go | 168 ++++++++-- apps/testapp/kv/http_server.go | 165 +++++----- apps/testapp/kv/http_server_test.go | 58 +--- apps/testapp/kv/kvexecutor.go | 485 +++++++++++++++------------- apps/testapp/kv/kvexecutor_test.go | 180 ++++++----- 9 files changed, 587 insertions(+), 516 deletions(-) diff --git a/apps/testapp/cmd/init.go b/apps/testapp/cmd/init.go index 792bc85523..625948b7a1 100644 --- a/apps/testapp/cmd/init.go +++ b/apps/testapp/cmd/init.go @@ -35,7 +35,7 @@ func InitCmd() *cobra.Command { // we use load in order to parse all the flags cfg, _ := rollconf.Load(cmd) cfg.Node.Aggregator = aggregator - cfg.Node.BlockTime = rollconf.DurationWrapper{Duration: 10 * time.Millisecond} + cfg.Node.BlockTime = rollconf.DurationWrapper{Duration: 100 * time.Millisecond} if err := cfg.Validate(); err != nil { return fmt.Errorf("error validating config: %w", err) } diff --git a/apps/testapp/cmd/rollback.go b/apps/testapp/cmd/rollback.go index 87d9a8738f..185956b041 100644 --- a/apps/testapp/cmd/rollback.go +++ b/apps/testapp/cmd/rollback.go @@ -56,10 +56,7 @@ func NewRollbackCmd() *cobra.Command { height = currentHeight - 1 } - executor, err := kvexecutor.NewKVExecutor(nodeConfig.RootDir, nodeConfig.DBPath) - if err != nil { - return err - } + executor := kvexecutor.NewKVExecutor() // rollback ev-node main state // Note: With the unified store approach, the ev-node store is the single source of truth. diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index 75e5a49019..baa544671b 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -47,10 +47,7 @@ var RunCmd = &cobra.Command{ } // Create test implementations - executor, err := kvexecutor.NewKVExecutor(nodeConfig.RootDir, nodeConfig.DBPath) - if err != nil { - return err - } + executor := kvexecutor.NewKVExecutor() ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/apps/testapp/kv/README.md b/apps/testapp/kv/README.md index e080067ac9..ee1900e623 100644 --- a/apps/testapp/kv/README.md +++ b/apps/testapp/kv/README.md @@ -30,41 +30,6 @@ The HTTP server provides the following endpoints: - `GET /store`: Get all key-value pairs in the store. - Returns a JSON object with all key-value pairs. -## CLI Client - -A simple CLI client is provided to interact with the KV executor HTTP server. To build the client: - -```bash -cd test/executors/kv/cmd/txclient -go build -o txclient -``` - -### Usage - -Submit a transaction with key and value: - -```bash -./txclient -key mykey -value myvalue -``` - -Submit a raw transaction: - -```bash -./txclient -raw "mykey=myvalue" -``` - -List all key-value pairs in the store: - -```bash -./txclient -list -``` - -Specify a different server address: - -```bash -./txclient -addr http://localhost:40042 -key mykey -value myvalue -``` - ## Transaction Format The KV executor expects transactions in the format `key=value`. For example: diff --git a/apps/testapp/kv/bench/main.go b/apps/testapp/kv/bench/main.go index 873107c95b..33e4627ab8 100644 --- a/apps/testapp/kv/bench/main.go +++ b/apps/testapp/kv/bench/main.go @@ -10,29 +10,41 @@ import ( "net" "net/http" "os" + "strconv" "strings" "sync/atomic" "time" ) type serverStats struct { - InjectedTxs uint64 `json:"injected_txs"` ExecutedTxs uint64 `json:"executed_txs"` BlocksProduced uint64 `json:"blocks_produced"` } +const uniquePoolSize = 65536 + +var uniqueBodies [uniquePoolSize]string + +func init() { + for i := range uniquePoolSize { + uniqueBodies[i] = "k" + strconv.Itoa(i) + "=v" + } +} + func main() { addr := flag.String("addr", "localhost:9090", "server host:port") duration := flag.Duration("duration", 10*time.Second, "test duration") workers := flag.Int("workers", 1000, "concurrent workers") - targetRPS := flag.Uint64("target-rps", 10_000_000, "target requests per second") + batchSize := flag.Int("batch", 1, "transactions per request (uses /tx/batch when >1)") + targetRPS := flag.Uint64("target-rps", 1_000_000, "target transactions per second") flag.Parse() fmt.Printf("Stress Test Configuration\n") fmt.Printf(" Server: %s\n", *addr) fmt.Printf(" Duration: %s\n", *duration) fmt.Printf(" Workers: %d\n", *workers) - fmt.Printf(" Goal: %d req/s\n\n", *targetRPS) + fmt.Printf(" Batch: %d txs/request\n", *batchSize) + fmt.Printf(" Goal: %d tx/s\n\n", *targetRPS) if err := checkServer(*addr); err != nil { fmt.Fprintf(os.Stderr, "Server check failed: %v\n", err) @@ -41,28 +53,28 @@ func main() { before := fetchStats(*addr) - rawReq := fmt.Appendf(nil, - "POST /tx HTTP/1.1\r\nHost: %s\r\nContent-Type: text/plain\r\nContent-Length: 3\r\n\r\ns=v", - *addr, - ) - var success atomic.Uint64 var failures atomic.Uint64 + var totalTxs atomic.Uint64 ctx, cancel := context.WithTimeout(context.Background(), *duration) defer cancel() done := make(chan struct{}, *workers) for i := 0; i < *workers; i++ { - go worker(ctx, *addr, rawReq, &success, &failures, done) + if *batchSize > 1 { + go batchWorker(ctx, *addr, *batchSize, &success, &failures, &totalTxs, done) + } else { + go singleWorker(ctx, *addr, &success, &failures, &totalTxs, done) + } } start := time.Now() ticker := time.NewTicker(time.Second) defer ticker.Stop() - var lastCount uint64 - var peakRPS uint64 + var lastTxs uint64 + var peakTPS uint64 loop: for { @@ -70,15 +82,15 @@ loop: case <-ctx.Done(): break loop case <-ticker.C: - cur := success.Load() + failures.Load() - rps := cur - lastCount - lastCount = cur - if rps > peakRPS { - peakRPS = rps + cur := totalTxs.Load() + tps := cur - lastTxs + lastTxs = cur + if tps > peakTPS { + peakTPS = tps } elapsed := time.Since(start).Truncate(time.Second) - fmt.Printf("\r[%6s] Total: %12d | Success: %12d | Fail: %8d | RPS: %12d ", - elapsed, cur, success.Load(), failures.Load(), rps) + fmt.Printf("\r[%6s] Total txs: %12d | Success: %12d | Fail: %8d | TPS: %12d ", + elapsed, cur, success.Load(), failures.Load(), tps) } } @@ -90,8 +102,8 @@ loop: after := fetchStats(*addr) - total := success.Load() + failures.Load() - avgRPS := float64(total) / elapsed.Seconds() + total := totalTxs.Load() + avgTPS := float64(total) / elapsed.Seconds() var txsPerBlock float64 deltaBlocks := after.BlocksProduced - before.BlocksProduced @@ -100,17 +112,28 @@ loop: txsPerBlock = float64(deltaTxs) / float64(deltaBlocks) } - reached := avgRPS >= float64(*targetRPS) + reached := avgTPS >= float64(*targetRPS) fmt.Println() fmt.Println() - printResults(elapsed, uint64(*workers), total, success.Load(), failures.Load(), - avgRPS, float64(peakRPS), deltaBlocks, deltaTxs, txsPerBlock, reached, *targetRPS) + printResults(elapsed, uint64(*workers), uint64(*batchSize), total, success.Load(), failures.Load(), + avgTPS, float64(peakTPS), deltaBlocks, deltaTxs, txsPerBlock, reached, *targetRPS) } -func worker(ctx context.Context, addr string, rawReq []byte, success, failures *atomic.Uint64, done chan struct{}) { +func singleWorker(ctx context.Context, addr string, success, failures, totalTxs *atomic.Uint64, done chan struct{}) { defer func() { done <- struct{}{} }() + rawReqs := make([][]byte, uniquePoolSize) + for i := range uniquePoolSize { + body := uniqueBodies[i] + rawReqs[i] = fmt.Appendf(nil, + "POST /tx HTTP/1.1\r\nHost: %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\n\r\n%s", + addr, len(body), body, + ) + } + + var idx uint64 + for { select { case <-ctx.Done(): @@ -134,7 +157,8 @@ func worker(ctx context.Context, addr string, rawReq []byte, success, failures * default: } - if _, err := conn.Write(rawReq); err != nil { + i := atomic.AddUint64(&idx, 1) % uniquePoolSize + if _, err := conn.Write(rawReqs[i]); err != nil { failures.Add(1) conn.Close() break @@ -151,6 +175,79 @@ func worker(ctx context.Context, addr string, rawReq []byte, success, failures * if resp.StatusCode == http.StatusAccepted { success.Add(1) + totalTxs.Add(1) + } else { + failures.Add(1) + } + } + } +} + +func batchWorker(ctx context.Context, addr string, batchSize int, success, failures, totalTxs *atomic.Uint64, done chan struct{}) { + defer func() { done <- struct{}{} }() + + var idx uint64 + + for { + select { + case <-ctx.Done(): + return + default: + } + + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + failures.Add(1) + continue + } + + br := bufio.NewReaderSize(conn, 4096) + + for { + select { + case <-ctx.Done(): + conn.Close() + return + default: + } + + base := atomic.AddUint64(&idx, uint64(batchSize)) + var body strings.Builder + body.Grow(batchSize * 10) + for i := range batchSize { + body.WriteString(uniqueBodies[(base+uint64(i))%uniquePoolSize]) + body.WriteByte('\n') + } + bodyStr := body.String() + + header := fmt.Sprintf( + "POST /tx/batch HTTP/1.1\r\nHost: %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\n\r\n", + addr, len(bodyStr), + ) + rawReq := []byte(header + bodyStr) + + if _, err := conn.Write(rawReq); err != nil { + failures.Add(1) + conn.Close() + break + } + + resp, err := http.ReadResponse(br, nil) + if err != nil { + failures.Add(1) + conn.Close() + break + } + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted { + var result struct { + Accepted int `json:"accepted"` + } + json.Unmarshal(respBody, &result) + success.Add(1) + totalTxs.Add(uint64(result.Accepted)) } else { failures.Add(1) } @@ -196,10 +293,10 @@ func formatNum(n uint64) string { return result.String() } -func printResults(elapsed time.Duration, workers, total, success, failures uint64, - avgRPS, peakRPS float64, blocks, executedTxs uint64, txsPerBlock float64, reached bool, targetRPS uint64) { +func printResults(elapsed time.Duration, workers, batchSize, total, success, failures uint64, + avgTPS, peakTPS float64, blocks, executedTxs uint64, txsPerBlock float64, reached bool, targetRPS uint64) { - goalLabel := fmt.Sprintf("Goal (%s req/s)", formatNum(targetRPS)) + goalLabel := fmt.Sprintf("Goal (%s tx/s)", formatNum(targetRPS)) sep := "+----------------------------------------+----------------------------------------+" rowFmt := "| %-38s | %-38s |" @@ -208,13 +305,14 @@ func printResults(elapsed time.Duration, workers, total, success, failures uint6 fmt.Println(sep) fmt.Printf(rowFmt+"\n", "Duration", elapsed.Truncate(time.Millisecond).String()) fmt.Printf(rowFmt+"\n", "Workers", fmt.Sprintf("%d", workers)) + fmt.Printf(rowFmt+"\n", "Batch Size", fmt.Sprintf("%d txs/request", batchSize)) fmt.Println(sep) - fmt.Printf(rowFmt+"\n", "Total Requests", formatNum(total)) - fmt.Printf(rowFmt+"\n", "Successful (202)", formatNum(success)) - fmt.Printf(rowFmt+"\n", "Failed", formatNum(failures)) + fmt.Printf(rowFmt+"\n", "Total Transactions", formatNum(total)) + fmt.Printf(rowFmt+"\n", "Successful Requests", formatNum(success)) + fmt.Printf(rowFmt+"\n", "Failed Requests", formatNum(failures)) fmt.Println(sep) - fmt.Printf(rowFmt+"\n", "Avg req/s", formatFloat(avgRPS)) - fmt.Printf(rowFmt+"\n", "Peak req/s (1s window)", formatFloat(peakRPS)) + fmt.Printf(rowFmt+"\n", "Avg tx/s", formatFloat(avgTPS)) + fmt.Printf(rowFmt+"\n", "Peak tx/s (1s window)", formatFloat(peakTPS)) fmt.Println(sep) fmt.Printf(rowFmt+"\n", "Server Blocks Produced", formatNum(blocks)) fmt.Printf(rowFmt+"\n", "Server Txs Executed", formatNum(executedTxs)) @@ -226,13 +324,13 @@ func printResults(elapsed time.Duration, workers, total, success, failures uint6 fmt.Println(sep) fmt.Println() fmt.Println(" ====================================================") - fmt.Printf(" S U C C E S S ! %s R E A C H E D !\n", formatNum(targetRPS)) + fmt.Printf(" S U C C E S S ! %s T X / S R E A C H E D !\n", formatNum(targetRPS)) fmt.Println(" ====================================================") } else { fmt.Printf(rowFmt+"\n", goalLabel, "NOT REACHED") fmt.Println(sep) fmt.Printf("\n Achieved %.2f%% of target (%.1fx away)\n", - avgRPS/float64(targetRPS)*100, float64(targetRPS)/avgRPS) + avgTPS/float64(targetRPS)*100, float64(targetRPS)/avgTPS) } } diff --git a/apps/testapp/kv/http_server.go b/apps/testapp/kv/http_server.go index f4e7508abc..17d8d74421 100644 --- a/apps/testapp/kv/http_server.go +++ b/apps/testapp/kv/http_server.go @@ -1,6 +1,7 @@ package executor import ( + "bufio" "context" "encoding/json" "errors" @@ -11,17 +12,16 @@ import ( "time" ds "github.com/ipfs/go-datastore" - "github.com/ipfs/go-datastore/query" ) -// HTTPServer wraps a KVExecutor and provides an HTTP interface for it +var acceptedResp = []byte("Transaction accepted") + type HTTPServer struct { executor *KVExecutor server *http.Server injectedTxs atomic.Uint64 } -// NewHTTPServer creates a new HTTP server for the KVExecutor func NewHTTPServer(executor *KVExecutor, listenAddr string) *HTTPServer { hs := &HTTPServer{ executor: executor, @@ -29,6 +29,7 @@ func NewHTTPServer(executor *KVExecutor, listenAddr string) *HTTPServer { mux := http.NewServeMux() mux.HandleFunc("/tx", hs.handleTx) + mux.HandleFunc("/tx/batch", hs.handleTxBatch) mux.HandleFunc("/kv", hs.handleKV) mux.HandleFunc("/store", hs.handleStore) mux.HandleFunc("/stats", hs.handleStats) @@ -37,14 +38,13 @@ func NewHTTPServer(executor *KVExecutor, listenAddr string) *HTTPServer { Addr: listenAddr, Handler: mux, ReadHeaderTimeout: 10 * time.Second, + MaxHeaderBytes: 4096, } return hs } -// Start begins listening for HTTP requests func (hs *HTTPServer) Start(ctx context.Context) error { - // Start the server in a goroutine errCh := make(chan error, 1) go func() { fmt.Printf("KV Executor HTTP server starting on %s\n", hs.server.Addr) @@ -53,10 +53,8 @@ func (hs *HTTPServer) Start(ctx context.Context) error { } }() - // Monitor for context cancellation go func() { <-ctx.Done() - // Create a timeout context for shutdown shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -66,42 +64,30 @@ func (hs *HTTPServer) Start(ctx context.Context) error { } }() - // Check if the server started successfully select { case err := <-errCh: return err - case <-time.After(100 * time.Millisecond): // Give it a moment to start - // Server started successfully + case <-time.After(100 * time.Millisecond): return nil } } -// Stop shuts down the HTTP server func (hs *HTTPServer) Stop() error { return hs.server.Close() } -// handleTx handles transaction submissions -// POST /tx with raw binary data or text in request body -// It is recommended to use transactions in the format "key=value" to be consistent -// with the KVExecutor implementation that parses transactions in this format. -// Example: "mykey=myvalue" func (hs *HTTPServer) handleTx(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - body, err := io.ReadAll(r.Body) + body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) + r.Body.Close() if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } - defer func() { - if err := r.Body.Close(); err != nil { - fmt.Printf("Error closing request body: %v\n", err) - } - }() if len(body) == 0 { http.Error(w, "Empty transaction", http.StatusBadRequest) @@ -111,84 +97,103 @@ func (hs *HTTPServer) handleTx(w http.ResponseWriter, r *http.Request) { hs.executor.InjectTx(body) hs.injectedTxs.Add(1) w.WriteHeader(http.StatusAccepted) - _, err = w.Write([]byte("Transaction accepted")) + w.Write(acceptedResp) +} + +func (hs *HTTPServer) handleTxBatch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + r.Body.Close() if err != nil { - fmt.Printf("Error writing response: %v\n", err) + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + if len(body) == 0 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Empty body")) + return } + + txs := splitLines(body) + accepted := hs.executor.InjectTxs(txs) + hs.injectedTxs.Add(uint64(accepted)) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]int{"accepted": accepted}) } -// handleKV handles direct key-value operations (GET/POST) against the database -// GET /kv?key=somekey - retrieve a value -// POST /kv with JSON {"key": "somekey", "value": "somevalue"} - set a value -func (hs *HTTPServer) handleKV(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - key := r.URL.Query().Get("key") - if key == "" { - http.Error(w, "Missing key parameter", http.StatusBadRequest) - return +func splitLines(data []byte) [][]byte { + var lines [][]byte + scanner := bufio.NewScanner(bytesReader(data)) + scanner.Buffer(make([]byte, 0, 256), 4096) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) > 0 { + tx := make([]byte, len(line)) + copy(tx, line) + lines = append(lines, tx) } + } + return lines +} - // Use r.Context() when calling the executor method - value, exists := hs.executor.GetStoreValue(r.Context(), key) - if !exists { - // GetStoreValue now returns false on error too, check logs for details - // Check if the key truly doesn't exist vs a DB error occurred. - // For simplicity here, we treat both as Not Found for the client. - // A more robust implementation might check the error type. - _, err := hs.executor.db.Get(r.Context(), ds.NewKey(key)) - if errors.Is(err, ds.ErrNotFound) { - http.Error(w, "Key not found", http.StatusNotFound) - } else { - // Some other DB error occurred - http.Error(w, "Failed to retrieve key", http.StatusInternalServerError) - fmt.Printf("Error retrieving key '%s' from DB: %v\n", key, err) - } - return - } +type bytesReaderImpl struct { + data []byte + pos int +} - _, err := w.Write([]byte(value)) - if err != nil { - fmt.Printf("Error writing response: %v\n", err) - } +func bytesReader(data []byte) *bytesReaderImpl { + return &bytesReaderImpl{data: data} +} - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +func (r *bytesReaderImpl) Read(p []byte) (int, error) { + if r.pos >= len(r.data) { + return 0, io.EOF } + n := copy(p, r.data[r.pos:]) + r.pos += n + return n, nil } -// handleStore returns all non-reserved key-value pairs in the store by querying the database -// GET /store -func (hs *HTTPServer) handleStore(w http.ResponseWriter, r *http.Request) { +func (hs *HTTPServer) handleKV(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - store := make(map[string]string) - q := query.Query{} // Query all entries - results, err := hs.executor.db.Query(r.Context(), q) - if err != nil { - http.Error(w, "Failed to query store", http.StatusInternalServerError) - fmt.Printf("Error querying datastore: %v\n", err) + key := r.URL.Query().Get("key") + if key == "" { + http.Error(w, "Missing key parameter", http.StatusBadRequest) return } - defer results.Close() - for result := range results.Next() { - if result.Error != nil { - http.Error(w, "Failed during store iteration", http.StatusInternalServerError) - fmt.Printf("Error iterating datastore results: %v\n", result.Error) - return + value, exists := hs.executor.GetStoreValue(r.Context(), key) + if !exists { + if _, err := hs.executor.db.Get(r.Context(), ds.NewKey(key)); errors.Is(err, ds.ErrNotFound) { + http.Error(w, "Key not found", http.StatusNotFound) + } else { + http.Error(w, "Failed to retrieve key", http.StatusInternalServerError) + fmt.Printf("Error retrieving key '%s' from DB: %v\n", key, err) } - // Exclude reserved genesis keys from the output - dsKey := ds.NewKey(result.Key) - if dsKey.Equal(genesisInitializedKey) || dsKey.Equal(genesisStateRootKey) { - continue - } - store[result.Key] = string(result.Value) + return } + w.Write([]byte(value)) +} + +func (hs *HTTPServer) handleStore(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + store := hs.executor.GetAllEntries() + w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(store); err != nil { fmt.Printf("Error encoding JSON response: %v\n", err) @@ -213,7 +218,5 @@ func (hs *HTTPServer) handleStats(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(stats); err != nil { - fmt.Printf("Error encoding stats response: %v\n", err) - } + json.NewEncoder(w).Encode(stats) } diff --git a/apps/testapp/kv/http_server_test.go b/apps/testapp/kv/http_server_test.go index 85d6448a5e..6eb0b3ea47 100644 --- a/apps/testapp/kv/http_server_test.go +++ b/apps/testapp/kv/http_server_test.go @@ -45,10 +45,7 @@ func TestHandleTx(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() server := NewHTTPServer(exec, ":0") req := httptest.NewRequest(tt.method, "/tx", strings.NewReader(tt.body)) @@ -64,9 +61,7 @@ func TestHandleTx(t *testing.T) { t.Errorf("expected body %q, got %q", tt.expectedBody, rr.Body.String()) } - // Verify the transaction was added to the channel if it was a valid POST if tt.method == http.MethodPost && tt.expectedStatus == http.StatusAccepted { - // Allow a moment for the channel send to potentially complete time.Sleep(10 * time.Millisecond) ctx := context.Background() retrievedTxs, err := exec.GetTxs(ctx) @@ -79,7 +74,6 @@ func TestHandleTx(t *testing.T) { t.Errorf("expected channel to contain %q, got %q", tt.body, string(retrievedTxs[0])) } } else if tt.method == http.MethodPost { - // If it was a POST but not accepted, ensure nothing ended up in the channel ctx := context.Background() retrievedTxs, err := exec.GetTxs(ctx) if err != nil { @@ -130,15 +124,10 @@ func TestHandleKV_Get(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() server := NewHTTPServer(exec, ":0") - // Set up initial data if needed if tt.key != "" && tt.value != "" { - // Create and execute the transaction directly tx := fmt.Appendf(nil, "%s=%s", tt.key, tt.value) ctx := context.Background() _, err := exec.ExecuteTxs(ctx, [][]byte{tx}, 1, time.Now(), []byte("")) @@ -169,18 +158,12 @@ func TestHandleKV_Get(t *testing.T) { } func TestHTTPServerStartStop(t *testing.T) { - // Create a test server that listens on a random port - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // This is just a placeholder handler w.WriteHeader(http.StatusOK) })) defer server.Close() - // Test the NewHTTPServer function httpServer := NewHTTPServer(exec, server.URL) if httpServer == nil { t.Fatal("NewHTTPServer returned nil") @@ -190,36 +173,23 @@ func TestHTTPServerStartStop(t *testing.T) { t.Error("HTTPServer.executor not set correctly") } - // Note: We don't test Start() and Stop() methods directly - // as they actually bind to ports, which can be problematic in unit tests. - // In a real test environment, you might want to use integration tests for these. - - // Test with context (minimal test just to verify it compiles) _, cancel := context.WithCancel(context.Background()) defer cancel() - // Just create a mock test to ensure the context parameter is accepted - // Don't actually start the server in the test testServer := &HTTPServer{ server: &http.Server{ - Addr: ":0", // Use a random port - ReadHeaderTimeout: 10 * time.Second, // Add timeout to prevent Slowloris attacks + Addr: ":0", + ReadHeaderTimeout: 10 * time.Second, }, executor: exec, } - // Just verify the method signature works _ = testServer.Start } -// TestHTTPServerContextCancellation tests that the server shuts down properly when the context is cancelled func TestHTTPServerContextCancellation(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() - // Use a random available port listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("Failed to find available port: %v", err) @@ -232,19 +202,15 @@ func TestHTTPServerContextCancellation(t *testing.T) { serverAddr := fmt.Sprintf("127.0.0.1:%d", port) server := NewHTTPServer(exec, serverAddr) - // Create a context with cancel function ctx, cancel := context.WithCancel(context.Background()) - // Start the server errCh := make(chan error, 1) go func() { errCh <- server.Start(ctx) }() - // Give it time to start time.Sleep(100 * time.Millisecond) - // Send a request to confirm it's running client := &http.Client{Timeout: 1 * time.Second} resp, err := client.Get(fmt.Sprintf("http://%s/store", serverAddr)) if err != nil { @@ -258,10 +224,8 @@ func TestHTTPServerContextCancellation(t *testing.T) { t.Fatalf("Expected status 200, got %d", resp.StatusCode) } - // Cancel the context to shut down the server cancel() - // Wait for shutdown to complete with timeout select { case err := <-errCh: if err != nil && errors.Is(err, http.ErrServerClosed) { @@ -271,7 +235,6 @@ func TestHTTPServerContextCancellation(t *testing.T) { t.Fatal("Server shutdown timed out") } - // Verify server is actually shutdown by attempting a new connection _, err = client.Get(fmt.Sprintf("http://%s/store", serverAddr)) if err == nil { t.Fatal("Expected connection error after shutdown, but got none") @@ -279,15 +242,11 @@ func TestHTTPServerContextCancellation(t *testing.T) { } func TestHTTPIntegration_GetKVWithMultipleHeights(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() ctx := context.Background() - // Execute transactions at different heights for the same key txsHeight1 := [][]byte{[]byte("testkey=original_value")} - _, err = exec.ExecuteTxs(ctx, txsHeight1, 1, time.Now(), []byte("")) + _, err := exec.ExecuteTxs(ctx, txsHeight1, 1, time.Now(), []byte("")) if err != nil { t.Fatalf("ExecuteTxs failed for height 1: %v", err) } @@ -300,7 +259,6 @@ func TestHTTPIntegration_GetKVWithMultipleHeights(t *testing.T) { server := NewHTTPServer(exec, ":0") - // Test GET request - should return the latest value req := httptest.NewRequest(http.MethodGet, "/kv?key=testkey", nil) rr := httptest.NewRecorder() diff --git a/apps/testapp/kv/kvexecutor.go b/apps/testapp/kv/kvexecutor.go index aef3aedf3a..cf2338c343 100644 --- a/apps/testapp/kv/kvexecutor.go +++ b/apps/testapp/kv/kvexecutor.go @@ -4,16 +4,21 @@ import ( "context" "errors" "fmt" + "hash/fnv" + "maps" "sort" + "strconv" "strings" + "sync" "sync/atomic" "time" + "unsafe" ds "github.com/ipfs/go-datastore" "github.com/ipfs/go-datastore/query" + dssync "github.com/ipfs/go-datastore/sync" "github.com/evstack/ev-node/core/execution" - "github.com/evstack/ev-node/pkg/store" ) var ( @@ -21,25 +26,46 @@ var ( genesisStateRootKey = ds.NewKey("/genesis/stateroot") heightKeyPrefix = ds.NewKey("/height") finalizedHeightKey = ds.NewKey("/finalizedHeight") - // Define a buffer size for the transaction channel - txChannelBufferSize = 100_000_000 - // reservedKeys defines the set of keys that should be excluded from state root calculation - // and protected from transaction modifications - reservedKeys = map[ds.Key]bool{ + reservedKeys = map[ds.Key]bool{ genesisInitializedKey: true, genesisStateRootKey: true, finalizedHeightKey: true, } ) -// KVExecutor is a simple key-value store backed by go-datastore that implements the Executor interface -// for testing purposes. It uses a buffered channel as a mempool for transactions. -// It also includes fields to track genesis initialization persisted in the datastore. +const shardCount = 64 + +type txShard struct { + mu sync.Mutex + buf [][]byte + flush chan [][]byte +} + type KVExecutor struct { db ds.Batching - txChan chan []byte blocksProduced atomic.Uint64 totalExecutedTxs atomic.Uint64 + + stateMu sync.RWMutex + stateMap map[string]string + sortedKeys []string + cachedRoot []byte + rootDirty bool + + shards [shardCount]txShard + shardIdx atomic.Uint64 + + dbWriteCh chan dbWriteReq +} + +type dbWriteReq struct { + height uint64 + kvs []kvEntry +} + +type kvEntry struct { + key string + value string } type ExecutorStats struct { @@ -54,110 +80,92 @@ func (k *KVExecutor) GetStats() ExecutorStats { } } -// NewKVExecutor creates a new instance of KVExecutor with initialized store and mempool channel. -func NewKVExecutor(rootdir, dbpath string) (*KVExecutor, error) { - datastore, err := store.NewDefaultKVStore(rootdir, dbpath, "executor") - if err != nil { - return nil, err +func NewKVExecutor() *KVExecutor { + k := &KVExecutor{ + db: dssync.MutexWrap(ds.NewMapDatastore()), + stateMap: make(map[string]string), + dbWriteCh: make(chan dbWriteReq, 4096), } - return &KVExecutor{ - db: datastore, - txChan: make(chan []byte, txChannelBufferSize), - }, nil -} -// GetStoreValue is a helper for the HTTP interface to retrieve the value for a key from the database. -// It searches across all block heights to find the latest value for the given key. -func (k *KVExecutor) GetStoreValue(ctx context.Context, key string) (string, bool) { - // Query all keys to find height-prefixed versions of this key - q := query.Query{} - results, err := k.db.Query(ctx, q) - if err != nil { - fmt.Printf("Error querying DB for key '%s': %v\n", key, err) - return "", false + for i := range k.shards { + k.shards[i].buf = make([][]byte, 0, 256) + k.shards[i].flush = make(chan [][]byte, 64) } - defer results.Close() - heightPrefix := heightKeyPrefix.String() - var latestValue string - var latestHeight uint64 - found := false + go k.dbWriterLoop() - for result := range results.Next() { - if result.Error != nil { - fmt.Printf("Error iterating query results for key '%s': %v\n", key, result.Error) - return "", false - } + return k +} - resultKey := result.Key - // Check if this is a height-prefixed key that matches our target key - if after, ok := strings.CutPrefix(resultKey, heightPrefix+"/"); ok { - // Extract height and actual key: /height/{height}/{actual_key} - parts := strings.Split(after, "/") - if len(parts) >= 2 { - var keyHeight uint64 - if _, err := fmt.Sscanf(parts[0], "%d", &keyHeight); err == nil { - // Reconstruct the actual key by joining all parts after the height - actualKey := strings.Join(parts[1:], "/") - if actualKey == key { - // This key matches - check if it's the latest height - if !found || keyHeight > latestHeight { - latestHeight = keyHeight - latestValue = string(result.Value) - found = true - } - } - } - } +func (k *KVExecutor) dbWriterLoop() { + for req := range k.dbWriteCh { + if len(req.kvs) == 0 { + continue + } + ctx := context.Background() + batch, err := k.db.Batch(ctx) + if err != nil { + continue + } + for _, kv := range req.kvs { + dsKey := getTxKey(req.height, kv.key) + _ = batch.Put(ctx, dsKey, bytesFromString(kv.value)) } + _ = batch.Commit(ctx) } +} - if !found { - return "", false - } +func (k *KVExecutor) GetStoreValue(_ context.Context, key string) (string, bool) { + k.stateMu.RLock() + val := k.stateMap[key] + k.stateMu.RUnlock() + return val, val != "" +} - return latestValue, true +func (k *KVExecutor) GetAllEntries() map[string]string { + k.stateMu.RLock() + m := make(map[string]string, len(k.stateMap)) + maps.Copy(m, k.stateMap) + k.stateMu.RUnlock() + return m } -// computeStateRoot computes a deterministic state root by querying all keys, sorting them, -// and concatenating key-value pairs from the database. -func (k *KVExecutor) computeStateRoot(ctx context.Context) ([]byte, error) { - q := query.Query{KeysOnly: true} - results, err := k.db.Query(ctx, q) - if err != nil { - return nil, fmt.Errorf("failed to query keys for state root: %w", err) +func (k *KVExecutor) insertSorted(key string) { + n := len(k.sortedKeys) + i := sort.SearchStrings(k.sortedKeys, key) + if i < n && k.sortedKeys[i] == key { + return } - defer results.Close() + k.sortedKeys = append(k.sortedKeys, "") + copy(k.sortedKeys[i+1:], k.sortedKeys[i:]) + k.sortedKeys[i] = key +} - keys := make([]string, 0) - for result := range results.Next() { - if result.Error != nil { - return nil, fmt.Errorf("error iterating query results: %w", result.Error) - } - // Exclude reserved keys from the state root calculation - dsKey := ds.NewKey(result.Key) - if reservedKeys[dsKey] { - continue - } - keys = append(keys, result.Key) +func (k *KVExecutor) computeRootFromStateLocked() []byte { + if !k.rootDirty && k.cachedRoot != nil { + return k.cachedRoot } - sort.Strings(keys) - var sb strings.Builder - for _, key := range keys { - valueBytes, err := k.db.Get(ctx, ds.NewKey(key)) - if err != nil { - // This shouldn't happen if the key came from the query, but handle defensively - return nil, fmt.Errorf("failed to get value for key '%s' during state root computation: %w", key, err) - } - sb.WriteString(fmt.Sprintf("%s:%s;", key, string(valueBytes))) + h := fnv.New128a() + for _, key := range k.sortedKeys { + h.Write(bytesFromString(key)) + h.Write([]byte{0}) + h.Write(bytesFromString(k.stateMap[key])) + h.Write([]byte{0}) } - return []byte(sb.String()), nil + + k.cachedRoot = h.Sum(nil) + k.rootDirty = false + return k.cachedRoot +} + +func (k *KVExecutor) computeStateRoot(_ context.Context) ([]byte, error) { + k.stateMu.Lock() + root := k.computeRootFromStateLocked() + k.stateMu.Unlock() + return root, nil } -// InitChain initializes the chain state with genesis parameters. -// It checks the database to see if genesis was already performed. -// If not, it computes the state root from the current DB state and persists genesis info. func (k *KVExecutor) InitChain(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) ([]byte, error) { select { case <-ctx.Done(): @@ -178,36 +186,28 @@ func (k *KVExecutor) InitChain(ctx context.Context, genesisTime time.Time, initi return genesisRoot, nil } - // Genesis not initialized. Compute state root from the current DB state. - // Note: The DB might not be empty if restarting, this reflects the state *at genesis time*. stateRoot, err := k.computeStateRoot(ctx) if err != nil { return nil, fmt.Errorf("failed to compute initial state root for genesis: %w", err) } - // Persist genesis state root and initialized flag batch, err := k.db.Batch(ctx) if err != nil { return nil, fmt.Errorf("failed to create batch for genesis persistence: %w", err) } - err = batch.Put(ctx, genesisStateRootKey, stateRoot) - if err != nil { + if err := batch.Put(ctx, genesisStateRootKey, stateRoot); err != nil { return nil, fmt.Errorf("failed to put genesis state root in batch: %w", err) } - err = batch.Put(ctx, genesisInitializedKey, []byte("true")) // Store a marker value - if err != nil { + if err := batch.Put(ctx, genesisInitializedKey, []byte("true")); err != nil { return nil, fmt.Errorf("failed to put genesis initialized flag in batch: %w", err) } - err = batch.Commit(ctx) - if err != nil { + if err := batch.Commit(ctx); err != nil { return nil, fmt.Errorf("failed to commit genesis persistence batch: %w", err) } return stateRoot, nil } -// GetTxs retrieves available transactions from the mempool channel. -// It drains the channel in a non-blocking way. func (k *KVExecutor) GetTxs(ctx context.Context) ([][]byte, error) { select { case <-ctx.Done(): @@ -215,30 +215,31 @@ func (k *KVExecutor) GetTxs(ctx context.Context) ([][]byte, error) { default: } - // Drain the channel efficiently - txs := make([][]byte, 0, len(k.txChan)) // Pre-allocate roughly - for { + var all [][]byte + for i := range k.shards { + s := &k.shards[i] + s.mu.Lock() + if len(s.buf) > 0 { + batch := s.buf + s.buf = make([][]byte, 0, cap(batch)) + s.mu.Unlock() + all = append(all, batch...) + } else { + s.mu.Unlock() + } select { - case tx := <-k.txChan: - txs = append(txs, tx) - default: // Channel is empty or context is done - // Check context again in case it was cancelled during drain - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - if len(txs) == 0 { - return nil, nil // Return nil slice if no transactions were retrieved - } - return txs, nil - } + case batch := <-s.flush: + all = append(all, batch...) + default: } } + + if len(all) == 0 { + return nil, nil + } + return all, nil } -// ExecuteTxs processes each transaction assumed to be in the format "key=value". -// It updates the database accordingly using a batch and removes the executed transactions from the mempool. -// Invalid transactions are filtered out and logged, but execution continues. func (k *KVExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) { select { case <-ctx.Done(): @@ -246,82 +247,66 @@ func (k *KVExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight u default: } - batch, err := k.db.Batch(ctx) - if err != nil { - return nil, fmt.Errorf("failed to create database batch: %w", err) + type parsedTx struct { + key string + value string } - validTxCount := 0 - invalidTxCount := 0 + parsed := make([]parsedTx, 0, len(txs)) - // Process transactions and stage them in the batch - // Filter out invalid/gibberish transactions gracefully - for i, tx := range txs { - // Skip empty transactions + for _, tx := range txs { if len(tx) == 0 { - fmt.Printf("Warning: skipping empty transaction at index %d in block %d\n", i, blockHeight) - invalidTxCount++ continue } - parts := strings.SplitN(string(tx), "=", 2) - if len(parts) != 2 { - fmt.Printf("Warning: filtering out malformed transaction at index %d in block %d (expected format key=value): %s\n", i, blockHeight, string(tx)) - invalidTxCount++ + s := stringUnsafe(tx) + before, after, ok := strings.Cut(s, "=") + if !ok { continue } - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) + key := strings.TrimSpace(before) if key == "" { - fmt.Printf("Warning: filtering out transaction with empty key at index %d in block %d\n", i, blockHeight) - invalidTxCount++ continue } + value := strings.TrimSpace(after) dsKey := getTxKey(blockHeight, key) - - // Prevent writing reserved keys via transactions if reservedKeys[dsKey] { - fmt.Printf("Warning: filtering out transaction attempting to modify reserved key at index %d in block %d: %s\n", i, blockHeight, key) - invalidTxCount++ continue } - err = batch.Put(ctx, dsKey, []byte(value)) - if err != nil { - // This error is unlikely for Put unless the context is cancelled. - return nil, fmt.Errorf("failed to stage put operation in batch for key '%s': %w", key, err) - } - validTxCount++ + parsed = append(parsed, parsedTx{key: key, value: value}) } - // Log filtering results if any transactions were filtered - if invalidTxCount > 0 { - fmt.Printf("Block %d: processed %d valid transactions, filtered out %d invalid transactions\n", blockHeight, validTxCount, invalidTxCount) + k.stateMu.Lock() + for _, p := range parsed { + if _, exists := k.stateMap[p.key]; !exists { + k.insertSorted(p.key) + } + k.stateMap[p.key] = p.value } + k.rootDirty = true + root := k.computeRootFromStateLocked() + k.stateMu.Unlock() - // Commit the batch to apply all changes atomically - err = batch.Commit(ctx) - if err != nil { - return nil, fmt.Errorf("failed to commit transaction batch: %w", err) + if len(parsed) > 0 { + kvs := make([]kvEntry, len(parsed)) + for i, p := range parsed { + kvs[i] = kvEntry{key: p.key, value: p.value} + } + select { + case k.dbWriteCh <- dbWriteReq{height: blockHeight, kvs: kvs}: + default: + } } k.blocksProduced.Add(1) - k.totalExecutedTxs.Add(uint64(validTxCount)) - - // Compute the new state root *after* successful commit - stateRoot, err := k.computeStateRoot(ctx) - if err != nil { - // This is problematic, state was changed but root calculation failed. - // May need more robust error handling or recovery logic. - return nil, fmt.Errorf("failed to compute state root after executing transactions: %w", err) - } + k.totalExecutedTxs.Add(uint64(len(parsed))) - return stateRoot, nil + return root, nil } -// SetFinal marks a block as finalized at the specified height. func (k *KVExecutor) SetFinal(ctx context.Context, blockHeight uint64) error { select { case <-ctx.Done(): @@ -329,28 +314,32 @@ func (k *KVExecutor) SetFinal(ctx context.Context, blockHeight uint64) error { default: } - // Validate blockHeight if blockHeight == 0 { return errors.New("invalid blockHeight: cannot be zero") } - return k.db.Put(ctx, finalizedHeightKey, fmt.Appendf(nil, "%d", blockHeight)) + return k.db.Put(ctx, finalizedHeightKey, strconv.AppendUint(nil, blockHeight, 10)) } -// InjectTx adds a transaction to the mempool channel. -// Uses a non-blocking send to avoid blocking the caller if the channel is full. func (k *KVExecutor) InjectTx(tx []byte) { - select { - case k.txChan <- tx: - // Transaction successfully sent to channel - default: - // Channel is full, transaction is dropped. Log this event. - fmt.Printf("Warning: Transaction channel buffer full. Dropping transaction.\n") - // Consider adding metrics here + s := &k.shards[(k.shardIdx.Add(1))%shardCount] + s.mu.Lock() + s.buf = append(s.buf, tx) + s.mu.Unlock() +} + +func (k *KVExecutor) InjectTxs(txs [][]byte) int { + accepted := 0 + for _, tx := range txs { + s := &k.shards[(k.shardIdx.Add(1))%shardCount] + s.mu.Lock() + s.buf = append(s.buf, tx) + s.mu.Unlock() + accepted++ } + return accepted } -// Rollback reverts the state to the previous block height. func (k *KVExecutor) Rollback(ctx context.Context, height uint64) error { select { case <-ctx.Done(): @@ -358,18 +347,15 @@ func (k *KVExecutor) Rollback(ctx context.Context, height uint64) error { default: } - // Validate height constraints if height == 0 { return fmt.Errorf("cannot rollback to height 0: invalid height") } - // Create a batch for atomic rollback operation batch, err := k.db.Batch(ctx) if err != nil { return fmt.Errorf("failed to create batch for rollback: %w", err) } - // Query all keys to find those with height > target height q := query.Query{} results, err := k.db.Query(ctx, q) if err != nil { @@ -386,14 +372,11 @@ func (k *KVExecutor) Rollback(ctx context.Context, height uint64) error { } key := result.Key - // Check if this is a height-prefixed key if after, ok := strings.CutPrefix(key, heightPrefix+"/"); ok { - // Extract height from key: /height/{height}/{actual_key} (see getTxKey) parts := strings.Split(after, "/") if len(parts) > 0 { var keyHeight uint64 if _, err := fmt.Sscanf(parts[0], "%d", &keyHeight); err == nil { - // If this key's height is greater than target, mark for deletion if keyHeight > height { keysToDelete = append(keysToDelete, ds.NewKey(key)) } @@ -402,64 +385,99 @@ func (k *KVExecutor) Rollback(ctx context.Context, height uint64) error { } } - // Delete all keys with height > target height for _, key := range keysToDelete { select { case <-ctx.Done(): return ctx.Err() default: } - - err = batch.Delete(ctx, key) - if err != nil { + if err := batch.Delete(ctx, key); err != nil { return fmt.Errorf("failed to stage delete operation for key '%s' during rollback: %w", key.String(), err) } } - // Update finalized height if necessary - it should not exceed rollback height - finalizedHeightKey := ds.NewKey("/finalizedHeight") if finalizedHeightBytes, err := k.db.Get(ctx, finalizedHeightKey); err == nil { var finalizedHeight uint64 if _, err := fmt.Sscanf(string(finalizedHeightBytes), "%d", &finalizedHeight); err == nil { if finalizedHeight > height { - err = batch.Put(ctx, finalizedHeightKey, fmt.Appendf([]byte{}, "%d", height)) - if err != nil { + if err := batch.Put(ctx, finalizedHeightKey, strconv.AppendUint(nil, height, 10)); err != nil { return fmt.Errorf("failed to update finalized height during rollback: %w", err) } } } } - // Commit the batch atomically - err = batch.Commit(ctx) - if err != nil { + if err := batch.Commit(ctx); err != nil { return fmt.Errorf("failed to commit rollback batch: %w", err) } + k.stateMu.Lock() + k.stateMap = make(map[string]string) + k.rebuildStateFromDB(ctx) + k.sortedKeys = k.sortedKeys[:0] + for key := range k.stateMap { + k.sortedKeys = append(k.sortedKeys, key) + } + sort.Strings(k.sortedKeys) + k.rootDirty = true + k.stateMu.Unlock() + return nil } +func (k *KVExecutor) rebuildStateFromDB(ctx context.Context) { + q := query.Query{} + results, err := k.db.Query(ctx, q) + if err != nil { + return + } + defer results.Close() + + heightPrefix := heightKeyPrefix.String() + type entry struct { + height uint64 + value string + } + latest := make(map[string]entry) + + for result := range results.Next() { + if result.Error != nil { + return + } + if after, ok := strings.CutPrefix(result.Key, heightPrefix+"/"); ok { + parts := strings.Split(after, "/") + if len(parts) >= 2 { + var keyHeight uint64 + if _, err := fmt.Sscanf(parts[0], "%d", &keyHeight); err == nil { + actualKey := strings.Join(parts[1:], "/") + if e, ok := latest[actualKey]; !ok || keyHeight > e.height { + latest[actualKey] = entry{height: keyHeight, value: string(result.Value)} + } + } + } + } + } + + for key, e := range latest { + k.stateMap[key] = e.value + } +} + func getTxKey(height uint64, txKey string) ds.Key { - return heightKeyPrefix.Child(ds.NewKey(fmt.Sprintf("%d/%s", height, txKey))) + return heightKeyPrefix.ChildString(strconv.FormatUint(height, 10) + "/" + txKey) } -// GetExecutionInfo returns execution layer parameters. -// For KVExecutor, returns MaxGas=0 indicating no gas-based filtering. -func (k *KVExecutor) GetExecutionInfo(ctx context.Context) (execution.ExecutionInfo, error) { +func (k *KVExecutor) GetExecutionInfo(_ context.Context) (execution.ExecutionInfo, error) { return execution.ExecutionInfo{MaxGas: 0}, nil } -// FilterTxs validates force-included transactions and applies size filtering. -// For KVExecutor, validates key=value format when force-included txs are present. -// KVExecutor doesn't track gas, so maxGas is ignored. -func (k *KVExecutor) FilterTxs(ctx context.Context, txs [][]byte, maxBytes, maxGas uint64, hasForceIncludedTransaction bool) ([]execution.FilterStatus, error) { +func (k *KVExecutor) FilterTxs(_ context.Context, txs [][]byte, maxBytes, maxGas uint64, hasForceIncludedTransaction bool) ([]execution.FilterStatus, error) { result := make([]execution.FilterStatus, len(txs)) var cumulativeBytes uint64 limitReached := false for i, tx := range txs { - // Skip empty transactions if len(tx) == 0 { result[i] = execution.FilterRemove continue @@ -467,30 +485,28 @@ func (k *KVExecutor) FilterTxs(ctx context.Context, txs [][]byte, maxBytes, maxG txBytes := uint64(len(tx)) - // Only validate tx format if force-included txs are present - // Mempool txs are already validated if hasForceIncludedTransaction { - // Basic format validation: must be key=value - parts := strings.SplitN(string(tx), "=", 2) - if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { + eqIdx := indexByte(tx, '=') + if eqIdx < 0 { + result[i] = execution.FilterRemove + continue + } + if isAllSpace(tx[:eqIdx]) { result[i] = execution.FilterRemove continue } } - // Skip tx that can never make it in a block (too big) if maxBytes > 0 && txBytes > maxBytes { result[i] = execution.FilterRemove continue } - // Once limit is reached, postpone remaining txs if limitReached { result[i] = execution.FilterPostpone continue } - // Check size limit if maxBytes > 0 && cumulativeBytes+txBytes > maxBytes { limitReached = true result[i] = execution.FilterPostpone @@ -503,3 +519,36 @@ func (k *KVExecutor) FilterTxs(ctx context.Context, txs [][]byte, maxBytes, maxG return result, nil } + +func indexByte(s []byte, c byte) int { + for i, b := range s { + if b == c { + return i + } + } + return -1 +} + +func isAllSpace(s []byte) bool { + allSpace := true + for _, b := range s { + if b != ' ' && b != '\t' && b != '\n' && b != '\r' { + allSpace = false + break + } + } + return allSpace && len(s) == 0 +} + +func stringUnsafe(b []byte) string { + return *(*string)(unsafe.Pointer(&b)) +} + +func bytesFromString(s string) []byte { + return *(*[]byte)(unsafe.Pointer( + &struct { + string + int + }{s, len(s)}, + )) +} diff --git a/apps/testapp/kv/kvexecutor_test.go b/apps/testapp/kv/kvexecutor_test.go index 97280aee10..0a04d17c54 100644 --- a/apps/testapp/kv/kvexecutor_test.go +++ b/apps/testapp/kv/kvexecutor_test.go @@ -1,55 +1,42 @@ package executor import ( - "bytes" "context" "reflect" - "strings" "testing" "time" ) func TestInitChain_Idempotency(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() ctx := context.Background() genesisTime := time.Now() initialHeight := uint64(1) chainID := "test-chain" - // First call initializes genesis state stateRoot1, err := exec.InitChain(ctx, genesisTime, initialHeight, chainID) if err != nil { t.Fatalf("InitChain failed on first call: %v", err) } - // Second call should return the same genesis state root stateRoot2, err := exec.InitChain(ctx, genesisTime, initialHeight, chainID) if err != nil { t.Fatalf("InitChain failed on second call: %v", err) } - if !bytes.Equal(stateRoot1, stateRoot2) { - t.Errorf("Genesis state roots do not match: %s vs %s", stateRoot1, stateRoot2) + if !reflect.DeepEqual(stateRoot1, stateRoot2) { + t.Errorf("Genesis state roots do not match: %x vs %x", stateRoot1, stateRoot2) } } func TestGetTxs(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() ctx := context.Background() - // Inject transactions using the InjectTx method which sends to the channel tx1 := []byte("a=1") tx2 := []byte("b=2") exec.InjectTx(tx1) exec.InjectTx(tx2) - // Allow a brief moment for transactions to be processed by the channel if needed, - // though for buffered channels it should be immediate unless full. time.Sleep(10 * time.Millisecond) txs, err := exec.GetTxs(ctx) @@ -59,14 +46,7 @@ func TestGetTxs(t *testing.T) { if len(txs) != 2 { t.Errorf("Expected 2 transactions, got %d", len(txs)) } - if !reflect.DeepEqual(txs[0], tx1) { - t.Errorf("Expected first tx 'a=1', got %s", string(txs[0])) - } - if !reflect.DeepEqual(txs[1], tx2) { - t.Errorf("Expected second tx 'b=2', got %s", string(txs[1])) - } - // GetTxs should drain the channel, so a second call should return empty or nil txsAgain, err := exec.GetTxs(ctx) if err != nil { t.Fatalf("GetTxs (second call) returned error: %v", err) @@ -75,7 +55,6 @@ func TestGetTxs(t *testing.T) { t.Errorf("Expected 0 transactions on second call (drained), got %d", len(txsAgain)) } - // Inject another transaction and verify it's available tx3 := []byte("c=3") exec.InjectTx(tx3) time.Sleep(10 * time.Millisecond) @@ -87,19 +66,15 @@ func TestGetTxs(t *testing.T) { if len(txsAfterReinject) != 1 { t.Errorf("Expected 1 transaction after re-inject, got %d", len(txsAfterReinject)) } - if !reflect.DeepEqual(txsAfterReinject[0], tx3) { + if string(txsAfterReinject[0]) != "c=3" { t.Errorf("Expected tx 'c=3' after re-inject, got %s", string(txsAfterReinject[0])) } } func TestExecuteTxs_Valid(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() ctx := context.Background() - // Prepare valid transactions txs := [][]byte{ []byte("key1=value1"), []byte("key2=value2"), @@ -110,24 +85,24 @@ func TestExecuteTxs_Valid(t *testing.T) { t.Fatalf("ExecuteTxs failed: %v", err) } - // Check that stateRoot contains the updated key-value pairs - rootStr := string(stateRoot) - if !strings.Contains(rootStr, "key1:value1;") || !strings.Contains(rootStr, "key2:value2;") { - t.Errorf("State root does not contain expected key-values: %s", rootStr) + if stateRoot == nil { + t.Fatal("Expected non-nil state root") + } + + val, ok := exec.GetStoreValue(ctx, "key1") + if !ok || val != "value1" { + t.Errorf("Expected key1=value1, got %q, ok=%v", val, ok) + } + val, ok = exec.GetStoreValue(ctx, "key2") + if !ok || val != "value2" { + t.Errorf("Expected key2=value2, got %q, ok=%v", val, ok) } } func TestExecuteTxs_Invalid(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() ctx := context.Background() - // According to the Executor interface: "Must handle gracefully gibberish transactions" - // Invalid transactions should be filtered out, not cause errors - - // Prepare invalid transactions (missing '=') txs := [][]byte{ []byte("invalidformat"), []byte("another_invalid_one"), @@ -139,12 +114,10 @@ func TestExecuteTxs_Invalid(t *testing.T) { t.Fatalf("ExecuteTxs should handle gibberish gracefully, got error: %v", err) } - // State root should still be computed (empty block is valid) if stateRoot == nil { t.Error("Expected non-nil state root even with all invalid transactions") } - // Test mix of valid and invalid transactions mixedTxs := [][]byte{ []byte("valid_key=valid_value"), []byte("invalidformat"), @@ -152,32 +125,30 @@ func TestExecuteTxs_Invalid(t *testing.T) { []byte(""), } - stateRoot2, err := exec.ExecuteTxs(ctx, mixedTxs, 2, time.Now(), stateRoot) + _, err = exec.ExecuteTxs(ctx, mixedTxs, 2, time.Now(), stateRoot) if err != nil { t.Fatalf("ExecuteTxs should filter invalid transactions and process valid ones, got error: %v", err) } - // State root should contain only the valid transactions - rootStr := string(stateRoot2) - if !strings.Contains(rootStr, "valid_key:valid_value") || !strings.Contains(rootStr, "another_valid:value2") { - t.Errorf("State root should contain valid transactions: %s", rootStr) + val, ok := exec.GetStoreValue(ctx, "valid_key") + if !ok || val != "valid_value" { + t.Errorf("Expected valid_key=valid_value, got %q, ok=%v", val, ok) + } + val, ok = exec.GetStoreValue(ctx, "another_valid") + if !ok || val != "value2" { + t.Errorf("Expected another_valid=value2, got %q, ok=%v", val, ok) } } func TestSetFinal(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() ctx := context.Background() - // Test with valid blockHeight - err = exec.SetFinal(ctx, 1) + err := exec.SetFinal(ctx, 1) if err != nil { t.Errorf("Expected nil error for valid blockHeight, got %v", err) } - // Test with invalid blockHeight (zero) err = exec.SetFinal(ctx, 0) if err == nil { t.Error("Expected error for blockHeight 0, got nil") @@ -185,19 +156,14 @@ func TestSetFinal(t *testing.T) { } func TestReservedKeysExcludedFromAppHash(t *testing.T) { - exec, err := NewKVExecutor(t.TempDir(), "testdb") - if err != nil { - t.Fatalf("Failed to create KVExecutor: %v", err) - } + exec := NewKVExecutor() ctx := context.Background() - // Initialize chain to set up genesis state (this writes genesis reserved keys) - _, err = exec.InitChain(ctx, time.Now(), 1, "test-chain") + _, err := exec.InitChain(ctx, time.Now(), 1, "test-chain") if err != nil { t.Fatalf("Failed to initialize chain: %v", err) } - // Add some application data txs := [][]byte{ []byte("user/key1=value1"), []byte("user/key2=value2"), @@ -207,19 +173,16 @@ func TestReservedKeysExcludedFromAppHash(t *testing.T) { t.Fatalf("Failed to execute transactions: %v", err) } - // Compute baseline state root baselineStateRoot, err := exec.computeStateRoot(ctx) if err != nil { t.Fatalf("Failed to compute baseline state root: %v", err) } - // Write to finalizedHeight (a reserved key) err = exec.SetFinal(ctx, 5) if err != nil { t.Fatalf("Failed to set final height: %v", err) } - // Verify finalizedHeight was written finalizedHeightExists, err := exec.db.Has(ctx, finalizedHeightKey) if err != nil { t.Fatalf("Failed to check if finalizedHeight exists: %v", err) @@ -228,33 +191,16 @@ func TestReservedKeysExcludedFromAppHash(t *testing.T) { t.Error("Expected finalizedHeight to exist in database") } - // State root should be unchanged (reserved keys excluded from calculation) stateRootAfterReservedKeyWrite, err := exec.computeStateRoot(ctx) if err != nil { t.Fatalf("Failed to compute state root after writing reserved key: %v", err) } - if string(baselineStateRoot) != string(stateRootAfterReservedKeyWrite) { - t.Errorf("State root changed after writing reserved key:\nBefore: %s\nAfter: %s", - string(baselineStateRoot), string(stateRootAfterReservedKeyWrite)) - } - - // Verify state root contains only user data, not reserved keys - stateRootStr := string(stateRootAfterReservedKeyWrite) - if !strings.Contains(stateRootStr, "user/key1:value1") || - !strings.Contains(stateRootStr, "user/key2:value2") { - t.Errorf("State root should contain user data: %s", stateRootStr) - } - - // Verify reserved keys are NOT in state root - for key := range reservedKeys { - keyStr := key.String() - if strings.Contains(stateRootStr, keyStr) { - t.Errorf("State root should NOT contain reserved key %s: %s", keyStr, stateRootStr) - } + if !reflect.DeepEqual(baselineStateRoot, stateRootAfterReservedKeyWrite) { + t.Errorf("State root changed after writing reserved key:\nBefore: %x\nAfter: %x", + baselineStateRoot, stateRootAfterReservedKeyWrite) } - // Verify that adding user data DOES change the state root moreTxs := [][]byte{ []byte("user/key3=value3"), } @@ -268,7 +214,65 @@ func TestReservedKeysExcludedFromAppHash(t *testing.T) { t.Fatalf("Failed to compute final state root: %v", err) } - if string(baselineStateRoot) == string(finalStateRoot) { + if reflect.DeepEqual(baselineStateRoot, finalStateRoot) { t.Error("Expected state root to change after adding user data") } } + +func TestStateRootDeterministic(t *testing.T) { + exec1 := NewKVExecutor() + ctx := context.Background() + + txs := [][]byte{ + []byte("alpha=1"), + []byte("beta=2"), + []byte("gamma=3"), + } + _, err := exec1.ExecuteTxs(ctx, txs, 1, time.Now(), []byte("")) + if err != nil { + t.Fatalf("ExecuteTxs failed: %v", err) + } + root1, err := exec1.computeStateRoot(ctx) + if err != nil { + t.Fatalf("computeStateRoot failed: %v", err) + } + + exec2 := NewKVExecutor() + _, err = exec2.ExecuteTxs(ctx, [][]byte{ + []byte("gamma=3"), + []byte("alpha=1"), + []byte("beta=2"), + }, 1, time.Now(), []byte("")) + if err != nil { + t.Fatalf("ExecuteTxs failed: %v", err) + } + root2, err := exec2.computeStateRoot(ctx) + if err != nil { + t.Fatalf("computeStateRoot failed: %v", err) + } + + if !reflect.DeepEqual(root1, root2) { + t.Errorf("State roots should be deterministic regardless of insertion order:\n%x\n%x", root1, root2) + } +} + +func TestExecuteTxsDuplicateKeys(t *testing.T) { + exec := NewKVExecutor() + ctx := context.Background() + + txs := [][]byte{ + []byte("key=v1"), + []byte("key=v2"), + []byte("key=v3"), + } + + _, err := exec.ExecuteTxs(ctx, txs, 1, time.Now(), []byte("")) + if err != nil { + t.Fatalf("ExecuteTxs failed: %v", err) + } + + val, ok := exec.GetStoreValue(ctx, "key") + if !ok || val != "v3" { + t.Errorf("Expected key=v3 (last write wins), got %q, ok=%v", val, ok) + } +} From d28180e56927a99416c5d76106b924e88cc5c782 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 21:16:12 +0200 Subject: [PATCH 18/23] remove redundant --- block/internal/reaping/reaper.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/block/internal/reaping/reaper.go b/block/internal/reaping/reaper.go index 7a38155bfe..0a1e38d8ff 100644 --- a/block/internal/reaping/reaper.go +++ b/block/internal/reaping/reaper.go @@ -155,10 +155,9 @@ type pendingTx struct { func (r *Reaper) drainMempool() (bool, error) { var totalSubmitted int - submitted := false defer func() { - if submitted && r.onTxsSubmitted != nil { + if totalSubmitted > 0 && r.onTxsSubmitted != nil { r.onTxsSubmitted() } }() @@ -182,7 +181,6 @@ func (r *Reaper) drainMempool() (bool, error) { return totalSubmitted > 0, err } totalSubmitted += n - submitted = true } if totalSubmitted > 0 { From 70c8b897754ee181fe7d29bfdcfed26d905e3afb Mon Sep 17 00:00:00 2001 From: julienrbrt Date: Thu, 9 Apr 2026 21:21:06 +0200 Subject: [PATCH 19/23] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e62149b481..5eaa327134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changes + +- Improve reaper to sustain txs burst better [#3236](https://github.com/evstack/ev-node/pull/3236) + ## v1.1.0 No changes from v1.1.0-rc.2. From 6c443814afea00d15d1cb3c018fdb01242f07e2c Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 9 Apr 2026 21:45:10 +0200 Subject: [PATCH 20/23] feedback --- block/internal/reaping/reaper.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/block/internal/reaping/reaper.go b/block/internal/reaping/reaper.go index 0a1e38d8ff..7448c7d1a3 100644 --- a/block/internal/reaping/reaper.go +++ b/block/internal/reaping/reaper.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "runtime" "sync" "time" @@ -96,7 +97,7 @@ func (r *Reaper) reaperLoop() { r.logger.Warn(). Err(err). Int("consecutive_failures", consecutiveFailures). - Dur("backoff", backoff). + Dur("next_retry_in", backoff). Msg("reaper error, backing off") if r.wait(backoff, nil) { return @@ -110,9 +111,13 @@ func (r *Reaper) reaperLoop() { } if submitted { + runtime.Gosched() continue } + // Note: if the cleanup ticker fires before the idle interval elapses, + // the remaining idle duration is discarded. drainMempool() is called + // immediately and a fresh idle wait starts from scratch. if r.wait(r.interval, cleanupTicker.C) { return } From 076f0943549b2875cef7b3de2bdb6e5c4a7cb088 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Fri, 10 Apr 2026 09:50:30 +0200 Subject: [PATCH 21/23] fixes --- apps/testapp/Dockerfile | 1 + block/internal/common/consts.go | 4 ++-- pkg/sequencers/solo/README.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/testapp/Dockerfile b/apps/testapp/Dockerfile index ed4f2f2b73..e41335f7b6 100644 --- a/apps/testapp/Dockerfile +++ b/apps/testapp/Dockerfile @@ -28,6 +28,7 @@ COPY . . WORKDIR /ev-node/apps/testapp +# 125829120 = 120 MB RUN go build -ldflags "-X github.com/evstack/ev-node/block/internal/common.defaultMaxBlobSizeStr=125829120" -o /go/bin/testapp . ## prep the final image. diff --git a/block/internal/common/consts.go b/block/internal/common/consts.go index 0ac92a9655..840b2faa97 100644 --- a/block/internal/common/consts.go +++ b/block/internal/common/consts.go @@ -9,12 +9,12 @@ import "strconv" var defaultMaxBlobSizeStr = "5242880" // 5 MB // DefaultMaxBlobSize is the max blob size limit used for blob submission. -var DefaultMaxBlobSize uint64 = 5 * 1024 * 1024 +var DefaultMaxBlobSize uint64 func init() { v, err := strconv.ParseUint(defaultMaxBlobSizeStr, 10, 64) if err != nil || v == 0 { - DefaultMaxBlobSize = 5 * 1024 * 1024 + DefaultMaxBlobSize = 5 * 1024 * 1024 // 5 MB fallback return } DefaultMaxBlobSize = v diff --git a/pkg/sequencers/solo/README.md b/pkg/sequencers/solo/README.md index bdeb3cd7dc..d58d33ed8a 100644 --- a/pkg/sequencers/solo/README.md +++ b/pkg/sequencers/solo/README.md @@ -32,7 +32,7 @@ flowchart TD B -->|No| C["Return ErrInvalidID"] B -->|Yes| D{"Empty batch?"} D -->|Yes| E["Return OK"] - F -->|No| H["Append txs to queue"] + D -->|No| H["Append txs to queue"] H --> E ``` From 6e37903b1fc11e3dbb96e54bfbe4f7d71c12b79f Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Fri, 10 Apr 2026 12:58:14 +0200 Subject: [PATCH 22/23] updates --- apps/testapp/kv/bench/main.go | 6 +- apps/testapp/kv/http_server.go | 115 +++++-- apps/testapp/kv/http_server_test.go | 31 +- apps/testapp/kv/kvexecutor.go | 479 +++++++++++++--------------- apps/testapp/kv/kvexecutor_test.go | 149 ++++----- 5 files changed, 402 insertions(+), 378 deletions(-) diff --git a/apps/testapp/kv/bench/main.go b/apps/testapp/kv/bench/main.go index 33e4627ab8..7f2d296c9b 100644 --- a/apps/testapp/kv/bench/main.go +++ b/apps/testapp/kv/bench/main.go @@ -323,9 +323,9 @@ func printResults(elapsed time.Duration, workers, batchSize, total, success, fai fmt.Printf(rowFmt+"\n", goalLabel, "REACHED") fmt.Println(sep) fmt.Println() - fmt.Println(" ====================================================") - fmt.Printf(" S U C C E S S ! %s T X / S R E A C H E D !\n", formatNum(targetRPS)) - fmt.Println(" ====================================================") + fmt.Println("====================================================") + fmt.Printf(" S U C C E S S ! %s T X / S R E A C H E D !\n", formatNum(targetRPS)) + fmt.Println("====================================================") } else { fmt.Printf(rowFmt+"\n", goalLabel, "NOT REACHED") fmt.Println(sep) diff --git a/apps/testapp/kv/http_server.go b/apps/testapp/kv/http_server.go index 17d8d74421..3a71136e94 100644 --- a/apps/testapp/kv/http_server.go +++ b/apps/testapp/kv/http_server.go @@ -12,16 +12,17 @@ import ( "time" ds "github.com/ipfs/go-datastore" + "github.com/ipfs/go-datastore/query" ) -var acceptedResp = []byte("Transaction accepted") - +// HTTPServer wraps a KVExecutor and provides an HTTP interface for it type HTTPServer struct { executor *KVExecutor server *http.Server injectedTxs atomic.Uint64 } +// NewHTTPServer creates a new HTTP server for the KVExecutor func NewHTTPServer(executor *KVExecutor, listenAddr string) *HTTPServer { hs := &HTTPServer{ executor: executor, @@ -38,13 +39,14 @@ func NewHTTPServer(executor *KVExecutor, listenAddr string) *HTTPServer { Addr: listenAddr, Handler: mux, ReadHeaderTimeout: 10 * time.Second, - MaxHeaderBytes: 4096, } return hs } +// Start begins listening for HTTP requests func (hs *HTTPServer) Start(ctx context.Context) error { + // Start the server in a goroutine errCh := make(chan error, 1) go func() { fmt.Printf("KV Executor HTTP server starting on %s\n", hs.server.Addr) @@ -53,8 +55,10 @@ func (hs *HTTPServer) Start(ctx context.Context) error { } }() + // Monitor for context cancellation go func() { <-ctx.Done() + // Create a timeout context for shutdown shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -64,30 +68,42 @@ func (hs *HTTPServer) Start(ctx context.Context) error { } }() + // Check if the server started successfully select { case err := <-errCh: return err - case <-time.After(100 * time.Millisecond): + case <-time.After(100 * time.Millisecond): // Give it a moment to start + // Server started successfully return nil } } +// Stop shuts down the HTTP server func (hs *HTTPServer) Stop() error { return hs.server.Close() } +// handleTx handles transaction submissions +// POST /tx with raw binary data or text in request body +// It is recommended to use transactions in the format "key=value" to be consistent +// with the KVExecutor implementation that parses transactions in this format. +// Example: "mykey=myvalue" func (hs *HTTPServer) handleTx(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) - r.Body.Close() + body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } + defer func() { + if err := r.Body.Close(); err != nil { + fmt.Printf("Error closing request body: %v\n", err) + } + }() if len(body) == 0 { http.Error(w, "Empty transaction", http.StatusBadRequest) @@ -97,7 +113,10 @@ func (hs *HTTPServer) handleTx(w http.ResponseWriter, r *http.Request) { hs.executor.InjectTx(body) hs.injectedTxs.Add(1) w.WriteHeader(http.StatusAccepted) - w.Write(acceptedResp) + _, err = w.Write([]byte("Transaction accepted")) + if err != nil { + fmt.Printf("Error writing response: %v\n", err) + } } func (hs *HTTPServer) handleTxBatch(w http.ResponseWriter, r *http.Request) { @@ -160,39 +179,77 @@ func (r *bytesReaderImpl) Read(p []byte) (int, error) { return n, nil } +// handleKV handles direct key-value operations (GET/POST) against the database +// GET /kv?key=somekey - retrieve a value +// POST /kv with JSON {"key": "somekey", "value": "somevalue"} - set a value func (hs *HTTPServer) handleKV(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } + switch r.Method { + case http.MethodGet: + key := r.URL.Query().Get("key") + if key == "" { + http.Error(w, "Missing key parameter", http.StatusBadRequest) + return + } - key := r.URL.Query().Get("key") - if key == "" { - http.Error(w, "Missing key parameter", http.StatusBadRequest) - return - } + // Use r.Context() when calling the executor method + value, exists := hs.executor.GetStoreValue(r.Context(), key) + if !exists { + // GetStoreValue now returns false on error too, check logs for details + // Check if the key truly doesn't exist vs a DB error occurred. + // For simplicity here, we treat both as Not Found for the client. + // A more robust implementation might check the error type. + _, err := hs.executor.db.Get(r.Context(), ds.NewKey(key)) + if errors.Is(err, ds.ErrNotFound) { + http.Error(w, "Key not found", http.StatusNotFound) + } else { + // Some other DB error occurred + http.Error(w, "Failed to retrieve key", http.StatusInternalServerError) + fmt.Printf("Error retrieving key '%s' from DB: %v\n", key, err) + } + return + } - value, exists := hs.executor.GetStoreValue(r.Context(), key) - if !exists { - if _, err := hs.executor.db.Get(r.Context(), ds.NewKey(key)); errors.Is(err, ds.ErrNotFound) { - http.Error(w, "Key not found", http.StatusNotFound) - } else { - http.Error(w, "Failed to retrieve key", http.StatusInternalServerError) - fmt.Printf("Error retrieving key '%s' from DB: %v\n", key, err) + _, err := w.Write([]byte(value)) + if err != nil { + fmt.Printf("Error writing response: %v\n", err) } - return - } - w.Write([]byte(value)) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } } +// handleStore returns all non-reserved key-value pairs in the store by querying the database +// GET /store func (hs *HTTPServer) handleStore(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - store := hs.executor.GetAllEntries() + store := make(map[string]string) + q := query.Query{} // Query all entries + results, err := hs.executor.db.Query(r.Context(), q) + if err != nil { + http.Error(w, "Failed to query store", http.StatusInternalServerError) + fmt.Printf("Error querying datastore: %v\n", err) + return + } + defer results.Close() + + for result := range results.Next() { + if result.Error != nil { + http.Error(w, "Failed during store iteration", http.StatusInternalServerError) + fmt.Printf("Error iterating datastore results: %v\n", result.Error) + return + } + // Exclude reserved genesis keys from the output + dsKey := ds.NewKey(result.Key) + if dsKey.Equal(genesisInitializedKey) || dsKey.Equal(genesisStateRootKey) { + continue + } + store[result.Key] = string(result.Value) + } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(store); err != nil { @@ -218,5 +275,7 @@ func (hs *HTTPServer) handleStats(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(stats) + if err := json.NewEncoder(w).Encode(stats); err != nil { + fmt.Printf("Error encoding stats response: %v\n", err) + } } diff --git a/apps/testapp/kv/http_server_test.go b/apps/testapp/kv/http_server_test.go index 6eb0b3ea47..0a16e28961 100644 --- a/apps/testapp/kv/http_server_test.go +++ b/apps/testapp/kv/http_server_test.go @@ -61,7 +61,9 @@ func TestHandleTx(t *testing.T) { t.Errorf("expected body %q, got %q", tt.expectedBody, rr.Body.String()) } + // Verify the transaction was added to the channel if it was a valid POST if tt.method == http.MethodPost && tt.expectedStatus == http.StatusAccepted { + // Allow a moment for the channel send to potentially complete time.Sleep(10 * time.Millisecond) ctx := context.Background() retrievedTxs, err := exec.GetTxs(ctx) @@ -74,6 +76,7 @@ func TestHandleTx(t *testing.T) { t.Errorf("expected channel to contain %q, got %q", tt.body, string(retrievedTxs[0])) } } else if tt.method == http.MethodPost { + // If it was a POST but not accepted, ensure nothing ended up in the channel ctx := context.Background() retrievedTxs, err := exec.GetTxs(ctx) if err != nil { @@ -127,7 +130,9 @@ func TestHandleKV_Get(t *testing.T) { exec := NewKVExecutor() server := NewHTTPServer(exec, ":0") + // Set up initial data if needed if tt.key != "" && tt.value != "" { + // Create and execute the transaction directly tx := fmt.Appendf(nil, "%s=%s", tt.key, tt.value) ctx := context.Background() _, err := exec.ExecuteTxs(ctx, [][]byte{tx}, 1, time.Now(), []byte("")) @@ -158,12 +163,15 @@ func TestHandleKV_Get(t *testing.T) { } func TestHTTPServerStartStop(t *testing.T) { + // Create a test server that listens on a random port exec := NewKVExecutor() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // This is just a placeholder handler w.WriteHeader(http.StatusOK) })) defer server.Close() + // Test the NewHTTPServer function httpServer := NewHTTPServer(exec, server.URL) if httpServer == nil { t.Fatal("NewHTTPServer returned nil") @@ -173,23 +181,33 @@ func TestHTTPServerStartStop(t *testing.T) { t.Error("HTTPServer.executor not set correctly") } + // Note: We don't test Start() and Stop() methods directly + // as they actually bind to ports, which can be problematic in unit tests. + // In a real test environment, you might want to use integration tests for these. + + // Test with context (minimal test just to verify it compiles) _, cancel := context.WithCancel(context.Background()) defer cancel() + // Just create a mock test to ensure the context parameter is accepted + // Don't actually start the server in the test testServer := &HTTPServer{ server: &http.Server{ - Addr: ":0", - ReadHeaderTimeout: 10 * time.Second, + Addr: ":0", // Use a random port + ReadHeaderTimeout: 10 * time.Second, // Add timeout to prevent Slowloris attacks }, executor: exec, } + // Just verify the method signature works _ = testServer.Start } +// TestHTTPServerContextCancellation tests that the server shuts down properly when the context is cancelled func TestHTTPServerContextCancellation(t *testing.T) { exec := NewKVExecutor() + // Use a random available port listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("Failed to find available port: %v", err) @@ -202,15 +220,19 @@ func TestHTTPServerContextCancellation(t *testing.T) { serverAddr := fmt.Sprintf("127.0.0.1:%d", port) server := NewHTTPServer(exec, serverAddr) + // Create a context with cancel function ctx, cancel := context.WithCancel(context.Background()) + // Start the server errCh := make(chan error, 1) go func() { errCh <- server.Start(ctx) }() + // Give it time to start time.Sleep(100 * time.Millisecond) + // Send a request to confirm it's running client := &http.Client{Timeout: 1 * time.Second} resp, err := client.Get(fmt.Sprintf("http://%s/store", serverAddr)) if err != nil { @@ -224,8 +246,10 @@ func TestHTTPServerContextCancellation(t *testing.T) { t.Fatalf("Expected status 200, got %d", resp.StatusCode) } + // Cancel the context to shut down the server cancel() + // Wait for shutdown to complete with timeout select { case err := <-errCh: if err != nil && errors.Is(err, http.ErrServerClosed) { @@ -235,6 +259,7 @@ func TestHTTPServerContextCancellation(t *testing.T) { t.Fatal("Server shutdown timed out") } + // Verify server is actually shutdown by attempting a new connection _, err = client.Get(fmt.Sprintf("http://%s/store", serverAddr)) if err == nil { t.Fatal("Expected connection error after shutdown, but got none") @@ -245,6 +270,7 @@ func TestHTTPIntegration_GetKVWithMultipleHeights(t *testing.T) { exec := NewKVExecutor() ctx := context.Background() + // Execute transactions at different heights for the same key txsHeight1 := [][]byte{[]byte("testkey=original_value")} _, err := exec.ExecuteTxs(ctx, txsHeight1, 1, time.Now(), []byte("")) if err != nil { @@ -259,6 +285,7 @@ func TestHTTPIntegration_GetKVWithMultipleHeights(t *testing.T) { server := NewHTTPServer(exec, ":0") + // Test GET request - should return the latest value req := httptest.NewRequest(http.MethodGet, "/kv?key=testkey", nil) rr := httptest.NewRecorder() diff --git a/apps/testapp/kv/kvexecutor.go b/apps/testapp/kv/kvexecutor.go index cf2338c343..ed6a29ea59 100644 --- a/apps/testapp/kv/kvexecutor.go +++ b/apps/testapp/kv/kvexecutor.go @@ -4,15 +4,10 @@ import ( "context" "errors" "fmt" - "hash/fnv" - "maps" "sort" - "strconv" "strings" - "sync" "sync/atomic" "time" - "unsafe" ds "github.com/ipfs/go-datastore" "github.com/ipfs/go-datastore/query" @@ -26,46 +21,25 @@ var ( genesisStateRootKey = ds.NewKey("/genesis/stateroot") heightKeyPrefix = ds.NewKey("/height") finalizedHeightKey = ds.NewKey("/finalizedHeight") - reservedKeys = map[ds.Key]bool{ + // Define a buffer size for the transaction channel + txChannelBufferSize = 100_000_000 + // reservedKeys defines the set of keys that should be excluded from state root calculation + // and protected from transaction modifications + reservedKeys = map[ds.Key]bool{ genesisInitializedKey: true, genesisStateRootKey: true, finalizedHeightKey: true, } ) -const shardCount = 64 - -type txShard struct { - mu sync.Mutex - buf [][]byte - flush chan [][]byte -} - +// KVExecutor is a simple key-value store backed by go-datastore that implements the Executor interface +// for testing purposes. It uses a buffered channel as a mempool for transactions. +// It also includes fields to track genesis initialization persisted in the datastore. type KVExecutor struct { db ds.Batching + txChan chan []byte blocksProduced atomic.Uint64 totalExecutedTxs atomic.Uint64 - - stateMu sync.RWMutex - stateMap map[string]string - sortedKeys []string - cachedRoot []byte - rootDirty bool - - shards [shardCount]txShard - shardIdx atomic.Uint64 - - dbWriteCh chan dbWriteReq -} - -type dbWriteReq struct { - height uint64 - kvs []kvEntry -} - -type kvEntry struct { - key string - value string } type ExecutorStats struct { @@ -80,92 +54,106 @@ func (k *KVExecutor) GetStats() ExecutorStats { } } +// NewKVExecutor creates a new instance of KVExecutor with initialized store and mempool channel. func NewKVExecutor() *KVExecutor { - k := &KVExecutor{ - db: dssync.MutexWrap(ds.NewMapDatastore()), - stateMap: make(map[string]string), - dbWriteCh: make(chan dbWriteReq, 4096), + return &KVExecutor{ + db: dssync.MutexWrap(ds.NewMapDatastore()), + txChan: make(chan []byte, txChannelBufferSize), } +} - for i := range k.shards { - k.shards[i].buf = make([][]byte, 0, 256) - k.shards[i].flush = make(chan [][]byte, 64) +// GetStoreValue is a helper for the HTTP interface to retrieve the value for a key from the database. +// It searches across all block heights to find the latest value for the given key. +func (k *KVExecutor) GetStoreValue(ctx context.Context, key string) (string, bool) { + // Query all keys to find height-prefixed versions of this key + q := query.Query{} + results, err := k.db.Query(ctx, q) + if err != nil { + fmt.Printf("Error querying DB for key '%s': %v\n", key, err) + return "", false } + defer results.Close() - go k.dbWriterLoop() - - return k -} + heightPrefix := heightKeyPrefix.String() + var latestValue string + var latestHeight uint64 + found := false -func (k *KVExecutor) dbWriterLoop() { - for req := range k.dbWriteCh { - if len(req.kvs) == 0 { - continue - } - ctx := context.Background() - batch, err := k.db.Batch(ctx) - if err != nil { - continue + for result := range results.Next() { + if result.Error != nil { + fmt.Printf("Error iterating query results for key '%s': %v\n", key, result.Error) + return "", false } - for _, kv := range req.kvs { - dsKey := getTxKey(req.height, kv.key) - _ = batch.Put(ctx, dsKey, bytesFromString(kv.value)) + + resultKey := result.Key + // Check if this is a height-prefixed key that matches our target key + if after, ok := strings.CutPrefix(resultKey, heightPrefix+"/"); ok { + // Extract height and actual key: /height/{height}/{actual_key} + parts := strings.Split(after, "/") + if len(parts) >= 2 { + var keyHeight uint64 + if _, err := fmt.Sscanf(parts[0], "%d", &keyHeight); err == nil { + // Reconstruct the actual key by joining all parts after the height + actualKey := strings.Join(parts[1:], "/") + if actualKey == key { + // This key matches - check if it's the latest height + if !found || keyHeight > latestHeight { + latestHeight = keyHeight + latestValue = string(result.Value) + found = true + } + } + } + } } - _ = batch.Commit(ctx) } -} -func (k *KVExecutor) GetStoreValue(_ context.Context, key string) (string, bool) { - k.stateMu.RLock() - val := k.stateMap[key] - k.stateMu.RUnlock() - return val, val != "" -} + if !found { + return "", false + } -func (k *KVExecutor) GetAllEntries() map[string]string { - k.stateMu.RLock() - m := make(map[string]string, len(k.stateMap)) - maps.Copy(m, k.stateMap) - k.stateMu.RUnlock() - return m + return latestValue, true } -func (k *KVExecutor) insertSorted(key string) { - n := len(k.sortedKeys) - i := sort.SearchStrings(k.sortedKeys, key) - if i < n && k.sortedKeys[i] == key { - return +// computeStateRoot computes a deterministic state root by querying all keys, sorting them, +// and concatenating key-value pairs from the database. +func (k *KVExecutor) computeStateRoot(ctx context.Context) ([]byte, error) { + q := query.Query{KeysOnly: true} + results, err := k.db.Query(ctx, q) + if err != nil { + return nil, fmt.Errorf("failed to query keys for state root: %w", err) } - k.sortedKeys = append(k.sortedKeys, "") - copy(k.sortedKeys[i+1:], k.sortedKeys[i:]) - k.sortedKeys[i] = key -} + defer results.Close() -func (k *KVExecutor) computeRootFromStateLocked() []byte { - if !k.rootDirty && k.cachedRoot != nil { - return k.cachedRoot + keys := make([]string, 0) + for result := range results.Next() { + if result.Error != nil { + return nil, fmt.Errorf("error iterating query results: %w", result.Error) + } + // Exclude reserved keys from the state root calculation + dsKey := ds.NewKey(result.Key) + if reservedKeys[dsKey] { + continue + } + keys = append(keys, result.Key) } + sort.Strings(keys) - h := fnv.New128a() - for _, key := range k.sortedKeys { - h.Write(bytesFromString(key)) - h.Write([]byte{0}) - h.Write(bytesFromString(k.stateMap[key])) - h.Write([]byte{0}) + var sb strings.Builder + for _, key := range keys { + valueBytes, err := k.db.Get(ctx, ds.NewKey(key)) + if err != nil { + // This shouldn't happen if the key came from the query, but handle defensively + return nil, fmt.Errorf("failed to get value for key '%s' during state root computation: %w", key, err) + } + sb.WriteString(fmt.Sprintf("%s:%s;", key, string(valueBytes))) } - - k.cachedRoot = h.Sum(nil) - k.rootDirty = false - return k.cachedRoot -} - -func (k *KVExecutor) computeStateRoot(_ context.Context) ([]byte, error) { - k.stateMu.Lock() - root := k.computeRootFromStateLocked() - k.stateMu.Unlock() - return root, nil + return []byte(sb.String()), nil } +// InitChain initializes the chain state with genesis parameters. +// It checks the database to see if genesis was already performed. +// If not, it computes the state root from the current DB state and persists genesis info. func (k *KVExecutor) InitChain(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) ([]byte, error) { select { case <-ctx.Done(): @@ -186,28 +174,36 @@ func (k *KVExecutor) InitChain(ctx context.Context, genesisTime time.Time, initi return genesisRoot, nil } + // Genesis not initialized. Compute state root from the current DB state. + // Note: The DB might not be empty if restarting, this reflects the state *at genesis time*. stateRoot, err := k.computeStateRoot(ctx) if err != nil { return nil, fmt.Errorf("failed to compute initial state root for genesis: %w", err) } + // Persist genesis state root and initialized flag batch, err := k.db.Batch(ctx) if err != nil { return nil, fmt.Errorf("failed to create batch for genesis persistence: %w", err) } - if err := batch.Put(ctx, genesisStateRootKey, stateRoot); err != nil { + err = batch.Put(ctx, genesisStateRootKey, stateRoot) + if err != nil { return nil, fmt.Errorf("failed to put genesis state root in batch: %w", err) } - if err := batch.Put(ctx, genesisInitializedKey, []byte("true")); err != nil { + err = batch.Put(ctx, genesisInitializedKey, []byte("true")) // Store a marker value + if err != nil { return nil, fmt.Errorf("failed to put genesis initialized flag in batch: %w", err) } - if err := batch.Commit(ctx); err != nil { + err = batch.Commit(ctx) + if err != nil { return nil, fmt.Errorf("failed to commit genesis persistence batch: %w", err) } return stateRoot, nil } +// GetTxs retrieves available transactions from the mempool channel. +// It drains the channel in a non-blocking way. func (k *KVExecutor) GetTxs(ctx context.Context) ([][]byte, error) { select { case <-ctx.Done(): @@ -215,31 +211,30 @@ func (k *KVExecutor) GetTxs(ctx context.Context) ([][]byte, error) { default: } - var all [][]byte - for i := range k.shards { - s := &k.shards[i] - s.mu.Lock() - if len(s.buf) > 0 { - batch := s.buf - s.buf = make([][]byte, 0, cap(batch)) - s.mu.Unlock() - all = append(all, batch...) - } else { - s.mu.Unlock() - } + // Drain the channel efficiently + txs := make([][]byte, 0, len(k.txChan)) // Pre-allocate roughly + for { select { - case batch := <-s.flush: - all = append(all, batch...) - default: + case tx := <-k.txChan: + txs = append(txs, tx) + default: // Channel is empty or context is done + // Check context again in case it was cancelled during drain + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + if len(txs) == 0 { + return nil, nil // Return nil slice if no transactions were retrieved + } + return txs, nil + } } } - - if len(all) == 0 { - return nil, nil - } - return all, nil } +// ExecuteTxs processes each transaction assumed to be in the format "key=value". +// It updates the database accordingly using a batch and removes the executed transactions from the mempool. +// Invalid transactions are filtered out and logged, but execution continues. func (k *KVExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight uint64, timestamp time.Time, prevStateRoot []byte) ([]byte, error) { select { case <-ctx.Done(): @@ -247,66 +242,82 @@ func (k *KVExecutor) ExecuteTxs(ctx context.Context, txs [][]byte, blockHeight u default: } - type parsedTx struct { - key string - value string + batch, err := k.db.Batch(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create database batch: %w", err) } - parsed := make([]parsedTx, 0, len(txs)) + validTxCount := 0 + invalidTxCount := 0 - for _, tx := range txs { + // Process transactions and stage them in the batch + // Filter out invalid/gibberish transactions gracefully + for i, tx := range txs { + // Skip empty transactions if len(tx) == 0 { + fmt.Printf("Warning: skipping empty transaction at index %d in block %d\n", i, blockHeight) + invalidTxCount++ continue } - s := stringUnsafe(tx) - before, after, ok := strings.Cut(s, "=") - if !ok { + parts := strings.SplitN(string(tx), "=", 2) + if len(parts) != 2 { + fmt.Printf("Warning: filtering out malformed transaction at index %d in block %d (expected format key=value): %s\n", i, blockHeight, string(tx)) + invalidTxCount++ continue } - key := strings.TrimSpace(before) + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) if key == "" { + fmt.Printf("Warning: filtering out transaction with empty key at index %d in block %d\n", i, blockHeight) + invalidTxCount++ continue } - value := strings.TrimSpace(after) dsKey := getTxKey(blockHeight, key) + + // Prevent writing reserved keys via transactions if reservedKeys[dsKey] { + fmt.Printf("Warning: filtering out transaction attempting to modify reserved key at index %d in block %d: %s\n", i, blockHeight, key) + invalidTxCount++ continue } - parsed = append(parsed, parsedTx{key: key, value: value}) + err = batch.Put(ctx, dsKey, []byte(value)) + if err != nil { + // This error is unlikely for Put unless the context is cancelled. + return nil, fmt.Errorf("failed to stage put operation in batch for key '%s': %w", key, err) + } + validTxCount++ } - k.stateMu.Lock() - for _, p := range parsed { - if _, exists := k.stateMap[p.key]; !exists { - k.insertSorted(p.key) - } - k.stateMap[p.key] = p.value + // Log filtering results if any transactions were filtered + if invalidTxCount > 0 { + fmt.Printf("Block %d: processed %d valid transactions, filtered out %d invalid transactions\n", blockHeight, validTxCount, invalidTxCount) } - k.rootDirty = true - root := k.computeRootFromStateLocked() - k.stateMu.Unlock() - if len(parsed) > 0 { - kvs := make([]kvEntry, len(parsed)) - for i, p := range parsed { - kvs[i] = kvEntry{key: p.key, value: p.value} - } - select { - case k.dbWriteCh <- dbWriteReq{height: blockHeight, kvs: kvs}: - default: - } + // Commit the batch to apply all changes atomically + err = batch.Commit(ctx) + if err != nil { + return nil, fmt.Errorf("failed to commit transaction batch: %w", err) } k.blocksProduced.Add(1) - k.totalExecutedTxs.Add(uint64(len(parsed))) + k.totalExecutedTxs.Add(uint64(validTxCount)) - return root, nil + // Compute the new state root *after* successful commit + stateRoot, err := k.computeStateRoot(ctx) + if err != nil { + // This is problematic, state was changed but root calculation failed. + // May need more robust error handling or recovery logic. + return nil, fmt.Errorf("failed to compute state root after executing transactions: %w", err) + } + + return stateRoot, nil } +// SetFinal marks a block as finalized at the specified height. func (k *KVExecutor) SetFinal(ctx context.Context, blockHeight uint64) error { select { case <-ctx.Done(): @@ -314,32 +325,42 @@ func (k *KVExecutor) SetFinal(ctx context.Context, blockHeight uint64) error { default: } + // Validate blockHeight if blockHeight == 0 { return errors.New("invalid blockHeight: cannot be zero") } - return k.db.Put(ctx, finalizedHeightKey, strconv.AppendUint(nil, blockHeight, 10)) + return k.db.Put(ctx, finalizedHeightKey, fmt.Appendf(nil, "%d", blockHeight)) } +// InjectTx adds a transaction to the mempool channel. +// Uses a non-blocking send to avoid blocking the caller if the channel is full. func (k *KVExecutor) InjectTx(tx []byte) { - s := &k.shards[(k.shardIdx.Add(1))%shardCount] - s.mu.Lock() - s.buf = append(s.buf, tx) - s.mu.Unlock() + select { + case k.txChan <- tx: + // Transaction successfully sent to channel + default: + // Channel is full, transaction is dropped. Log this event. + fmt.Printf("Warning: Transaction channel buffer full. Dropping transaction.\n") + // Consider adding metrics here + } } +// InjectTxs adds multiple transactions to the mempool channel and returns the number accepted. func (k *KVExecutor) InjectTxs(txs [][]byte) int { accepted := 0 for _, tx := range txs { - s := &k.shards[(k.shardIdx.Add(1))%shardCount] - s.mu.Lock() - s.buf = append(s.buf, tx) - s.mu.Unlock() - accepted++ + select { + case k.txChan <- tx: + accepted++ + default: + fmt.Printf("Warning: Transaction channel buffer full. Dropping transaction.\n") + } } return accepted } +// Rollback reverts the state to the previous block height. func (k *KVExecutor) Rollback(ctx context.Context, height uint64) error { select { case <-ctx.Done(): @@ -347,15 +368,18 @@ func (k *KVExecutor) Rollback(ctx context.Context, height uint64) error { default: } + // Validate height constraints if height == 0 { return fmt.Errorf("cannot rollback to height 0: invalid height") } + // Create a batch for atomic rollback operation batch, err := k.db.Batch(ctx) if err != nil { return fmt.Errorf("failed to create batch for rollback: %w", err) } + // Query all keys to find those with height > target height q := query.Query{} results, err := k.db.Query(ctx, q) if err != nil { @@ -372,11 +396,14 @@ func (k *KVExecutor) Rollback(ctx context.Context, height uint64) error { } key := result.Key + // Check if this is a height-prefixed key if after, ok := strings.CutPrefix(key, heightPrefix+"/"); ok { + // Extract height from key: /height/{height}/{actual_key} (see getTxKey) parts := strings.Split(after, "/") if len(parts) > 0 { var keyHeight uint64 if _, err := fmt.Sscanf(parts[0], "%d", &keyHeight); err == nil { + // If this key's height is greater than target, mark for deletion if keyHeight > height { keysToDelete = append(keysToDelete, ds.NewKey(key)) } @@ -385,99 +412,64 @@ func (k *KVExecutor) Rollback(ctx context.Context, height uint64) error { } } + // Delete all keys with height > target height for _, key := range keysToDelete { select { case <-ctx.Done(): return ctx.Err() default: } - if err := batch.Delete(ctx, key); err != nil { + + err = batch.Delete(ctx, key) + if err != nil { return fmt.Errorf("failed to stage delete operation for key '%s' during rollback: %w", key.String(), err) } } + // Update finalized height if necessary - it should not exceed rollback height + finalizedHeightKey := ds.NewKey("/finalizedHeight") if finalizedHeightBytes, err := k.db.Get(ctx, finalizedHeightKey); err == nil { var finalizedHeight uint64 if _, err := fmt.Sscanf(string(finalizedHeightBytes), "%d", &finalizedHeight); err == nil { if finalizedHeight > height { - if err := batch.Put(ctx, finalizedHeightKey, strconv.AppendUint(nil, height, 10)); err != nil { + err = batch.Put(ctx, finalizedHeightKey, fmt.Appendf([]byte{}, "%d", height)) + if err != nil { return fmt.Errorf("failed to update finalized height during rollback: %w", err) } } } } - if err := batch.Commit(ctx); err != nil { + // Commit the batch atomically + err = batch.Commit(ctx) + if err != nil { return fmt.Errorf("failed to commit rollback batch: %w", err) } - k.stateMu.Lock() - k.stateMap = make(map[string]string) - k.rebuildStateFromDB(ctx) - k.sortedKeys = k.sortedKeys[:0] - for key := range k.stateMap { - k.sortedKeys = append(k.sortedKeys, key) - } - sort.Strings(k.sortedKeys) - k.rootDirty = true - k.stateMu.Unlock() - return nil } -func (k *KVExecutor) rebuildStateFromDB(ctx context.Context) { - q := query.Query{} - results, err := k.db.Query(ctx, q) - if err != nil { - return - } - defer results.Close() - - heightPrefix := heightKeyPrefix.String() - type entry struct { - height uint64 - value string - } - latest := make(map[string]entry) - - for result := range results.Next() { - if result.Error != nil { - return - } - if after, ok := strings.CutPrefix(result.Key, heightPrefix+"/"); ok { - parts := strings.Split(after, "/") - if len(parts) >= 2 { - var keyHeight uint64 - if _, err := fmt.Sscanf(parts[0], "%d", &keyHeight); err == nil { - actualKey := strings.Join(parts[1:], "/") - if e, ok := latest[actualKey]; !ok || keyHeight > e.height { - latest[actualKey] = entry{height: keyHeight, value: string(result.Value)} - } - } - } - } - } - - for key, e := range latest { - k.stateMap[key] = e.value - } -} - func getTxKey(height uint64, txKey string) ds.Key { - return heightKeyPrefix.ChildString(strconv.FormatUint(height, 10) + "/" + txKey) + return heightKeyPrefix.Child(ds.NewKey(fmt.Sprintf("%d/%s", height, txKey))) } -func (k *KVExecutor) GetExecutionInfo(_ context.Context) (execution.ExecutionInfo, error) { +// GetExecutionInfo returns execution layer parameters. +// For KVExecutor, returns MaxGas=0 indicating no gas-based filtering. +func (k *KVExecutor) GetExecutionInfo(ctx context.Context) (execution.ExecutionInfo, error) { return execution.ExecutionInfo{MaxGas: 0}, nil } -func (k *KVExecutor) FilterTxs(_ context.Context, txs [][]byte, maxBytes, maxGas uint64, hasForceIncludedTransaction bool) ([]execution.FilterStatus, error) { +// FilterTxs validates force-included transactions and applies size filtering. +// For KVExecutor, validates key=value format when force-included txs are present. +// KVExecutor doesn't track gas, so maxGas is ignored. +func (k *KVExecutor) FilterTxs(ctx context.Context, txs [][]byte, maxBytes, maxGas uint64, hasForceIncludedTransaction bool) ([]execution.FilterStatus, error) { result := make([]execution.FilterStatus, len(txs)) var cumulativeBytes uint64 limitReached := false for i, tx := range txs { + // Skip empty transactions if len(tx) == 0 { result[i] = execution.FilterRemove continue @@ -485,28 +477,30 @@ func (k *KVExecutor) FilterTxs(_ context.Context, txs [][]byte, maxBytes, maxGas txBytes := uint64(len(tx)) + // Only validate tx format if force-included txs are present + // Mempool txs are already validated if hasForceIncludedTransaction { - eqIdx := indexByte(tx, '=') - if eqIdx < 0 { - result[i] = execution.FilterRemove - continue - } - if isAllSpace(tx[:eqIdx]) { + // Basic format validation: must be key=value + parts := strings.SplitN(string(tx), "=", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { result[i] = execution.FilterRemove continue } } + // Skip tx that can never make it in a block (too big) if maxBytes > 0 && txBytes > maxBytes { result[i] = execution.FilterRemove continue } + // Once limit is reached, postpone remaining txs if limitReached { result[i] = execution.FilterPostpone continue } + // Check size limit if maxBytes > 0 && cumulativeBytes+txBytes > maxBytes { limitReached = true result[i] = execution.FilterPostpone @@ -519,36 +513,3 @@ func (k *KVExecutor) FilterTxs(_ context.Context, txs [][]byte, maxBytes, maxGas return result, nil } - -func indexByte(s []byte, c byte) int { - for i, b := range s { - if b == c { - return i - } - } - return -1 -} - -func isAllSpace(s []byte) bool { - allSpace := true - for _, b := range s { - if b != ' ' && b != '\t' && b != '\n' && b != '\r' { - allSpace = false - break - } - } - return allSpace && len(s) == 0 -} - -func stringUnsafe(b []byte) string { - return *(*string)(unsafe.Pointer(&b)) -} - -func bytesFromString(s string) []byte { - return *(*[]byte)(unsafe.Pointer( - &struct { - string - int - }{s, len(s)}, - )) -} diff --git a/apps/testapp/kv/kvexecutor_test.go b/apps/testapp/kv/kvexecutor_test.go index 0a04d17c54..a4a286633b 100644 --- a/apps/testapp/kv/kvexecutor_test.go +++ b/apps/testapp/kv/kvexecutor_test.go @@ -1,8 +1,10 @@ package executor import ( + "bytes" "context" "reflect" + "strings" "testing" "time" ) @@ -14,17 +16,19 @@ func TestInitChain_Idempotency(t *testing.T) { initialHeight := uint64(1) chainID := "test-chain" + // First call initializes genesis state stateRoot1, err := exec.InitChain(ctx, genesisTime, initialHeight, chainID) if err != nil { t.Fatalf("InitChain failed on first call: %v", err) } + // Second call should return the same genesis state root stateRoot2, err := exec.InitChain(ctx, genesisTime, initialHeight, chainID) if err != nil { t.Fatalf("InitChain failed on second call: %v", err) } - if !reflect.DeepEqual(stateRoot1, stateRoot2) { - t.Errorf("Genesis state roots do not match: %x vs %x", stateRoot1, stateRoot2) + if !bytes.Equal(stateRoot1, stateRoot2) { + t.Errorf("Genesis state roots do not match: %s vs %s", stateRoot1, stateRoot2) } } @@ -32,11 +36,14 @@ func TestGetTxs(t *testing.T) { exec := NewKVExecutor() ctx := context.Background() + // Inject transactions using the InjectTx method which sends to the channel tx1 := []byte("a=1") tx2 := []byte("b=2") exec.InjectTx(tx1) exec.InjectTx(tx2) + // Allow a brief moment for transactions to be processed by the channel if needed, + // though for buffered channels it should be immediate unless full. time.Sleep(10 * time.Millisecond) txs, err := exec.GetTxs(ctx) @@ -46,7 +53,14 @@ func TestGetTxs(t *testing.T) { if len(txs) != 2 { t.Errorf("Expected 2 transactions, got %d", len(txs)) } + if !reflect.DeepEqual(txs[0], tx1) { + t.Errorf("Expected first tx 'a=1', got %s", string(txs[0])) + } + if !reflect.DeepEqual(txs[1], tx2) { + t.Errorf("Expected second tx 'b=2', got %s", string(txs[1])) + } + // GetTxs should drain the channel, so a second call should return empty or nil txsAgain, err := exec.GetTxs(ctx) if err != nil { t.Fatalf("GetTxs (second call) returned error: %v", err) @@ -55,6 +69,7 @@ func TestGetTxs(t *testing.T) { t.Errorf("Expected 0 transactions on second call (drained), got %d", len(txsAgain)) } + // Inject another transaction and verify it's available tx3 := []byte("c=3") exec.InjectTx(tx3) time.Sleep(10 * time.Millisecond) @@ -66,7 +81,7 @@ func TestGetTxs(t *testing.T) { if len(txsAfterReinject) != 1 { t.Errorf("Expected 1 transaction after re-inject, got %d", len(txsAfterReinject)) } - if string(txsAfterReinject[0]) != "c=3" { + if !reflect.DeepEqual(txsAfterReinject[0], tx3) { t.Errorf("Expected tx 'c=3' after re-inject, got %s", string(txsAfterReinject[0])) } } @@ -75,6 +90,7 @@ func TestExecuteTxs_Valid(t *testing.T) { exec := NewKVExecutor() ctx := context.Background() + // Prepare valid transactions txs := [][]byte{ []byte("key1=value1"), []byte("key2=value2"), @@ -85,17 +101,10 @@ func TestExecuteTxs_Valid(t *testing.T) { t.Fatalf("ExecuteTxs failed: %v", err) } - if stateRoot == nil { - t.Fatal("Expected non-nil state root") - } - - val, ok := exec.GetStoreValue(ctx, "key1") - if !ok || val != "value1" { - t.Errorf("Expected key1=value1, got %q, ok=%v", val, ok) - } - val, ok = exec.GetStoreValue(ctx, "key2") - if !ok || val != "value2" { - t.Errorf("Expected key2=value2, got %q, ok=%v", val, ok) + // Check that stateRoot contains the updated key-value pairs + rootStr := string(stateRoot) + if !strings.Contains(rootStr, "key1:value1;") || !strings.Contains(rootStr, "key2:value2;") { + t.Errorf("State root does not contain expected key-values: %s", rootStr) } } @@ -103,6 +112,10 @@ func TestExecuteTxs_Invalid(t *testing.T) { exec := NewKVExecutor() ctx := context.Background() + // According to the Executor interface: "Must handle gracefully gibberish transactions" + // Invalid transactions should be filtered out, not cause errors + + // Prepare invalid transactions (missing '=') txs := [][]byte{ []byte("invalidformat"), []byte("another_invalid_one"), @@ -114,10 +127,12 @@ func TestExecuteTxs_Invalid(t *testing.T) { t.Fatalf("ExecuteTxs should handle gibberish gracefully, got error: %v", err) } + // State root should still be computed (empty block is valid) if stateRoot == nil { t.Error("Expected non-nil state root even with all invalid transactions") } + // Test mix of valid and invalid transactions mixedTxs := [][]byte{ []byte("valid_key=valid_value"), []byte("invalidformat"), @@ -125,18 +140,15 @@ func TestExecuteTxs_Invalid(t *testing.T) { []byte(""), } - _, err = exec.ExecuteTxs(ctx, mixedTxs, 2, time.Now(), stateRoot) + stateRoot2, err := exec.ExecuteTxs(ctx, mixedTxs, 2, time.Now(), stateRoot) if err != nil { t.Fatalf("ExecuteTxs should filter invalid transactions and process valid ones, got error: %v", err) } - val, ok := exec.GetStoreValue(ctx, "valid_key") - if !ok || val != "valid_value" { - t.Errorf("Expected valid_key=valid_value, got %q, ok=%v", val, ok) - } - val, ok = exec.GetStoreValue(ctx, "another_valid") - if !ok || val != "value2" { - t.Errorf("Expected another_valid=value2, got %q, ok=%v", val, ok) + // State root should contain only the valid transactions + rootStr := string(stateRoot2) + if !strings.Contains(rootStr, "valid_key:valid_value") || !strings.Contains(rootStr, "another_valid:value2") { + t.Errorf("State root should contain valid transactions: %s", rootStr) } } @@ -144,13 +156,14 @@ func TestSetFinal(t *testing.T) { exec := NewKVExecutor() ctx := context.Background() + // Test with valid blockHeight err := exec.SetFinal(ctx, 1) if err != nil { t.Errorf("Expected nil error for valid blockHeight, got %v", err) } - err = exec.SetFinal(ctx, 0) - if err == nil { + // Test with invalid blockHeight (zero) + if err := exec.SetFinal(ctx, 0); err == nil { t.Error("Expected error for blockHeight 0, got nil") } } @@ -159,11 +172,13 @@ func TestReservedKeysExcludedFromAppHash(t *testing.T) { exec := NewKVExecutor() ctx := context.Background() + // Initialize chain to set up genesis state (this writes genesis reserved keys) _, err := exec.InitChain(ctx, time.Now(), 1, "test-chain") if err != nil { t.Fatalf("Failed to initialize chain: %v", err) } + // Add some application data txs := [][]byte{ []byte("user/key1=value1"), []byte("user/key2=value2"), @@ -173,16 +188,19 @@ func TestReservedKeysExcludedFromAppHash(t *testing.T) { t.Fatalf("Failed to execute transactions: %v", err) } + // Compute baseline state root baselineStateRoot, err := exec.computeStateRoot(ctx) if err != nil { t.Fatalf("Failed to compute baseline state root: %v", err) } + // Write to finalizedHeight (a reserved key) err = exec.SetFinal(ctx, 5) if err != nil { t.Fatalf("Failed to set final height: %v", err) } + // Verify finalizedHeight was written finalizedHeightExists, err := exec.db.Has(ctx, finalizedHeightKey) if err != nil { t.Fatalf("Failed to check if finalizedHeight exists: %v", err) @@ -191,16 +209,33 @@ func TestReservedKeysExcludedFromAppHash(t *testing.T) { t.Error("Expected finalizedHeight to exist in database") } + // State root should be unchanged (reserved keys excluded from calculation) stateRootAfterReservedKeyWrite, err := exec.computeStateRoot(ctx) if err != nil { t.Fatalf("Failed to compute state root after writing reserved key: %v", err) } - if !reflect.DeepEqual(baselineStateRoot, stateRootAfterReservedKeyWrite) { - t.Errorf("State root changed after writing reserved key:\nBefore: %x\nAfter: %x", - baselineStateRoot, stateRootAfterReservedKeyWrite) + if string(baselineStateRoot) != string(stateRootAfterReservedKeyWrite) { + t.Errorf("State root changed after writing reserved key:\nBefore: %s\nAfter: %s", + string(baselineStateRoot), string(stateRootAfterReservedKeyWrite)) + } + + // Verify state root contains only user data, not reserved keys + stateRootStr := string(stateRootAfterReservedKeyWrite) + if !strings.Contains(stateRootStr, "user/key1:value1") || + !strings.Contains(stateRootStr, "user/key2:value2") { + t.Errorf("State root should contain user data: %s", stateRootStr) + } + + // Verify reserved keys are NOT in state root + for key := range reservedKeys { + keyStr := key.String() + if strings.Contains(stateRootStr, keyStr) { + t.Errorf("State root should NOT contain reserved key %s: %s", keyStr, stateRootStr) + } } + // Verify that adding user data DOES change the state root moreTxs := [][]byte{ []byte("user/key3=value3"), } @@ -214,65 +249,7 @@ func TestReservedKeysExcludedFromAppHash(t *testing.T) { t.Fatalf("Failed to compute final state root: %v", err) } - if reflect.DeepEqual(baselineStateRoot, finalStateRoot) { + if string(baselineStateRoot) == string(finalStateRoot) { t.Error("Expected state root to change after adding user data") } } - -func TestStateRootDeterministic(t *testing.T) { - exec1 := NewKVExecutor() - ctx := context.Background() - - txs := [][]byte{ - []byte("alpha=1"), - []byte("beta=2"), - []byte("gamma=3"), - } - _, err := exec1.ExecuteTxs(ctx, txs, 1, time.Now(), []byte("")) - if err != nil { - t.Fatalf("ExecuteTxs failed: %v", err) - } - root1, err := exec1.computeStateRoot(ctx) - if err != nil { - t.Fatalf("computeStateRoot failed: %v", err) - } - - exec2 := NewKVExecutor() - _, err = exec2.ExecuteTxs(ctx, [][]byte{ - []byte("gamma=3"), - []byte("alpha=1"), - []byte("beta=2"), - }, 1, time.Now(), []byte("")) - if err != nil { - t.Fatalf("ExecuteTxs failed: %v", err) - } - root2, err := exec2.computeStateRoot(ctx) - if err != nil { - t.Fatalf("computeStateRoot failed: %v", err) - } - - if !reflect.DeepEqual(root1, root2) { - t.Errorf("State roots should be deterministic regardless of insertion order:\n%x\n%x", root1, root2) - } -} - -func TestExecuteTxsDuplicateKeys(t *testing.T) { - exec := NewKVExecutor() - ctx := context.Background() - - txs := [][]byte{ - []byte("key=v1"), - []byte("key=v2"), - []byte("key=v3"), - } - - _, err := exec.ExecuteTxs(ctx, txs, 1, time.Now(), []byte("")) - if err != nil { - t.Fatalf("ExecuteTxs failed: %v", err) - } - - val, ok := exec.GetStoreValue(ctx, "key") - if !ok || val != "v3" { - t.Errorf("Expected key=v3 (last write wins), got %q, ok=%v", val, ok) - } -} From 485cff27234b5fd07f1a1bde058c5130ef3b2193 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Fri, 10 Apr 2026 13:30:51 +0200 Subject: [PATCH 23/23] feedback --- block/internal/executing/executor.go | 10 +++++++-- block/internal/reaping/reaper.go | 31 +++++++++++++------------- block/internal/reaping/reaper_test.go | 16 ++++++------- block/internal/submitting/submitter.go | 11 +++++++-- block/internal/syncing/syncer.go | 1 + 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/block/internal/executing/executor.go b/block/internal/executing/executor.go index 4cdf94a885..e1c335f1e5 100644 --- a/block/internal/executing/executor.go +++ b/block/internal/executing/executor.go @@ -162,14 +162,20 @@ func (e *Executor) SetBlockProducer(bp BlockProducer) { } // Start begins the execution component -func (e *Executor) Start(ctx context.Context) error { +func (e *Executor) Start(ctx context.Context) (err error) { if e.cancel != nil { return errors.New("executor already started") } e.ctx, e.cancel = context.WithCancel(ctx) + defer func() { // if error during init cancel context + if err != nil { + e.cancel() + e.ctx, e.cancel = nil, nil + } + }() // Initialize state - if err := e.initializeState(); err != nil { + if err = e.initializeState(); err != nil { return fmt.Errorf("failed to initialize state: %w", err) } diff --git a/block/internal/reaping/reaper.go b/block/internal/reaping/reaper.go index 7448c7d1a3..796505e462 100644 --- a/block/internal/reaping/reaper.go +++ b/block/internal/reaping/reaper.go @@ -88,9 +88,9 @@ func (r *Reaper) reaperLoop() { consecutiveFailures := 0 for { - submitted, err := r.drainMempool() + submitted, err := r.drainMempool(cleanupTicker.C) - if err != nil && !errors.Is(err, context.Canceled) { + if err != nil && r.ctx.Err() == nil { consecutiveFailures++ backoff := r.interval * time.Duration(1< 0 { - r.logger.Info().Int("removed", removed).Msg("cleaned up old transaction hashes") - } - return false case <-timer.C: return false } @@ -158,7 +148,7 @@ type pendingTx struct { hash string } -func (r *Reaper) drainMempool() (bool, error) { +func (r *Reaper) drainMempool(cleanupCh <-chan time.Time) (bool, error) { var totalSubmitted int defer func() { @@ -168,6 +158,15 @@ func (r *Reaper) drainMempool() (bool, error) { }() for { + select { + case <-cleanupCh: + removed := r.cache.CleanupOldTxs(cache.DefaultTxCacheRetention) + if removed > 0 { + r.logger.Info().Int("removed", removed).Msg("cleaned up old transaction hashes") + } + default: + } + txs, err := r.exec.GetTxs(r.ctx) if err != nil { return totalSubmitted > 0, fmt.Errorf("failed to get txs from executor: %w", err) diff --git a/block/internal/reaping/reaper_test.go b/block/internal/reaping/reaper_test.go index 2cff6bdfa3..6bf4426d4a 100644 --- a/block/internal/reaping/reaper_test.go +++ b/block/internal/reaping/reaper_test.go @@ -87,7 +87,7 @@ func TestReaper_NewTxs_SubmitsAndPersistsAndNotifies(t *testing.T) { return &coresequencer.SubmitBatchTxsResponse{}, nil }).Once() - submitted, err := env.reaper.drainMempool() + submitted, err := env.reaper.drainMempool(nil) assert.NoError(t, err) assert.True(t, submitted) assert.True(t, env.cache.IsTxSeen(hashTx(tx1))) @@ -106,7 +106,7 @@ func TestReaper_AllSeen_NoSubmit(t *testing.T) { env.execMock.EXPECT().GetTxs(mock.Anything).Return([][]byte{tx1, tx2}, nil).Once() - submitted, err := env.reaper.drainMempool() + submitted, err := env.reaper.drainMempool(nil) assert.NoError(t, err) assert.False(t, submitted) assert.False(t, env.wasNotified()) @@ -129,7 +129,7 @@ func TestReaper_PartialSeen_FiltersAndPersists(t *testing.T) { return &coresequencer.SubmitBatchTxsResponse{}, nil }).Once() - submitted, err := env.reaper.drainMempool() + submitted, err := env.reaper.drainMempool(nil) assert.NoError(t, err) assert.True(t, submitted) assert.True(t, env.cache.IsTxSeen(hashTx(txOld))) @@ -147,7 +147,7 @@ func TestReaper_SequencerError_NoPersistence_NoNotify(t *testing.T) { env.seqMock.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). Return((*coresequencer.SubmitBatchTxsResponse)(nil), assert.AnError).Once() - _, err := env.reaper.drainMempool() + _, err := env.reaper.drainMempool(nil) assert.Error(t, err) assert.False(t, env.cache.IsTxSeen(hashTx(tx))) assert.False(t, env.wasNotified()) @@ -169,7 +169,7 @@ func TestReaper_DrainsMempoolInMultipleRounds(t *testing.T) { return &coresequencer.SubmitBatchTxsResponse{}, nil }).Twice() - submitted, err := env.reaper.drainMempool() + submitted, err := env.reaper.drainMempool(nil) assert.NoError(t, err) assert.True(t, submitted) assert.True(t, env.cache.IsTxSeen(hashTx(tx1))) @@ -183,7 +183,7 @@ func TestReaper_EmptyMempool_NoAction(t *testing.T) { env.execMock.EXPECT().GetTxs(mock.Anything).Return(nil, nil).Once() - submitted, err := env.reaper.drainMempool() + submitted, err := env.reaper.drainMempool(nil) assert.NoError(t, err) assert.False(t, submitted) assert.False(t, env.wasNotified()) @@ -201,7 +201,7 @@ func TestReaper_HashComputedOnce(t *testing.T) { env.seqMock.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). Return(&coresequencer.SubmitBatchTxsResponse{}, nil).Once() - submitted, err := env.reaper.drainMempool() + submitted, err := env.reaper.drainMempool(nil) assert.NoError(t, err) assert.True(t, submitted) assert.True(t, env.cache.IsTxSeen(expectedHash)) @@ -227,7 +227,7 @@ func TestReaper_NilCallback_NoPanic(t *testing.T) { mockSeq.EXPECT().SubmitBatchTxs(mock.Anything, mock.AnythingOfType("sequencer.SubmitBatchTxsRequest")). Return(&coresequencer.SubmitBatchTxsResponse{}, nil).Once() - submitted, err := r.drainMempool() + submitted, err := r.drainMempool(nil) assert.NoError(t, err) assert.True(t, submitted) } diff --git a/block/internal/submitting/submitter.go b/block/internal/submitting/submitter.go index 73a05602fc..d92cf9258c 100644 --- a/block/internal/submitting/submitter.go +++ b/block/internal/submitting/submitter.go @@ -118,14 +118,21 @@ func NewSubmitter( } // Start begins the submitting component -func (s *Submitter) Start(ctx context.Context) error { +func (s *Submitter) Start(ctx context.Context) (err error) { if s.cancel != nil { return errors.New("submitter already started") } + s.ctx, s.cancel = context.WithCancel(ctx) + defer func() { // if error during init cancel context + if err != nil { + s.cancel() + s.ctx, s.cancel = nil, nil + } + }() // Initialize DA included height - if err := s.initializeDAIncludedHeight(ctx); err != nil { + if err = s.initializeDAIncludedHeight(ctx); err != nil { return err } diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index d9546d076c..13cdad4c1f 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -162,6 +162,7 @@ func (s *Syncer) SetBlockSyncer(bs BlockSyncer) { } // Start begins the syncing component +// The component should not be started after being stopped. func (s *Syncer) Start(ctx context.Context) (err error) { if s.cancel != nil { return errors.New("syncer already started")