From f4f90cd841a9aee6f239cdf828c7f999d16e1c2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:52:03 +0000 Subject: [PATCH 01/16] Initial plan From b962c16ae9bb2c63058bb0fd5183bb8b24e9be15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:09:22 +0000 Subject: [PATCH 02/16] Add recovery history retention pruning Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- apps/evm/cmd/rollback.go | 1 + apps/evm/cmd/run.go | 1 + apps/evm/go.mod | 4 ++++ execution/evm/execution.go | 9 +++++++++ execution/evm/store.go | 16 ++++++++++++++- execution/evm/store_test.go | 39 +++++++++++++++++++++++++++++++++++++ node/full.go | 3 +++ node/light.go | 3 +++ pkg/config/config.go | 4 ++++ pkg/config/config_test.go | 4 +++- pkg/config/defaults.go | 3 +++ pkg/store/batch.go | 14 ++++++++++++- pkg/store/store.go | 9 ++++++++- pkg/store/store_test.go | 30 ++++++++++++++++++++++++++++ 14 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 execution/evm/store_test.go diff --git a/apps/evm/cmd/rollback.go b/apps/evm/cmd/rollback.go index 3f11ef8d4f..e8eaaae3d6 100644 --- a/apps/evm/cmd/rollback.go +++ b/apps/evm/cmd/rollback.go @@ -73,6 +73,7 @@ func NewRollbackCmd() *cobra.Command { if err != nil { cmd.Printf("Warning: failed to create engine client, skipping EL rollback: %v\n", err) } else { + engineClient.SetExecMetaRetention(nodeConfig.Node.StateHistoryRetention) if err := engineClient.Rollback(goCtx, height); err != nil { return fmt.Errorf("failed to rollback execution layer: %w", err) } diff --git a/apps/evm/cmd/run.go b/apps/evm/cmd/run.go index 6690d02d52..84a3b6de25 100644 --- a/apps/evm/cmd/run.go +++ b/apps/evm/cmd/run.go @@ -69,6 +69,7 @@ var RunCmd = &cobra.Command{ // Attach logger to the EVM engine client if available if ec, ok := executor.(*evm.EngineClient); ok { + ec.SetExecMetaRetention(nodeConfig.Node.StateHistoryRetention) ec.SetLogger(logger.With().Str("module", "engine_client").Logger()) } diff --git a/apps/evm/go.mod b/apps/evm/go.mod index ed4b6c5126..08da3b511b 100644 --- a/apps/evm/go.mod +++ b/apps/evm/go.mod @@ -222,3 +222,7 @@ replace ( google.golang.org/genproto/googleapis/api => google.golang.org/genproto/googleapis/api v0.0.0-20240213162025-012b6fc9bca9 google.golang.org/genproto/googleapis/rpc => google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 ) + +replace github.com/evstack/ev-node => ../../ + +replace github.com/evstack/ev-node/execution/evm => ../../execution/evm diff --git a/execution/evm/execution.go b/execution/evm/execution.go index 7a0a9dfb5a..9138cbe380 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -270,6 +270,15 @@ func (c *EngineClient) SetLogger(l zerolog.Logger) { c.logger = l } +// SetExecMetaRetention configures how many recent execution metadata entries are retained. +// A value of 0 keeps all entries. +func (c *EngineClient) SetExecMetaRetention(limit uint64) { + if c.store == nil { + return + } + c.store.SetExecMetaRetention(limit) +} + // InitChain initializes the blockchain with the given genesis parameters func (c *EngineClient) InitChain(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) ([]byte, error) { if initialHeight != 1 { diff --git a/execution/evm/store.go b/execution/evm/store.go index af731ee7c6..078e7c93f4 100644 --- a/execution/evm/store.go +++ b/execution/evm/store.go @@ -85,7 +85,8 @@ func (em *ExecMeta) FromProto(other *pb.ExecMeta) error { // EVMStore wraps a ds.Batching datastore with a prefix for EVM execution data. // This keeps EVM-specific data isolated from other ev-node data. type EVMStore struct { - db ds.Batching + db ds.Batching + execMetaRetention uint64 } // NewEVMStore creates a new EVMStore wrapping the given datastore. @@ -93,6 +94,12 @@ func NewEVMStore(db ds.Batching) *EVMStore { return &EVMStore{db: db} } +// SetExecMetaRetention sets the number of recent exec meta entries to keep. +// A value of 0 keeps all exec meta history. +func (s *EVMStore) SetExecMetaRetention(limit uint64) { + s.execMetaRetention = limit +} + // execMetaKey returns the datastore key for ExecMeta at a given height. func execMetaKey(height uint64) ds.Key { heightBytes := make([]byte, 8) @@ -137,6 +144,13 @@ func (s *EVMStore) SaveExecMeta(ctx context.Context, meta *ExecMeta) error { return fmt.Errorf("failed to save exec meta: %w", err) } + if s.execMetaRetention > 0 && meta.Height > s.execMetaRetention { + pruneHeight := meta.Height - s.execMetaRetention + if err := s.db.Delete(ctx, execMetaKey(pruneHeight)); err != nil && !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to prune exec meta at height %d: %w", pruneHeight, err) + } + } + return nil } diff --git a/execution/evm/store_test.go b/execution/evm/store_test.go new file mode 100644 index 0000000000..97b2a2eff1 --- /dev/null +++ b/execution/evm/store_test.go @@ -0,0 +1,39 @@ +package evm + +import ( + "context" + "testing" + + ds "github.com/ipfs/go-datastore" + dssync "github.com/ipfs/go-datastore/sync" + "github.com/stretchr/testify/require" +) + +func TestSaveExecMetaPrunesHistory(t *testing.T) { + t.Parallel() + + store := NewEVMStore(dssync.MutexWrap(ds.NewMapDatastore())) + store.SetExecMetaRetention(2) + + ctx := context.Background() + for height := uint64(1); height <= 3; height++ { + require.NoError(t, store.SaveExecMeta(ctx, &ExecMeta{ + Height: height, + Stage: ExecStageStarted, + })) + } + + meta, err := store.GetExecMeta(ctx, 1) + require.NoError(t, err) + require.Nil(t, meta) + + meta, err = store.GetExecMeta(ctx, 2) + require.NoError(t, err) + require.NotNil(t, meta) + require.Equal(t, uint64(2), meta.Height) + + meta, err = store.GetExecMeta(ctx, 3) + require.NoError(t, err) + require.NotNil(t, meta) + require.Equal(t, uint64(3), meta.Height) +} diff --git a/node/full.go b/node/full.go index 4fa2ff7c52..3ba3497300 100644 --- a/node/full.go +++ b/node/full.go @@ -83,6 +83,9 @@ func newFullNode( mainKV := store.NewEvNodeKVStore(database) baseStore := store.New(mainKV) + if defaultStore, ok := baseStore.(*store.DefaultStore); ok { + defaultStore.SetStateHistoryRetention(nodeConfig.Node.StateHistoryRetention) + } // Wrap with cached store for LRU caching of headers and block data cachedStore, err := store.NewCachedStore(baseStore) diff --git a/node/light.go b/node/light.go index 8790507a07..db9b36b86f 100644 --- a/node/light.go +++ b/node/light.go @@ -50,6 +50,9 @@ func newLightNode( componentLogger := logger.With().Str("component", "HeaderSyncService").Logger() baseStore := store.New(database) + if defaultStore, ok := baseStore.(*store.DefaultStore); ok { + defaultStore.SetStateHistoryRetention(conf.Node.StateHistoryRetention) + } // Wrap with cached store for LRU caching of headers cachedStore, err := store.NewCachedStore(baseStore) diff --git a/pkg/config/config.go b/pkg/config/config.go index e03a277ce8..744239b4a4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -51,6 +51,8 @@ const ( FlagReadinessMaxBlocksBehind = FlagPrefixEvnode + "node.readiness_max_blocks_behind" // FlagScrapeInterval is a flag for specifying the reaper scrape interval FlagScrapeInterval = FlagPrefixEvnode + "node.scrape_interval" + // FlagStateHistoryRetention is a flag for specifying how much state/exec metadata history to keep + FlagStateHistoryRetention = FlagPrefixEvnode + "node.state_history_retention" // FlagClearCache is a flag for clearing the cache FlagClearCache = FlagPrefixEvnode + "clear_cache" @@ -257,6 +259,7 @@ type NodeConfig struct { LazyMode bool `mapstructure:"lazy_mode" yaml:"lazy_mode" comment:"Enables lazy aggregation mode, where blocks are only produced when transactions are available or after LazyBlockTime. Optimizes resources by avoiding empty block creation during periods of inactivity."` LazyBlockInterval DurationWrapper `mapstructure:"lazy_block_interval" yaml:"lazy_block_interval" comment:"Maximum interval between blocks in lazy aggregation mode (LazyAggregator). Ensures blocks are produced periodically even without transactions to keep the chain active. Generally larger than BlockTime."` ScrapeInterval DurationWrapper `mapstructure:"scrape_interval" yaml:"scrape_interval" comment:"Interval at which the reaper polls the execution layer for new transactions. Lower values reduce transaction detection latency but increase RPC load. Examples: \"250ms\", \"500ms\", \"1s\"."` + StateHistoryRetention uint64 `mapstructure:"state_history_retention" yaml:"state_history_retention" comment:"Number of recent heights to keep state and execution metadata for recovery (0 keeps all)."` // Readiness / health configuration ReadinessWindowSeconds uint64 `mapstructure:"readiness_window_seconds" yaml:"readiness_window_seconds" comment:"Time window in seconds used to calculate ReadinessMaxBlocksBehind based on block time. Default: 15 seconds."` @@ -436,6 +439,7 @@ func AddFlags(cmd *cobra.Command) { cmd.Flags().Uint64(FlagReadinessWindowSeconds, def.Node.ReadinessWindowSeconds, "time window in seconds for calculating readiness threshold based on block time (default: 15s)") cmd.Flags().Uint64(FlagReadinessMaxBlocksBehind, def.Node.ReadinessMaxBlocksBehind, "how many blocks behind best-known head the node can be and still be considered ready (0 = must be at head)") cmd.Flags().Duration(FlagScrapeInterval, def.Node.ScrapeInterval.Duration, "interval at which the reaper polls the execution layer for new transactions") + cmd.Flags().Uint64(FlagStateHistoryRetention, def.Node.StateHistoryRetention, "number of recent heights to keep state and execution metadata for recovery (0 keeps all)") // Data Availability configuration flags cmd.Flags().String(FlagDAAddress, def.DA.Address, "DA address (host:port)") diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1834e1b405..03d84e34c4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -33,6 +33,7 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, uint64(0), def.Node.MaxPendingHeadersAndData) assert.Equal(t, false, def.Node.LazyMode) assert.Equal(t, 60*time.Second, def.Node.LazyBlockInterval.Duration) + assert.Equal(t, uint64(5000), def.Node.StateHistoryRetention) assert.Equal(t, "file", def.Signer.SignerType) assert.Equal(t, "config", def.Signer.SignerPath) assert.Equal(t, "127.0.0.1:7331", def.RPC.Address) @@ -64,6 +65,7 @@ func TestAddFlags(t *testing.T) { assertFlagValue(t, flags, FlagReadinessWindowSeconds, DefaultConfig().Node.ReadinessWindowSeconds) assertFlagValue(t, flags, FlagReadinessMaxBlocksBehind, DefaultConfig().Node.ReadinessMaxBlocksBehind) assertFlagValue(t, flags, FlagScrapeInterval, DefaultConfig().Node.ScrapeInterval) + assertFlagValue(t, flags, FlagStateHistoryRetention, DefaultConfig().Node.StateHistoryRetention) // DA flags assertFlagValue(t, flags, FlagDAAddress, DefaultConfig().DA.Address) @@ -112,7 +114,7 @@ func TestAddFlags(t *testing.T) { assertFlagValue(t, flags, FlagRPCEnableDAVisualization, DefaultConfig().RPC.EnableDAVisualization) // Count the number of flags we're explicitly checking - expectedFlagCount := 63 // Update this number if you add more flag checks above + expectedFlagCount := 64 // Update this number if you add more flag checks above // Get the actual number of flags (both regular and persistent) actualFlagCount := 0 diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 0de2f4bc27..b74ea95933 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -16,6 +16,8 @@ const ( ConfigName = ConfigFileName + "." + ConfigExtension // AppConfigDir is the directory name for the app configuration. AppConfigDir = "config" + + defaultStateHistoryRetention = uint64(5000) ) // DefaultRootDir returns the default root directory for evolve @@ -66,6 +68,7 @@ func DefaultConfig() Config { LazyMode: false, LazyBlockInterval: DurationWrapper{60 * time.Second}, Light: false, + StateHistoryRetention: defaultStateHistoryRetention, ReadinessWindowSeconds: defaultReadinessWindowSeconds, ReadinessMaxBlocksBehind: calculateReadinessMaxBlocksBehind(defaultBlockTime.Duration, defaultReadinessWindowSeconds), ScrapeInterval: DurationWrapper{1 * time.Second}, diff --git a/pkg/store/batch.go b/pkg/store/batch.go index 405119c612..e738812fdc 100644 --- a/pkg/store/batch.go +++ b/pkg/store/batch.go @@ -3,6 +3,7 @@ package store import ( "context" "crypto/sha256" + "errors" "fmt" ds "github.com/ipfs/go-datastore" @@ -93,7 +94,18 @@ func (b *DefaultBatch) UpdateState(state types.State) error { return fmt.Errorf("failed to marshal state to protobuf: %w", err) } - return b.batch.Put(b.ctx, ds.NewKey(getStateAtHeightKey(height)), data) + if err := b.batch.Put(b.ctx, ds.NewKey(getStateAtHeightKey(height)), data); err != nil { + return err + } + + if b.store.stateHistoryRetention > 0 && height > b.store.stateHistoryRetention { + pruneHeight := height - b.store.stateHistoryRetention + if err := b.batch.Delete(b.ctx, ds.NewKey(getStateAtHeightKey(pruneHeight))); err != nil && !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to prune state at height %d: %w", pruneHeight, err) + } + } + + return nil } // Commit commits all batched operations atomically diff --git a/pkg/store/store.go b/pkg/store/store.go index eafa47ae75..5a89815162 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -18,7 +18,8 @@ import ( // DefaultStore is a default store implementation. type DefaultStore struct { - db ds.Batching + db ds.Batching + stateHistoryRetention uint64 } var _ Store = &DefaultStore{} @@ -30,6 +31,12 @@ func New(ds ds.Batching) Store { } } +// SetStateHistoryRetention sets the number of recent state entries to keep. +// A value of 0 keeps all state history. +func (s *DefaultStore) SetStateHistoryRetention(limit uint64) { + s.stateHistoryRetention = limit +} + // Close safely closes underlying data storage, to ensure that data is actually saved. func (s *DefaultStore) Close() error { return s.db.Close() diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go index 6a09db465e..dd36e8d50b 100644 --- a/pkg/store/store_test.go +++ b/pkg/store/store_test.go @@ -591,6 +591,36 @@ func TestUpdateStateError(t *testing.T) { require.Contains(err.Error(), mockErrPut.Error()) } +func TestUpdateStatePrunesHistory(t *testing.T) { + t.Parallel() + require := require.New(t) + + kv, err := NewTestInMemoryKVStore() + require.NoError(err) + + store := New(kv) + store.(*DefaultStore).SetStateHistoryRetention(2) + + for height := uint64(1); height <= 3; height++ { + batch, err := store.NewBatch(t.Context()) + require.NoError(err) + require.NoError(batch.SetHeight(height)) + require.NoError(batch.UpdateState(types.State{LastBlockHeight: height})) + require.NoError(batch.Commit()) + } + + _, err = store.GetStateAtHeight(t.Context(), 1) + require.ErrorIs(err, ds.ErrNotFound) + + state, err := store.GetStateAtHeight(t.Context(), 2) + require.NoError(err) + require.Equal(uint64(2), state.LastBlockHeight) + + state, err = store.GetStateAtHeight(t.Context(), 3) + require.NoError(err) + require.Equal(uint64(3), state.LastBlockHeight) +} + func TestGetStateError(t *testing.T) { t.Parallel() require := require.New(t) From 11d98d1271830a4dd700a279216b579ac127e126 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:17:36 +0000 Subject: [PATCH 03/16] feat: add configurable recovery history retention Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- apps/evm/go.sum | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/evm/go.sum b/apps/evm/go.sum index 00e5995e9a..49e723062b 100644 --- a/apps/evm/go.sum +++ b/apps/evm/go.sum @@ -411,12 +411,8 @@ github.com/ethereum/go-ethereum v1.16.8 h1:LLLfkZWijhR5m6yrAXbdlTeXoqontH+Ga2f9i github.com/ethereum/go-ethereum v1.16.8/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= -github.com/evstack/ev-node v1.0.0-rc.4 h1:Ju7pSETFdadBZxmAj0//4z7hHkXbSRDy9iTzhF60Dew= -github.com/evstack/ev-node v1.0.0-rc.4/go.mod h1:xGCH5NCdGiYk6v3GVPm4NhzAtcKQgnaVnORg8b4tbOk= github.com/evstack/ev-node/core v1.0.0-rc.1 h1:Dic2PMUMAYUl5JW6DkDj6HXDEWYzorVJQuuUJOV0FjE= github.com/evstack/ev-node/core v1.0.0-rc.1/go.mod h1:n2w/LhYQTPsi48m6lMj16YiIqsaQw6gxwjyJvR+B3sY= -github.com/evstack/ev-node/execution/evm v1.0.0-rc.3 h1:3o8H1TNywnst56lo2RlS2SXulDfp9yZJtkYYh7ZJrdM= -github.com/evstack/ev-node/execution/evm v1.0.0-rc.3/go.mod h1:VUEEklKoclg45GL7dzLoDwu3UQ4ptT3rF8bw5zUmnRk= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= From dbc8eb176b2c1ceb5fe5afc819f9964de4fa217f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:23:12 +0000 Subject: [PATCH 04/16] Refactor recovery pruning into pruner component Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- apps/evm/cmd/rollback.go | 9 +- apps/evm/cmd/run.go | 12 +- block/components.go | 31 ++++++ block/components_test.go | 2 + block/internal/pruner/pruner.go | 157 +++++++++++++++++++++++++++ block/internal/pruner/pruner_test.go | 51 +++++++++ execution/evm/execution.go | 17 +-- execution/evm/store.go | 21 ++-- execution/evm/store_test.go | 25 ++--- execution/evm/test/execution_test.go | 3 + node/full.go | 3 - node/light.go | 3 - pkg/store/batch.go | 14 +-- pkg/store/cached_store.go | 13 +++ pkg/store/store.go | 13 ++- pkg/store/store_test.go | 25 ++--- pkg/store/tracing.go | 27 +++++ 17 files changed, 330 insertions(+), 96 deletions(-) create mode 100644 block/internal/pruner/pruner.go create mode 100644 block/internal/pruner/pruner_test.go diff --git a/apps/evm/cmd/rollback.go b/apps/evm/cmd/rollback.go index e8eaaae3d6..911c330dd6 100644 --- a/apps/evm/cmd/rollback.go +++ b/apps/evm/cmd/rollback.go @@ -8,6 +8,7 @@ import ( "github.com/ethereum/go-ethereum/common" ds "github.com/ipfs/go-datastore" + "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/evstack/ev-node/execution/evm" @@ -30,6 +31,7 @@ func NewRollbackCmd() *cobra.Command { if err != nil { return err } + logger := rollcmd.SetupLogger(nodeConfig.Log) goCtx := cmd.Context() if goCtx == nil { @@ -69,11 +71,10 @@ func NewRollbackCmd() *cobra.Command { } // rollback execution layer via EngineClient - engineClient, err := createRollbackEngineClient(cmd, rawEvolveDB) + engineClient, err := createRollbackEngineClient(cmd, rawEvolveDB, logger.With().Str("module", "engine_client").Logger()) if err != nil { cmd.Printf("Warning: failed to create engine client, skipping EL rollback: %v\n", err) } else { - engineClient.SetExecMetaRetention(nodeConfig.Node.StateHistoryRetention) if err := engineClient.Rollback(goCtx, height); err != nil { return fmt.Errorf("failed to rollback execution layer: %w", err) } @@ -100,7 +101,7 @@ func NewRollbackCmd() *cobra.Command { return cmd } -func createRollbackEngineClient(cmd *cobra.Command, db ds.Batching) (*evm.EngineClient, error) { +func createRollbackEngineClient(cmd *cobra.Command, db ds.Batching, logger zerolog.Logger) (*evm.EngineClient, error) { ethURL, err := cmd.Flags().GetString(evm.FlagEvmEthURL) if err != nil { return nil, fmt.Errorf("failed to get '%s' flag: %w", evm.FlagEvmEthURL, err) @@ -129,5 +130,5 @@ func createRollbackEngineClient(cmd *cobra.Command, db ds.Batching) (*evm.Engine return nil, fmt.Errorf("JWT secret file '%s' is empty", jwtSecretFile) } - return evm.NewEngineExecutionClient(ethURL, engineURL, jwtSecret, common.Hash{}, common.Address{}, db, false) + return evm.NewEngineExecutionClient(ethURL, engineURL, jwtSecret, common.Hash{}, common.Address{}, db, false, logger) } diff --git a/apps/evm/cmd/run.go b/apps/evm/cmd/run.go index 84a3b6de25..5ae854037e 100644 --- a/apps/evm/cmd/run.go +++ b/apps/evm/cmd/run.go @@ -55,7 +55,7 @@ var RunCmd = &cobra.Command{ } tracingEnabled := nodeConfig.Instrumentation.IsTracingEnabled() - executor, err := createExecutionClient(cmd, datastore, tracingEnabled) + executor, err := createExecutionClient(cmd, datastore, tracingEnabled, logger.With().Str("module", "engine_client").Logger()) if err != nil { return err } @@ -67,12 +67,6 @@ var RunCmd = &cobra.Command{ daClient := block.NewDAClient(blobClient, nodeConfig, logger) - // Attach logger to the EVM engine client if available - if ec, ok := executor.(*evm.EngineClient); ok { - ec.SetExecMetaRetention(nodeConfig.Node.StateHistoryRetention) - ec.SetLogger(logger.With().Str("module", "engine_client").Logger()) - } - headerNamespace := da.NamespaceFromString(nodeConfig.DA.GetNamespace()) dataNamespace := da.NamespaceFromString(nodeConfig.DA.GetDataNamespace()) @@ -193,7 +187,7 @@ func createSequencer( return sequencer, nil } -func createExecutionClient(cmd *cobra.Command, db datastore.Batching, tracingEnabled bool) (execution.Executor, error) { +func createExecutionClient(cmd *cobra.Command, db datastore.Batching, tracingEnabled bool, logger zerolog.Logger) (execution.Executor, error) { // Read execution client parameters from flags ethURL, err := cmd.Flags().GetString(evm.FlagEvmEthURL) if err != nil { @@ -238,7 +232,7 @@ func createExecutionClient(cmd *cobra.Command, db datastore.Batching, tracingEna genesisHash := common.HexToHash(genesisHashStr) feeRecipient := common.HexToAddress(feeRecipientStr) - return evm.NewEngineExecutionClient(ethURL, engineURL, jwtSecret, genesisHash, feeRecipient, db, tracingEnabled) + return evm.NewEngineExecutionClient(ethURL, engineURL, jwtSecret, genesisHash, feeRecipient, db, tracingEnabled, logger) } // addFlags adds flags related to the EVM execution client diff --git a/block/components.go b/block/components.go index d5af466ef6..303bed07ab 100644 --- a/block/components.go +++ b/block/components.go @@ -12,6 +12,7 @@ import ( "github.com/evstack/ev-node/block/internal/common" da "github.com/evstack/ev-node/block/internal/da" "github.com/evstack/ev-node/block/internal/executing" + "github.com/evstack/ev-node/block/internal/pruner" "github.com/evstack/ev-node/block/internal/reaping" "github.com/evstack/ev-node/block/internal/submitting" "github.com/evstack/ev-node/block/internal/syncing" @@ -29,6 +30,7 @@ import ( // Components represents the block-related components type Components struct { Executor *executing.Executor + Pruner *pruner.Pruner Reaper *reaping.Reaper Syncer *syncing.Syncer Submitter *submitting.Submitter @@ -60,6 +62,11 @@ func (bc *Components) Start(ctx context.Context) error { return fmt.Errorf("failed to start executor: %w", err) } } + if bc.Pruner != nil { + if err := bc.Pruner.Start(ctxWithCancel); err != nil { + return fmt.Errorf("failed to start pruner: %w", err) + } + } if bc.Reaper != nil { if err := bc.Reaper.Start(ctxWithCancel); err != nil { return fmt.Errorf("failed to start reaper: %w", err) @@ -96,6 +103,11 @@ func (bc *Components) Stop() error { errs = errors.Join(errs, fmt.Errorf("failed to stop executor: %w", err)) } } + if bc.Pruner != nil { + if err := bc.Pruner.Stop(); err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to stop pruner: %w", err)) + } + } if bc.Reaper != nil { if err := bc.Reaper.Stop(); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to stop reaper: %w", err)) @@ -166,6 +178,14 @@ func NewSyncComponents( syncer.SetBlockSyncer(syncing.WithTracingBlockSyncer(syncer)) } + var execPruner pruner.ExecMetaPruner + if exec != nil { + if candidate, ok := exec.(pruner.ExecMetaPruner); ok { + execPruner = candidate + } + } + recoveryPruner := pruner.New(store, execPruner, config.Node.StateHistoryRetention, pruner.DefaultPruneInterval, logger.With().Str("component", "Pruner").Logger()) + // Create submitter for sync nodes (no signer, only DA inclusion processing) var daSubmitter submitting.DASubmitterAPI = submitting.NewDASubmitter(daClient, config, genesis, blockOpts, metrics, logger, headerDAHintAppender, dataDAHintAppender) if config.Instrumentation.IsTracingEnabled() { @@ -189,6 +209,7 @@ func NewSyncComponents( Syncer: syncer, Submitter: submitter, Cache: cacheManager, + Pruner: recoveryPruner, errorCh: errorCh, }, nil } @@ -248,6 +269,14 @@ func NewAggregatorComponents( executor.SetBlockProducer(executing.WithTracingBlockProducer(executor)) } + var execPruner pruner.ExecMetaPruner + if exec != nil { + if candidate, ok := exec.(pruner.ExecMetaPruner); ok { + execPruner = candidate + } + } + recoveryPruner := pruner.New(store, execPruner, config.Node.StateHistoryRetention, pruner.DefaultPruneInterval, logger.With().Str("component", "Pruner").Logger()) + reaper, err := reaping.NewReaper( exec, sequencer, @@ -264,6 +293,7 @@ func NewAggregatorComponents( if config.Node.BasedSequencer { // no submissions needed for bases sequencer return &Components{ Executor: executor, + Pruner: recoveryPruner, Reaper: reaper, Cache: cacheManager, errorCh: errorCh, @@ -290,6 +320,7 @@ func NewAggregatorComponents( return &Components{ Executor: executor, + Pruner: recoveryPruner, Reaper: reaper, Submitter: submitter, Cache: cacheManager, diff --git a/block/components_test.go b/block/components_test.go index 93c08a655a..3d1a1f4a1e 100644 --- a/block/components_test.go +++ b/block/components_test.go @@ -127,6 +127,7 @@ func TestNewSyncComponents_Creation(t *testing.T) { assert.NotNil(t, components.Syncer) assert.NotNil(t, components.Submitter) assert.NotNil(t, components.Cache) + assert.NotNil(t, components.Pruner) assert.NotNil(t, components.errorCh) assert.Nil(t, components.Executor) // Sync nodes don't have executors } @@ -183,6 +184,7 @@ func TestNewAggregatorComponents_Creation(t *testing.T) { assert.NotNil(t, components.Executor) assert.NotNil(t, components.Submitter) assert.NotNil(t, components.Cache) + assert.NotNil(t, components.Pruner) assert.NotNil(t, components.errorCh) assert.Nil(t, components.Syncer) // Aggregator nodes currently don't create syncers in this constructor } diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go new file mode 100644 index 0000000000..60f4add34f --- /dev/null +++ b/block/internal/pruner/pruner.go @@ -0,0 +1,157 @@ +package pruner + +import ( + "context" + "errors" + "sync" + "time" + + ds "github.com/ipfs/go-datastore" + "github.com/rs/zerolog" + + "github.com/evstack/ev-node/pkg/store" +) + +const ( + DefaultPruneInterval = time.Minute + maxPruneBatch = uint64(1000) +) + +// ExecMetaPruner removes execution metadata at a given height. +type ExecMetaPruner interface { + PruneExecMeta(ctx context.Context, height uint64) error +} + +type stateDeleter interface { + DeleteStateAtHeight(ctx context.Context, height uint64) error +} + +// Pruner periodically removes old state and execution metadata entries. +type Pruner struct { + store store.Store + stateDeleter stateDeleter + execPruner ExecMetaPruner + retention uint64 + interval time.Duration + logger zerolog.Logger + lastPruned uint64 + + wg sync.WaitGroup + cancel context.CancelFunc +} + +// New creates a new Pruner instance. +func New(store store.Store, execPruner ExecMetaPruner, retention uint64, interval time.Duration, logger zerolog.Logger) *Pruner { + if interval <= 0 { + interval = DefaultPruneInterval + } + + var deleter stateDeleter + if store != nil { + if sd, ok := store.(stateDeleter); ok { + deleter = sd + } + } + + return &Pruner{ + store: store, + stateDeleter: deleter, + execPruner: execPruner, + retention: retention, + interval: interval, + logger: logger, + } +} + +// Start begins the pruning loop. +func (p *Pruner) Start(ctx context.Context) error { + if p == nil || p.retention == 0 || (p.stateDeleter == nil && p.execPruner == nil) { + return nil + } + + loopCtx, cancel := context.WithCancel(ctx) + p.cancel = cancel + + p.wg.Add(1) + go p.pruneLoop(loopCtx) + + return nil +} + +// Stop stops the pruning loop. +func (p *Pruner) Stop() error { + if p == nil || p.cancel == nil { + return nil + } + + p.cancel() + p.wg.Wait() + return nil +} + +func (p *Pruner) pruneLoop(ctx context.Context) { + defer p.wg.Done() + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + + if err := p.pruneOnce(ctx); err != nil { + p.logger.Error().Err(err).Msg("failed to prune recovery history") + } + + for { + select { + case <-ticker.C: + if err := p.pruneOnce(ctx); err != nil { + p.logger.Error().Err(err).Msg("failed to prune recovery history") + } + case <-ctx.Done(): + return + } + } +} + +func (p *Pruner) pruneOnce(ctx context.Context) error { + if p.retention == 0 || p.store == nil { + return nil + } + + height, err := p.store.Height(ctx) + if err != nil { + return err + } + + if height <= p.retention { + return nil + } + + target := height - p.retention + if target < p.lastPruned { + p.lastPruned = target + return nil + } + if target == p.lastPruned { + return nil + } + + start := p.lastPruned + 1 + end := target + if end-start+1 > maxPruneBatch { + end = start + maxPruneBatch - 1 + } + + for h := start; h <= end; h++ { + if p.stateDeleter != nil { + if err := p.stateDeleter.DeleteStateAtHeight(ctx, h); err != nil && !errors.Is(err, ds.ErrNotFound) { + return err + } + } + if p.execPruner != nil { + if err := p.execPruner.PruneExecMeta(ctx, h); err != nil && !errors.Is(err, ds.ErrNotFound) { + return err + } + } + } + + p.lastPruned = end + return nil +} diff --git a/block/internal/pruner/pruner_test.go b/block/internal/pruner/pruner_test.go new file mode 100644 index 0000000000..874e22b76c --- /dev/null +++ b/block/internal/pruner/pruner_test.go @@ -0,0 +1,51 @@ +package pruner + +import ( + "context" + "testing" + "time" + + ds "github.com/ipfs/go-datastore" + dssync "github.com/ipfs/go-datastore/sync" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/pkg/store" + "github.com/evstack/ev-node/types" +) + +type execMetaAdapter struct { + existing map[uint64]struct{} +} + +func (e *execMetaAdapter) PruneExecMeta(ctx context.Context, height uint64) error { + delete(e.existing, height) + return nil +} + +func TestPrunerPrunesRecoveryHistory(t *testing.T) { + t.Parallel() + + ctx := context.Background() + kv := dssync.MutexWrap(ds.NewMapDatastore()) + stateStore := store.New(kv) + + for height := uint64(1); height <= 3; height++ { + batch, err := stateStore.NewBatch(ctx) + require.NoError(t, err) + require.NoError(t, batch.SetHeight(height)) + require.NoError(t, batch.UpdateState(types.State{LastBlockHeight: height})) + require.NoError(t, batch.Commit()) + } + + execAdapter := &execMetaAdapter{existing: map[uint64]struct{}{1: {}, 2: {}, 3: {}}} + + recoveryPruner := New(stateStore, execAdapter, 2, time.Minute, zerolog.Nop()) + require.NoError(t, recoveryPruner.pruneOnce(ctx)) + + _, err := stateStore.GetStateAtHeight(ctx, 1) + require.ErrorIs(t, err, ds.ErrNotFound) + + _, exists := execAdapter.existing[1] + require.False(t, exists) +} diff --git a/execution/evm/execution.go b/execution/evm/execution.go index 9138cbe380..675afe26c5 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -198,6 +198,7 @@ func NewEngineExecutionClient( feeRecipient common.Address, db ds.Batching, tracingEnabled bool, + logger zerolog.Logger, ) (*EngineClient, error) { if db == nil { return nil, errors.New("db is required for EVM execution client") @@ -261,22 +262,16 @@ func NewEngineExecutionClient( currentSafeBlockHash: genesisHash, currentFinalizedBlockHash: genesisHash, blockHashCache: make(map[uint64]common.Hash), - logger: zerolog.Nop(), + logger: logger, }, nil } -// SetLogger allows callers to attach a structured logger. -func (c *EngineClient) SetLogger(l zerolog.Logger) { - c.logger = l -} - -// SetExecMetaRetention configures how many recent execution metadata entries are retained. -// A value of 0 keeps all entries. -func (c *EngineClient) SetExecMetaRetention(limit uint64) { +// PruneExecMeta removes execution metadata at the given height. +func (c *EngineClient) PruneExecMeta(ctx context.Context, height uint64) error { if c.store == nil { - return + return nil } - c.store.SetExecMetaRetention(limit) + return c.store.DeleteExecMeta(ctx, height) } // InitChain initializes the blockchain with the given genesis parameters diff --git a/execution/evm/store.go b/execution/evm/store.go index 078e7c93f4..a5c29a3db8 100644 --- a/execution/evm/store.go +++ b/execution/evm/store.go @@ -85,8 +85,7 @@ func (em *ExecMeta) FromProto(other *pb.ExecMeta) error { // EVMStore wraps a ds.Batching datastore with a prefix for EVM execution data. // This keeps EVM-specific data isolated from other ev-node data. type EVMStore struct { - db ds.Batching - execMetaRetention uint64 + db ds.Batching } // NewEVMStore creates a new EVMStore wrapping the given datastore. @@ -94,12 +93,6 @@ func NewEVMStore(db ds.Batching) *EVMStore { return &EVMStore{db: db} } -// SetExecMetaRetention sets the number of recent exec meta entries to keep. -// A value of 0 keeps all exec meta history. -func (s *EVMStore) SetExecMetaRetention(limit uint64) { - s.execMetaRetention = limit -} - // execMetaKey returns the datastore key for ExecMeta at a given height. func execMetaKey(height uint64) ds.Key { heightBytes := make([]byte, 8) @@ -144,11 +137,13 @@ func (s *EVMStore) SaveExecMeta(ctx context.Context, meta *ExecMeta) error { return fmt.Errorf("failed to save exec meta: %w", err) } - if s.execMetaRetention > 0 && meta.Height > s.execMetaRetention { - pruneHeight := meta.Height - s.execMetaRetention - if err := s.db.Delete(ctx, execMetaKey(pruneHeight)); err != nil && !errors.Is(err, ds.ErrNotFound) { - return fmt.Errorf("failed to prune exec meta at height %d: %w", pruneHeight, err) - } + return nil +} + +// DeleteExecMeta removes execution metadata for the given height. +func (s *EVMStore) DeleteExecMeta(ctx context.Context, height uint64) error { + if err := s.db.Delete(ctx, execMetaKey(height)); err != nil && !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete exec meta at height %d: %w", height, err) } return nil diff --git a/execution/evm/store_test.go b/execution/evm/store_test.go index 97b2a2eff1..1c6b3dc5fd 100644 --- a/execution/evm/store_test.go +++ b/execution/evm/store_test.go @@ -9,31 +9,22 @@ import ( "github.com/stretchr/testify/require" ) -func TestSaveExecMetaPrunesHistory(t *testing.T) { +func TestDeleteExecMeta(t *testing.T) { t.Parallel() store := NewEVMStore(dssync.MutexWrap(ds.NewMapDatastore())) - store.SetExecMetaRetention(2) ctx := context.Background() - for height := uint64(1); height <= 3; height++ { - require.NoError(t, store.SaveExecMeta(ctx, &ExecMeta{ - Height: height, - Stage: ExecStageStarted, - })) - } + require.NoError(t, store.SaveExecMeta(ctx, &ExecMeta{ + Height: 1, + Stage: ExecStageStarted, + })) + + require.NoError(t, store.DeleteExecMeta(ctx, 1)) meta, err := store.GetExecMeta(ctx, 1) require.NoError(t, err) require.Nil(t, meta) - meta, err = store.GetExecMeta(ctx, 2) - require.NoError(t, err) - require.NotNil(t, meta) - require.Equal(t, uint64(2), meta.Height) - - meta, err = store.GetExecMeta(ctx, 3) - require.NoError(t, err) - require.NotNil(t, meta) - require.Equal(t, uint64(3), meta.Height) + require.NoError(t, store.DeleteExecMeta(ctx, 1)) } diff --git a/execution/evm/test/execution_test.go b/execution/evm/test/execution_test.go index aa6f2db174..867b99b77d 100644 --- a/execution/evm/test/execution_test.go +++ b/execution/evm/test/execution_test.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum/ethclient" ds "github.com/ipfs/go-datastore" dssync "github.com/ipfs/go-datastore/sync" + "github.com/rs/zerolog" "github.com/stretchr/testify/require" "github.com/evstack/ev-node/execution/evm" @@ -75,6 +76,7 @@ func TestEngineExecution(t *testing.T) { common.Address{}, store, false, + zerolog.Nop(), ) require.NoError(tt, err) @@ -172,6 +174,7 @@ func TestEngineExecution(t *testing.T) { common.Address{}, store, false, + zerolog.Nop(), ) require.NoError(tt, err) diff --git a/node/full.go b/node/full.go index 3ba3497300..4fa2ff7c52 100644 --- a/node/full.go +++ b/node/full.go @@ -83,9 +83,6 @@ func newFullNode( mainKV := store.NewEvNodeKVStore(database) baseStore := store.New(mainKV) - if defaultStore, ok := baseStore.(*store.DefaultStore); ok { - defaultStore.SetStateHistoryRetention(nodeConfig.Node.StateHistoryRetention) - } // Wrap with cached store for LRU caching of headers and block data cachedStore, err := store.NewCachedStore(baseStore) diff --git a/node/light.go b/node/light.go index db9b36b86f..8790507a07 100644 --- a/node/light.go +++ b/node/light.go @@ -50,9 +50,6 @@ func newLightNode( componentLogger := logger.With().Str("component", "HeaderSyncService").Logger() baseStore := store.New(database) - if defaultStore, ok := baseStore.(*store.DefaultStore); ok { - defaultStore.SetStateHistoryRetention(conf.Node.StateHistoryRetention) - } // Wrap with cached store for LRU caching of headers cachedStore, err := store.NewCachedStore(baseStore) diff --git a/pkg/store/batch.go b/pkg/store/batch.go index e738812fdc..405119c612 100644 --- a/pkg/store/batch.go +++ b/pkg/store/batch.go @@ -3,7 +3,6 @@ package store import ( "context" "crypto/sha256" - "errors" "fmt" ds "github.com/ipfs/go-datastore" @@ -94,18 +93,7 @@ func (b *DefaultBatch) UpdateState(state types.State) error { return fmt.Errorf("failed to marshal state to protobuf: %w", err) } - if err := b.batch.Put(b.ctx, ds.NewKey(getStateAtHeightKey(height)), data); err != nil { - return err - } - - if b.store.stateHistoryRetention > 0 && height > b.store.stateHistoryRetention { - pruneHeight := height - b.store.stateHistoryRetention - if err := b.batch.Delete(b.ctx, ds.NewKey(getStateAtHeightKey(pruneHeight))); err != nil && !errors.Is(err, ds.ErrNotFound) { - return fmt.Errorf("failed to prune state at height %d: %w", pruneHeight, err) - } - } - - return nil + return b.batch.Put(b.ctx, ds.NewKey(getStateAtHeightKey(height)), data) } // Commit commits all batched operations atomically diff --git a/pkg/store/cached_store.go b/pkg/store/cached_store.go index e59d4e28c2..9c003a1ba1 100644 --- a/pkg/store/cached_store.go +++ b/pkg/store/cached_store.go @@ -2,6 +2,7 @@ package store import ( "context" + "fmt" lru "github.com/hashicorp/golang-lru/v2" @@ -157,6 +158,18 @@ func (cs *CachedStore) Rollback(ctx context.Context, height uint64, aggregator b return nil } +// DeleteStateAtHeight removes the state entry at the given height from the underlying store. +func (cs *CachedStore) DeleteStateAtHeight(ctx context.Context, height uint64) error { + deleter, ok := cs.Store.(interface { + DeleteStateAtHeight(ctx context.Context, height uint64) error + }) + if !ok { + return fmt.Errorf("underlying store does not support state deletion") + } + + return deleter.DeleteStateAtHeight(ctx, height) +} + // Close closes the underlying store. func (cs *CachedStore) Close() error { cs.ClearCache() diff --git a/pkg/store/store.go b/pkg/store/store.go index 5a89815162..387e83e293 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -18,8 +18,7 @@ import ( // DefaultStore is a default store implementation. type DefaultStore struct { - db ds.Batching - stateHistoryRetention uint64 + db ds.Batching } var _ Store = &DefaultStore{} @@ -31,10 +30,12 @@ func New(ds ds.Batching) Store { } } -// SetStateHistoryRetention sets the number of recent state entries to keep. -// A value of 0 keeps all state history. -func (s *DefaultStore) SetStateHistoryRetention(limit uint64) { - s.stateHistoryRetention = limit +// DeleteStateAtHeight removes the state entry at the given height. +func (s *DefaultStore) DeleteStateAtHeight(ctx context.Context, height uint64) error { + if err := s.db.Delete(ctx, ds.NewKey(getStateAtHeightKey(height))); err != nil && !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete state at height %d: %w", height, err) + } + return nil } // Close safely closes underlying data storage, to ensure that data is actually saved. diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go index dd36e8d50b..1f6d71aaba 100644 --- a/pkg/store/store_test.go +++ b/pkg/store/store_test.go @@ -591,7 +591,7 @@ func TestUpdateStateError(t *testing.T) { require.Contains(err.Error(), mockErrPut.Error()) } -func TestUpdateStatePrunesHistory(t *testing.T) { +func TestDeleteStateAtHeight(t *testing.T) { t.Parallel() require := require.New(t) @@ -599,26 +599,17 @@ func TestUpdateStatePrunesHistory(t *testing.T) { require.NoError(err) store := New(kv) - store.(*DefaultStore).SetStateHistoryRetention(2) - for height := uint64(1); height <= 3; height++ { - batch, err := store.NewBatch(t.Context()) - require.NoError(err) - require.NoError(batch.SetHeight(height)) - require.NoError(batch.UpdateState(types.State{LastBlockHeight: height})) - require.NoError(batch.Commit()) - } + batch, err := store.NewBatch(t.Context()) + require.NoError(err) + require.NoError(batch.SetHeight(1)) + require.NoError(batch.UpdateState(types.State{LastBlockHeight: 1})) + require.NoError(batch.Commit()) + + require.NoError(store.(*DefaultStore).DeleteStateAtHeight(t.Context(), 1)) _, err = store.GetStateAtHeight(t.Context(), 1) require.ErrorIs(err, ds.ErrNotFound) - - state, err := store.GetStateAtHeight(t.Context(), 2) - require.NoError(err) - require.Equal(uint64(2), state.LastBlockHeight) - - state, err = store.GetStateAtHeight(t.Context(), 3) - require.NoError(err) - require.Equal(uint64(3), state.LastBlockHeight) } func TestGetStateError(t *testing.T) { diff --git a/pkg/store/tracing.go b/pkg/store/tracing.go index 33a21f93ad..a6cca2b93f 100644 --- a/pkg/store/tracing.go +++ b/pkg/store/tracing.go @@ -3,6 +3,7 @@ package store import ( "context" "encoding/hex" + "fmt" ds "github.com/ipfs/go-datastore" "go.opentelemetry.io/otel" @@ -228,6 +229,32 @@ func (t *tracedStore) DeleteMetadata(ctx context.Context, key string) error { return nil } +// DeleteStateAtHeight removes the state entry at the given height from the underlying store. +func (t *tracedStore) DeleteStateAtHeight(ctx context.Context, height uint64) error { + ctx, span := t.tracer.Start(ctx, "Store.DeleteStateAtHeight", + trace.WithAttributes(attribute.Int64("height", int64(height))), + ) + defer span.End() + + deleter, ok := t.inner.(interface { + DeleteStateAtHeight(ctx context.Context, height uint64) error + }) + if !ok { + err := fmt.Errorf("underlying store does not support state deletion") + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + + if err := deleter.DeleteStateAtHeight(ctx, height); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + + return nil +} + func (t *tracedStore) Rollback(ctx context.Context, height uint64, aggregator bool) error { ctx, span := t.tracer.Start(ctx, "Store.Rollback", trace.WithAttributes( From 24732a9d8a3506f9793c224a2eef82496162f9d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:24:48 +0000 Subject: [PATCH 05/16] Address pruner review feedback Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- block/internal/pruner/pruner.go | 6 +++--- execution/evm/store_test.go | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go index 60f4add34f..b6f03f1140 100644 --- a/block/internal/pruner/pruner.go +++ b/block/internal/pruner/pruner.go @@ -41,7 +41,7 @@ type Pruner struct { } // New creates a new Pruner instance. -func New(store store.Store, execPruner ExecMetaPruner, retention uint64, interval time.Duration, logger zerolog.Logger) *Pruner { +func New(store store.Store, execMetaPruner ExecMetaPruner, retention uint64, interval time.Duration, logger zerolog.Logger) *Pruner { if interval <= 0 { interval = DefaultPruneInterval } @@ -56,7 +56,7 @@ func New(store store.Store, execPruner ExecMetaPruner, retention uint64, interva return &Pruner{ store: store, stateDeleter: deleter, - execPruner: execPruner, + execPruner: execMetaPruner, retention: retention, interval: interval, logger: logger, @@ -126,7 +126,7 @@ func (p *Pruner) pruneOnce(ctx context.Context) error { target := height - p.retention if target < p.lastPruned { - p.lastPruned = target + p.lastPruned = 0 return nil } if target == p.lastPruned { diff --git a/execution/evm/store_test.go b/execution/evm/store_test.go index 1c6b3dc5fd..68fd306955 100644 --- a/execution/evm/store_test.go +++ b/execution/evm/store_test.go @@ -25,6 +25,4 @@ func TestDeleteExecMeta(t *testing.T) { meta, err := store.GetExecMeta(ctx, 1) require.NoError(t, err) require.Nil(t, meta) - - require.NoError(t, store.DeleteExecMeta(ctx, 1)) } From 997c14941e758bfdb4f2312d9b767bcb768690fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:29:10 +0000 Subject: [PATCH 06/16] Update evm test module dependency Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- execution/evm/test/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/evm/test/go.mod b/execution/evm/test/go.mod index 1eaa13d1d7..744c263775 100644 --- a/execution/evm/test/go.mod +++ b/execution/evm/test/go.mod @@ -8,6 +8,7 @@ require ( github.com/evstack/ev-node/execution/evm v0.0.0-00010101000000-000000000000 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/ipfs/go-datastore v0.9.0 + github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 ) @@ -147,7 +148,6 @@ require ( github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/rs/zerolog v1.34.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sasha-s/go-deadlock v0.3.5 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect From 80e43177165b2fbbb3fe41b67daac8cc880c2b03 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:30:44 +0000 Subject: [PATCH 07/16] Address code review feedback Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- block/internal/pruner/pruner.go | 1 - execution/evm/execution.go | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go index b6f03f1140..019e6e4347 100644 --- a/block/internal/pruner/pruner.go +++ b/block/internal/pruner/pruner.go @@ -126,7 +126,6 @@ func (p *Pruner) pruneOnce(ctx context.Context) error { target := height - p.retention if target < p.lastPruned { - p.lastPruned = 0 return nil } if target == p.lastPruned { diff --git a/execution/evm/execution.go b/execution/evm/execution.go index 675afe26c5..f6f9e82097 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -267,6 +267,7 @@ func NewEngineExecutionClient( } // PruneExecMeta removes execution metadata at the given height. +// It is used by the block pruner to delete historical exec meta entries. func (c *EngineClient) PruneExecMeta(ctx context.Context, height uint64) error { if c.store == nil { return nil From 966cfc721f2d1e31e7100fdf2f5e71d449411a30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:31:44 +0000 Subject: [PATCH 08/16] Refine pruner checks and docs Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- block/internal/pruner/pruner.go | 5 +---- execution/evm/execution.go | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go index 019e6e4347..12718b73de 100644 --- a/block/internal/pruner/pruner.go +++ b/block/internal/pruner/pruner.go @@ -125,10 +125,7 @@ func (p *Pruner) pruneOnce(ctx context.Context) error { } target := height - p.retention - if target < p.lastPruned { - return nil - } - if target == p.lastPruned { + if target <= p.lastPruned { return nil } diff --git a/execution/evm/execution.go b/execution/evm/execution.go index f6f9e82097..1239ff5676 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -268,6 +268,7 @@ func NewEngineExecutionClient( // PruneExecMeta removes execution metadata at the given height. // It is used by the block pruner to delete historical exec meta entries. +// Returns nil if no store is configured. func (c *EngineClient) PruneExecMeta(ctx context.Context, height uint64) error { if c.store == nil { return nil From e69266179022c454027a5f34403dda7461792b5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:33:02 +0000 Subject: [PATCH 09/16] Clarify PruneExecMeta comment Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- execution/evm/execution.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/evm/execution.go b/execution/evm/execution.go index 1239ff5676..3d5896d0e3 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -268,7 +268,7 @@ func NewEngineExecutionClient( // PruneExecMeta removes execution metadata at the given height. // It is used by the block pruner to delete historical exec meta entries. -// Returns nil if no store is configured. +// Returns a nil error if no store is configured. func (c *EngineClient) PruneExecMeta(ctx context.Context, height uint64) error { if c.store == nil { return nil From 8395e0a798acbe83d9a1fc013b65c44a4c15b3b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:08:03 +0000 Subject: [PATCH 10/16] Rename recovery history setting Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- block/components.go | 4 ++-- pkg/config/config.go | 8 ++++---- pkg/config/config_test.go | 4 ++-- pkg/config/defaults.go | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/block/components.go b/block/components.go index 303bed07ab..5aa38c03ed 100644 --- a/block/components.go +++ b/block/components.go @@ -184,7 +184,7 @@ func NewSyncComponents( execPruner = candidate } } - recoveryPruner := pruner.New(store, execPruner, config.Node.StateHistoryRetention, pruner.DefaultPruneInterval, logger.With().Str("component", "Pruner").Logger()) + recoveryPruner := pruner.New(store, execPruner, config.Node.RecoveryHistoryDepth, pruner.DefaultPruneInterval, logger.With().Str("component", "Pruner").Logger()) // Create submitter for sync nodes (no signer, only DA inclusion processing) var daSubmitter submitting.DASubmitterAPI = submitting.NewDASubmitter(daClient, config, genesis, blockOpts, metrics, logger, headerDAHintAppender, dataDAHintAppender) @@ -275,7 +275,7 @@ func NewAggregatorComponents( execPruner = candidate } } - recoveryPruner := pruner.New(store, execPruner, config.Node.StateHistoryRetention, pruner.DefaultPruneInterval, logger.With().Str("component", "Pruner").Logger()) + recoveryPruner := pruner.New(store, execPruner, config.Node.RecoveryHistoryDepth, pruner.DefaultPruneInterval, logger.With().Str("component", "Pruner").Logger()) reaper, err := reaping.NewReaper( exec, diff --git a/pkg/config/config.go b/pkg/config/config.go index 744239b4a4..67242a71f9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -51,8 +51,8 @@ const ( FlagReadinessMaxBlocksBehind = FlagPrefixEvnode + "node.readiness_max_blocks_behind" // FlagScrapeInterval is a flag for specifying the reaper scrape interval FlagScrapeInterval = FlagPrefixEvnode + "node.scrape_interval" - // FlagStateHistoryRetention is a flag for specifying how much state/exec metadata history to keep - FlagStateHistoryRetention = FlagPrefixEvnode + "node.state_history_retention" + // FlagRecoveryHistoryDepth is a flag for specifying how much recovery history to keep + FlagRecoveryHistoryDepth = FlagPrefixEvnode + "node.recovery_history_depth" // FlagClearCache is a flag for clearing the cache FlagClearCache = FlagPrefixEvnode + "clear_cache" @@ -259,7 +259,7 @@ type NodeConfig struct { LazyMode bool `mapstructure:"lazy_mode" yaml:"lazy_mode" comment:"Enables lazy aggregation mode, where blocks are only produced when transactions are available or after LazyBlockTime. Optimizes resources by avoiding empty block creation during periods of inactivity."` LazyBlockInterval DurationWrapper `mapstructure:"lazy_block_interval" yaml:"lazy_block_interval" comment:"Maximum interval between blocks in lazy aggregation mode (LazyAggregator). Ensures blocks are produced periodically even without transactions to keep the chain active. Generally larger than BlockTime."` ScrapeInterval DurationWrapper `mapstructure:"scrape_interval" yaml:"scrape_interval" comment:"Interval at which the reaper polls the execution layer for new transactions. Lower values reduce transaction detection latency but increase RPC load. Examples: \"250ms\", \"500ms\", \"1s\"."` - StateHistoryRetention uint64 `mapstructure:"state_history_retention" yaml:"state_history_retention" comment:"Number of recent heights to keep state and execution metadata for recovery (0 keeps all)."` + RecoveryHistoryDepth uint64 `mapstructure:"recovery_history_depth" yaml:"recovery_history_depth" comment:"Number of recent heights to keep state and execution metadata indexed for recovery (0 keeps all)."` // Readiness / health configuration ReadinessWindowSeconds uint64 `mapstructure:"readiness_window_seconds" yaml:"readiness_window_seconds" comment:"Time window in seconds used to calculate ReadinessMaxBlocksBehind based on block time. Default: 15 seconds."` @@ -439,7 +439,7 @@ func AddFlags(cmd *cobra.Command) { cmd.Flags().Uint64(FlagReadinessWindowSeconds, def.Node.ReadinessWindowSeconds, "time window in seconds for calculating readiness threshold based on block time (default: 15s)") cmd.Flags().Uint64(FlagReadinessMaxBlocksBehind, def.Node.ReadinessMaxBlocksBehind, "how many blocks behind best-known head the node can be and still be considered ready (0 = must be at head)") cmd.Flags().Duration(FlagScrapeInterval, def.Node.ScrapeInterval.Duration, "interval at which the reaper polls the execution layer for new transactions") - cmd.Flags().Uint64(FlagStateHistoryRetention, def.Node.StateHistoryRetention, "number of recent heights to keep state and execution metadata for recovery (0 keeps all)") + cmd.Flags().Uint64(FlagRecoveryHistoryDepth, def.Node.RecoveryHistoryDepth, "number of recent heights to keep state and execution metadata indexed for recovery (0 keeps all)") // Data Availability configuration flags cmd.Flags().String(FlagDAAddress, def.DA.Address, "DA address (host:port)") diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 03d84e34c4..5f951172e9 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -33,7 +33,7 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, uint64(0), def.Node.MaxPendingHeadersAndData) assert.Equal(t, false, def.Node.LazyMode) assert.Equal(t, 60*time.Second, def.Node.LazyBlockInterval.Duration) - assert.Equal(t, uint64(5000), def.Node.StateHistoryRetention) + assert.Equal(t, uint64(5000), def.Node.RecoveryHistoryDepth) assert.Equal(t, "file", def.Signer.SignerType) assert.Equal(t, "config", def.Signer.SignerPath) assert.Equal(t, "127.0.0.1:7331", def.RPC.Address) @@ -65,7 +65,7 @@ func TestAddFlags(t *testing.T) { assertFlagValue(t, flags, FlagReadinessWindowSeconds, DefaultConfig().Node.ReadinessWindowSeconds) assertFlagValue(t, flags, FlagReadinessMaxBlocksBehind, DefaultConfig().Node.ReadinessMaxBlocksBehind) assertFlagValue(t, flags, FlagScrapeInterval, DefaultConfig().Node.ScrapeInterval) - assertFlagValue(t, flags, FlagStateHistoryRetention, DefaultConfig().Node.StateHistoryRetention) + assertFlagValue(t, flags, FlagRecoveryHistoryDepth, DefaultConfig().Node.RecoveryHistoryDepth) // DA flags assertFlagValue(t, flags, FlagDAAddress, DefaultConfig().DA.Address) diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index b74ea95933..10d4c1e5bd 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -17,7 +17,7 @@ const ( // AppConfigDir is the directory name for the app configuration. AppConfigDir = "config" - defaultStateHistoryRetention = uint64(5000) + defaultRecoveryHistoryDepth = uint64(5000) ) // DefaultRootDir returns the default root directory for evolve @@ -68,7 +68,7 @@ func DefaultConfig() Config { LazyMode: false, LazyBlockInterval: DurationWrapper{60 * time.Second}, Light: false, - StateHistoryRetention: defaultStateHistoryRetention, + RecoveryHistoryDepth: defaultRecoveryHistoryDepth, ReadinessWindowSeconds: defaultReadinessWindowSeconds, ReadinessMaxBlocksBehind: calculateReadinessMaxBlocksBehind(defaultBlockTime.Duration, defaultReadinessWindowSeconds), ScrapeInterval: DurationWrapper{1 * time.Second}, From 394ecc5f621d943210b6f3b995f76c685c16e02f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:15:42 +0000 Subject: [PATCH 11/16] Adjust pruner interval and defaults Co-authored-by: julienrbrt <29894366+julienrbrt@users.noreply.github.com> --- block/internal/pruner/pruner.go | 7 ++++--- pkg/config/config_test.go | 2 +- pkg/config/defaults.go | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go index 12718b73de..90c2f24a10 100644 --- a/block/internal/pruner/pruner.go +++ b/block/internal/pruner/pruner.go @@ -13,8 +13,9 @@ import ( ) const ( - DefaultPruneInterval = time.Minute - maxPruneBatch = uint64(1000) + DefaultPruneInterval = 15 * time.Minute + // maxPruneBatch limits how many heights we prune per cycle to bound work. + maxPruneBatch = uint64(1000) ) // ExecMetaPruner removes execution metadata at a given height. @@ -65,7 +66,7 @@ func New(store store.Store, execMetaPruner ExecMetaPruner, retention uint64, int // Start begins the pruning loop. func (p *Pruner) Start(ctx context.Context) error { - if p == nil || p.retention == 0 || (p.stateDeleter == nil && p.execPruner == nil) { + if p.retention == 0 { return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 5f951172e9..0c881dd700 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -33,7 +33,7 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, uint64(0), def.Node.MaxPendingHeadersAndData) assert.Equal(t, false, def.Node.LazyMode) assert.Equal(t, 60*time.Second, def.Node.LazyBlockInterval.Duration) - assert.Equal(t, uint64(5000), def.Node.RecoveryHistoryDepth) + assert.Equal(t, uint64(0), def.Node.RecoveryHistoryDepth) assert.Equal(t, "file", def.Signer.SignerType) assert.Equal(t, "config", def.Signer.SignerPath) assert.Equal(t, "127.0.0.1:7331", def.RPC.Address) diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 10d4c1e5bd..67c8fa34cd 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -17,7 +17,7 @@ const ( // AppConfigDir is the directory name for the app configuration. AppConfigDir = "config" - defaultRecoveryHistoryDepth = uint64(5000) + defaultRecoveryHistoryDepth = uint64(0) ) // DefaultRootDir returns the default root directory for evolve From 65592e8852e1e3169149d1fc7fc85efe495a74ec Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 12 Feb 2026 12:33:05 +0100 Subject: [PATCH 12/16] fixes --- block/components.go | 26 ++++----- block/internal/pruner/pruner.go | 84 +++++++++++++--------------- block/internal/pruner/pruner_test.go | 8 +-- pkg/store/store.go | 16 +++--- 4 files changed, 62 insertions(+), 72 deletions(-) diff --git a/block/components.go b/block/components.go index 5aa38c03ed..a15d5be341 100644 --- a/block/components.go +++ b/block/components.go @@ -178,13 +178,11 @@ func NewSyncComponents( syncer.SetBlockSyncer(syncing.WithTracingBlockSyncer(syncer)) } - var execPruner pruner.ExecMetaPruner - if exec != nil { - if candidate, ok := exec.(pruner.ExecMetaPruner); ok { - execPruner = candidate - } + var execPruner pruner.ExecPruner + if p, ok := exec.(pruner.ExecPruner); ok { + execPruner = p } - recoveryPruner := pruner.New(store, execPruner, config.Node.RecoveryHistoryDepth, pruner.DefaultPruneInterval, logger.With().Str("component", "Pruner").Logger()) + pruner := pruner.New(logger, store, execPruner, config.Node) // Create submitter for sync nodes (no signer, only DA inclusion processing) var daSubmitter submitting.DASubmitterAPI = submitting.NewDASubmitter(daClient, config, genesis, blockOpts, metrics, logger, headerDAHintAppender, dataDAHintAppender) @@ -209,7 +207,7 @@ func NewSyncComponents( Syncer: syncer, Submitter: submitter, Cache: cacheManager, - Pruner: recoveryPruner, + Pruner: pruner, errorCh: errorCh, }, nil } @@ -269,13 +267,11 @@ func NewAggregatorComponents( executor.SetBlockProducer(executing.WithTracingBlockProducer(executor)) } - var execPruner pruner.ExecMetaPruner - if exec != nil { - if candidate, ok := exec.(pruner.ExecMetaPruner); ok { - execPruner = candidate - } + var execPruner pruner.ExecPruner + if p, ok := exec.(pruner.ExecPruner); ok { + execPruner = p } - recoveryPruner := pruner.New(store, execPruner, config.Node.RecoveryHistoryDepth, pruner.DefaultPruneInterval, logger.With().Str("component", "Pruner").Logger()) + pruner := pruner.New(logger, store, execPruner, config.Node) reaper, err := reaping.NewReaper( exec, @@ -293,7 +289,7 @@ func NewAggregatorComponents( if config.Node.BasedSequencer { // no submissions needed for bases sequencer return &Components{ Executor: executor, - Pruner: recoveryPruner, + Pruner: pruner, Reaper: reaper, Cache: cacheManager, errorCh: errorCh, @@ -320,7 +316,7 @@ func NewAggregatorComponents( return &Components{ Executor: executor, - Pruner: recoveryPruner, + Pruner: pruner, Reaper: reaper, Submitter: submitter, Cache: cacheManager, diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go index 90c2f24a10..0de32a44d3 100644 --- a/block/internal/pruner/pruner.go +++ b/block/internal/pruner/pruner.go @@ -9,18 +9,19 @@ import ( ds "github.com/ipfs/go-datastore" "github.com/rs/zerolog" + "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/store" ) const ( - DefaultPruneInterval = 15 * time.Minute + defaultPruneInterval = 15 * time.Minute // maxPruneBatch limits how many heights we prune per cycle to bound work. maxPruneBatch = uint64(1000) ) -// ExecMetaPruner removes execution metadata at a given height. -type ExecMetaPruner interface { - PruneExecMeta(ctx context.Context, height uint64) error +// ExecPruner removes execution metadata at a given height. +type ExecPruner interface { + PruneExec(ctx context.Context, height uint64) error } type stateDeleter interface { @@ -31,22 +32,24 @@ type stateDeleter interface { type Pruner struct { store store.Store stateDeleter stateDeleter - execPruner ExecMetaPruner - retention uint64 - interval time.Duration + execPruner ExecPruner + cfg config.NodeConfig logger zerolog.Logger lastPruned uint64 + // Lifecycle + ctx context.Context wg sync.WaitGroup cancel context.CancelFunc } // New creates a new Pruner instance. -func New(store store.Store, execMetaPruner ExecMetaPruner, retention uint64, interval time.Duration, logger zerolog.Logger) *Pruner { - if interval <= 0 { - interval = DefaultPruneInterval - } - +func New( + logger zerolog.Logger, + store store.Store, + execPruner ExecPruner, + cfg config.NodeConfig, +) *Pruner { var deleter stateDeleter if store != nil { if sd, ok := store.(stateDeleter); ok { @@ -57,75 +60,66 @@ func New(store store.Store, execMetaPruner ExecMetaPruner, retention uint64, int return &Pruner{ store: store, stateDeleter: deleter, - execPruner: execMetaPruner, - retention: retention, - interval: interval, - logger: logger, + execPruner: execPruner, + cfg: cfg, + logger: logger.With().Str("component", "prune").Logger(), } } // Start begins the pruning loop. func (p *Pruner) Start(ctx context.Context) error { - if p.retention == 0 { - return nil - } + p.ctx, p.cancel = context.WithCancel(ctx) - loopCtx, cancel := context.WithCancel(ctx) - p.cancel = cancel - - p.wg.Add(1) - go p.pruneLoop(loopCtx) + // Start pruner loop + p.wg.Go(p.pruneLoop) + p.logger.Info().Msg("pruner started") return nil } // Stop stops the pruning loop. func (p *Pruner) Stop() error { - if p == nil || p.cancel == nil { - return nil + if p.cancel != nil { + p.cancel() } - - p.cancel() p.wg.Wait() + + p.logger.Info().Msg("pruner stopped") return nil } -func (p *Pruner) pruneLoop(ctx context.Context) { - defer p.wg.Done() - ticker := time.NewTicker(p.interval) +func (p *Pruner) pruneLoop() { + ticker := time.NewTicker(defaultPruneInterval) defer ticker.Stop() - if err := p.pruneOnce(ctx); err != nil { - p.logger.Error().Err(err).Msg("failed to prune recovery history") - } - for { select { case <-ticker.C: - if err := p.pruneOnce(ctx); err != nil { + if err := p.pruneRecoveryHistory(p.ctx, p.cfg.RecoveryHistoryDepth); err != nil { p.logger.Error().Err(err).Msg("failed to prune recovery history") } - case <-ctx.Done(): + + // TODO: add pruning of old blocks // https://github.com/evstack/ev-node/pull/2984 + case <-p.ctx.Done(): return } } } -func (p *Pruner) pruneOnce(ctx context.Context) error { - if p.retention == 0 || p.store == nil { - return nil - } - +// pruneRecoveryHistory prunes old state and execution metadata entries based on the configured retention depth. +// It does not prunes old blocks, as those are handled by the pruning logic. +// Pruning old state does not lose history but limit the ability to recover (replay or rollback) to the last HEAD-N blocks, where N is the retention depth. +func (p *Pruner) pruneRecoveryHistory(ctx context.Context, retention uint64) error { height, err := p.store.Height(ctx) if err != nil { return err } - if height <= p.retention { + if height <= retention { return nil } - target := height - p.retention + target := height - retention if target <= p.lastPruned { return nil } @@ -143,7 +137,7 @@ func (p *Pruner) pruneOnce(ctx context.Context) error { } } if p.execPruner != nil { - if err := p.execPruner.PruneExecMeta(ctx, h); err != nil && !errors.Is(err, ds.ErrNotFound) { + if err := p.execPruner.PruneExec(ctx, h); err != nil && !errors.Is(err, ds.ErrNotFound) { return err } } diff --git a/block/internal/pruner/pruner_test.go b/block/internal/pruner/pruner_test.go index 874e22b76c..fbaa04d029 100644 --- a/block/internal/pruner/pruner_test.go +++ b/block/internal/pruner/pruner_test.go @@ -3,13 +3,13 @@ package pruner import ( "context" "testing" - "time" ds "github.com/ipfs/go-datastore" dssync "github.com/ipfs/go-datastore/sync" "github.com/rs/zerolog" "github.com/stretchr/testify/require" + "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/store" "github.com/evstack/ev-node/types" ) @@ -18,7 +18,7 @@ type execMetaAdapter struct { existing map[uint64]struct{} } -func (e *execMetaAdapter) PruneExecMeta(ctx context.Context, height uint64) error { +func (e *execMetaAdapter) PruneExec(ctx context.Context, height uint64) error { delete(e.existing, height) return nil } @@ -40,8 +40,8 @@ func TestPrunerPrunesRecoveryHistory(t *testing.T) { execAdapter := &execMetaAdapter{existing: map[uint64]struct{}{1: {}, 2: {}, 3: {}}} - recoveryPruner := New(stateStore, execAdapter, 2, time.Minute, zerolog.Nop()) - require.NoError(t, recoveryPruner.pruneOnce(ctx)) + recoveryPruner := New(zerolog.Nop(), stateStore, execAdapter, config.NodeConfig{RecoveryHistoryDepth: 2}) + require.NoError(t, recoveryPruner.pruneRecoveryHistory(ctx, recoveryPruner.cfg.RecoveryHistoryDepth)) _, err := stateStore.GetStateAtHeight(ctx, 1) require.ErrorIs(t, err, ds.ErrNotFound) diff --git a/pkg/store/store.go b/pkg/store/store.go index 387e83e293..bc428a45d4 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -30,14 +30,6 @@ func New(ds ds.Batching) Store { } } -// DeleteStateAtHeight removes the state entry at the given height. -func (s *DefaultStore) DeleteStateAtHeight(ctx context.Context, height uint64) error { - if err := s.db.Delete(ctx, ds.NewKey(getStateAtHeightKey(height))); err != nil && !errors.Is(err, ds.ErrNotFound) { - return fmt.Errorf("failed to delete state at height %d: %w", height, err) - } - return nil -} - // Close safely closes underlying data storage, to ensure that data is actually saved. func (s *DefaultStore) Close() error { return s.db.Close() @@ -180,6 +172,14 @@ func (s *DefaultStore) GetStateAtHeight(ctx context.Context, height uint64) (typ return state, nil } +// DeleteStateAtHeight removes the state entry at the given height. +func (s *DefaultStore) DeleteStateAtHeight(ctx context.Context, height uint64) error { + if err := s.db.Delete(ctx, ds.NewKey(getStateAtHeightKey(height))); err != nil && !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete state at height %d: %w", height, err) + } + return nil +} + // SetMetadata saves arbitrary value in the store. // // Metadata is separated from other data by using prefix in KV. From 03b9ad86864de7184daf38c262a6804937edca1b Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 12 Feb 2026 13:26:01 +0100 Subject: [PATCH 13/16] updates --- block/internal/pruner/pruner.go | 19 ++++++++++++++----- block/internal/pruner/pruner_test.go | 3 ++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go index 0de32a44d3..565aaabda7 100644 --- a/block/internal/pruner/pruner.go +++ b/block/internal/pruner/pruner.go @@ -13,11 +13,7 @@ import ( "github.com/evstack/ev-node/pkg/store" ) -const ( - defaultPruneInterval = 15 * time.Minute - // maxPruneBatch limits how many heights we prune per cycle to bound work. - maxPruneBatch = uint64(1000) -) +const defaultPruneInterval = 15 * time.Minute // ExecPruner removes execution metadata at a given height. type ExecPruner interface { @@ -110,6 +106,10 @@ func (p *Pruner) pruneLoop() { // It does not prunes old blocks, as those are handled by the pruning logic. // Pruning old state does not lose history but limit the ability to recover (replay or rollback) to the last HEAD-N blocks, where N is the retention depth. func (p *Pruner) pruneRecoveryHistory(ctx context.Context, retention uint64) error { + if p.cfg.RecoveryHistoryDepth == 0 { + return nil + } + height, err := p.store.Height(ctx) if err != nil { return err @@ -124,6 +124,15 @@ func (p *Pruner) pruneRecoveryHistory(ctx context.Context, retention uint64) err return nil } + // maxPruneBatch limits how many heights we prune per cycle to bound work. + // it is callibrated to prune the last N blocks in one cycle, where N is the number of blocks produced in the defaultPruneInterval. + blockTime := p.cfg.BlockTime.Duration + if blockTime == 0 { + blockTime = 1 + } + + maxPruneBatch := max(uint64(defaultPruneInterval/blockTime), (target-p.lastPruned)/5) + start := p.lastPruned + 1 end := target if end-start+1 > maxPruneBatch { diff --git a/block/internal/pruner/pruner_test.go b/block/internal/pruner/pruner_test.go index fbaa04d029..2d6b73d406 100644 --- a/block/internal/pruner/pruner_test.go +++ b/block/internal/pruner/pruner_test.go @@ -3,6 +3,7 @@ package pruner import ( "context" "testing" + "time" ds "github.com/ipfs/go-datastore" dssync "github.com/ipfs/go-datastore/sync" @@ -40,7 +41,7 @@ func TestPrunerPrunesRecoveryHistory(t *testing.T) { execAdapter := &execMetaAdapter{existing: map[uint64]struct{}{1: {}, 2: {}, 3: {}}} - recoveryPruner := New(zerolog.Nop(), stateStore, execAdapter, config.NodeConfig{RecoveryHistoryDepth: 2}) + recoveryPruner := New(zerolog.Nop(), stateStore, execAdapter, config.NodeConfig{RecoveryHistoryDepth: 2, BlockTime: config.DurationWrapper{Duration: 10 * time.Second}}) require.NoError(t, recoveryPruner.pruneRecoveryHistory(ctx, recoveryPruner.cfg.RecoveryHistoryDepth)) _, err := stateStore.GetStateAtHeight(ctx, 1) From 96793e4568351edb272b18150277c24ec12ee926 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 12 Feb 2026 15:24:45 +0100 Subject: [PATCH 14/16] updates --- apps/evm/go.mod | 10 +-- block/components.go | 8 +-- block/internal/pruner/pruner.go | 47 ++++-------- execution/evm/execution.go | 17 ++--- execution/evm/store.go | 47 ++++++++++-- execution/evm/store_test.go | 18 ----- pkg/store/tracing.go | 13 +--- pkg/store/tracing_test.go | 14 +++- pkg/store/types.go | 4 ++ test/mocks/store.go | 122 +++++++++++++++++++++++++++++--- 10 files changed, 205 insertions(+), 95 deletions(-) diff --git a/apps/evm/go.mod b/apps/evm/go.mod index eae857f007..548f247167 100644 --- a/apps/evm/go.mod +++ b/apps/evm/go.mod @@ -2,7 +2,11 @@ module github.com/evstack/ev-node/apps/evm go 1.25.6 -replace github.com/evstack/ev-node/core => ../../core +replace ( + github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core + github.com/evstack/ev-node/execution/evm => ../../execution/evm +) require ( github.com/ethereum/go-ethereum v1.16.8 @@ -219,7 +223,3 @@ replace ( google.golang.org/genproto/googleapis/api => google.golang.org/genproto/googleapis/api v0.0.0-20240213162025-012b6fc9bca9 google.golang.org/genproto/googleapis/rpc => google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 ) - -replace github.com/evstack/ev-node => ../../ - -replace github.com/evstack/ev-node/execution/evm => ../../execution/evm diff --git a/block/components.go b/block/components.go index a15d5be341..2956a98d81 100644 --- a/block/components.go +++ b/block/components.go @@ -178,8 +178,8 @@ func NewSyncComponents( syncer.SetBlockSyncer(syncing.WithTracingBlockSyncer(syncer)) } - var execPruner pruner.ExecPruner - if p, ok := exec.(pruner.ExecPruner); ok { + var execPruner coreexecutor.ExecPruner + if p, ok := exec.(coreexecutor.ExecPruner); ok { execPruner = p } pruner := pruner.New(logger, store, execPruner, config.Node) @@ -267,8 +267,8 @@ func NewAggregatorComponents( executor.SetBlockProducer(executing.WithTracingBlockProducer(executor)) } - var execPruner pruner.ExecPruner - if p, ok := exec.(pruner.ExecPruner); ok { + var execPruner coreexecutor.ExecPruner + if p, ok := exec.(coreexecutor.ExecPruner); ok { execPruner = p } pruner := pruner.New(logger, store, execPruner, config.Node) diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go index 565aaabda7..2a54da6137 100644 --- a/block/internal/pruner/pruner.go +++ b/block/internal/pruner/pruner.go @@ -9,29 +9,21 @@ import ( ds "github.com/ipfs/go-datastore" "github.com/rs/zerolog" + coreexecutor "github.com/evstack/ev-node/core/execution" + "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/store" ) const defaultPruneInterval = 15 * time.Minute -// ExecPruner removes execution metadata at a given height. -type ExecPruner interface { - PruneExec(ctx context.Context, height uint64) error -} - -type stateDeleter interface { - DeleteStateAtHeight(ctx context.Context, height uint64) error -} - // Pruner periodically removes old state and execution metadata entries. type Pruner struct { - store store.Store - stateDeleter stateDeleter - execPruner ExecPruner - cfg config.NodeConfig - logger zerolog.Logger - lastPruned uint64 + store store.Store + execPruner coreexecutor.ExecPruner + cfg config.NodeConfig + logger zerolog.Logger + lastPruned uint64 // Lifecycle ctx context.Context @@ -43,22 +35,14 @@ type Pruner struct { func New( logger zerolog.Logger, store store.Store, - execPruner ExecPruner, + execPruner coreexecutor.ExecPruner, cfg config.NodeConfig, ) *Pruner { - var deleter stateDeleter - if store != nil { - if sd, ok := store.(stateDeleter); ok { - deleter = sd - } - } - return &Pruner{ - store: store, - stateDeleter: deleter, - execPruner: execPruner, - cfg: cfg, - logger: logger.With().Str("component", "prune").Logger(), + store: store, + execPruner: execPruner, + cfg: cfg, + logger: logger.With().Str("component", "prune").Logger(), } } @@ -140,11 +124,10 @@ func (p *Pruner) pruneRecoveryHistory(ctx context.Context, retention uint64) err } for h := start; h <= end; h++ { - if p.stateDeleter != nil { - if err := p.stateDeleter.DeleteStateAtHeight(ctx, h); err != nil && !errors.Is(err, ds.ErrNotFound) { - return err - } + if err := p.store.DeleteStateAtHeight(ctx, h); err != nil && !errors.Is(err, ds.ErrNotFound) { + return err } + if p.execPruner != nil { if err := p.execPruner.PruneExec(ctx, h); err != nil && !errors.Is(err, ds.ErrNotFound) { return err diff --git a/execution/evm/execution.go b/execution/evm/execution.go index f04d93343d..a25fbd3567 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -270,16 +270,6 @@ func NewEngineExecutionClient( }, nil } -// PruneExecMeta removes execution metadata at the given height. -// It is used by the block pruner to delete historical exec meta entries. -// Returns a nil error if no store is configured. -func (c *EngineClient) PruneExecMeta(ctx context.Context, height uint64) error { - if c.store == nil { - return nil - } - return c.store.DeleteExecMeta(ctx, height) -} - // InitChain initializes the blockchain with the given genesis parameters func (c *EngineClient) InitChain(ctx context.Context, genesisTime time.Time, initialHeight uint64, chainID string) ([]byte, error) { if initialHeight != 1 { @@ -1102,6 +1092,13 @@ func (c *EngineClient) Rollback(ctx context.Context, targetHeight uint64) error return nil } +// PruneExec implements execution.ExecPruner by delegating to the +// underlying EVMStore. It is safe to call this multiple times with the same +// or increasing heights; the store tracks its own last-pruned height. +func (c *EngineClient) PruneExec(ctx context.Context, height uint64) error { + return c.store.PruneExec(ctx, height) +} + // decodeSecret decodes a hex-encoded JWT secret string into a byte slice. func decodeSecret(jwtSecret string) ([]byte, error) { secret, err := hex.DecodeString(strings.TrimPrefix(jwtSecret, "0x")) diff --git a/execution/evm/store.go b/execution/evm/store.go index eb8dc3b6bc..21fe5008ae 100644 --- a/execution/evm/store.go +++ b/execution/evm/store.go @@ -145,10 +145,49 @@ func (s *EVMStore) SaveExecMeta(ctx context.Context, meta *ExecMeta) error { return nil } -// DeleteExecMeta removes execution metadata for the given height. -func (s *EVMStore) DeleteExecMeta(ctx context.Context, height uint64) error { - if err := s.db.Delete(ctx, execMetaKey(height)); err != nil && !errors.Is(err, ds.ErrNotFound) { - return fmt.Errorf("failed to delete exec meta at height %d: %w", height, err) +// PruneExec removes ExecMeta entries up to and including the given height. +// It is safe to call this multiple times with the same or increasing heights; +// previously pruned ranges will be skipped based on the last-pruned marker. +func (s *EVMStore) PruneExec(ctx context.Context, height uint64) error { + // Load last pruned height, if any. + var lastPruned uint64 + data, err := s.db.Get(ctx, ds.NewKey(lastPrunedExecMetaKey)) + if err != nil { + if !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to get last pruned execmeta height: %w", err) + } + } else if len(data) == 8 { + lastPruned = binary.BigEndian.Uint64(data) + } + + // Nothing new to prune. + if height <= lastPruned { + return nil + } + + batch, err := s.db.Batch(ctx) + if err != nil { + return fmt.Errorf("failed to create batch for execmeta pruning: %w", err) + } + + for h := lastPruned + 1; h <= height; h++ { + key := execMetaKey(h) + if err := batch.Delete(ctx, key); err != nil { + if !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete exec meta at height %d: %w", h, err) + } + } + } + + // Persist updated last pruned height. + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, height) + if err := batch.Put(ctx, ds.NewKey(lastPrunedExecMetaKey), buf); err != nil { + return fmt.Errorf("failed to update last pruned execmeta height: %w", err) + } + + if err := batch.Commit(ctx); err != nil { + return fmt.Errorf("failed to commit execmeta pruning batch: %w", err) } return nil diff --git a/execution/evm/store_test.go b/execution/evm/store_test.go index 4f819e0781..d3067fc1a6 100644 --- a/execution/evm/store_test.go +++ b/execution/evm/store_test.go @@ -10,24 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestDeleteExecMeta(t *testing.T) { - t.Parallel() - - store := NewEVMStore(dssync.MutexWrap(ds.NewMapDatastore())) - - ctx := context.Background() - require.NoError(t, store.SaveExecMeta(ctx, &ExecMeta{ - Height: 1, - Stage: ExecStageStarted, - })) - - require.NoError(t, store.DeleteExecMeta(ctx, 1)) - - meta, err := store.GetExecMeta(ctx, 1) - require.NoError(t, err) - require.Nil(t, meta) -} - // newTestDatastore creates an in-memory datastore for testing. func newTestDatastore(t *testing.T) ds.Batching { t.Helper() diff --git a/pkg/store/tracing.go b/pkg/store/tracing.go index 0cbc086a0a..ea1d5c8842 100644 --- a/pkg/store/tracing.go +++ b/pkg/store/tracing.go @@ -3,7 +3,6 @@ package store import ( "context" "encoding/hex" - "fmt" ds "github.com/ipfs/go-datastore" "go.opentelemetry.io/otel" @@ -236,17 +235,7 @@ func (t *tracedStore) DeleteStateAtHeight(ctx context.Context, height uint64) er ) defer span.End() - deleter, ok := t.inner.(interface { - DeleteStateAtHeight(ctx context.Context, height uint64) error - }) - if !ok { - err := fmt.Errorf("underlying store does not support state deletion") - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - return err - } - - if err := deleter.DeleteStateAtHeight(ctx, height); err != nil { + if err := t.inner.DeleteStateAtHeight(ctx, height); err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return err diff --git a/pkg/store/tracing_test.go b/pkg/store/tracing_test.go index 5477a66985..1109791c76 100644 --- a/pkg/store/tracing_test.go +++ b/pkg/store/tracing_test.go @@ -30,6 +30,8 @@ type tracingMockStore struct { setMetadataFn func(ctx context.Context, key string, value []byte) error deleteMetadataFn func(ctx context.Context, key string) error rollbackFn func(ctx context.Context, height uint64, aggregator bool) error + pruneBlocksFn func(ctx context.Context, height uint64) error + deleteStateAtHeightFn func(ctx context.Context, height uint64) error newBatchFn func(ctx context.Context) (Batch, error) } @@ -125,8 +127,16 @@ func (m *tracingMockStore) Rollback(ctx context.Context, height uint64, aggregat } func (m *tracingMockStore) PruneBlocks(ctx context.Context, height uint64) error { - // For tracing tests we don't need pruning behavior; just satisfy the Store - // interface. Specific pruning behavior is tested separately in store_test.go. + if m.pruneBlocksFn != nil { + return m.pruneBlocksFn(ctx, height) + } + return nil +} + +func (m *tracingMockStore) DeleteStateAtHeight(ctx context.Context, height uint64) error { + if m.deleteStateAtHeightFn != nil { + return m.deleteStateAtHeightFn(ctx, height) + } return nil } diff --git a/pkg/store/types.go b/pkg/store/types.go index a5635feec1..f785850b2c 100644 --- a/pkg/store/types.go +++ b/pkg/store/types.go @@ -99,4 +99,8 @@ type Pruner interface { // up to and including the given height from the store, without modifying // state snapshots or the current chain height. PruneBlocks(ctx context.Context, height uint64) error + + // DeleteStateAtHeight removes the state at the given height from the store. + // It does not affect the current state or any states at other heights, allowing for targeted pruning of historical state snapshots. + DeleteStateAtHeight(ctx context.Context, height uint64) error } diff --git a/test/mocks/store.go b/test/mocks/store.go index efd1939940..2cde50c543 100644 --- a/test/mocks/store.go +++ b/test/mocks/store.go @@ -39,14 +39,6 @@ func (_m *MockStore) EXPECT() *MockStore_Expecter { return &MockStore_Expecter{mock: &_m.Mock} } -// PruneBlocks provides a mock implementation for the Store's pruning method. -// Tests using MockStore currently do not exercise pruning behavior, so this -// method simply satisfies the interface and can be extended with expectations -// later if needed. -func (_mock *MockStore) PruneBlocks(ctx context.Context, height uint64) error { - return nil -} - // Close provides a mock function for the type MockStore func (_mock *MockStore) Close() error { ret := _mock.Called() @@ -148,6 +140,63 @@ func (_c *MockStore_DeleteMetadata_Call) RunAndReturn(run func(ctx context.Conte return _c } +// DeleteStateAtHeight provides a mock function for the type MockStore +func (_mock *MockStore) DeleteStateAtHeight(ctx context.Context, height uint64) error { + ret := _mock.Called(ctx, height) + + if len(ret) == 0 { + panic("no return value specified for DeleteStateAtHeight") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64) error); ok { + r0 = returnFunc(ctx, height) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockStore_DeleteStateAtHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteStateAtHeight' +type MockStore_DeleteStateAtHeight_Call struct { + *mock.Call +} + +// DeleteStateAtHeight is a helper method to define mock.On call +// - ctx context.Context +// - height uint64 +func (_e *MockStore_Expecter) DeleteStateAtHeight(ctx interface{}, height interface{}) *MockStore_DeleteStateAtHeight_Call { + return &MockStore_DeleteStateAtHeight_Call{Call: _e.mock.On("DeleteStateAtHeight", ctx, height)} +} + +func (_c *MockStore_DeleteStateAtHeight_Call) Run(run func(ctx context.Context, height uint64)) *MockStore_DeleteStateAtHeight_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uint64 + if args[1] != nil { + arg1 = args[1].(uint64) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_DeleteStateAtHeight_Call) Return(err error) *MockStore_DeleteStateAtHeight_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockStore_DeleteStateAtHeight_Call) RunAndReturn(run func(ctx context.Context, height uint64) error) *MockStore_DeleteStateAtHeight_Call { + _c.Call.Return(run) + return _c +} + // GetBlockByHash provides a mock function for the type MockStore func (_mock *MockStore) GetBlockByHash(ctx context.Context, hash []byte) (*types.SignedHeader, *types.Data, error) { ret := _mock.Called(ctx, hash) @@ -888,6 +937,63 @@ func (_c *MockStore_NewBatch_Call) RunAndReturn(run func(ctx context.Context) (s return _c } +// PruneBlocks provides a mock function for the type MockStore +func (_mock *MockStore) PruneBlocks(ctx context.Context, height uint64) error { + ret := _mock.Called(ctx, height) + + if len(ret) == 0 { + panic("no return value specified for PruneBlocks") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uint64) error); ok { + r0 = returnFunc(ctx, height) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockStore_PruneBlocks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PruneBlocks' +type MockStore_PruneBlocks_Call struct { + *mock.Call +} + +// PruneBlocks is a helper method to define mock.On call +// - ctx context.Context +// - height uint64 +func (_e *MockStore_Expecter) PruneBlocks(ctx interface{}, height interface{}) *MockStore_PruneBlocks_Call { + return &MockStore_PruneBlocks_Call{Call: _e.mock.On("PruneBlocks", ctx, height)} +} + +func (_c *MockStore_PruneBlocks_Call) Run(run func(ctx context.Context, height uint64)) *MockStore_PruneBlocks_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uint64 + if args[1] != nil { + arg1 = args[1].(uint64) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockStore_PruneBlocks_Call) Return(err error) *MockStore_PruneBlocks_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockStore_PruneBlocks_Call) RunAndReturn(run func(ctx context.Context, height uint64) error) *MockStore_PruneBlocks_Call { + _c.Call.Return(run) + return _c +} + // Rollback provides a mock function for the type MockStore func (_mock *MockStore) Rollback(ctx context.Context, height uint64, aggregator bool) error { ret := _mock.Called(ctx, height, aggregator) From a6fe4aa9f8e9c3a5940d84dea90cce6ef5ebae20 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 12 Feb 2026 15:29:55 +0100 Subject: [PATCH 15/16] updates --- block/internal/pruner/pruner.go | 55 ++++++++++++++++++++++++++ block/internal/submitting/submitter.go | 44 --------------------- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/block/internal/pruner/pruner.go b/block/internal/pruner/pruner.go index 2a54da6137..14e23bc7a4 100644 --- a/block/internal/pruner/pruner.go +++ b/block/internal/pruner/pruner.go @@ -2,7 +2,9 @@ package pruner import ( "context" + "encoding/binary" "errors" + "fmt" "sync" "time" @@ -79,6 +81,10 @@ func (p *Pruner) pruneLoop() { p.logger.Error().Err(err).Msg("failed to prune recovery history") } + if err := p.pruneBlocks(); err != nil { + p.logger.Error().Err(err).Msg("failed to prune old blocks") + } + // TODO: add pruning of old blocks // https://github.com/evstack/ev-node/pull/2984 case <-p.ctx.Done(): return @@ -86,6 +92,55 @@ func (p *Pruner) pruneLoop() { } } +func (p *Pruner) pruneBlocks() error { + if !p.cfg.PruningEnabled || p.cfg.PruningKeepRecent == 0 || p.cfg.PruningInterval == 0 { + return nil + } + + var currentDAIncluded uint64 + currentDAIncludedBz, err := p.store.GetMetadata(p.ctx, store.DAIncludedHeightKey) + if err == nil && len(currentDAIncludedBz) == 8 { + currentDAIncluded = binary.LittleEndian.Uint64(currentDAIncludedBz) + } else { + // if we cannot get the current DA height, we cannot safely prune, so we skip pruning until we can get it. + return nil + } + + var lastPruned uint64 + if bz, err := p.store.GetMetadata(p.ctx, store.LastPrunedBlockHeightKey); err == nil && len(bz) == 8 { + lastPruned = binary.LittleEndian.Uint64(bz) + } + + storeHeight, err := p.store.Height(p.ctx) + if err != nil { + return fmt.Errorf("failed to get store height for pruning: %w", err) + } + if storeHeight <= lastPruned+p.cfg.PruningInterval { + return nil + } + + // Never prune blocks that are not DA included + upperBound := min(storeHeight, currentDAIncluded) + if upperBound <= p.cfg.PruningKeepRecent { + // Not enough fully included blocks to prune + return nil + } + + targetHeight := upperBound - p.cfg.PruningKeepRecent + + if err := p.store.PruneBlocks(p.ctx, targetHeight); err != nil { + p.logger.Error().Err(err).Uint64("target_height", targetHeight).Msg("failed to prune old block data") + } + + if p.execPruner != nil { + if err := p.execPruner.PruneExec(p.ctx, targetHeight); err != nil && !errors.Is(err, ds.ErrNotFound) { + return err + } + } + + return nil +} + // pruneRecoveryHistory prunes old state and execution metadata entries based on the configured retention depth. // It does not prunes old blocks, as those are handled by the pruning logic. // Pruning old state does not lose history but limit the ability to recover (replay or rollback) to the last HEAD-N blocks, where N is the retention depth. diff --git a/block/internal/submitting/submitter.go b/block/internal/submitting/submitter.go index 6a40bd818e..1c5b034c19 100644 --- a/block/internal/submitting/submitter.go +++ b/block/internal/submitting/submitter.go @@ -364,50 +364,6 @@ func (s *Submitter) processDAInclusionLoop() { // This can only be performed after the height has been persisted to store s.cache.DeleteHeight(nextHeight) } - - // Run height-based pruning if enabled. - s.pruneBlocks() - } - } -} - -func (s *Submitter) pruneBlocks() { - if !s.config.Node.PruningEnabled || s.config.Node.PruningKeepRecent == 0 || s.config.Node.PruningInterval == 0 { - return - } - - currentDAIncluded := s.GetDAIncludedHeight() - - var lastPruned uint64 - if bz, err := s.store.GetMetadata(s.ctx, store.LastPrunedBlockHeightKey); err == nil && len(bz) == 8 { - lastPruned = binary.LittleEndian.Uint64(bz) - } - - storeHeight, err := s.store.Height(s.ctx) - if err != nil { - s.logger.Error().Err(err).Msg("failed to get store height for pruning") - return - } - if storeHeight <= lastPruned+s.config.Node.PruningInterval { - return - } - - // Never prune blocks that are not DA included - upperBound := min(storeHeight, currentDAIncluded) - if upperBound <= s.config.Node.PruningKeepRecent { - // Not enough fully included blocks to prune - return - } - - targetHeight := upperBound - s.config.Node.PruningKeepRecent - - if err := s.store.PruneBlocks(s.ctx, targetHeight); err != nil { - s.logger.Error().Err(err).Uint64("target_height", targetHeight).Msg("failed to prune old block data") - } - - if pruner, ok := s.exec.(coreexecutor.ExecPruner); ok { - if err := pruner.PruneExec(s.ctx, targetHeight); err != nil { - s.logger.Error().Err(err).Uint64("target_height", targetHeight).Msg("failed to prune execution metadata") } } } From 80759f56988e84f533aea199be6c5deca60cbd92 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Thu, 12 Feb 2026 15:30:47 +0100 Subject: [PATCH 16/16] comment --- pkg/store/cached_store.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/store/cached_store.go b/pkg/store/cached_store.go index 27a1507fec..490f44411a 100644 --- a/pkg/store/cached_store.go +++ b/pkg/store/cached_store.go @@ -170,6 +170,7 @@ func (cs *CachedStore) PruneBlocks(ctx context.Context, height uint64) error { } // DeleteStateAtHeight removes the state entry at the given height from the underlying store. +// This value is not cached, so nothing to invalidate. func (cs *CachedStore) DeleteStateAtHeight(ctx context.Context, height uint64) error { return cs.DeleteStateAtHeight(ctx, height) }