Skip to content

Integrate undo log - #4498

Merged
alrevuelta merged 4 commits into
mainfrom
undolog-2
Oct 15, 2026
Merged

alrevuelta merged 4 commits into
mainfrom
undolog-2

Conversation

@alrevuelta

@alrevuelta alrevuelta commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor
  • This PR integrates the new historical state store (based on undo logs) in giga.

@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedOct 15, 2026, 7:48 AM

@codecov

codecov Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.25806% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.39%. Comparing base (e089531) to head (0d28886).

Files with missing lines Patch % Lines
giga/evmonly/giga_store.go 29.41% 12 Missing ⚠️
sei-db/state_db/giga/historical_view.go 84.61% 6 Missing ⚠️
sei-db/db_engine/pebbledb/undo/db.go 92.42% 5 Missing ⚠️
sei-db/state_db/giga/state_db.go 87.80% 5 Missing ⚠️
sei-db/config/giga_config.go 0.00% 3 Missing ⚠️
giga/config/config.go 87.50% 1 Missing ⚠️
sei-db/state_db/giga/state_db_replay.go 80.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4498      +/-   ##
==========================================
+ Coverage   57.30%   57.39%   +0.08%     
==========================================
  Files        2144     2145       +1     
  Lines      167447   167582     +135     
==========================================
+ Hits        95950    96178     +228     
+ Misses      71492    71399      -93     
  Partials        5        5              
Flag Coverage Δ
sei-chain 55.47% <79.20%> (+<0.01%) ⬆️
sei-db 75.17% <ø> (-0.24%) ⬇️
sei-db-state-db 80.02% <85.88%> (+0.99%) ⬆️

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

Files with missing lines Coverage Δ
giga/evmonly/pipeline_overlay.go 93.90% <100.00%> (+0.07%) ⬆️
sei-tendermint/node/setup.go 66.90% <100.00%> (+0.07%) ⬆️
giga/config/config.go 83.33% <87.50%> (+0.40%) ⬆️
sei-db/state_db/giga/state_db_replay.go 68.26% <80.00%> (+34.52%) ⬆️
sei-db/config/giga_config.go 78.57% <0.00%> (-3.52%) ⬇️
sei-db/db_engine/pebbledb/undo/db.go 87.52% <92.42%> (+0.05%) ⬆️
sei-db/state_db/giga/state_db.go 78.23% <87.80%> (+53.80%) ⬆️
sei-db/state_db/giga/historical_view.go 84.61% <84.61%> (ø)
giga/evmonly/giga_store.go 80.89% <29.41%> (-6.53%) ⬇️

... and 28 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.

@alrevuelta
alrevuelta force-pushed the undolog-2 branch 2 times, most recently from 227dd5a to 2214180 Compare October 14, 2026 11:19
@alrevuelta
alrevuelta marked this pull request as ready for review October 14, 2026 11:20
@cursor

cursor Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

PR Summary

High Risk
Changes core state persistence, commit APIs, rollback/prune behavior, and historical reads; misconfiguration or missing prior values on undo commits could corrupt served history.

Overview
Adds giga.storage.state_store_backend so operators can choose pebbledb (full versioned history) or undolog (prior values per block over current state). Node setup applies the choice via WithStateStoreBackend.

Undo-log integration: When the undo backend is selected, StateDB opens the Pebble undo store instead of the classic EVM SS, resumes it against the state-commit head, commits through CommitStateChangesWithPrior, and rejects state-changing blocks without a change set. Rollback/discard and pruning route through the undo log; OpenViewAt serves historical EVM state via a new historicalView (undo log or pebbledb SS).

Execution path: The EVM-only executor clones prior balance/code in pipeline overlays and, for undo-capable stores, passes the block StateChangeSet into commit. The undo engine’s ApplyBlock now consumes structured gigatypes.StateChangeSet priors instead of raw KV pairs when writing undo records.

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

seidroid[bot]
seidroid Bot previously requested changes Oct 14, 2026

@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.

This PR wires the undo-log history store into the giga StateDB. Execution now hands each block's prior values to it, and OpenViewAt serves past heights from either the undo log or the pebbledb state store. The blocker is that the undo log stores non-empty bytecode made only of zero bytes as absent code. Of the three codex findings I verified and kept all three: the zero-byte code bug (blocker), nil priors through CommitStateChanges (suggestion), and the unpinned pebbledb view (suggestion); no reading was dropped.

Blocking

1 finding on the changed lines, as inline comments.

Non-blocking

3 findings on the changed lines, as inline comments.

seidroid review · decision request_changes · session 0040bb59d27c4a1393c5004e6b92d309 · turn resp_claude_c58bdc026bcd54df681568a3f3452f17 · item 127bd4f807c056f7b3ed28e04ae4fca1

Findings: 1 blocking | 3 non-blocking | 4 posted inline

Comment thread sei-db/db_engine/pebbledb/undo/db.go Outdated
Comment thread sei-db/state_db/giga/state_db.go
Comment thread sei-db/state_db/giga/state_db.go Outdated
Comment thread sei-db/db_engine/pebbledb/undo/db.go

@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.

Stale Bugbot comment from a previous run.

for _, change := range changes.Storage {
w.add(StateKeyPrefix[0], change.Address[:], change.Key[:], nonZero(change.PriorValue[:]))
}
return w.finish()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Undo log drops storage clears

High Severity

writeRecords never records StorageClears. It only writes per-slot Storage entries, on the assumption that a clear only covers storage created in the same transaction. SelfDestruct also clears pre-existing slots that never appear in Storage, including slots that were only read. After that block, historical reads of those slots fall through to the cleared head value, so earlier heights look empty.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2214180. Configure here.

@alrevuelta

Copy link
Copy Markdown
Contributor Author

@seidroid you failed, review again

@seidroid

seidroid Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Claude finished @alrevuelta's task in 1m 53s —— View job


Re-review at bf423c0

Sorry about the failed run. That was a fault in the review driver, not in this PR. I re-reviewed the full diff against origin/main.

No blockers remain. The latest commit fixes three of the four findings from the earlier review. The storage-clear finding is still open, and I've added one new non-blocking note.

Earlier findings, re-checked

Finding Status
Blocker: zero-byte bytecode was recorded as absent ✅ Fixed. writeRecords now treats code as absent only when len(PriorCode) == 0, the same rule the code hash uses. TestZeroByteCodeIsPresent covers it.
CommitStateChanges with no priors silently advanced the undo log ✅ Fixed. CommitStateChangesWithPrior now rejects a non-empty changeset with changes == nil when s.undo != nil (state_db.go), and TestUndoStateDBRefusesABlockWithoutItsChangeSet covers it. Side effect: gigasim, the loadtest genesis commit, and any other caller of the plain gigatypes.StateDB method now fail loudly under undolog instead of corrupting history. That's the right failure mode.
Pebbledb OpenViewAt reported pruned keys as absent ✅ Fixed. Each read checks GetEarliestVersion() again after the Get. I checked the ordering: mvcc.pruneAscending → beginPrunePass → SetEarliestVersion raises the marker before anything is deleted, so a read that races a prune either sees the data or fails. It can't come back empty. TestSSViewFailsReadsOnceItsHeightIsPruned covers it.
A storage clear assumes the cleared storage was already empty ⚠️ Still open (see below)

Non-blocking

1. A storage clear records no priors (sei-db/db_engine/pebbledb/undo/db.go, writeRecords). Cursor flagged the same thing.
The doc comment says a clear "only clears accounts created in the same transaction, whose storage was empty". Nothing enforces that:

  • nativeStateDB.Finalise (giga/evmonly/state_db.go:648-657) clears storage for any account where SelfDestructed is set. Legacy SelfDestruct (state_db.go:496) sets it with no Created check. Only SelfDestruct6780 restricts it. So any block that runs under pre-Cancun rules can wipe storage that already existed.
  • Even under 6780, an account created at an address that already has storage would wipe that storage. GetStorageRoot always returns zero (state_db.go:465), so geth's EIP-7610 collision check never fires.
  • encodeFlatKVChangeSet turns each clear into deletes of every slot SC holds (appendFlatKVStorageClearPairs). Meanwhile changeSetInto emits StorageChange only for slots with a non-zero new value. The deleted slots never reach the undo log, so history below that block silently reads them as empty.

On a chain that is Cancun from genesis this is narrow, but it fails silently. Cheapest fix: the encoder already iterates the cleared slots, so either emit a StorageChange{PriorValue: …} for each one, or return an error when a clear finds stored slots, so the assumption becomes an enforced invariant.

2. Any unclean shutdown drops the whole history window (state_db.go resumeUndoLog → undo.resume).
ApplyBlock is queued asynchronously and records are written pebble.NoSync, but SC catches up to the WAL head on restart. After a crash the log is almost always behind SC, and then resume calls raiseEarliest(height): the node keeps running with no historical state, and the only signal is a Warn log line. The resumeUndoLog comment acknowledges this. Even so, it's a significant operational difference from the pebbledb backend that deserves at least one of these:

  • a line in the state_store_backend config template,
  • a metric or a louder log,
  • or Sync on the latest-height marker every N blocks, to limit how much is lost.

Minor

  • historicalView.Get panics for non-EVM modules and on read errors. That's consistent with the "views have no recoverable errors" contract, but RPC callers of OpenViewAt need to know that a pruned pebbledb height now panics mid-request rather than returning an error.
  • gigatypes.StateChangeSet duplicates the evmonly types, and gigaChangeSet copies every block. This is already marked TODO; fine for now.

Not run

I didn't run the tests in this job. Codecov reports patch coverage of 82%; the gaps are mostly in giga_store.go (commitState/gigaChangeSet, 29%).

@alrevuelta

Copy link
Copy Markdown
Contributor Author

@seidroid im aware of this "A storage clear records no priors". The undo log assumes that self destruct behaviour is post cancun fork. review and approve if you are ok.

@seidroid

seidroid Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Claude finished @alrevuelta's task in 41s —— View job


Storage-clear assumption: I'm OK with this

I can't submit a formal GitHub approval, because the app doesn't have that permission (FAQ). A maintainer will need to click approve. On the merits, I have no remaining objections, and I'm withdrawing my earlier change request. The "storage clear records no priors" note is accepted as a documented design constraint.

Why the post-Cancun assumption holds in production

  • The only production construction of the executor, sei-tendermint/internal/evmonlyapp/app.go:164-168, uses params.AllDevChainProtocolChanges, which turns on Cancun (and later forks) at genesis. When Config.ChainConfig is nil, Executor.chainConfig (giga/evmonly/executor.go:524-530) falls back to the same config.
  • Under those rules geth calls SelfDestruct6780. That function only reaches SelfDestruct when acct.Created is set (giga/evmonly/state_db.go:508-513), so a clear only empties storage written in the same transaction. That storage never reached SC, so it has no prior to record.
  • The legacy SelfDestruct path, which clears storage that already existed, is only reachable through legacySelfDestructChainConfig in tests (executor_test.go:2804). No production config reaches it.

Remaining edge (non-blocking, practically unreachable)

GetStorageRoot always returns zero (state_db.go:465), so the EIP-7610 collision check never fires. Suppose a CREATE/CREATE2 landed on an address that already held storage but had nonce 0 and no code. That storage would be cleared without its priors being recorded. On a Cancun-from-genesis chain, the only way to get such an address is a CREATE2 address collision or storage seeded at genesis. I'm fine leaving it.

Optional hardening (not required for merge)

Today the assumption lives in a doc comment on writeRecords and in the app's choice of chain config. If someone later passes a pre-Cancun ChainConfig with state_store_backend = "undolog", history goes wrong silently. Either of these would turn it into an enforced invariant:

  • When the undolog backend opens, refuse a chain config that isn't Cancun at genesis.
  • Return an error from the encoder when a clear finds slots stored in SC.

Either fits a follow-up PR.

Status of the other findings at bf423c0

  • Fixed: the zero-byte-code blocker, CommitStateChanges without priors, and pebbledb OpenViewAt reads after pruning.
  • Still open, non-blocking: an unclean shutdown drops the whole history window, and the only signal is a Warn log. A note in the config template or a metric would still be worthwhile.

@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.

This PR wires the undo-log EVM state store into giga as an alternative SS backend: it adds a config switch, feeds each block's change set through CommitStateChangesWithPrior, and adds historical OpenViewAt views over either backend. Two of my four earlier findings are fixed and the other two are now non-blocking, so this approves; I kept codex's pruning-panic point as a suggestion, and the tree at the PR merge commit was available but no Go toolchain was, so tests were not run.

Non-blocking

2 findings on the changed lines, as inline comments.

  • With the undo backend, any unclean exit while blocks are still queued in the undo writer leaves the log behind SC, because SC commits synchronously before ApplyBlock enqueues. resumeUndoLog then calls Resume, which raises the earliest height to SC's height and discards all retained history. That includes the writer's own panic(err) path. The comment says this is deliberate, but it turns a crash or a single write failure into losing the whole history. Consider bounding that loss, for example by having the commit wait on the undo writer, or at least alerting on the dropped range beyond the warn-level log.

seidroid review · decision approve · session 36ae51512c614f9fb25d9571f8159d68 · turn resp_claude_a38e6684aeab06079e7b01e6f3c71cf8 · item 54ca185086505c2692819d2592dfd85d

Findings: 0 blocking | 3 non-blocking | 2 posted inline

}

// writeRecords writes a record of the value before the block at height of every key changes writes,
// absent for a key that held a zero value or none. A storage clear writes none: it only clears accounts

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 — This comment still assumes a storage clear only empties storage that was already empty, and nothing enforces it. encodeFlatKVChangeSet turns each clear into deletes of every slot SC currently holds, while the change set carries priors only for slots the block then writes non-zero. If a clear ever drops an existing slot, history below that block silently loses it. Either record a prior for each cleared slot SC held, or fail the block when a clear finds slots.

}
return value, err
}
return &historicalView{height: blockNum, read: read, release: func() {}}, true

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 — (from codex) The pebbledb view now detects pruning correctly, but it still pins nothing and its release does nothing. A normal prune cycle can therefore invalidate a view that opened successfully, and the next read errors, which historicalView.Get turns into a panic (TestSSViewFailsReadsOnceItsHeightIsPruned asserts this). The undo branch keeps its view valid until Close, and the OpenViewAt contract implies the same. Hold the retained range until Close here too, before an RPC caller exists to crash on it.

@seidroid
seidroid Bot dismissed their stale review October 14, 2026 15:41

Superseded: the latest review found nothing blocking in this change.

@cody-littley cody-littley left a comment

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.

LGTM, but addressing the view pin vs. pruning race might be nice (i.e. the comment from the LLM). Also ok if you think this should be handled as a follow up PR.


import "math/big"

// TODO: Temporal copy of giga/evmonly/types.go

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.

nit, "temporary" is probably a better term. I see "temporal" used in a few places other than here as well. I understand what you mean by this, but temporal is usually interpreted as meaning "related to time", not "for a limited amount of time".

@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.

There are 2 total unresolved issues (including 1 from previous review).

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 0d28886. Configure here.

if s.undo != nil && changes == nil && len(changeset) != 0 {
// The undo log would advance past the block with no record of what it replaced.
return fmt.Errorf("commit block %d: the undo-log EVM state store needs the block's change set", blockNum)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty EVM blocks fail undo commit

High Severity

The undo-log commit guard treats any non-empty changeset as requiring a non-nil EVM changes pointer. The executor clones an empty EVM change set to nil while still appending extra non-EVM changesets (the production cursor/app-hash encoder runs on every block), so blocks that did not touch EVM state fail CommitStateChangesWithPrior instead of recording nothing and advancing.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0d28886. Configure here.

Comment thread giga/config/config.go
LookbackWindow int64 `mapstructure:"lookback_window"`
// StateStoreBackend is how the state store keeps its history: "pebbledb" stores every version of
// every key, "undolog" the value each block replaced, over the current state.
StateStoreBackend string `mapstructure:"state_store_backend"`

@yzang2019 yzang2019 Oct 15, 2026 •

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.

Do we plan to support multiple backend in giga world? If undo log is the only SS we want to support moving forwards, I think we don't need to add this config at all

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.

i plan to remove the "old ss" at some point, and just support undo log for giga.

@yzang2019

yzang2019 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

StateDB should only contains SC, SS and StateWAL, undo log ss should be folded into ss package as one of the backend, there should be no giga. We can do this as a separate refactor though

if err := s.catchUpToWAL(ctx); err != nil {
return nil, err
}
if err := s.resumeUndoLog(); err != nil {

@yzang2019 yzang2019 Oct 15, 2026 •

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 an issue: fresh undolog node deadlocks in InitChain

  • resumeUndoLog pins an SC view at version 0 here.
  • seedInitialStateVersion (evmonlyapp/app.go:289) then calls SC().SetInitialVersion, which hangs in flushLatestVersion (flatkv/store_meta.go:420) while that view is held. Reproduced with initial height 1 and 5; pebbledb backend returns fine.
  • Even without the hang, initial height > 1 leaves the undo log at 0 while SC is at H-1, so the first block fails does not follow the latest applied block and the writer panics.
  • Suggest a StateDB.SetInitialVersion that releases the undo head, seeds SC, then re-Resumes, and route InitChain through it.

return fmt.Errorf("commit block %d: block number must not be negative", blockNum)
}

if s.undo != nil && changes == nil && len(changeset) != 0 {

@yzang2019 yzang2019 Oct 15, 2026 •

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.

Another issue: any block with no EVM changes halts the pipeline

  • Empty EVM change set -> clone() returns nil -> gigaChangeSet(nil) is nil.
  • The cursor changeset is still appended every block, so len(changeset) != 0 and this guard errors.
  • The error becomes pipelineFailure, so every later block fails too. Reproduceble locally.
  • Suggest checking for a non-empty EVM-keyed changeset instead of len(changeset), or passing an empty &gigatypes.StateChangeSet{} instead of nil.

}
// Before SC: the undo log holds a view of SC's current block until it closes.
if s.undo != nil {
if err := timer.Close("ss", s.undo.Close); err != 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.

Shutdown with a historical view still open

  • If a caller still holds a view from OpenViewAt, undo.Close refuses and returns an error, so the undo log stays open.
  • We then close SC anyway, while the undo log is still holding an SC view.
  • No production caller of OpenViewAt yet, but once RPC uses it, an in-flight query during shutdown hits this.
  • Suggest waiting for (or force-releasing) open views before closing, or skipping SC close when the undo log failed to close.

@alrevuelta

Copy link
Copy Markdown
Contributor Author

@yzang2019 @cody-littley will address your comments in followup PRs. tracked in linear.

@alrevuelta
alrevuelta added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit a47089b Oct 15, 2026
99 checks passed
@alrevuelta
alrevuelta deleted the undolog-2 branch October 15, 2026 10:03
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.

4 participants