Skip to content

move flatKV hashing to a background thread - #4085

Open
cody-littley wants to merge 5 commits into
mainfrom
cjl/flatkv-hash-refactor-2
Open

move flatKV hashing to a background thread#4085
cody-littley wants to merge 5 commits into
mainfrom
cjl/flatkv-hash-refactor-2

Conversation

@cody-littley

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Move flatKV hashing to background threads

@cursor

cursor Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes consensus-critical AppHash assembly (async flatKV hashes, mid-block commits, channel backpressure) and hash-log wiring; regressions could stall commits or produce wrong lattice roots.

Overview
FlatKV lattice hashing moves off the commit path into a background pipeline: a hash engine gathers sealed block diffs, computes LtHashes in parallel, and a FinalizationManager writes per-DB metadata and publishes ordered block hashes on a channel. LiveStateStore drops synchronous RootHash / RecordHashes in favor of PublishedHash, HashChan, FlushHashes, and CommitPendingBlock.

Cosmos still needs synchronous AppHash inputs, so the composite store adds flatKVHashCache (latticeHash / mustLatticeHash) to commit pending blocks, drain the hash stream, and serve lattice roots for working and last commit info. Rollback clears the cache.

Hash logging is opened in rootmulti before NewCompositeCommitStore, passed into NewCommitStore(..., hashLogger), and flatKV registers categories and reports hashes from the finalization goroutine (memIAVL-only reporting remains on the commit path). openHashLogger is refactored to a standalone helper; lazy open on first recordBlockHashes is removed.

The lthash package is reworked around gather/combine workers (replacing HashCalculator / api.go); flatKV config gains HashEngineConfig, FinalizationQueueSize, and HashChanSize. Call sites and tests pass nil for the logger where hashing is not exercised and assert roots via PublishedHash / FlushHashes.

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

Comment thread sei-db/state_db/sc/flatkv/store.go
@github-actions

github-actions Bot commented Sep 2, 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 2, 2026, 8:24 PM

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.27869% with 212 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.88%. Comparing base (7273a43) to head (9608f1e).

Files with missing lines Patch % Lines
sei-db/state_db/sc/flatkv/finalization_manager.go 75.17% 25 Missing and 10 partials ⚠️
sei-db/state_db/sc/flatkv/store.go 78.76% 12 Missing and 12 partials ⚠️
sei-db/state_db/sc/composite/flatkv_hash.go 66.66% 10 Missing and 9 partials ⚠️
sei-db/state_db/sc/flatkv/store_write.go 54.76% 7 Missing and 12 partials ⚠️
sei-db/state_db/sc/flatkv/lthash/hash_engine.go 72.88% 8 Missing and 8 partials ⚠️
sei-db/state_db/sc/flatkv/lthash/block_gatherer.go 85.39% 8 Missing and 5 partials ⚠️
sei-db/state_db/sc/flatkv/hashlog.go 50.00% 8 Missing and 4 partials ⚠️
sei-db/state_db/sc/flatkv/state_view.go 33.33% 9 Missing and 1 partial ⚠️
...db/state_db/sc/flatkv/lthash/hash_engine_config.go 50.00% 4 Missing and 4 partials ⚠️
.../state_db/sc/flatkv/lthash/hash_engine_messages.go 50.00% 5 Missing and 3 partials ⚠️
... and 14 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4085      +/-   ##
==========================================
- Coverage   61.30%   60.88%   -0.43%     
==========================================
  Files        2178     2132      -46     
  Lines      190788   185151    -5637     
==========================================
- Hits       116968   112731    -4237     
+ Misses      62796    61837     -959     
+ Partials    11024    10583     -441     
Flag Coverage Δ
sei-chain-pr 40.46% <74.28%> (?)
sei-db 69.80% <ø> (ø)
sei-db-state-db ?
sei-db-state-db-pr 77.47% <78.42%> (?)

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

Files with missing lines Coverage Δ
sei-db/bootstrap/storage_manager.go 68.62% <100.00%> (ø)
sei-db/state_db/sc/flatkv/config/config.go 78.46% <100.00%> (+1.04%) ⬆️
...db/state_db/sc/flatkv/config/flatkv_test_config.go 100.00% <100.00%> (ø)
sei-db/state_db/sc/flatkv/finalization_messages.go 100.00% <100.00%> (ø)
sei-db/state_db/sc/flatkv/importer.go 91.48% <100.00%> (+1.32%) ⬆️
sei-db/state_db/sc/flatkv/ktype/meta.go 100.00% <ø> (ø)
sei-db/state_db/sc/flatkv/lthash/stats.go 100.00% <ø> (ø)
sei-db/state_db/sc/flatkv/store_lifecycle.go 68.18% <100.00%> (-0.48%) ⬇️
sei-db/state_db/sc/flatkv/store_read.go 62.31% <100.00%> (ø)
sei-db/state_db/sc/flatkv/store_replay.go 73.07% <ø> (-0.26%) ⬇️
... and 29 more

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

Reviewed by Cursor Bugbot for commit 9608f1e. Configure here.

if published.BlockNumber == height {
checksum := published.Global.Checksum()
return checksum[:], 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.

Published hash race strands stream

Medium Severity

awaitHeight may answer from PublishedHash without taking that block off HashChan. The finalizer stores latest and reports to the logger before publish, so a concurrent read can observe the new height while the stream entry is still unsent. The comments already note that leaving a published hash queued strands it for good.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9608f1e. Configure here.

Value: value,
LastValue: old[string(key)],
Delete: value == 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.

Diff nil treated as deletion

High Severity

The gatherer sets Delete from value == nil when converting a view diff into KeyMutation. FlatKV must not treat nil and empty []byte as the same: empty writes are real values, and protobuf already collapses empty to nil. Inferring deletion from a nil value can MixOut a live key and diverge LtHash / AppHash.

Fix in Cursor Fix in Web

Triggered by learned rule: sei-db flatkv: changeset deletion is Delete flag, not nil Value — protobuf erases empty vs nil

Reviewed by Cursor Bugbot for commit 9608f1e. 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 hashing off the commit thread is a well-structured refactor (pipelined gather/hash/combine engine, a finalization manager that writes each block's metadata into the same batch as its data, and a new golden-hash regression test), but the new hash stream applies backpressure to Commit while several live paths never consume it — most notably a paused EVM migration, which is the default post-upgrade state, and writable WAL replay. Both stall commit permanently.

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

Blockers

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

Non-blocking

  • [suggestion] FinalizationManager (357 lines) and flatKVHashCache are both new, concurrency-heavy and consensus-critical, and neither has a dedicated unit test (lthash.HashEngine does, in hash_engine_test.go). Worth direct tests for: the finalizer's discard/abandon paths, the hash.BlockNumber != pending.blockNumber out-of-step guard, the latched-failure behaviour, and the cache's clamp-to-committed / "no longer available" / stream-drain-before-published-hash ordering.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.


if cs.shouldAppendLatticeHash() {
return cs.appendEvmLatticeHash(ci, cs.flatKVWorkingHash(version))
return cs.appendEvmLatticeHash(ci, cs.mustLatticeHash(version))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] flatKV's hash stream is only drained through mustLatticeHashflatKVHashCache, and both call sites (here and refreshLastCommitInfo) are gated on shouldAppendLatticeHash(). Commit however calls cs.flatKV.Commit(version) unconditionally whenever flatKV != nil, so in MigrateEVM with the migration not yet started the store commits a block — and publishes a hash — every block with nothing reading it.

That state is the default, not a corner case: app/migration/params.go sets DefaultNumKeysToMigratePerBlock = 0 ("leaves the migration paused"), and with migrationBatchSize == 0 MigrationManager.ApplyChangeSets skips the boundary advance entirely (advanceMigration := firstBatchInBlock && m.migrationBatchSize > 0), so neither MigrationBoundaryKey nor MigrationVersionKey is ever written and migrationStarted stays false until governance raises the param.

With the defaults (HashChanSize: 1024, FinalizationQueueSize: 64) the chain halts after ~1088 blocks in that state: publishedHashChan fills, FinalizationManager.run blocks in publish, messageChan fills, and Offer — reached from sealBlock inside flatkv.Commit — blocks forever.

The drain needs to be unconditional for a committing flatKV rather than tied to the AppHash-participation gate.

s.hashLogger,
)

if s.readOnly {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] The self-drain is conditioned on s.readOnly, but a writable store also hashes blocks with no consumer attached: openToreplayIntoMutableStorecatchUpFromWALreplayBlocksapplyAndCommitsealBlock, which offers every replayed block to the finalizer. The composite store's flatKVHashCache is not created until the first hash question, which is long after LoadVersion returns, so nothing reads publishedHashChan during replay. Past HashChanSize + FinalizationQueueSize (1024 + 64 by default) blocks, Offer blocks forever and replay never returns — before it can reach the FlushHashes in requireAlignedDataDBs.

Replays that long are reachable: Rollback reopens at a snapshot base and replays forward to the target (up to SnapshotInterval, default 10000), as do rebuildIfAnyDataDBIsUnreachablerebuildWorkingCopy and openTo(version) for an older height. So seid rollback and post-corruption repair can hang instead of failing.

The same gap hits every writable standalone user, since HashChan is now handed out but nobody takes it: sei-db/state_db/bench/wrappers/db_implementations.go, the seidb operations helpers, giga's test StateDB, and the flatkv test helpers themselves (commitAndCheck/rootHash call FlushHashes but never dequeue), so any of them committing >1024 blocks deadlocks.

Draining whenever no external consumer has claimed the stream (rather than only for readOnly) would cover replay and every standalone caller in one place.

case message := <-g.scheduledBlockChan:
request, ok := message.(*hashRequest)
if !ok {
continue

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] teardown skips every non-hashRequest message with continue, which silently swallows a queued flushRequest without closing its doneChan. A HashEngine.Flush() whose request lands on scheduledBlockChan just as the engine's context is cancelled then blocks forever on <-request.doneChan, since nothing else ever closes it — and FlushHashes is reachable from verifyLtHashInternal, outOfBandSnapshot and reloadLocalMeta.

FinalizationManager.abandonMessage already gets this right (case *finalizationFlushRequest: close(request.doneChan)); mirroring that here would make the two teardown paths consistent.


// Controls the number of workers in the dedicated lattice-hash pool used to
// compute per-module LtHashes during ApplyChangeSets. The worker count is
// HashEngineConfig configures the pipeline that hashes each committed block.

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] HashEngineConfig was inserted into the middle of LtHashThreadsPerCore's existing two-part comment, so the godoc for both fields is now garbled: HashEngineConfig reads "Controls the number of workers in the dedicated lattice-hash pool used to compute per-module LtHashes during ApplyChangeSets. The worker count is / HashEngineConfig configures the pipeline...", and LtHashThreadsPerCore is left starting mid-sentence ("LtHashThreadsPerCore * runtime.NumCPU() (clamped to at least 1)..."). Dropping the two orphaned lines above and restoring a leading sentence on LtHashThreadsPerCore fixes both.

//
// A read-only store gets them too: it replays blocks to reach its target height, and each replayed block
// is hashed against the one before it exactly as a committed block is.
func (s *CommitStore) startHashing() error {

@yzang2019 yzang2019 Sep 3, 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 seems to be a bug that Writable WAL replay could deadlock past the stream's dept:

startHashing only attaches a drain when s.readOnly (flatkv/store.go:1130-1139). A writable store also hashes every block it replays: openTo → replayIntoMutableStore → catchUpFromWAL → replayBlocks → applyAndCommit → sealBlock, which calls finalizer.Offer and hashEngine.ScheduleHash for each block. Nothing reads publishedHashChan during replay — the composite store's flatKVHashCache isn't created until the first hash question, long after LoadVersion returns — so FinalizationManager.publish blocks, messageChan fills, and Offer/ScheduleHash block forever.

// flatkv — see latticeHash — so by the time this runs flatkv may already sit at version, and a
// height derived from its own state would land on the next block and commit one that never existed.
// Handing it the height the caller means lets flatkv recognise the block it already committed.
func (cs *CompositeCommitStore) Commit(version int64) (int64, error) {

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.

A paused EVM migration commits and publishes with nobody reading could lead to the chain halts:

Commit calls cs.flatKV.Commit(version) unconditionally whenever flatKV != nil (composite/store.go:819), but the only thing that drains the stream is mustLatticeHash → flatKVHashCache, and both of its call sites (store.go:1134 and refreshLastCommitInfo at :1197) are gated on shouldAppendLatticeHash(). In MigrateEVM with the migration not yet started, that gate is closed, so flatKV commits and publishes a hash every block with nothing reading it.

}

// Flush blocks until the engine has published a hash for every block scheduled so far.
func (he *HashEngine) Flush() error {

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.

Some corner case: HashEngine.Flush() can hang forever on shutdown:

blockGatherer.teardown skips every non-hashRequest message with continue (lthash/block_gatherer.go:85-88), which silently swallows a queued flushRequest without closing its doneChan. A Flush() whose request lands on scheduledBlockChan just as the engine's context is cancelled then blocks forever on <-request.doneChan

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.

2 participants