Skip to content

--beacon-db-pruning: Prune state diff buckets when the --enable-state-diff flag is used. - #17289

Draft
nalepae wants to merge 8 commits into
developfrom
prune-state-diff-bucket
Draft

--beacon-db-pruning: Prune state diff buckets when the --enable-state-diff flag is used.#17289
nalepae wants to merge 8 commits into
developfrom
prune-state-diff-bucket

Conversation

@nalepae

@nalepae nalepae commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?
Feature

What does this PR do? Why is it needed?
Before this PR, when both using --beacon-db-pruning and --enable-state-diff, the state (diffs) are actually not pruned, which kind of defeat the purpose of pruning.
(When using --beacon-db-pruning without --enable-state-diff, states are pruned.)

This pull request implements the state diff buckets pruning.

To ensure every state in the retention window is recomputable, we prune the state diffs according to the following pattern:

With C the cutoff slot (first slot for which the state has to be re-computable), and k0, k1 and k2 the last entry of each level at or before it.

  • . 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  .........................................................#############

Rebuilding a state needs, for each level, the entry stored at the last boundary of that level at or before that state. For every state at or after C, those are either entries at or after C, or, for each level, the single entry stored at the last boundary before it. So only k0, k1 and k2 are kept below the cutoff, and everything else there is deleted.

The slots between C and the next stored entry have no state of their own, and are recomputed by replaying blocks from k2. This is what the + blocks are for, and their only purpose: they are older than the retention period, and kept so that the states between C and the next entry stay recomputable.

Note

With the default --state-diff-exponents, the finest level spans 32 slots, so C is a boundary itself, k2 is C and no extra block is kept.

Other notes for review
Please read commit by commit, with commit messages.

Acknowledgements

  • I have read CONTRIBUTING.md.
  • I have included a uniquely named changelog fragment file.
  • I have added a description with sufficient context for reviewers to understand this PR.
  • I have tested that my changes work as expected and I added a testing plan to the PR description (if applicable).

@nalepae
nalepae force-pushed the prune-state-diff-bucket branch 2 times, most recently from d84c89e to 12c0cb9 Compare August 3, 2026 09:58
Base automatically changed from statediff-corrupted-db to develop August 3, 2026 12:10
nalepae added 3 commits August 3, 2026 14:26
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.
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.
@nalepae
nalepae force-pushed the prune-state-diff-bucket branch from 12c0cb9 to 2aaa9e2 Compare August 3, 2026 12:30
Comment thread beacon-chain/db/kv/state_diff_prune.go Outdated
Comment on lines +152 to +155
for _, exponent := range exponents {
if exponent < flags.MinStateDiffExponent || exponent >= 64 {
return nil, fmt.Errorf("state diff exponent %d out of range for uint64", exponent)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you don't really need all these checks. they will get validated upon node startup. (also the len check above)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the slot<offset is also duplicate since the caller checks it. but it doesn't matter much.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 40ed984.

Comment thread beacon-chain/db/kv/state_diff_prune.go Outdated
Comment on lines +204 to +207
// Metadata keys are shorter than a tree key, and are never pruned.
if len(key) < stateDiffTreeKeyLength {
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might be dangerous if we add a metdata key longer than a node key

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in dee2706.

continue
}

if !bytes.Equal(entryPrefix, key[:stateDiffTreeKeyLength]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this confused me. maybe add a comment about why we're doing this mentioning the key_s/v/b keys for each node.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 9e53c68.

Comment on lines +219 to +221
if entries == maxEntries {
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd put this check in the for loop condition. (key != nil && entries <= maxEntries;). might be cleaner, but doesn't matter.


if len(keys) > 0 {
if err := s.deleteStateDiffKeys(keys); err != nil {
return 0, fmt.Errorf("delete state diff keys: %w", err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should use errors.Wrap(). also in other places, I won't comment on them so it's not spammy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the contrary, we should probably stop using errors.Wrap at all.
errors.Wrap depends on an external package that moved into maintenance mode several years ago.

This packages was created (and used by Prysm) because before 1.13, go had no standard way to wrap errors.
Since, 1.13, it is possible to wrap errors without depending on any external library, which is %w way with fmt.Errorf.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh. I remember Kasey mentioning that we should use errors.Wrap.
nevermind then.

Comment thread beacon-chain/db/kv/state_diff_prune.go Outdated
Comment on lines +200 to +202
if ctx.Err() != nil {
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we return the error here? because imagine the context gets cancelled before any key is found, if we don't return an error, the caller will think that we are finished with the pruning (cause 0 was returned), and it will wrongfully reset the anchor which might mess up the tree, or on later restarts complain about corrupted db.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ee2ec14.

Comment thread beacon-chain/db/kv/state_diff_cache.go Outdated
Comment on lines +241 to +243
for level := range c.anchors {
c.anchors[level] = nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of this you should call c.clearAnchors(). it will do this and also reset their metric gauges.

@nalepae nalepae Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was not possible to apply the exact suggestion because the lock is already taken, but implemented that instead: 50e2b0d

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants