diff --git a/cmd/geth/chaincmd.go b/cmd/geth/chaincmd.go
index 1084100f39..247240be89 100644
--- a/cmd/geth/chaincmd.go
+++ b/cmd/geth/chaincmd.go
@@ -720,6 +720,9 @@ func pruneHistory(ctx *cli.Context) error {
if mode == history.KeepAll {
return errors.New("--history.chain=all is not valid for pruning. To restore history, use 'geth import-history'")
}
+ if mode == history.KeepRecent {
+ return errors.New("--history.chain=recent is not valid for prune-history. Use it as a runtime flag with geth instead")
+ }
stack, _ := makeConfigNode(ctx)
defer stack.Close()
@@ -731,7 +734,7 @@ func pruneHistory(ctx *cli.Context) error {
// Determine the prune point based on the history mode.
genesisHash := chain.Genesis().Hash()
- policy, err := history.NewPolicy(mode, genesisHash)
+ policy, err := history.NewPolicy(mode, genesisHash, 0)
if err != nil {
return err
}
diff --git a/cmd/geth/main.go b/cmd/geth/main.go
index b72cbb9885..310c5217df 100644
--- a/cmd/geth/main.go
+++ b/cmd/geth/main.go
@@ -89,6 +89,7 @@ var (
utils.TxLookupLimitFlag, // deprecated
utils.TransactionHistoryFlag,
utils.ChainHistoryFlag,
+ utils.HistoryBlocksFlag,
utils.LogHistoryFlag,
utils.LogNoHistoryFlag,
utils.LogExportCheckpointsFlag,
diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go
index c1284044eb..55ae9fcf8c 100644
--- a/cmd/utils/flags.go
+++ b/cmd/utils/flags.go
@@ -40,6 +40,7 @@ import (
"github.com/ethereum/go-ethereum/common/fdlimit"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core"
+ "github.com/ethereum/go-ethereum/core/history"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/txpool/blobpool"
"github.com/ethereum/go-ethereum/core/txpool/legacypool"
@@ -323,10 +324,16 @@ var (
}
ChainHistoryFlag = &cli.StringFlag{
Name: "history.chain",
- Usage: `Blockchain history retention ("all", "postmerge", or "postprague")`,
+ Usage: `Blockchain history retention ("all", "postmerge", "postprague" or "recent")`,
Value: ethconfig.Defaults.HistoryMode.String(),
Category: flags.StateCategory,
}
+ HistoryBlocksFlag = &cli.Uint64Flag{
+ Name: "history.blocks",
+ Usage: "Number of recent blocks to keep bodies/receipts for in rolling pruning mode (default = ~1 month, minimum 100000)",
+ Value: 216000,
+ Category: flags.StateCategory,
+ }
LogHistoryFlag = &cli.Uint64Flag{
Name: "history.logs",
Usage: "Number of recent blocks to maintain log search index for (default = about one year, 0 = entire chain)",
@@ -1780,6 +1787,20 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) {
Fatalf("--%s: %v", ChainHistoryFlag.Name, err)
}
}
+ if ctx.IsSet(HistoryBlocksFlag.Name) {
+ cfg.HistoryBlocks = ctx.Uint64(HistoryBlocksFlag.Name)
+ if cfg.HistoryBlocks < params.FullImmutabilityThreshold+10000 {
+ Fatalf("--%s: value %d is too small, minimum is %d", HistoryBlocksFlag.Name, cfg.HistoryBlocks, params.FullImmutabilityThreshold+10000)
+ }
+ if cfg.HistoryMode != history.KeepRecent {
+ log.Info("Setting history mode to recent due to --history.blocks flag")
+ cfg.HistoryMode = history.KeepRecent
+ }
+ }
+ if cfg.HistoryMode == history.KeepRecent && cfg.HistoryBlocks == 0 {
+ // use default (~1 month)
+ cfg.HistoryBlocks = HistoryBlocksFlag.Value
+ }
if ctx.IsSet(CacheFlag.Name) || ctx.IsSet(CacheDatabaseFlag.Name) {
cfg.DatabaseCache = ctx.Int(CacheFlag.Name) * ctx.Int(CacheDatabaseFlag.Name) / 100
@@ -1838,6 +1859,12 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) {
log.Warn("Disabled transaction unindexing for archive node")
}
}
+ // Cap transaction history to history blocks in rolling expiry mode.
+ // Block bodies have been anyway pruned and the txes are not accessible.
+ if cfg.HistoryMode == history.KeepRecent && cfg.TransactionHistory > cfg.HistoryBlocks {
+ log.Warn("Cap transaction history to history.blocks window", "was", cfg.TransactionHistory, "now", cfg.HistoryBlocks)
+ cfg.TransactionHistory = cfg.HistoryBlocks
+ }
if ctx.IsSet(LogHistoryFlag.Name) {
cfg.LogHistory = ctx.Uint64(LogHistoryFlag.Name)
}
diff --git a/cmd/workload/testsuite.go b/cmd/workload/testsuite.go
index 4e33522f1b..1ae0200cef 100644
--- a/cmd/workload/testsuite.go
+++ b/cmd/workload/testsuite.go
@@ -155,7 +155,7 @@ func testConfigFromCLI(ctx *cli.Context) (cfg testConfig) {
}
cfg.historyPruneBlock = new(uint64)
- if p, err := history.NewPolicy(history.KeepPostMerge, params.MainnetGenesisHash); err == nil {
+ if p, err := history.NewPolicy(history.KeepPostMerge, params.MainnetGenesisHash, 0); err == nil {
*cfg.historyPruneBlock = p.Target.BlockNumber
}
case ctx.Bool(testSepoliaFlag.Name):
@@ -182,7 +182,7 @@ func testConfigFromCLI(ctx *cli.Context) (cfg testConfig) {
}
cfg.historyPruneBlock = new(uint64)
- if p, err := history.NewPolicy(history.KeepPostMerge, params.SepoliaGenesisHash); err == nil {
+ if p, err := history.NewPolicy(history.KeepPostMerge, params.SepoliaGenesisHash, 0); err == nil {
*cfg.historyPruneBlock = p.Target.BlockNumber
}
default:
diff --git a/core/blockchain.go b/core/blockchain.go
index 1b45a5ac39..ac35954c7c 100644
--- a/core/blockchain.go
+++ b/core/blockchain.go
@@ -194,7 +194,7 @@ type BlockChainConfig struct {
SnapshotNoBuild bool // Whether the background generation is allowed
SnapshotWait bool // Wait for snapshot construction on startup. TODO(karalabe): This is a dirty hack for testing, nuke it
- // HistoryPolicy defines the chain history pruning intent.
+ // HistoryPolicy defines the chain history pruning intent from user.
HistoryPolicy history.HistoryPolicy
// Misc options
@@ -325,6 +325,7 @@ type BlockChain struct {
triedb *triedb.Database // The database handler for maintaining trie nodes.
codedb *state.CodeDB // The database handler for maintaining contract codes.
txIndexer *txIndexer // Transaction indexer, might be nil if not enabled
+ histPruner *historyPruner // Rolling history pruner, might be nil if not enabled
hc *HeaderChain
rmLogsFeed event.Feed
@@ -560,6 +561,11 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine,
bc.txIndexer = newTxIndexer(uint64(bc.cfg.TxLookupLimit), bc)
}
+ // Start rolling history pruner if configured.
+ if bc.cfg.HistoryPolicy.Mode == history.KeepRecent && bc.cfg.HistoryPolicy.Window > 0 {
+ bc.histPruner = newHistoryPruner(bc.cfg.HistoryPolicy.Window, bc)
+ }
+
// Start state size tracker
if bc.cfg.StateSizeTracking {
stateSizer, err := state.NewSizeTracker(bc.db, bc.triedb)
@@ -712,47 +718,72 @@ func (bc *BlockChain) loadLastState() error {
return nil
}
-// initializeHistoryPruning sets bc.historyPrunePoint.
+// initializeHistoryPruning sets bc.historyPrunePoint based on actual DB state,
+// and prunes chain history at startup if needed.
func (bc *BlockChain) initializeHistoryPruning(latest uint64) error {
- freezerTail, _ := bc.db.Tail()
+ freezerTail, err := bc.db.Tail()
+ if err != nil {
+ return err
+ }
policy := bc.cfg.HistoryPolicy
-
+ // Compute the current prune target from the policy.
+ var target uint64
switch policy.Mode {
case history.KeepAll:
+ // No pruning. Record actual DB state if already pruned.
if freezerTail > 0 {
- // Database was pruned externally. Record the actual state.
- log.Warn("Chain history database is pruned", "tail", freezerTail, "mode", policy.Mode)
- bc.historyPrunePoint.Store(&history.PrunePoint{
- BlockNumber: freezerTail,
- BlockHash: bc.GetCanonicalHash(freezerTail),
- })
+ bc.updateHistoryPrunePoint(freezerTail)
}
return nil
case history.KeepPostMerge, history.KeepPostPrague:
- target := policy.Target
- // Already at the target.
- if freezerTail == target.BlockNumber {
- bc.historyPrunePoint.Store(target)
+ target = policy.Target.BlockNumber
+
+ case history.KeepRecent:
+ head := bc.CurrentBlock()
+ if head == nil || head.Number.Uint64() <= policy.Window {
+ // Chain too short for pruning. Record actual DB state.
+ if freezerTail > 0 {
+ bc.updateHistoryPrunePoint(freezerTail)
+ }
return nil
}
- // Database is pruned beyond the target.
- if freezerTail > target.BlockNumber {
- return fmt.Errorf("database pruned beyond requested history (tail=%d, target=%d)", freezerTail, target.BlockNumber)
- }
- // Database needs pruning (freezerTail < target).
- if latest != 0 {
- log.Error(fmt.Sprintf("Chain history mode is configured as %q, but database is not pruned to the target block.", policy.Mode.String()))
- log.Error(fmt.Sprintf("Run 'geth prune-history --history.chain %s' to prune history.", policy.Mode.String()))
- return errors.New("history pruning required")
- }
- // Fresh database (latest == 0), will sync from target point.
- bc.historyPrunePoint.Store(target)
- return nil
-
- default:
- return fmt.Errorf("invalid history mode: %d", policy.Mode)
+ target = head.Number.Uint64() - policy.Window
}
+
+ // Already at the target, just record the state.
+ if freezerTail == target {
+ bc.updateHistoryPrunePoint(freezerTail)
+ return nil
+ }
+ // Database is pruned beyond the target.
+ if freezerTail > target {
+ // For KeepRecent this is benign (e.g. window was expanded after
+ // previously running with a smaller one). Accept the actual tail.
+ if policy.Mode == history.KeepRecent {
+ bc.updateHistoryPrunePoint(freezerTail)
+ return nil
+ }
+ log.Error("Database pruned beyond configured history mode", "tail", freezerTail, "target", target, "mode", policy.Mode)
+ return fmt.Errorf("database pruned beyond requested history (tail=%d, target=%d)", freezerTail, target)
+ }
+ // Need to prune (freezerTail < target). Ensure the target is frozen.
+ if latest < target+params.FullImmutabilityThreshold {
+ return fmt.Errorf("chain not far enough past target block %d, need %d more blocks",
+ target, target+params.FullImmutabilityThreshold-latest)
+ }
+ // For static prune points, verify the canonical hash matches.
+ if policy.Target != nil {
+ hash := bc.GetCanonicalHash(target)
+ if hash != policy.Target.BlockHash {
+ return fmt.Errorf("target block hash mismatch at block %d: got %s, want %s", target, hash.Hex(), policy.Target.BlockHash.Hex())
+ }
+ }
+ if err := bc.pruneChainHistory(target); err != nil {
+ return fmt.Errorf("failed to prune chain history: %w", err)
+ }
+ log.Info("Pruned chain history at startup", "from", freezerTail, "to", target)
+ return nil
}
// SetHead rewinds the local chain to a new head. Depending on whether the node
@@ -1307,6 +1338,10 @@ func (bc *BlockChain) stopWithoutSaving() {
if !bc.stopping.CompareAndSwap(false, true) {
return
}
+ // Signal shutdown history pruner.
+ if bc.histPruner != nil {
+ bc.histPruner.close()
+ }
// Signal shutdown tx indexer.
if bc.txIndexer != nil {
bc.txIndexer.close()
diff --git a/core/blockchain_reader.go b/core/blockchain_reader.go
index f1b40d0d0c..fb04dfaac3 100644
--- a/core/blockchain_reader.go
+++ b/core/blockchain_reader.go
@@ -24,6 +24,7 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus"
"github.com/ethereum/go-ethereum/consensus/misc/eip4844"
+ "github.com/ethereum/go-ethereum/core/history"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/state/snapshot"
@@ -472,8 +473,8 @@ func (bc *BlockChain) StateIndexProgress() (uint64, error) {
return bc.triedb.IndexProgress()
}
-// HistoryPruningCutoff returns the configured history pruning point.
-// Blocks before this might not be available in the database.
+// HistoryPruningCutoff returns the actual history pruning point based on DB state.
+// Blocks before this are not available in the database.
func (bc *BlockChain) HistoryPruningCutoff() (uint64, common.Hash) {
pt := bc.historyPrunePoint.Load()
if pt == nil {
@@ -482,6 +483,12 @@ func (bc *BlockChain) HistoryPruningCutoff() (uint64, common.Hash) {
return pt.BlockNumber, pt.BlockHash
}
+// HistoryPolicy returns the configured history pruning policy. The downloader
+// uses this to decide what blocks to fetch during sync.
+func (bc *BlockChain) HistoryPolicy() history.HistoryPolicy {
+ return bc.cfg.HistoryPolicy
+}
+
// TrieDB retrieves the low level trie database used for data storage.
func (bc *BlockChain) TrieDB() *triedb.Database {
return bc.triedb
diff --git a/core/history/historymode.go b/core/history/historymode.go
index 1adfe014b2..16220374f2 100644
--- a/core/history/historymode.go
+++ b/core/history/historymode.go
@@ -35,10 +35,14 @@ const (
// KeepPostPrague sets the history pruning point to the Prague (Pectra) activation block.
KeepPostPrague
+
+ // KeepRecent configures a rolling history window, keeping the last N blocks
+ // and continuously pruning older block bodies and receipts.
+ KeepRecent
)
func (m HistoryMode) IsValid() bool {
- return m <= KeepPostPrague
+ return m <= KeepRecent
}
func (m HistoryMode) String() string {
@@ -49,6 +53,8 @@ func (m HistoryMode) String() string {
return "postmerge"
case KeepPostPrague:
return "postprague"
+ case KeepRecent:
+ return "recent"
default:
return fmt.Sprintf("invalid HistoryMode(%d)", m)
}
@@ -71,8 +77,10 @@ func (m *HistoryMode) UnmarshalText(text []byte) error {
*m = KeepPostMerge
case "postprague":
*m = KeepPostPrague
+ case "recent":
+ *m = KeepRecent
default:
- return fmt.Errorf(`unknown history mode %q, want "all", "postmerge", or "postprague"`, text)
+ return fmt.Errorf(`unknown history mode %q, want "all", "postmerge", "postprague" or "recent"`, text)
}
return nil
}
@@ -111,15 +119,19 @@ var staticPrunePoints = map[HistoryMode]map[common.Hash]*PrunePoint{
}
// HistoryPolicy describes the configured history pruning strategy. It captures
-// user intent as opposed to the actual DB state.
+// user intent as opposed to actual DB state.
type HistoryPolicy struct {
Mode HistoryMode
// Static prune point for PostMerge/PostPrague, nil otherwise.
Target *PrunePoint
+ // Rolling window size for KeepRecent, 0 otherwise.
+ Window uint64
}
-// NewPolicy constructs a HistoryPolicy from the given mode and genesis hash.
-func NewPolicy(mode HistoryMode, genesisHash common.Hash) (HistoryPolicy, error) {
+// NewPolicy constructs a HistoryPolicy from the given mode, genesis hash, and
+// rolling window size. The genesis hash is used to look up static prune points
+// for PostMerge/PostPrague modes.
+func NewPolicy(mode HistoryMode, genesisHash common.Hash, historyBlocks uint64) (HistoryPolicy, error) {
switch mode {
case KeepAll:
return HistoryPolicy{Mode: KeepAll}, nil
@@ -131,6 +143,13 @@ func NewPolicy(mode HistoryMode, genesisHash common.Hash) (HistoryPolicy, error)
}
return HistoryPolicy{Mode: mode, Target: point}, nil
+ case KeepRecent:
+ const minHistoryBlocks = params.FullImmutabilityThreshold + 10000
+ if historyBlocks < minHistoryBlocks {
+ return HistoryPolicy{}, fmt.Errorf("history.blocks must be at least %d, got %d", minHistoryBlocks, historyBlocks)
+ }
+ return HistoryPolicy{Mode: KeepRecent, Window: historyBlocks}, nil
+
default:
return HistoryPolicy{}, fmt.Errorf("invalid history mode: %d", mode)
}
diff --git a/core/history/historymode_test.go b/core/history/historymode_test.go
index 87eae188dd..d6a3062d62 100644
--- a/core/history/historymode_test.go
+++ b/core/history/historymode_test.go
@@ -24,17 +24,17 @@ import (
)
func TestNewPolicy(t *testing.T) {
- // KeepAll: no target.
- p, err := NewPolicy(KeepAll, params.MainnetGenesisHash)
+ // KeepAll: no target, no window.
+ p, err := NewPolicy(KeepAll, params.MainnetGenesisHash, 0)
if err != nil {
t.Fatalf("KeepAll: %v", err)
}
- if p.Mode != KeepAll || p.Target != nil {
+ if p.Mode != KeepAll || p.Target != nil || p.Window != 0 {
t.Errorf("KeepAll: unexpected policy %+v", p)
}
// PostMerge: resolves known mainnet prune point.
- p, err = NewPolicy(KeepPostMerge, params.MainnetGenesisHash)
+ p, err = NewPolicy(KeepPostMerge, params.MainnetGenesisHash, 0)
if err != nil {
t.Fatalf("PostMerge: %v", err)
}
@@ -43,7 +43,7 @@ func TestNewPolicy(t *testing.T) {
}
// PostPrague: resolves known mainnet prune point.
- p, err = NewPolicy(KeepPostPrague, params.MainnetGenesisHash)
+ p, err = NewPolicy(KeepPostPrague, params.MainnetGenesisHash, 0)
if err != nil {
t.Fatalf("PostPrague: %v", err)
}
@@ -52,7 +52,21 @@ func TestNewPolicy(t *testing.T) {
}
// PostMerge on unknown network: error.
- if _, err = NewPolicy(KeepPostMerge, common.HexToHash("0xdeadbeef")); err == nil {
+ if _, err = NewPolicy(KeepPostMerge, common.HexToHash("0xdeadbeef"), 0); err == nil {
t.Fatal("PostMerge unknown network: expected error")
}
+
+ // KeepRecent: valid window.
+ p, err = NewPolicy(KeepRecent, common.Hash{}, 200000)
+ if err != nil {
+ t.Fatalf("KeepRecent: %v", err)
+ }
+ if p.Window != 200000 {
+ t.Errorf("KeepRecent: window got %d, want 200000", p.Window)
+ }
+
+ // KeepRecent below minimum: error.
+ if _, err = NewPolicy(KeepRecent, common.Hash{}, 50000); err == nil {
+ t.Fatal("KeepRecent below minimum: expected error")
+ }
}
diff --git a/core/history_pruner.go b/core/history_pruner.go
new file mode 100644
index 0000000000..35472dd4d0
--- /dev/null
+++ b/core/history_pruner.go
@@ -0,0 +1,131 @@
+// Copyright 2026 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// The go-ethereum library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see .
+
+package core
+
+import (
+ "time"
+
+ "github.com/ethereum/go-ethereum/core/history"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/log"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// pruneChainHistory prunes block bodies, receipts, and transaction index entries
+// below the given target block. It is the single shared implementation used by
+// both startup pruning and the rolling history pruner.
+func (bc *BlockChain) pruneChainHistory(target uint64) error {
+ tail, err := bc.db.Tail()
+ if err != nil {
+ return err
+ }
+ if tail >= target {
+ return nil
+ }
+ rawdb.PruneTransactionIndex(bc.db, target)
+ if _, err := bc.db.TruncateTail(target); err != nil {
+ return err
+ }
+ bc.updateHistoryPrunePoint(target)
+ log.Debug("Pruned chain history", "from", tail, "to", target)
+ return nil
+}
+
+// updateHistoryPrunePoint updates the atomic prune point on the blockchain.
+func (bc *BlockChain) updateHistoryPrunePoint(blockNumber uint64) {
+ hash := bc.GetCanonicalHash(blockNumber)
+ bc.historyPrunePoint.Store(&history.PrunePoint{
+ BlockNumber: blockNumber,
+ BlockHash: hash,
+ })
+}
+
+// historyPruner continuously prunes old block bodies and receipts, maintaining
+// a rolling window of recent blocks.
+type historyPruner struct {
+ historyBlocks uint64
+ chain *BlockChain
+ term chan chan struct{}
+ closed chan struct{}
+}
+
+// newHistoryPruner creates a new history pruner and starts its background loop.
+func newHistoryPruner(historyBlocks uint64, chain *BlockChain) *historyPruner {
+ pruner := &historyPruner{
+ historyBlocks: historyBlocks,
+ chain: chain,
+ term: make(chan chan struct{}),
+ closed: make(chan struct{}),
+ }
+ go pruner.loop()
+ log.Info("Initialized rolling history pruner", "window", historyBlocks)
+ return pruner
+}
+
+// loop is the main background goroutine that periodically checks if pruning is needed.
+func (p *historyPruner) loop() {
+ defer close(p.closed)
+
+ // Fire immediately on first run
+ timer := time.NewTimer(0)
+ defer timer.Stop()
+
+ for {
+ select {
+ case <-timer.C:
+ p.prune()
+ timer.Reset(3 * time.Hour)
+
+ case ch := <-p.term:
+ close(ch)
+ return
+ }
+ }
+}
+
+// prune performs a single round of pruning if needed.
+func (p *historyPruner) prune() {
+ head := p.chain.CurrentBlock()
+ if head == nil {
+ return
+ }
+ headNum := head.Number.Uint64()
+ if headNum <= p.historyBlocks {
+ return
+ }
+ target := headNum - p.historyBlocks
+
+ // Sanity check that target has been frozen.
+ frozen := headNum - params.FullImmutabilityThreshold
+ if target > frozen {
+ log.Error("Rolling pruner target exceeds frozen range", "target", target, "frozen", frozen, "head", headNum, "window", p.historyBlocks)
+ return
+ }
+ if err := p.chain.pruneChainHistory(target); err != nil {
+ log.Error("Failed to prune chain history", "err", err, "target", target)
+ }
+}
+
+// close signals the pruner to stop and waits for it to exit.
+func (p *historyPruner) close() {
+ ch := make(chan struct{})
+ select {
+ case p.term <- ch:
+ <-ch
+ case <-p.closed:
+ }
+}
diff --git a/core/history_pruner_test.go b/core/history_pruner_test.go
new file mode 100644
index 0000000000..eaeee06eb8
--- /dev/null
+++ b/core/history_pruner_test.go
@@ -0,0 +1,213 @@
+// Copyright 2026 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// The go-ethereum library is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see .
+
+package core
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/beacon"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core/history"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/ethdb"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// newTestChain generates a test chain of the given length and inserts it into a
+// fresh database using InsertReceiptChain so the blocks end up in the freezer.
+// Returns the database (still open), the genesis spec, and the generated blocks.
+func newTestChain(t *testing.T, length int) (ethdb.Database, *Genesis, []*types.Block) {
+ t.Helper()
+
+ gspec := &Genesis{
+ Config: params.TestChainConfig,
+ Alloc: types.GenesisAlloc{common.HexToAddress("0x01"): {Balance: big.NewInt(1e18)}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ engine := beacon.New(ethash.NewFaker())
+ _, blocks, receipts := GenerateChainWithGenesis(gspec, engine, length, nil)
+
+ // Insert the chain into a KeepAll database so all blocks land in the freezer.
+ db, _ := rawdb.Open(rawdb.NewMemoryDatabase(), rawdb.OpenOptions{})
+ chain, err := NewBlockChain(db, gspec, engine, DefaultConfig().WithStateScheme(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("failed to create chain: %v", err)
+ }
+ if _, err := chain.InsertReceiptChain(blocks, types.EncodeBlockReceiptLists(receipts), uint64(length)); err != nil {
+ t.Fatalf("failed to insert receipt chain: %v", err)
+ }
+ chain.Stop()
+ return db, gspec, blocks
+}
+
+// reopenChain reopens a BlockChain on the given database with the given history policy.
+// Returns the chain and any error from NewBlockChain (including initializeHistoryPruning errors).
+func reopenChain(db ethdb.Database, gspec *Genesis, policy history.HistoryPolicy) (*BlockChain, error) {
+ cfg := DefaultConfig().WithStateScheme(rawdb.HashScheme)
+ cfg.HistoryPolicy = policy
+ return NewBlockChain(db, gspec, beacon.New(ethash.NewFaker()), cfg)
+}
+
+func TestInitHistoryPruningKeepAllPrunedDB(t *testing.T) {
+ db, gspec, _ := newTestChain(t, 200)
+ defer db.Close()
+
+ // Pre-prune the freezer to simulate a previously pruned database.
+ if _, err := db.TruncateTail(50); err != nil {
+ t.Fatalf("failed to truncate tail: %v", err)
+ }
+
+ chain, err := reopenChain(db, gspec, history.HistoryPolicy{Mode: history.KeepAll})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ defer chain.Stop()
+
+ cutoff, _ := chain.HistoryPruningCutoff()
+ if cutoff != 50 {
+ t.Errorf("prune point: got %d, want 50", cutoff)
+ }
+}
+
+func TestInitHistoryPruningKeepRecentExpandedWindow(t *testing.T) {
+ db, gspec, _ := newTestChain(t, 200)
+ defer db.Close()
+
+ // Pre-prune to block 100.
+ if _, err := db.TruncateTail(100); err != nil {
+ t.Fatalf("failed to truncate tail: %v", err)
+ }
+
+ // Reopen with a larger window — tail (100) > target (200-150=50).
+ // KeepRecent should accept this (window was expanded).
+ policy := history.HistoryPolicy{Mode: history.KeepRecent, Window: 150}
+ chain, err := reopenChain(db, gspec, policy)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ defer chain.Stop()
+
+ cutoff, _ := chain.HistoryPruningCutoff()
+ if cutoff != 100 {
+ t.Errorf("should accept existing tail: got cutoff=%d, want 100", cutoff)
+ }
+}
+
+func TestPruneChainHistory(t *testing.T) {
+ db, gspec, _ := newTestChain(t, 200)
+ defer db.Close()
+
+ chain, err := reopenChain(db, gspec, history.HistoryPolicy{Mode: history.KeepAll})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ defer chain.Stop()
+
+ // Prune to block 50 and verify the freezer tail and prune point advance.
+ if err := chain.pruneChainHistory(50); err != nil {
+ t.Fatalf("pruneChainHistory: %v", err)
+ }
+ tail, _ := db.Tail()
+ if tail != 50 {
+ t.Errorf("freezer tail: got %d, want 50", tail)
+ }
+ cutoff, _ := chain.HistoryPruningCutoff()
+ if cutoff != 50 {
+ t.Errorf("prune cutoff: got %d, want 50", cutoff)
+ }
+
+ // Prune again to a higher target.
+ if err := chain.pruneChainHistory(100); err != nil {
+ t.Fatalf("pruneChainHistory: %v", err)
+ }
+ tail, _ = db.Tail()
+ if tail != 100 {
+ t.Errorf("freezer tail after second prune: got %d, want 100", tail)
+ }
+ cutoff, _ = chain.HistoryPruningCutoff()
+ if cutoff != 100 {
+ t.Errorf("prune cutoff after second prune: got %d, want 100", cutoff)
+ }
+
+ // Prune to a lower target — should be a no-op.
+ if err := chain.pruneChainHistory(50); err != nil {
+ t.Fatalf("pruneChainHistory (no-op): %v", err)
+ }
+ tail, _ = db.Tail()
+ if tail != 100 {
+ t.Errorf("freezer tail after no-op prune: got %d, want 100", tail)
+ }
+}
+
+func TestInitHistoryPruningStartupPrune(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping slow test")
+ }
+ db, gspec, blocks := newTestChain(t, 91000)
+ defer db.Close()
+
+ // Reopen with a static target at block 500. The chain is long enough
+ // (91000 >= 500 + 90000) so initializeHistoryPruning should prune.
+ policy := history.HistoryPolicy{
+ Mode: history.KeepPostMerge,
+ Target: &history.PrunePoint{
+ BlockNumber: 500,
+ BlockHash: blocks[499].Hash(),
+ },
+ }
+ chain, err := reopenChain(db, gspec, policy)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ defer chain.Stop()
+
+ tail, _ := db.Tail()
+ if tail != 500 {
+ t.Errorf("freezer tail: got %d, want 500", tail)
+ }
+ cutoff, _ := chain.HistoryPruningCutoff()
+ if cutoff != 500 {
+ t.Errorf("prune cutoff: got %d, want 500", cutoff)
+ }
+}
+
+func TestInitHistoryPruningStaticModeBeyondTarget(t *testing.T) {
+ db, gspec, blocks := newTestChain(t, 200)
+ defer db.Close()
+
+ // Pre-prune to block 100.
+ if _, err := db.TruncateTail(100); err != nil {
+ t.Fatalf("failed to truncate tail: %v", err)
+ }
+
+ // Use a static policy with target at block 50 — tail (100) > target (50).
+ // Static modes should error.
+ policy := history.HistoryPolicy{
+ Mode: history.KeepPostMerge,
+ Target: &history.PrunePoint{
+ BlockNumber: 50,
+ BlockHash: blocks[49].Hash(),
+ },
+ }
+ _, err := reopenChain(db, gspec, policy)
+ if err == nil {
+ t.Fatal("expected 'pruned beyond' error for static mode, got nil")
+ }
+}
diff --git a/eth/backend.go b/eth/backend.go
index e9bea59734..b43c3607d8 100644
--- a/eth/backend.go
+++ b/eth/backend.go
@@ -221,7 +221,7 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) {
rawdb.WriteDatabaseVersion(chainDb, core.BlockChainVersion)
}
}
- histPolicy, err := history.NewPolicy(config.HistoryMode, genesisHash)
+ histPolicy, err := history.NewPolicy(config.HistoryMode, genesisHash, config.HistoryBlocks)
if err != nil {
return nil, err
}
diff --git a/eth/downloader/downloader.go b/eth/downloader/downloader.go
index 1de0933842..50662e502e 100644
--- a/eth/downloader/downloader.go
+++ b/eth/downloader/downloader.go
@@ -27,6 +27,7 @@ import (
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/history"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/state/snapshot"
"github.com/ethereum/go-ethereum/core/types"
@@ -123,9 +124,10 @@ type Downloader struct {
committed atomic.Bool
ancientLimit uint64 // The maximum block number which can be regarded as ancient data.
- // The cutoff block number and hash before which chain segments (bodies
- // and receipts) are skipped during synchronization. 0 means the entire
- // chain segment is aimed for synchronization.
+ // History pruning policy and derived cutoff. The policy is the configured
+ // intent; cutoff number/hash are computed from it (possibly deferred for
+ // KeepRecent until the sync pivot is known).
+ histPolicy history.HistoryPolicy
chainCutoffNumber uint64
chainCutoffHash common.Hash
@@ -223,14 +225,19 @@ type BlockChain interface {
// with trie nodes.
TrieDB() *triedb.Database
- // HistoryPruningCutoff returns the configured history pruning point.
- // Block bodies along with the receipts will be skipped for synchronization.
- HistoryPruningCutoff() (uint64, common.Hash)
+ // HistoryPolicy returns the configured history pruning policy (intent).
+ HistoryPolicy() history.HistoryPolicy
}
// New creates a new downloader to fetch hashes and blocks from remote peers.
func New(stateDb ethdb.Database, mode ethconfig.SyncMode, mux *event.TypeMux, chain BlockChain, dropPeer peerDropFn, success func()) *Downloader {
- cutoffNumber, cutoffHash := chain.HistoryPruningCutoff()
+ policy := chain.HistoryPolicy()
+ var cutoffNumber uint64
+ var cutoffHash common.Hash
+ if policy.Target != nil {
+ cutoffNumber = policy.Target.BlockNumber
+ cutoffHash = policy.Target.BlockHash
+ }
dl := &Downloader{
stateDB: stateDb,
moder: newSyncModer(mode, chain, stateDb),
@@ -238,6 +245,7 @@ func New(stateDb ethdb.Database, mode ethconfig.SyncMode, mux *event.TypeMux, ch
queue: newQueue(blockCacheMaxItems, blockCacheInitialItems),
peers: newPeerSet(),
blockchain: chain,
+ histPolicy: policy,
chainCutoffNumber: cutoffNumber,
chainCutoffHash: cutoffHash,
dropPeer: dropPeer,
@@ -572,6 +580,16 @@ func (d *Downloader) syncToHead() (err error) {
log.Info("Truncated excess ancient chain segment", "oldhead", frozen-1, "newhead", origin)
}
}
+ // For KeepRecent mode, compute the cutoff now that we know the sync target.
+ if mode == ethconfig.SnapSync && d.histPolicy.Mode == history.KeepRecent && d.histPolicy.Window != 0 {
+ if height > d.histPolicy.Window {
+ d.chainCutoffNumber = height - d.histPolicy.Window
+ if h := d.skeleton.Header(d.chainCutoffNumber); h != nil {
+ d.chainCutoffHash = h.Hash()
+ }
+ log.Info("Computed rolling history cutoff for sync", "cutoff", d.chainCutoffNumber, "window", d.histPolicy.Window, "head", height)
+ }
+ }
// Skip ancient chain segments if Geth is running with a configured chain cutoff.
// These segments are not guaranteed to be available in the network.
chainOffset := origin + 1
diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go
index 01aaaa751b..1e3b2eedfd 100644
--- a/eth/ethconfig/config.go
+++ b/eth/ethconfig/config.go
@@ -95,6 +95,10 @@ type Config struct {
// HistoryMode configures chain history retention.
HistoryMode history.HistoryMode
+ // HistoryBlocks specifies the rolling window size for KeepRecent mode.
+ // Only used when HistoryMode is KeepRecent.
+ HistoryBlocks uint64 `toml:",omitempty"`
+
// This can be set to list of enrtree:// URLs which will be queried for
// nodes to connect to.
EthDiscoveryURLs []string
diff --git a/eth/ethconfig/gen_config.go b/eth/ethconfig/gen_config.go
index 6f94a409e5..640eb728b6 100644
--- a/eth/ethconfig/gen_config.go
+++ b/eth/ethconfig/gen_config.go
@@ -21,6 +21,7 @@ func (c Config) MarshalTOML() (interface{}, error) {
NetworkId uint64
SyncMode SyncMode
HistoryMode history.HistoryMode
+ HistoryBlocks uint64 `toml:",omitempty"`
EthDiscoveryURLs []string
SnapDiscoveryURLs []string
NoPruning bool
@@ -74,6 +75,7 @@ func (c Config) MarshalTOML() (interface{}, error) {
enc.NetworkId = c.NetworkId
enc.SyncMode = c.SyncMode
enc.HistoryMode = c.HistoryMode
+ enc.HistoryBlocks = c.HistoryBlocks
enc.EthDiscoveryURLs = c.EthDiscoveryURLs
enc.SnapDiscoveryURLs = c.SnapDiscoveryURLs
enc.NoPruning = c.NoPruning
@@ -131,6 +133,7 @@ func (c *Config) UnmarshalTOML(unmarshal func(interface{}) error) error {
NetworkId *uint64
SyncMode *SyncMode
HistoryMode *history.HistoryMode
+ HistoryBlocks *uint64 `toml:",omitempty"`
EthDiscoveryURLs []string
SnapDiscoveryURLs []string
NoPruning *bool
@@ -195,6 +198,9 @@ func (c *Config) UnmarshalTOML(unmarshal func(interface{}) error) error {
if dec.HistoryMode != nil {
c.HistoryMode = *dec.HistoryMode
}
+ if dec.HistoryBlocks != nil {
+ c.HistoryBlocks = *dec.HistoryBlocks
+ }
if dec.EthDiscoveryURLs != nil {
c.EthDiscoveryURLs = dec.EthDiscoveryURLs
}