Skip to content

move pebble checkpoints off of the execution goroutine - #4039

Open
cody-littley wants to merge 1 commit into
mainfrom
cjl/background-snapshots
Open

move pebble checkpoints off of the execution goroutine#4039
cody-littley wants to merge 1 commit into
mainfrom
cjl/background-snapshots

Conversation

@cody-littley

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Moves pebble checkpointing (what we call snapshots) off of the transaction execution goroutine

@cursor

cursor Bot commented Aug 27, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes core FlatKV commit, persistence, and shutdown paths; mis-handled async snapshots or reservation release could stall flushes, corrupt exports, or halt the node on writer failure.

Overview
FlatKV Pebble checkpoint snapshots no longer run synchronously inside Commit. A background SnapshotWriter takes blocks offered after each commit, decides cadence from SnapshotInterval, and writes checkpoints on its own goroutine while holding view reservations until each block is handled.

Backpressure and defaults: New MaxSnapshotLagBlocks (default 512) caps how many committed blocks may queue behind an in-flight snapshot before Offer blocks on commit. View-manager MaxUnflushedVersions defaults rise from 4 → 1024 so more in-memory versions can accumulate while snapshots lag. flatkv_snapshot_queue_depth metrics track queue buildup.

API and lifecycle: Public WriteSnapshot is removed from the FlatKV store interface; import, seed, and tests use internal outOfBandSnapshot() (must quiesce the writer first). Callers that need on-disk snapshots to catch up use FlushSnapshots(). Snapshot publish/checkpoint logic is refactored into shared helpers with concurrent multi-DB checkpoints.

Tests and tooling: Integration tests wait for async state-store application and snapshot publication; commitAndCheck and export/rollback tests call FlushSnapshots() to avoid races with snapshot pruning during directory copies.

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

@github-actions

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 27, 2026, 6:16 PM

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.27132% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.53%. Comparing base (bafdcf3) to head (96c22ca).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
sei-db/state_db/sc/flatkv/snapshot.go 72.58% 9 Missing and 8 partials ⚠️
sei-db/state_db/sc/flatkv/snapshot_writer.go 90.84% 9 Missing and 5 partials ⚠️
...-db/state_db/sc/flatkv/snapshot_writer_messages.go 66.66% 2 Missing and 1 partial ⚠️
sei-db/state_db/sc/flatkv/store_write.go 71.42% 1 Missing and 1 partial ⚠️
sei-db/state_db/sc/flatkv/importer.go 0.00% 0 Missing and 1 partial ⚠️
sei-db/state_db/sc/flatkv/store_meta.go 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4039      +/-   ##
==========================================
- Coverage   61.22%   60.53%   -0.70%     
==========================================
  Files        2153     2079      -74     
  Lines      188393   180394    -7999     
==========================================
- Hits       115351   109205    -6146     
+ Misses      62298    61056    -1242     
+ Partials    10744    10133     -611     
Flag Coverage Δ
sei-db 69.80% <ø> (ø)
sei-db-state-db ?
sei-db-state-db-pr 75.31% <85.27%> (?)

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

Files with missing lines Coverage Δ
sei-db/db_engine/view/view_manager_config.go 94.73% <ø> (-0.14%) ⬇️
sei-db/state_db/sc/flatkv/config/config.go 77.41% <100.00%> (+0.37%) ⬆️
...db/state_db/sc/flatkv/config/flatkv_test_config.go 100.00% <100.00%> (ø)
sei-db/state_db/sc/flatkv/metrics.go 95.65% <ø> (ø)
sei-db/state_db/sc/flatkv/store.go 78.95% <100.00%> (+0.04%) ⬆️
sei-db/state_db/sc/flatkv/store_apply.go 85.28% <ø> (ø)
sei-db/state_db/sc/flatkv/store_gc.go 84.69% <ø> (ø)
sei-db/state_db/sc/flatkv/importer.go 91.25% <0.00%> (ø)
sei-db/state_db/sc/flatkv/store_meta.go 79.64% <0.00%> (ø)
sei-db/state_db/sc/flatkv/store_write.go 76.56% <71.42%> (-0.46%) ⬇️
... and 3 more

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

@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 2 potential issues.

Fix All in Cursor

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

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 96c22ca. Configure here.

// reconciles whatever the caller's halt leaves behind.
if s.config.SnapshotInterval > 0 && version%int64(s.config.SnapshotInterval) == 0 {
s.phaseTimer.SetPhase("commit_write_snapshot")
if err := s.WriteSnapshot(""); 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.

Export races with async snapshot pruning

High Severity

Commit now returns after Offer, so a snapshot interval height is not on disk yet. Exporter still clones a snapshot directory without FlushSnapshots, while publishSnapshot can prune that directory mid-copy. State-sync at an interval height may replay a full WAL gap or copy a directory the writer is deleting.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 96c22ca. Configure here.

return
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Close may process queued snapshots

Medium Severity

After an in-flight checkpoint, run loops back into a select on ctx.Done() and messages. If both are ready, Go picks at random, so Close can write more queued snapshots instead of discarding them. Shutdown then waits on extra checkpoints that the contract says are dropped.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 96c22ca. Configure here.

@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 FlatKV pebble checkpointing onto a dedicated SnapshotWriter goroutine is well-structured: reservation ownership is handed off cleanly at every path (written/declined/failed/discarded), failures are latched and re-surfaced on the commit path, and teardown drains an in-flight checkpoint before the databases close. Two non-blocking issues: the new max-snapshot-lag-blocks key is declared configurable but never read, and the 256× increase in MaxUnflushedVersions raises an in-memory backlog that has no byte-based bound.

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.

// reach disk until it completes, and each one is retained in memory meanwhile. This bounds how far
// that can run, trading a pause in block production for the memory the backlog would otherwise
// consume. It bounds blocks rather than bytes, so it mitigates exhaustion rather than preventing it.
MaxSnapshotLagBlocks uint32 `mapstructure:"max-snapshot-lag-blocks"`

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] The mapstructure:"max-snapshot-lag-blocks" tag is inert: neither sei-cosmos/server/config.GetConfig (which explicitly reads five other state-commit.flatkv.* keys — fsync, async-write-buffer, snapshot-interval, snapshot-keep-recent, enable-read-write-metrics) nor app/parseSCConfigs reads this key, and the Viper path ignores unknown TOML keys. An operator who sets state-commit.flatkv.max-snapshot-lag-blocks in app.toml gets silence, and the queue stays pinned at 512.

This knob is the whole of the writer's backpressure and the one lever an operator has when a checkpoint outruns block production, so it is the flatkv field most worth wiring rather than least. Suggest adding the guarded read alongside the other four in GetConfig, and a row in sei-cosmos/server/config/config_fuzz_test.go ({Key: "state-commit.flatkv.max-snapshot-lag-blocks", Path: "StateCommit.FlatKVConfig.MaxSnapshotLagBlocks", Set: ...}) per testutil/configtest/AGENTS.md. If it is deliberately not operator-tunable for now, dropping the tag (as ExternalPruning does with mapstructure:"-") records that decision instead of implying one that does not hold.

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

MetricsEnabled: true,
MetricsScrapeIntervalSeconds: 10,
MaxUnflushedVersions: 4,
MaxUnflushedVersions: 1024,

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] Raising the default from 4 to 1024 applies to all four FlatKV view managers, and this is the only bound on that backlog: MaxSize above is documented as constraining "only the DB read-cache, not view data, since views cannot be freed without compromising consistency semantics". So the cap is a version count with no byte-based counterpart — 1024 block diffs of arbitrary size may now be resident per manager, where previously it was 4.

The increase is required by the design (with 4, Commit would stall inside view-manager backpressure for the whole checkpoint, defeating the point of the PR), and MaxSnapshotLagBlocks caps the snapshot-driven backlog at ~513. But the new value also governs the unrelated case this field was written for — Pebble being the bottleneck — where nothing bounds the accumulation at 512, and a slow-flush episode can now hold 256× as much in memory before backpressure engages.

Worth recording the reasoning here (why 1024 rather than something just above MaxSnapshotLagBlocks) and noting the relationship between the two knobs, since they are set in different packages and only stay consistent by hand.

MetricsEnabled: true,
MetricsScrapeIntervalSeconds: 10,
MaxUnflushedVersions: 4,
MaxUnflushedVersions: 1024,

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.

any OOM risk here to MaxUnflushedVersions to 1024?

// Step 5: Offer the block to the snapshot writer, which decides whether it becomes a snapshot and,
// if so, writes it on its own goroutine. Periodic snapshots are what keep the WAL bounded and
// restarts fast.
if s.snapshotWriter != 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.

Offer now reports the writer's latched failure, so the error names the current height, not the height whose snapshot failed. The halt is also one block late. Both are acceptable if nothing downstream reads that height for recovery. Please confirm, and consider naming the failed height in the latched error so the log points at the real block.

// cadence is outrunning.
w.phaseTimer.SetPhase("idle")
select {
case <-w.ctx.Done():

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.

select randomly chooses among ready cases, so shutdown competes with the queue instead of taking priority. Once stop() is called, w.ctx.Done() remains ready, causing races on both sides of the channel.

Here, a queued message may win, causing Close to wait for another snapshot and breaking its guarantee that queued work is discarded.

In enqueue at L166, a send may win if the queue has room, so Offer returns nil even though the writer may exit without processing it. Commit then reports success for a snapshot that may never be written.

Both sides need to be fixed: one prevents extra work during shutdown; the other prevents false success after shutdown.

FlatKVConfig.AccountStoreConfig.MetricsEnabled = bool(true)
FlatKVConfig.AccountStoreConfig.MetricsScrapeIntervalSeconds = float64(10)
FlatKVConfig.AccountStoreConfig.MaxUnflushedVersions = uint64(4)
FlatKVConfig.AccountStoreConfig.MaxUnflushedVersions = uint64(1024)

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 seems to be a pretty big jump? Why do we need that big of backlog?

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.

3 participants