From 225078e78d912e3499b1da1b46ebc81ef8bc4b2d Mon Sep 17 00:00:00 2001 From: Manu NALEPA Date: Sun, 2 Aug 2026 23:43:20 +0200 Subject: [PATCH 1/8] Prune whole epochs instead of pruning at the middle of the epoch. Before this commit, pruning occured at the middle of the epoch, and pruned data up to the middle of the epoch. After this commit, pruning still occurs at the middle of the epoch, but it prunes data up to the start of the epoch. --- beacon-chain/db/pruner/BUILD.bazel | 1 + beacon-chain/db/pruner/pruner.go | 3 ++- beacon-chain/db/pruner/pruner_test.go | 36 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/beacon-chain/db/pruner/BUILD.bazel b/beacon-chain/db/pruner/BUILD.bazel index 5474232ed2f0..e5703669aaa4 100644 --- a/beacon-chain/db/pruner/BUILD.bazel +++ b/beacon-chain/db/pruner/BUILD.bazel @@ -34,6 +34,7 @@ go_test( "//testing/assert:go_default_library", "//testing/require:go_default_library", "//testing/util:go_default_library", + "//time/slots:go_default_library", "//time/slots/testing:go_default_library", "@com_github_sirupsen_logrus//:go_default_library", "@com_github_sirupsen_logrus//hooks/test:go_default_library", diff --git a/beacon-chain/db/pruner/pruner.go b/beacon-chain/db/pruner/pruner.go index 118aa3aff157..86c2b6c9c604 100644 --- a/beacon-chain/db/pruner/pruner.go +++ b/beacon-chain/db/pruner/pruner.go @@ -246,6 +246,7 @@ func pruneStartSlotFunc(retentionEpochs primitives.Epoch) func(primitives.Slot) if offset >= current { return 0 } - return current - offset + + return slots.UnsafeEpochStart(slots.ToEpoch(current - offset)) } } diff --git a/beacon-chain/db/pruner/pruner_test.go b/beacon-chain/db/pruner/pruner_test.go index c659aa9678d3..f8941a3df846 100644 --- a/beacon-chain/db/pruner/pruner_test.go +++ b/beacon-chain/db/pruner/pruner_test.go @@ -3,6 +3,7 @@ package pruner import ( "context" "errors" + "math" "testing" "time" @@ -11,6 +12,7 @@ import ( eth "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1" "github.com/OffchainLabs/prysm/v7/testing/util" + "github.com/OffchainLabs/prysm/v7/time/slots" slottest "github.com/OffchainLabs/prysm/v7/time/slots/testing" "github.com/sirupsen/logrus" @@ -311,6 +313,40 @@ func TestWithRetentionPeriod_EnforcesMinimum(t *testing.T) { } } +func TestPruneStartSlotFunc(t *testing.T) { + params.SetupTestConfigCleanup(t) + params.OverrideBeaconConfig(params.MinimalSpecConfig()) + + slotsPerEpoch := params.BeaconConfig().SlotsPerEpoch + retentionEpochs := primitives.Epoch(params.BeaconConfig().MinEpochsForBlockRequests + 1) + retentionSlots := primitives.Slot(retentionEpochs) * slotsPerEpoch + + t.Run("clamps an overflowing retention period", func(t *testing.T) { + for _, epochs := range []primitives.Epoch{slots.MaxSafeEpoch(), slots.MaxSafeEpoch() + 1, math.MaxUint64} { + ps := pruneStartSlotFunc(epochs) + require.Equal(t, primitives.Slot(0), ps(1_000_000)) + } + }) + + t.Run("aligns the cutoff on an epoch start", func(t *testing.T) { + ps := pruneStartSlotFunc(retentionEpochs) + + epochStart := retentionSlots + 10*slotsPerEpoch + for offsetInEpoch := primitives.Slot(0); offsetInEpoch < slotsPerEpoch; offsetInEpoch++ { + require.Equal(t, epochStart-retentionSlots, ps(epochStart+offsetInEpoch)) + } + + require.Equal(t, epochStart-retentionSlots+slotsPerEpoch, ps(epochStart+slotsPerEpoch)) + }) + + t.Run("prunes nothing before the retention period has elapsed", func(t *testing.T) { + ps := pruneStartSlotFunc(retentionEpochs) + + require.Equal(t, primitives.Slot(0), ps(retentionSlots)) + require.Equal(t, primitives.Slot(0), ps(retentionSlots-1)) + }) +} + func TestPruner_UpdateEarliestSlotError(t *testing.T) { params.SetupTestConfigCleanup(t) config := params.BeaconConfig() From 937e3b708c17b72725840adbd091ec74434722c6 Mon Sep 17 00:00:00 2001 From: Manu NALEPA Date: Mon, 3 Aug 2026 10:35:29 +0200 Subject: [PATCH 2/8] Prune the state-diff tree Before this commit: The state-diff tree was never pruned. A node running with `--beacon-db-pruning` and `--enable-state-diff` dropped its blocks once they aged past the retention period, but kept every state it had ever stored, all the way back to its checkpoint sync. After this commit: Rebuilding a state needs, for each level of the tree, the entry stored at the last boundary of that level at or before that state. For every state at or after the cutoff, those entries are either at or after the cutoff, or, for each level, the single one stored at the last boundary before it. Only those are kept below the cutoff, whatever the retention period is. With `C` the cutoff slot, and `k0`, `k1` and `k2` the last entry of each level at or before it. Each level spans twice the one below it, and an entry is stored at the coarsest level it belongs to. `.` is deleted, `#` is kept, and `+` is a block kept although older than `C`: k0 k1 k2 C slot ------------------------------------------------------------------ epochs | | | | | | | | | | | | | | | | | level 0 . # # level 1 . # level 2 . . . # blocks .........................................................++++##### states .........................................................######### The kept entries support each other: the spans divide each other, so the anchor of a kept entry is the kept entry of the level above it. The slots between `C` and the next stored entry have no state of their own, and are recomputed by replaying blocks from `k2`. Keeping those blocks is the job of the previous commit. Deletion happens in batches, spread over as many pruning runs as needed. The offset moves to `k0` only with the last batch, together with the deletion of the previous anchor snapshot, so an interrupted run leaves a tree that reads exactly as before, with unreachable entries that the next run deletes. --- beacon-chain/db/iface/interface.go | 2 + beacon-chain/db/kv/BUILD.bazel | 2 + beacon-chain/db/kv/state_diff_cache.go | 13 + beacon-chain/db/kv/state_diff_helpers.go | 5 +- beacon-chain/db/kv/state_diff_prune.go | 320 ++++++++++++++++++++ beacon-chain/db/kv/state_diff_prune_test.go | 277 +++++++++++++++++ beacon-chain/db/pruner/pruner.go | 46 +++ 7 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 beacon-chain/db/kv/state_diff_prune.go create mode 100644 beacon-chain/db/kv/state_diff_prune_test.go diff --git a/beacon-chain/db/iface/interface.go b/beacon-chain/db/iface/interface.go index 4f28c9be1085..94d4c3c27b17 100644 --- a/beacon-chain/db/iface/interface.go +++ b/beacon-chain/db/iface/interface.go @@ -127,6 +127,8 @@ type NoHeadAccessDatabase interface { CleanUpDirtyStates(ctx context.Context, slotsPerArchivedPoint primitives.Slot) error DeleteHistoricalDataBeforeSlot(ctx context.Context, slot primitives.Slot, batchSize int) (int, error) + DeleteStateDiffBeforeSlot(ctx context.Context, slot primitives.Slot, maxEntries int) (int, error) + LastStateDiffBoundary(slot primitives.Slot) (primitives.Slot, error) // Genesis operations. LoadGenesis(ctx context.Context, stateBytes []byte) error diff --git a/beacon-chain/db/kv/BUILD.bazel b/beacon-chain/db/kv/BUILD.bazel index 3dd68b05f5f6..c44fc54296be 100644 --- a/beacon-chain/db/kv/BUILD.bazel +++ b/beacon-chain/db/kv/BUILD.bazel @@ -31,6 +31,7 @@ go_library( "state_diff.go", "state_diff_cache.go", "state_diff_helpers.go", + "state_diff_prune.go", "state_hot_snapshots.go", "state_summary.go", "state_summary_cache.go", @@ -109,6 +110,7 @@ go_test( "migration_state_validators_test.go", "p2p_test.go", "state_diff_helpers_test.go", + "state_diff_prune_test.go", "state_diff_test.go", "state_hot_snapshots_test.go", "state_summary_test.go", diff --git a/beacon-chain/db/kv/state_diff_cache.go b/beacon-chain/db/kv/state_diff_cache.go index 2e929d2bda1b..e4242e6872d7 100644 --- a/beacon-chain/db/kv/state_diff_cache.go +++ b/beacon-chain/db/kv/state_diff_cache.go @@ -230,6 +230,19 @@ func (c *stateDiffCache) setAnchor(level int, anchor state.ReadOnlyBeaconState) return nil } +// reanchor points the cache at a new offset and drops the cached anchors +func (c *stateDiffCache) reanchor(offset uint64, levelsWithData []bool) { + c.Lock() + defer c.Unlock() + + c.offset = offset + c.levelsWithData = levelsWithData + + for level := range c.anchors { + c.anchors[level] = nil + } +} + func (c *stateDiffCache) levelHasData(level int) bool { c.RLock() defer c.RUnlock() diff --git a/beacon-chain/db/kv/state_diff_helpers.go b/beacon-chain/db/kv/state_diff_helpers.go index 6abc4a6bffef..0fc42e7aeca1 100644 --- a/beacon-chain/db/kv/state_diff_helpers.go +++ b/beacon-chain/db/kv/state_diff_helpers.go @@ -21,6 +21,9 @@ import ( "go.etcd.io/bbolt" ) +// stateDiffTreeKeyLength is the length of a state-diff tree key, before any suffix. +const stateDiffTreeKeyLength = 16 + var ( offsetKey = []byte("offset") exponentsKey = []byte("exponents") @@ -112,7 +115,7 @@ func (s *Store) loadStateDiffExponents() ([]int, error) { } func makeKeyForStateDiffTree(level int, slot uint64) []byte { - buf := make([]byte, 16) + buf := make([]byte, stateDiffTreeKeyLength) buf[0] = byte(level) binary.LittleEndian.PutUint64(buf[1:], slot) return buf diff --git a/beacon-chain/db/kv/state_diff_prune.go b/beacon-chain/db/kv/state_diff_prune.go new file mode 100644 index 000000000000..bf9ecef49261 --- /dev/null +++ b/beacon-chain/db/kv/state_diff_prune.go @@ -0,0 +1,320 @@ +package kv + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags" + "github.com/OffchainLabs/prysm/v7/config/features" + "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" + "github.com/OffchainLabs/prysm/v7/math" + "github.com/OffchainLabs/prysm/v7/monitoring/tracing/trace" + bolt "go.etcd.io/bbolt" +) + +// LastStateDiffBoundary returns the highest state-diff tree boundary at or before the given slot. +// The state stored there is the closest one a state after it can be replayed from, so keeping the +// blocks above that boundary keeps every state after it rebuildable. +// The slot is returned unchanged when state diff is disabled, or when the tree does not reach it. +func (s *Store) LastStateDiffBoundary(slot primitives.Slot) (primitives.Slot, error) { + if !features.Get().EnableStateDiff { + return slot, nil + } + + hasOffset, err := s.hasStateDiffOffset() + if err != nil { + return 0, fmt.Errorf("has state diff offset: %w", err) + } + if !hasOffset { + return slot, nil + } + + offset, err := s.loadOffset() + if err != nil { + return 0, fmt.Errorf("load offset: %w", err) + } + if uint64(slot) <= offset { + return slot, nil + } + + // The last kept slot is the one of the finest level, hence the closest boundary to the slot. + keptSlots, err := stateDiffSlotsToKeep(offset, uint64(slot)) + if err != nil { + return 0, err + } + + return primitives.Slot(keptSlots[len(keptSlots)-1]), nil +} + +// DeleteStateDiffBeforeSlot deletes at most maxEntries state-diff entries that are not needed any +// more to rebuild the states at or after the given slot, and returns the number of deleted keys. +// Zero means there was nothing left to do. Call it repeatedly until it returns zero. +func (s *Store) DeleteStateDiffBeforeSlot(ctx context.Context, cutoffSlot primitives.Slot, maxEntries int) (int, error) { + _, span := trace.StartSpan(ctx, "BeaconDB.DeleteStateDiffBeforeSlot") + defer span.End() + + if !features.Get().EnableStateDiff { + return 0, nil + } + + if maxEntries <= 0 { + return 0, fmt.Errorf("maximum number of entries to delete must be positive, got %d", maxEntries) + } + + hasOffset, err := s.hasStateDiffOffset() + if err != nil { + return 0, fmt.Errorf("has state diff offset: %w", err) + } + if !hasOffset { + return 0, nil + } + + offset, err := s.loadOffset() + if err != nil { + return 0, fmt.Errorf("load offset: %w", err) + } + + cutoff := uint64(cutoffSlot) + if cutoff <= offset { + return 0, nil + } + + keptSlots, err := stateDiffSlotsToKeep(offset, cutoff) + if err != nil { + return 0, fmt.Errorf("state diff slots to keep: %w", err) + } + + // The tree is anchored on the kept level 0 entry from now on. + // Refuse to prune a tree that cannot be re-anchored, rather than making it unreadable. + newOffset := keptSlots[0] + key := makeKeyForStateDiffTree(0, newOffset) + hasNewAnchor, err := s.hasStateDiffKey(key) + if err != nil { + return 0, fmt.Errorf("has state diff key: %w", err) + } + if !hasNewAnchor { + return 0, fmt.Errorf("%w: no level 0 snapshot at slot %d to re-anchor the tree on", ErrStateDiffCorrupted, newOffset) + } + + kept := make(map[uint64]bool, len(keptSlots)) + for _, slot := range keptSlots { + kept[slot] = true + } + + // The current anchor snapshot is deleted last: the tree is read with the current offset until + // the very last call, and needs it. + currentAnchorKey := makeKeyForStateDiffTree(0, offset) + + keys, err := s.stateDiffKeysBefore(ctx, cutoff, kept, currentAnchorKey, maxEntries) + if err != nil { + return 0, fmt.Errorf("state diff keys before: %w", err) + } + + if len(keys) > 0 { + if err := s.deleteStateDiffKeys(keys); err != nil { + return 0, fmt.Errorf("delete state diff keys: %w", err) + } + + return len(keys), nil + } + + // Everything that is not needed any more is gone: re-anchor the tree. + if newOffset == offset { + return 0, nil + } + + count, err := s.reanchorStateDiff(currentAnchorKey, newOffset) + if err != nil { + return 0, fmt.Errorf("re-anchor state diff: %w", err) + } + + return count, nil +} + +// stateDiffSlotsToKeep returns, for every level of the tree, the slot of the last entry at or +// before the given slot. Those are the only entries below it that a state at or after it needs. +func stateDiffSlotsToKeep(offset, slot uint64) ([]uint64, error) { + exponents := flags.Get().StateDiffExponents + if len(exponents) == 0 { + return nil, errors.New("state diff exponents cannot be empty") + } + + if slot < offset { + return nil, fmt.Errorf("slot %d is before the state diff offset %d", slot, offset) + } + + relativeSlot := slot - offset + keptSlots := make([]uint64, 0, len(exponents)) + + for _, exponent := range exponents { + if exponent < flags.MinStateDiffExponent || exponent >= 64 { + return nil, fmt.Errorf("state diff exponent %d out of range for uint64", exponent) + } + + span := math.PowerOf2(uint64(exponent)) + keptSlots = append(keptSlots, offset+relativeSlot/span*span) + } + + return keptSlots, nil +} + +// hasStateDiffKey reports whether the given key is present in the state-diff bucket. +func (s *Store) hasStateDiffKey(key []byte) (bool, error) { + var has bool + if err := s.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket(stateDiffBucket) + if bucket == nil { + return bolt.ErrBucketNotFound + } + + has = bucket.Get(key) != nil + + return nil + }); err != nil { + return false, err + } + + return has, nil +} + +// stateDiffKeysBefore collects the keys of at most maxEntries tree entries stored before the given +// slot, skipping the entries stored at the kept slots and the given key. +func (s *Store) stateDiffKeysBefore(ctx context.Context, slot uint64, kept map[uint64]bool, skipKey []byte, maxEntries int) ([][]byte, error) { + var ( + keys [][]byte + entries int + entryPrefix []byte + ) + + if err := s.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket(stateDiffBucket) + if bucket == nil { + return bolt.ErrBucketNotFound + } + + cursor := bucket.Cursor() + for key, _ := cursor.First(); key != nil; key, _ = cursor.Next() { + if ctx.Err() != nil { + return nil + } + + // Metadata keys are shorter than a tree key, and are never pruned. + if len(key) < stateDiffTreeKeyLength { + continue + } + + entrySlot := binary.LittleEndian.Uint64(key[1:9]) + if entrySlot >= slot || kept[entrySlot] { + continue + } + + if bytes.Equal(key, skipKey) { + continue + } + + if !bytes.Equal(entryPrefix, key[:stateDiffTreeKeyLength]) { + if entries == maxEntries { + return nil + } + + entryPrefix = bytes.Clone(key[:stateDiffTreeKeyLength]) + entries++ + } + + keys = append(keys, bytes.Clone(key)) + } + + return nil + }); err != nil { + return nil, err + } + + return keys, nil +} + +// deleteStateDiffKeys deletes the given keys from the state-diff bucket, in a single transaction. +func (s *Store) deleteStateDiffKeys(keys [][]byte) error { + return s.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(stateDiffBucket) + if bucket == nil { + return bolt.ErrBucketNotFound + } + + for _, key := range keys { + if err := bucket.Delete(key); err != nil { + return fmt.Errorf("delete state diff entry: %w", err) + } + } + + return nil + }) +} + +// reanchorStateDiff deletes the previous anchor snapshot and moves the offset to the new anchor, in +// a single transaction, then points the in-memory cache at it. +// It returns the number of deleted keys. +func (s *Store) reanchorStateDiff(previousAnchorKey []byte, offset uint64) (int, error) { + deleted := 0 + if err := s.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(stateDiffBucket) + if bucket == nil { + return bolt.ErrBucketNotFound + } + + if bucket.Get(previousAnchorKey) != nil { + if err := bucket.Delete(previousAnchorKey); err != nil { + return fmt.Errorf("delete previous anchor snapshot: %w", err) + } + + deleted = 1 + } + + offsetBytes := make([]byte, 8) + binary.LittleEndian.PutUint64(offsetBytes, offset) + + return bucket.Put(offsetKey, offsetBytes) + }); err != nil { + return 0, err + } + + if err := s.reanchorStateDiffCache(offset); err != nil { + return deleted, fmt.Errorf("reanchor state diff cache: %w", err) + } + + log.WithField("offset", offset).Debug("Re-anchored the pruned state-diff tree") + + return deleted, nil +} + +// reanchorStateDiffCache points the in-memory cache at the new offset. The cached anchors are +// dropped, since they may belong to entries that have just been deleted. +func (s *Store) reanchorStateDiffCache(offset uint64) error { + if s.stateDiffCache == nil { + return nil + } + + levelsWithData := make([]bool, len(flags.Get().StateDiffExponents)) + if err := s.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket(stateDiffBucket) + if bucket == nil { + return bolt.ErrBucketNotFound + } + + cursor := bucket.Cursor() + for level := range levelsWithData { + key, _ := cursor.Seek([]byte{byte(level)}) + levelsWithData[level] = key != nil && len(key) >= stateDiffTreeKeyLength && key[0] == byte(level) + } + + return nil + }); err != nil { + return err + } + + s.stateDiffCache.reanchor(offset, levelsWithData) + + return nil +} diff --git a/beacon-chain/db/kv/state_diff_prune_test.go b/beacon-chain/db/kv/state_diff_prune_test.go new file mode 100644 index 000000000000..d843a879542a --- /dev/null +++ b/beacon-chain/db/kv/state_diff_prune_test.go @@ -0,0 +1,277 @@ +package kv + +import ( + "testing" + + "github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags" + "github.com/OffchainLabs/prysm/v7/config/features" + "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" + "github.com/OffchainLabs/prysm/v7/runtime/version" + "github.com/OffchainLabs/prysm/v7/testing/require" + bolt "go.etcd.io/bbolt" +) + +// deleteStateDiffBucket drops the whole state-diff bucket, to exercise the paths that deal with a +// database that does not have one. +func deleteStateDiffBucket(t *testing.T, db *Store) { + require.NoError(t, db.db.Update(func(tx *bolt.Tx) error { + return tx.DeleteBucket(stateDiffBucket) + })) +} + +func TestStateDiff_LastBoundary(t *testing.T) { + t.Run("returns the slot when state diff is disabled", func(t *testing.T) { + setStateDiffExponents([]int{7, 5}) + db := setupDB(t) + + boundary, err := db.LastStateDiffBoundary(100) + require.NoError(t, err) + require.Equal(t, primitives.Slot(100), boundary) + }) + + t.Run("returns the slot when the tree has not been initialized", func(t *testing.T) { + resetCfg := features.InitWithReset(&features.Flags{EnableStateDiff: true}) + defer resetCfg() + + setStateDiffExponents([]int{7, 5}) + db := setupDB(t) + + boundary, err := db.LastStateDiffBoundary(100) + require.NoError(t, err) + require.Equal(t, primitives.Slot(100), boundary) + }) + + t.Run("returns the slot when the tree does not reach it", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 96) + + boundary, err := db.LastStateDiffBoundary(0) + require.NoError(t, err) + require.Equal(t, primitives.Slot(0), boundary) + }) + + t.Run("returns the finest boundary at or before the slot", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 96) + + // The finest level spans 32 slots here, and is counted from the anchor. + for slot, want := range map[primitives.Slot]primitives.Slot{32: 32, 33: 32, 63: 32, 64: 64, 100: 96} { + boundary, err := db.LastStateDiffBoundary(slot) + require.NoError(t, err) + require.Equal(t, want, boundary) + } + }) +} + +func TestStateDiff_DeleteBeforeSlot(t *testing.T) { + resetCfg := features.InitWithReset(&features.Flags{EnableStateDiff: true}) + defer resetCfg() + + t.Run("does nothing when the state diff feature is disabled", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 384) + + disableCfg := features.InitWithReset(&features.Flags{EnableStateDiff: false}) + deleted, err := db.DeleteStateDiffBeforeSlot(t.Context(), 320, 512) + disableCfg() + + require.NoError(t, err) + require.Equal(t, 0, deleted) + require.Equal(t, uint64(0), db.getOffset()) + }) + + t.Run("refuses a non-positive maximum number of entries", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 96) + + for _, maxEntries := range []int{0, -1} { + _, err := db.DeleteStateDiffBeforeSlot(t.Context(), 96, maxEntries) + require.ErrorContains(t, "maximum number of entries to delete must be positive", err) + } + }) + + t.Run("does nothing when the tree has not been initialized", func(t *testing.T) { + setStateDiffExponents([]int{7, 5}) + + // A database that never went through a checkpoint or a genesis sync has no anchor, hence + // nothing to prune. + db := setupDB(t) + + deleted, err := db.DeleteStateDiffBeforeSlot(t.Context(), 320, 512) + require.NoError(t, err) + require.Equal(t, 0, deleted) + }) + + t.Run("deletes everything no retained state needs", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 384) + + // Rebuilding a state at or after slot 320 needs the level 0 entry at slot 256 and the level + // 1 entry at slot 320, and nothing else below the cutoff slot. A small batch size makes + // sure this needs several calls. + deleted := drainStateDiffPruning(t, db, 320, 2) + require.Equal(t, true, deleted > 0) + + // The tree is now anchored on the kept level 0 entry. + storedOffset, err := db.loadOffset() + require.NoError(t, err) + require.Equal(t, uint64(256), storedOffset) + require.Equal(t, uint64(256), db.getOffset()) + + // The states at or after the cutoff slot are still readable, and so is the anchor. + for _, slot := range []primitives.Slot{256, 320, 352, 384} { + st, err := db.stateByDiff(t.Context(), slot) + require.NoError(t, err) + require.Equal(t, slot, st.Slot()) + } + + // Everything else is gone: the entries below the anchor, and the ones between the anchor + // and the cutoff slot that no retained state needs. + for _, slot := range []primitives.Slot{0, 128, 224} { + _, err := db.stateByDiff(t.Context(), slot) + require.ErrorIs(t, err, ErrSlotBeforeOffset) + } + + _, err = db.stateByDiff(t.Context(), 288) + require.NotNil(t, err) + + // And the database still opens on the next restart. + cache, err := populateStateDiffCacheFromDB(db, storedOffset) + require.NoError(t, err) + require.NoError(t, validateStateDiffCache(t.Context(), db, cache)) + }) + + t.Run("is idempotent", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 384) + + deleted := drainStateDiffPruning(t, db, 320, 512) + require.Equal(t, true, deleted > 0) + + // Pruning again at the same cutoff slot, or below the new offset, is a no-op. + for _, cutoff := range []primitives.Slot{320, 256, 100} { + deleted, err := db.DeleteStateDiffBeforeSlot(t.Context(), cutoff, 512) + require.NoError(t, err) + require.Equal(t, 0, deleted) + require.Equal(t, uint64(256), db.getOffset()) + } + + st, err := db.stateByDiff(t.Context(), 352) + require.NoError(t, err) + require.Equal(t, primitives.Slot(352), st.Slot()) + }) + + t.Run("refuses to prune a tree it cannot re-anchor", func(t *testing.T) { + // The tree stops before the second level 0 boundary, so there is no snapshot at slot 128 to + // re-anchor it on. + db := setupPrunableStateDiffTree(t, 96) + + _, err := db.DeleteStateDiffBeforeSlot(t.Context(), 200, 512) + require.ErrorIs(t, err, ErrStateDiffCorrupted) + + // The tree is left untouched. + require.Equal(t, uint64(0), db.getOffset()) + st, err := db.stateByDiff(t.Context(), 96) + require.NoError(t, err) + require.Equal(t, primitives.Slot(96), st.Slot()) + }) +} + +func TestStateDiff_SlotsToKeep(t *testing.T) { + t.Run("refuses a slot before the offset", func(t *testing.T) { + setStateDiffExponents([]int{7, 5}) + + _, err := stateDiffSlotsToKeep(256, 128) + require.ErrorContains(t, "is before the state diff offset", err) + }) + + t.Run("refuses an out of range exponent", func(t *testing.T) { + // Too small to be a level span, then too large for a uint64 shift. + for _, exponents := range [][]int{{7, flags.MinStateDiffExponent - 1}, {64, 5}} { + setStateDiffExponents(exponents) + + _, err := stateDiffSlotsToKeep(0, 320) + require.ErrorContains(t, "out of range for uint64", err) + } + }) +} + +func TestStateDiff_HasKey(t *testing.T) { + db := setupPrunableStateDiffTree(t, 96) + deleteStateDiffBucket(t, db) + + _, err := db.hasStateDiffKey(makeKeyForStateDiffTree(0, 0)) + require.ErrorIs(t, err, bolt.ErrBucketNotFound) +} + +func TestStateDiff_KeysBefore(t *testing.T) { + db := setupPrunableStateDiffTree(t, 96) + deleteStateDiffBucket(t, db) + + _, err := db.stateDiffKeysBefore(t.Context(), 96, nil, nil, 512) + require.ErrorIs(t, err, bolt.ErrBucketNotFound) +} + +func TestStateDiff_DeleteKeys(t *testing.T) { + db := setupPrunableStateDiffTree(t, 96) + deleteStateDiffBucket(t, db) + + err := db.deleteStateDiffKeys([][]byte{makeKeyForStateDiffTree(0, 0)}) + require.ErrorIs(t, err, bolt.ErrBucketNotFound) +} + +func TestStateDiff_Reanchor(t *testing.T) { + db := setupPrunableStateDiffTree(t, 96) + deleteStateDiffBucket(t, db) + + _, err := db.reanchorStateDiff(makeKeyForStateDiffTree(0, 0), 32) + require.ErrorIs(t, err, bolt.ErrBucketNotFound) +} + +func TestStateDiff_ReanchorCache(t *testing.T) { + t.Run("does nothing without a cache", func(t *testing.T) { + setStateDiffExponents([]int{7, 5}) + + // The cache only exists once the tree has been initialized. + db := setupDB(t) + require.IsNil(t, db.stateDiffCache) + require.NoError(t, db.reanchorStateDiffCache(32)) + }) + + t.Run("errors without a state-diff bucket", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 96) + deleteStateDiffBucket(t, db) + + err := db.reanchorStateDiffCache(32) + require.ErrorIs(t, err, bolt.ErrBucketNotFound) + }) +} + +// setupPrunableStateDiffTree anchors a tree at slot 0 with a level 0 span of 128 slots and a level +// 1 span of 32 slots, and fills it with epoch boundary states up to (and including) the given slot. +func setupPrunableStateDiffTree(t *testing.T, upTo primitives.Slot) *Store { + resetCfg := features.InitWithReset(&features.Flags{EnableStateDiff: true}) + t.Cleanup(resetCfg) + + setStateDiffExponents([]int{7, 5}) + + db := setupDB(t) + + anchorState, _ := createState(t, 0, version.Fulu) + require.NoError(t, db.initializeStateDiff(0, anchorState)) + + for slot := primitives.Slot(32); slot <= upTo; slot += 32 { + st, _ := createState(t, slot, version.Fulu) + require.NoError(t, db.saveStateByDiff(t.Context(), st)) + } + + return db +} + +// drainStateDiffPruning prunes until there is nothing left to delete, as the pruner service does, +// and returns the total number of deleted keys. +func drainStateDiffPruning(t *testing.T, db *Store, cutoffSlot primitives.Slot, maxEntries int) int { + total := 0 + for { + deleted, err := db.DeleteStateDiffBeforeSlot(t.Context(), cutoffSlot, maxEntries) + require.NoError(t, err) + if deleted == 0 { + return total + } + total += deleted + } +} diff --git a/beacon-chain/db/pruner/pruner.go b/beacon-chain/db/pruner/pruner.go index 86c2b6c9c604..7cdce0595deb 100644 --- a/beacon-chain/db/pruner/pruner.go +++ b/beacon-chain/db/pruner/pruner.go @@ -2,6 +2,7 @@ package pruner import ( "context" + "fmt" "time" "github.com/OffchainLabs/prysm/v7/beacon-chain/db" @@ -20,6 +21,8 @@ const ( defaultPruningWindow = time.Second * 3 // defaultNumBatchesToPrune is the number of batches to prune in one pruning window. defaultNumBatchesToPrune = 15 + // defaultPrunableStateDiffEntries is the number of state-diff tree entries deleted at once. + defaultPrunableStateDiffEntries = 512 ) // custodyUpdater is a tiny interface that p2p service implements; kept here to avoid @@ -152,6 +155,16 @@ func (p *Service) prune(slot primitives.Slot) error { return nil } + pruneUpto, err := p.db.LastStateDiffBoundary(pruneUpto) + if err != nil { + return errors.Wrap(err, "last state diff boundary") + } + + // Can't prune beyond genesis. + if pruneUpto == 0 { + return nil + } + // Skip if already pruned up to this slot. if pruneUpto <= p.prunedUpto { return nil @@ -177,6 +190,11 @@ func (p *Service) prune(slot primitives.Slot) error { return errors.Wrap(err, "update earliest available slot") } + deletedStateDiffKeys, err := p.pruneStateDiff(pruneUpto) + if err != nil { + return fmt.Errorf("prune state diff: %w", err) + } + log.WithFields(logrus.Fields{ "prunedUpto": pruneUpto, "earliestAvailableSlot": earliestAvailableSlot, @@ -184,11 +202,39 @@ func (p *Service) prune(slot primitives.Slot) error { "currentSlot": slot, "batchSize": defaultPrunableBatchSize, "numBatches": numBatches, + "stateDiffKeys": deletedStateDiffKeys, }).Debug("Successfully pruned chain data") return nil } +// pruneStateDiff deletes the state-diff entries up to pruneUpto. +// The work is spread over batches, and over as many pruning runs as needed. +func (p *Service) pruneStateDiff(pruneUpto primitives.Slot) (int, error) { + ctx, cancel := context.WithTimeout(p.ctx, defaultPruningWindow) + defer cancel() + + deleted := 0 + for { + select { + case <-ctx.Done(): + return deleted, nil + default: + batch, err := p.db.DeleteStateDiffBeforeSlot(ctx, pruneUpto, defaultPrunableStateDiffEntries) + if err != nil { + return deleted, err + } + + // Nothing left to delete. + if batch == 0 { + return deleted, nil + } + + deleted += batch + } + } +} + // updateEarliestAvailableSlot updates the earliest available slot via the injected custody updater // and also persists it to the database. func (p *Service) updateEarliestAvailableSlot(earliestAvailableSlot primitives.Slot) error { From 2aaa9e2f4d6d2c548613524be756b3e4019c4738 Mon Sep 17 00:00:00 2001 From: Manu NALEPA Date: Mon, 3 Aug 2026 14:29:12 +0200 Subject: [PATCH 3/8] Add changelog entries for the pruning changes. --- changelog/manu_prune-state-diff-tree.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/manu_prune-state-diff-tree.md diff --git a/changelog/manu_prune-state-diff-tree.md b/changelog/manu_prune-state-diff-tree.md new file mode 100644 index 000000000000..12890795df6a --- /dev/null +++ b/changelog/manu_prune-state-diff-tree.md @@ -0,0 +1,3 @@ +### Added + +- Prune the state-diff tree along with the rest of the historical data when `--beacon-db-pruning` is enabled. From 40ed98428adf9fff9ea3b5526b4ca08eebb00b33 Mon Sep 17 00:00:00 2001 From: Manu NALEPA Date: Fri, 14 Aug 2026 15:37:34 +0200 Subject: [PATCH 4/8] Address @Inspector-Butters's comment. --- beacon-chain/db/kv/state_diff_helpers.go | 8 ++---- beacon-chain/db/kv/state_diff_prune.go | 28 ++++----------------- beacon-chain/db/kv/state_diff_prune_test.go | 21 ++++++++-------- cmd/beacon-chain/flags/config_test.go | 4 +++ 4 files changed, 21 insertions(+), 40 deletions(-) diff --git a/beacon-chain/db/kv/state_diff_helpers.go b/beacon-chain/db/kv/state_diff_helpers.go index 0fc42e7aeca1..a22146631315 100644 --- a/beacon-chain/db/kv/state_diff_helpers.go +++ b/beacon-chain/db/kv/state_diff_helpers.go @@ -130,10 +130,8 @@ func (s *Store) getAnchorState(ctx context.Context, offset uint64, lvl int, slot return nil, ErrSlotBeforeOffset } relSlot := uint64(slot) - offset + // The exponents are validated at node startup, so they always fit in a uint64 shift. prevExp := flags.Get().StateDiffExponents[lvl-1] - if prevExp < flags.MinStateDiffExponent || prevExp >= 64 { - return nil, fmt.Errorf("state diff exponent %d out of range for uint64", prevExp) - } span := math.PowerOf2(uint64(prevExp)) anchorSlot := primitives.Slot(uint64(slot) - relSlot%span) @@ -182,10 +180,8 @@ func computeLevel(offset uint64, slot primitives.Slot) int { return -1 } rel := uint64(slot) - offset + // The exponents are validated at node startup, so they always fit in a uint64 shift. for i, exp := range flags.Get().StateDiffExponents { - if exp < flags.MinStateDiffExponent || exp >= 64 { - return -1 - } span := math.PowerOf2(uint64(exp)) if rel%span == 0 { return i diff --git a/beacon-chain/db/kv/state_diff_prune.go b/beacon-chain/db/kv/state_diff_prune.go index bf9ecef49261..3f5bb525120a 100644 --- a/beacon-chain/db/kv/state_diff_prune.go +++ b/beacon-chain/db/kv/state_diff_prune.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/binary" - "errors" "fmt" "github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags" @@ -41,10 +40,7 @@ func (s *Store) LastStateDiffBoundary(slot primitives.Slot) (primitives.Slot, er } // The last kept slot is the one of the finest level, hence the closest boundary to the slot. - keptSlots, err := stateDiffSlotsToKeep(offset, uint64(slot)) - if err != nil { - return 0, err - } + keptSlots := stateDiffSlotsToKeep(offset, uint64(slot)) return primitives.Slot(keptSlots[len(keptSlots)-1]), nil } @@ -82,10 +78,7 @@ func (s *Store) DeleteStateDiffBeforeSlot(ctx context.Context, cutoffSlot primit return 0, nil } - keptSlots, err := stateDiffSlotsToKeep(offset, cutoff) - if err != nil { - return 0, fmt.Errorf("state diff slots to keep: %w", err) - } + keptSlots := stateDiffSlotsToKeep(offset, cutoff) // The tree is anchored on the kept level 0 entry from now on. // Refuse to prune a tree that cannot be re-anchored, rather than making it unreadable. @@ -136,29 +129,18 @@ func (s *Store) DeleteStateDiffBeforeSlot(ctx context.Context, cutoffSlot primit // stateDiffSlotsToKeep returns, for every level of the tree, the slot of the last entry at or // before the given slot. Those are the only entries below it that a state at or after it needs. -func stateDiffSlotsToKeep(offset, slot uint64) ([]uint64, error) { +// The exponents are validated at node startup, and the callers check the slot against the offset. +func stateDiffSlotsToKeep(offset, slot uint64) []uint64 { exponents := flags.Get().StateDiffExponents - if len(exponents) == 0 { - return nil, errors.New("state diff exponents cannot be empty") - } - - if slot < offset { - return nil, fmt.Errorf("slot %d is before the state diff offset %d", slot, offset) - } - relativeSlot := slot - offset keptSlots := make([]uint64, 0, len(exponents)) for _, exponent := range exponents { - if exponent < flags.MinStateDiffExponent || exponent >= 64 { - return nil, fmt.Errorf("state diff exponent %d out of range for uint64", exponent) - } - span := math.PowerOf2(uint64(exponent)) keptSlots = append(keptSlots, offset+relativeSlot/span*span) } - return keptSlots, nil + return keptSlots } // hasStateDiffKey reports whether the given key is present in the state-diff bucket. diff --git a/beacon-chain/db/kv/state_diff_prune_test.go b/beacon-chain/db/kv/state_diff_prune_test.go index d843a879542a..d7920f7e0914 100644 --- a/beacon-chain/db/kv/state_diff_prune_test.go +++ b/beacon-chain/db/kv/state_diff_prune_test.go @@ -3,7 +3,6 @@ package kv import ( "testing" - "github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags" "github.com/OffchainLabs/prysm/v7/config/features" "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" "github.com/OffchainLabs/prysm/v7/runtime/version" @@ -172,21 +171,21 @@ func TestStateDiff_DeleteBeforeSlot(t *testing.T) { } func TestStateDiff_SlotsToKeep(t *testing.T) { - t.Run("refuses a slot before the offset", func(t *testing.T) { + t.Run("returns the last boundary of every level", func(t *testing.T) { setStateDiffExponents([]int{7, 5}) - _, err := stateDiffSlotsToKeep(256, 128) - require.ErrorContains(t, "is before the state diff offset", err) + // The levels span 128 and 32 slots, counted from the offset. + require.DeepEqual(t, []uint64{256, 320}, stateDiffSlotsToKeep(0, 320)) + require.DeepEqual(t, []uint64{256, 352}, stateDiffSlotsToKeep(0, 383)) + require.DeepEqual(t, []uint64{384, 384}, stateDiffSlotsToKeep(0, 384)) }) - t.Run("refuses an out of range exponent", func(t *testing.T) { - // Too small to be a level span, then too large for a uint64 shift. - for _, exponents := range [][]int{{7, flags.MinStateDiffExponent - 1}, {64, 5}} { - setStateDiffExponents(exponents) + t.Run("counts the boundaries from the offset", func(t *testing.T) { + setStateDiffExponents([]int{7, 5}) - _, err := stateDiffSlotsToKeep(0, 320) - require.ErrorContains(t, "out of range for uint64", err) - } + require.DeepEqual(t, []uint64{128, 128}, stateDiffSlotsToKeep(128, 128)) + require.DeepEqual(t, []uint64{256, 288}, stateDiffSlotsToKeep(128, 300)) + require.DeepEqual(t, []uint64{256, 256}, stateDiffSlotsToKeep(128, 256)) }) } diff --git a/cmd/beacon-chain/flags/config_test.go b/cmd/beacon-chain/flags/config_test.go index 4e3f7bce18ca..c9718bc78c43 100644 --- a/cmd/beacon-chain/flags/config_test.go +++ b/cmd/beacon-chain/flags/config_test.go @@ -26,6 +26,10 @@ func TestValidateStateDiffExponents(t *testing.T) { {exponents: []int{}, wantErr: true, errMsg: "between 1 and 15 values"}, {exponents: []int{30, 18, 16, 13, 11, 9, 5}, wantErr: false}, {exponents: []int{31, 18, 16, 13, 11, 9, 5}, wantErr: true, errMsg: "<= 30"}, + // Rejecting these here is what lets the rest of the code shift by an exponent without + // checking that it fits in a uint64 first. + {exponents: []int{64, 18, 16, 13, 11, 9, 5}, wantErr: true, errMsg: "<= 30"}, + {exponents: []int{64}, wantErr: true, errMsg: "<= 30"}, } for i, tt := range tests { From dee27062b3acea6d53b06f3a719bf9e711ad34b4 Mon Sep 17 00:00:00 2001 From: Manu NALEPA Date: Fri, 14 Aug 2026 16:04:10 +0200 Subject: [PATCH 5/8] Address @Inspector-Butters's comment. --- beacon-chain/db/kv/state_diff_helpers.go | 39 +++++++++++++++++-- beacon-chain/db/kv/state_diff_helpers_test.go | 33 ++++++++++++++++ beacon-chain/db/kv/state_diff_prune.go | 8 ++-- beacon-chain/db/kv/state_diff_prune_test.go | 37 ++++++++++++++++++ 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/beacon-chain/db/kv/state_diff_helpers.go b/beacon-chain/db/kv/state_diff_helpers.go index a22146631315..29a573bc7544 100644 --- a/beacon-chain/db/kv/state_diff_helpers.go +++ b/beacon-chain/db/kv/state_diff_helpers.go @@ -21,8 +21,15 @@ import ( "go.etcd.io/bbolt" ) -// stateDiffTreeKeyLength is the length of a state-diff tree key, before any suffix. -const stateDiffTreeKeyLength = 16 +const ( + // stateDiffTreeKeyLength is the length of a state-diff tree key, before any suffix. + stateDiffTreeKeyLength = 16 + + // stateDiffTreeKeySlotEnd is the end of the meaningful part of a state-diff tree key: a level + // byte followed by a little-endian slot. The bytes up to stateDiffTreeKeyLength are padding, + // and are always zero. + stateDiffTreeKeySlotEnd = 9 +) var ( offsetKey = []byte("offset") @@ -117,10 +124,36 @@ func (s *Store) loadStateDiffExponents() ([]int, error) { func makeKeyForStateDiffTree(level int, slot uint64) []byte { buf := make([]byte, stateDiffTreeKeyLength) buf[0] = byte(level) - binary.LittleEndian.PutUint64(buf[1:], slot) + binary.LittleEndian.PutUint64(buf[1:stateDiffTreeKeySlotEnd], slot) return buf } +// isStateDiffTreeKey reports whether the given key holds a tree entry, as opposed to one of the +// metadata keys stored in the same bucket. +func isStateDiffTreeKey(key []byte) bool { + if len(key) < stateDiffTreeKeyLength { + return false + } + + if int(key[0]) >= len(flags.Get().StateDiffExponents) { + return false + } + + for _, padding := range key[stateDiffTreeKeySlotEnd:stateDiffTreeKeyLength] { + if padding != 0 { + return false + } + } + + return true +} + +// stateDiffTreeKeySlot returns the slot a state-diff tree key is stored at. +// It must only be called on a key that isStateDiffTreeKey accepts. +func stateDiffTreeKeySlot(key []byte) uint64 { + return binary.LittleEndian.Uint64(key[1:stateDiffTreeKeySlotEnd]) +} + func (s *Store) getAnchorState(ctx context.Context, offset uint64, lvl int, slot primitives.Slot) (anchor state.ReadOnlyBeaconState, err error) { if lvl <= 0 || lvl > len(flags.Get().StateDiffExponents) { return nil, errors.New("invalid value for level") diff --git a/beacon-chain/db/kv/state_diff_helpers_test.go b/beacon-chain/db/kv/state_diff_helpers_test.go index 560c15fb5d91..6a76d6c875ab 100644 --- a/beacon-chain/db/kv/state_diff_helpers_test.go +++ b/beacon-chain/db/kv/state_diff_helpers_test.go @@ -1,6 +1,7 @@ package kv import ( + "bytes" "testing" "github.com/OffchainLabs/prysm/v7/runtime/version" @@ -17,6 +18,38 @@ func TestMakeKeyForStateDiffTree_KeyLength(t *testing.T) { require.Equal(t, 16, len(key)) } +func TestIsStateDiffTreeKey(t *testing.T) { + setStateDiffExponents([]int{7, 5}) + + // A tree key with a level byte, a slot, and zero padding, then the same with an entry suffix. + treeKey := makeKeyForStateDiffTree(1, 320) + suffixedKey := append(bytes.Clone(treeKey), stateSuffix...) + + // A key that is shaped like a tree key up to its padding, which a tree key never sets. + paddedKey := bytes.Clone(treeKey) + paddedKey[stateDiffTreeKeySlotEnd] = 'x' + + tests := []struct { + name string + key []byte + want bool + }{ + {name: "tree key", key: treeKey, want: true}, + {name: "suffixed tree key", key: suffixedKey, want: true}, + {name: "offset metadata key", key: offsetKey, want: false}, + {name: "exponents metadata key", key: exponentsKey, want: false}, + {name: "metadata key longer than a tree key", key: []byte("a-long-metadata-key-here"), want: false}, + {name: "level byte out of range", key: append([]byte("m"), make([]byte, stateDiffTreeKeyLength)...), want: false}, + {name: "non-zero padding", key: paddedKey, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isStateDiffTreeKey(tt.key)) + }) + } +} + func TestKeyForSnapshot_AllVersions(t *testing.T) { for _, v := range version.All() { t.Run(version.String(v), func(t *testing.T) { diff --git a/beacon-chain/db/kv/state_diff_prune.go b/beacon-chain/db/kv/state_diff_prune.go index 3f5bb525120a..e176b2e09213 100644 --- a/beacon-chain/db/kv/state_diff_prune.go +++ b/beacon-chain/db/kv/state_diff_prune.go @@ -183,12 +183,12 @@ func (s *Store) stateDiffKeysBefore(ctx context.Context, slot uint64, kept map[u return nil } - // Metadata keys are shorter than a tree key, and are never pruned. - if len(key) < stateDiffTreeKeyLength { + // The bucket also holds metadata keys, which are never pruned. + if !isStateDiffTreeKey(key) { continue } - entrySlot := binary.LittleEndian.Uint64(key[1:9]) + entrySlot := stateDiffTreeKeySlot(key) if entrySlot >= slot || kept[entrySlot] { continue } @@ -288,7 +288,7 @@ func (s *Store) reanchorStateDiffCache(offset uint64) error { cursor := bucket.Cursor() for level := range levelsWithData { key, _ := cursor.Seek([]byte{byte(level)}) - levelsWithData[level] = key != nil && len(key) >= stateDiffTreeKeyLength && key[0] == byte(level) + levelsWithData[level] = key != nil && isStateDiffTreeKey(key) && key[0] == byte(level) } return nil diff --git a/beacon-chain/db/kv/state_diff_prune_test.go b/beacon-chain/db/kv/state_diff_prune_test.go index d7920f7e0914..d6a7045861aa 100644 --- a/beacon-chain/db/kv/state_diff_prune_test.go +++ b/beacon-chain/db/kv/state_diff_prune_test.go @@ -135,6 +135,43 @@ func TestStateDiff_DeleteBeforeSlot(t *testing.T) { require.NoError(t, validateStateDiffCache(t.Context(), db, cache)) }) + t.Run("keeps the metadata keys, whatever their length", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 384) + + // Metadata keys are told apart from tree keys by their shape, not by their length, so a + // metadata key longer than a tree key is not mistaken for an entry to prune. + longKeys := [][]byte{ + // A word longer than a tree key. + []byte("a-metadata-key-longer-than-a-tree-key"), + // One whose bytes would otherwise decode as a tree entry at slot 0, below the cutoff. + append([]byte("m"), make([]byte, stateDiffTreeKeyLength)...), + } + + require.NoError(t, db.db.Update(func(tx *bolt.Tx) error { + for _, key := range longKeys { + require.Equal(t, true, len(key) > stateDiffTreeKeyLength) + if err := tx.Bucket(stateDiffBucket).Put(key, []byte("value")); err != nil { + return err + } + } + + return nil + })) + + require.Equal(t, true, drainStateDiffPruning(t, db, 320, 2) > 0) + + require.NoError(t, db.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket(stateDiffBucket) + for _, key := range longKeys { + require.DeepEqual(t, []byte("value"), bucket.Get(key)) + } + require.Equal(t, true, bucket.Get(offsetKey) != nil) + require.Equal(t, true, bucket.Get(exponentsKey) != nil) + + return nil + })) + }) + t.Run("is idempotent", func(t *testing.T) { db := setupPrunableStateDiffTree(t, 384) From 9e53c686d932d4df29a918c775b4857420cd610f Mon Sep 17 00:00:00 2001 From: Manu NALEPA Date: Fri, 14 Aug 2026 16:09:20 +0200 Subject: [PATCH 6/8] Address @Inspector-Butters's comment. --- beacon-chain/db/kv/state_diff_prune.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/beacon-chain/db/kv/state_diff_prune.go b/beacon-chain/db/kv/state_diff_prune.go index e176b2e09213..0e028d754112 100644 --- a/beacon-chain/db/kv/state_diff_prune.go +++ b/beacon-chain/db/kv/state_diff_prune.go @@ -197,6 +197,9 @@ func (s *Store) stateDiffKeysBefore(ctx context.Context, slot uint64, kept map[u continue } + // A diff entry is split into a state, a validator and a balances key, which share the + // same prefix and hence follow each other. Counting prefixes rather than keys keeps the + // budget in entries, and ends the batch between two of them, never in the middle of one. if !bytes.Equal(entryPrefix, key[:stateDiffTreeKeyLength]) { if entries == maxEntries { return nil From ee2ec1410d9ad0291385a2da072b9c400edb59cf Mon Sep 17 00:00:00 2001 From: Manu NALEPA Date: Fri, 14 Aug 2026 16:20:32 +0200 Subject: [PATCH 7/8] Address @Inspector-Butters's comment. --- beacon-chain/db/kv/state_diff_prune.go | 4 +- beacon-chain/db/kv/state_diff_prune_test.go | 54 +++++++++++++++++++++ beacon-chain/db/pruner/pruner.go | 4 ++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/beacon-chain/db/kv/state_diff_prune.go b/beacon-chain/db/kv/state_diff_prune.go index 0e028d754112..c9b3c0dfa4ab 100644 --- a/beacon-chain/db/kv/state_diff_prune.go +++ b/beacon-chain/db/kv/state_diff_prune.go @@ -179,8 +179,8 @@ func (s *Store) stateDiffKeysBefore(ctx context.Context, slot uint64, kept map[u cursor := bucket.Cursor() for key, _ := cursor.First(); key != nil; key, _ = cursor.Next() { - if ctx.Err() != nil { - return nil + if err := ctx.Err(); err != nil { + return err } // The bucket also holds metadata keys, which are never pruned. diff --git a/beacon-chain/db/kv/state_diff_prune_test.go b/beacon-chain/db/kv/state_diff_prune_test.go index d6a7045861aa..674877830661 100644 --- a/beacon-chain/db/kv/state_diff_prune_test.go +++ b/beacon-chain/db/kv/state_diff_prune_test.go @@ -1,6 +1,8 @@ package kv import ( + "bytes" + "context" "testing" "github.com/OffchainLabs/prysm/v7/config/features" @@ -172,6 +174,58 @@ func TestStateDiff_DeleteBeforeSlot(t *testing.T) { })) }) + t.Run("does not re-anchor the tree on a cancelled context", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 384) + + // A scan that stops early has not proven there is nothing left to delete, so it must not + // pass for a finished one and let the tree be re-anchored on a slot it never cleaned up to. + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := db.DeleteStateDiffBeforeSlot(ctx, 320, 512) + require.ErrorIs(t, err, context.Canceled) + + // The tree is left untouched, and the entries below the would-be new anchor are still there. + require.Equal(t, uint64(0), db.getOffset()) + st, err := db.stateByDiff(t.Context(), 128) + require.NoError(t, err) + require.Equal(t, primitives.Slot(128), st.Slot()) + }) + + t.Run("never ends a batch in the middle of an entry", func(t *testing.T) { + db := setupPrunableStateDiffTree(t, 384) + + // The budget is spent on entries rather than on keys, so a batch holds every key of the + // entries it touches, and none of the next one. + kept := map[uint64]bool{256: true, 320: true} + for _, maxEntries := range []int{1, 2, 3} { + keys, err := db.stateDiffKeysBefore(t.Context(), 320, kept, makeKeyForStateDiffTree(0, 0), maxEntries) + require.NoError(t, err) + require.Equal(t, true, len(keys) > 0) + + batch := make(map[string]int) + for _, key := range keys { + batch[string(key[:stateDiffTreeKeyLength])]++ + } + require.Equal(t, true, len(batch) <= maxEntries) + + require.NoError(t, db.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket(stateDiffBucket) + for prefix, batched := range batch { + stored := 0 + cursor := bucket.Cursor() + for key, _ := cursor.Seek([]byte(prefix)); bytes.HasPrefix(key, []byte(prefix)); key, _ = cursor.Next() { + stored++ + } + + require.Equal(t, stored, batched) + } + + return nil + })) + } + }) + t.Run("is idempotent", func(t *testing.T) { db := setupPrunableStateDiffTree(t, 384) diff --git a/beacon-chain/db/pruner/pruner.go b/beacon-chain/db/pruner/pruner.go index 7cdce0595deb..1dca66d13da7 100644 --- a/beacon-chain/db/pruner/pruner.go +++ b/beacon-chain/db/pruner/pruner.go @@ -222,6 +222,10 @@ func (p *Service) pruneStateDiff(pruneUpto primitives.Slot) (int, error) { default: batch, err := p.db.DeleteStateDiffBeforeSlot(ctx, pruneUpto, defaultPrunableStateDiffEntries) if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return deleted, nil + } + return deleted, err } From 50e2b0db79956cd10224f2e6adb60e8a336af397 Mon Sep 17 00:00:00 2001 From: Manu NALEPA Date: Fri, 14 Aug 2026 16:29:38 +0200 Subject: [PATCH 8/8] Address @Inspector-Butters's comment. --- beacon-chain/db/kv/state_diff_cache.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/beacon-chain/db/kv/state_diff_cache.go b/beacon-chain/db/kv/state_diff_cache.go index e4242e6872d7..de353ecf3057 100644 --- a/beacon-chain/db/kv/state_diff_cache.go +++ b/beacon-chain/db/kv/state_diff_cache.go @@ -238,9 +238,7 @@ func (c *stateDiffCache) reanchor(offset uint64, levelsWithData []bool) { c.offset = offset c.levelsWithData = levelsWithData - for level := range c.anchors { - c.anchors[level] = nil - } + c.clearAnchorsLocked() } func (c *stateDiffCache) levelHasData(level int) bool { @@ -277,6 +275,11 @@ func (c *stateDiffCache) setOffset(offset uint64) { func (c *stateDiffCache) clearAnchors() { c.Lock() defer c.Unlock() + c.clearAnchorsLocked() +} + +// clearAnchorsLocked is clearAnchors, for the callers that already hold the lock. +func (c *stateDiffCache) clearAnchorsLocked() { c.anchors = make([][]byte, len(flags.Get().StateDiffExponents)-1) // -1 because last level doesn't need to be cached for level := range len(c.anchors) { stateDiffAnchorCacheBytes.WithLabelValues(strconv.Itoa(level)).Set(0)