Skip to content

Rollback tooling for LittDB / recieptDB - #4080

Open
cody-littley wants to merge 1 commit into
mainfrom
cjl/reciept-db-rollback
Open

Rollback tooling for LittDB / recieptDB#4080
cody-littley wants to merge 1 commit into
mainfrom
cjl/reciept-db-rollback

Conversation

@cody-littley

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Tooling that allows the caller to get the blocks stored in the DB and to prune the DB before first starting it.

@cursor

cursor Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Offline PruneAfter and RollbackLittDB permanently delete on-disk segment and index data; mistakes or running against a live DB (mitigated by directory locks) could cause data loss or inconsistency.

Overview
Adds offline tooling to inspect and trim LittDB-backed data while the database is stopped, plus receipt-store helpers for startup-time operations.

Disk table iterators no longer depend on a live DiskTable for cleanup: forward and reverse iterators use an onClose hook (live path still releases segment reservations and notifies the control loop via closeLiveIterator). New NewOfflineForwardIterator / NewOfflineReverseIterator wire directory-lock release instead.

The former rollback package is renamed and broadened to litt/offline: NewIterator locks data dirs, gathers segments (respecting the GC watermark), and returns forward or reverse iterators; RollbackLittDB now takes *litt.Config (paths, fsync) instead of a raw path slice.

receipt.GetRange scans the litt receipt table offline to report min/max block height. receipt.PruneAfter rolls back receipt bodies above a block via offline.RollbackLittDB, then range-deletes pebble tag-index keys and updates latest-block metadata, with a pre-mutation check against the retention floor.

Reviewed by Cursor Bugbot for commit 483e238. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedSep 2, 2026, 3:50 PM

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 483e238. Configure here.

Comment thread sei-db/ledger_db/receipt/offline.go
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.70588% with 72 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.17%. Comparing base (b68026f) to head (483e238).

Files with missing lines Patch % Lines
sei-db/ledger_db/receipt/offline.go 55.43% 21 Missing and 20 partials ⚠️
sei-db/db_engine/litt/offline/iterator.go 60.34% 12 Missing and 11 partials ⚠️
...ei-db/db_engine/litt/disktable/forward_iterator.go 86.20% 2 Missing and 2 partials ⚠️
sei-db/db_engine/litt/offline/rollback.go 55.55% 2 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4080      +/-   ##
==========================================
- Coverage   61.23%   60.17%   -1.06%     
==========================================
  Files        2177     2072     -105     
  Lines      190632   178426   -12206     
==========================================
- Hits       116729   107368    -9361     
+ Misses      62892    61041    -1851     
+ Partials    11011    10017     -994     
Flag Coverage Δ
sei-chain-pr 66.43% <64.70%> (?)
sei-db 69.80% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ei-db/db_engine/litt/disktable/reverse_iterator.go 88.88% <100.00%> (+5.81%) ⬆️
...ei-db/db_engine/litt/disktable/forward_iterator.go 80.16% <86.20%> (+1.98%) ⬆️
sei-db/db_engine/litt/offline/rollback.go 66.91% <55.55%> (ø)
sei-db/db_engine/litt/offline/iterator.go 60.34% <60.34%> (ø)
sei-db/ledger_db/receipt/offline.go 55.43% <55.43%> (ø)

... and 109 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The offline iterator extraction and the litt package rename are clean and well tested, but the new receipt.PruneAfter/GetRange entry points can silently report success while leaving the store inconsistent: they ignore cfg.Backend (which defaults to pebbledb), and they rewrite the store's head metadata even when the litt rollback found no pivot and changed nothing.

Findings: 2 blocking | 5 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • [suggestion] GetRange reports the lowest block physically present in litt, which can be below the store's retention floor (m:earliest): after pruning, bodies survive until litt's TTL/GC reclaims them, so an operator using GetRange to choose a rollback target can be shown a lowest block the store will not serve and that PruneAfter will then refuse. Consider reading m:earliest and reporting the served floor (or documenting the distinction on GetRange).
  • [suggestion] Test coverage gaps for the two riskiest PruneAfter paths: a target below the lowest block present in litt (the no-pivot case), and a config whose backend is not littidx. Both currently return success having done nothing meaningful.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

// checked before any mutation, so a refusal leaves the store untouched. Otherwise it rolls back the
// litt-backed receipt bodies, then deletes the pebble tag-index entries above highestBlockToKeep and moves
// the store's latest-block metadata back to match.
func PruneAfter(cfg dbconfig.ReceiptStoreConfig, highestBlockToKeep uint64) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] GetRange and PruneAfter both assume the littidx on-disk layout (<DBDirectory>/littdb, <DBDirectory>/log-index) but never consult cfg.Backend, which defaults to pebbledb (dbconfig.DefaultReceiptStoreConfig). For a pebbledb-backed store the receipts live directly under DBDirectory (mvcc.OpenDB(ssConfig.DBDirectory, ...) in receipt_store.go), so:

  • GetRange returns ok=false ("no receipts") for a store full of receipts, and
  • PruneAfter opens/creates an empty log-index pebble DB, reads latest == 0, and returns nil — reporting success while pruning nothing.

Both also create the missing littdb/log-index subdirectories inside the live store's directory as a side effect, and because neither touches the pebbledb backend's own lock, this can happen while the node is running (the littidx path is protected only incidentally, by pebble's lock on log-index).

Guard at the entry point, e.g. reject normalizeReceiptBackend(cfg.Backend) != receiptBackendLittIdx before doing anything.

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.

+1

}
return height <= highestBlockToKeep, nil
}
if err := offline.RollbackLittDB(littConfig, filter); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] When the filter never returns true, RollbackLittDB logs "no rollback point found, leaving table unchanged" and returns nil (offline/rollback.go:137-140), but PruneAfter carries on to delete the tag-index entries above highestBlockToKeep and move m:latest down.

That happens whenever highestBlockToKeep is below the lowest block present in litt — e.g. PruneAfter(cfg, 0), or a store whose receipts start at a state-sync height being rolled back below it. The refusal above doesn't catch it: m:earliest is 0 on a never-pruned store.

Result: the store reports head highestBlockToKeep, FilterLogs sees nothing above it, but every receipt body above it is still in litt and still served by GetReceiptFromStore / eth_getTransactionReceipt, because the read-time floor keys off m:earliest, which is untouched.

RollbackLittDB should report whether it found a pivot (per table) so PruneAfter can fail loudly — or discard the table outright — instead of rewriting metadata that the data no longer matches.

// Returns 0 if the key is absent, malformed, or unreadable, matching littReceiptStore.readMeta's behavior.
func readMetaOffline(index dbtypes.KeyValueDB, key []byte) (uint64, error) {
val, err := index.Get(key)
if err != nil || len(val) != blockNumLen {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] readMetaOffline collapses every failure into (0, nil), though pebble distinguishes a missing key (errorutils.ErrNotFound) from a real read error (pebbledb/db.go:105-116). On this path that turns two distinct corruptions into silent wrong behaviour:

  • m:earliest unreadable or malformed ⇒ earliest == 0 ⇒ the retention-floor refusal this function's own godoc promises ("checked before any mutation") is skipped and the rollback proceeds;
  • m:latest unreadable ⇒ latest == 0PruneAfter returns nil having done nothing, reporting success.

The function already returns an error it never populates — map only ErrNotFound to 0 and propagate the rest. (littReceiptStore.readMeta's laxity is fine by comparison: it defaults an in-memory hint rather than gating a destructive operation, so the "matching readMeta's behavior" rationale in the comment doesn't carry over.)

if err != nil {
return fmt.Errorf("failed to open receipt log index: %w", err)
}
defer func() { _ = index.Close() }()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] On this mutating path the index Close error is discarded, so a failure to flush the range-delete tombstone and the new m:latest still lets PruneAfter return nil. Capture it into a named return (err = errors.Join(err, index.Close())) so a failed close surfaces as a failed prune.


lowestSegmentIndex, highestSegmentIndex, segments, err := segment.GatherSegmentFiles(
logger, errorMonitor, segmentPaths, false /* snapshottingEnabled */, time.Now(),
true /* cleanOrphans */, fsync)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] cleanOrphans: true makes the read-only iterator delete orphaned segment files (and GatherSegmentFiles unconditionally removes garbage files), while NewIterator also creates the data directories at line 34. So GetRange, documented as reporting heights "without opening the store", mutates the directory it inspects and destroys exactly the leftovers an operator would want for a post-mortem. Passing false here loads the same segments without the cleanup — the rollback path is the one that legitimately wants true.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants