This commit is contained in:
Sina M 2026-05-06 21:55:16 -07:00 committed by GitHub
commit 1f6917fa0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 572 additions and 90 deletions

View file

@ -212,6 +212,7 @@ This command dumps out the state for a given block (or latest, if none provided)
ArgsUsage: "", ArgsUsage: "",
Flags: slices.Concat(utils.DatabaseFlags, []cli.Flag{ Flags: slices.Concat(utils.DatabaseFlags, []cli.Flag{
utils.ChainHistoryFlag, utils.ChainHistoryFlag,
utils.HistoryBlocksFlag,
}), }),
Description: ` Description: `
The prune-history command removes historical block bodies and receipts from the 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: 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. - 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{ downloadEraCommand = &cli.Command{
@ -729,51 +731,60 @@ func pruneHistory(ctx *cli.Context) error {
defer chaindb.Close() defer chaindb.Close()
defer chain.Stop() defer chain.Stop()
// Determine the prune point based on the history mode. // Determine the prune target based on the history mode.
genesisHash := chain.Genesis().Hash()
policy, err := history.NewPolicy(mode, genesisHash)
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.
currentHeader := chain.CurrentHeader() currentHeader := chain.CurrentHeader()
if currentHeader == nil { if currentHeader == nil {
return errors.New("current header not found") 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 { if currentHeader.Number.Uint64() < targetBlock+params.FullImmutabilityThreshold {
return fmt.Errorf("chain not far enough past target block %d, need %d more blocks", return fmt.Errorf("chain not far enough past target block %d, need %d more blocks",
targetBlock, targetBlock+params.FullImmutabilityThreshold-currentHeader.Number.Uint64()) targetBlock, targetBlock+params.FullImmutabilityThreshold-currentHeader.Number.Uint64())
} }
// Double-check the target block in db has the expected hash. log.Info("Starting history pruning", "head", currentHeader.Number, "target", targetBlock)
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())
start := time.Now() start := time.Now()
rawdb.PruneTransactionIndex(chaindb, targetBlock) rawdb.PruneTransactionIndex(chaindb, targetBlock)
if _, err := chaindb.TruncateTail(targetBlock); err != nil { if _, err := chaindb.TruncateTail(targetBlock); err != nil {

View file

@ -89,6 +89,7 @@ var (
utils.TxLookupLimitFlag, // deprecated utils.TxLookupLimitFlag, // deprecated
utils.TransactionHistoryFlag, utils.TransactionHistoryFlag,
utils.ChainHistoryFlag, utils.ChainHistoryFlag,
utils.HistoryBlocksFlag,
utils.LogHistoryFlag, utils.LogHistoryFlag,
utils.LogNoHistoryFlag, utils.LogNoHistoryFlag,
utils.LogExportCheckpointsFlag, utils.LogExportCheckpointsFlag,

View file

@ -40,6 +40,7 @@ import (
"github.com/ethereum/go-ethereum/common/fdlimit" "github.com/ethereum/go-ethereum/common/fdlimit"
"github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core" "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/rawdb"
"github.com/ethereum/go-ethereum/core/txpool/blobpool" "github.com/ethereum/go-ethereum/core/txpool/blobpool"
"github.com/ethereum/go-ethereum/core/txpool/legacypool" "github.com/ethereum/go-ethereum/core/txpool/legacypool"
@ -329,10 +330,16 @@ var (
} }
ChainHistoryFlag = &cli.StringFlag{ ChainHistoryFlag = &cli.StringFlag{
Name: "history.chain", 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(), Value: ethconfig.Defaults.HistoryMode.String(),
Category: flags.StateCategory, 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{ LogHistoryFlag = &cli.Uint64Flag{
Name: "history.logs", Name: "history.logs",
Usage: "Number of recent blocks to maintain log search index for (default = about one year, 0 = entire chain)", Usage: "Number of recent blocks to maintain log search index for (default = about one year, 0 = entire chain)",
@ -1786,6 +1793,20 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) {
Fatalf("--%s: %v", ChainHistoryFlag.Name, err) 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) { if ctx.IsSet(CacheFlag.Name) || ctx.IsSet(CacheDatabaseFlag.Name) {
cfg.DatabaseCache = ctx.Int(CacheFlag.Name) * ctx.Int(CacheDatabaseFlag.Name) / 100 cfg.DatabaseCache = ctx.Int(CacheFlag.Name) * ctx.Int(CacheDatabaseFlag.Name) / 100
@ -1847,6 +1868,12 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) {
log.Warn("Disabled transaction unindexing for archive node") 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) { if ctx.IsSet(LogHistoryFlag.Name) {
cfg.LogHistory = ctx.Uint64(LogHistoryFlag.Name) cfg.LogHistory = ctx.Uint64(LogHistoryFlag.Name)
} }

View file

@ -155,7 +155,7 @@ func testConfigFromCLI(ctx *cli.Context) (cfg testConfig) {
} }
cfg.historyPruneBlock = new(uint64) 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 *cfg.historyPruneBlock = p.Target.BlockNumber
} }
case ctx.Bool(testSepoliaFlag.Name): case ctx.Bool(testSepoliaFlag.Name):
@ -182,7 +182,7 @@ func testConfigFromCLI(ctx *cli.Context) (cfg testConfig) {
} }
cfg.historyPruneBlock = new(uint64) 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 *cfg.historyPruneBlock = p.Target.BlockNumber
} }
default: default:

View file

@ -195,7 +195,7 @@ type BlockChainConfig struct {
SnapshotNoBuild bool // Whether the background generation is allowed 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 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 HistoryPolicy history.HistoryPolicy
// Misc options // Misc options
@ -327,6 +327,7 @@ type BlockChain struct {
triedb *triedb.Database // The database handler for maintaining trie nodes. triedb *triedb.Database // The database handler for maintaining trie nodes.
codedb *state.CodeDB // The database handler for maintaining contract codes. codedb *state.CodeDB // The database handler for maintaining contract codes.
txIndexer *txIndexer // Transaction indexer, might be nil if not enabled txIndexer *txIndexer // Transaction indexer, might be nil if not enabled
histPruner *historyPruner // Rolling history pruner, might be nil if not enabled
hc *HeaderChain hc *HeaderChain
rmLogsFeed event.Feed rmLogsFeed event.Feed
@ -562,6 +563,11 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine,
bc.txIndexer = newTxIndexer(uint64(bc.cfg.TxLookupLimit), bc) 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 // Start state size tracker
if bc.cfg.StateSizeTracking { if bc.cfg.StateSizeTracking {
stateSizer, err := state.NewSizeTracker(bc.db, bc.triedb) stateSizer, err := state.NewSizeTracker(bc.db, bc.triedb)
@ -714,47 +720,58 @@ func (bc *BlockChain) loadLastState() error {
return nil return nil
} }
// initializeHistoryPruning sets bc.historyPrunePoint. // initializeHistoryPruning sets bc.historyPrunePoint based on actual DB state.
func (bc *BlockChain) initializeHistoryPruning(latest uint64) error { func (bc *BlockChain) initializeHistoryPruning(latest uint64) error {
freezerTail, _ := bc.db.Tail() var (
policy := bc.cfg.HistoryPolicy freezerTail, _ = bc.db.Tail()
policy = bc.cfg.HistoryPolicy
target uint64
)
// Compute the current prune target from the policy.
switch policy.Mode { switch policy.Mode {
case history.KeepAll: case history.KeepAll:
if freezerTail > 0 { if freezerTail > 0 {
// Database was pruned externally. Record the actual state. // Database was pruned externally. Record the actual state.
log.Warn("Chain history database is pruned", "tail", freezerTail, "mode", policy.Mode) log.Warn("Chain history database is pruned", "tail", freezerTail, "mode", policy.Mode)
bc.historyPrunePoint.Store(&history.PrunePoint{ bc.updateHistoryPrunePoint(freezerTail)
BlockNumber: freezerTail,
BlockHash: bc.GetCanonicalHash(freezerTail),
})
} }
return nil return nil
case history.KeepPostMerge, history.KeepPostPrague: case history.KeepPostMerge, history.KeepPostPrague:
target := policy.Target target = policy.Target.BlockNumber
// Already at the target.
if freezerTail == target.BlockNumber { case history.KeepRecent:
bc.historyPrunePoint.Store(target) head := bc.CurrentBlock()
if head == nil || head.Number.Uint64() <= policy.Window {
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 return nil
} }
// Database is pruned beyond the target. target = head.Number.Uint64() - policy.Window
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)
} }
// 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
}
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).
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 // SetHead rewinds the local chain to a new head. Depending on whether the node
@ -1309,6 +1326,10 @@ func (bc *BlockChain) stopWithoutSaving() {
if !bc.stopping.CompareAndSwap(false, true) { if !bc.stopping.CompareAndSwap(false, true) {
return return
} }
// Signal shutdown history pruner.
if bc.histPruner != nil {
bc.histPruner.close()
}
// Signal shutdown tx indexer. // Signal shutdown tx indexer.
if bc.txIndexer != nil { if bc.txIndexer != nil {
bc.txIndexer.close() bc.txIndexer.close()

View file

@ -24,6 +24,7 @@ import (
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus" "github.com/ethereum/go-ethereum/consensus"
"github.com/ethereum/go-ethereum/consensus/misc/eip4844" "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/rawdb"
"github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/state/snapshot" "github.com/ethereum/go-ethereum/core/state/snapshot"
@ -503,8 +504,8 @@ func (bc *BlockChain) StateIndexProgress() (uint64, uint64, error) {
return bc.triedb.IndexProgress() return bc.triedb.IndexProgress()
} }
// HistoryPruningCutoff returns the configured history pruning point. // HistoryPruningCutoff returns the history pruning point based on DB state.
// Blocks before this might not be available in the database. // Blocks before this are not available in the database.
func (bc *BlockChain) HistoryPruningCutoff() (uint64, common.Hash) { func (bc *BlockChain) HistoryPruningCutoff() (uint64, common.Hash) {
pt := bc.historyPrunePoint.Load() pt := bc.historyPrunePoint.Load()
if pt == nil { if pt == nil {
@ -513,6 +514,12 @@ func (bc *BlockChain) HistoryPruningCutoff() (uint64, common.Hash) {
return pt.BlockNumber, pt.BlockHash 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. // TrieDB retrieves the low level trie database used for data storage.
func (bc *BlockChain) TrieDB() *triedb.Database { func (bc *BlockChain) TrieDB() *triedb.Database {
return bc.triedb return bc.triedb

View file

@ -35,10 +35,14 @@ const (
// KeepPostPrague sets the history pruning point to the Prague (Pectra) activation block. // KeepPostPrague sets the history pruning point to the Prague (Pectra) activation block.
KeepPostPrague 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 { func (m HistoryMode) IsValid() bool {
return m <= KeepPostPrague return m <= KeepRecent
} }
func (m HistoryMode) String() string { func (m HistoryMode) String() string {
@ -49,6 +53,8 @@ func (m HistoryMode) String() string {
return "postmerge" return "postmerge"
case KeepPostPrague: case KeepPostPrague:
return "postprague" return "postprague"
case KeepRecent:
return "recent"
default: default:
return fmt.Sprintf("invalid HistoryMode(%d)", m) return fmt.Sprintf("invalid HistoryMode(%d)", m)
} }
@ -71,8 +77,10 @@ func (m *HistoryMode) UnmarshalText(text []byte) error {
*m = KeepPostMerge *m = KeepPostMerge
case "postprague": case "postprague":
*m = KeepPostPrague *m = KeepPostPrague
case "recent":
*m = KeepRecent
default: 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 return nil
} }
@ -115,15 +123,19 @@ var staticPrunePoints = map[HistoryMode]map[common.Hash]*PrunePoint{
} }
// HistoryPolicy describes the configured history pruning strategy. It captures // 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 { type HistoryPolicy struct {
Mode HistoryMode Mode HistoryMode
// Static prune point for PostMerge/PostPrague, nil otherwise. // Static prune point for PostMerge/PostPrague, nil otherwise.
Target *PrunePoint Target *PrunePoint
// Rolling window size for KeepRecent, 0 otherwise.
Window uint64
} }
// NewPolicy constructs a HistoryPolicy from the given mode and genesis hash. // NewPolicy constructs a HistoryPolicy from the given mode, genesis hash, and
func NewPolicy(mode HistoryMode, genesisHash common.Hash) (HistoryPolicy, error) { // 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 { switch mode {
case KeepAll: case KeepAll:
return HistoryPolicy{Mode: KeepAll}, nil return HistoryPolicy{Mode: KeepAll}, nil
@ -135,6 +147,13 @@ func NewPolicy(mode HistoryMode, genesisHash common.Hash) (HistoryPolicy, error)
} }
return HistoryPolicy{Mode: mode, Target: point}, nil 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: default:
return HistoryPolicy{}, fmt.Errorf("invalid history mode: %d", mode) return HistoryPolicy{}, fmt.Errorf("invalid history mode: %d", mode)
} }

View file

@ -24,17 +24,17 @@ import (
) )
func TestNewPolicy(t *testing.T) { func TestNewPolicy(t *testing.T) {
// KeepAll: no target. // KeepAll: no target, no window.
p, err := NewPolicy(KeepAll, params.MainnetGenesisHash) p, err := NewPolicy(KeepAll, params.MainnetGenesisHash, 0)
if err != nil { if err != nil {
t.Fatalf("KeepAll: %v", err) 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) t.Errorf("KeepAll: unexpected policy %+v", p)
} }
// PostMerge: resolves known mainnet prune point. // PostMerge: resolves known mainnet prune point.
p, err = NewPolicy(KeepPostMerge, params.MainnetGenesisHash) p, err = NewPolicy(KeepPostMerge, params.MainnetGenesisHash, 0)
if err != nil { if err != nil {
t.Fatalf("PostMerge: %v", err) t.Fatalf("PostMerge: %v", err)
} }
@ -43,7 +43,7 @@ func TestNewPolicy(t *testing.T) {
} }
// PostPrague: resolves known mainnet prune point. // PostPrague: resolves known mainnet prune point.
p, err = NewPolicy(KeepPostPrague, params.MainnetGenesisHash) p, err = NewPolicy(KeepPostPrague, params.MainnetGenesisHash, 0)
if err != nil { if err != nil {
t.Fatalf("PostPrague: %v", err) t.Fatalf("PostPrague: %v", err)
} }
@ -52,7 +52,21 @@ func TestNewPolicy(t *testing.T) {
} }
// PostMerge on unknown network: error. // 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") 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")
}
} }

131
core/history_pruner.go Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
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:
}
}

223
core/history_pruner_test.go Normal file
View file

@ -0,0 +1,223 @@
// 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 <http://www.gnu.org/licenses/>.
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 TestInitHistoryPruningStaticModeRequiresPruneHistory(t *testing.T) {
db, gspec, blocks := newTestChain(t, 200)
defer db.Close()
// 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: 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 TestInitHistoryPruningKeepRecentRequiresPruneHistory(t *testing.T) {
db, gspec, blocks := newTestChain(t, 200)
defer db.Close()
// 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}
_, err := reopenChain(db, gspec, policy)
if err == nil {
t.Fatal("expected error when history not pruned to target, got nil")
}
}
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")
}
}

View file

@ -221,7 +221,7 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) {
rawdb.WriteDatabaseVersion(chainDb, core.BlockChainVersion) rawdb.WriteDatabaseVersion(chainDb, core.BlockChainVersion)
} }
} }
histPolicy, err := history.NewPolicy(config.HistoryMode, genesisHash) histPolicy, err := history.NewPolicy(config.HistoryMode, genesisHash, config.HistoryBlocks)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -27,6 +27,7 @@ import (
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common" "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/rawdb"
"github.com/ethereum/go-ethereum/core/state/snapshot" "github.com/ethereum/go-ethereum/core/state/snapshot"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
@ -123,9 +124,10 @@ type Downloader struct {
committed atomic.Bool committed atomic.Bool
ancientLimit uint64 // The maximum block number which can be regarded as ancient data. ancientLimit uint64 // The maximum block number which can be regarded as ancient data.
// The cutoff block number and hash before which chain segments (bodies // History pruning policy and derived cutoff. The policy is the configured
// and receipts) are skipped during synchronization. 0 means the entire // intent; cutoff number/hash are computed from it (possibly deferred for
// chain segment is aimed for synchronization. // KeepRecent until the sync pivot is known).
histPolicy history.HistoryPolicy
chainCutoffNumber uint64 chainCutoffNumber uint64
chainCutoffHash common.Hash chainCutoffHash common.Hash
@ -223,14 +225,19 @@ type BlockChain interface {
// with trie nodes. // with trie nodes.
TrieDB() *triedb.Database TrieDB() *triedb.Database
// HistoryPruningCutoff returns the configured history pruning point. // HistoryPolicy returns the configured history pruning policy (intent).
// Block bodies along with the receipts will be skipped for synchronization. HistoryPolicy() history.HistoryPolicy
HistoryPruningCutoff() (uint64, common.Hash)
} }
// New creates a new downloader to fetch hashes and blocks from remote peers. // 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 { 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{ dl := &Downloader{
stateDB: stateDb, stateDB: stateDb,
moder: newSyncModer(mode, chain, 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), queue: newQueue(blockCacheMaxItems, blockCacheInitialItems),
peers: newPeerSet(), peers: newPeerSet(),
blockchain: chain, blockchain: chain,
histPolicy: policy,
chainCutoffNumber: cutoffNumber, chainCutoffNumber: cutoffNumber,
chainCutoffHash: cutoffHash, chainCutoffHash: cutoffHash,
dropPeer: dropPeer, dropPeer: dropPeer,
@ -572,6 +580,16 @@ func (d *Downloader) syncToHead() (err error) {
log.Info("Truncated excess ancient chain segment", "oldhead", frozen-1, "newhead", origin) 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. // Skip ancient chain segments if Geth is running with a configured chain cutoff.
// These segments are not guaranteed to be available in the network. // These segments are not guaranteed to be available in the network.
chainOffset := origin + 1 chainOffset := origin + 1

View file

@ -97,6 +97,10 @@ type Config struct {
// HistoryMode configures chain history retention. // HistoryMode configures chain history retention.
HistoryMode history.HistoryMode 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 // This can be set to list of enrtree:// URLs which will be queried for
// nodes to connect to. // nodes to connect to.
EthDiscoveryURLs []string EthDiscoveryURLs []string

View file

@ -21,6 +21,7 @@ func (c Config) MarshalTOML() (interface{}, error) {
NetworkId uint64 NetworkId uint64
SyncMode SyncMode SyncMode SyncMode
HistoryMode history.HistoryMode HistoryMode history.HistoryMode
HistoryBlocks uint64 `toml:",omitempty"`
EthDiscoveryURLs []string EthDiscoveryURLs []string
SnapDiscoveryURLs []string SnapDiscoveryURLs []string
NoPruning bool NoPruning bool
@ -75,6 +76,7 @@ func (c Config) MarshalTOML() (interface{}, error) {
enc.NetworkId = c.NetworkId enc.NetworkId = c.NetworkId
enc.SyncMode = c.SyncMode enc.SyncMode = c.SyncMode
enc.HistoryMode = c.HistoryMode enc.HistoryMode = c.HistoryMode
enc.HistoryBlocks = c.HistoryBlocks
enc.EthDiscoveryURLs = c.EthDiscoveryURLs enc.EthDiscoveryURLs = c.EthDiscoveryURLs
enc.SnapDiscoveryURLs = c.SnapDiscoveryURLs enc.SnapDiscoveryURLs = c.SnapDiscoveryURLs
enc.NoPruning = c.NoPruning enc.NoPruning = c.NoPruning
@ -133,6 +135,7 @@ func (c *Config) UnmarshalTOML(unmarshal func(interface{}) error) error {
NetworkId *uint64 NetworkId *uint64
SyncMode *SyncMode SyncMode *SyncMode
HistoryMode *history.HistoryMode HistoryMode *history.HistoryMode
HistoryBlocks *uint64 `toml:",omitempty"`
EthDiscoveryURLs []string EthDiscoveryURLs []string
SnapDiscoveryURLs []string SnapDiscoveryURLs []string
NoPruning *bool NoPruning *bool
@ -198,6 +201,9 @@ func (c *Config) UnmarshalTOML(unmarshal func(interface{}) error) error {
if dec.HistoryMode != nil { if dec.HistoryMode != nil {
c.HistoryMode = *dec.HistoryMode c.HistoryMode = *dec.HistoryMode
} }
if dec.HistoryBlocks != nil {
c.HistoryBlocks = *dec.HistoryBlocks
}
if dec.EthDiscoveryURLs != nil { if dec.EthDiscoveryURLs != nil {
c.EthDiscoveryURLs = dec.EthDiscoveryURLs c.EthDiscoveryURLs = dec.EthDiscoveryURLs
} }