Skip to content

Make Receipt and SS write fully async - #4159

Merged
yzang2019 merged 13 commits into
mainfrom
yzang/receipt-write-perf-improve
Sep 15, 2026
Merged

yzang2019 merged 13 commits into
mainfrom
yzang/receipt-write-perf-improve

Conversation

@yzang2019

@yzang2019 yzang2019 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Both the receipt write and the EVM state store (SS) write were doing slow synchronous work on the block commit path. This makes both of them fully async.

Receipt store

SetReceipts used to write the receipt bodies, the eth_getLogs index and the version marker inline, and the index commit alone was ~74% of the call. It now hands the block to a background writer and returns.

Measured at 2,000 receipts per block, the commit path went from 4.0 ms to 11 µs. The work still costs the same; it just happens on the writer, where it overlaps with execution instead of serializing against it.

  • receipt-store.async-write-buffer (default 100) bounds how many blocks the store may fall behind. A full queue blocks the caller — that is the back-pressure.
  • Setting it to <= 0 keeps writes synchronous, which is the escape hatch if strict read-after-write is wanted.
  • LatestVersion() only advances once a write has actually been applied, so it never advertises a receipt that is not yet readable. It is the watermark a reader follows.

EVM state store

enqueue_ss looked async but was dominated by a synchronous changelog WAL write sitting in front of the queue. That is also why its queue depth always read 0: queue depth only reveals a slow consumer, and here the producer was the slow side.

Under giga that changelog is written every block and never read — crash recovery replays giga's own state WAL via catchUpTo, and rollback rewinds SS from its snapshots against that same WAL. So giga now opens SS with DisableInternalWAL and the commit-path write is gone.

The composite (non-giga) path is untouched and keeps its changelog, which it does need: ss/composite rollback replays it to reach versions above a snapshot.

Interface cleanup

SetLatestVersion / SetEarliestVersion are no longer on the ReceiptStore interface. No production code called them — the write path carries the markers, and every external caller was test or benchmark scaffolding. cryptosim's redundant SetLatestVersion after each block is deleted for the same reason.

Bug fixed along the way

Draining the pebble async writer on close was nested inside the changelog check:

if db.streamHandler != nil {
    close(db.pendingChanges)
    db.asyncWriteWG.Wait()
    ...
}

With the changelog off, that drain would never run, silently dropping queued blocks on every clean shutdown. The drain is now unconditional, behind a sync.Once so Close stays idempotent.

Dashboard

receipt_write_queue_depth now covers the whole receipt write. The old "ReceiptDB Queue Depth" panel tracked only litt's table queue, which is ~7% of the call, which is why it read 0 while write_receipts was a large share of the execution loop.

Testing performed to validate your change

  • sei-db/ledger_db/..., sei-db/state_db/..., sei-db/bootstrap, sei-db/config, sei-db/db_engine/pebbledb/..., giga/evmonly/..., evmrpc/... and x/evm/keeper all pass.
  • The receipt package passes three repeats under -race.
  • make dblint reports 0 issues; go vet ./... is clean.

New tests:

  • TestLittIdxSynchronousWriteBuffer — with the buffer off, a block is queryable the moment SetReceipts returns.
  • TestLittIdxWriteBufferBoundsLag — the buffer is the back-pressure point; the store cannot trail further than it allows.
  • TestOpenSSKeepsNoChangelogOfItsOwn — pins the absence of the SS changelog under giga rather than trusting the config. Verified non-vacuous by re-enabling the flag and watching it fail.

Tests that previously relied on read-after-write now wait on LatestVersion instead. Worth noting for reviewers: that watermark is necessary but not sufficient as a "my write landed" signal — the bodies land just before the version marker commits, and a block written in parts advances the marker on its first part. The littidx helper waits on both.

@cursor

cursor Bot commented Sep 13, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes block-commit latency and read-after-write semantics for receipts, and alter how giga’s EVM SS persists commits (no internal changelog), though bounded by queue back-pressure, LatestVersion, and extensive tests.

Overview
Receipt commits no longer block on LittDB bodies, the log index, or version markers. SetReceipts enqueues a whole-block write when receipt-store.async-write-buffer is positive (default lowered from 100 to 10); the writer applies work in the background, exposes receipt_write queue depth / blocked time, and LatestVersion advances only after apply. Version markers move off the public ReceiptStore API to receipt.PinVersions / VersionPinner for tests and simulators that seed receipts without going through the write path.

Giga’s EVM state store opens with DisableInternalWAL, dropping the pebble changelog write that sat in front of enqueue_ss while recovery still replays giga’s state WAL via catchUpTo. Pebble Close always drains pendingChanges (even without a changelog), fixing dropped queued blocks on shutdown when the internal WAL is off.

Gigasim pre-marshals ReceiptRecords during block generation (removes gigasim_receipt_write phase metrics) and uses a cheaper deterministic bloom for synthetic logs. Grafana retitles the receipt queue panel around receipt_write_queue_depth, drops the old encode-vs-store pie, and adds SS commit-queue blocked-time alongside receipt-store write breakdown panels.

Tests and RPC harnesses PinVersions / wait on LatestVersion instead of calling SetLatestVersion directly; new coverage for async back-pressure, write failures, and giga SS opening without a changelog.

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

@github-actions

github-actions Bot commented Sep 13, 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 15, 2026, 1:46 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.

Stale Bugbot comment from a previous run.

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

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.15068% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.39%. Comparing base (f0cd46c) to head (58eed66).

Files with missing lines Patch % Lines
sei-db/ledger_db/receipt/receipt_store.go 66.66% 2 Missing ⚠️
sei-db/state_db/giga/state_db.go 50.00% 2 Missing ⚠️
sei-db/db_engine/pebbledb/mvcc/db.go 91.66% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4159      +/-   ##
==========================================
- Coverage   66.70%   65.39%   -1.31%     
==========================================
  Files        2204     2081     -123     
  Lines      169840   157648   -12192     
==========================================
- Hits       113285   103093   -10192     
+ Misses      56414    54414    -2000     
  Partials      141      141              
Flag Coverage Δ
sei-chain-pr 77.92% <95.65%> (?)
sei-db 74.50% <ø> (-0.25%) ⬇️
sei-db-state-db ?
sei-db-state-db-pr 30.27% <50.00%> (?)

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

Files with missing lines Coverage Δ
evmrpc/tests/utils.go 79.62% <100.00%> (-0.25%) ⬇️
sei-db/config/giga_config.go 81.96% <100.00%> (+0.30%) ⬆️
sei-db/config/receipt_config.go 85.71% <100.00%> (ø)
sei-db/config/ss_config.go 100.00% <ø> (ø)
sei-db/ledger_db/receipt/litt_receipt_store.go 86.34% <100.00%> (+2.88%) ⬆️
sei-db/db_engine/pebbledb/mvcc/db.go 77.71% <91.66%> (+0.06%) ⬆️
sei-db/ledger_db/receipt/receipt_store.go 72.88% <66.66%> (-0.22%) ⬇️
sei-db/state_db/giga/state_db.go 25.95% <50.00%> (+6.57%) ⬆️

... and 196 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[bot]
seidroid Bot previously requested changes Sep 13, 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.

Moves the littidx receipt write onto a background writer goroutine bounded by AsyncWriteBuffer, with LatestVersion as the publish watermark and the version markers folded into SetReceipts. The mechanism is clean, but a failed queued write still lets the head advance past the missing block, and the new crash-loss window interacts with startup recovery in a way the diff does not address.

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

Blockers

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

Non-blocking

  • [suggestion] No test covers the async writer's failure path — that a queued write's error reaches a later SetReceipts caller (and Close), and what the head does afterwards. The two new tests (TestLittIdxSynchronousWriteBuffer, TestLittIdxWriteBufferBoundsLag) only exercise the happy path. Given the head-advance issue above, this is the case most worth pinning.
  • [suggestion] Narrowing ReceiptStore by dropping SetLatestVersion/SetEarliestVersion traded a compile-time check for a runtime one, and the same anonymous-interface assertion is now copied into four places (evmrpc/setup_test.go, evmrpc/tests/utils.go, sei-db/ledger_db/receipt/receipt_store_test.go, sei-db/bootstrap/recovery_test.go), two of them panicking rather than failing. evmrpc/setup_test.go already has the right shape in pinReceiptVersions; consider exporting one such helper (e.g. from the receipt package as a test-only export) and having the other three call it, rather than three hand-rolled copies.
  • [suggestion] evmrpc/watermark_manager.go:76 mins the RPC latest against receiptStore.LatestVersion(), so with the write now queued the advertised chain head trails the real one by the queue depth under write pressure instead of the write back-pressuring the block pipeline. That looks like the intent of the PR, but it is a user-visible change to eth_blockNumber/latest freshness that neither the PR description nor the config doc mentions; worth stating explicitly somewhere an operator will read it.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-db/ledger_db/receipt/litt_receipt_store.go
Comment thread sei-db/ledger_db/receipt/litt_receipt_store.go Outdated
Comment thread sei-db/ledger_db/receipt/litt_receipt_store.go Outdated
Comment thread sei-db/ledger_db/receipt/litt_receipt_store.go Outdated
@yzang2019 yzang2019 changed the title Make receipt write fully async Make Receipt and SS write fully async Sep 13, 2026
@yzang2019

Copy link
Copy Markdown
Contributor Author

@seidroid review

seidroid[bot]
seidroid Bot previously requested changes Sep 13, 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.

The async receipt writer is in good shape — the failure latch, the closed-store refusal and the new litt_write_failure_internal_test.go cases address every inline finding from the previous review. What blocks is an unrelated line riding along: DefaultGigaStorageConfig now flips SeparateEVMSubDBs to true, changing giga's on-disk EVM state-store layout with no migration, no detection, and no mention in the PR description.

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

Blockers

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

Non-blocking

  • [suggestion] sei-db/config/toml.go:204-208 still documents the receipt store's async-write-buffer as "Applies only when rs-backend = "pebbledb"" and "defaults to 100". This PR makes the setting the back-pressure and crash-loss bound for littidx too, and lowers the default to 10, so the template an operator actually reads is now wrong on both counts. Worth carrying over the recovery-cost and RPC-head-staleness wording that the Go doc in receipt_config.go gained — especially since an existing app.toml rendered from the old template still carries a literal 100, so the lowered default only reaches fresh configs.
  • 3 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] sei-db/db_engine/pebbledb/mvcc/db.go — when wal.NewChangelogWAL fails, OpenDB returns the error without closing the Pebble handle it already opened (every other error path in the function does _ = db.Close() first). Leaks the open database on a rare startup failure. Pre-existing; the PR only moved the block inside the !DisableInternalWAL guard.

Comment thread sei-db/config/giga_config.go Outdated
Comment thread sei-db/ledger_db/receipt/litt_receipt_store.go Outdated
Comment thread sei-db/config/ss_config.go Outdated
Comment thread sei-db/state_db/giga/state_db.go Outdated
@yzang2019

Copy link
Copy Markdown
Contributor Author

@seidroid review

seidroid[bot]
seidroid Bot previously requested changes Sep 13, 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.

The async receipt writer is now in good shape — the admission lock, the failure latch and the new race/close tests close every unresolved finding from the previous reviews, and PinVersions/VersionPinner cleans up the interface narrowing. What blocks is an unrelated set of Pebble tuning constants riding along in mvcc/db.go (4× memtable memory per DB, 4× compaction concurrency) with no mention in the PR description and a stale // 64 MB comment proving the edit was incidental.

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

Blockers

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

Non-blocking

  • [suggestion] sei-db/config/toml.go:204-208 still tells operators that receipt-store.async-write-buffer "Applies only when rs-backend = "pebbledb"" and "defaults to 100". This PR makes the setting the back-pressure, crash-loss and RPC-head-staleness bound for littidx too, and lowers the default to 10, so the template an operator actually reads is wrong on both counts. sei-db/config/toml_test.go:141 actively pins the stale sentence (require.Contains(t, output, "Applies only when rs-backend = \"pebbledb\"")), so the template and that assertion have to move together. Worth carrying over the recovery-cost and watermark wording the Go doc in receipt_config.go just gained. This was raised in the previous review and is still unaddressed.
  • 3 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] sei-db/db_engine/pebbledb/mvcc/db.go — when wal.NewChangelogWAL fails, OpenDB returns the error without closing the Pebble handle it already opened (every other error path in the function does _ = db.Close() first), leaking the open database on a rare startup failure. Pre-existing; this PR only moved the block inside the new !DisableInternalWAL guard.

Comment thread sei-db/db_engine/pebbledb/mvcc/db.go Outdated
Comment thread sei-db/state_db/giga/state_db.go Outdated
Comment thread sei-db/ledger_db/receipt/littidx_test.go
Comment thread sei-db/db_engine/pebbledb/mvcc/db.go
@yzang2019

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.

The async receipt writer and the giga SS changelog removal are now in good shape: the unrelated Pebble tuning constants that blocked the last revision are gone, the writeLitBlock barrier now waits on the log index rather than just the body, and stateStoreConfigFor puts the changelog guard at the single point both the open and rollback paths pass through. What remains are two non-blocking carry-overs — the orphaned SS changelog/ directory on a giga upgrade, and the receipt async-write-buffer documentation in toml.go that is now wrong on both its backend scope and its default.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] sei-db/config/toml.go:204-208 still tells operators that receipt-store.async-write-buffer "Applies only when rs-backend = "pebbledb"" and "defaults to 100". This PR makes the setting the back-pressure, crash-loss and RPC-head-staleness bound for littidx too, and lowers the default to 10, so the template an operator actually reads is wrong on both counts. sei-db/config/toml_test.go:141 pins the stale sentence, so the template and that assertion have to move together. Worth carrying over the recovery-cost and watermark wording the Go doc in receipt_config.go gained. (The PR description also still says "default 100".) Raised in the two previous reviews and still unaddressed; also raised by Codex.
  • [suggestion] The gigasim bloom change (sei-db/bench/gigasim/receipt.go) — replacing keccak-derived bloom bits with an FNV-1a mix, plus the receipt-field cache — is unrelated to making the receipt and SS writes async and is not mentioned in the PR description or its testing section. It is confined to the benchmark harness so nothing in production is affected, but it does change what the benchmark stores: the receipts' LogsBloom bytes are no longer what a real log bloom would produce, which is a fidelity trade the description should state alongside the generator speedup it buys.
  • 2 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] sei-db/db_engine/pebbledb/mvcc/db.go:261-262 — when wal.NewChangelogWAL fails, OpenDB returns the error without closing the Pebble handle it already opened (every other error path in the function does _ = db.Close() first), leaking the open database on a rare startup failure. Pre-existing; this PR only moved the block inside the new !DisableInternalWAL guard.

return nil, err
// An owner that logs every block replays it into this store, leaving the changelog here written
// and never read.
if !config.DisableInternalWAL {

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] For a giga node upgrading onto this build, the changelog/ directory already written under the EVM SS directory becomes orphaned: with DisableInternalWAL set, streamHandler stays nil, so PruneWALBeforeVersion (line 317) returns immediately and nothing else touches the directory. Per the comment just below, that changelog is sized at "roughly one snapshot interval of changelog per retained snapshot", so the disk it holds is never reclaimed and never re-read. Note the rollback path does not clean it either — RewindClosedStoreTo/ResetClosedStore only move snapshot files.

Worth either deleting the directory once when the flag is set, or noting in the release notes that operators should remove it by hand after the upgrade.

(Still open from the previous review; also raised by Codex.)

keccakSet += countBloomBits(keccak)
}
// Three bits per value either way, less whatever collides; the collision rates have to agree.
require.InDelta(t, keccakSet, mixedSet, float64(keccakSet)*0.01,

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 assertion cannot fail for any bit-picking function, so it does not pin the property its name claims. Each iteration sets three bits into a fresh ethtypes.Bloom, so countBloomBits returns 3 unless two of the three picks collide — for both sides. Over 2048 values that is ~6144 bits each way, with a per-value collision probability around 0.3%, and the tolerance is 1% of the total. A bloomBitsFor that returned, say, bits[i] = uint(i) for every input would pass.

To actually compare density (and therefore how the corpus compresses, which is what the comment says is at stake), accumulate every value into one shared mixed bloom and one shared keccak bloom and compare the saturation of those two — that is where a clustered bit-picker diverges from keccak.

@seidroid
seidroid Bot dismissed stale reviews from themself September 13, 2026 23:51

Superseded: latest AI review found no blocking issues.

@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 58eed66. Configure here.

return ErrStoreClosed
}
seidbmetrics.Send(s.writeQueue, s.writes, write)
return 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.

Async receipt write aliases caller data

Medium Severity

SetReceipts now queues the caller’s receipts slice and returns, but the writer later reads those ReceiptRecords (including Receipt pointers and ReceiptBytes) without taking its own copy. Callers that reuse, pool, or Release the source after a successful SetReceipts can have the background write persist mutated or recycled data. The giga commit path encodes records from result, queues them, then returns result to the caller while the store may still be several blocks behind.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 58eed66. Configure here.

@yzang2019
yzang2019 added this pull request to the merge queue Sep 15, 2026
@yzang2019
yzang2019 removed this pull request from the merge queue due to a manual request Sep 15, 2026
@yzang2019 yzang2019 added the backport giga-1 Backport to experimental Giga 1 branch label Sep 15, 2026
@yzang2019
yzang2019 enabled auto-merge September 15, 2026 13:45
@yzang2019
yzang2019 added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit c45517d Sep 15, 2026
80 checks passed
@yzang2019
yzang2019 deleted the yzang/receipt-write-perf-improve branch September 15, 2026 14:15
@seidroid

seidroid Bot commented Sep 15, 2026

Copy link
Copy Markdown

Successfully created backport PR for giga-1:

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

Labels

backport giga-1 Backport to experimental Giga 1 branch non-app-hash-breaking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants