Skip to content

evmonly: start the receipt write as soon as execution returns; the state commit waits for it - #4266

Merged
bdchatham merged 4 commits into
giga-1from
devin/1789914633-receipts-early
Sep 20, 2026
Merged

bdchatham merged 4 commits into
giga-1from
devin/1789914633-receipts-early

Conversation

@bdchatham

@bdchatham bdchatham commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Since #4264 the receipt encode+write for block N is kicked off at the very end of ExecutePreparedBlock (start_commit), and Commit then waits for it to land before advancing the RPC-visible head. On testnet-2 that wait shows up as app_commit ≈ 0.16 s/s: the litt writer's ~3.5 ms/block used to trail off-loop and now sits on the critical path, because nothing overlaps it.

This PR starts the receipt write the moment execution returns, so it runs under the rest of the block's tail (block-changeset encoder, await_commit for block N-1, start_commit) and the router's tail (vault commit, PushAppHash) instead of after them. The head-advance invariant is unchanged: Commit still calls AwaitReceipts and the state commit still waits for the block's receipts before committing, so the store never holds a block whose receipts cannot be read.

ExecutePreparedBlock(N):
  execute
  receipts := startReceiptWrite(N)          // new: was inside startPipelineCommit
  [await_commit(N-1) | encode_block_changesets]   // receipt write overlaps these
  startPipelineCommit(N, receipts):          // goroutine: <-receipts.done; commitStateChanges

startReceiptWrite:

  • runs one goroutine per block that first waits for the previous block's receipt write, so receipts still land in block order and the store's LatestVersion keeps meaning "every block up to here"; a failed previous write fails this one too (the pipeline is already latched at that point).
  • records itself as pipelineReceipts immediately, so AwaitReceipts finds it even if the block later fails on the loop (block encoder error, ctx cancel). Close now also waits on it, since such a block has no commit to drain. Receipts for a block that failed after execution can therefore be persisted ahead of state — same class as the existing "state failure leaves receipts behind" case, healed by re-execution on restart; README updated.
  • startPipelineCommit no longer takes a ctx or retains the result; the receipt goroutine owns the retention.

Metrics: receipt phases move to their own PhaseTimer, built from the same evmonly_pipeline factory with a stage="receipts" label (the state commit's timer gets stage="state"): the receipt write now overlaps block N-1's commit_state, so one timer is not goroutine-safe (the race detector caught this on the first cut) and the two record overlapping intervals — filter on stage for a share-of-clock panel. Phase names: encode_receipts/write_receipts unchanged; the litt-landing wait is now await_store; the old await_receipts is retired; await_receipt_write (stage="state") is the state-commit goroutine's wait for the receipt write (i.e. the part of the receipt write that did not overlap). New start_receipts block phase (handoff only).

Independent of #4265 (receipt store optional); the two touch startPipelineCommit so whichever lands second gets a small rebase.

Expected effect: recovers the part of the 0.16 s/s app_commit wait that the ~2 ms loop+router tail can hide; RPC nodes benefit regardless of whether validators turn receipts off.

Testing performed to validate your change

  • New TestReceiptWriteStartsBeforeThePreviousCommitLands; TestCloseWaitsForTheReceiptsOfABlockThatFailedAfterHandingThemOff pins that Close drains the receipt write of a block that failed on the loop after the handoff (no state commit exists to drain it through): with a store-reading block encoder (loop must wait for N-1's commit) and N-1's commit held, block N's receipts are written while N's ExecutePreparedBlock is still blocked; N returns only after release; commits land in order.
  • Existing pipeline tests unchanged and passing: receipt-failure fails the block before state commit, dropped-write detection, AwaitReceipts before commit lands, encoder sees own changes, ReadLatestAccount retiring cases.
  • go test -race -count=5 ./giga/evmonly/, go test -race ./giga/evmonly/... ./sei-tendermint/internal/evmonlyapp/... green.
  • make fmtcheck, golangci-lint run ./giga/evmonly/... (0 issues), go build ./....

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

…ate commit waits for it

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 20, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes ordering and lifecycle of persistence on the block hot path; invariants are preserved but mistakes could affect RPC head visibility or leave receipt/state gaps on failure.

Overview
Receipt persistence is kicked off right after block execution, instead of at the end of the commit pipeline, so encode/write can run in parallel with the block encoder, waiting on the previous state commit, and upstream router work. State commits still block until that block’s receipts are readable, preserving the invariant that committed state never advances without matching receipts.

The refactor splits startReceiptWrite (ordered background goroutines, retains the block result until receipts land) from startPipelineCommit (waits on receipts.done before commitStateChanges). Close now drains in-flight receipt writes when a block failed after handoff with no state commit. OTel pipeline metrics use separate PhaseTimers with stage=receipts vs stage=state because the two paths overlap. README and tests cover overlap timing, failure latching, and close behavior.

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

@github-actions

github-actions Bot commented Sep 20, 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, 3:12 PM

@codecov

codecov Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.59%. Comparing base (7e3ca24) to head (1644b9b).
⚠️ Report is 45 commits behind head on giga-1.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           giga-1    #4266       +/-   ##
===========================================
+ Coverage   65.55%   91.59%   +26.03%     
===========================================
  Files        2081       20     -2061     
  Lines      157460     3081   -154379     
===========================================
- Hits       103222     2822   -100400     
+ Misses      54097      259    -53838     
+ Partials      141        0      -141     
Flag Coverage Δ
sei-chain ?
sei-chain-pr 91.59% <100.00%> (?)
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 92.56% <100.00%> (+1.35%) ⬆️
giga/evmonly/giga_store.go 93.36% <100.00%> (+0.25%) ⬆️

... and 2068 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 the receipt write off the block tail is correctly ordered: writes chain on the previous block's done, startPipelineCommit still gates the state commit on receipts.done, and the phase-timer handoff (Reset before close(done)) keeps the new receiptPhases single-threaded. Two non-blocking notes: the new timer reuses the evmonly_pipeline metric name while now recording concurrently with it, and the new failure path this PR introduces (block fails after the receipt handoff) has no test.

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

Blockers

  • None at the file/PR level.

Non-blocking

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

Comment thread giga/evmonly/executor.go Outdated
Comment thread giga/evmonly/executor.go
…lose draining a failed block's receipt write

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.

Hoisting the receipt write ahead of the block tail is correctly ordered: receipts stay in block order via the previous.done chain, the state commit still gates on receipts.done, the phase timer handoff (Reset before close(done)) keeps receiptPhases single-threaded, and Close now drains a write with no commit behind it before occPool.Close(). Three non-blocking notes: an untested new chained-failure branch, a godoc that understates how long the pooled result is retained, and an existing evmonly_pipeline phase name whose meaning changes silently.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] The evmonly_pipeline phase name await_receipts is reused with a different meaning: before this PR it was the litt-landing wait inside persistReceipts (now await_store, under stage="receipts"), and it now names the state-commit goroutine's wait for the receipt write (stage="state"). Any existing panel keyed on phase="await_receipts" keeps rendering but silently plots a different quantity, since the old series has no stage label to distinguish it from the new one. The PR body documents the change, but a distinct name (e.g. await_receipt_write) would make the break visible as a gap in the panel instead of a shifted line.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread giga/evmonly/giga_store.go
Comment thread giga/evmonly/giga_store.go Outdated
…e; test the write after a failed one; state the result's retention window

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

Copy link
Copy Markdown
Contributor

On the phase-name note: renamed the state commit's wait to await_receipt_write (5f7e57f), so the old await_receipts series ends at the roll instead of shifting meaning.

…ner)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@bdchatham
bdchatham enabled auto-merge September 20, 2026 15:13
@bdchatham
bdchatham added this pull request to the merge queue Sep 20, 2026
Merged via the queue into giga-1 with commit 187cd78 Sep 20, 2026
62 checks passed
@bdchatham
bdchatham deleted the devin/1789914633-receipts-early branch September 20, 2026 15:35
devin-ai-integration Bot added a commit that referenced this pull request Sep 20, 2026
… skips the receipt write

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