From 5b15ecb8bb2b63b1940c7a571a99c7fe6ef73874 Mon Sep 17 00:00:00 2001 From: Sina Mahmoodi Date: Fri, 27 Mar 2026 11:26:31 +0000 Subject: [PATCH 1/7] core: rolling history expiry Introduce a new history mode `--history.chain=recent` which can be configured with a `--history.blocks` flag that continuously prunes old block bodies, receipts, and the tx index, maintaining a configurable rolling window of recent history. --- cmd/geth/chaincmd.go | 5 +- cmd/geth/main.go | 1 + cmd/utils/flags.go | 29 ++++- cmd/workload/testsuite.go | 4 +- core/blockchain.go | 95 +++++++++----- core/blockchain_reader.go | 11 +- core/history/historymode.go | 29 ++++- core/history/historymode_test.go | 26 +++- core/history_pruner.go | 131 +++++++++++++++++++ core/history_pruner_test.go | 213 +++++++++++++++++++++++++++++++ eth/backend.go | 2 +- eth/downloader/downloader.go | 32 ++++- eth/ethconfig/config.go | 4 + eth/ethconfig/gen_config.go | 6 + 14 files changed, 533 insertions(+), 55 deletions(-) create mode 100644 core/history_pruner.go create mode 100644 core/history_pruner_test.go 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 } From b4156b31ccdc79e7b75c372476e08c3090ecb854 Mon Sep 17 00:00:00 2001 From: Sina Mahmoodi Date: Fri, 27 Mar 2026 15:53:48 +0000 Subject: [PATCH 2/7] fix ci --- core/blockchain.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index ac35954c7c..913a408e89 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -721,10 +721,7 @@ func (bc *BlockChain) loadLastState() error { // 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, err := bc.db.Tail() - if err != nil { - return err - } + freezerTail, _ := bc.db.Tail() policy := bc.cfg.HistoryPolicy // Compute the current prune target from the policy. var target uint64 From 43cc92e68ae0da69b2463a539e6cd8bb8c6e75fb Mon Sep 17 00:00:00 2001 From: Sina Mahmoodi Date: Wed, 1 Apr 2026 15:40:46 +0000 Subject: [PATCH 3/7] dont do runtime pruning --- core/blockchain.go | 26 ++++++++++----------- core/history_pruner_test.go | 45 ++++++++++++++++++++++--------------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index 913a408e89..025107cc52 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -764,22 +764,20 @@ func (bc *BlockChain) initializeHistoryPruning(latest uint64) error { 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()) + // Need to prune (freezerTail < target). + switch policy.Mode { + case history.KeepPostMerge, history.KeepPostPrague: + // Static modes require the user to run 'geth prune-history' offline + // rather than blocking startup for hours (tx index pruning is slow). + return fmt.Errorf("history not pruned to target block %d (current tail %d), run 'geth prune-history --history.chain=%s' first", target, freezerTail, policy.Mode) + + case history.KeepRecent: + // The rolling pruner will gradually catch up in the background. + if freezerTail > 0 { + bc.updateHistoryPrunePoint(freezerTail) } + log.Warn("Chain history is behind pruning target, rolling pruner will catch up", "tail", freezerTail, "target", target) } - 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 } diff --git a/core/history_pruner_test.go b/core/history_pruner_test.go index eaeee06eb8..c9bfd3bbd7 100644 --- a/core/history_pruner_test.go +++ b/core/history_pruner_test.go @@ -156,36 +156,45 @@ func TestPruneChainHistory(t *testing.T) { } } -func TestInitHistoryPruningStartupPrune(t *testing.T) { - if testing.Short() { - t.Skip("skipping slow test") - } - db, gspec, blocks := newTestChain(t, 91000) +func TestInitHistoryPruningStaticModeRequiresPruneHistory(t *testing.T) { + db, gspec, blocks := newTestChain(t, 200) defer db.Close() - // Reopen with a static target at block 500. The chain is long enough - // (91000 >= 500 + 90000) so initializeHistoryPruning should prune. + // Reopen with a static target at block 50. The database is not yet + // pruned to that target, so startup should fail and tell the user to + // run 'geth prune-history'. policy := history.HistoryPolicy{ Mode: history.KeepPostMerge, Target: &history.PrunePoint{ - BlockNumber: 500, - BlockHash: blocks[499].Hash(), + BlockNumber: 50, + BlockHash: blocks[49].Hash(), }, } + _, err := reopenChain(db, gspec, policy) + if err == nil { + t.Fatal("expected error when history not pruned to static target, got nil") + } + + // Freezer tail should remain at 0 — no pruning happened. + tail, _ := db.Tail() + if tail != 0 { + t.Errorf("freezer tail: got %d, want 0 (startup should not prune)", tail) + } +} + +func TestInitHistoryPruningKeepRecentAllowsStartup(t *testing.T) { + db, gspec, _ := newTestChain(t, 200) + defer db.Close() + + // Reopen with KeepRecent and a small window. The tail (0) is behind the + // target but KeepRecent should still allow startup — the rolling pruner + // handles catch-up in the background. + policy := history.HistoryPolicy{Mode: history.KeepRecent, Window: 50} 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) { From f871e1785c708d51713fd6f02e05097f8d6cc021 Mon Sep 17 00:00:00 2001 From: Sina Mahmoodi Date: Wed, 1 Apr 2026 16:05:26 +0000 Subject: [PATCH 4/7] strict recent rule on init --- core/blockchain.go | 19 ++++--------------- core/history_pruner_test.go | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index 025107cc52..e352b9cc6d 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -764,21 +764,10 @@ func (bc *BlockChain) initializeHistoryPruning(latest uint64) error { 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). - switch policy.Mode { - case history.KeepPostMerge, history.KeepPostPrague: - // Static modes require the user to run 'geth prune-history' offline - // rather than blocking startup for hours (tx index pruning is slow). - return fmt.Errorf("history not pruned to target block %d (current tail %d), run 'geth prune-history --history.chain=%s' first", target, freezerTail, policy.Mode) - - case history.KeepRecent: - // The rolling pruner will gradually catch up in the background. - if freezerTail > 0 { - bc.updateHistoryPrunePoint(freezerTail) - } - log.Warn("Chain history is behind pruning target, rolling pruner will catch up", "tail", freezerTail, "target", target) - } - return nil + // Need to prune (freezerTail < target). Large-scale pruning is not + // performed at startup to avoid blocking the node for hours (tx index + // pruning is particularly slow). Use 'geth prune-history' instead. + return fmt.Errorf("history not pruned to target block %d (current tail %d), run 'geth prune-history' first", target, freezerTail) } // SetHead rewinds the local chain to a new head. Depending on whether the node diff --git a/core/history_pruner_test.go b/core/history_pruner_test.go index c9bfd3bbd7..7aacecd8bc 100644 --- a/core/history_pruner_test.go +++ b/core/history_pruner_test.go @@ -182,19 +182,20 @@ func TestInitHistoryPruningStaticModeRequiresPruneHistory(t *testing.T) { } } -func TestInitHistoryPruningKeepRecentAllowsStartup(t *testing.T) { - db, gspec, _ := newTestChain(t, 200) +func TestInitHistoryPruningKeepRecentRequiresPruneHistory(t *testing.T) { + db, gspec, blocks := newTestChain(t, 200) defer db.Close() - // Reopen with KeepRecent and a small window. The tail (0) is behind the - // target but KeepRecent should still allow startup — the rolling pruner - // handles catch-up in the background. + // Set the head block so CurrentBlock() returns block 200 on reopen. + rawdb.WriteHeadBlockHash(db, blocks[len(blocks)-1].Hash()) + + // Reopen with KeepRecent and a small window. The tail (0) is behind + // the target (200-50=150), so startup should fail. policy := history.HistoryPolicy{Mode: history.KeepRecent, Window: 50} - chain, err := reopenChain(db, gspec, policy) - if err != nil { - t.Fatalf("unexpected error: %v", err) + _, err := reopenChain(db, gspec, policy) + if err == nil { + t.Fatal("expected error when history not pruned to target, got nil") } - defer chain.Stop() } func TestInitHistoryPruningStaticModeBeyondTarget(t *testing.T) { From bfd03b86ae33153ac2355f71a6c9965f8174b6ed Mon Sep 17 00:00:00 2001 From: Sina Mahmoodi Date: Wed, 1 Apr 2026 20:37:14 +0200 Subject: [PATCH 5/7] comment tweaks --- core/blockchain.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index e352b9cc6d..084e5368ad 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -718,8 +718,7 @@ func (bc *BlockChain) loadLastState() error { return nil } -// initializeHistoryPruning sets bc.historyPrunePoint based on actual DB state, -// and prunes chain history at startup if needed. +// initializeHistoryPruning sets bc.historyPrunePoint based on actual DB state. func (bc *BlockChain) initializeHistoryPruning(latest uint64) error { freezerTail, _ := bc.db.Tail() policy := bc.cfg.HistoryPolicy @@ -727,8 +726,9 @@ func (bc *BlockChain) initializeHistoryPruning(latest uint64) error { 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.updateHistoryPrunePoint(freezerTail) } return nil @@ -761,12 +761,11 @@ func (bc *BlockChain) initializeHistoryPruning(latest uint64) error { 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). Large-scale pruning is not // performed at startup to avoid blocking the node for hours (tx index - // pruning is particularly slow). Use 'geth prune-history' instead. + // pruning is particularly slow). return fmt.Errorf("history not pruned to target block %d (current tail %d), run 'geth prune-history' first", target, freezerTail) } From 91b40ed46f3c24df402c1ac95faf955290bbe7f2 Mon Sep 17 00:00:00 2001 From: Sina Mahmoodi Date: Wed, 1 Apr 2026 20:50:58 +0200 Subject: [PATCH 6/7] minor improvements --- core/blockchain.go | 11 +++++++---- core/blockchain_reader.go | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/core/blockchain.go b/core/blockchain.go index 084e5368ad..db7144f2e7 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -720,10 +720,12 @@ func (bc *BlockChain) loadLastState() error { // initializeHistoryPruning sets bc.historyPrunePoint based on actual DB state. func (bc *BlockChain) initializeHistoryPruning(latest uint64) error { - freezerTail, _ := bc.db.Tail() - policy := bc.cfg.HistoryPolicy + var ( + freezerTail, _ = bc.db.Tail() + policy = bc.cfg.HistoryPolicy + target uint64 + ) // Compute the current prune target from the policy. - var target uint64 switch policy.Mode { case history.KeepAll: if freezerTail > 0 { @@ -739,8 +741,9 @@ func (bc *BlockChain) initializeHistoryPruning(latest uint64) error { 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 { + // Chain too short for pruning. Record actual DB state. + log.Warn("Chain too short for pruning", "tail", freezerTail, "window", policy.Window) bc.updateHistoryPrunePoint(freezerTail) } return nil diff --git a/core/blockchain_reader.go b/core/blockchain_reader.go index fb04dfaac3..72c2d3ed59 100644 --- a/core/blockchain_reader.go +++ b/core/blockchain_reader.go @@ -473,7 +473,7 @@ func (bc *BlockChain) StateIndexProgress() (uint64, error) { return bc.triedb.IndexProgress() } -// HistoryPruningCutoff returns the actual history pruning point based on DB state. +// HistoryPruningCutoff returns the 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() From b4d3d8c01b0a283c0414e83304d0e4d237d15a4a Mon Sep 17 00:00:00 2001 From: Sina Mahmoodi Date: Thu, 2 Apr 2026 18:07:43 +0000 Subject: [PATCH 7/7] allow window pruning in prune-history cmd --- cmd/geth/chaincmd.go | 88 ++++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/cmd/geth/chaincmd.go b/cmd/geth/chaincmd.go index 247240be89..6e05660c09 100644 --- a/cmd/geth/chaincmd.go +++ b/cmd/geth/chaincmd.go @@ -212,6 +212,7 @@ This command dumps out the state for a given block (or latest, if none provided) ArgsUsage: "", Flags: slices.Concat(utils.DatabaseFlags, []cli.Flag{ utils.ChainHistoryFlag, + utils.HistoryBlocksFlag, }), Description: ` The prune-history command removes historical block bodies and receipts from the @@ -220,7 +221,8 @@ helps reduce storage requirements for nodes that don't need full historical data The --history.chain flag is required to specify the pruning target: - postmerge: Prune up to the merge block. The node will keep the merge block and everything thereafter. - - postprague: Prune up to the Prague (Pectra) upgrade block. The node will keep the prague block and everything thereafter.`, + - postprague: Prune up to the Prague (Pectra) upgrade block. The node will keep the prague block and everything thereafter. + - recent: Prune to keep only the last N blocks (set N with --history.blocks).`, } downloadEraCommand = &cli.Command{ @@ -720,9 +722,6 @@ 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() @@ -732,51 +731,60 @@ func pruneHistory(ctx *cli.Context) error { defer chaindb.Close() defer chain.Stop() - // Determine the prune point based on the history mode. - genesisHash := chain.Genesis().Hash() - policy, err := history.NewPolicy(mode, genesisHash, 0) - if err != nil { - return err - } - if policy.Target == nil { - return fmt.Errorf("prune point for %q not found for this network", mode.String()) - } - var ( - targetBlock = policy.Target.BlockNumber - targetBlockHash = policy.Target.BlockHash - ) - - // Check the current freezer tail to see if pruning is needed/possible. - freezerTail, _ := chaindb.Tail() - if freezerTail > 0 { - if freezerTail == targetBlock { - log.Info("Database already pruned to target block", "tail", freezerTail) - return nil - } - if freezerTail > targetBlock { - // Database is pruned beyond the target - can't unprune. - return fmt.Errorf("database is already pruned to block %d, which is beyond target %d. Cannot unprune. To restore history, use 'geth import-history'", freezerTail, targetBlock) - } - // freezerTail < targetBlock: we can prune further, continue below. - } - - // Check we're far enough past the target to ensure all data is in freezer. + // Determine the prune target based on the history mode. currentHeader := chain.CurrentHeader() if currentHeader == nil { return errors.New("current header not found") } + var targetBlock uint64 + switch mode { + case history.KeepPostMerge, history.KeepPostPrague: + genesisHash := chain.Genesis().Hash() + policy, err := history.NewPolicy(mode, genesisHash, 0) + if err != nil { + return err + } + if policy.Target == nil { + return fmt.Errorf("prune point for %q not found for this network", mode.String()) + } + targetBlock = policy.Target.BlockNumber + + // Double-check the target block in db has the expected hash. + hash := rawdb.ReadCanonicalHash(chaindb, targetBlock) + if hash != policy.Target.BlockHash { + return fmt.Errorf("target block hash mismatch at block %d: got %s, want %s", targetBlock, hash.Hex(), policy.Target.BlockHash.Hex()) + } + + case history.KeepRecent: + window := utils.HistoryBlocksFlag.Value + if ctx.IsSet(utils.HistoryBlocksFlag.Name) { + window = ctx.Uint64(utils.HistoryBlocksFlag.Name) + } + if window < params.FullImmutabilityThreshold+10000 { + return fmt.Errorf("--%s: value %d is too small, minimum is %d", utils.HistoryBlocksFlag.Name, window, params.FullImmutabilityThreshold+10000) + } + head := currentHeader.Number.Uint64() + if head <= window { + log.Info("Chain too short for pruning", "head", head, "window", window) + return nil + } + targetBlock = head - window + } + + // Check the current freezer tail to see if pruning is needed/possible. + freezerTail, _ := chaindb.Tail() + if freezerTail >= targetBlock { + log.Info("Database already pruned to or beyond target", "tail", freezerTail, "target", targetBlock) + return nil + } + + // Check we're far enough past the target to ensure all data is in freezer. if currentHeader.Number.Uint64() < targetBlock+params.FullImmutabilityThreshold { return fmt.Errorf("chain not far enough past target block %d, need %d more blocks", targetBlock, targetBlock+params.FullImmutabilityThreshold-currentHeader.Number.Uint64()) } - // Double-check the target block in db has the expected hash. - hash := rawdb.ReadCanonicalHash(chaindb, targetBlock) - if hash != targetBlockHash { - return fmt.Errorf("target block hash mismatch: got %s, want %s", hash.Hex(), targetBlockHash.Hex()) - } - - log.Info("Starting history pruning", "head", currentHeader.Number, "target", targetBlock, "targetHash", targetBlockHash.Hex()) + log.Info("Starting history pruning", "head", currentHeader.Number, "target", targetBlock) start := time.Now() rawdb.PruneTransactionIndex(chaindb, targetBlock) if _, err := chaindb.TruncateTail(targetBlock); err != nil {