Skip to content

evmonly: persist receipts and encode state changes behind the block; Commit advances the head once the receipts have landed - #4264

Merged
bdchatham merged 3 commits into
giga-1from
devin/1789850613-receipts-in-pipeline
Sep 20, 2026
Merged

bdchatham merged 3 commits into
giga-1from
devin/1789850613-receipts-in-pipeline

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Third of the three execution-side PRs (after #4262, #4263). Takes the rest of the executor's storage tail off the block loop, the way #4254 did for the state commit, while keeping "a block RPC serves has readable receipts".

Before, after execution the loop did, serially: encode state changesets → block encoder → encode receipts → SetReceipts → await previous commit → start this block's commit in the background. On testnet-2 the two encodes were ~0.08 s/s of loop time.

After, the loop does: [await previous commit] → block encoder (stays on the loop: what it stages is what the caller reports for the block) → await previous commit → start the pipeline → return. The pipeline goroutine then, per block, in order:

persistReceipts:   encode receipts (parallel) → SetReceipts → WaitForPendingWrites → check LatestVersion >= block
release the pooled BlockResult
commitStateChanges: encode state changesets (from a clone) → append block encoder's → CommitStateChanges

NamedChangeSetEncoder now runs after the previous block has landed and before this one starts, so an encoder that reads the store (the storage-clear expansion) sees every earlier block and none of this one; the encodingReadsTheStore special case that used to serialize such blocks is gone. It runs on a clone, so the pooled result is free as soon as receipts are encoded.

Head advances after receipts land. evmOnlyApplication.Commit calls the new Executor.AwaitReceipts() before advancing the cursor. The router publishes the block (and RPC latest follows it) only after Commit, so latest never points at a block whose receipts eth_getTransactionReceipt / eth_getBlockReceipts cannot read. Previously the litt store's own async writer meant receipts could trail the visible head by up to its queue depth; that window is closed, not widened.

To make "land" real rather than "enqueued", littReceiptStore now implements the existing seidbtypes.PendingWriteWaiter capability: each queued write carries a landed channel the writer closes after applying it (or skipping it after a latched failure), and WaitForPendingWrites waits on the last admitted one. The executor asserts LatestVersion() >= block afterwards so a write dropped after a latched failure is reported for this block rather than by the next SetReceipts.

Failure semantics are unchanged in shape: a receipt or state persistence failure latches (pipelineFailure), Commit returns it if the receipt stage failed, and the next FinalizeBlock returns it otherwise (persist block: …). Once latched the executor refuses further blocks. Cancelling the request that ran the block no longer abandons its persistence (context.WithoutCancel): Close waits for it, and the block's outcome is reported through the pipeline rather than by dropping it.

Cost to watch after the roll. Commit now waits for this block's receipt encode + litt write, which overlap the router's vault_commit (#4263) but no longer with the next block's execution. New evmonly_pipeline phase timer (encode_receipts, write_receipts, await_receipts, encode_changesets, commit_state) and the loop's evmonly_block timer (encode_block_changesets, await_commit, start_commit) show where the time went; if await_receipts is large the litt write itself is the next target.

Testing performed to validate your change

  • giga/evmonly: TestAwaitReceiptsReturnsBeforeTheStateCommitLands (receipts readable and pool lease returned while the state commit is still gated), TestFailedReceiptWriteFailsTheBlockBeforeItsStateCommit (no state commit after a failed receipt write; failure surfaces), TestBackgroundEncoderSeesTheBlocksOwnChanges (encoder gets a clone, not the recycled pooled result); existing store/pipeline/overlay/determinism tests unchanged except TestExecutorReturnsReceiptStoreError, updated for the write now landing behind the block and latching.
  • sei-db/ledger_db/receipt: TestLittIdxWaitForPendingWritesLandsEveryQueuedBlock.
  • sei-tendermint/internal/evmonlyapp: TestEVMOnlyApplicationCommitReturnsWithTheBlocksReceiptsReadable (receipt for height N readable from the store the moment Commit returns, N=1..3); existing failed-commit fallback and reopen tests unchanged.
  • scripts/ramtest.sh ./giga/evmonly/... -race, ./sei-db/ledger_db/receipt/ -race, go test -race -count=8 ./internal/evmonlyapp/, go vet, golangci-lint run on touched packages, make fmtcheck — all green.

Link to Devin session: https://app.devin.ai/sessions/ff612badcded4aa5914ea408dbb41888
Open in Devin Desktop: https://app.devin.ai/desktop/session/ff612badcded4aa5914ea408dbb41888?variant=devin
Requested by: @bdchatham

…Commit waits for the receipts to land

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@cursor

cursor Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

PR Summary

High Risk
Changes block commit ordering, ABCI Commit blocking on receipt durability, and async failure/latching semantics for ledger state and receipts—areas that directly affect what RPC and restarts observe.

Overview
Moves receipt persistence and state changeset encoding off the EVM-only block loop into a background pipeline (receipts first, then state commit), while the loop keeps only the block changeset encoder and hands off work via startPipelineCommit.

ResultSink / ExecuteBlock can return before writes land; callers that need readable receipts use the new Executor.AwaitReceipts(), which evmOnlyApplication.Commit now calls so RPC latest height only advances once that block’s receipts are queryable. littReceiptStore implements WaitForPendingWrites (per-write landed channel + version check) so “enqueued” is not mistaken for durable.

NamedChangeSetEncoder runs in the pipeline on a cloned changeset, so pooled BlockResult can be released after receipt encode; store-reading encoders still wait for the prior commit via the existing pipeline ordering. Failures latch (persist block / receipt errors); request cancellation no longer drops background persistence (context.WithoutCancel). Docs and tests cover receipts-before-state, failed receipt writes skipping state commit, and commit-time receipt readability.

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

@github-actions

github-actions Bot commented Sep 19, 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 20, 2026, 4:34 AM

@codecov

codecov Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.13924% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.47%. Comparing base (7e3ca24) to head (c6d9b4e).
⚠️ Report is 44 commits behind head on giga-1.

Files with missing lines Patch % Lines
giga/evmonly/giga_store.go 95.00% 3 Missing ⚠️
sei-db/ledger_db/receipt/receipt_store.go 0.00% 2 Missing ⚠️
sei-tendermint/internal/evmonlyapp/app.go 66.66% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           giga-1    #4264       +/-   ##
===========================================
+ Coverage   65.55%   86.47%   +20.91%     
===========================================
  Files        2081       36     -2045     
  Lines      157460     4871   -152589     
===========================================
- Hits       103222     4212    -99010     
+ Misses      54097      658    -53439     
+ Partials      141        1      -140     
Flag Coverage Δ
sei-chain ?
sei-chain-pr 86.47% <91.13%> (?)
sei-db ?

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

Files with missing lines Coverage Δ
giga/evmonly/executor.go 91.46% <100.00%> (+0.25%) ⬆️
sei-db/ledger_db/receipt/litt_receipt_store.go 88.41% <100.00%> (+4.94%) ⬆️
sei-db/ledger_db/receipt/receipt_store.go 74.11% <0.00%> (+1.01%) ⬆️
sei-tendermint/internal/evmonlyapp/app.go 87.04% <66.66%> (+5.27%) ⬆️
giga/evmonly/giga_store.go 90.99% <95.00%> (-2.11%) ⬇️

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

Moving receipt persistence and state-changeset encoding behind the block loop is implemented coherently: the pipeline goroutine starts only after the previous block's commit landed (so the state encoder still sees "every earlier block and none of this one" without the old encodingReadsTheStore special case), the pooled result is refcounted across the loop and the background write, Close still drains the goroutine, and Commit gates head advance on AwaitReceipts. No blocking defects found; three non-blocking notes, mainly around the new WaitForPendingWrites contract and coverage of the new "did not land" branch.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] The new "receipts did not land" branch in persistReceipts (giga/evmonly/giga_store.go:379) is untested. TestFailedReceiptWriteFailsTheBlockBeforeItsStateCommit covers a SetReceipts error return, not the case this branch exists for — a write admitted to the litt queue and then skipped by applyWrite after a latched failure, where landed closes but LatestVersion stays short of the block. That path is what makes a dropped write surface on this block rather than the next one, and it is the difference between a halted node and a silently missing receipt range.
  • [suggestion] persistReceipts verifies the landing only when the receipt store implements seidbtypes.PendingWriteWaiter; for any other store it assumes that SetReceipts returning means the receipts are readable. The pebble-backed receiptStore applies via ApplyChangesetAsync and does not implement the interface, so the PR's headline invariant (Commit returns only once the block's receipts are readable) would silently not hold there. Giga hardcodes littidx in DefaultGigaStorageConfig, so this is latent rather than live, but a WaitForPendingWrites on receiptStore delegating to s.db (which already implements it — see sei-db/ledger_db/receipt/receipt_store.go:337) would remove the silent-degradation mode.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-db/ledger_db/receipt/litt_receipt_store.go
…s WaitForPendingWrites; test a write that never lands

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

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

Moving receipt persistence and state-changeset encoding behind the block loop is implemented soundly: the pipeline goroutine is strictly serialized behind the previous block's done, refcounting on the pooled result and the landed/lastQueued markers in littReceiptStore are correctly ordered, and all three previous review notes are addressed. No blocking defects; two non-blocking notes about the new Commit wait landing inside the mempool-locked section and stale contract text in giga/evmonly/README.md.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] giga/evmonly/README.md now contradicts the executor's behaviour. Lines 92–94 ("Encoding, state commit, or receipt-store failures release the block result and return an error without invoking ResultSink") and line 99 ("ResultSink runs only after both stores succeed") were written for the serial path. State encoding and receipt persistence now run in the background goroutine started by startPipelineCommit, which ExecutePreparedBlock calls before sinkBlockResult — this PR's own executor_test.go change asserts the opposite of the README (require.Len(t, sink.results, 1) on a receipt-store failure, with the comment "the sink has already seen the result"). The surrounding sentences that are still accurate (receipts before the height-advancing state commit, a receipt failure leaving state unchanged) make the stale ones easy to trust.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.
  • 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] giga/evmonly/README.md:84 states "The snapshot stays open through the commit and is always closed afterward." The defer snapshot.Close() in executePreparedBlockWithStore runs when the function returns, which since the state commit was backgrounded is before CommitStateChanges completes. Already stale on the base branch, not introduced here.

Comment thread sei-tendermint/internal/evmonlyapp/app.go
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

README notes taken in c6d9b4e: giga/evmonly/README.md now describes the pipelined path (writes run behind the block in order, ResultSink runs once they're handed off, AwaitReceipts is the readable-receipts sync point, failures latch) and the snapshot sentence now says it closes when execution returns rather than after the commit. Mempool-lock note answered in its thread: evmonly's Commit is only reached from the giga router's execute loop, not BlockExecutor.Commit, so it isn't under the mempool lock.

@bdchatham
bdchatham added this pull request to the merge queue Sep 20, 2026
Merged via the queue into giga-1 with commit 5c37395 Sep 20, 2026
68 checks passed
@bdchatham
bdchatham deleted the devin/1789850613-receipts-in-pipeline branch September 20, 2026 12:58
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.

1 participant