diff --git a/apps/evm/go.mod b/apps/evm/go.mod index b4a4d2a3b..4e373eb12 100644 --- a/apps/evm/go.mod +++ b/apps/evm/go.mod @@ -4,6 +4,7 @@ go 1.25.6 replace ( github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core github.com/evstack/ev-node/execution/evm => ../../execution/evm ) diff --git a/apps/evm/go.sum b/apps/evm/go.sum index 5ea0339d4..58c363be5 100644 --- a/apps/evm/go.sum +++ b/apps/evm/go.sum @@ -411,8 +411,6 @@ 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/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/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= diff --git a/apps/grpc/go.mod b/apps/grpc/go.mod index 9a14cf9cf..31ecc287f 100644 --- a/apps/grpc/go.mod +++ b/apps/grpc/go.mod @@ -4,6 +4,7 @@ go 1.25.6 replace ( github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core github.com/evstack/ev-node/execution/grpc => ../../execution/grpc ) diff --git a/apps/grpc/go.sum b/apps/grpc/go.sum index fb175ac5c..d1fd875f0 100644 --- a/apps/grpc/go.sum +++ b/apps/grpc/go.sum @@ -367,8 +367,6 @@ github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6Ni github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= github.com/envoyproxy/protoc-gen-validate v1.0.1/go.mod h1:0vj8bNkYbSTNS2PIyH87KZaeN4x9zpL9Qt8fQC7d+vs= github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= -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/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= diff --git a/apps/testapp/go.mod b/apps/testapp/go.mod index d68a967d6..c67b20897 100644 --- a/apps/testapp/go.mod +++ b/apps/testapp/go.mod @@ -2,7 +2,10 @@ module github.com/evstack/ev-node/apps/testapp go 1.25.6 -replace github.com/evstack/ev-node => ../../ +replace ( + github.com/evstack/ev-node => ../../. + github.com/evstack/ev-node/core => ../../core +) require ( github.com/evstack/ev-node v1.0.0-rc.3 diff --git a/apps/testapp/go.sum b/apps/testapp/go.sum index fb175ac5c..d1fd875f0 100644 --- a/apps/testapp/go.sum +++ b/apps/testapp/go.sum @@ -367,8 +367,6 @@ github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6Ni github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= github.com/envoyproxy/protoc-gen-validate v1.0.1/go.mod h1:0vj8bNkYbSTNS2PIyH87KZaeN4x9zpL9Qt8fQC7d+vs= github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= -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/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= diff --git a/block/internal/submitting/submitter.go b/block/internal/submitting/submitter.go index 1c5b034c1..6a40bd818 100644 --- a/block/internal/submitting/submitter.go +++ b/block/internal/submitting/submitter.go @@ -364,6 +364,50 @@ 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") } } } diff --git a/core/execution/execution.go b/core/execution/execution.go index 276da369e..f3ebe1da9 100644 --- a/core/execution/execution.go +++ b/core/execution/execution.go @@ -161,3 +161,13 @@ type Rollbackable interface { // Rollback resets the execution layer head to the specified height. Rollback(ctx context.Context, targetHeight uint64) error } + +// ExecPruner is an optional interface that execution clients can implement +// to support height-based pruning of their execution metadata. +type ExecPruner interface { + // PruneExec should delete execution metadata for all heights up to and + // including the given height. Implementations should be idempotent and track + // their own progress so that repeated calls with the same or decreasing + // heights are cheap no-ops. + PruneExec(ctx context.Context, height uint64) error +} diff --git a/docs/learn/config.md b/docs/learn/config.md index ba900a163..fea509fa5 100644 --- a/docs/learn/config.md +++ b/docs/learn/config.md @@ -17,6 +17,7 @@ This document provides a comprehensive reference for all configuration options a - [Maximum Pending Blocks](#maximum-pending-blocks) - [Lazy Mode (Lazy Aggregator)](#lazy-mode-lazy-aggregator) - [Lazy Block Interval](#lazy-block-interval) + - [Pruning (Height-Based Pruning)](#pruning-height-based-pruning) - [Data Availability Configuration (`da`)](#data-availability-configuration-da) - [DA Service Address](#da-service-address) - [DA Authentication Token](#da-authentication-token) @@ -279,6 +280,40 @@ _Example:_ `--rollkit.node.lazy_block_interval 1m` _Default:_ `"30s"` _Constant:_ `FlagLazyBlockTime` +### Pruning (Height-Based Pruning) + +**Description:** +Controls height-based pruning of stored block data (headers, data, signatures, and index) from the local store. When pruning is enabled, the node periodically deletes old blocks while keeping a recent window of history. When disabled, the node keeps all blocks (archive mode). + +**YAML:** + +```yaml +node: + pruning_enabled: true + pruning_keep_recent: 100000 + pruning_interval: 1000 +``` + +**Command-line Flags:** + +- `--evnode.node.pruning_enabled` (boolean) + - _Description:_ Enable height-based pruning of stored block data. When disabled, all blocks are kept (archive mode). +- `--evnode.node.pruning_keep_recent ` + - _Description:_ Number of most recent blocks to retain when pruning is enabled. Must be > 0 when pruning is enabled; set `pruning_enabled=false` to keep all blocks. +- `--evnode.node.pruning_interval ` + - _Description:_ Run pruning every N blocks. Must be >= 1 when pruning is enabled. + +_Defaults:_ + +```yaml +node: + pruning_enabled: false + pruning_keep_recent: 0 + pruning_interval: 0 +``` + +_Constants:_ `FlagNodePruningEnabled`, `FlagNodePruningKeepRecent`, `FlagNodePruningInterval` + ## Data Availability Configuration (`da`) Parameters for connecting and interacting with the Data Availability (DA) layer, which Evolve uses to publish block data. diff --git a/execution/evm/execution.go b/execution/evm/execution.go index 7a0a9dfb5..399f7c91e 100644 --- a/execution/evm/execution.go +++ b/execution/evm/execution.go @@ -65,6 +65,10 @@ var _ execution.HeightProvider = (*EngineClient)(nil) // Ensure EngineClient implements the execution.Rollbackable interface var _ execution.Rollbackable = (*EngineClient)(nil) +// Ensure EngineClient implements optional pruning interface when used with +// ev-node's height-based pruning. +var _ execution.ExecPruner = (*EngineClient)(nil) + // validatePayloadStatus checks the payload status and returns appropriate errors. // It implements the Engine API specification's status handling: // - VALID: Operation succeeded, return nil @@ -265,6 +269,13 @@ func NewEngineExecutionClient( }, 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) +} + // SetLogger allows callers to attach a structured logger. func (c *EngineClient) SetLogger(l zerolog.Logger) { c.logger = l diff --git a/execution/evm/go.mod b/execution/evm/go.mod index ecd188489..7d46e8657 100644 --- a/execution/evm/go.mod +++ b/execution/evm/go.mod @@ -103,3 +103,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) + +replace ( + github.com/evstack/ev-node => ../../ + github.com/evstack/ev-node/core => ../../core +) diff --git a/execution/evm/go.sum b/execution/evm/go.sum index f00f1139a..3294e40da 100644 --- a/execution/evm/go.sum +++ b/execution/evm/go.sum @@ -78,10 +78,6 @@ 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.3 h1:hphJBI0b1TgGN9wajB1twouMVMjhyHXXrS9QaG1XwvQ= -github.com/evstack/ev-node v1.0.0-rc.3/go.mod h1:5Cf3SauhgIV+seQKBJavv3f8ZZw+YTnH5DRJcI4Ooj0= -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/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= diff --git a/execution/evm/store.go b/execution/evm/store.go index af731ee7c..21fe5008a 100644 --- a/execution/evm/store.go +++ b/execution/evm/store.go @@ -15,6 +15,11 @@ import ( // Store prefix for execution/evm data - keeps it isolated from other ev-node data const evmStorePrefix = "evm/" +// lastPrunedExecMetaKey is the datastore key used to track the highest +// execution height for which ExecMeta has been pruned. All ExecMeta entries +// for heights <= this value are considered pruned. +const lastPrunedExecMetaKey = evmStorePrefix + "last-pruned-execmeta-height" + // ExecMeta stages const ( ExecStageStarted = "started" @@ -140,6 +145,54 @@ func (s *EVMStore) SaveExecMeta(ctx context.Context, meta *ExecMeta) error { return nil } +// 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 +} + // Sync ensures all pending writes are flushed to disk. func (s *EVMStore) Sync(ctx context.Context) error { return s.db.Sync(ctx, ds.NewKey(evmStorePrefix)) diff --git a/execution/evm/store_test.go b/execution/evm/store_test.go new file mode 100644 index 000000000..d3067fc1a --- /dev/null +++ b/execution/evm/store_test.go @@ -0,0 +1,99 @@ +package evm + +import ( + "context" + "encoding/binary" + "testing" + + ds "github.com/ipfs/go-datastore" + dssync "github.com/ipfs/go-datastore/sync" + "github.com/stretchr/testify/require" +) + +// newTestDatastore creates an in-memory datastore for testing. +func newTestDatastore(t *testing.T) ds.Batching { + t.Helper() + // Wrap the in-memory MapDatastore to satisfy the Batching interface. + return dssync.MutexWrap(ds.NewMapDatastore()) +} + +func TestPruneExec_PrunesUpToTargetHeight(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db := newTestDatastore(t) + store := NewEVMStore(db) + + // Seed ExecMeta entries at heights 1..5 + for h := uint64(1); h <= 5; h++ { + meta := &ExecMeta{Height: h} + require.NoError(t, store.SaveExecMeta(ctx, meta)) + } + + // Sanity: all heights should be present + for h := uint64(1); h <= 5; h++ { + meta, err := store.GetExecMeta(ctx, h) + require.NoError(t, err) + require.NotNil(t, meta) + require.Equal(t, h, meta.Height) + } + + // Prune up to height 3 + require.NoError(t, store.PruneExec(ctx, 3)) + + // Heights 1..3 should be gone + for h := uint64(1); h <= 3; h++ { + meta, err := store.GetExecMeta(ctx, h) + require.NoError(t, err) + require.Nil(t, meta) + } + + // Heights 4..5 should remain + for h := uint64(4); h <= 5; h++ { + meta, err := store.GetExecMeta(ctx, h) + require.NoError(t, err) + require.NotNil(t, meta) + } + + // Re-pruning with the same height should be a no-op + require.NoError(t, store.PruneExec(ctx, 3)) +} + +func TestPruneExec_TracksLastPrunedHeight(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db := newTestDatastore(t) + store := NewEVMStore(db) + + // Seed ExecMeta entries at heights 1..5 + for h := uint64(1); h <= 5; h++ { + meta := &ExecMeta{Height: h} + require.NoError(t, store.SaveExecMeta(ctx, meta)) + } + + // First prune up to 2 + require.NoError(t, store.PruneExec(ctx, 2)) + + // Then prune up to 4; heights 3..4 should be deleted in this run + require.NoError(t, store.PruneExec(ctx, 4)) + + // Verify all heights 1..4 are gone, 5 remains + for h := uint64(1); h <= 4; h++ { + meta, err := store.GetExecMeta(ctx, h) + require.NoError(t, err) + require.Nil(t, meta) + } + + meta, err := store.GetExecMeta(ctx, 5) + require.NoError(t, err) + require.NotNil(t, meta) + require.Equal(t, uint64(5), meta.Height) + + // Ensure last-pruned marker is set to 4 + raw, err := db.Get(ctx, ds.NewKey(lastPrunedExecMetaKey)) + require.NoError(t, err) + require.Len(t, raw, 8) + last := binary.BigEndian.Uint64(raw) + require.Equal(t, uint64(4), last) +} diff --git a/go.mod b/go.mod index d07ea7a7e..8f10b14e5 100644 --- a/go.mod +++ b/go.mod @@ -195,3 +195,6 @@ 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 ) + +// use local core module during development/CI +replace github.com/evstack/ev-node/core => ./core diff --git a/go.sum b/go.sum index fb175ac5c..d1fd875f0 100644 --- a/go.sum +++ b/go.sum @@ -367,8 +367,6 @@ github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6Ni github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= github.com/envoyproxy/protoc-gen-validate v1.0.1/go.mod h1:0vj8bNkYbSTNS2PIyH87KZaeN4x9zpL9Qt8fQC7d+vs= github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= -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/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= diff --git a/pkg/config/config.go b/pkg/config/config.go index e03a277ce..b964ac7bf 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -261,6 +261,13 @@ type NodeConfig struct { // 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."` ReadinessMaxBlocksBehind uint64 `mapstructure:"readiness_max_blocks_behind" yaml:"readiness_max_blocks_behind" comment:"How many blocks behind best-known head the node can be and still be considered ready. 0 means must be exactly at head."` + + // Pruning configuration + // When enabled, the node will periodically prune old block data (headers, data, + // signatures, and hash index) from the local store while keeping recent history. + PruningEnabled bool `mapstructure:"pruning_enabled" yaml:"pruning_enabled" comment:"Enable height-based pruning of stored block data. When disabled, all blocks are kept (archive mode)."` + PruningKeepRecent uint64 `mapstructure:"pruning_keep_recent" yaml:"pruning_keep_recent" comment:"Number of most recent blocks to retain when pruning is enabled. Must be > 0 when pruning is enabled; set pruning_enabled=false to keep all blocks (archive mode)."` + PruningInterval uint64 `mapstructure:"pruning_interval" yaml:"pruning_interval" comment:"Run pruning every N blocks. Must be >= 1 when pruning is enabled."` } // LogConfig contains all logging configuration parameters @@ -376,6 +383,17 @@ func (c *Config) Validate() error { if err := c.Raft.Validate(); err != nil { return err } + + // Validate pruning configuration + if c.Node.PruningEnabled { + if c.Node.PruningInterval == 0 { + return fmt.Errorf("pruning_interval must be >= 1 when pruning is enabled") + } + if c.Node.PruningKeepRecent == 0 { + return fmt.Errorf("pruning_keep_recent must be > 0 when pruning is enabled; use pruning_enabled=false to keep all blocks") + } + } + return nil } @@ -436,6 +454,10 @@ 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") + // Pruning configuration flags + cmd.Flags().Bool(FlagPrefixEvnode+"node.pruning_enabled", def.Node.PruningEnabled, "enable height-based pruning of stored block data (headers, data, signatures, index)") + cmd.Flags().Uint64(FlagPrefixEvnode+"node.pruning_keep_recent", def.Node.PruningKeepRecent, "number of most recent blocks to retain when pruning is enabled (must be > 0; disable pruning to keep all blocks)") + cmd.Flags().Uint64(FlagPrefixEvnode+"node.pruning_interval", def.Node.PruningInterval, "run pruning every N blocks (must be >= 1 when pruning is enabled)") // 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 1834e1b40..9189aa57e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -112,7 +112,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 := 66 // 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 0de2f4bc2..ee2cbfbee 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -69,6 +69,9 @@ func DefaultConfig() Config { ReadinessWindowSeconds: defaultReadinessWindowSeconds, ReadinessMaxBlocksBehind: calculateReadinessMaxBlocksBehind(defaultBlockTime.Duration, defaultReadinessWindowSeconds), ScrapeInterval: DurationWrapper{1 * time.Second}, + PruningEnabled: false, + PruningKeepRecent: 0, + PruningInterval: 0, }, DA: DAConfig{ Address: "http://localhost:7980", diff --git a/pkg/store/cached_store.go b/pkg/store/cached_store.go index e59d4e28c..8a5cca5b3 100644 --- a/pkg/store/cached_store.go +++ b/pkg/store/cached_store.go @@ -157,6 +157,18 @@ func (cs *CachedStore) Rollback(ctx context.Context, height uint64, aggregator b return nil } +// PruneBlocks wraps the underlying store's PruneBlocks and invalidates caches +// up to the heigh that we purne +func (cs *CachedStore) PruneBlocks(ctx context.Context, height uint64) error { + if err := cs.Store.PruneBlocks(ctx, height); err != nil { + return err + } + + // Invalidate cache for pruned heights + cs.InvalidateRange(1, height) + return nil +} + // Close closes the underlying store. func (cs *CachedStore) Close() error { cs.ClearCache() diff --git a/pkg/store/keys.go b/pkg/store/keys.go index dd989c0e8..ff96fd895 100644 --- a/pkg/store/keys.go +++ b/pkg/store/keys.go @@ -25,6 +25,12 @@ const ( // LastSubmittedHeaderHeightKey is the key used for persisting the last submitted header height in store. LastSubmittedHeaderHeightKey = "last-submitted-header-height" + // LastPrunedBlockHeightKey is the metadata key used for persisting the last + // pruned block height in the store. All block data (header, data, + // signature, and hash index) for heights <= this value are considered + // pruned and may be missing from the store. + LastPrunedBlockHeightKey = "last-pruned-block-height" + headerPrefix = "h" dataPrefix = "d" signaturePrefix = "c" diff --git a/pkg/store/store.go b/pkg/store/store.go index eafa47ae7..d981400f0 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -332,6 +332,99 @@ func (s *DefaultStore) Rollback(ctx context.Context, height uint64, aggregator b return nil } +// PruneBlocks removes block data (header, data, signature, and hash index) +// up to and including the given height from the store. It does not modify +// the current chain height or any state snapshots. +// +// This method is intended for long-term storage reduction and is safe to +// call repeatedly with the same or increasing heights. +func (s *DefaultStore) PruneBlocks(ctx context.Context, height uint64) error { + batch, err := s.db.Batch(ctx) + if err != nil { + return fmt.Errorf("failed to create a new batch for pruning: %w", err) + } + + // Track the last successfully pruned height so we can resume across restarts. + var lastPruned uint64 + meta, err := s.GetMetadata(ctx, LastPrunedBlockHeightKey) + if err != nil { + if !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to get last pruned height: %w", err) + } + } else if len(meta) == heightLength { + lastPruned, err = decodeHeight(meta) + if err != nil { + return fmt.Errorf("failed to decode last pruned height: %w", err) + } + } + + // Nothing new to prune. + if height <= lastPruned { + return nil + } + + // Delete block data for heights in (lastPruned, height]. + for h := lastPruned + 1; h <= height; h++ { + // Get header blob to compute the hash index key. If header is already + // missing (e.g. due to previous partial pruning), just skip this height. + headerBlob, err := s.db.Get(ctx, ds.NewKey(getHeaderKey(h))) + if err != nil { + if errors.Is(err, ds.ErrNotFound) { + continue + } + return fmt.Errorf("failed to get header at height %d during pruning: %w", h, err) + } + + if err := batch.Delete(ctx, ds.NewKey(getHeaderKey(h))); err != nil { + if !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete header at height %d during pruning: %w", h, err) + } + } + + if err := batch.Delete(ctx, ds.NewKey(getDataKey(h))); err != nil { + if !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete data at height %d during pruning: %w", h, err) + } + } + + if err := batch.Delete(ctx, ds.NewKey(getSignatureKey(h))); err != nil { + if !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete signature at height %d during pruning: %w", h, err) + } + } + + // Delete per-height DA metadata associated with this height, if any. + if err := batch.Delete(ctx, ds.NewKey(getMetaKey(GetHeightToDAHeightHeaderKey(h)))); err != nil { + if !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete header DA height metadata at height %d during pruning: %w", h, err) + } + } + if err := batch.Delete(ctx, ds.NewKey(getMetaKey(GetHeightToDAHeightDataKey(h)))); err != nil { + if !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete data DA height metadata at height %d during pruning: %w", h, err) + } + } + + headerHash := sha256.Sum256(headerBlob) + if err := batch.Delete(ctx, ds.NewKey(getIndexKey(headerHash[:]))); err != nil { + if !errors.Is(err, ds.ErrNotFound) { + return fmt.Errorf("failed to delete index for height %d during pruning: %w", h, err) + } + } + } + + // Persist the updated last pruned height. + if err := batch.Put(ctx, ds.NewKey(getMetaKey(LastPrunedBlockHeightKey)), encodeHeight(height)); err != nil { + return fmt.Errorf("failed to update last pruned height: %w", err) + } + + if err := batch.Commit(ctx); err != nil { + return fmt.Errorf("failed to commit pruning batch: %w", err) + } + + return nil +} + const heightLength = 8 func encodeHeight(height uint64) []byte { diff --git a/pkg/store/store_adapter.go b/pkg/store/store_adapter.go index 08743c6eb..7aa08e6a2 100644 --- a/pkg/store/store_adapter.go +++ b/pkg/store/store_adapter.go @@ -286,6 +286,7 @@ func (c *pendingCache[H]) recalcMaxHeight() { // a block, it writes to the underlying store, and subsequent reads will come from the store. type StoreAdapter[H EntityWithDAHint[H]] struct { getter StoreGetter[H] + store Store genesisInitialHeight uint64 // heightSub tracks the current height and allows waiting for specific heights. @@ -304,9 +305,9 @@ type StoreAdapter[H EntityWithDAHint[H]] struct { onDeleteFn func(context.Context, uint64) error } -// NewStoreAdapter creates a new StoreAdapter wrapping the given store getter. +// NewStoreAdapter creates a new StoreAdapter wrapping the given store getter and backing Store. // The genesis is used to determine the initial height for efficient Tail lookups. -func NewStoreAdapter[H EntityWithDAHint[H]](getter StoreGetter[H], gen genesis.Genesis) *StoreAdapter[H] { +func NewStoreAdapter[H EntityWithDAHint[H]](getter StoreGetter[H], store Store, gen genesis.Genesis) *StoreAdapter[H] { // Get actual current height from store (0 if empty) var storeHeight uint64 if h, err := getter.Height(context.Background()); err == nil { @@ -315,6 +316,7 @@ func NewStoreAdapter[H EntityWithDAHint[H]](getter StoreGetter[H], gen genesis.G adapter := &StoreAdapter[H]{ getter: getter, + store: store, genesisInitialHeight: max(gen.InitialHeight, 1), pending: newPendingCache[H](), heightSub: newHeightSub(storeHeight), @@ -386,7 +388,6 @@ func (a *StoreAdapter[H]) Head(ctx context.Context, _ ...header.HeadOption[H]) ( // Tail returns the lowest item in the store. // For ev-node, this is typically the genesis/initial height. // If pruning has occurred, it walks up from initialHeight to find the first available item. -// TODO(@julienrbrt): Optimize this when pruning is enabled. func (a *StoreAdapter[H]) Tail(ctx context.Context) (H, error) { var zero H @@ -400,8 +401,19 @@ func (a *StoreAdapter[H]) Tail(ctx context.Context) (H, error) { height = h } - // Try genesisInitialHeight first (most common case - no pruning) - item, err := a.getter.GetByHeight(ctx, a.genesisInitialHeight) + // Determine the first candidate tail height. By default, this is the + // genesis initial height, but if pruning metadata is available we can + // skip directly past fully-pruned ranges. + startHeight := a.genesisInitialHeight + if meta, err := a.store.GetMetadata(ctx, LastPrunedBlockHeightKey); err == nil && len(meta) == heightLength { + if lastPruned, err := decodeHeight(meta); err == nil { + if candidate := lastPruned + 1; candidate > startHeight { + startHeight = candidate + } + } + } + + item, err := a.getter.GetByHeight(ctx, startHeight) if err == nil { return item, nil } @@ -411,8 +423,8 @@ func (a *StoreAdapter[H]) Tail(ctx context.Context) (H, error) { return pendingItem, nil } - // Walk up from genesisInitialHeight to find the first available item (pruning case) - for h := a.genesisInitialHeight + 1; h <= height; h++ { + // Walk up from startHeight to find the first available item + for h := startHeight + 1; h <= height; h++ { item, err = a.getter.GetByHeight(ctx, h) if err == nil { return item, nil @@ -881,11 +893,11 @@ type DataStoreAdapter = StoreAdapter[*types.P2PData] // NewHeaderStoreAdapter creates a new StoreAdapter for headers. // The genesis is used to determine the initial height for efficient Tail lookups. func NewHeaderStoreAdapter(store Store, gen genesis.Genesis) *HeaderStoreAdapter { - return NewStoreAdapter(NewHeaderStoreGetter(store), gen) + return NewStoreAdapter(NewHeaderStoreGetter(store), store, gen) } // NewDataStoreAdapter creates a new StoreAdapter for data. // The genesis is used to determine the initial height for efficient Tail lookups. func NewDataStoreAdapter(store Store, gen genesis.Genesis) *DataStoreAdapter { - return NewStoreAdapter(NewDataStoreGetter(store), gen) + return NewStoreAdapter(NewDataStoreGetter(store), store, gen) } diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go index 6a09db465..f1b9a131c 100644 --- a/pkg/store/store_test.go +++ b/pkg/store/store_test.go @@ -789,6 +789,85 @@ func TestRollback(t *testing.T) { require.Equal(rollbackToHeight, state.LastBlockHeight) } +func TestPruneBlocks_RemovesOldBlockDataOnly(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ds, err := NewTestInMemoryKVStore() + require.NoError(t, err) + + s := New(ds).(*DefaultStore) + + // create and store a few blocks with headers, data, signatures, state, and per-height DA metadata + batch, err := s.NewBatch(ctx) + require.NoError(t, err) + + var lastState types.State + for h := uint64(1); h <= 5; h++ { + header := &types.SignedHeader{Header: types.Header{BaseHeader: types.BaseHeader{Height: h}}} + data := &types.Data{} + sig := types.Signature([]byte{byte(h)}) + + require.NoError(t, batch.SaveBlockData(header, data, &sig)) + + // fake state snapshot per height + lastState = types.State{LastBlockHeight: h} + require.NoError(t, batch.UpdateState(lastState)) + + // store fake DA metadata per height + hDaKey := GetHeightToDAHeightHeaderKey(h) + dDaKey := GetHeightToDAHeightDataKey(h) + bz := make([]byte, 8) + binary.LittleEndian.PutUint64(bz, h+100) // arbitrary DA height + require.NoError(t, s.SetMetadata(ctx, hDaKey, bz)) + require.NoError(t, s.SetMetadata(ctx, dDaKey, bz)) + } + require.NoError(t, batch.SetHeight(5)) + require.NoError(t, batch.Commit()) + + // prune everything up to height 3 + require.NoError(t, s.PruneBlocks(ctx, 3)) + + // old block data should be gone + for h := uint64(1); h <= 3; h++ { + _, _, err := s.GetBlockData(ctx, h) + assert.Error(t, err, "expected block data at height %d to be pruned", h) + } + + // recent block data should remain + for h := uint64(4); h <= 5; h++ { + _, _, err := s.GetBlockData(ctx, h) + assert.NoError(t, err, "expected block data at height %d to be kept", h) + } + + // state snapshots are not pruned by PruneBlocks + for h := uint64(1); h <= 5; h++ { + st, err := s.GetStateAtHeight(ctx, h) + assert.NoError(t, err, "expected state at height %d to remain", h) + assert.Equal(t, h, st.LastBlockHeight) + } + + // per-height DA metadata for pruned heights should be gone + for h := uint64(1); h <= 3; h++ { + hDaKey := GetHeightToDAHeightHeaderKey(h) + dDaKey := GetHeightToDAHeightDataKey(h) + _, err := s.GetMetadata(ctx, hDaKey) + assert.Error(t, err, "expected header DA metadata at height %d to be pruned", h) + _, err = s.GetMetadata(ctx, dDaKey) + assert.Error(t, err, "expected data DA metadata at height %d to be pruned", h) + } + + // per-height DA metadata for unpruned heights should remain + for h := uint64(4); h <= 5; h++ { + hDaKey := GetHeightToDAHeightHeaderKey(h) + dDaKey := GetHeightToDAHeightDataKey(h) + _, err := s.GetMetadata(ctx, hDaKey) + assert.NoError(t, err, "expected header DA metadata at height %d to remain", h) + _, err = s.GetMetadata(ctx, dDaKey) + assert.NoError(t, err, "expected data DA metadata at height %d to remain", h) + } +} + // TestRollbackToSameHeight verifies that rollback to same height is a no-op func TestRollbackToSameHeight(t *testing.T) { t.Parallel() diff --git a/pkg/store/tracing.go b/pkg/store/tracing.go index 33a21f93a..42d686d61 100644 --- a/pkg/store/tracing.go +++ b/pkg/store/tracing.go @@ -247,6 +247,22 @@ func (t *tracedStore) Rollback(ctx context.Context, height uint64, aggregator bo return nil } +func (t *tracedStore) PruneBlocks(ctx context.Context, height uint64) error { + ctx, span := t.tracer.Start(ctx, "Store.PruneBlocks", + trace.WithAttributes(attribute.Int64("height", int64(height))), + ) + defer span.End() + + err := t.inner.PruneBlocks(ctx, height) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + + return nil +} + func (t *tracedStore) Close() error { return t.inner.Close() } diff --git a/pkg/store/tracing_test.go b/pkg/store/tracing_test.go index 0c0b96760..5477a6698 100644 --- a/pkg/store/tracing_test.go +++ b/pkg/store/tracing_test.go @@ -124,6 +124,12 @@ func (m *tracingMockStore) Rollback(ctx context.Context, height uint64, aggregat return nil } +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. + return nil +} + func (m *tracingMockStore) Close() error { return nil } diff --git a/pkg/store/types.go b/pkg/store/types.go index 13b62e2f1..a5635feec 100644 --- a/pkg/store/types.go +++ b/pkg/store/types.go @@ -31,9 +31,10 @@ type Batch interface { // Store is minimal interface for storing and retrieving blocks, commits and state. type Store interface { - Rollback Reader Metadata + Rollback + Pruner // Close safely closes underlying data storage, to ensure that data is actually saved. Close() error @@ -91,3 +92,11 @@ type Rollback interface { // Aggregator is used to determine if the rollback is performed on the aggregator node. Rollback(ctx context.Context, height uint64, aggregator bool) error } + +// Pruner provides long-term, height-based pruning of historical block data. +type Pruner interface { + // PruneBlocks removes block data (header, data, signature, and hash index) + // 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 +} diff --git a/test/mocks/store.go b/test/mocks/store.go index 353fa0a1b..efd193994 100644 --- a/test/mocks/store.go +++ b/test/mocks/store.go @@ -39,6 +39,14 @@ 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()