From 5f66f0310bb9fcbc62bdb712b5976bd0a83a5ad1 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 1 Sep 2026 09:30:34 -0500 Subject: [PATCH 1/4] hashing gold file test --- .../sc/flatkv/lthash/hash_calculator.go | 10 +- .../sc/flatkv/lthash_agreement_test.go | 26 ++- .../state_db/sc/flatkv/lthash_golden_test.go | 206 ++++++++++++++++++ .../lthash_golden/0-1-12-lthash_golden.hlog | 13 ++ sei-db/state_db/sc/hashlog/hash_log_reader.go | 53 ++++- .../sc/hashlog/hash_log_reader_test.go | 112 +++++++++- sei-db/tools/cmd/seidb/operations/hashlog.go | 6 +- .../cmd/seidb/operations/hashlog_test.go | 2 +- 8 files changed, 401 insertions(+), 27 deletions(-) create mode 100644 sei-db/state_db/sc/flatkv/lthash_golden_test.go create mode 100644 sei-db/state_db/sc/flatkv/testdata/lthash_golden/0-1-12-lthash_golden.hlog diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go b/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go index 11c28bddbc..4effd1665e 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go @@ -34,16 +34,18 @@ type DBPairs struct { Pairs []KVPairWithLastValue } -// Result holds the recomputed hash state after folding a block's pairs. PerDB +// BlockHash holds the recomputed hash state after folding a block's pairs. PerDB // and PerModule contain an entry for every DB dir the HashCalculator was // configured with (so callers can swap them in wholesale). Global is the // homomorphic sum of the per-DB roots. PerModuleStats holds the per-(dir, // module) key-count / byte totals accumulated alongside the hash. -type Result struct { +type BlockHash struct { + BlockNumber int64 PerDB map[string]*LtHash PerModule map[string]map[string]*LtHash PerModuleStats map[string]map[string]ModuleStats Global *LtHash + Error error } // HashCalculator encapsulates the per-block lattice-hash pipeline over an @@ -103,7 +105,7 @@ func (c *HashCalculator) Compute( prevPerDB map[string]*LtHash, prevPerModule map[string]map[string]*LtHash, prevPerModuleStats map[string]map[string]ModuleStats, -) (*Result, error) { +) (*BlockHash, error) { newPerDB := make(map[string]*LtHash, len(c.dbDirs)) newPerModule := make(map[string]map[string]*LtHash, len(c.dbDirs)) newPerModuleStats := make(map[string]map[string]ModuleStats, len(c.dbDirs)) @@ -152,7 +154,7 @@ func (c *HashCalculator) Compute( global.MixIn(newPerDB[dir]) } - return &Result{ + return &BlockHash{ PerDB: newPerDB, PerModule: newPerModule, PerModuleStats: newPerModuleStats, diff --git a/sei-db/state_db/sc/flatkv/lthash_agreement_test.go b/sei-db/state_db/sc/flatkv/lthash_agreement_test.go index 7b642740c7..a10d0781c1 100644 --- a/sei-db/state_db/sc/flatkv/lthash_agreement_test.go +++ b/sei-db/state_db/sc/flatkv/lthash_agreement_test.go @@ -740,6 +740,10 @@ type miscCoord struct { type agreementWorkload struct { rng *rand.Rand + // blockSize decides one block's total operation budget, which planBlock splits across the four + // categories. + blockSize func() int + // Index-derived pools rather than the byte-indexed addrN/slotN helpers, which top out at 256 // values — too few to absorb hundreds of operations per block. addrs []ktype.Address @@ -765,7 +769,26 @@ const ( agreementAimAttempts = 8 ) +// newAgreementWorkload draws each block's operation budget from the agreementMinOpsPerBlock.. +// agreementMaxOpsPerBlock range. func newAgreementWorkload(rng *rand.Rand) *agreementWorkload { + w := buildAgreementWorkload(rng) + span := agreementMaxOpsPerBlock - agreementMinOpsPerBlock + 1 + w.blockSize = func() int { return agreementMinOpsPerBlock + rng.Intn(span) } + return w +} + +// newFixedSizeAgreementWorkload gives every block the same operation budget. For a caller whose expected +// output is recorded rather than derived, and so must not move if the randomized range is ever retuned. +func newFixedSizeAgreementWorkload(rng *rand.Rand, opsPerBlock int) *agreementWorkload { + w := buildAgreementWorkload(rng) + w.blockSize = func() int { return opsPerBlock } + return w +} + +// buildAgreementWorkload assembles the generator state shared by both constructors, leaving blockSize +// for the caller to set. +func buildAgreementWorkload(rng *rand.Rand) *agreementWorkload { w := &agreementWorkload{ rng: rng, miscModules: []string{keys.EVMStoreKey, "bank", "staking"}, @@ -822,8 +845,7 @@ func (p blockPlan) total() int { return p.creates + p.updates + p.deletes + p.ab // get a guaranteed share: a category that only appears sometimes is a category that is untested on the // blocks where it does not. func (w *agreementWorkload) planBlock() blockPlan { - span := agreementMaxOpsPerBlock - agreementMinOpsPerBlock + 1 - total := agreementMinOpsPerBlock + w.rng.Intn(span) + total := w.blockSize() plan := blockPlan{ creates: total * 40 / 100, updates: total * 30 / 100, diff --git a/sei-db/state_db/sc/flatkv/lthash_golden_test.go b/sei-db/state_db/sc/flatkv/lthash_golden_test.go new file mode 100644 index 0000000000..74b4f17181 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/lthash_golden_test.go @@ -0,0 +1,206 @@ +package flatkv + +import ( + "flag" + "fmt" + "math/rand" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/common/unit" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" +) + +// This file pins flatKV's lattice hashes to values recorded on disk, so that a change to how hashing is +// organised has to either reproduce them or be seen changing them. +// +// The rest of the lthash suite checks hashes against things derived at the same time as the hashes: a +// full rescan, or a model built from the same changeset stream. Those catch a wrong answer, but not an +// answer that changed. This does, because the expected values were computed by a build that no longer +// exists and are read back from testdata rather than recomputed. +// +// The recorded archive is produced by the same code path that reports hashes in production — +// CommitStore.HashCategories and CommitStore.RecordHashes into a hashlog.HashLogger — so the format is +// a CSV of one row per block, and comparing two runs is hashlog.CompareHashesInRange. + +// goldenRecord regenerates the committed archive instead of checking against it. Off by default, and +// refused outright on CI: see recordGoldenArchive. +var goldenRecord = flag.Bool("lthash-golden-record", false, + "rewrite the committed lthash golden archive from this build instead of verifying against it") + +const ( + // goldenSeed drives the workload. A literal rather than lthash_agreement_test.go's agreementSeed, + // whose -lthash-agreement-seed flag would silently change what the recorded hashes describe. + goldenSeed = 0x5ea1_0000_600d_1eaf + + // goldenBlocks is how many blocks the workload produces. Enough that a block's hash depends on a long + // chain of predecessors, so an accumulator that loses a delta diverges and stays diverged. + goldenBlocks = 12 + + // goldenOpsPerBlock is each block's operation budget, split by planBlock across creates, updates, + // deletes and deletes of absent keys. + goldenOpsPerBlock = 1000 + + // goldenVersion is embedded in the archive's file names, so it is fixed rather than the real build + // version: the recorded archive has to keep the name it was recorded under. + goldenVersion = "lthash-golden" + + // goldenArchiveDir holds the recorded archive, committed to the repository. + goldenArchiveDir = "testdata/lthash_golden" +) + +// TestLtHashGoldenHashesUnchanged replays the recorded workload and requires this build to produce the +// hashes that are committed under testdata. +// +// A failure here is one of two things, and the changeset column says which. If the "changeset" hashes +// differ, the workload itself changed and the lattice hashes are incomparable — fix the generator. If +// only the flatKV columns differ, this build hashes the same blocks differently, which is the failure +// this test exists for. +func TestLtHashGoldenHashesUnchanged(t *testing.T) { + if *goldenRecord { + recordGoldenArchive(t) + return + } + + fresh := filepath.Join(t.TempDir(), "fresh") + writeGoldenRun(t, fresh, config.DefaultTestConfig(t)) + requireArchivesAgree(t, goldenArchiveDir, fresh) +} + +// TestLtHashGoldenIsIndependentOfWorkerCount requires the recorded hashes to hold under a different +// lthash worker count. +// +// MixIn and MixOut are commutative and associative, so the number of workers and the chunk size cannot +// move the result. That is the property the parallel fold rests on, and it is what will let hashing be +// reorganised without the hashes moving — so it is asserted rather than assumed. +func TestLtHashGoldenIsIndependentOfWorkerCount(t *testing.T) { + for _, threadsPerCore := range []float64{0.5, 4.0} { + t.Run(fmt.Sprintf("threadsPerCore=%v", threadsPerCore), func(t *testing.T) { + cfg := config.DefaultTestConfig(t) + cfg.LtHashThreadsPerCore = threadsPerCore + + fresh := filepath.Join(t.TempDir(), "fresh") + writeGoldenRun(t, fresh, cfg) + requireArchivesAgree(t, goldenArchiveDir, fresh) + }) + } +} + +// requireArchivesAgree requires the two archives to report identical hashes for every golden block. +// +// requireEveryBlock is what makes a pass mean something: without it an archive that recorded nothing — +// because the run died early, or the directory was wrong — compares equal to anything. +func requireArchivesAgree(t *testing.T, recorded string, fresh string) { + t.Helper() + + diffs, err := hashlog.CompareHashesInRange(recorded, fresh, 1, goldenBlocks, -1, true) + require.NoError(t, err, "comparing recorded archive %s against this build's %s", recorded, fresh) + + for _, diff := range diffs { + t.Errorf("block %s hashes differ:\n recorded: %s\n this build: %s", + diffBlockLabel(diff), formatReports(diff.HashesFromA), formatReports(diff.HashesFromB)) + } + require.Empty(t, diffs, "this build does not reproduce the recorded lattice hashes; "+ + "if the change is intended, re-record with -lthash-golden-record and review the diff") +} + +// writeGoldenRun drives the golden workload against a fresh store and records each block's hashes into +// an archive at dir. +func writeGoldenRun(t *testing.T, dir string, cfg *config.Config) { + t.Helper() + + store := setupTestStoreWithConfig(t, cfg) + defer func() { require.NoError(t, store.Close()) }() + + logger := newGoldenHashLogger(t, dir, store.HashCategories()) + defer func() { require.NoError(t, logger.Close()) }() + + workload := newFixedSizeAgreementWorkload( + rand.New(rand.NewSource(goldenSeed)), //nolint:gosec // deterministic test data only + goldenOpsPerBlock) + + for height := int64(1); height <= goldenBlocks; height++ { + changeSets := workload.nextBlock(height) + require.NotEmpty(t, changeSets, "block %d produced no changesets", height) + require.NoError(t, store.ApplyChangeSets(height, changeSets), "apply block %d", height) + + _, err := store.Commit(height) + require.NoError(t, err, "commit block %d", height) + require.Equal(t, height, store.Version()) + + block := uint64(height) //nolint:gosec // heights start at 1 and only increase + logger.ReportChangeset(block, changeSets) + require.NoError(t, store.RecordHashes(logger, block), "record hashes for block %d", height) + } +} + +// newGoldenHashLogger opens a logger that records the store's hash categories plus the changeset column +// into dir. +// +// The file size cap is raised well past what this workload writes, because a rotation would split the +// archive across files named after the blocks they hold, and the recorded names have to stay stable. +func newGoldenHashLogger(t *testing.T, dir string, hashTypes []string) hashlog.HashLogger { + t.Helper() + + cfg := hashlog.DefaultHashLoggerConfig(dir, goldenVersion) + cfg.HashTypes = hashTypes + cfg.TargetFileSize = unit.MB + + logger, err := hashlog.NewHashLogger(cfg) + require.NoError(t, err) + return logger +} + +// recordGoldenArchive replaces the committed archive with one produced by this build. +// +// It refuses to run on CI. The archive is the only statement of what the hashes are expected to be, so a +// build that regenerated it as part of an ordinary test run would report success for having agreed with +// itself. Recording is a deliberate local act whose output a human reads in a diff. +func recordGoldenArchive(t *testing.T) { + t.Helper() + + if os.Getenv("CI") != "" { + t.Fatal("refusing to re-record the lthash golden archive on CI: " + + "the recorded hashes are the expected values, so a build that rewrites them verifies nothing") + } + + require.NoError(t, os.RemoveAll(goldenArchiveDir)) + writeGoldenRun(t, goldenArchiveDir, config.DefaultTestConfig(t)) + + t.Logf("recorded %d blocks into %s — review the diff before committing", goldenBlocks, goldenArchiveDir) +} + +// diffBlockLabel names the block a diff describes, taking it from whichever side has a report. +func diffBlockLabel(diff *hashlog.HashLogPair) string { + for _, reports := range [][]*hashlog.HashLog{diff.HashesFromA, diff.HashesFromB} { + if len(reports) > 0 { + return fmt.Sprintf("%d", reports[0].BlockNumber) + } + } + return "(unknown)" +} + +// formatReports renders one side of a diff for a failure message, hash types in a stable order since +// they come out of a map. +func formatReports(reports []*hashlog.HashLog) string { + if len(reports) == 0 { + return "(no report)" + } + var out string + for _, report := range reports { + types := make([]string, 0, len(report.Hashes)) + for hashType := range report.Hashes { + types = append(types, hashType) + } + slices.Sort(types) + for _, hashType := range types { + out += fmt.Sprintf("\n %-24s %x", hashType, report.Hashes[hashType]) + } + } + return out +} diff --git a/sei-db/state_db/sc/flatkv/testdata/lthash_golden/0-1-12-lthash_golden.hlog b/sei-db/state_db/sc/flatkv/testdata/lthash_golden/0-1-12-lthash_golden.hlog new file mode 100644 index 0000000000..6d0aaf5424 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/testdata/lthash_golden/0-1-12-lthash_golden.hlog @@ -0,0 +1,13 @@ +block_number,changeset,flatKV/root,flatKV/db/account,flatKV/db/code,flatKV/db/storage,flatKV/db/misc +1,adb4bf5ef516d535,5bf8d609d48f649f1a8ef0ea6789ab37e2150188fb328a84d27b3428dfb8adbd,3fbd79e5aeb145403a283adb95e57026f09bf22122e99c8d93f22ff827bf65f2,ea743925cec94c99a2b3bc1ec5655b1b4e520b15e0c1a5e7837be8c163abf796,aa4dbc16fbe9a481b8e1a29eb06bf4f1e2f0191fad06722a817104692eb75a7a,f02165c8fa33c8dadc2b58d7345676495f68d0611a48f99941edeefbde2ec1d1 +2,1e7ad4fc5d784235,758da03c4b1b9d087e50aff73876fab91ad47528c7cc9ee1b201ce80e950336e,b35370875eb01909acce1e1ecf3143177c2a65fdf8a3be22843081f859ceba09,fa96df5df5c952f3eb1f1d72e9e3a26b8dddf1e00fc257b8c719a0043402697b,5ed2c9473973f9b54fb246fe213a70d4e6e7dd4effd7780ef9ad394a250d80e0,791e224f133ccdb9b0c5add53552f0b37b957e2e175df3108b01cbddfad564c2 +3,b9656afa0e0548b4,45f2a5398ae9ab6cdf5252e8391d2ef37d314259d35499d59d3a8acff06ef158,87c2641e6063e566bd57a634f139994f3d1312dddf9eaaa4d4e09083e5eadab0,183cbd20c3c9efdbe1c6409477b869a5aecd07fd3da9b442ac925a86d702f1cd,c2caa1399eeb7d1eede11b17bf8765695bd4ba947a2ade156499d4eae4a2d825,289181075805618a918ad7cba2a546bfc8f2a4ad5fcfdf7137019cae7e45626e +4,fbb55652ff6d0d86,7638eb2eb9bb924b3d16766bc7593638d857d8ac65a0b2d7826e0a52a44097c3,49a7e5e46aa4e360a549a01f121a54249965e9f93e9752e107e3b2b0ad284774,ef42a94789b872c726889799a9520fb3abaa78354d3b1cf8287d37039dd9b388,9722e353d6f7dbc86ec46383edb41a0d8e035e52c35a7e8b9338f230f1717401,27316598db9e2e0fb169795b6c032ac4a0586f9a58594abcad7b082a078916f3 +5,e39136ea16d5ddb3,13d70e068893508bd9be4d485a644da4f31281d32aa43aafd0f6f8dcb930ceae,55d9751a0d855d236ff3b55eda6180c1590e989d3964c99c86823b695bce57aa,7ba16ba599c9a83ddcaf11778b577b3c6cd96bef54eb58559c41df59328a08ce,524551b368f5212bec8611f5152c220b58ea64e915820d3508d9055d6479b562,89ba7005b0f46bc2b767d1b3fb43e05ae277a2a8fc8e0fa2a30319b42f46a32e +6,d492323dc09baa7d,81692bfe7eff70d34bfceae8ac1308dabcf97e46df26c6ead1355da905927833,fea0fe4f6d1f2212692f61802e4ea1be1ba44c177aa20f663453d76c6adc4aef,8217adc837c2eee422b794602b796e94573e4b302252610b4363832f4977c30d,75063679f450873ae5db4281863e74224ace2826491418971cc91f44829962dd,62976f07497abefa7a44eef32e985b00a01402064703ae1bb1c8ba4559db4d26 +7,93be9228f3b821f0,810505a30990fa9b11729973746a61c50297b079bad617346ed6c142fe319883,6ac68dc438880ef73abd860efe370d234f4bf3898341446e6ecd3206e7452a84,cf9c6d16636360ce87b57486c618562ccf2000f66a6c5b300b3840f608d76c95,c95d2043cfb22adb4e524f5acdb900ce942e5ad0f267b64a655bfe20e4a80ed8,fbf8961c2052a4d42c6bf8f003c4672382ba1d07089aa62344b4a02314ccac55 +8,789ebec8a2013ea4,442ee882c5aad67b6fcac096fd74939ad2d2e881da2aae21e38681660aa3b0db,42bd816b98cda4d21ed403f80e967e271a7a1c19d82cf8d15e5f20f061fac85d,ae576650a6a9c7584c4b156828444a063141d7d4b4374a8f41c8110d74918546,b020c8218a44c289983fde87b13f8683a346c5801dc70826b4699d2c1c9b17b0,a9c48efad4b8d594677d5199ce76088edc49c9a20a5cb79811e98974ab35f773 +9,69369bb5b694830f,8e4a2432ea800a892441eaf13bc4f2365e04986d857085b287bf0858e1cf8bb1,49821bd37525ae5954c3e5847a3a36376e8b34854c8dd37c280b3007c96dfcba,88f153d82ea4af135c6e4aca35c127aa420a6fde27dccd4b2c7e9ae2c75a1b11,b3e76f7cedef24eef4e3a212f88b6ede5cc8bdf42db695760f8d07d7a973c293,bc9db2b77555f7d853910531a6b97d1b4685a0ab701ce359443725cbd418197c +10,261506a5a898ecfc,6a05aff48bc1b8b0721e78d62becdcaa5960cdbe0ffd730e0acbe2f72d5f874c,e7dd5424cb634747e8c05b3ae83ce7c2b16539898a3ac2479033bfbdb389a33d,e56a9f0a8a86572b51d70e28604250a922591f8e6370342dcb69e4f875435d1e,305ea227f21130214ac89af427368a3c27b161263db208c80c875597880e7dd4,9ce5aecfb5edbb2497ed68ebca74d28fb66193ef28d1289f64139e32fb1c9db9 +11,598f0d8a615a6541,13f5a37d3b5e354cd441c117c58a3db354e9f00321345f7d6c35b5acc6e41a04,f348b842fe2c4217ccf840cba06ff92cb5ac5821d5b445cf5d01c04bda62f002,7b8a3f03fece8a5ee01091aea34a62194c23494597b87f0ac59fee8565919683,dc060fa2c26f6260951094cb24e4d32e29017e285071aaa7c6b5c70f322605c3,be9df2ce4762a10ae32622cdf97b6d55cb11d49427d325943244c8a33927e3ab +12,7cec98f6ac931ae2,5e3e160e7cd6961392e45e86ed4e179a22a7e56d8229e3212575f58cc7b98672,99ce63f65a03b9379ba6ea338d7054701ea692b7a19579baffff1da25383a308,b513784dc7e5d76063fb118be8207d0f5a60142fbe4e6fa71cc9f88c374591ef,2670af86691752074efdfc96ba78ae6cd52477f60102bb3d8818821cacc60c10,1bfdba87f29e93c9c7c6db0258cc2e92c87ec7072ea01eca1064c1c956391764 diff --git a/sei-db/state_db/sc/hashlog/hash_log_reader.go b/sei-db/state_db/sc/hashlog/hash_log_reader.go index f749afb6d8..3c209d1094 100644 --- a/sei-db/state_db/sc/hashlog/hash_log_reader.go +++ b/sei-db/state_db/sc/hashlog/hash_log_reader.go @@ -242,6 +242,9 @@ func CompareHashes( // and run for a long time, the number of deviant blocks may be very large. This always returns the first // diffs encountered if it does not return all diffs. maxDiffCount int, + // when true, both archives must hold a report for every block in the compared range, and two archives + // that are both empty are rejected rather than reported as agreeing. + requireEveryBlock bool, ) ([]*HashLogPair, error) { readerA, readerB, err := openArchiveReaders(pathA, pathB) if err != nil { @@ -249,14 +252,21 @@ func CompareHashes( } lowBlock, highBlock, ok := globalBlockRange(readerA, readerB) if !ok { + if requireEveryBlock { + return nil, fmt.Errorf("neither archive holds any blocks") + } return nil, nil } - return compareBlockRange(readerA, readerB, lowBlock, highBlock, maxDiffCount) + return compareBlockRange(readerA, readerB, lowBlock, highBlock, maxDiffCount, requireEveryBlock) } // CompareHashesInRange is CompareHashes restricted to the inclusive block range [lowBlock, highBlock], for -// zooming in on a region of interest. The requested window is clamped to the blocks actually present in the -// archives, and files entirely below the window are never read, so it is cheap even far from block zero. +// zooming in on a region of interest. Files entirely below the window are never read, so it is cheap even far +// from block zero. +// +// Without requireEveryBlock the window is clamped to the blocks the archives actually hold, since nothing +// outside that range can differ. With it the window is compared as asked, so a window reaching past either +// archive is reported as a missing block. func CompareHashesInRange( pathA string, pathB string, @@ -266,6 +276,8 @@ func CompareHashesInRange( highBlock uint64, // the maximum number of diffs to return, or -1 for all (see CompareHashes) maxDiffCount int, + // when true, both archives must hold a report for every block in [lowBlock, highBlock] (see CompareHashes) + requireEveryBlock bool, ) ([]*HashLogPair, error) { if lowBlock > highBlock { return nil, fmt.Errorf("lowBlock (%d) must not exceed highBlock (%d)", lowBlock, highBlock) @@ -276,15 +288,20 @@ func CompareHashesInRange( } globalLow, globalHigh, ok := globalBlockRange(readerA, readerB) if !ok { + if requireEveryBlock { + return nil, fmt.Errorf("neither archive holds any blocks") + } return nil, nil } - // Clamp the requested window to the blocks actually present; nothing outside that range can differ. + if requireEveryBlock { + return compareBlockRange(readerA, readerB, lowBlock, highBlock, maxDiffCount, true) + } low := max(lowBlock, globalLow) high := min(highBlock, globalHigh) if low > high { return nil, nil } - return compareBlockRange(readerA, readerB, low, high, maxDiffCount) + return compareBlockRange(readerA, readerB, low, high, maxDiffCount, false) } // openArchiveReaders opens both archives for streaming comparison. @@ -315,14 +332,17 @@ func globalBlockRange(readerA *archiveReader, readerB *archiveReader) (low uint6 } // compareBlockRange streams the comparison over the inclusive range [lowBlock, highBlock], which the caller -// must have already validated and clamped. Both readers are advanced in lockstep, in non-decreasing block -// order, as required by archiveReader.at. +// must have already validated. Both readers are advanced in lockstep, in non-decreasing block order, as +// required by archiveReader.at. +// +// requireEveryBlock rejects a range the archives do not both cover, rather than reporting it as agreement. func compareBlockRange( readerA *archiveReader, readerB *archiveReader, lowBlock uint64, highBlock uint64, maxDiffCount int, + requireEveryBlock bool, ) ([]*HashLogPair, error) { var diffs []*HashLogPair for block := lowBlock; block <= highBlock; block++ { @@ -334,6 +354,12 @@ func compareBlockRange( if err != nil { return nil, fmt.Errorf("failed to read block %d from archive B: %w", block, err) } + if requireEveryBlock { + // Checked before the comparison below, which reads two absent blocks as agreement. + if err := requireBothPresent(block, hashesA, hashesB); err != nil { + return nil, err + } + } if hashLogsDiffer(hashesA, hashesB) { if maxDiffCount >= 0 && len(diffs) >= maxDiffCount { break @@ -344,6 +370,19 @@ func compareBlockRange( return diffs, nil } +// requireBothPresent reports an error naming the archive that has no report for the given block. +func requireBothPresent(block uint64, hashesA []*HashLog, hashesB []*HashLog) error { + switch { + case len(hashesA) == 0 && len(hashesB) == 0: + return fmt.Errorf("block %d is missing from both archives", block) + case len(hashesA) == 0: + return fmt.Errorf("block %d is missing from archive A", block) + case len(hashesB) == 0: + return fmt.Errorf("block %d is missing from archive B", block) + } + return nil +} + // hashLogsDiffer reports whether the reports for a single block differ between two archives. func hashLogsDiffer(a []*HashLog, b []*HashLog) bool { if len(a) != len(b) { diff --git a/sei-db/state_db/sc/hashlog/hash_log_reader_test.go b/sei-db/state_db/sc/hashlog/hash_log_reader_test.go index 534718ee35..b6959ae784 100644 --- a/sei-db/state_db/sc/hashlog/hash_log_reader_test.go +++ b/sei-db/state_db/sc/hashlog/hash_log_reader_test.go @@ -64,7 +64,7 @@ func TestCompareHashesFindsDeviations(t *testing.T) { log(3, map[string][]byte{"root": {0x03}}), }) - diffs, err := CompareHashes(dirA, dirB, -1) + diffs, err := CompareHashes(dirA, dirB, -1, false) require.NoError(t, err) require.Len(t, diffs, 1) require.Equal(t, uint64(2), diffs[0].HashesFromA[0].BlockNumber) @@ -83,7 +83,7 @@ func TestCompareHashesIdentical(t *testing.T) { writeArchive(t, dirA, 0, "v1", hashTypes, logs) writeArchive(t, dirB, 0, "v1", hashTypes, logs) - diffs, err := CompareHashes(dirA, dirB, -1) + diffs, err := CompareHashes(dirA, dirB, -1, false) require.NoError(t, err) require.Empty(t, diffs) } @@ -104,7 +104,7 @@ func TestCompareHashesRespectsMaxDiffCount(t *testing.T) { log(3, map[string][]byte{"root": {0xA3}}), }) - diffs, err := CompareHashes(dirA, dirB, 2) + diffs, err := CompareHashes(dirA, dirB, 2, false) require.NoError(t, err) require.Len(t, diffs, 2, "should stop at maxDiffCount") // Returned lowest-first. @@ -130,7 +130,7 @@ func TestCompareHashesStreamsAcrossManyFiles(t *testing.T) { []*HashLog{log(block, map[string][]byte{"root": valueB})}) } - diffs, err := CompareHashes(dirA, dirB, -1) + diffs, err := CompareHashes(dirA, dirB, -1, false) require.NoError(t, err) require.Len(t, diffs, 1) require.Equal(t, uint64(17), diffs[0].HashesFromA[0].BlockNumber) @@ -163,7 +163,7 @@ func TestCompareHashesOverlappingRollbackFile(t *testing.T) { log(6, map[string][]byte{"root": {0x66}}), }) - diffs, err := CompareHashes(dirA, dirB, -1) + diffs, err := CompareHashes(dirA, dirB, -1, false) require.NoError(t, err) require.Len(t, diffs, 1) require.Equal(t, uint64(5), diffs[0].HashesFromA[0].BlockNumber) @@ -190,13 +190,13 @@ func TestCompareHashesInRangeRestrictsToWindow(t *testing.T) { } // Zooming into [10, 20] must surface only the block-17 deviation. - diffs, err := CompareHashesInRange(dirA, dirB, 10, 20, -1) + diffs, err := CompareHashesInRange(dirA, dirB, 10, 20, -1, false) require.NoError(t, err) require.Len(t, diffs, 1) require.Equal(t, uint64(17), diffs[0].HashesFromA[0].BlockNumber) // The full comparison still finds all three, confirming the window is what narrowed the result. - all, err := CompareHashes(dirA, dirB, -1) + all, err := CompareHashes(dirA, dirB, -1, false) require.NoError(t, err) require.Len(t, all, 3) } @@ -215,18 +215,18 @@ func TestCompareHashesInRangeClampsAndValidates(t *testing.T) { }) // A window wider than the data is clamped to what's present and still finds the deviation. - diffs, err := CompareHashesInRange(dirA, dirB, 0, 1_000_000, -1) + diffs, err := CompareHashesInRange(dirA, dirB, 0, 1_000_000, -1, false) require.NoError(t, err) require.Len(t, diffs, 1) require.Equal(t, uint64(6), diffs[0].HashesFromA[0].BlockNumber) // A window entirely outside the data yields nothing. - none, err := CompareHashesInRange(dirA, dirB, 100, 200, -1) + none, err := CompareHashesInRange(dirA, dirB, 100, 200, -1, false) require.NoError(t, err) require.Empty(t, none) // An inverted range is rejected. - _, err = CompareHashesInRange(dirA, dirB, 10, 5, -1) + _, err = CompareHashesInRange(dirA, dirB, 10, 5, -1, false) require.Error(t, err) } @@ -271,7 +271,97 @@ func TestCompareHashesDifferentTypeSets(t *testing.T) { }) // The extra "flatKV" hash on side B (absent on A) counts as a deviation. - diffs, err := CompareHashes(dirA, dirB, -1) + diffs, err := CompareHashes(dirA, dirB, -1, false) require.NoError(t, err) require.Len(t, diffs, 1) } + +// The three cases below are the ways a comparison can report agreement while having compared nothing. +// Each is the intended behaviour with requireEveryBlock clear, and an error with it set. A caller +// asserting that two archives match — a golden-hash regression test, say — depends on the second half +// of each: without it a run that produced no blocks at all is indistinguishable from a run that +// produced matching ones. + +func TestCompareHashesEmptyArchives(t *testing.T) { + dirA := t.TempDir() + dirB := t.TempDir() + + diffs, err := CompareHashes(dirA, dirB, -1, false) + require.NoError(t, err) + require.Empty(t, diffs) + + _, err = CompareHashes(dirA, dirB, -1, true) + require.ErrorContains(t, err, "neither archive holds any blocks") + + _, err = CompareHashesInRange(dirA, dirB, 1, 10, -1, true) + require.ErrorContains(t, err, "neither archive holds any blocks") +} + +func TestCompareHashesBlockMissingFromBothArchives(t *testing.T) { + dirA := filepath.Join(t.TempDir(), "a") + dirB := filepath.Join(t.TempDir(), "b") + hashTypes := []string{"root"} + // Both archives skip block 2, so it is absent on each side and compares as agreement. + logs := []*HashLog{ + log(1, map[string][]byte{"root": {0x01}}), + log(3, map[string][]byte{"root": {0x03}}), + } + writeArchive(t, dirA, 0, "v1", hashTypes, logs) + writeArchive(t, dirB, 0, "v1", hashTypes, logs) + + diffs, err := CompareHashes(dirA, dirB, -1, false) + require.NoError(t, err) + require.Empty(t, diffs) + + _, err = CompareHashes(dirA, dirB, -1, true) + require.ErrorContains(t, err, "block 2 is missing from both archives") +} + +func TestCompareHashesInRangeWindowPastArchives(t *testing.T) { + dirA := filepath.Join(t.TempDir(), "a") + dirB := filepath.Join(t.TempDir(), "b") + hashTypes := []string{"root"} + logs := []*HashLog{ + log(1, map[string][]byte{"root": {0x01}}), + log(2, map[string][]byte{"root": {0x02}}), + } + writeArchive(t, dirA, 0, "v1", hashTypes, logs) + writeArchive(t, dirB, 0, "v1", hashTypes, logs) + + // Asking for 1..12 against archives holding 1..2 is clamped to 1..2 and reports agreement. + diffs, err := CompareHashesInRange(dirA, dirB, 1, 12, -1, false) + require.NoError(t, err) + require.Empty(t, diffs) + + _, err = CompareHashesInRange(dirA, dirB, 1, 12, -1, true) + require.ErrorContains(t, err, "block 3 is missing from both archives") + + // The window the archives do cover passes under either setting. + diffs, err = CompareHashesInRange(dirA, dirB, 1, 2, -1, true) + require.NoError(t, err) + require.Empty(t, diffs) +} + +func TestCompareHashesRequireEveryBlockNamesTheShortArchive(t *testing.T) { + dirA := filepath.Join(t.TempDir(), "a") + dirB := filepath.Join(t.TempDir(), "b") + hashTypes := []string{"root"} + writeArchive(t, dirA, 0, "v1", hashTypes, []*HashLog{ + log(1, map[string][]byte{"root": {0x01}}), + log(2, map[string][]byte{"root": {0x02}}), + }) + writeArchive(t, dirB, 0, "v1", hashTypes, []*HashLog{ + log(1, map[string][]byte{"root": {0x01}}), + }) + + // A block only one side reached is a diff when coverage is not required, since that is the more + // useful report for an operator comparing two nodes. + diffs, err := CompareHashes(dirA, dirB, -1, false) + require.NoError(t, err) + require.Len(t, diffs, 1) + require.Equal(t, uint64(2), diffs[0].HashesFromA[0].BlockNumber) + require.Empty(t, diffs[0].HashesFromB) + + _, err = CompareHashes(dirA, dirB, -1, true) + require.ErrorContains(t, err, "block 2 is missing from archive B") +} diff --git a/sei-db/tools/cmd/seidb/operations/hashlog.go b/sei-db/tools/cmd/seidb/operations/hashlog.go index 881d351119..1b234133ca 100644 --- a/sei-db/tools/cmd/seidb/operations/hashlog.go +++ b/sei-db/tools/cmd/seidb/operations/hashlog.go @@ -93,9 +93,11 @@ func executeHashLogCompare(cmd *cobra.Command, args []string) { result.ranged = true result.low, _ = cmd.Flags().GetUint64("low") result.high, _ = cmd.Flags().GetUint64("high") - diffs, err = hashlog.CompareHashesInRange(archiveA, archiveB, result.low, result.high, maxDiffs) + // Coverage is not required: an operator comparing two nodes wants the deviant blocks listed, and a + // block only one node reached is such a block rather than a reason to abandon the comparison. + diffs, err = hashlog.CompareHashesInRange(archiveA, archiveB, result.low, result.high, maxDiffs, false) } else { - diffs, err = hashlog.CompareHashes(archiveA, archiveB, maxDiffs) + diffs, err = hashlog.CompareHashes(archiveA, archiveB, maxDiffs, false) } if err != nil { panic(fmt.Errorf("compare hash archives: %w", err)) diff --git a/sei-db/tools/cmd/seidb/operations/hashlog_test.go b/sei-db/tools/cmd/seidb/operations/hashlog_test.go index b9d7eebe66..50f091c0e2 100644 --- a/sei-db/tools/cmd/seidb/operations/hashlog_test.go +++ b/sei-db/tools/cmd/seidb/operations/hashlog_test.go @@ -263,7 +263,7 @@ func TestHashLogReadEndToEnd(t *testing.T) { require.Contains(t, out, "root: 02") require.Contains(t, out, "version: v1.2.3") - diffs, err := hashlog.CompareHashes(dirA, dirB, -1) + diffs, err := hashlog.CompareHashes(dirA, dirB, -1, false) require.NoError(t, err) require.Len(t, diffs, 1) require.Equal(t, uint64(2), pairBlock(diffs[0])) From 4ecbe0e29ed1e063fa9a4f17f3f76dc04203de42 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 1 Sep 2026 09:52:01 -0500 Subject: [PATCH 2/4] impl stub --- .../state_db/sc/flatkv/lthash/hash_engine.go | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 sei-db/state_db/sc/flatkv/lthash/hash_engine.go diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_engine.go b/sei-db/state_db/sc/flatkv/lthash/hash_engine.go new file mode 100644 index 0000000000..674b57afc0 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine.go @@ -0,0 +1,37 @@ +package lthash + +import "github.com/sei-protocol/sei-chain/sei-db/common/threading" + +// Computes lattice hashes for flatKV. +type HashEngine struct { +} + +// TODO create a config + +func NewHashEngine(pool threading.Pool, dbDirs []string, moduleOf ModuleFunc) (*HashEngine, error) { + return nil, nil // TODO +} + +// Schedule a block to be hashed. +func (he *HashEngine) ScheduleHash(current *storeView, previous *storeView) error { // TODO Claude: we need to move storeView and the atomic store view to a new package called flatkv/sview + // TODO + + // Three phases of hashing, which should be fully pipelined. + // 1. collect key-value pairs we need to hash from the storeView objects, we can use a single worker thread for this + // 2. fan out to thread pool to hash key-value pairs, ok if multiple blocks are in this phase at once + // 3. single thread that stitches hashes together, on block at a time in block order (since block N depends on block N-1) + + // Phase 1 and 3 should have a dedicated goroutine, phase 2 should use the pool in the constructor. + // Communication to and from each of these phases should happen via channels. + // - channel from ScheduleHash to phase 1 worker + // - channel from phase 1 worker to each of the pool workers (managed internally by the pool) + // - channel from each phase 2 worker to the phase 3 worker + // - channel from phase 3 worker to AwaitHash() + + return nil +} + +// Returns a channel that returns block hashes, as they are computed. +func (he *HashCalculator) AwaitHash() <-chan *BlockHash { + return nil // TODO +} From 9bfeb2e7ee0bef39fad723aa576dc0da778e49f9 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 2 Sep 2026 12:07:20 -0500 Subject: [PATCH 3/4] refactor flatKV hash threading pattern --- .../storev2/rootmulti/flatkv_helpers_test.go | 6 +- .../rootmulti/flatkv_migration_test.go | 2 +- .../storev2/rootmulti/flatkv_workload_test.go | 6 +- sei-cosmos/storev2/rootmulti/hashlog.go | 39 +- sei-cosmos/storev2/rootmulti/store.go | 19 +- .../bench/wrappers/db_implementations.go | 4 +- sei-db/state_db/giga/live_state_store.go | 33 +- sei-db/state_db/giga/state_db_impl_test.go | 2 +- .../sc/composite/commit_info_stored_test.go | 4 +- sei-db/state_db/sc/composite/flatkv_hash.go | 97 +++++ sei-db/state_db/sc/composite/hashlog.go | 18 +- .../composite/random_test_framework_test.go | 6 +- sei-db/state_db/sc/composite/store.go | 67 +-- .../state_db/sc/composite/store_auto_test.go | 12 +- .../sc/composite/store_init_repair_test.go | 4 +- .../state_db/sc/composite/store_load_test.go | 4 +- .../sc/composite/store_migration_test.go | 20 +- sei-db/state_db/sc/composite/store_test.go | 164 ++++---- sei-db/state_db/sc/flatkv/config/config.go | 28 +- .../sc/flatkv/config/flatkv_test_config.go | 4 + .../sc/flatkv/finalization_manager.go | 357 ++++++++++++++++ .../sc/flatkv/finalization_messages.go | 41 ++ sei-db/state_db/sc/flatkv/hashlog.go | 53 ++- sei-db/state_db/sc/flatkv/hashlog_test.go | 27 +- .../state_db/sc/flatkv/import_export_test.go | 2 +- sei-db/state_db/sc/flatkv/importer.go | 77 ++-- sei-db/state_db/sc/flatkv/ktype/meta.go | 47 +-- sei-db/state_db/sc/flatkv/lthash/api.go | 252 ------------ .../sc/flatkv/lthash/block_gatherer.go | 181 ++++++++ .../sc/flatkv/lthash/hash_calculator.go | 389 ------------------ .../sc/flatkv/lthash/hash_combiner.go | 244 +++++++++++ .../state_db/sc/flatkv/lthash/hash_engine.go | 182 +++++++- .../sc/flatkv/lthash/hash_engine_config.go | 51 +++ .../sc/flatkv/lthash/hash_engine_messages.go | 82 ++++ .../sc/flatkv/lthash/hash_engine_test.go | 359 ++++++++++++++++ .../state_db/sc/flatkv/lthash/hash_types.go | 65 +++ .../state_db/sc/flatkv/lthash/leaf_hasher.go | 226 ++++++++++ .../state_db/sc/flatkv/lthash/lthash_test.go | 80 ++-- sei-db/state_db/sc/flatkv/lthash/stats.go | 4 +- .../state_db/sc/flatkv/lthash/stats_test.go | 32 +- .../sc/flatkv/lthash_agreement_test.go | 33 +- .../sc/flatkv/lthash_correctness_test.go | 28 +- .../state_db/sc/flatkv/lthash_golden_test.go | 18 +- .../state_db/sc/flatkv/perdb_lthash_test.go | 60 +-- .../sc/flatkv/permodule_lthash_test.go | 46 +-- .../sc/flatkv/permodule_stats_test.go | 20 +- sei-db/state_db/sc/flatkv/snapshot.go | 20 +- sei-db/state_db/sc/flatkv/snapshot_writer.go | 35 +- .../sc/flatkv/snapshot_writer_messages.go | 18 +- .../sc/flatkv/snapshot_writer_test.go | 35 +- sei-db/state_db/sc/flatkv/state_view.go | 36 +- sei-db/state_db/sc/flatkv/state_view_test.go | 4 +- sei-db/state_db/sc/flatkv/store.go | 331 +++++++++++---- .../sc/flatkv/store_init_repair_test.go | 2 +- sei-db/state_db/sc/flatkv/store_lifecycle.go | 7 +- sei-db/state_db/sc/flatkv/store_meta.go | 56 ++- sei-db/state_db/sc/flatkv/store_meta_test.go | 30 +- sei-db/state_db/sc/flatkv/store_read.go | 4 +- sei-db/state_db/sc/flatkv/store_replay.go | 1 - .../state_db/sc/flatkv/store_replay_test.go | 2 +- sei-db/state_db/sc/flatkv/store_test.go | 34 +- sei-db/state_db/sc/flatkv/store_write.go | 271 ++++-------- sei-db/state_db/sc/flatkv/store_write_test.go | 59 +-- .../flatkv/{ => sview}/atomic_store_view.go | 44 +- .../{ => sview}/atomic_store_view_test.go | 39 +- .../sc/flatkv/{ => sview}/store_view.go | 63 ++- .../sc/flatkv/{ => sview}/store_view_test.go | 30 +- .../state_db/sc/flatkv/sview/testutil_test.go | 93 +++++ sei-db/state_db/sc/flatkv/testutil_test.go | 71 +++- sei-db/state_db/sc/flatkv/verify.go | 33 +- sei-db/state_db/sc/flatkv/verify_test.go | 24 +- .../state_db/sc/flatkv/wal_testutil_test.go | 2 +- .../migration_test_framework_test.go | 2 +- .../tools/cmd/seidb/operations/dump_flatkv.go | 13 +- .../cmd/seidb/operations/dump_flatkv_test.go | 12 +- .../tools/cmd/seidb/operations/flatkv_open.go | 2 +- .../cmd/seidb/operations/flatkv_open_test.go | 2 +- .../operations/flatkv_state_size_test.go | 2 +- .../operations/import_flatkv_from_memiavl.go | 2 +- .../import_flatkv_from_memiavl_test.go | 2 +- 80 files changed, 3209 insertions(+), 1667 deletions(-) create mode 100644 sei-db/state_db/sc/composite/flatkv_hash.go create mode 100644 sei-db/state_db/sc/flatkv/finalization_manager.go create mode 100644 sei-db/state_db/sc/flatkv/finalization_messages.go delete mode 100644 sei-db/state_db/sc/flatkv/lthash/api.go create mode 100644 sei-db/state_db/sc/flatkv/lthash/block_gatherer.go delete mode 100644 sei-db/state_db/sc/flatkv/lthash/hash_calculator.go create mode 100644 sei-db/state_db/sc/flatkv/lthash/hash_combiner.go create mode 100644 sei-db/state_db/sc/flatkv/lthash/hash_engine_config.go create mode 100644 sei-db/state_db/sc/flatkv/lthash/hash_engine_messages.go create mode 100644 sei-db/state_db/sc/flatkv/lthash/hash_engine_test.go create mode 100644 sei-db/state_db/sc/flatkv/lthash/hash_types.go create mode 100644 sei-db/state_db/sc/flatkv/lthash/leaf_hasher.go rename sei-db/state_db/sc/flatkv/{ => sview}/atomic_store_view.go (62%) rename sei-db/state_db/sc/flatkv/{ => sview}/atomic_store_view_test.go (82%) rename sei-db/state_db/sc/flatkv/{ => sview}/store_view.go (58%) rename sei-db/state_db/sc/flatkv/{ => sview}/store_view_test.go (83%) create mode 100644 sei-db/state_db/sc/flatkv/sview/testutil_test.go diff --git a/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go b/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go index 29b5407424..5305287db1 100644 --- a/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go +++ b/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go @@ -369,7 +369,7 @@ func rollbackFlatKV(t *testing.T, dir string, cfg seidbconfig.StateCommitConfig, flatkvCfg.DataDir = utils.GetFlatKVPath(dir) stateWAL, err := flatkv.OpenStateWAL(&flatkvCfg) require.NoError(t, err) - evmStore, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL) + evmStore, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL, nil) require.NoError(t, err) err = evmStore.LoadLatest() require.NoError(t, err) @@ -397,7 +397,7 @@ func openFlatKVReadOnly(t *testing.T, dir string, cfg seidbconfig.StateCommitCon flatkvCfg.DataDir = utils.GetFlatKVPath(dir) stateWAL, err := flatkv.OpenStateWAL(&flatkvCfg) require.NoError(t, err) - store, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL) + store, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL, nil) require.NoError(t, err) ro, err := store.LoadVersionReadOnly(version) require.NoError(t, err) @@ -462,7 +462,7 @@ func collectFlatKVEVM(t *testing.T, dir string, cfg seidbconfig.StateCommitConfi stateWAL, err := flatkv.OpenStateWAL(&flatkvCfg) require.NoError(t, err) - s, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL) + s, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL, nil) require.NoError(t, err) defer func() { require.NoError(t, s.Close()) }() diff --git a/sei-cosmos/storev2/rootmulti/flatkv_migration_test.go b/sei-cosmos/storev2/rootmulti/flatkv_migration_test.go index eea3fb307f..196d81d886 100644 --- a/sei-cosmos/storev2/rootmulti/flatkv_migration_test.go +++ b/sei-cosmos/storev2/rootmulti/flatkv_migration_test.go @@ -38,7 +38,7 @@ func migrationVersionInFlatKV(t *testing.T, dir string, cfg seidbconfig.StateCom flatkvCfg.DataDir = utils.GetFlatKVPath(dir) stateWAL, err := flatkv.OpenStateWAL(&flatkvCfg) require.NoError(t, err) - s, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL) + s, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL, nil) require.NoError(t, err) err = s.LoadLatest() require.NoError(t, err) diff --git a/sei-cosmos/storev2/rootmulti/flatkv_workload_test.go b/sei-cosmos/storev2/rootmulti/flatkv_workload_test.go index 074d430d53..cab7a35e7a 100644 --- a/sei-cosmos/storev2/rootmulti/flatkv_workload_test.go +++ b/sei-cosmos/storev2/rootmulti/flatkv_workload_test.go @@ -57,9 +57,9 @@ func TestFlatKVFullScanLtHashVerification(t *testing.T) { require.NoError(t, flatkv.VerifyLtHash(ro), "full-scan LtHash verification failed") - roHash, _ := ro.RootHash() - require.Equal(t, expectedLatticeHash, roHash, - "flatkv RootHash should match evm_lattice in CommitInfo") + roHash := ro.PublishedHash().Global.Checksum() + require.Equal(t, expectedLatticeHash, roHash[:], + "flatkv's published root should match evm_lattice in CommitInfo") } // --------------------------------------------------------------------------- diff --git a/sei-cosmos/storev2/rootmulti/hashlog.go b/sei-cosmos/storev2/rootmulti/hashlog.go index 4d3a91a25e..be5bad3d9a 100644 --- a/sei-cosmos/storev2/rootmulti/hashlog.go +++ b/sei-cosmos/storev2/rootmulti/hashlog.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" + "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" ) @@ -55,11 +56,11 @@ func (rs *Store) SetNextResultHash(resultHash []byte) { // hashLogDir returns the directory hash log files are written to, defaulting to a "hash.log" directory // under the state-commit store's data directory (sibling of committer.db / receipt.db). The ".log" // suffix mirrors the data/ naming convention (.db, .wal); the files inside keep the .hlog format. -func (rs *Store) hashLogDir() string { - if rs.hashLoggerConfig.Directory != "" { - return rs.hashLoggerConfig.Directory +func hashLogDir(scDir string, cfg config.HashLoggerConfig) string { + if cfg.Directory != "" { + return cfg.Directory } - return filepath.Join(rs.scDir, "data", "hash.log") + return filepath.Join(scDir, "data", "hash.log") } // desiredHashCategories computes the full caller-reported category set for the current backend state: @@ -87,25 +88,24 @@ func (rs *Store) desiredHashCategories() map[string]struct{} { // column); syncHashCategories then registers the live categories, which the logger handles as runtime // column changes (each new column rotates to a fresh file, but the empty initial files are dropped and // their indexes reused, so the first file with data starts at index 0). -func (rs *Store) openHashLogger() error { - loggerVersion := rs.hashLoggerConfig.Version +func openHashLogger(scDir string, hashLoggerConfig config.HashLoggerConfig) (hashlog.HashLogger, error) { + loggerVersion := hashLoggerConfig.Version if loggerVersion == "" { loggerVersion = "unknown" } - cfg := hashlog.DefaultHashLoggerConfig(rs.hashLogDir(), loggerVersion) + cfg := hashlog.DefaultHashLoggerConfig(hashLogDir(scDir, hashLoggerConfig), loggerVersion) // Propagate the operator-configured retention tunables verbatim. A configured 0 must reach the logger // (where it disables that dimension); the old `if > 0` guards swallowed it. Defaults are applied at // config construction (config.DefaultHashLoggerConfig), so these always carry a meaningful value. - cfg.BlocksToRetain = rs.hashLoggerConfig.BlocksToRetain - cfg.TargetFileSize = rs.hashLoggerConfig.TargetFileSize - cfg.MaxDiskSize = rs.hashLoggerConfig.MaxDiskSize + cfg.BlocksToRetain = hashLoggerConfig.BlocksToRetain + cfg.TargetFileSize = hashLoggerConfig.TargetFileSize + cfg.MaxDiskSize = hashLoggerConfig.MaxDiskSize hl, err := hashlog.NewHashLogger(cfg) if err != nil { - return fmt.Errorf("failed to create hash logger: %w", err) + return nil, fmt.Errorf("failed to create hash logger: %w", err) } - rs.hashLogger = hl - return nil + return hl, nil } // syncHashCategories brings the logger's column set in line with the desired set for the current backend @@ -145,21 +145,12 @@ func (rs *Store) disableHashLogger() { } } -// recordBlockHashes reports every hash for the just-committed block at the given version. It opens the -// logger on first use and keeps its column set in sync with the live backends. On open failure it -// disables hash logging rather than disrupting commit. Must be called with rs.mtx held (from Commit). +// recordBlockHashes reports every hash for the just-committed block at the given version, keeping the +// logger's column set in sync with the live backends. Must be called with rs.mtx held (from Commit). func (rs *Store) recordBlockHashes(version int64) { if rs.hashLoggerDisabled { return } - - if rs.hashLogger == nil { - if err := rs.openHashLogger(); err != nil { - logger.Error("failed to open hash logger; disabling hash logging", "err", err) - rs.disableHashLogger() - return - } - } rs.syncHashCategories() blockNumber := uint64(version) //nolint:gosec // commit versions are non-negative diff --git a/sei-cosmos/storev2/rootmulti/store.go b/sei-cosmos/storev2/rootmulti/store.go index bc5e3e2f78..e3270e0001 100644 --- a/sei-cosmos/storev2/rootmulti/store.go +++ b/sei-cosmos/storev2/rootmulti/store.go @@ -143,8 +143,22 @@ func NewStore( if scConfig.HistoricalProofRateLimit > 0 { limiter = rate.NewLimiter(rate.Limit(scConfig.HistoricalProofRateLimit), burst) } + // Opened before the store it is handed to: flatKV reports its hashes from its own finalization + // goroutine, so it needs the logger at construction rather than per block. + hashLoggingOn := scConfig.HashLogger.Enable + var hashLogger hashlog.HashLogger + if hashLoggingOn { + hl, err := openHashLogger(scDir, scConfig.HashLogger) + if err != nil { + logger.Error("failed to open hash logger; disabling hash logging", "err", err) + hashLoggingOn = false + } else { + hashLogger = hl + } + } + ctx := context.Background() - scStore, err := composite.NewCompositeCommitStore(ctx, scDir, scConfig) + scStore, err := composite.NewCompositeCommitStore(ctx, scDir, scConfig, hashLogger) if err != nil { panic(err) } @@ -169,7 +183,8 @@ func NewStore( MaxBytes: scConfig.SubspaceMaxBytes, }, hashLoggerConfig: scConfig.HashLogger, - hashLoggerDisabled: !scConfig.HashLogger.Enable, + hashLogger: hashLogger, + hashLoggerDisabled: !hashLoggingOn, scDir: scDir, // No height has been flushed yet, and the first block is 1, so -1 cannot collide with it. flushedVersion: -1, diff --git a/sei-db/state_db/bench/wrappers/db_implementations.go b/sei-db/state_db/bench/wrappers/db_implementations.go index c17e2b3af2..b6d7f06c67 100644 --- a/sei-db/state_db/bench/wrappers/db_implementations.go +++ b/sei-db/state_db/bench/wrappers/db_implementations.go @@ -81,7 +81,7 @@ func newFlatKVCommitStore(ctx context.Context, dbDir string, config *flatkvConfi if err != nil { return nil, fmt.Errorf("failed to open FlatKV state WAL: %w", err) } - cs, err := flatkv.NewCommitStore(ctx, config, stateWAL) + cs, err := flatkv.NewCommitStore(ctx, config, stateWAL, nil) if err != nil { _ = stateWAL.Close() return nil, fmt.Errorf("failed to create FlatKV commit store: %w", err) @@ -101,7 +101,7 @@ func newCompositeCommitStore(ctx context.Context, dbDir string, writeMode sctype cfg.MemIAVLConfig.AsyncCommitBuffer = 10 cfg.MemIAVLConfig.SnapshotInterval = 100 - cs, err := composite.NewCompositeCommitStore(ctx, dbDir, cfg) + cs, err := composite.NewCompositeCommitStore(ctx, dbDir, cfg, nil) if err != nil { return nil, fmt.Errorf("failed to create composite commit store: %w", err) } diff --git a/sei-db/state_db/giga/live_state_store.go b/sei-db/state_db/giga/live_state_store.go index 674e03f57f..29e24d5437 100644 --- a/sei-db/state_db/giga/live_state_store.go +++ b/sei-db/state_db/giga/live_state_store.go @@ -7,7 +7,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/metrics" "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -122,18 +122,35 @@ type LiveStateStore interface { ascending bool, ) (dbm.Iterator, error) - // RootHash returns the 32-byte checksum of the committed LtHash and the height that checksum - // describes. Note: the checksum is the Blake3-256 digest of the underlying 2048-byte raw LtHash - // vector. - RootHash() ([]byte, int64) + // PublishedHash returns the most recent block hash the store has published: its height, its + // lattice hash root, and each database's root. Hashing is asynchronous, so on a committing store + // this lags the committed version; use FlushHashes to make it describe the version just committed. + // On a freshly loaded or read-only store it is the height that was loaded. + PublishedHash() *lthash.BlockHash + + // HashChan returns a channel producing the hash of each block: exactly one per block committed, in + // block order, with no gaps or duplicates, closed once the store stops hashing. + // + // The channel has finite depth, so failure to dequeue hashes for long enough blocks commit. Every + // deployment therefore needs a consumer. + HashChan() <-chan *lthash.BlockHash + + // FlushHashes blocks until the store has published a hash for every block committed so far, and + // recorded each one's metadata alongside the block it describes. + FlushHashes() error + + // CommitPendingBlock commits the block currently being applied, if any, so that it has a hash. A + // no-op on a store with no pending writes. + // + // A block that has not been committed has no hash, so a caller wanting one mid-block is asking for + // the block to be committed. This is that request, made explicitly. Post-Cosmos nothing asks for a + // hash mid-block and this goes away. + CommitPendingBlock() error // HashCategories returns the hash logger category names this store reports (the global root plus one // per data DB). The set is fixed. The caller registers these on the logger. HashCategories() []string - // RecordHashes reports this store's hashes (root + per-DB) for blockNumber. Call right after Commit. - RecordHashes(hl hashlog.HashLogger, blockNumber uint64) error - // Version returns the latest committed version. Version() int64 diff --git a/sei-db/state_db/giga/state_db_impl_test.go b/sei-db/state_db/giga/state_db_impl_test.go index 0aeb9f6f17..acbf7bd2d9 100644 --- a/sei-db/state_db/giga/state_db_impl_test.go +++ b/sei-db/state_db/giga/state_db_impl_test.go @@ -62,7 +62,7 @@ func (w *fakeStateWAL) SignalEndOfBlock() error { func newTestStateDB(t *testing.T) (giga.StateDB, *fakeStateWAL, *flatkv.CommitStore) { t.Helper() - liveStateDB, err := flatkv.NewCommitStore(t.Context(), config.DefaultTestConfig(t), nil) + liveStateDB, err := flatkv.NewCommitStore(t.Context(), config.DefaultTestConfig(t), nil, nil) require.NoError(t, err) require.NoError(t, liveStateDB.LoadLatest()) t.Cleanup(func() { require.NoError(t, liveStateDB.Close()) }) diff --git a/sei-db/state_db/sc/composite/commit_info_stored_test.go b/sei-db/state_db/sc/composite/commit_info_stored_test.go index 927d5a6510..5b6041c275 100644 --- a/sei-db/state_db/sc/composite/commit_info_stored_test.go +++ b/sei-db/state_db/sc/composite/commit_info_stored_test.go @@ -25,7 +25,7 @@ func storedInfoConfig() config.StateCommitConfig { func openStoredInfoStore(t *testing.T, dir string) *CompositeCommitStore { t.Helper() - cs, err := NewCompositeCommitStore(t.Context(), dir, storedInfoConfig()) + cs, err := NewCompositeCommitStore(t.Context(), dir, storedInfoConfig(), nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) require.NoError(t, cs.LoadLatest()) @@ -83,7 +83,7 @@ func TestLastCommitInfoUnmovedByWorkingHash(t *testing.T) { require.NoError(t, cs.ApplyChangeSets(storedInfoChangeset(2))) require.NotNil(t, cs.WorkingCommitInfo(cs.Version()+1)) - _, flatKVVersion := cs.flatKV.RootHash() + flatKVVersion := cs.flatKV.Version() require.Equal(t, committed+1, flatKVVersion, "flatkv should be a block ahead for this test to mean anything") after := cs.LastCommitInfo() diff --git a/sei-db/state_db/sc/composite/flatkv_hash.go b/sei-db/state_db/sc/composite/flatkv_hash.go new file mode 100644 index 0000000000..89ca9d8e67 --- /dev/null +++ b/sei-db/state_db/sc/composite/flatkv_hash.go @@ -0,0 +1,97 @@ +package composite + +import ( + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" +) + +// flatKVHashCache answers Cosmos's synchronous hash questions from flatKV's asynchronous hash stream. +// +// Cosmos asks three times per block — for the working hash during FinalizeBlock, again inside Commit, +// and once more for the last commit info — so only the first ask per height can miss. It is also this +// cache's reads that keep flatKV's hash channel drained: a channel nobody reads eventually blocks +// commit. +// +// This exists for Cosmos and dies with it. A caller that tolerates an asynchronous hash consumes the +// channel directly. +// +// Not safe for concurrent use. Cosmos's hash path is single-threaded, and the composite store's lock +// serializes the callers that reach it. +type flatKVHashCache struct { + // hashes holds the heights read off the stream but not yet asked for. + hashes map[int64][]byte + + // highest is the greatest height read so far, so that a height already passed is reported as gone + // rather than waited for. The stream only moves forwards. + highest int64 +} + +func newFlatKVHashCache() *flatKVHashCache { + return &flatKVHashCache{hashes: make(map[int64][]byte)} +} + +// hashAtVersion returns flatKV's lattice hash for the given height, committing the block first if it is +// still being applied. +func (c *flatKVHashCache) hashAtVersion(store giga.LiveStateStore, version int64) ([]byte, error) { + // A block that has not been committed has no hash, so asking for one is asking for the commit. + if err := store.CommitPendingBlock(); err != nil { + return nil, fmt.Errorf("seal flatkv block %d before hashing: %w", version, err) + } + + // A block none of whose writes reached flatKV leaves it a height behind. Its hash has not moved — + // an empty block does not shift the lattice — so the height it did reach is the right answer. + if committed := store.Version(); committed < version { + version = committed + } + return c.awaitHeight(store, version) +} + +// awaitHeight reports the hash for height, reading the stream until it arrives. +func (c *flatKVHashCache) awaitHeight(store giga.LiveStateStore, height int64) ([]byte, error) { + if hash, ok := c.hashes[height]; ok { + c.forget(height) + return hash, nil + } + + // A store publishes the hash of the height it loaded at before it hashes anything, so a historical + // read — open at version N, ask about N — is answered here without a block ever being hashed. + // Waiting on the stream for it would wait forever. + published := store.PublishedHash() + if published.BlockNumber == height { + checksum := published.Global.Checksum() + return checksum[:], nil + } + if height < published.BlockNumber || height <= c.highest { + return nil, fmt.Errorf("flatkv hash for block %d is no longer available: the stream has reached %d", + height, max(published.BlockNumber, c.highest)) + } + + for hash := range store.HashChan() { + checksum := hash.Global.Checksum() + c.hashes[hash.BlockNumber] = checksum[:] + if hash.BlockNumber > c.highest { + c.highest = hash.BlockNumber + } + if hash.BlockNumber >= height { + break + } + } + + result, ok := c.hashes[height] + if !ok { + return nil, fmt.Errorf("flatkv stopped producing hashes before block %d", height) + } + c.forget(height) + return result, nil +} + +// forget drops every height at or below the one just answered. The stream is one-directional, so +// nothing below can be asked for again. +func (c *flatKVHashCache) forget(height int64) { + for cached := range c.hashes { + if cached <= height { + delete(c.hashes, cached) + } + } +} diff --git a/sei-db/state_db/sc/composite/hashlog.go b/sei-db/state_db/sc/composite/hashlog.go index 06df0f4963..9332069ff7 100644 --- a/sei-db/state_db/sc/composite/hashlog.go +++ b/sei-db/state_db/sc/composite/hashlog.go @@ -20,19 +20,15 @@ func (cs *CompositeCommitStore) HashCategories() []string { return categories } -// RecordHashes reports every live backend's hashes for blockNumber. Call right after Commit. +// RecordHashes reports memIAVL's hashes for blockNumber. Call right after Commit. +// +// flatKV is absent because it reports its own from its finalization goroutine, under the height each +// hash describes rather than the height being committed. func (cs *CompositeCommitStore) RecordHashes(hl hashlog.HashLogger, blockNumber uint64) error { - if cs.memIAVL != nil { - if err := cs.memIAVL.RecordHashes(hl, blockNumber); err != nil { - return err - } - } - if cs.flatKV != nil { - if err := cs.flatKV.RecordHashes(hl, blockNumber); err != nil { - return err - } + if cs.memIAVL == nil { + return nil } - return nil + return cs.memIAVL.RecordHashes(hl, blockNumber) } // MemIAVLCommitInfo returns the raw memIAVL commit info (its per-store hashes), or nil when memIAVL is diff --git a/sei-db/state_db/sc/composite/random_test_framework_test.go b/sei-db/state_db/sc/composite/random_test_framework_test.go index ea6f3450c6..00b8744bc9 100644 --- a/sei-db/state_db/sc/composite/random_test_framework_test.go +++ b/sei-db/state_db/sc/composite/random_test_framework_test.go @@ -1503,7 +1503,7 @@ func applyTestMigrationBatchSize(t *testing.T, cs *CompositeCommitStore) { func openComposite(t *testing.T, dir string, cfg config.StateCommitConfig) *CompositeCommitStore { t.Helper() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize(keys.MemIAVLStoreKeys)) err = cs.LoadLatest() @@ -1550,7 +1550,7 @@ func stateSyncClone( require.NoError(t, exporter.Close()) dstDir := t.TempDir() - dst, err := NewCompositeCommitStore(t.Context(), dstDir, cfg) + dst, err := NewCompositeCommitStore(t.Context(), dstDir, cfg, nil) require.NoError(t, err) require.NoError(t, dst.Initialize(keys.MemIAVLStoreKeys)) // Open then close the writable handle so the importer takes over a @@ -1610,7 +1610,7 @@ func rollbackFlatKVIndependently(t *testing.T, dir string, cfg config.StateCommi flatkvCfg.DataDir = utils.GetFlatKVPath(dir) flatkvWAL, err := flatkv.OpenStateWAL(&flatkvCfg) require.NoError(t, err) - evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL) + evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL, nil) require.NoError(t, err) err = evmStore.LoadLatest() require.NoError(t, err) diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index 98396bb779..fe39e1e5c1 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -18,6 +18,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/migration" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" @@ -44,6 +45,11 @@ type CompositeCommitStore struct { // The flatKV backend. Will be nil if migration to flatKV has not yet started. flatKV giga.LiveStateStore + // flatKVHashes answers Cosmos's synchronous hash questions from flatKV's asynchronous stream, and + // is what keeps that stream drained. Built on first use, and dropped by a rollback, which is the one + // operation that moves heights backwards. + flatKVHashes *flatKVHashCache + // Manages routing of traffic between the memiavl and flatkv backends. // Built (and rebuilt) inside LoadVersion against the just-opened // backends so that lazily-eager constructors like @@ -69,6 +75,9 @@ type CompositeCommitStore struct { // config holds the store configuration config config.StateCommitConfig + // hashLogger is handed to every flatKV instance this store builds. Nil records nothing. + hashLogger hashlog.HashLogger + // currentWriteMode is the write mode actually driving routing and // mode-dependent gating. It equals the configured WriteMode unless the // configured mode is types.Auto, in which case it is derived from @@ -154,6 +163,8 @@ func NewCompositeCommitStore( ctx context.Context, homeDir string, cfg config.StateCommitConfig, + // Receives flatKV's per-block hashes. Nil records nothing. + hl hashlog.HashLogger, ) (*CompositeCommitStore, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid state commit config: %w", err) @@ -186,7 +197,7 @@ func NewCompositeCommitStore( if err != nil { return nil, fmt.Errorf("failed to open FlatKV state WAL: %w", err) } - fkv, err := flatkv.NewCommitStore(ctx, &cfg.FlatKVConfig, stateWAL) + fkv, err := flatkv.NewCommitStore(ctx, &cfg.FlatKVConfig, stateWAL, hl) if err != nil { _ = stateWAL.Close() return nil, fmt.Errorf("failed to create FlatKV commit store: %w", err) @@ -201,6 +212,7 @@ func NewCompositeCommitStore( config: cfg, currentWriteMode: cfg.WriteMode, ctx: ctx, + hashLogger: hl, }, nil } @@ -703,7 +715,7 @@ func (cs *CompositeCommitStore) newFlatKVInstance() (giga.LiveStateStore, error) if err != nil { return nil, fmt.Errorf("failed to open FlatKV state WAL: %w", err) } - created, err := flatkv.NewCommitStore(cs.ctx, &flatKVConfig, stateWAL) + created, err := flatkv.NewCommitStore(cs.ctx, &flatKVConfig, stateWAL, cs.hashLogger) if err != nil { _ = stateWAL.Close() return nil, fmt.Errorf("failed to create FlatKV commit store: %w", err) @@ -784,7 +796,7 @@ func (cs *CompositeCommitStore) ApplyUpgrades(upgrades []*proto.TreeNameUpgrade) // building. // // The height comes from the caller rather than from a backend. Taking a block's hash seals it on -// flatkv — see flatKVWorkingHash — so by the time this runs flatkv may already sit at version, and a +// 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) { @@ -1120,43 +1132,43 @@ func (cs *CompositeCommitStore) WorkingCommitInfo(version int64) *proto.CommitIn } if cs.shouldAppendLatticeHash() { - return cs.appendEvmLatticeHash(ci, cs.flatKVWorkingHash(version)) + return cs.appendEvmLatticeHash(ci, cs.mustLatticeHash(version)) } return ci } -// flatKVWorkingHash seals the pending block and returns its root hash. +// latticeHash returns flatKV's lattice hash for the height the chain is building, sealing that block +// first if it is still being applied. // -// Cosmos asks for a block's hash before it calls Commit, and FlatKV has a hash only once the block is -// sealed, so the seal happens here. The Commit that follows finds the block already committed and does -// nothing. +// Cosmos asks for a block's hash before it calls Commit, and flatKV has a hash only once the block is +// committed, so the commit happens here; the Commit that follows finds the block already committed and +// does nothing. Hashing is asynchronous, so the answer is then waited for on the hash stream — and +// these reads are also what keeps that stream drained. // // Sealing early requires that every one of the block's writes has already arrived. rootmulti's // GetWorkingHash flushes every buffered changeset into the store before reading the hash, and nothing -// writes to the multistore after that point. A changeset arriving later is not caught: the FlatKV +// writes to the multistore after that point. A changeset arriving later is not caught: the flatKV // writer stamps it at the sealed height plus one, which is a valid stamp for the next block, so it // silently becomes part of that block instead. // -// version is the height the caller is building. Sealing that height rather than one FlatKV derives for -// itself is what keeps FlatKV in step: a block whose writes all miss FlatKV leaves it with nothing -// staged, and a store left to its own devices would stay a height behind with a hash that happens to -// be right — an empty block does not move the LtHash — but describes the wrong block. -// // Post-Cosmos this goes away along with rootmulti: a single call will supply a block's writes and // commit them, and nothing will ask for a hash mid-block. -func (cs *CompositeCommitStore) flatKVWorkingHash(version int64) []byte { - if _, err := cs.flatKV.Commit(version); err != nil { - // Consensus-critical: nothing in the Cosmos hash path can carry an error, and a store that - // cannot commit cannot produce a trustworthy hash either. Returning a stale one would let the - // chain proceed on it. - panic(fmt.Sprintf("composite: failed to seal flatkv block %d before hashing: %v", version, err)) +func (cs *CompositeCommitStore) latticeHash(version int64) ([]byte, error) { + if cs.flatKVHashes == nil { + cs.flatKVHashes = newFlatKVHashCache() } + return cs.flatKVHashes.hashAtVersion(cs.flatKV, version) +} - hash, hashed := cs.flatKV.RootHash() - if hashed != version { - panic(fmt.Sprintf( - "composite: flatkv hashed block %d but the chain is building block %d", hashed, version)) +// mustLatticeHash is latticeHash for the Cosmos paths that cannot carry an error. +// +// Consensus-critical: a store that cannot produce a hash cannot produce a trustworthy one either, and +// returning a stale hash would let the chain proceed on it. +func (cs *CompositeCommitStore) mustLatticeHash(version int64) []byte { + hash, err := cs.latticeHash(version) + if err != nil { + panic(fmt.Sprintf("composite: failed to obtain flatkv hash for block %d: %v", version, err)) } return hash } @@ -1183,8 +1195,7 @@ func (cs *CompositeCommitStore) refreshLastCommitInfo() { } if cs.shouldAppendLatticeHash() { - hash, _ := cs.flatKV.RootHash() - ci = cs.appendEvmLatticeHash(ci, hash) + ci = cs.appendEvmLatticeHash(ci, cs.mustLatticeHash(ci.Version)) } // Cloned because this is held until the next refresh, and memiavl's hashes point into a snapshot // mapping it is free to drop before then. @@ -1342,6 +1353,10 @@ func (cs *CompositeCommitStore) Rollback(targetVersion int64) error { cs.latticeAppendLatched.Store(false) cs.memiavlHashExcluded.Store(false) + // The hash cache tracks a one-directional stream, so a rollback — the one operation that moves + // heights backwards — has to leave it empty rather than holding heights that no longer exist. + cs.flatKVHashes = nil + // Rollback is offline (no commit cycle in flight); clear the per-block // migration-advance gate defensively. cs.migrationAdvancedThisCommit = false diff --git a/sei-db/state_db/sc/composite/store_auto_test.go b/sei-db/state_db/sc/composite/store_auto_test.go index 5526c98d95..26545b2eed 100644 --- a/sei-db/state_db/sc/composite/store_auto_test.go +++ b/sei-db/state_db/sc/composite/store_auto_test.go @@ -29,7 +29,7 @@ func autoConfig() config.StateCommitConfig { // openAutoStore opens (or reopens) a composite store at dir in Auto mode. func openAutoStore(t *testing.T, dir string, batch int) *CompositeCommitStore { t.Helper() - cs, err := NewCompositeCommitStore(t.Context(), dir, autoConfig()) + cs, err := NewCompositeCommitStore(t.Context(), dir, autoConfig(), nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(batch)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -232,7 +232,7 @@ func TestComposite_SetWriteModeRequiresAutoConfig(t *testing.T) { cfg.WriteMode = types.MemiavlOnly cfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -244,7 +244,7 @@ func TestComposite_SetWriteModeRequiresAutoConfig(t *testing.T) { } func TestComposite_SetWriteModeBeforeLoadVersion(t *testing.T) { - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), autoConfig()) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), autoConfig(), nil) require.NoError(t, err) require.Error(t, cs.SetWriteMode(types.MigrateEVM)) } @@ -305,7 +305,7 @@ func autoExportConfig() config.StateCommitConfig { // openAutoStoreWithConfig mirrors openAutoStore for a caller-supplied config. func openAutoStoreWithConfig(t *testing.T, dir string, cfg config.StateCommitConfig, batch int) *CompositeCommitStore { t.Helper() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(batch)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -432,7 +432,7 @@ func TestComposite_ImporterRejectsFlatKVSectionOnMemiavlOnly(t *testing.T) { cfg.WriteMode = types.MemiavlOnly cfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) err = cs.LoadLatest() @@ -630,7 +630,7 @@ func TestComposite_Auto_ReadOnlyPreFlatKVEraHeightNowFails(t *testing.T) { } func TestComposite_Auto_InitializeRejectsNonCanonicalStores(t *testing.T) { - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), autoConfig()) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), autoConfig(), nil) require.NoError(t, err) require.Error(t, cs.Initialize([]string{"not-a-canonical-store"}), "Auto must enforce canonical store names since the mode may become mixed") diff --git a/sei-db/state_db/sc/composite/store_init_repair_test.go b/sei-db/state_db/sc/composite/store_init_repair_test.go index 827ce45a8a..5de6b984ee 100644 --- a/sei-db/state_db/sc/composite/store_init_repair_test.go +++ b/sei-db/state_db/sc/composite/store_init_repair_test.go @@ -51,7 +51,7 @@ func TestAuto_TornFlatKVSeedRecoversAndReseeds(t *testing.T) { initializeUnseededFlatKV(t, cfg, flatkvDir) stampSeedRecords(t, flatkvDir, 99, "account", "code") - reopened, err := NewCompositeCommitStore(t.Context(), dir, cfg) + reopened, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) defer func() { _ = reopened.Close() }() require.NoError(t, reopened.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -89,7 +89,7 @@ func initializeUnseededFlatKV(t *testing.T, cfg config.StateCommitConfig, flatkv wal, err := flatkv.OpenStateWAL(&flatkvCfg) require.NoError(t, err) - store, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, wal) + store, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, wal, nil) require.NoError(t, err) require.NoError(t, store.LoadLatest()) require.Equal(t, int64(0), store.Version()) diff --git a/sei-db/state_db/sc/composite/store_load_test.go b/sei-db/state_db/sc/composite/store_load_test.go index bb5a3e944d..0f0a41a2f0 100644 --- a/sei-db/state_db/sc/composite/store_load_test.go +++ b/sei-db/state_db/sc/composite/store_load_test.go @@ -38,7 +38,7 @@ func TestCorruptFlatKVDirFailsOnLoad(t *testing.T) { require.NoError(t, os.RemoveAll(miscDir)) require.NoError(t, os.WriteFile(miscDir, []byte("not a pebble db"), 0o600)) - reopened, err := NewCompositeCommitStore(t.Context(), dir, autoExportConfig()) + reopened, err := NewCompositeCommitStore(t.Context(), dir, autoExportConfig(), nil) require.NoError(t, err, "construction does not open the DBs, so it cannot detect this") defer func() { _ = reopened.Close() }() @@ -67,7 +67,7 @@ func TestDerivedStoreRefusesLoads(t *testing.T) { require.Nil(t, cs.flatKV, "fixture precondition: flatkv must not be materialized") require.NoError(t, cs.Close()) - fresh, err := NewCompositeCommitStore(t.Context(), dir, cfg) + fresh, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) defer func() { _ = fresh.Close() }() require.NoError(t, fresh.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) diff --git a/sei-db/state_db/sc/composite/store_migration_test.go b/sei-db/state_db/sc/composite/store_migration_test.go index a07ca32198..22d10e407a 100644 --- a/sei-db/state_db/sc/composite/store_migration_test.go +++ b/sei-db/state_db/sc/composite/store_migration_test.go @@ -239,7 +239,7 @@ func driveMigrationWorkload( // commit and the post-reopen version checks become flaky. memCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -263,7 +263,7 @@ func driveMigrationWorkload( migCfg.WriteMode = types.MigrateEVM migCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err = NewCompositeCommitStore(t.Context(), dir, migCfg) + cs, err = NewCompositeCommitStore(t.Context(), dir, migCfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(keysToMigratePerBlock)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -291,7 +291,7 @@ func reopenInMigrateEVM(t *testing.T, dir string, batch int) *CompositeCommitSto cfg.WriteMode = types.MigrateEVM cfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(batch)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -308,7 +308,7 @@ func TestComposite_MigrateEVM_SecondNonEmptyFlushDoesNotAdvanceMigration(t *test memCfg := config.DefaultStateCommitConfig() memCfg.WriteMode = types.MemiavlOnly memCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -450,7 +450,7 @@ func TestComposite_MigrateEVM_PruneZeroStorageSlotsDuringMigration(t *testing.T) memCfg := config.DefaultStateCommitConfig() memCfg.WriteMode = types.MemiavlOnly memCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -508,7 +508,7 @@ func TestComposite_MigrateEVM_PruneZeroStorageSlotsDuringMigration(t *testing.T) finalCfg := evmMigratedConfig() finalCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err = NewCompositeCommitStore(t.Context(), dir, finalCfg) + cs, err = NewCompositeCommitStore(t.Context(), dir, finalCfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -702,7 +702,7 @@ func TestComposite_MigrateEVM_CrashAndResume(t *testing.T) { memCfg := config.DefaultStateCommitConfig() memCfg.WriteMode = types.MemiavlOnly memCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -791,7 +791,7 @@ func TestComposite_MigrateEVM_DeterministicAcrossTwoStores(t *testing.T) { memCfg := config.DefaultStateCommitConfig() memCfg.WriteMode = types.MemiavlOnly memCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -859,7 +859,7 @@ func TestComposite_MigrateEVM_PostCompletionFlipToEVMMigrated(t *testing.T) { // --- Mode flip: reopen as EVMMigrated. --- finalCfg := evmMigratedConfig() finalCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, finalCfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, finalCfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -944,7 +944,7 @@ func openCompositeForRollback( cfg.FlatKVConfig.SnapshotInterval = snap.flatkvInterval cfg.FlatKVConfig.SnapshotKeepRecent = 5 - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(batch)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) diff --git a/sei-db/state_db/sc/composite/store_test.go b/sei-db/state_db/sc/composite/store_test.go index 6e128945af..6dad650c49 100644 --- a/sei-db/state_db/sc/composite/store_test.go +++ b/sei-db/state_db/sc/composite/store_test.go @@ -18,7 +18,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/migration" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" @@ -52,24 +52,30 @@ func (f *failingEVMStore) RawGlobalIterator() (dbm.Iterator, error) { return nil func (f *failingEVMStore) Iterator(string, []byte, []byte, bool) (dbm.Iterator, error) { return nil, nil } -func (f *failingEVMStore) RootHash() ([]byte, int64) { return nil, 0 } -func (f *failingEVMStore) Version() int64 { return 0 } -func (f *failingEVMStore) PendingVersion() int64 { return 0 } -func (f *failingEVMStore) GetLatestVersion() (int64, error) { return 0, nil } -func (f *failingEVMStore) Rollback(int64) error { return nil } -func (f *failingEVMStore) Exporter(int64) (types.Exporter, error) { return nil, nil } -func (f *failingEVMStore) Importer(int64) (types.Importer, error) { return nil, nil } -func (f *failingEVMStore) GetPhaseTimer() *metrics.PhaseTimer { return nil } -func (f *failingEVMStore) HashCategories() []string { return nil } -func (f *failingEVMStore) RecordHashes(hashlog.HashLogger, uint64) error { return nil } -func (f *failingEVMStore) CleanupOrphanedReadOnlyDirs() error { return nil } -func (f *failingEVMStore) Close() error { return nil } - -// flatKVRootHash returns the committed root hash of the store's flatkv backend, discarding the height -// it describes. Tests that care about the height assert on it directly rather than through this. +func (f *failingEVMStore) PublishedHash() *lthash.BlockHash { return lthash.NewBlockHash(nil) } +func (f *failingEVMStore) HashChan() <-chan *lthash.BlockHash { return nil } +func (f *failingEVMStore) FlushHashes() error { return nil } +func (f *failingEVMStore) CommitPendingBlock() error { return nil } +func (f *failingEVMStore) Version() int64 { return 0 } +func (f *failingEVMStore) PendingVersion() int64 { return 0 } +func (f *failingEVMStore) GetLatestVersion() (int64, error) { return 0, nil } +func (f *failingEVMStore) Rollback(int64) error { return nil } +func (f *failingEVMStore) Exporter(int64) (types.Exporter, error) { return nil, nil } +func (f *failingEVMStore) Importer(int64) (types.Importer, error) { return nil, nil } +func (f *failingEVMStore) GetPhaseTimer() *metrics.PhaseTimer { return nil } +func (f *failingEVMStore) HashCategories() []string { return nil } +func (f *failingEVMStore) CleanupOrphanedReadOnlyDirs() error { return nil } +func (f *failingEVMStore) Close() error { return nil } + +// flatKVRootHash returns the root hash of the store's flatkv backend once hashing has caught up with +// what was committed. Hashing is asynchronous, so that barrier is what stops an assertion racing the +// pipeline. Tests that care about the height assert on it directly rather than through this. func flatKVRootHash(cs *CompositeCommitStore) []byte { - hash, _ := cs.flatKV.RootHash() - return hash + if err := cs.flatKV.FlushHashes(); err != nil { + panic(fmt.Sprintf("composite: flush flatkv hashes: %v", err)) + } + checksum := cs.flatKV.PublishedHash().Global.Checksum() + return checksum[:] } func padLeft32(val ...byte) []byte { @@ -82,7 +88,7 @@ func TestCompositeStoreBasicOperations(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -132,7 +138,7 @@ func TestEmptyChangesets(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -154,7 +160,7 @@ func TestLoadVersionCopyExisting(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -192,7 +198,7 @@ func TestWorkingAndLastCommitInfo(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -266,7 +272,7 @@ func TestLatticeHashCommitInfo(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = tt.writeMode - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -287,7 +293,7 @@ func TestLatticeHashCommitInfo(t *testing.T) { // no hash to compare against. var expectedEvmHash []byte if tt.expectLattice { - expectedEvmHash, _ = cs.flatKV.RootHash() + expectedEvmHash = flatKVRootHash(cs) } cosmosCount := len(expectedCosmos.StoreInfos) @@ -324,7 +330,7 @@ func TestLatticeHashCommitInfo(t *testing.T) { expectedCosmosLast := cs.memIAVL.LastCommitInfo() var expectedEvmCommitted []byte if tt.expectLattice { - expectedEvmCommitted, _ = cs.flatKV.RootHash() + expectedEvmCommitted = flatKVRootHash(cs) require.Equal(t, expectedEvmHash, expectedEvmCommitted) } @@ -418,7 +424,7 @@ func TestMemiavlOnlyToMigrateEVMPreservesLastCommitInfoBeforeFirstCommit(t *test cosmosCfg := config.DefaultStateCommitConfig() cosmosCfg.WriteMode = types.MemiavlOnly - cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg) + cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg, nil) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -455,7 +461,7 @@ func TestMemiavlOnlyToMigrateEVMPreservesLastCommitInfoBeforeFirstCommit(t *test // height. migrateCfg := config.DefaultStateCommitConfig() migrateCfg.WriteMode = types.MigrateEVM - cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(100)) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -499,7 +505,7 @@ func TestMemiavlOnlyToMigrateEVMPreservesLastCommitInfoBeforeFirstCommit(t *test func TestMigrateEVMGenesisPreFirstCommitOmitsLatticeHash(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -535,7 +541,7 @@ func TestMigrateEVMGenesisPreFirstCommitOmitsLatticeHash(t *testing.T) { func TestMigrateEVMIncludesLatticeHashAfterFirstCommit(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -583,7 +589,7 @@ func TestMigrateEVMLatticeRemainsAfterRestartPostMigrationCompletion(t *testing. // iterator's first batch reports MigrationBoundaryComplete and the // manager atomically deletes the boundary key and writes the version // key on the same commit. - cs1, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs1, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs1.SetMigrationBatchSize(1000)) require.NoError(t, cs1.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -616,7 +622,7 @@ func TestMigrateEVMLatticeRemainsAfterRestartPostMigrationCompletion(t *testing. // only inspects MigrationBoundaryKey would treat this state as // NotStarted and wrongly suppress the lattice — silently rewriting // the AppHash that Tendermint already accepted at this height. - cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs2.LoadLatest() @@ -632,7 +638,7 @@ func TestRollback(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -669,7 +675,7 @@ func TestGetVersions(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -693,7 +699,7 @@ func TestGetVersions(t *testing.T) { } require.NoError(t, cs.Close()) - cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey})) @@ -716,7 +722,7 @@ func TestGetLatestVersionMemiavlOnly(t *testing.T) { // CompositeCommitStore.GetLatestVersion for the full rationale. cfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) err = cs.LoadLatest() @@ -747,7 +753,7 @@ func TestGetLatestVersionFlatKVOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) err = cs.LoadLatest() require.NoError(t, err) @@ -781,7 +787,7 @@ func TestGetLatestVersionBothBackendsAligned(t *testing.T) { // CompositeCommitStore.GetLatestVersion for the full rationale. cfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -820,7 +826,7 @@ func TestReadOnlyLoadVersionFailsLoudWhenFlatKVUnavailable(t *testing.T) { // Need flatkv to be allocated and exercised by LoadVersion; // MemiavlOnly would not touch the flatkv path at all. cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -860,7 +866,7 @@ func TestLoadVersionFlatKVOnlyReadWrite(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.Nil(t, cs.memIAVL, "FlatKVOnly must not allocate memIAVL") require.NotNil(t, cs.flatKV, "FlatKVOnly must allocate flatKV") @@ -892,7 +898,7 @@ func TestLoadVersionFlatKVOnlyReadOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) err = cs.LoadLatest() require.NoError(t, err) @@ -930,7 +936,7 @@ func TestLoadVersionFlatKVOnlyReadOnly(t *testing.T) { func TestLoadVersionRebuildsRouterOnReload(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -962,7 +968,7 @@ func TestLoadVersionRebuildsRouterOnReload(t *testing.T) { func TestLoadVersionDoesNotMountMigrationStoreInMigrationMode(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -985,7 +991,7 @@ func TestLoadVersionDoesNotMountMigrationStoreInMemiavlOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) err = cs.LoadLatest() @@ -1063,7 +1069,7 @@ func TestExportImportEVMMigrated(t *testing.T) { // --- Source store: write cosmos + EVM data --- srcDir := t.TempDir() - src, err := NewCompositeCommitStore(t.Context(), srcDir, cfg) + src, err := NewCompositeCommitStore(t.Context(), srcDir, cfg, nil) require.NoError(t, err) require.NoError(t, src.Initialize([]string{"bank", keys.EVMStoreKey})) err = src.LoadLatest() @@ -1112,7 +1118,7 @@ func TestExportImportEVMMigrated(t *testing.T) { // --- Destination store: import --- dstDir := t.TempDir() - dst, err := NewCompositeCommitStore(t.Context(), dstDir, cfg) + dst, err := NewCompositeCommitStore(t.Context(), dstDir, cfg, nil) require.NoError(t, err) require.NoError(t, dst.Initialize([]string{"bank", keys.EVMStoreKey})) err = dst.LoadLatest() @@ -1152,7 +1158,7 @@ func TestExportMemiavlOnlyHasNoFlatKVModule(t *testing.T) { cfg.MemIAVLConfig.AsyncCommitBuffer = 0 dir := t.TempDir() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{"bank"})) err = cs.LoadLatest() @@ -1190,7 +1196,7 @@ func TestExporterFailsLoudOnFlatKVLoadFailure(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.MemIAVLConfig.AsyncCommitBuffer = 0 cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -1280,7 +1286,7 @@ func TestReconcileVersionsAfterCrash(t *testing.T) { cfg := evmMigratedConfig() dir := t.TempDir() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1321,7 +1327,7 @@ func TestReconcileVersionsAfterCrash(t *testing.T) { flatkvCfg.DataDir = utils.GetFlatKVPath(dir) flatkvWAL, err := flatkv.OpenStateWAL(&flatkvCfg) require.NoError(t, err) - evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL) + evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL, nil) require.NoError(t, err) err = evmStore.LoadLatest() require.NoError(t, err) @@ -1333,7 +1339,7 @@ func TestReconcileVersionsAfterCrash(t *testing.T) { // Reopen the composite store — LoadVersion(0) should detect the // mismatch and reconcile both backends to version 2. - cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs2.LoadLatest() @@ -1359,7 +1365,7 @@ func TestReconcileVersionsThenContinueCommitting(t *testing.T) { cfg := evmMigratedConfig() dir := t.TempDir() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1385,7 +1391,7 @@ func TestReconcileVersionsThenContinueCommitting(t *testing.T) { flatkvCfg.DataDir = utils.GetFlatKVPath(dir) flatkvWAL, err := flatkv.OpenStateWAL(&flatkvCfg) require.NoError(t, err) - evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL) + evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL, nil) require.NoError(t, err) err = evmStore.LoadLatest() require.NoError(t, err) @@ -1393,7 +1399,7 @@ func TestReconcileVersionsThenContinueCommitting(t *testing.T) { require.NoError(t, evmStore.Close()) // Reopen — reconciliation should bring both to version 2. - cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs2.LoadLatest() @@ -1424,7 +1430,7 @@ func TestReconcileVersionsThenContinueCommitting(t *testing.T) { // Reopen a third time to verify the post-reconciliation commits are durable // and both backends agree on version 5. - cs3, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs3, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs3.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs3.LoadLatest() @@ -1455,7 +1461,7 @@ func setupComposite(t *testing.T, writeMode types.WriteMode) *CompositeCommitSto cfg := config.DefaultStateCommitConfig() cfg.WriteMode = writeMode - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.StakingStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1707,7 +1713,7 @@ func TestCompositeEVMMigratedEVMReadsAreVisible(t *testing.T) { dir := t.TempDir() cfg := evmMigratedConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1783,7 +1789,7 @@ func TestReconcileVersionsCosmosAheadByMultiple(t *testing.T) { cfg := evmMigratedConfig() dir := t.TempDir() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1819,7 +1825,7 @@ func TestReconcileVersionsCosmosAheadByMultiple(t *testing.T) { flatkvCfg.DataDir = utils.GetFlatKVPath(dir) flatkvWAL, err := flatkv.OpenStateWAL(&flatkvCfg) require.NoError(t, err) - evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL) + evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL, nil) require.NoError(t, err) err = evmStore.LoadLatest() require.NoError(t, err) @@ -1827,7 +1833,7 @@ func TestReconcileVersionsCosmosAheadByMultiple(t *testing.T) { require.NoError(t, err) require.NoError(t, evmStore.Close()) - cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs2.LoadLatest() @@ -1857,7 +1863,7 @@ func TestMigrationEntrySeedingMemiavlToMigrateEVM(t *testing.T) { cosmosCfg := config.DefaultStateCommitConfig() cosmosCfg.WriteMode = types.MemiavlOnly - cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg) + cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg, nil) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -1884,7 +1890,7 @@ func TestMigrationEntrySeedingMemiavlToMigrateEVM(t *testing.T) { // version 100 so the very next commit produces version 101 on both. migrateCfg := config.DefaultStateCommitConfig() migrateCfg.WriteMode = types.MigrateEVM - cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(100)) require.NoError(t, cs2.Initialize([]string{"bank", keys.EVMStoreKey})) @@ -1926,7 +1932,7 @@ func TestMigrateEVMReopenPreservesPreFlipLastCommitInfo(t *testing.T) { memCfg.WriteMode = types.MemiavlOnly memCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs1, err := NewCompositeCommitStore(t.Context(), dir, memCfg) + cs1, err := NewCompositeCommitStore(t.Context(), dir, memCfg, nil) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -1956,7 +1962,7 @@ func TestMigrateEVMReopenPreservesPreFlipLastCommitInfo(t *testing.T) { migrateCfg.WriteMode = types.MigrateEVM migrateCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(1)) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -2002,7 +2008,7 @@ func TestMigrationEntrySeedingIsIdempotentAcrossRestarts(t *testing.T) { cosmosCfg := config.DefaultStateCommitConfig() cosmosCfg.WriteMode = types.MemiavlOnly - cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg) + cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg, nil) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -2020,7 +2026,7 @@ func TestMigrationEntrySeedingIsIdempotentAcrossRestarts(t *testing.T) { migrateCfg := config.DefaultStateCommitConfig() migrateCfg.WriteMode = types.MigrateEVM - cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(100)) require.NoError(t, cs2.Initialize([]string{"bank", keys.EVMStoreKey})) @@ -2032,7 +2038,7 @@ func TestMigrationEntrySeedingIsIdempotentAcrossRestarts(t *testing.T) { require.Equal(t, int64(6), cs2.Version()) require.NoError(t, cs2.Close()) - cs3, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) + cs3, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) require.NoError(t, err) require.NoError(t, cs3.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs3.LoadLatest() @@ -2049,7 +2055,7 @@ func TestInitializeIsNoOpInFlatKVOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.Nil(t, cs.memIAVL, "FlatKVOnly must not allocate a memIAVL backend") require.NotPanics(t, func() { @@ -2064,7 +2070,7 @@ func TestSetInitialVersionMemiavlOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs.LoadLatest() @@ -2090,7 +2096,7 @@ func TestSetInitialVersionMemiavlOnly(t *testing.T) { func TestSetInitialVersionDelegatesToBothBackends(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) @@ -2129,7 +2135,7 @@ func TestSetInitialVersionDelegatesToBothBackends(t *testing.T) { func TestSetInitialVersionRetryIsIdempotent(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) @@ -2156,7 +2162,7 @@ func TestInitializeRejectsUnknownStoreNames(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2180,7 +2186,7 @@ func TestInitializeAcceptsUnknownStoreNamesInMemiavlOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2215,7 +2221,7 @@ func TestInitializeAcceptsUnknownStoreNamesInFlatKVOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) require.Nil(t, cs.memIAVL, "FlatKVOnly must not allocate a memIAVL backend") defer func() { _ = cs.Close() }() @@ -2246,7 +2252,7 @@ func TestInitializeAcceptsAllMemIAVLStoreKeys(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2265,7 +2271,7 @@ func TestCopyProducesUsableSnapshot(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2336,7 +2342,7 @@ func TestInitializeRejectsMigrationStoreName(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = tc.mode - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2467,7 +2473,7 @@ func TestGetChildStoreByName_NameValidation(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = tc.mode - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2518,7 +2524,7 @@ func TestLoadVersionReadOnlyDuringMigrateEVMTransition(t *testing.T) { v0Cfg := config.DefaultStateCommitConfig() v0Cfg.WriteMode = types.MemiavlOnly - cs1, err := NewCompositeCommitStore(t.Context(), dir, v0Cfg) + cs1, err := NewCompositeCommitStore(t.Context(), dir, v0Cfg, nil) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -2541,7 +2547,7 @@ func TestLoadVersionReadOnlyDuringMigrateEVMTransition(t *testing.T) { // flagged. migrateCfg := config.DefaultStateCommitConfig() migrateCfg.WriteMode = types.MigrateEVM - cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(100)) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) diff --git a/sei-db/state_db/sc/flatkv/config/config.go b/sei-db/state_db/sc/flatkv/config/config.go index 2aeacbffb9..18735d1040 100644 --- a/sei-db/state_db/sc/flatkv/config/config.go +++ b/sei-db/state_db/sc/flatkv/config/config.go @@ -6,6 +6,8 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/unit" "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb" "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) // Config defines configuration for the FlatKV (EVM) commit store. @@ -108,20 +110,31 @@ type Config struct { // 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. + HashEngineConfig lthash.Config + + // FinalizationQueueSize is how many sealed blocks may be waiting to have their hashes recorded + // before Commit blocks. + // + // A block waiting here holds a reservation on its own views, and a held reservation stops its + // database's flush frontier, so this bounds how much of the pipeline stays resident. + FinalizationQueueSize uint32 `mapstructure:"finalization-queue-size"` + + // HashChanSize is the depth of the channel block hashes are published on. + // + // Headroom for a consumer that reads later than it commits, not a memory bound: a block's views are + // released before its hash is published. A consumer that stops reading entirely stalls commit. + HashChanSize uint32 `mapstructure:"hash-chan-size"` + // LtHashThreadsPerCore * runtime.NumCPU() (clamped to at least 1). LtHash // computation is CPU-bound, so ~1 worker per core is a sensible default. LtHashThreadsPerCore float64 } -// MetaKeyPrefix is the key namespace FlatKV reserves for per-database metadata, and which each -// view manager owns: Finalize writes land under it and iteration filters it out. It matches -// ktype.MetaKeyPrefixBytes, restated here because ktype imports this package's siblings. -const MetaKeyPrefix = "_meta/" - // defaultStoreConfig returns the view manager defaults for one database, named for the database's // directory so metrics and per-database hash bookkeeping can tell the stores apart. func defaultStoreConfig(name string) view.ViewManagerConfig { - return *view.DefaultViewManagerConfig(name, MetaKeyPrefix) + return *view.DefaultViewManagerConfig(name, ktype.MetaKeyPrefix) } // DefaultConfig returns Config with safe default values. @@ -147,6 +160,9 @@ func DefaultConfig() *Config { MiscPoolThreadsPerCore: 4.0, MiscConstantThreadCount: 0, LtHashThreadsPerCore: 1.0, + HashEngineConfig: *lthash.DefaultConfig(), + FinalizationQueueSize: 64, + HashChanSize: 1024, } cfg.AccountStoreConfig.MaxSize = unit.GB diff --git a/sei-db/state_db/sc/flatkv/config/flatkv_test_config.go b/sei-db/state_db/sc/flatkv/config/flatkv_test_config.go index 20983577c0..b7e66f0312 100644 --- a/sei-db/state_db/sc/flatkv/config/flatkv_test_config.go +++ b/sei-db/state_db/sc/flatkv/config/flatkv_test_config.go @@ -7,6 +7,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/unit" "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb" "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) func smallTestPebbleConfig() pebbledb.PebbleDBConfig { @@ -42,5 +43,8 @@ func DefaultTestConfig(t *testing.T) *Config { ReaderPoolQueueSize: 1024, MiscPoolThreadsPerCore: 4.0, LtHashThreadsPerCore: 1.0, + HashEngineConfig: *lthash.DefaultConfig(), + FinalizationQueueSize: 64, + HashChanSize: 1024, } } diff --git a/sei-db/state_db/sc/flatkv/finalization_manager.go b/sei-db/state_db/sc/flatkv/finalization_manager.go new file mode 100644 index 0000000000..3b71f72a71 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/finalization_manager.go @@ -0,0 +1,357 @@ +package flatkv + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" +) + +// FinalizationManager records each block's lattice hashes onto that block's own views, in the same +// atomic batch as the data they describe, off the execution goroutine. +// +// Sealed blocks go in through Offer(), which reserves the view and releases it once the block's +// metadata has been written, and hashes come out of HashChan(), one per block in block +// order and only once that write has happened. PublishedHash() answers with the most recent. +// +// There are no recoverable errors. The first failure is latched and stops the manager, and every later +// call reports it. +type FinalizationManager struct { + // hashes is the engine's stream. This manager is its sole consumer, and must drain it to completion + // even while failing, or the engine blocks forever trying to publish. + engineHashChan <-chan *lthash.BlockHash + + // queue carries sealed blocks and control messages, in block order. + messageChan chan any + + // published is the outbound stream, one entry per block, put there only once the block's metadata is + // on its way to disk. + publishedHashChan chan *lthash.BlockHash + + // latest is the most recently finalized block's hash, for a reader that wants the current answer + // rather than the stream. Single writer, so a plain atomic swap is enough. + latest atomic.Pointer[lthash.BlockHash] + + // ctx is cancelled when the manager is stopping, to release a publish that nobody is reading. + ctx context.Context + + // cancel stops the goroutine. Called by Close, and by the store's own context. + cancel context.CancelFunc + + // streamClosed guards publishedHashChan, which is closed either when a block fails or at teardown, + // whichever comes first. + streamClosed sync.Once + + // wg tracks the goroutine, so that Close can wait for it to return. + wg sync.WaitGroup + + // fatalErr latches the first failure. Nil until something fails. + fatalErr atomic.Pointer[error] + + // hashLogger receives each block's hashes as it is finalized. Never nil. + hashLogger hashlog.HashLogger + + // reportingFailed stops reporting after the logger first rejects a hash, so a logger closed + // underneath this manager costs one log line rather than one per block. + reportingFailed bool +} + +// newFinalizationManager starts a manager consuming the hash engine's stream. +func newFinalizationManager( + // Cancelling this stops the manager, exactly as Close does. + parent context.Context, + // The engine's output. This manager is its only reader. + engineHashChan <-chan *lthash.BlockHash, + // The hash of the height the store loaded at, so that a reader has an answer before the first block + // is finalized. + loaded *lthash.BlockHash, + // How many offered blocks may wait to be finalized before Offer blocks. + queueSize uint32, + // Depth of the channel finalized hashes are published on. + chanSize uint32, + // Receives each block's hashes as it is finalized. + hl hashlog.HashLogger, +) *FinalizationManager { + ctx, cancel := context.WithCancel(parent) + fm := &FinalizationManager{ + engineHashChan: engineHashChan, + messageChan: make(chan any, max(queueSize, 1)), + publishedHashChan: make(chan *lthash.BlockHash, max(chanSize, 1)), + ctx: ctx, + cancel: cancel, + hashLogger: hl, + } + fm.latest.Store(loaded) + fm.wg.Add(1) + go fm.run() + return fm +} + +// Offer hands a sealed block to the manager, to be finalized once its hash arrives. +// +// The manager takes its own reservation on the view and releases it once the block's metadata has +// been written. The caller keeps its own. +// +// Blocks while the manager is too far behind. +func (fm *FinalizationManager) Offer( + blockNumber int64, + // The block's sealed view, which this block's hashes are recorded onto. + blockView *sview.StoreView, + // The replay skip list: the height each database had already reached when replay started, or nil + // outside replay. + alreadyHave map[string]int64, +) error { + if err := blockView.Reserve(); err != nil { + return fmt.Errorf("reserve block %d for finalization: %w", blockNumber, err) + } + pending := &pendingFinalization{ + blockNumber: blockNumber, + blockView: blockView, + alreadyHave: alreadyHave, + } + if err := fm.enqueue(pending); err != nil { + return errors.Join( + fmt.Errorf("offer block %d for finalization: %w", blockNumber, err), + pending.release()) + } + return nil +} + +// PublishedHash returns the most recently finalized block's hash. It is the height the store loaded at +// until the first block has been finalized, and lags the committed version by however far this manager +// is behind. +func (fm *FinalizationManager) PublishedHash() *lthash.BlockHash { + return fm.latest.Load() +} + +// HashChan returns the stream of block hashes, one per block in block order. +// +// A block that failed arrives with Error set and the stream closes behind it, since nothing is +// published after one. It also closes when the manager does. +func (fm *FinalizationManager) HashChan() <-chan *lthash.BlockHash { + return fm.publishedHashChan +} + +// Flush blocks until the manager has finalized every block offered so far. +func (fm *FinalizationManager) Flush() error { + request := newFinalizationFlushRequest() + if err := fm.enqueue(request); err != nil { + return fmt.Errorf("flush finalization manager: %w", err) + } + <-request.doneChan + if err := fm.errorIfBricked(); err != nil { + return fmt.Errorf("flush finalization manager: %w", err) + } + return nil +} + +// Close stops the manager and waits for it to finish, reporting the latched error if it failed. +// +// Never call concurrently with another method: behaviour is undefined if anything else is in flight. +// Blocks that have been offered but not yet finalized are abandoned rather than +// finished — their reservations are released, and their rows are still in the WAL for replay to +// recover. +// +// The hash engine must be closed before this, so that this manager's read of its stream terminates. +func (fm *FinalizationManager) Close() error { + fm.cancel() + fm.wg.Wait() + if err := fm.errorIfBricked(); err != nil { + return fmt.Errorf("close finalization manager: %w", err) + } + return nil +} + +// enqueue puts a message on the queue, blocking while it is full. +func (fm *FinalizationManager) enqueue(message any) error { + if err := fm.errorIfBricked(); err != nil { + return fmt.Errorf("finalization manager failed: %w", err) + } + fm.messageChan <- message + return nil +} + +// run finalizes blocks until the manager is stopped or a block fails. +func (fm *FinalizationManager) run() { + defer fm.wg.Done() + defer fm.closeStream() + + failed := false + for { + select { + case message := <-fm.messageChan: + if failed { + // Once a block has failed, the hashes any later block would record cannot be + // trusted, so nothing more is written. What is still queued is given back rather + // than finalized. + fm.abandonMessage(message) + continue + } + if failed = !fm.handle(message); failed { + // The stream is closed on failure rather than left to teardown, because nothing is + // published after a failed block: a consumer waiting on the next hash would otherwise + // wait until the store closed. + fm.closeStream() + } + case <-fm.ctx.Done(): + fm.abandon() + return + } + } +} + +// handle deals with one message, reporting whether the manager may continue. +func (fm *FinalizationManager) handle(message any) bool { + switch request := message.(type) { + case *pendingFinalization: + stopped, err := fm.finalize(request) + if err != nil { + // Published before the failure is latched, because a consumer reading the stream has to be + // told the block failed; a closed channel alone reads as an orderly end. + fm.publish(<hash.BlockHash{BlockNumber: request.blockNumber, Error: err}) + fm.brick(err) + return false + } + return !stopped + case *finalizationFlushRequest: + close(request.doneChan) + return true + default: + fm.brick(fmt.Errorf("unknown finalization message type %T", message)) + return false + } +} + +// finalize writes one block's hashes onto its own views, releases its reservation, and publishes the +// hash. +// It reports stopped when the engine has no more hashes to give, which is teardown rather than failure. +func (fm *FinalizationManager) finalize(pending *pendingFinalization) (stopped bool, err error) { + hash, ok := <-fm.engineHashChan + if !ok { + // The engine has stopped, so this block will never be hashed. That is teardown rather than + // failure: its rows are in the WAL and replay recovers them. Discarding releases the reservation, + // which is the part that must not be skipped. + return true, fm.discard(pending) + } + if hash.Error != nil { + return false, errors.Join( + fmt.Errorf("hash block %d: %w", pending.blockNumber, hash.Error), + fm.discard(pending)) + } + if hash.BlockNumber != pending.blockNumber { + return false, errors.Join( + fmt.Errorf("finalization is out of step: holding block %d, hashed block %d", + pending.blockNumber, hash.BlockNumber), + fm.discard(pending)) + } + + for _, dbView := range pending.blockView.Views() { + if err := finalizeStore(dbView, pending.blockNumber, pending.alreadyHave, hash); err != nil { + return false, errors.Join( + fmt.Errorf("finalize %s at block %d: %w", dbView.Name(), pending.blockNumber, err), + pending.release()) + } + } + + // The reservation is only needed while the writes above happen. Released here rather than after + // publishing so the databases resume flushing even if nothing is reading the stream. + if err := pending.release(); err != nil { + return false, fmt.Errorf("release block %d after finalizing: %w", pending.blockNumber, err) + } + + fm.latest.Store(hash) + fm.reportHashes(hash) + fm.publish(hash) + return false, nil +} + +// discard finalizes a block's views with nothing recorded and releases its reservation, for a block +// that will never get a hash. Releasing the last reservation on an unfinalized view is a fatal error in the view +// manager, so an abandoned block still has to be finalized — and its data is still in the WAL, so a +// restart recovers it. +func (fm *FinalizationManager) discard(pending *pendingFinalization) error { + var errs []error + for _, dbView := range pending.blockView.Views() { + if err := dbView.Finalize(nil); err != nil { + errs = append(errs, fmt.Errorf("finalize discarded %s: %w", dbView.Name(), err)) + } + } + errs = append(errs, pending.release()) + return errors.Join(errs...) +} + +// abandon gives back everything still queued, without finalizing it. Queued blocks are discarded rather +// than finalized — after a failure the hashes they would record cannot be trusted, and during teardown +// they have no hashes at all — but their reservations are released either way, since a view left +// reserved can never flush. The engine's stream is drained so it is not left blocked publishing into it. +func (fm *FinalizationManager) abandon() { + for { + select { + case message := <-fm.messageChan: + fm.abandonMessage(message) + default: + fm.drainHashes() + return + } + } +} + +// abandonMessage gives one message back without acting on it: a block is discarded, which releases its +// reservation, and anything with a waiting caller is answered so that caller is not left blocked. +func (fm *FinalizationManager) abandonMessage(message any) { + switch request := message.(type) { + case *pendingFinalization: + if err := fm.discard(request); err != nil { + logger.Error("failed to discard an abandoned block", + "version", request.blockNumber, "err", err) + } + case *finalizationFlushRequest: + close(request.doneChan) + default: + fm.brick(fmt.Errorf("unknown finalization message type %T", message)) + } +} + +// drainHashes reads the engine's stream to completion. +// +// The engine blocks publishing a hash nobody reads, and this manager is its only reader, so a manager +// that stopped reading would leave the engine's own Close unable to return. +func (fm *FinalizationManager) drainHashes() { + for range fm.engineHashChan { //nolint:revive // draining is the point; the values are already accounted for + } +} + +// publish puts a block's hash on the outbound stream, giving up if the manager is stopping. +// +// Blocking here is the backpressure that stops a consumer falling arbitrarily far behind. Giving up on +// shutdown costs nothing: the block's metadata is already written by this point, so the hash is a +// notification rather than a durability step, and a stopped manager has no reader left to notify. +func (fm *FinalizationManager) publish(hash *lthash.BlockHash) { + select { + case fm.publishedHashChan <- hash: + case <-fm.ctx.Done(): + } +} + +// closeStream closes the outbound stream, which happens exactly once however often it is called. +func (fm *FinalizationManager) closeStream() { + fm.streamClosed.Do(func() { close(fm.publishedHashChan) }) +} + +// brick latches err as the manager's fatal error and stops it. +func (fm *FinalizationManager) brick(err error) { + fm.fatalErr.CompareAndSwap(nil, &err) +} + +// errorIfBricked reports the latched error, or nil if the manager has not failed. +func (fm *FinalizationManager) errorIfBricked() error { + if err := fm.fatalErr.Load(); err != nil { + return *err + } + return nil +} diff --git a/sei-db/state_db/sc/flatkv/finalization_messages.go b/sei-db/state_db/sc/flatkv/finalization_messages.go new file mode 100644 index 0000000000..7450566411 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/finalization_messages.go @@ -0,0 +1,41 @@ +package flatkv + +import ( + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" +) + +// The messages a FinalizationManager accepts. They share one queue so that a request is answered in the +// order it was made relative to the blocks around it. + +// pendingFinalization is one sealed block awaiting its hash. +type pendingFinalization struct { + // blockNumber is the height being finalized. Checked against the hash that arrives for it, since the + // two streams are independent and a mismatch means one of them has slipped. + blockNumber int64 + + // blockView is the block's sealed view, with a reservation this manager owns. Held until the block's + // hashes have been written onto it, because a view's last release must follow its finalization. + blockView *sview.StoreView + + // alreadyHave is the replay skip list: the height each database had already reached when replay + // started, or nil outside replay. It travels with the block because finalization consults it per + // database, and by the time this is finalized the store has moved on. + alreadyHave map[string]int64 +} + +// Releases the reservation this block holds, so its databases can resume flushing. +func (p *pendingFinalization) release() error { + return p.blockView.Release() +} + +// finalizationFlushRequest asks the manager to report once it has dealt with everything queued ahead of +// it. +type finalizationFlushRequest struct { + // done is closed once every message queued ahead of this one has been dealt with. A channel rather + // than a value, so the manager answering it can never block on a caller that has given up. + doneChan chan struct{} +} + +func newFinalizationFlushRequest() *finalizationFlushRequest { + return &finalizationFlushRequest{doneChan: make(chan struct{})} +} diff --git a/sei-db/state_db/sc/flatkv/hashlog.go b/sei-db/state_db/sc/flatkv/hashlog.go index b2fcd41517..8a0a156131 100644 --- a/sei-db/state_db/sc/flatkv/hashlog.go +++ b/sei-db/state_db/sc/flatkv/hashlog.go @@ -1,10 +1,6 @@ package flatkv -import ( - "fmt" - - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" -) +import "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" // Hash logger category names owned by the flatKV backend. flatKVDBHashPrefix is joined with a data DB // directory name (e.g. "flatKV/db/account"). @@ -17,6 +13,12 @@ const ( // per data DB. The set is fixed (the data DBs never change), so callers can use it to detect when the // overall logged category set has changed. func (s *CommitStore) HashCategories() []string { + return hashCategories() +} + +// hashCategories returns the same set without needing a store, for a caller that must open the logger +// before the store that reports to it. +func hashCategories() []string { categories := make([]string, 0, len(dataDBDirs)+1) categories = append(categories, FlatKVRootHashType) for _, dir := range dataDBDirs { @@ -25,28 +27,35 @@ func (s *CommitStore) HashCategories() []string { return categories } -// RecordHashes reports this store's hashes for blockNumber: the committed global root and each data DB's -// committed per-DB LtHash checksum. Call right after Commit; a blockNumber the store is not committed at -// is an error, since the hashes would then be attributed to a block they do not describe. -func (s *CommitStore) RecordHashes(hl hashlog.HashLogger, blockNumber uint64) error { - rootHash, version := s.RootHash() - if uint64(version) != blockNumber { //nolint:gosec // commit versions are non-negative - return fmt.Errorf("flatkv: asked to record hashes for block %d but the store is committed at %d", - blockNumber, version) +// Reports one block's hashes: the global root and each data database's per-DB checksum, under the +// height the hash describes rather than the height being committed. +// +// Runs on the finalization goroutine, so a hash reaches the log without the commit path waiting for +// hashing to catch up. Failures are logged and stop further reporting: the log is diagnostic, and a +// logger closed underneath this manager would otherwise complain once per block forever. +func (fm *FinalizationManager) reportHashes(hash *lthash.BlockHash) { + if fm.reportingFailed { + return } - if err := hl.ReportHash(blockNumber, FlatKVRootHashType, rootHash); err != nil { - return fmt.Errorf("failed to report flatkv root hash: %w", err) + blockNumber := uint64(hash.BlockNumber) //nolint:gosec // commit versions are non-negative + + rootHash := hash.Global.Checksum() + if err := fm.hashLogger.ReportHash(blockNumber, FlatKVRootHashType, rootHash[:]); err != nil { + fm.reportingFailed = true + logger.Error("stopped reporting flatkv hashes", "block", blockNumber, "err", err) + return } for _, dir := range dataDBDirs { - var hash []byte - if meta := s.localMeta[dir]; meta.LtHash != nil { - checksum := meta.LtHash.Checksum() - hash = checksum[:] + var dbChecksum []byte + if dbHash := hash.PerDB[dir]; dbHash != nil { + checksum := dbHash.Checksum() + dbChecksum = checksum[:] } category := flatKVDBHashPrefix + dir - if err := hl.ReportHash(blockNumber, category, hash); err != nil { - return fmt.Errorf("failed to report flatkv db hash %q: %w", category, err) + if err := fm.hashLogger.ReportHash(blockNumber, category, dbChecksum); err != nil { + fm.reportingFailed = true + logger.Error("stopped reporting flatkv hashes", "block", blockNumber, "category", category, "err", err) + return } } - return nil } diff --git a/sei-db/state_db/sc/flatkv/hashlog_test.go b/sei-db/state_db/sc/flatkv/hashlog_test.go index 468d48b37b..0ae896bdca 100644 --- a/sei-db/state_db/sc/flatkv/hashlog_test.go +++ b/sei-db/state_db/sc/flatkv/hashlog_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) @@ -41,7 +42,14 @@ func (c *captureLogger) ReportChangeset(uint64, []*proto.NamedChangeSet) { c.cha func (c *captureLogger) Close() error { return nil } func TestFlatKVHashReporting(t *testing.T) { - s := setupTestStore(t) + // The logger precedes the store, which reports to it as each block is finalized. + logger := newCaptureLogger() + for _, category := range hashCategories() { + require.NoError(t, logger.RegisterHashType(category)) + } + require.Len(t, logger.registered, 5) + + s := setupTestStoreWithHashLogger(t, config.DefaultTestConfig(t), logger) defer func() { require.NoError(t, s.Close()) }() // Write some EVM storage so the account/storage DBs have non-empty LtHashes. @@ -59,13 +67,7 @@ func TestFlatKVHashReporting(t *testing.T) { "flatKV/db/misc", }, s.HashCategories()) - logger := newCaptureLogger() - for _, category := range s.HashCategories() { - require.NoError(t, logger.RegisterHashType(category)) - } - require.Len(t, logger.registered, 5) - - require.NoError(t, s.RecordHashes(logger, 1)) + require.NoError(t, s.FlushHashes()) // Every category is reported, and the root matches CommittedRootHash. for _, category := range s.HashCategories() { @@ -74,7 +76,12 @@ func TestFlatKVHashReporting(t *testing.T) { } require.Equal(t, rootHash(s), logger.hashes["flatKV/root"]) - // Each reported per-DB hash is the checksum of that DB's committed LtHash. + // Each reported per-DB hash is the checksum of the LtHash that database actually recorded. Read back + // off disk rather than from the store's load-time copy: the finalizer writes it, so disk is the only + // place the two can be compared. + // + require.NoError(t, s.reloadLocalMeta()) + for _, dir := range dataDBDirs { checksum := s.localMeta[dir].LtHash.Checksum() require.Equal(t, checksum[:], logger.hashes["flatKV/db/"+dir]) @@ -85,5 +92,5 @@ func TestFlatKVHashReporting(t *testing.T) { for _, dir := range dataDBDirs { sum.MixIn(s.localMeta[dir].LtHash) } - require.True(t, sum.Equal(s.committedLtHash)) + require.True(t, sum.Equal(s.maintainedHashes().Global)) } diff --git a/sei-db/state_db/sc/flatkv/import_export_test.go b/sei-db/state_db/sc/flatkv/import_export_test.go index c0192a7085..cb722d17ee 100644 --- a/sei-db/state_db/sc/flatkv/import_export_test.go +++ b/sei-db/state_db/sc/flatkv/import_export_test.go @@ -797,7 +797,7 @@ func TestExporterCorruptAccountValueInDB(t *testing.T) { _ = batch.Close() require.NoError(t, corrupt.Close()) - s, err := NewCommitStore(t.Context(), cfg, nil) + s, err := NewCommitStore(t.Context(), cfg, nil, nil) require.NoError(t, err) defer s.Close() require.NoError(t, s.LoadLatest()) diff --git a/sei-db/state_db/sc/flatkv/importer.go b/sei-db/state_db/sc/flatkv/importer.go index e00d816c61..2d8836a4db 100644 --- a/sei-db/state_db/sc/flatkv/importer.go +++ b/sei-db/state_db/sc/flatkv/importer.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + "github.com/sei-protocol/sei-chain/sei-db/common/threading" seidbtypes "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" @@ -39,13 +40,13 @@ var flushHookForTest atomic.Pointer[func(string)] // and flushes (commit + LtHash update) when the buffer is full or the // channel is closed. type dbWorker struct { - ctx context.Context - dir string - db seidbtypes.KeyValueDB - ch chan rawKVPair - batch seidbtypes.Batch - ltPairs []lthash.KVPairWithLastValue - ltHash *lthash.LtHash + ctx context.Context + dir string + db seidbtypes.KeyValueDB + ch chan rawKVPair + batch seidbtypes.Batch + ltMutations []lthash.KeyMutation + ltHash *lthash.LtHash // moduleLtHash tracks the per-module decomposition of ltHash, keyed by the // "/" physical-key prefix. Its homomorphic sum equals ltHash. moduleLtHash map[string]*lthash.LtHash @@ -53,26 +54,41 @@ type dbWorker struct { // alongside moduleLtHash, keyed the same way. Mirrors the live commit path // so an imported store carries identical per-module stats metadata. moduleStats map[string]lthash.ModuleStats - // calc is the shared lattice-hash calculator. Its worker pool is used to - // distribute this worker's flushed pairs and compute per-module deltas — - // the same path the live commit uses (see HashCalculator.ComputeModuleHashInfos). - calc *lthash.HashCalculator - flushes int64 - pairs int64 + // pool distributes this worker's flushed mutations across every core to compute per-module deltas — + // the same path the live commit uses (see lthash.ComputeModuleHashInfos). + pool threading.Pool + // moduleOf names the module a physical key belongs to, for bucketing this worker's mutations. + moduleOf lthash.ModuleParser + // chunkSize is how many KV pairs each leaf-hash task carries. + chunkSize uint32 + flushes int64 + pairs int64 } -func newDBWorker(ctx context.Context, dir string, db seidbtypes.KeyValueDB, calc *lthash.HashCalculator, ltHash *lthash.LtHash, moduleLtHash map[string]*lthash.LtHash, moduleStats map[string]lthash.ModuleStats) *dbWorker { +func newDBWorker( + ctx context.Context, + dir string, + db seidbtypes.KeyValueDB, + pool threading.Pool, + moduleOf lthash.ModuleParser, + ltHash *lthash.LtHash, + moduleLtHash map[string]*lthash.LtHash, + moduleStats map[string]lthash.ModuleStats, + chunkSize uint32, +) *dbWorker { return &dbWorker{ ctx: ctx, dir: dir, db: db, ch: make(chan rawKVPair, workerChanSize), batch: db.NewBatch(), - ltPairs: make([]lthash.KVPairWithLastValue, 0, importBatchSize), + ltMutations: make([]lthash.KeyMutation, 0, importBatchSize), ltHash: ltHash, moduleLtHash: moduleLtHash, moduleStats: moduleStats, - calc: calc, + pool: pool, + moduleOf: moduleOf, + chunkSize: chunkSize, } } @@ -94,11 +110,11 @@ func (w *dbWorker) run(done <-chan struct{}) error { if err := w.batch.Set(kv.Key, kv.Value); err != nil { return fmt.Errorf("%s set: %w", w.dir, err) } - w.ltPairs = append(w.ltPairs, lthash.KVPairWithLastValue{ + w.ltMutations = append(w.ltMutations, lthash.KeyMutation{ Key: kv.Key, Value: kv.Value, }) - if len(w.ltPairs) >= importBatchSize { + if len(w.ltMutations) >= importBatchSize { if err := w.flush(); err != nil { return err } @@ -111,14 +127,14 @@ func (w *dbWorker) run(done <-chan struct{}) error { // flush commits the current PebbleDB batch and updates the running LtHash. func (w *dbWorker) flush() (err error) { - if len(w.ltPairs) == 0 { + if len(w.ltMutations) == 0 { return nil } if hook := flushHookForTest.Load(); hook != nil { (*hook)(w.dir) } start := time.Now() - pairCount := len(w.ltPairs) + pairCount := len(w.ltMutations) defer func() { otelMetrics.ImportWorkerFlushLatency.Record(w.ctx, secondsSince(start), metric.WithAttributes(dbAttr(w.dir), successAttr(err))) @@ -132,7 +148,8 @@ func (w *dbWorker) flush() (err error) { // per-module metadata and identical per-DB root a natively-committed store // would — and it lets a single large DB's batch fan out across every core // instead of being pinned to one import worker goroutine. - deltas, err := w.calc.ComputeModuleHashInfos([]lthash.DBPairs{{Dir: w.dir, Pairs: w.ltPairs}}) + deltas, err := lthash.ComputeModuleHashInfos( + w.pool, w.moduleOf, []lthash.DatabaseMutations{{DBName: w.dir, Mutations: w.ltMutations}}, w.chunkSize) if err != nil { return fmt.Errorf("%s compute module deltas: %w", w.dir, err) } @@ -157,7 +174,7 @@ func (w *dbWorker) flush() (err error) { w.flushes++ w.pairs += int64(pairCount) w.batch = w.db.NewBatch() - w.ltPairs = w.ltPairs[:0] + w.ltMutations = w.ltMutations[:0] return nil } @@ -201,10 +218,12 @@ func NewKVImporter(store *CommitStore, version int64, dbs rawDBs) types.Importer store.ctx, dir, dbs.forDir(dir), - store.ltCalc, - store.perDBWorkingLtHash[dir], - cloneModuleHashes(store.perDBModuleWorkingLtHash[dir]), - cloneModuleStats(store.perDBModuleWorkingStats[dir]), + store.ltHashPool, + store.moduleOf, + store.loadedHashes.PerDB[dir], + cloneModuleHashes(store.loadedHashes.PerModule[dir]), + cloneModuleStats(store.loadedHashes.PerModuleStats[dir]), + store.config.HashEngineConfig.ChunkSize, ) imp.workers[dir] = w } @@ -362,9 +381,9 @@ func (imp *KVImporter) Close() error { } for _, w := range imp.workers { - imp.store.perDBWorkingLtHash[w.dir] = w.ltHash - imp.store.perDBModuleWorkingLtHash[w.dir] = w.moduleLtHash - imp.store.perDBModuleWorkingStats[w.dir] = w.moduleStats + imp.store.loadedHashes.PerDB[w.dir] = w.ltHash + imp.store.loadedHashes.PerModule[w.dir] = w.moduleLtHash + imp.store.loadedHashes.PerModuleStats[w.dir] = w.moduleStats } if err = imp.store.FinalizeImport(imp.version); err != nil { diff --git a/sei-db/state_db/sc/flatkv/ktype/meta.go b/sei-db/state_db/sc/flatkv/ktype/meta.go index 7f93489b1b..e4c60563dc 100644 --- a/sei-db/state_db/sc/flatkv/ktype/meta.go +++ b/sei-db/state_db/sc/flatkv/ktype/meta.go @@ -1,29 +1,27 @@ package ktype -import ( - "bytes" +import "bytes" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" -) - -const metaKeyPrefix = "_meta/" +// MetaKeyPrefix is the key namespace a database reserves for its own metadata, which hashing must +// exclude: the metadata records the hash, so combining it in would make the hash depend on itself. +const MetaKeyPrefix = "_meta/" const ( - metaVersion = metaKeyPrefix + "version" - metaLtHash = metaKeyPrefix + "hash" + metaVersion = MetaKeyPrefix + "version" + metaLtHash = MetaKeyPrefix + "hash" // moduleLtHashPrefix brackets the per-module metadata keys stored in each // data DB, e.g. "_meta/x:evm/hash", "_meta/x:gov/stats". The "x:" segment // namespaces module names so they never collide with the fixed per-DB keys // (version / hash). Each module has a "/hash" key (its per-module // LtHash) and a "/stats" key (its per-module key-count / byte totals). - moduleLtHashPrefix = metaKeyPrefix + "x:" + moduleLtHashPrefix = MetaKeyPrefix + "x:" moduleLtHashSuffix = "/hash" moduleStatsSuffix = "/stats" ) var ( - MetaKeyPrefixBytes = []byte(metaKeyPrefix) + MetaKeyPrefixBytes = []byte(MetaKeyPrefix) MetaVersionKey = []byte(metaVersion) MetaLtHashKey = []byte(metaLtHash) // ModuleLtHashPrefixBytes is the inclusive lower bound for iterating the @@ -99,32 +97,3 @@ func parseModuleKey(key []byte, suffix string) (string, bool) { func IsMetaKey(key []byte) bool { return bytes.HasPrefix(key, MetaKeyPrefixBytes) } - -// LocalMeta stores one data DB's own view of its committed state, held at -// _meta/version, _meta/hash and _meta/x:/hash. -// -// The version and the root are written together or not at all, so a DB either -// reports both or has never had metadata written to it: a brand-new DB reports -// neither, a seeded DB reports a version with the identity root, and a DB that -// has committed a block reports its real root. -type LocalMeta struct { - // CommittedVersion is the version this DB last committed. It reads as 0 when - // no metadata has been written, which is indistinguishable from a genuine 0. - CommittedVersion int64 - - // LtHash is this DB's root over its own keys. nil only when no metadata has - // been written; writeLocalMetaToBatch refuses to record a version without one. - LtHash *lthash.LtHash - - // ModuleLtHashes holds the LtHash of each module's keys within this DB, - // keyed by module name (e.g. "evm", "gov"). The per-DB root (LtHash) - // equals the homomorphic sum of these module hashes. nil/empty when the - // DB has never been written (fresh store). - ModuleLtHashes map[string]*lthash.LtHash - - // ModuleStats holds the auxiliary key-count / byte totals of each module's - // keys within this DB, keyed by module name and mirroring ModuleLtHashes. - // Consensus-irrelevant; per-DB / global totals are derived on demand. - // nil/empty when the DB has never been written (fresh store). - ModuleStats map[string]lthash.ModuleStats -} diff --git a/sei-db/state_db/sc/flatkv/lthash/api.go b/sei-db/state_db/sc/flatkv/lthash/api.go deleted file mode 100644 index c4df55dd4b..0000000000 --- a/sei-db/state_db/sc/flatkv/lthash/api.go +++ /dev/null @@ -1,252 +0,0 @@ -package lthash - -import ( - "runtime" - "sync" - "time" -) - -// --- Public Types --- - -// KVPairWithLastValue holds a KV change for LtHash computation. -type KVPairWithLastValue struct { - Key []byte - Value []byte - LastValue []byte // Previous value (nil for new keys) - Delete bool // If true, only remove last value -} - -// LtHashTimings holds wall-clock timing breakdown for LtHash computation. -type LtHashTimings struct { - TotalNs int64 - Blake3Ns int64 - SerializeNs int64 - MixInOutNs int64 - MergeNs int64 -} - -// DefaultLtHashWorkers defaults to NumCPU. -var DefaultLtHashWorkers = runtime.NumCPU() - -// --- Public API --- - -// ComputeLtHash applies changes to prev LtHash and returns the result. -// For each KV: MixOut(LastValue) if set, MixIn(Value) if not Delete. -// If prev is nil, starts from zero. -// -// Invariants consumers rely on (do NOT break these without updating -// integration tests under sei-cosmos/storev2/rootmulti that assert them): -// -// 1. Commutativity and associativity across partitions. MixIn / MixOut -// are commutative and associative over the LtHash group, which lets -// the parallel path below split work across N workers and merge the -// per-worker results in any order without changing the output. Tests -// TestFlatKVLatticeHashDeterminism and -// TestFlatKVLargeChangesetDeterminism depend on this. -// -// 2. Delete-of-absent-key is a no-op. When LastValue is nil (key was not -// previously present) and Delete is true, both lastSerialized and -// newSerialized remain nil, so neither MixOut nor MixIn is invoked and -// this entry contributes zero to the hash. Same-block set-then-delete -// of a non-existent key therefore cannot shift the LtHash. -// TestFlatKVDeleteAndOverwriteWorkload (block 5) depends on this. -func ComputeLtHash(prev *LtHash, kvPairs []KVPairWithLastValue) (*LtHash, *LtHashTimings) { - delta, timings := computeDelta(kvPairs, DefaultLtHashWorkers) - - result := New() - if prev != nil { - result = prev.Clone() - } - result.MixIn(delta) - putLtHashToPool(delta) - - return result, timings -} - -// --- Internal computation --- - -// serializedKV holds serialized key-value data for hashing. -type serializedKV struct { - lastSerialized []byte - newSerialized []byte -} - -// lthashPair holds computed LtHash values for a single KV change. -type lthashPair struct { - lastLth *LtHash - newLth *LtHash -} - -// computeDelta computes the LtHash delta for a changeset. -func computeDelta(kvPairs []KVPairWithLastValue, numWorkers int) (*LtHash, *LtHashTimings) { - totalStart := time.Now() - - if numWorkers <= 0 { - numWorkers = DefaultLtHashWorkers - } - - if len(kvPairs) == 0 { - return New(), &LtHashTimings{TotalNs: time.Since(totalStart).Nanoseconds()} - } - - // Small changesets: serial is faster - if len(kvPairs) < 100 { - return computeDeltaSerial(kvPairs) - } - - // Phase 1: Serialize - serializeStart := time.Now() - serializedPairs := make([]serializedKV, len(kvPairs)) - for i, kv := range kvPairs { - if len(kv.LastValue) > 0 { - serializedPairs[i].lastSerialized = serializeKV(kv.Key, kv.LastValue) - } - if !kv.Delete && len(kv.Value) > 0 { - serializedPairs[i].newSerialized = serializeKV(kv.Key, kv.Value) - } - } - serializeNs := time.Since(serializeStart).Nanoseconds() - - // Phase 2: Hash (parallel) - blake3Start := time.Now() - lthashPairs := make([]lthashPair, len(kvPairs)) - chunkSize := (len(kvPairs) + numWorkers - 1) / numWorkers - var wg sync.WaitGroup - - for w := 0; w < numWorkers; w++ { - start := w * chunkSize - if start >= len(kvPairs) { - break - } - end := start + chunkSize - if end > len(kvPairs) { - end = len(kvPairs) - } - - wg.Add(1) - go func(startIdx, endIdx int) { - defer wg.Done() - for i := startIdx; i < endIdx; i++ { - skv := serializedPairs[i] - if skv.lastSerialized != nil { - lthashPairs[i].lastLth = hash(skv.lastSerialized) - } - if skv.newSerialized != nil { - lthashPairs[i].newLth = hash(skv.newSerialized) - } - } - }(start, end) - } - wg.Wait() - blake3Ns := time.Since(blake3Start).Nanoseconds() - - // Phase 3: MixIn/MixOut (parallel) - mixStart := time.Now() - results := make([]*LtHash, numWorkers) - - for w := 0; w < numWorkers; w++ { - start := w * chunkSize - if start >= len(kvPairs) { - break - } - end := start + chunkSize - if end > len(kvPairs) { - end = len(kvPairs) - } - - wg.Add(1) - go func(workerID int, startIdx, endIdx int) { - defer wg.Done() - workerLth := getLtHashFromPool() - for i := startIdx; i < endIdx; i++ { - lp := lthashPairs[i] - if lp.lastLth != nil { - workerLth.MixOut(lp.lastLth) - putLtHashToPool(lp.lastLth) - } - if lp.newLth != nil { - workerLth.MixIn(lp.newLth) - putLtHashToPool(lp.newLth) - } - } - results[workerID] = workerLth - }(w, start, end) - } - wg.Wait() - mixNs := time.Since(mixStart).Nanoseconds() - - // Phase 4: Merge - mergeStart := time.Now() - finalLth := New() - for _, r := range results { - if r != nil { - finalLth.MixIn(r) - putLtHashToPool(r) - } - } - mergeNs := time.Since(mergeStart).Nanoseconds() - - return finalLth, &LtHashTimings{ - TotalNs: time.Since(totalStart).Nanoseconds(), - SerializeNs: serializeNs, - Blake3Ns: blake3Ns, - MixInOutNs: mixNs, - MergeNs: mergeNs, - } -} - -// computeDeltaSerial is the serial version for small changesets. -func computeDeltaSerial(kvPairs []KVPairWithLastValue) (*LtHash, *LtHashTimings) { - totalStart := time.Now() - result := New() - - // Phase 1: Serialize - serializeStart := time.Now() - serializedPairs := make([]serializedKV, 0, len(kvPairs)) - for _, kv := range kvPairs { - skv := serializedKV{} - if len(kv.LastValue) > 0 { - skv.lastSerialized = serializeKV(kv.Key, kv.LastValue) - } - if !kv.Delete && len(kv.Value) > 0 { - skv.newSerialized = serializeKV(kv.Key, kv.Value) - } - serializedPairs = append(serializedPairs, skv) - } - serializeNs := time.Since(serializeStart).Nanoseconds() - - // Phase 2: Hash - blake3Start := time.Now() - lthashPairs := make([]lthashPair, len(serializedPairs)) - for i, skv := range serializedPairs { - if skv.lastSerialized != nil { - lthashPairs[i].lastLth = hash(skv.lastSerialized) - } - if skv.newSerialized != nil { - lthashPairs[i].newLth = hash(skv.newSerialized) - } - } - blake3Ns := time.Since(blake3Start).Nanoseconds() - - // Phase 3: MixIn/MixOut - mixStart := time.Now() - for _, lp := range lthashPairs { - if lp.lastLth != nil { - result.MixOut(lp.lastLth) - putLtHashToPool(lp.lastLth) - } - if lp.newLth != nil { - result.MixIn(lp.newLth) - putLtHashToPool(lp.newLth) - } - } - mixNs := time.Since(mixStart).Nanoseconds() - - return result, &LtHashTimings{ - TotalNs: time.Since(totalStart).Nanoseconds(), - SerializeNs: serializeNs, - Blake3Ns: blake3Ns, - MixInOutNs: mixNs, - MergeNs: 0, - } -} diff --git a/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go b/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go new file mode 100644 index 0000000000..52727e763d --- /dev/null +++ b/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go @@ -0,0 +1,181 @@ +package lthash + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" +) + +// blockGatherer reads what each sealed block changed and submits its leaf hashing to the pool. +type blockGatherer struct { + // hasher fans this block's leaf hashing out across the pool. + hasher *leafHasher + + // Sealed blocks and control messages arrive here from ScheduleHash(). + scheduledBlockChan chan any + + // Once a block has been gathered, it is put onto this channel for the combiner, in block order. + combineJobChan chan any + + // Cancelled when the engine is stopping, to release a send that the combiner is no longer reading. + ctx context.Context + + // brick latches a failure on the engine, which reports it from Close(). + brick func(error) + + // wg tracks run(), so that the engine can wait for it to return. + wg sync.WaitGroup +} + +func newBlockGatherer( + cfg *Config, + hasher *leafHasher, + // Cancelled when the engine is stopping, to release a send the combiner is no longer reading. + ctx context.Context, + // Latches a failure on the engine, which reports it from Close(). + brick func(error), +) *blockGatherer { + g := &blockGatherer{ + hasher: hasher, + scheduledBlockChan: make(chan any, cfg.ScheduleQueueSize), + combineJobChan: make(chan any, cfg.CombineQueueSize), + ctx: ctx, + brick: brick, + } + g.wg.Go(g.run) + return g +} + +// run reads each block's changed values, submits its leaf hashing to the pool, and passes the block to +// the combiner. +func (g *blockGatherer) run() { + defer g.teardown() + + for { + select { + case message := <-g.scheduledBlockChan: + switch request := message.(type) { + case *hashRequest: + g.gather(request) + case *flushRequest: + g.combineJobChan <- request + default: + g.brick(fmt.Errorf("unknown engine message type %T", message)) + return + } + case <-g.ctx.Done(): + return + } + } +} + +// Drain the queue without hashing it, releasing each block's reservation. +func (g *blockGatherer) teardown() { + defer close(g.combineJobChan) + + for { + select { + case message := <-g.scheduledBlockChan: + request, ok := message.(*hashRequest) + if !ok { + continue + } + if err := request.release(); err != nil { + g.brick(fmt.Errorf("release block %d while stopping: %w", request.blockNumber, err)) + } + default: + return + } + } +} + +// Deal with one block from the gatherer's queue. +func (g *blockGatherer) gather(request *hashRequest) { + changed, err := gatherChangesFromAllStores(request.current, request.previous) + + // Released even when the read failed: a reservation left held stalls its database's flushes + // indefinitely, and the read's own failure is reported either way. + releaseErr := request.release() + if err == nil { + err = releaseErr + } + + var hashes leafHashes + if err == nil { + hashes, err = g.hasher.submit(changed) + } + if err != nil { + err = fmt.Errorf("gather block %d: %w", request.blockNumber, err) + } + + g.combineJobChan <- &gatheredBlock{ + blockNumber: request.blockNumber, + hashes: hashes, + err: err, + } +} + +// Gather changes from all stores. +func gatherChangesFromAllStores(current *sview.StoreView, previous *sview.StoreView) ([]DatabaseMutations, error) { + out := make([]DatabaseMutations, 4) + errs := make([]error, 4) + + var wg sync.WaitGroup + wg.Go(func() { out[0], errs[0] = gatherChangesFromStore(current.AccountView(), previous.AccountView()) }) + wg.Go(func() { out[1], errs[1] = gatherChangesFromStore(current.CodeView(), previous.CodeView()) }) + wg.Go(func() { out[2], errs[2] = gatherChangesFromStore(current.StorageView(), previous.StorageView()) }) + wg.Go(func() { out[3], errs[3] = gatherChangesFromStore(current.MiscView(), previous.MiscView()) }) + wg.Wait() + + if err := errors.Join(errs...); err != nil { + return nil, err + } + return out, nil +} + +// Gather the changes from a specific store. +func gatherChangesFromStore(current view.View, previous view.View) (DatabaseMutations, error) { + diff, err := current.GetDiff() + if err != nil { + return DatabaseMutations{}, fmt.Errorf("%s read diff: %w", current.Name(), err) + } + if len(diff) == 0 { + return DatabaseMutations{DBName: current.Name()}, nil + } + + changedKeys := make([][]byte, 0, len(diff)) + for key := range diff { + if strings.HasPrefix(key, ktype.MetaKeyPrefix) { + continue + } + changedKeys = append(changedKeys, []byte(key)) + } + if len(changedKeys) == 0 { + return DatabaseMutations{DBName: current.Name()}, nil + } + + var old map[string][]byte + if previous != nil { + if old, err = previous.BatchGet(changedKeys); err != nil { + return DatabaseMutations{}, fmt.Errorf("%s read previous values: %w", current.Name(), err) + } + } + + out := make([]KeyMutation, 0, len(changedKeys)) + for _, key := range changedKeys { + value := diff[string(key)] + out = append(out, KeyMutation{ + Key: key, + Value: value, + LastValue: old[string(key)], + Delete: value == nil, + }) + } + return DatabaseMutations{DBName: current.Name(), Mutations: out}, nil +} diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go b/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go deleted file mode 100644 index 4effd1665e..0000000000 --- a/sei-db/state_db/sc/flatkv/lthash/hash_calculator.go +++ /dev/null @@ -1,389 +0,0 @@ -package lthash - -import ( - "fmt" - - "github.com/sei-protocol/sei-chain/sei-db/common/threading" -) - -const ( - // computeChunkSize is the number of KV pairs one task carries. Splitting a - // module's pairs into fixed-size chunks lets a single large module (e.g. the - // EVM storage DB in a big block) fan out across many workers instead of - // pinning one. Small enough to balance load, large enough to amortize the - // per-task scheduling overhead and result bookkeeping. - computeChunkSize = 100 - - // parallelThreshold is the minimum total pair count before the worker pool - // is engaged. Below it, the pool hand-off + merge overhead outweighs the - // parallelism, so the delta is computed inline on the caller goroutine. Kept - // a multiple of computeChunkSize (>= 2x) so that any batch which does go - // parallel splits into several chunks rather than paying the pool tax to run - // a single chunk on one worker. - parallelThreshold = 1000 -) - -// ModuleFunc extracts the owning module name from a physical key. Injected by -// the caller so the HashCalculator stays decoupled from the key-encoding package. -type ModuleFunc func(physicalKey []byte) (module string, err error) - -// DBPairs couples a data DB dir with the LtHash pairs to fold into it this -// block. -type DBPairs struct { - Dir string - Pairs []KVPairWithLastValue -} - -// BlockHash holds the recomputed hash state after folding a block's pairs. PerDB -// and PerModule contain an entry for every DB dir the HashCalculator was -// configured with (so callers can swap them in wholesale). Global is the -// homomorphic sum of the per-DB roots. PerModuleStats holds the per-(dir, -// module) key-count / byte totals accumulated alongside the hash. -type BlockHash struct { - BlockNumber int64 - PerDB map[string]*LtHash - PerModule map[string]map[string]*LtHash - PerModuleStats map[string]map[string]ModuleStats - Global *LtHash - Error error -} - -// HashCalculator encapsulates the per-block lattice-hash pipeline over an -// injected CPU-bound worker pool: -// -// Compute hashes individual keys and combines the per-worker results into the -// final per-module hashes, then derives each per-DB root and the global hash -// from those. Callers supply the key/old-value/new-value triples; reading the -// old values is not this package's job. -// -// The pool is supplied by the caller (the FlatKV store) rather than created -// here. The HashCalculator does not own the pool and never closes it; pool -// lifecycle is the caller's responsibility. -// -// The pool distributes independent per-chunk tasks, so ComputeModuleHashInfos is -// safe to call concurrently from multiple goroutines that share one -// HashCalculator (the state-sync importer runs a goroutine per DB). The live -// commit path is additionally serialized by FlatKV's write lock. -type HashCalculator struct { - pool threading.Pool - dbDirs []string - moduleOf ModuleFunc -} - -// NewHashCalculator creates a HashCalculator that runs on the provided pool. -// dbDirs is the canonical, ordered set of data DB directories; moduleOf extracts -// a physical key's owning module. The pool is owned by the caller — closing it -// is the caller's responsibility, not the HashCalculator's. -func NewHashCalculator(pool threading.Pool, dbDirs []string, moduleOf ModuleFunc) *HashCalculator { - return &HashCalculator{ - pool: pool, - dbDirs: append([]string(nil), dbDirs...), - moduleOf: moduleOf, - } -} - -// ModuleKey identifies a single (data DB dir, module) accumulator. -type ModuleKey struct { - Dir string - Module string -} - -// Compute folds pairSets into the previous hashes and derives the full result: -// per-module hashes (via ComputeModuleHashInfos), each touched per-DB root as the -// homomorphic sum of its module hashes, and the global hash as the sum of the -// per-DB roots. -// -// The returned maps are freshly allocated (cloned from prev), so the caller can -// swap them in without aliasing. Because MixIn/MixOut are commutative and -// associative, the result is identical to a single serial fold — the global -// store hash (and consensus AppHash) is independent of worker count or chunking. -// -// Used by the live commit path, which maintains a running per-DB/per-module -// hash (and per-module stats) across blocks. -func (c *HashCalculator) Compute( - pairSets []DBPairs, - prevPerDB map[string]*LtHash, - prevPerModule map[string]map[string]*LtHash, - prevPerModuleStats map[string]map[string]ModuleStats, -) (*BlockHash, error) { - newPerDB := make(map[string]*LtHash, len(c.dbDirs)) - newPerModule := make(map[string]map[string]*LtHash, len(c.dbDirs)) - newPerModuleStats := make(map[string]map[string]ModuleStats, len(c.dbDirs)) - for _, dir := range c.dbDirs { - if h := prevPerDB[dir]; h != nil { - newPerDB[dir] = h.Clone() - } else { - newPerDB[dir] = New() - } - newPerModule[dir] = cloneModuleMap(prevPerModule[dir]) - newPerModuleStats[dir] = cloneModuleStatsMap(prevPerModuleStats[dir]) - } - - deltas, err := c.ComputeModuleHashInfos(pairSets) - if err != nil { - return nil, err - } - - touched := make(map[string]struct{}, len(c.dbDirs)) - for key, delta := range deltas { - modBucket := newPerModule[key.Dir] - statBucket := newPerModuleStats[key.Dir] - if modBucket == nil { - // Defensive: a DB dir not in c.dbDirs still gets buckets so the - // delta is not silently dropped. - modBucket = make(map[string]*LtHash) - newPerModule[key.Dir] = modBucket - statBucket = make(map[string]ModuleStats) - newPerModuleStats[key.Dir] = statBucket - } - cur := modBucket[key.Module] - if cur == nil { - cur = New() - modBucket[key.Module] = cur - } - cur.MixIn(delta.Hash) - statBucket[key.Module] = statBucket[key.Module].Add(ModuleStats{KeyCount: delta.KeyCount, Bytes: delta.Bytes}) - touched[key.Dir] = struct{}{} - } - for dir := range touched { - newPerDB[dir] = SumModuleHashes(newPerModule[dir]) - } - - global := New() - for _, dir := range c.dbDirs { - global.MixIn(newPerDB[dir]) - } - - return &BlockHash{ - PerDB: newPerDB, - PerModule: newPerModule, - PerModuleStats: newPerModuleStats, - Global: global, - }, nil -} - -// ModuleHashInfo is the per-(dir, module) change computed for one block/batch: -// the homomorphic hash delta plus the net key-count and byte deltas implied by -// the same MixIn/MixOut transitions. -type ModuleHashInfo struct { - Hash *LtHash - KeyCount int64 - Bytes int64 -} - -// ComputeModuleHashInfos is the shared per-module hashing primitive used by both -// the live commit path (via Compute) and the state-sync importer. It processes -// the changeset pairs identically for both: bucket each DB's pairs by module, -// split every bucket into fixed-size chunks, and distribute those chunks across -// the shared worker pool to compute the per-(dir, module) homomorphic hash delta -// and the accompanying key-count / byte deltas. -// -// Each chunk is an independent, self-terminating task, so ComputeModuleHashInfos is -// safe to call concurrently from multiple goroutines sharing one pool (the -// importer runs a goroutine per DB). It never holds a worker while waiting on -// another task, so no oversubscription or deadlock can arise from the nesting. -// -// The caller decides how to apply the deltas: Compute mixes them onto a running -// per-block hash; the importer folds them into its per-DB accumulators. -func (c *HashCalculator) ComputeModuleHashInfos(pairSets []DBPairs) (map[ModuleKey]*ModuleHashInfo, error) { - tasks, total, err := c.buildTasks(pairSets) - if err != nil { - return nil, err - } - if len(tasks) == 0 { - return nil, nil - } - if total < parallelThreshold { - return computeDeltasSerial(tasks), nil - } - return c.computeDeltasParallel(tasks), nil -} - -// lthashTask is one unit of parallel work: a chunk of pairs that all belong to -// a single (db, module) bucket. -type lthashTask struct { - key ModuleKey - pairs []KVPairWithLastValue -} - -// buildTasks buckets each DB's pairs by module and splits every bucket into -// fixed-size tasks. It also returns the total pair count so callers can pick the -// serial vs parallel path. -func (c *HashCalculator) buildTasks(pairSets []DBPairs) (tasks []lthashTask, total int, err error) { - for _, ps := range pairSets { - if len(ps.Pairs) == 0 { - continue - } - total += len(ps.Pairs) - byModule, err := BucketByModule(ps.Pairs, c.moduleOf) - if err != nil { - return nil, 0, fmt.Errorf("failed to bucket %s pairs by module: %w", ps.Dir, err) - } - for module, mpairs := range byModule { - for start := 0; start < len(mpairs); start += computeChunkSize { - end := start + computeChunkSize - if end > len(mpairs) { - end = len(mpairs) - } - tasks = append(tasks, lthashTask{ - key: ModuleKey{Dir: ps.Dir, Module: module}, - pairs: mpairs[start:end], - }) - } - } - } - return tasks, total, nil -} - -// foldChunk computes the homomorphic hash delta and the net key-count / byte -// deltas for one chunk of pairs. Key presence is defined exactly as the hash -// defines it: a prior value exists iff LastValue is non-empty (an unmix), and a -// new value exists iff the entry is not a delete and Value is non-empty (a mix). -// - add (!old, new): +1 key, + (len(key)+len(newVal)) bytes -// - update ( old, new): 0 keys, + (len(newVal)-len(oldVal)) bytes -// - delete ( old, !new): -1 key, - (len(key)+len(oldVal)) bytes -// - no-op (!old, !new): unchanged (delete of an absent key) -func foldChunk(pairs []KVPairWithLastValue) *ModuleHashInfo { - d := &ModuleHashInfo{Hash: New()} - for _, kv := range pairs { - // A member exists iff serializeKV would produce a non-nil buffer, i.e. - // key and value are both non-empty. Keeping these predicates identical - // to the mix conditions guarantees the stats track exactly the set the - // hash represents. - hadOld := len(kv.Key) > 0 && len(kv.LastValue) > 0 - hasNew := len(kv.Key) > 0 && !kv.Delete && len(kv.Value) > 0 - if hadOld { - h := hash(serializeKV(kv.Key, kv.LastValue)) - d.Hash.MixOut(h) - putLtHashToPool(h) - } - if hasNew { - h := hash(serializeKV(kv.Key, kv.Value)) - d.Hash.MixIn(h) - putLtHashToPool(h) - } - switch { - case !hadOld && hasNew: - d.KeyCount++ - d.Bytes += int64(len(kv.Key)) + int64(len(kv.Value)) - case hadOld && hasNew: - d.Bytes += int64(len(kv.Value)) - int64(len(kv.LastValue)) - case hadOld && !hasNew: - d.KeyCount-- - d.Bytes -= int64(len(kv.Key)) + int64(len(kv.LastValue)) - } - } - return d -} - -// mergeDelta folds src into dst (hash + counts). dst must be non-nil. -func mergeDelta(dst, src *ModuleHashInfo) { - dst.Hash.MixIn(src.Hash) - dst.KeyCount += src.KeyCount - dst.Bytes += src.Bytes -} - -// computeDeltasSerial folds all tasks into per-(db,module) deltas on the caller -// goroutine. Used for small blocks where pool overhead does not pay off. -func computeDeltasSerial(tasks []lthashTask) map[ModuleKey]*ModuleHashInfo { - deltas := make(map[ModuleKey]*ModuleHashInfo) - for _, task := range tasks { - d := foldChunk(task.pairs) - if acc := deltas[task.key]; acc != nil { - mergeDelta(acc, d) - } else { - deltas[task.key] = d - } - } - return deltas -} - -// computeDeltasParallel distributes tasks across the fixed pool as independent, -// self-terminating units — one fold per chunk — then merges results as they -// arrive. A buffered result channel (capacity = task count) ensures workers -// never block on send, so a full pool queue only backpressures the submitter -// while already-running chunks drain. This is safe when several goroutines -// share one pool (the importer's per-DB workers all call through here). -// MixIn/addition are commutative, so merge order does not matter. -func (c *HashCalculator) computeDeltasParallel(tasks []lthashTask) map[ModuleKey]*ModuleHashInfo { - type result struct { - key ModuleKey - info *ModuleHashInfo - } - // Buffer must be large enough for every task: we submit all work before - // draining results, and Submit can block when the pool queue is full. If a - // finished worker then blocked on an unbuffered send here, nothing would - // free a queue slot and we'd deadlock. MixIn/addition are commutative, so - // merge order does not matter. - results := make(chan result, len(tasks)) - for i := range tasks { - task := tasks[i] - c.pool.Submit(func() { - results <- result{key: task.key, info: foldChunk(task.pairs)} - }) - } - - merged := make(map[ModuleKey]*ModuleHashInfo) - for range tasks { - r := <-results - if acc := merged[r.key]; acc != nil { - mergeDelta(acc, r.info) - } else { - merged[r.key] = r.info - } - } - return merged -} - -// BucketByModule groups LtHash pairs by their owning module, derived from each -// physical key via moduleOf. Used to decompose a per-DB root into additive -// per-module hashes without changing the root. -func BucketByModule( - pairs []KVPairWithLastValue, - moduleOf ModuleFunc, -) (map[string][]KVPairWithLastValue, error) { - byModule := make(map[string][]KVPairWithLastValue) - for _, pair := range pairs { - module, err := moduleOf(pair.Key) - if err != nil { - return nil, err - } - byModule[module] = append(byModule[module], pair) - } - return byModule, nil -} - -// SumModuleHashes returns the homomorphic sum of a DB's per-module hashes, i.e. -// its derived per-DB root. A nil/empty map yields the identity hash. -func SumModuleHashes(moduleHashes map[string]*LtHash) *LtHash { - root := New() - for _, h := range moduleHashes { - if h != nil { - root.MixIn(h) - } - } - return root -} - -// cloneModuleMap deep-copies a per-module hash map (cloning each LtHash). A -// nil/empty source yields a fresh empty map. -func cloneModuleMap(src map[string]*LtHash) map[string]*LtHash { - dst := make(map[string]*LtHash, len(src)) - for module, h := range src { - if h != nil { - dst[module] = h.Clone() - } - } - return dst -} - -// cloneModuleStatsMap copies a per-module stats map. ModuleStats is a value -// type, so a shallow per-entry copy is a full copy. A nil/empty source yields a -// fresh empty map. -func cloneModuleStatsMap(src map[string]ModuleStats) map[string]ModuleStats { - dst := make(map[string]ModuleStats, len(src)) - for module, s := range src { - dst[module] = s - } - return dst -} diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_combiner.go b/sei-db/state_db/sc/flatkv/lthash/hash_combiner.go new file mode 100644 index 0000000000..3d18a5bdeb --- /dev/null +++ b/sei-db/state_db/sc/flatkv/lthash/hash_combiner.go @@ -0,0 +1,244 @@ +package lthash + +import ( + "context" + "fmt" + "sync" +) + +// hashCombiner sums each block's leaf hashes onto the block before it and publishes the result. +type hashCombiner struct { + // dbNames is the canonical set of data databases, so every result describes all of them. + dbNames []string + + // combined is the hash state as of the most recently combined block, or the seed before any block + // has been combined. + combined *BlockHash + + // Gathered blocks arrive here, in block order. + combineJobChan <-chan any + + // When a block is fully hashed, the result is put onto this channel. + blockHashChan chan *BlockHash + + // Cancelled when the engine is stopping, to release a publish that nobody is reading. + ctx context.Context + + // brick latches a failure on the engine, which reports it from Close(). + brick func(error) + + // wg tracks run(), so that the engine can wait for it to return. + wg sync.WaitGroup +} + +func newHashCombiner( + // The canonical set of database names, so that every hash describes all of them. + dbNames []string, + // The hash state the first block is summed onto. + runningHash *BlockHash, + // Gathered blocks arrive here, in block order. + combineJobChan <-chan any, + // Cancelled when the engine is stopping, to release a publish that nobody is reading. + ctx context.Context, + // Depth of the channel finished hashes are published on. + hashChanSize uint32, + // Latches a failure on the engine, which reports it from Close(). + brick func(error), +) *hashCombiner { + c := &hashCombiner{ + dbNames: append([]string(nil), dbNames...), + combined: runningHash, + combineJobChan: combineJobChan, + blockHashChan: make(chan *BlockHash, hashChanSize), + ctx: ctx, + brick: brick, + } + c.wg.Go(c.run) + return c +} + +// run combines blocks until the gatherer stops sending them. +func (c *hashCombiner) run() { + defer close(c.blockHashChan) + + publishing := true + for job := range c.combineJobChan { + switch job := job.(type) { + case *gatheredBlock: + if publishing { + publishing = c.combineBlock(job) + } + case *flushRequest: + close(job.doneChan) + default: + // Bricked rather than stopped: the gatherer's send cannot be abandoned, so this goroutine + // has to keep draining combineJobChan until it closes. Close() reports the latched error. + c.brick(fmt.Errorf("unknown combine job type %T", job)) + } + } +} + +// combineBlock waits for one block's leaf hashes, sums them onto the running state, and publishes it, +// reporting whether the stream may carry on. +func (c *hashCombiner) combineBlock(job *gatheredBlock) bool { + if job.err != nil { + // The running hashes describe nothing trustworthy once a block has failed, so no later block + // may be derived from them. + c.publish(&BlockHash{BlockNumber: job.blockNumber, Error: job.err}) + c.brick(job.err) + return false + } + + deltas := make(map[ModuleKey]*ModuleHashInfo) + for i := 0; i < job.hashes.count; i++ { + result := <-job.hashes.resultChan + if acc := deltas[result.key]; acc != nil { + mergeDelta(acc, result.info) + } else { + deltas[result.key] = result.info + } + } + + c.combined = combine( + c.dbNames, + deltas, + c.combined.PerDB, + c.combined.PerModule, + c.combined.PerModuleStats) + c.combined.BlockNumber = job.blockNumber + + return c.publish(c.combined) +} + +// publish hands a finished hash to whoever is reading AwaitHash(), reporting whether it was taken. It +// gives up if the engine is stopping, since a stopped engine has no reader left to hand it to. +func (c *hashCombiner) publish(hash *BlockHash) bool { + select { + case c.blockHashChan <- hash: + return true + case <-c.ctx.Done(): + return false + } +} + +// combine folds deltas onto the previous block's hashes and derives the rest: each touched per-DB +// root as the homomorphic sum of its module hashes, and the global root as the sum of the per-DB roots. +// +// The returned maps are freshly allocated, cloned from prev, so the caller may hold the result while +// later blocks are folded. A nil or empty delta map carries the previous hashes forward unchanged. +// +// MixIn and MixOut are commutative and associative, so the result is identical to a single serial fold: +// the global store hash, and so the consensus AppHash, does not depend on worker count or chunking. +func combine( + // The canonical set of data databases, so that every one has an entry in the result even if this + // block did not touch it, and the caller can swap the maps in wholesale. + dbNames []string, + // This block's change to each (database, module), or nil for a block that changed nothing. + deltas map[ModuleKey]*ModuleHashInfo, + prevPerDB map[string]*LtHash, + prevPerModule map[string]map[string]*LtHash, + prevPerModuleStats map[string]map[string]ModuleStats, +) *BlockHash { + newPerDB := make(map[string]*LtHash, len(dbNames)) + newPerModule := make(map[string]map[string]*LtHash, len(dbNames)) + newPerModuleStats := make(map[string]map[string]ModuleStats, len(dbNames)) + for _, dbName := range dbNames { + if h := prevPerDB[dbName]; h != nil { + newPerDB[dbName] = h.Clone() + } else { + newPerDB[dbName] = New() + } + newPerModule[dbName] = cloneModuleMap(prevPerModule[dbName]) + newPerModuleStats[dbName] = cloneModuleStatsMap(prevPerModuleStats[dbName]) + } + + touched := make(map[string]struct{}, len(dbNames)) + for key, delta := range deltas { + modBucket := newPerModule[key.DBName] + statBucket := newPerModuleStats[key.DBName] + if modBucket == nil { + // Defensive: a database not in dbNames still gets buckets so the delta + // is not silently dropped. + modBucket = make(map[string]*LtHash) + newPerModule[key.DBName] = modBucket + statBucket = make(map[string]ModuleStats) + newPerModuleStats[key.DBName] = statBucket + } + cur := modBucket[key.Module] + if cur == nil { + cur = New() + modBucket[key.Module] = cur + } + cur.MixIn(delta.Hash) + statBucket[key.Module] = statBucket[key.Module].Add( + ModuleStats{KeyCount: delta.KeyCount, Bytes: delta.Bytes}) + touched[key.DBName] = struct{}{} + } + for dbName := range touched { + newPerDB[dbName] = SumModuleHashes(newPerModule[dbName]) + } + + return &BlockHash{ + PerDB: newPerDB, + PerModule: newPerModule, + PerModuleStats: newPerModuleStats, + Global: SumDBHashes(dbNames, newPerDB), + } +} + +// NewBlockHash returns the hash state of a store that has hashed nothing: an identity hash for every +// database in dbNames, and an identity global root. +func NewBlockHash(dbNames []string) *BlockHash { + return combine(dbNames, nil, nil, nil, nil) +} + +// SumDBHashes returns the store-wide root: the homomorphic sum of every data database's per-DB root. +func SumDBHashes( + // The canonical set of data databases. Summing over this rather than over perDB is what makes a + // database missing from perDB contribute the identity rather than be skipped silently. + dbNames []string, + perDB map[string]*LtHash, +) *LtHash { + global := New() + for _, dbName := range dbNames { + if h := perDB[dbName]; h != nil { + global.MixIn(h) + } + } + return global +} + +// SumModuleHashes returns the homomorphic sum of a DB's per-module hashes, i.e. +// its derived per-DB root. A nil/empty map yields the identity hash. +func SumModuleHashes(moduleHashes map[string]*LtHash) *LtHash { + root := New() + for _, h := range moduleHashes { + if h != nil { + root.MixIn(h) + } + } + return root +} + +// cloneModuleMap deep-copies a per-module hash map (cloning each LtHash). A +// nil/empty source yields a fresh empty map. +func cloneModuleMap(src map[string]*LtHash) map[string]*LtHash { + dst := make(map[string]*LtHash, len(src)) + for module, h := range src { + if h != nil { + dst[module] = h.Clone() + } + } + return dst +} + +// cloneModuleStatsMap copies a per-module stats map. ModuleStats is a value +// type, so a shallow per-entry copy is a full copy. A nil/empty source yields a +// fresh empty map. +func cloneModuleStatsMap(src map[string]ModuleStats) map[string]ModuleStats { + dst := make(map[string]ModuleStats, len(src)) + for module, s := range src { + dst[module] = s + } + return dst +} diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_engine.go b/sei-db/state_db/sc/flatkv/lthash/hash_engine.go index 674b57afc0..e6da0e9fe1 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_engine.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine.go @@ -1,37 +1,179 @@ package lthash -import "github.com/sei-protocol/sei-chain/sei-db/common/threading" +import ( + "context" + "errors" + "fmt" + "sync/atomic" -// Computes lattice hashes for flatKV. + "github.com/sei-protocol/sei-chain/sei-db/common/threading" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" +) + +/* + +The HashEngine hashes using three pipelined phases: + +-- Phase 1: Gather -- + +In order to compute a lattice hash, for each key-value pair that changed in a block, we must know +both the new value and the previous value. This phase is responsible for gathering these previous-new +pairs. + +-- Phase 2: Hash -- + +For each changed key-value pair in a block, we hash both the previous value and the new value. This +phase fans out to a large work pool, since individual leaf hashes can be computed independently. + +-- Phase 3: Combine -- + +In order to compute the final lattice hash, we need to "sum up" the individual leaf hashes. This operation +is done one block at a time, since block N's hash is a function of block N-1's hash. + +*/ + +// Computes lattice hashes for flatKV. This utility has no recoverable errors. type HashEngine struct { + // gatherer reads each block's changed values and submits its leaf hashing to the pool. + gatherer *blockGatherer + + // combiner sums each block's leaf hashes onto the block before it, and owns the running state. + combiner *hashCombiner + + // cancel stops the gatherer and the combiner. Called by Close, and by the store's own context. + cancel context.CancelFunc + + // fatalErr latches the first failure. Nil until something fails. + fatalErr atomic.Pointer[error] } -// TODO create a config +// Construct a new hash engine. +func NewHashEngine( + // Cancelling this stops the engine, exactly as Close does. + parent context.Context, + cfg *Config, + // Used to compute the leaf hashes. Owned by the caller, and must stay open for at least as long as the + // engine. + pool threading.Pool, + // The canonical set of database names so that we produce a hash for each DB for each block. + dbNames []string, + moduleParser ModuleParser, + // The hash state the first scheduled block is measured against. A store with history passes what it + // read off disk; one with none passes NewBlockHash(dbNames). + seed *BlockHash, +) (*HashEngine, error) { + if cfg == nil { + return nil, fmt.Errorf("config is nil") + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("validate hash engine config: %w", err) + } + if pool == nil { + return nil, fmt.Errorf("pool is nil") + } + if moduleParser == nil { + return nil, fmt.Errorf("module parser is nil") + } + if seed == nil { + return nil, fmt.Errorf("seed is nil") + } -func NewHashEngine(pool threading.Pool, dbDirs []string, moduleOf ModuleFunc) (*HashEngine, error) { - return nil, nil // TODO + ctx, cancel := context.WithCancel(parent) + he := &HashEngine{cancel: cancel} + he.gatherer = newBlockGatherer(cfg, newLeafHasher(pool, moduleParser, cfg.ChunkSize), ctx, he.brick) + he.combiner = newHashCombiner( + dbNames, seed, he.gatherer.combineJobChan, ctx, cfg.HashChanSize, he.brick) + return he, nil } // Schedule a block to be hashed. -func (he *HashEngine) ScheduleHash(current *storeView, previous *storeView) error { // TODO Claude: we need to move storeView and the atomic store view to a new package called flatkv/sview - // TODO +// +// The engine takes its own reservation on both views and releases it once it has read them. The +// caller keeps its own. +func (he *HashEngine) ScheduleHash( + // This block's sealed view. + current *sview.StoreView, + // The preceding block's view, which is where each changed key's value before the block is read from. + previous *sview.StoreView, +) error { + if current == nil || previous == nil { + return fmt.Errorf("schedule hash: current and previous views are both required") + } + request := &hashRequest{ + blockNumber: current.BlockHeight(), + current: current, + previous: previous, + } + if err := request.reserve(); err != nil { + return fmt.Errorf("schedule hash for block %d: %w", request.blockNumber, err) + } + if err := he.enqueue(request); err != nil { + return errors.Join( + fmt.Errorf("schedule hash for block %d: %w", request.blockNumber, err), + request.release()) + } + return nil +} - // Three phases of hashing, which should be fully pipelined. - // 1. collect key-value pairs we need to hash from the storeView objects, we can use a single worker thread for this - // 2. fan out to thread pool to hash key-value pairs, ok if multiple blocks are in this phase at once - // 3. single thread that stitches hashes together, on block at a time in block order (since block N depends on block N-1) +// Returns a channel that returns block hashes, as they are computed. +// +// One entry per block hashed, in block order, with no gaps or duplicates. A block whose hashing failed +// arrives with Error set and nothing is published after it. The channel closes when the engine does, +// which abandons anything it had not reached. It has finite depth, so a consumer that stops reading +// eventually stalls ScheduleHash(). +func (he *HashEngine) AwaitHash() <-chan *BlockHash { + return he.combiner.blockHashChan +} - // Phase 1 and 3 should have a dedicated goroutine, phase 2 should use the pool in the constructor. - // Communication to and from each of these phases should happen via channels. - // - channel from ScheduleHash to phase 1 worker - // - channel from phase 1 worker to each of the pool workers (managed internally by the pool) - // - channel from each phase 2 worker to the phase 3 worker - // - channel from phase 3 worker to AwaitHash() +// Flush blocks until the engine has published a hash for every block scheduled so far. +func (he *HashEngine) Flush() error { + request := newFlushRequest() + if err := he.enqueue(request); err != nil { + return fmt.Errorf("flush hash engine: %w", err) + } + <-request.doneChan + if err := he.errorIfBricked(); err != nil { + return fmt.Errorf("flush hash engine: %w", err) + } + return nil +} +// Close stops the engine and waits for it to finish, reporting the latched error if it failed. +// +// Never call concurrently with another method: behaviour is undefined if anything else is in flight. +// Blocks that have been scheduled but not yet hashed are abandoned rather than +// finished — their reservations are released, and their rows are still in the WAL for replay to +// recover. +func (he *HashEngine) Close() error { + he.cancel() + he.gatherer.wg.Wait() + he.combiner.wg.Wait() + if err := he.errorIfBricked(); err != nil { + return fmt.Errorf("close hash engine: %w", err) + } return nil } -// Returns a channel that returns block hashes, as they are computed. -func (he *HashCalculator) AwaitHash() <-chan *BlockHash { - return nil // TODO +// enqueue puts a message on the gatherer's queue, blocking while it is full. Cleaning up after a message +// it could not deliver belongs to the caller, which is the only one that knows whether the message owns +// anything. +func (he *HashEngine) enqueue(message any) error { + if err := he.errorIfBricked(); err != nil { + return fmt.Errorf("hash engine failed: %w", err) + } + he.gatherer.scheduledBlockChan <- message + return nil +} + +// brick latches err as the engine's fatal error and stops it. +func (he *HashEngine) brick(err error) { + he.fatalErr.CompareAndSwap(nil, &err) +} + +// errorIfBricked reports the latched error, or nil if the engine has not failed. +func (he *HashEngine) errorIfBricked() error { + if err := he.fatalErr.Load(); err != nil { + return *err + } + return nil } diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_engine_config.go b/sei-db/state_db/sc/flatkv/lthash/hash_engine_config.go new file mode 100644 index 0000000000..7328b00ae4 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine_config.go @@ -0,0 +1,51 @@ +package lthash + +import "fmt" + +// Config configures a HashEngine. The three queue sizes bound how far hashing may fall behind. +type Config struct { + // ScheduleQueueSize is how many scheduled blocks may be waiting to be read before ScheduleHash() + // blocks. A block waiting here still holds its views, which stops its databases flushing, so this is + // what bounds the memory the engine costs. + ScheduleQueueSize uint32 + + // CombineQueueSize is how many blocks may be part way through hashing at once. Their views have been + // released, so each costs the memory of its changed values rather than of a pinned database. + CombineQueueSize uint32 + + // HashChanSize is the depth of the channel AwaitHash() reads from. Headroom for a consumer that reads + // later than it schedules; one that stops reading entirely stalls the engine. + HashChanSize uint32 + + // ChunkSize is how many KV pairs one leaf-hash task carries. Splitting a module's pairs into + // fixed-size chunks lets a single large module, such as the EVM storage database in a big block, fan + // out across many workers instead of pinning one. + ChunkSize uint32 +} + +// DefaultConfig returns the default HashEngine configuration. +func DefaultConfig() *Config { + return &Config{ + ScheduleQueueSize: 64, + CombineQueueSize: 8, + HashChanSize: 1024, + ChunkSize: 128, + } +} + +// Validate reports whether this configuration can be used to build an engine. +func (c *Config) Validate() error { + if c.ScheduleQueueSize == 0 { + return fmt.Errorf("schedule queue size must be greater than 0") + } + if c.CombineQueueSize == 0 { + return fmt.Errorf("combine queue size must be greater than 0") + } + if c.HashChanSize == 0 { + return fmt.Errorf("hash chan size must be greater than 0") + } + if c.ChunkSize == 0 { + return fmt.Errorf("chunk size must be greater than 0") + } + return nil +} diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_engine_messages.go b/sei-db/state_db/sc/flatkv/lthash/hash_engine_messages.go new file mode 100644 index 0000000000..e708db644c --- /dev/null +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine_messages.go @@ -0,0 +1,82 @@ +package lthash + +import ( + "errors" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" +) + +// The messages the phases send one another, and the state one block carries as it moves between them. + +// hashRequest is one sealed block for the engine to hash. +type hashRequest struct { + // blockNumber is the height being hashed. + blockNumber int64 + + // current is the block's own sealed view. The gatherer reads this block's diff from it. + current *sview.StoreView + + // previous is the preceding block's view. The lattice hash is a delta, so every changed key's prior + // value is read here — and holding this reservation is what keeps the databases at the preceding + // version while that read happens. Releasing it early yields a wrong hash, silently. + previous *sview.StoreView +} + +// reserve takes this request's own reservation on both views, so that neither can be torn down while the +// engine still has to read it. On failure it releases whatever it took. +func (r *hashRequest) reserve() error { + if err := r.current.Reserve(); err != nil { + return fmt.Errorf("reserve block %d: %w", r.blockNumber, err) + } + if err := r.previous.Reserve(); err != nil { + return errors.Join( + fmt.Errorf("reserve block %d's predecessor: %w", r.blockNumber, err), + r.current.Release()) + } + return nil +} + +// Releases the reservations this request owns, so the databases can resume flushing. +// +// Both are released even if one fails, because a reservation left held stalls its database's flushes +// indefinitely. +func (r *hashRequest) release() error { + currentErr := r.current.Release() + previousErr := r.previous.Release() + if currentErr != nil { + return currentErr + } + return previousErr +} + +// gatheredBlock is one block the gather phase has finished with, waiting to be folded onto the +// running hash. +type gatheredBlock struct { + // blockNumber is the height this job hashes. + blockNumber int64 + + // hashes is this block's leaf hashing in flight, which the combiner drains to completion. + hashes leafHashes + + // err is set when the gatherer could not produce this block's chunks at all, in which case hashes is + // zero and the combiner fails the block rather than reading results. + err error +} + +// chunkResult is one chunk of one block, folded. +type chunkResult struct { + key ModuleKey + info *ModuleHashInfo +} + +// flushRequest asks the engine to report once it has dealt with everything queued ahead of it. +type flushRequest struct { + // done is closed once every message queued ahead of this one has been dealt with. A channel rather + // than a value, so the engine answering it can never block on a caller that has given up. + doneChan chan struct{} +} + +func newFlushRequest() *flushRequest { + return &flushRequest{doneChan: make(chan struct{})} +} diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_engine_test.go b/sei-db/state_db/sc/flatkv/lthash/hash_engine_test.go new file mode 100644 index 0000000000..d0d0c34468 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine_test.go @@ -0,0 +1,359 @@ +package lthash + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/common/threading" + "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" +) + +// These tests drive the engine over stub views, so they exercise the pipeline — ordering, backpressure, +// shutdown, failure — rather than the arithmetic, which lthash_test.go and the store's golden archive +// cover. + +const ( + engineAccountName = "account" + engineCodeName = "code" + engineStorageName = "storage" + engineMiscName = "misc" +) + +var engineDBNames = []string{engineAccountName, engineCodeName, engineStorageName, engineMiscName} + +// engineModuleOf puts every key in one module, which is all these tests need to distinguish. +func engineModuleOf([]byte) (string, error) { return "m", nil } + +var _ view.View = (*pipeView)(nil) + +// pipeView is a view holding one block's diff for one database, over a fixed prior state. Only the +// three methods the gatherer reaches are implemented. +type pipeView struct { + name string + + // diff is what this block changed, as GetDiff reports it. A nil value is a deletion. + diff map[string][]byte + + // prior is the state BatchGet answers from, i.e. what the keys held before this block. + prior map[string][]byte + + // getDiffErr, when set, fails the read. + getDiffErr error + + // reserves and releases count reservations, so a test can assert the engine balanced them. + reserves int + releases int +} + +func (v *pipeView) Name() string { return v.name } + +func (v *pipeView) GetDiff() (map[string][]byte, error) { + if v.getDiffErr != nil { + return nil, v.getDiffErr + } + return v.diff, nil +} + +func (v *pipeView) BatchGet(keys [][]byte) (map[string][]byte, error) { + out := make(map[string][]byte, len(keys)) + for _, key := range keys { + if value, ok := v.prior[string(key)]; ok { + out[string(key)] = value + } + } + return out, nil +} + +func (v *pipeView) Reserve() error { v.reserves++; return nil } +func (v *pipeView) Release() error { v.releases++; return nil } + +func (v *pipeView) Get([]byte, bool) ([]byte, bool, error) { panic("pipeView: unexpected Get") } +func (v *pipeView) Finalize([]*proto.KVPair) error { panic("pipeView: unexpected Finalize") } +func (v *pipeView) AwaitFlush(context.Context) error { panic("pipeView: unexpected AwaitFlush") } + +// blockViews builds the pair of store views for one block: current carries the diff, previous answers +// for the values it replaced. +func blockViews( + t *testing.T, + height int64, + diff map[string][]byte, + prior map[string][]byte, +) (current *sview.StoreView, previous *sview.StoreView, views []*pipeView) { + t.Helper() + var currents, previouses []view.View + for _, dbName := range engineDBNames { + // The whole diff goes to the account database; the rest are untouched, as most blocks leave + // most databases alone. + blockDiff := map[string][]byte{} + if dbName == engineAccountName { + blockDiff = diff + } + cur := &pipeView{name: dbName, diff: blockDiff} + prev := &pipeView{name: dbName, prior: prior} + views = append(views, cur, prev) + currents = append(currents, cur) + previouses = append(previouses, prev) + } + current, err := sview.NewStoreView(height, currents[0], currents[1], currents[2], currents[3]) + require.NoError(t, err) + previous, err = sview.NewStoreView(height-1, previouses[0], previouses[1], previouses[2], previouses[3]) + require.NoError(t, err) + return current, previous, views +} + +// newTestEngine builds an engine over a small pool, with the given channel depths. +func newTestEngine(t *testing.T, schedule uint32, fold uint32, hashes uint32) *HashEngine { + t.Helper() + pool := threading.NewFixedPool("lthash-engine-test", 4, 64) + t.Cleanup(pool.Close) + + cfg := DefaultConfig() + cfg.ScheduleQueueSize = schedule + cfg.CombineQueueSize = fold + cfg.HashChanSize = hashes + + engine, err := NewHashEngine(t.Context(), cfg, pool, engineDBNames, engineModuleOf, NewBlockHash(engineDBNames)) + require.NoError(t, err) + return engine +} + +// blockDiff builds a diff of n distinct keys for the given height. +func blockDiff(height int64, n int) map[string][]byte { + diff := make(map[string][]byte, n) + for i := 0; i < n; i++ { + diff[fmt.Sprintf("m/key-%03d", i)] = []byte(fmt.Sprintf("v-%d-%d", height, i)) + } + return diff +} + +// A block's hash must be the same whether it went through the pipeline or was computed in one call, or +// the pipeline has changed the answer — which is the one thing it must not do. +// The reference the engine's pipeline is checked against: hash one block's mutations and combine them +// in one call on this goroutine, with no pipelining and no previous block. +func compute( + pool threading.Pool, + dbNames []string, + moduleOf ModuleParser, + mutations []DatabaseMutations, + // How many KV pairs each task carries. + chunkSize uint32, +) (*BlockHash, error) { + deltas, err := ComputeModuleHashInfos(pool, moduleOf, mutations, chunkSize) + if err != nil { + return nil, err + } + return combine(dbNames, deltas, nil, nil, nil), nil +} + +func TestHashEngineAgreesWithSynchronousCompute(t *testing.T) { + pool := threading.NewFixedPool("lthash-sync", 4, 64) + defer pool.Close() + + diff := blockDiff(1, 250) + prior := map[string][]byte{"m/key-000": []byte("old"), "m/key-001": []byte("older")} + + engine := newTestEngine(t, 4, 4, 4) + current, previous, _ := blockViews(t, 1, diff, prior) + require.NoError(t, engine.ScheduleHash(current, previous)) + got := <-engine.AwaitHash() + require.NoError(t, got.Error) + require.NoError(t, engine.Close()) + + // The same block, folded in one call against the same empty starting state. + mutations, err := gatherChangesFromAllStores(mustViews(t, 1, diff, prior)) + require.NoError(t, err) + want, err := compute(pool, engineDBNames, engineModuleOf, mutations, DefaultConfig().ChunkSize) + require.NoError(t, err) + + require.Equal(t, want.Global.Checksum(), got.Global.Checksum(), + "the pipeline must produce the hash a single-call fold produces") + require.Equal(t, int64(1), got.BlockNumber) +} + +// mustViews is blockViews without the stub handles, for a caller that only wants the views. +func mustViews(t *testing.T, height int64, diff map[string][]byte, prior map[string][]byte) ( + *sview.StoreView, *sview.StoreView, +) { + t.Helper() + current, previous, _ := blockViews(t, height, diff, prior) + return current, previous +} + +// The stream's contract is exactly one hash per block, in block order. This schedules more blocks than +// the combine queue holds without reading any of them, so the gatherer runs ahead and several blocks are +// being hashed at once, then checks the order that came out. +func TestHashEngineStreamsOneHashPerBlockInOrder(t *testing.T) { + const blocks = 12 + engine := newTestEngine(t, 2, 2, blocks) + + for height := int64(1); height <= blocks; height++ { + current, previous := mustViews(t, height, blockDiff(height, 120), nil) + require.NoError(t, engine.ScheduleHash(current, previous)) + } + + for height := int64(1); height <= blocks; height++ { + got := <-engine.AwaitHash() + require.NoError(t, got.Error) + require.Equal(t, height, got.BlockNumber, "hashes must arrive in block order with no gaps") + } + require.NoError(t, engine.Close()) + + _, open := <-engine.AwaitHash() + require.False(t, open, "the stream closes once a stopped engine has drained") +} + +// The gatherer owns both blocks' reservations and must hand them back as soon as it has read them — +// a reservation left held stalls its database's flushes forever. +func TestHashEngineReleasesBothViews(t *testing.T) { + engine := newTestEngine(t, 4, 4, 4) + + current, previous, views := blockViews(t, 1, blockDiff(1, 10), nil) + require.NoError(t, engine.ScheduleHash(current, previous)) + require.NoError(t, (<-engine.AwaitHash()).Error) + require.NoError(t, engine.Close()) + + for _, v := range views { + require.Equal(t, 1, v.reserves, "%s: the engine must take its own reservation", v.name) + require.Equal(t, 1, v.releases, "%s: the engine must release the reservation it took", v.name) + } +} + +// An engine built with a seed measures its first block against that seed, which is how a store with +// history avoids folding block N onto an empty predecessor. +func TestHashEngineStartsFromItsSeed(t *testing.T) { + engine := newTestEngine(t, 4, 4, 4) + current, previous := mustViews(t, 1, blockDiff(1, 40), nil) + require.NoError(t, engine.ScheduleHash(current, previous)) + fromEmpty := (<-engine.AwaitHash()).Global.Checksum() + require.NoError(t, engine.Close()) + + pool := threading.NewFixedPool("lthash-seeded", 4, 64) + defer pool.Close() + cfg := DefaultConfig() + seeded, err := NewHashEngine(t.Context(), cfg, pool, engineDBNames, engineModuleOf, seedWithOneBlock(t)) + require.NoError(t, err) + current, previous = mustViews(t, 2, blockDiff(2, 40), nil) + require.NoError(t, seeded.ScheduleHash(current, previous)) + fromSeed := (<-seeded.AwaitHash()).Global.Checksum() + require.NoError(t, seeded.Close()) + + require.NotEqual(t, fromEmpty, fromSeed, "a seeded engine must not hash as though it had no history") +} + +// seedWithOneBlock returns the state a store would have loaded after one block. +func seedWithOneBlock(t *testing.T) *BlockHash { + t.Helper() + engine := newTestEngine(t, 4, 4, 4) + current, previous := mustViews(t, 1, blockDiff(1, 40), nil) + require.NoError(t, engine.ScheduleHash(current, previous)) + seed := <-engine.AwaitHash() + require.NoError(t, seed.Error) + require.NoError(t, engine.Close()) + return seed +} + +// Flush is the barrier a caller uses when it needs the engine to have caught up with what it scheduled. +func TestHashEngineFlushWaitsForScheduledBlocks(t *testing.T) { + const blocks = 6 + engine := newTestEngine(t, blocks, 2, blocks) + defer func() { require.NoError(t, engine.Close()) }() + + for height := int64(1); height <= blocks; height++ { + current, previous := mustViews(t, height, blockDiff(height, 80), nil) + require.NoError(t, engine.ScheduleHash(current, previous)) + } + require.NoError(t, engine.Flush()) + + // Every hash is already on the stream, so reading them cannot block. + for height := int64(1); height <= blocks; height++ { + select { + case got := <-engine.AwaitHash(): + require.Equal(t, height, got.BlockNumber) + default: + t.Fatalf("Flush returned before block %d was published", height) + } + } +} + +// Close abandons whatever it has not reached rather than finishing it, but every abandoned block still +// has to hand its reservations back — a view left reserved can never flush, and the store above could +// never finish tearing down. +func TestHashEngineCloseAbandonsAndReleases(t *testing.T) { + const blocks = 5 + engine := newTestEngine(t, blocks, blocks, blocks) + + var scheduled [][]*pipeView + for height := int64(1); height <= blocks; height++ { + current, previous, views := blockViews(t, height, blockDiff(height, 30), nil) + scheduled = append(scheduled, views) + require.NoError(t, engine.ScheduleHash(current, previous)) + } + + require.NoError(t, engine.Close()) + + for _, views := range scheduled { + for _, v := range views { + require.Equal(t, 1, v.reserves, "%s: the engine must take its own reservation", v.name) + require.Equal(t, 1, v.releases, + "%s: a block abandoned at Close must still hand its reservation back", v.name) + } + } + + // Whatever was hashed before the stop is on the stream, and the stream is closed behind it. + for range engine.AwaitHash() { + } +} + +// The first failure is delivered on the stream, and nothing is published after it: once a block has +// failed, the accumulator describes nothing a later block may be derived from. +func TestHashEngineDeliversFailureAndStops(t *testing.T) { + engine := newTestEngine(t, 4, 4, 4) + + current, previous, views := blockViews(t, 1, blockDiff(1, 10), nil) + for _, v := range views { + v.getDiffErr = errors.New("injected diff failure") + } + require.NoError(t, engine.ScheduleHash(current, previous)) + + got := <-engine.AwaitHash() + require.Error(t, got.Error) + require.ErrorContains(t, got.Error, "injected diff failure") + require.Equal(t, int64(1), got.BlockNumber) + + require.ErrorContains(t, engine.Close(), "injected diff failure") + + _, open := <-engine.AwaitHash() + require.False(t, open, "nothing is published after the failure, and the stream closes with the engine") + + for _, v := range views { + require.Equal(t, 1, v.reserves, "%s: the engine must take its own reservation", v.name) + require.Equal(t, 1, v.releases, "%s: a failed read must still hand its reservation back", v.name) + } +} + +// A failed engine refuses further work rather than accepting blocks it will never hash. +func TestHashEngineRefusesWorkAfterFailure(t *testing.T) { + engine := newTestEngine(t, 4, 4, 4) + + current, previous, views := blockViews(t, 1, blockDiff(1, 10), nil) + for _, v := range views { + v.getDiffErr = errors.New("injected diff failure") + } + require.NoError(t, engine.ScheduleHash(current, previous)) + require.Error(t, (<-engine.AwaitHash()).Error) + + next, nextPrev, nextViews := blockViews(t, 2, blockDiff(2, 10), nil) + err := engine.ScheduleHash(next, nextPrev) + require.Error(t, err, "a failed engine must refuse a block rather than swallow it") + for _, v := range nextViews { + require.Equal(t, 1, v.reserves, "%s: the engine must take its own reservation", v.name) + require.Equal(t, 1, v.releases, "%s: a refused block's reservation must be released", v.name) + } + require.ErrorContains(t, engine.Close(), "injected diff failure") +} diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_types.go b/sei-db/state_db/sc/flatkv/lthash/hash_types.go new file mode 100644 index 0000000000..6ee983da5d --- /dev/null +++ b/sei-db/state_db/sc/flatkv/lthash/hash_types.go @@ -0,0 +1,65 @@ +package lthash + +// The vocabulary shared by everything that hashes a block: the engine's inputs, its per-module +// intermediates, and the state it produces. + +// KeyMutation holds a KV change for LtHash computation. +type KeyMutation struct { + Key []byte + Value []byte + LastValue []byte // Previous value (nil for new keys) + Delete bool // If true, only remove last value +} + +// DatabaseMutations is everything one database changed in a block. +type DatabaseMutations struct { + DBName string + Mutations []KeyMutation +} + +// ModuleParser extracts the owning module name from a physical key. Injected by +// the caller so that hashing stays decoupled from the key-encoding package. +type ModuleParser func(physicalKey []byte) (module string, err error) + +// ModuleKey identifies a single (database, module) accumulator. +type ModuleKey struct { + DBName string + Module string +} + +// ModuleHashInfo is the per-(database, module) change computed for one block/batch: +// the homomorphic hash delta plus the net key-count and byte deltas implied by +// the same MixIn/MixOut transitions. +type ModuleHashInfo struct { + Hash *LtHash + KeyCount int64 + Bytes int64 +} + +// BlockHash is the complete lattice hash state as of one block: what hashing a block produces, what the +// engine publishes, and what it is seeded from. A value may be held for as long as its reader wants, +// and later blocks do not disturb it. +type BlockHash struct { + // BlockNumber is the height this state describes. + BlockNumber int64 + + // PerDB is each data database's lattice hash root, with an entry for every database the engine was + // configured with, so a caller can swap the map in wholesale. + PerDB map[string]*LtHash + + // PerModule is each database's per-module lattice hashes, keyed by database name then module. A + // database's root in PerDB is the homomorphic sum of its entries here. + PerModule map[string]map[string]*LtHash + + // PerModuleStats is each database's per-module key-count and byte totals, combined alongside the + // hashes and by the same membership rule. Consensus-irrelevant, but persisted and validated on load. + PerModuleStats map[string]map[string]ModuleStats + + // Global is the store-wide root, the homomorphic sum of the per-DB roots. This is the value that + // reaches consensus. + Global *LtHash + + // Error is the failure that stopped this block from being hashed, and is set only on a value + // delivered by the engine's stream. Nil everywhere else, including on a seed. + Error error +} diff --git a/sei-db/state_db/sc/flatkv/lthash/leaf_hasher.go b/sei-db/state_db/sc/flatkv/lthash/leaf_hasher.go new file mode 100644 index 0000000000..e629776bbd --- /dev/null +++ b/sei-db/state_db/sc/flatkv/lthash/leaf_hasher.go @@ -0,0 +1,226 @@ +package lthash + +import ( + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/common/threading" +) + +// The hash phase: turning a block's changed key-value pairs into one homomorphic delta per (database, +// module). Nothing here depends on any other block, which is what lets several blocks be hashed at once. + +// leafHasher turns one block's mutations into leaf hashes, fanned out across the pool. +type leafHasher struct { + // Computes the leaf hashes. Owned by the caller, and must stay open at least as long as this. + pool threading.Pool + + // Derives the module a raw key belongs to, which is how a block's mutations are bucketed. + moduleParser ModuleParser + + // How many KV pairs each task carries. + chunkSize uint32 +} + +// leafHashes is one block's leaf hashing in flight: exactly count results arrive on resultChan, in +// whatever order the workers finish. +// +// The channel is per-block, which is what keeps blocks from interleaving while several fold at once. It +// is buffered to count so a worker never blocks on send, which would hold a pool slot against the +// combiner. +type leafHashes struct { + count int + resultChan chan *chunkResult +} + +func newLeafHasher(pool threading.Pool, moduleParser ModuleParser, chunkSize uint32) *leafHasher { + return &leafHasher{pool: pool, moduleParser: moduleParser, chunkSize: chunkSize} +} + +// Submits one block's leaf hashing, returning the results still to arrive. +func (h *leafHasher) submit(mutations []DatabaseMutations) (leafHashes, error) { + tasks, err := buildTasks(h.moduleParser, mutations, h.chunkSize) + if err != nil { + return leafHashes{}, err + } + + pending := leafHashes{count: len(tasks), resultChan: make(chan *chunkResult, len(tasks))} + for i := range tasks { + task := tasks[i] + h.pool.Submit(func() { + pending.resultChan <- &chunkResult{key: task.key, info: hashChunk(task.mutations)} + }) + } + return pending, nil +} + +// ComputeModuleHashInfos buckets each database's mutations by module, splits every bucket into fixed-size +// and distributes those chunks across pool to compute the per-(database, module) homomorphic hash delta and +// the accompanying key-count / byte deltas. +// +// Each chunk is an independent, self-terminating task, so this is safe to call concurrently from +// several goroutines sharing one pool — the state-sync importer runs a goroutine per DB. It never holds +// a worker while waiting on another task, so no oversubscription or deadlock can arise from the nesting. +func ComputeModuleHashInfos( + pool threading.Pool, + moduleOf ModuleParser, + mutations []DatabaseMutations, + // How many KV pairs each task carries. + chunkSize uint32, +) (map[ModuleKey]*ModuleHashInfo, error) { + tasks, err := buildTasks(moduleOf, mutations, chunkSize) + if err != nil { + return nil, err + } + if len(tasks) == 0 { + return nil, nil + } + return hashChunks(pool, tasks), nil +} + +// lthashTask is one unit of parallel work: a chunk of pairs that all belong to +// a single (database, module) bucket. +type lthashTask struct { + key ModuleKey + mutations []KeyMutation +} + +// buildTasks buckets each database's mutations by module and splits every bucket into fixed-size +// tasks. +func buildTasks(moduleOf ModuleParser, mutations []DatabaseMutations, chunkSize uint32) ([]lthashTask, error) { + size := int(chunkSize) + var tasks []lthashTask + for _, dbMutations := range mutations { + if len(dbMutations.Mutations) == 0 { + continue + } + byModule, err := BucketByModule(dbMutations.Mutations, moduleOf) + if err != nil { + return nil, fmt.Errorf("failed to bucket %s mutations by module: %w", dbMutations.DBName, err) + } + for module, moduleMutations := range byModule { + for start := 0; start < len(moduleMutations); start += size { + end := start + size + if end > len(moduleMutations) { + end = len(moduleMutations) + } + tasks = append(tasks, lthashTask{ + key: ModuleKey{DBName: dbMutations.DBName, Module: module}, + mutations: moduleMutations[start:end], + }) + } + } + } + return tasks, nil +} + +// ComputeLtHash applies mutations to prev and returns the result. A nil prev starts from zero. +func ComputeLtHash(prev *LtHash, mutations []KeyMutation) *LtHash { + result := New() + if prev != nil { + result = prev.Clone() + } + result.MixIn(hashChunk(mutations).Hash) + return result +} + +// hashChunk computes the homomorphic hash delta and the net key-count / byte +// deltas for one chunk of pairs. Key presence is defined exactly as the hash +// defines it: a prior value exists iff LastValue is non-empty (an unmix), and a +// new value exists iff the entry is not a delete and Value is non-empty (a mix). +// - add (!old, new): +1 key, + (len(key)+len(newVal)) bytes +// - update ( old, new): 0 keys, + (len(newVal)-len(oldVal)) bytes +// - delete ( old, !new): -1 key, - (len(key)+len(oldVal)) bytes +// - no-op (!old, !new): unchanged (delete of an absent key) +func hashChunk(mutations []KeyMutation) *ModuleHashInfo { + d := &ModuleHashInfo{Hash: New()} + for _, mutation := range mutations { + // A member exists iff serializeKV would produce a non-nil buffer, i.e. + // key and value are both non-empty. Keeping these predicates identical + // to the mix conditions guarantees the stats track exactly the set the + // hash represents. + hadOld := len(mutation.Key) > 0 && len(mutation.LastValue) > 0 + hasNew := len(mutation.Key) > 0 && !mutation.Delete && len(mutation.Value) > 0 + if hadOld { + h := hash(serializeKV(mutation.Key, mutation.LastValue)) + d.Hash.MixOut(h) + putLtHashToPool(h) + } + if hasNew { + h := hash(serializeKV(mutation.Key, mutation.Value)) + d.Hash.MixIn(h) + putLtHashToPool(h) + } + switch { + case !hadOld && hasNew: + d.KeyCount++ + d.Bytes += int64(len(mutation.Key)) + int64(len(mutation.Value)) + case hadOld && hasNew: + d.Bytes += int64(len(mutation.Value)) - int64(len(mutation.LastValue)) + case hadOld && !hasNew: + d.KeyCount-- + d.Bytes -= int64(len(mutation.Key)) + int64(len(mutation.LastValue)) + } + } + return d +} + +// mergeDelta folds src into dst (hash + counts). dst must be non-nil. +func mergeDelta(dst, src *ModuleHashInfo) { + dst.Hash.MixIn(src.Hash) + dst.KeyCount += src.KeyCount + dst.Bytes += src.Bytes +} + +// hashChunks distributes tasks across pool as independent, self-terminating +// units — one fold per chunk — then merges results as they arrive. A buffered +// result channel (capacity = task count) ensures workers never block on send, so +// a full pool queue only backpressures the submitter while already-running chunks +// drain. This is safe when several goroutines share one pool (the importer's +// per-DB workers all call through here). MixIn/addition are commutative, so merge +// order does not matter. +func hashChunks(pool threading.Pool, tasks []lthashTask) map[ModuleKey]*ModuleHashInfo { + type result struct { + key ModuleKey + info *ModuleHashInfo + } + // Buffer must be large enough for every task: we submit all work before + // draining results, and Submit can block when the pool queue is full. If a + // finished worker then blocked on an unbuffered send here, nothing would + // free a queue slot and we'd deadlock. + resultChan := make(chan result, len(tasks)) + for i := range tasks { + task := tasks[i] + pool.Submit(func() { + resultChan <- result{key: task.key, info: hashChunk(task.mutations)} + }) + } + + merged := make(map[ModuleKey]*ModuleHashInfo) + for range tasks { + r := <-resultChan + if acc := merged[r.key]; acc != nil { + mergeDelta(acc, r.info) + } else { + merged[r.key] = r.info + } + } + return merged +} + +// BucketByModule groups mutations by their owning module, derived from each +// physical key via moduleOf. Used to decompose a per-DB root into additive +// per-module hashes without changing the root. +func BucketByModule( + mutations []KeyMutation, + moduleOf ModuleParser, +) (map[string][]KeyMutation, error) { + byModule := make(map[string][]KeyMutation) + for _, mutation := range mutations { + module, err := moduleOf(mutation.Key) + if err != nil { + return nil, err + } + byModule[module] = append(byModule[module], mutation) + } + return byModule, nil +} diff --git a/sei-db/state_db/sc/flatkv/lthash/lthash_test.go b/sei-db/state_db/sc/flatkv/lthash/lthash_test.go index d9827e1e5a..f1bde0b18c 100644 --- a/sei-db/state_db/sc/flatkv/lthash/lthash_test.go +++ b/sei-db/state_db/sc/flatkv/lthash/lthash_test.go @@ -17,7 +17,7 @@ func TestLtHashBasic(t *testing.T) { } // Test via ComputeLtHash - lth1, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + lth1 := ComputeLtHash(nil, []KeyMutation{ {Key: []byte("key"), Value: []byte("value")}, }) if lth1.IsZero() { @@ -43,12 +43,12 @@ func TestLtHashBasic(t *testing.T) { } func TestLtHashDeterminism(t *testing.T) { - kvPairs := []KVPairWithLastValue{ + mutations := []KeyMutation{ {Key: []byte("key"), Value: []byte("test data for determinism")}, } - lth1, _ := ComputeLtHash(nil, kvPairs) - lth2, _ := ComputeLtHash(nil, kvPairs) + lth1 := ComputeLtHash(nil, mutations) + lth2 := ComputeLtHash(nil, mutations) if !bytes.Equal(lth1.Marshal(), lth2.Marshal()) { t.Error("ComputeLtHash should be deterministic") @@ -61,10 +61,10 @@ func TestLtHashDeterminism(t *testing.T) { func TestHashKVNoCollision(t *testing.T) { // Verify length-prefixing prevents key||value concatenation collisions - lth1, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + lth1 := ComputeLtHash(nil, []KeyMutation{ {Key: []byte("a"), Value: []byte("bc")}, }) - lth2, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + lth2 := ComputeLtHash(nil, []KeyMutation{ {Key: []byte("ab"), Value: []byte("c")}, }) @@ -75,16 +75,12 @@ func TestHashKVNoCollision(t *testing.T) { func TestComputeLtHash(t *testing.T) { // Empty input - result, timings := ComputeLtHash(nil, nil) + result := ComputeLtHash(nil, nil) if !result.IsZero() { t.Error("Empty changeset should produce zero") } - if timings == nil { - t.Error("Timings should not be nil") - } - // Insert - result, _ = ComputeLtHash(nil, []KVPairWithLastValue{ + result = ComputeLtHash(nil, []KeyMutation{ {Key: []byte("key1"), Value: []byte("value1")}, }) if result.IsZero() { @@ -92,10 +88,10 @@ func TestComputeLtHash(t *testing.T) { } // Insert then delete should cancel out - result1, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + result1 := ComputeLtHash(nil, []KeyMutation{ {Key: []byte("key1"), Value: []byte("value1")}, }) - result2, _ := ComputeLtHash(result1, []KVPairWithLastValue{ + result2 := ComputeLtHash(result1, []KeyMutation{ {Key: []byte("key1"), LastValue: []byte("value1"), Delete: true}, }) if !result2.IsZero() { @@ -103,14 +99,14 @@ func TestComputeLtHash(t *testing.T) { } // Update: old value replaced with new value - initial, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + initial := ComputeLtHash(nil, []KeyMutation{ {Key: []byte("key1"), Value: []byte("value1")}, }) - updated, _ := ComputeLtHash(initial, []KVPairWithLastValue{ + updated := ComputeLtHash(initial, []KeyMutation{ {Key: []byte("key1"), Value: []byte("value2"), LastValue: []byte("value1")}, }) // updated should equal direct insert of value2 - direct, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + direct := ComputeLtHash(nil, []KeyMutation{ {Key: []byte("key1"), Value: []byte("value2")}, }) if updated.Checksum() != direct.Checksum() { @@ -119,28 +115,22 @@ func TestComputeLtHash(t *testing.T) { } func TestComputeLtHashLarge(t *testing.T) { - kvPairs := make([]KVPairWithLastValue, 500) - for i := range kvPairs { - kvPairs[i] = KVPairWithLastValue{ + mutations := make([]KeyMutation, 500) + for i := range mutations { + mutations[i] = KeyMutation{ Key: []byte{byte(i >> 8), byte(i)}, Value: []byte{byte(i), byte(i >> 8)}, } } - result, timings := ComputeLtHash(nil, kvPairs) + result := ComputeLtHash(nil, mutations) if result.IsZero() { t.Error("Large changeset should produce non-zero result") } - if timings.TotalNs <= 0 { - t.Error("Total time should be positive") - } - if timings.Blake3Ns <= 0 { - t.Error("Blake3 time should be positive") - } } func TestUnmarshal(t *testing.T) { - original, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + original := ComputeLtHash(nil, []KeyMutation{ {Key: []byte("key"), Value: []byte("test data")}, }) rawBytes := original.Marshal() @@ -161,7 +151,7 @@ func TestUnmarshal(t *testing.T) { } func TestChecksumHex(t *testing.T) { - lth, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + lth := ComputeLtHash(nil, []KeyMutation{ {Key: []byte("key"), Value: []byte("hello")}, }) checksum := lth.Checksum() @@ -172,7 +162,7 @@ func TestChecksumHex(t *testing.T) { } func TestReset(t *testing.T) { - lth, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + lth := ComputeLtHash(nil, []KeyMutation{ {Key: []byte("key"), Value: []byte("data")}, }) if lth.IsZero() { @@ -186,14 +176,14 @@ func TestReset(t *testing.T) { func TestEmptyKeyOrValue(t *testing.T) { // Empty key or value should be skipped - result, _ := ComputeLtHash(nil, []KVPairWithLastValue{ + result := ComputeLtHash(nil, []KeyMutation{ {Key: nil, Value: []byte("value")}, }) if !result.IsZero() { t.Error("Empty key should be skipped") } - result, _ = ComputeLtHash(nil, []KVPairWithLastValue{ + result = ComputeLtHash(nil, []KeyMutation{ {Key: []byte("key"), Value: nil}, }) if !result.IsZero() { @@ -206,18 +196,18 @@ func TestEmptyKeyOrValue(t *testing.T) { func TestParallelConsistency(t *testing.T) { // Create enough pairs to trigger parallel path (> 100) count := 500 - kvPairs := make([]KVPairWithLastValue, count) + mutations := make([]KeyMutation, count) for i := 0; i < count; i++ { key := fmt.Sprintf("key-%d", i) val := fmt.Sprintf("val-%d", i) - kvPairs[i] = KVPairWithLastValue{ + mutations[i] = KeyMutation{ Key: []byte(key), Value: []byte(val), } } // 1. Run with parallel workers (default) - parallelResult, _ := ComputeLtHash(nil, kvPairs) + parallelResult := ComputeLtHash(nil, mutations) // 2. Run strictly serial by forcing computeDeltaSerial logic via small chunks or mock? // Actually, we can just call computeDeltaSerial directly if we export it or use reflection, @@ -227,9 +217,9 @@ func TestParallelConsistency(t *testing.T) { chunkSize := 50 for i := 0; i < count; i += chunkSize { end := i + chunkSize - chunk := kvPairs[i:end] + chunk := mutations[i:end] // Calling ComputeLtHash with small chunk will trigger serial path - chunkHash, _ := ComputeLtHash(nil, chunk) + chunkHash := ComputeLtHash(nil, chunk) serialResult.MixIn(chunkHash) } @@ -242,13 +232,13 @@ func TestParallelConsistency(t *testing.T) { // Commutativity: A + B = B + A // Associativity: (A + B) + C = A + (B + C) func TestHomomorphicProperties(t *testing.T) { - kv1 := []KVPairWithLastValue{{Key: []byte("k1"), Value: []byte("v1")}} - kv2 := []KVPairWithLastValue{{Key: []byte("k2"), Value: []byte("v2")}} - kv3 := []KVPairWithLastValue{{Key: []byte("k3"), Value: []byte("v3")}} + kv1 := []KeyMutation{{Key: []byte("k1"), Value: []byte("v1")}} + kv2 := []KeyMutation{{Key: []byte("k2"), Value: []byte("v2")}} + kv3 := []KeyMutation{{Key: []byte("k3"), Value: []byte("v3")}} - h1, _ := ComputeLtHash(nil, kv1) - h2, _ := ComputeLtHash(nil, kv2) - h3, _ := ComputeLtHash(nil, kv3) + h1 := ComputeLtHash(nil, kv1) + h2 := ComputeLtHash(nil, kv2) + h3 := ComputeLtHash(nil, kv3) // Commutativity: h1 + h2 == h2 + h1 sum12 := h1.Clone() @@ -290,7 +280,7 @@ func TestFuzz(t *testing.T) { binary.LittleEndian.PutUint64(val, rng.Uint64()) // Randomly insert or delete - op := KVPairWithLastValue{Key: key} + op := KeyMutation{Key: key} if rng.Intn(2) == 0 { // Insert op.Value = val @@ -300,7 +290,7 @@ func TestFuzz(t *testing.T) { op.Delete = true } - next, _ := ComputeLtHash(base, []KVPairWithLastValue{op}) + next := ComputeLtHash(base, []KeyMutation{op}) base = next } diff --git a/sei-db/state_db/sc/flatkv/lthash/stats.go b/sei-db/state_db/sc/flatkv/lthash/stats.go index 0aef2956b6..580b4abe30 100644 --- a/sei-db/state_db/sc/flatkv/lthash/stats.go +++ b/sei-db/state_db/sc/flatkv/lthash/stats.go @@ -9,12 +9,12 @@ import ( // two big-endian int64s (KeyCount || Bytes). const moduleStatsEncodedLen = 16 -// ModuleStats is auxiliary per-(DB, module) metadata accumulated alongside the +// ModuleStats is auxiliary per-(DB, module) metadata combined alongside the // lattice hash: the number of live keys and their total serialized footprint // (physical key bytes + serialized value bytes) for that module within a DB. // // Both are net running totals maintained with the same key-membership rule the -// lattice hash uses (see foldChunk): an add increments KeyCount and adds +// lattice hash uses (see hashChunk): an add increments KeyCount and adds // key+value bytes; an update leaves KeyCount unchanged and adjusts Bytes by the // value-size delta; a delete decrements KeyCount and subtracts the old // key+value bytes. They are consensus-irrelevant (not folded into the AppHash) diff --git a/sei-db/state_db/sc/flatkv/lthash/stats_test.go b/sei-db/state_db/sc/flatkv/lthash/stats_test.go index 42c9569aa8..7a56472e10 100644 --- a/sei-db/state_db/sc/flatkv/lthash/stats_test.go +++ b/sei-db/state_db/sc/flatkv/lthash/stats_test.go @@ -48,44 +48,44 @@ func TestFoldChunkStats(t *testing.T) { tests := []struct { name string - pair KVPairWithLastValue + pair KeyMutation wantKeys int64 wantByte int64 }{ { name: "add", - pair: KVPairWithLastValue{Key: key, Value: []byte("newvalue")}, + pair: KeyMutation{Key: key, Value: []byte("newvalue")}, wantKeys: 1, wantByte: int64(len(key)) + int64(len("newvalue")), }, { name: "update grows", - pair: KVPairWithLastValue{Key: key, Value: []byte("longer-value"), LastValue: []byte("short")}, + pair: KeyMutation{Key: key, Value: []byte("longer-value"), LastValue: []byte("short")}, wantKeys: 0, wantByte: int64(len("longer-value")) - int64(len("short")), }, { name: "update shrinks", - pair: KVPairWithLastValue{Key: key, Value: []byte("v"), LastValue: []byte("wasbigger")}, + pair: KeyMutation{Key: key, Value: []byte("v"), LastValue: []byte("wasbigger")}, wantKeys: 0, wantByte: int64(len("v")) - int64(len("wasbigger")), }, { name: "delete", - pair: KVPairWithLastValue{Key: key, LastValue: []byte("oldvalue"), Delete: true}, + pair: KeyMutation{Key: key, LastValue: []byte("oldvalue"), Delete: true}, wantKeys: -1, wantByte: -(int64(len(key)) + int64(len("oldvalue"))), }, { name: "delete absent is no-op", - pair: KVPairWithLastValue{Key: key, Delete: true}, + pair: KeyMutation{Key: key, Delete: true}, wantKeys: 0, wantByte: 0, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - d := foldChunk([]KVPairWithLastValue{tc.pair}) + d := hashChunk([]KeyMutation{tc.pair}) require.Equal(t, tc.wantKeys, d.KeyCount) require.Equal(t, tc.wantByte, d.Bytes) }) @@ -96,28 +96,28 @@ func TestFoldChunkStats(t *testing.T) { // and checks the aggregated per-module stats equal a straightforward serial // tally, proving the chunk-and-merge does not lose or double-count. func TestComputeModuleHashInfosStatsParallel(t *testing.T) { - const dir = "d" + const dbName = "d" moduleOf := func([]byte) (string, error) { return "m", nil } pool := threading.NewFixedPool("test", 4, 4) defer pool.Close() - c := NewHashCalculator(pool, []string{dir}, moduleOf) - - const n = computeChunkSize*3 + 7 // spans several chunks, not a chunk multiple - pairs := make([]KVPairWithLastValue, n) + cfg := DefaultConfig() + n := int(cfg.ChunkSize)*3 + 7 // spans several chunks, not a chunk multiple + mutations := make([]KeyMutation, n) var wantKeys, wantBytes int64 - for i := range pairs { + for i := range mutations { key := []byte(fmt.Sprintf("m/key-%05d", i)) val := []byte(fmt.Sprintf("value-%d", i)) - pairs[i] = KVPairWithLastValue{Key: key, Value: val} + mutations[i] = KeyMutation{Key: key, Value: val} wantKeys++ wantBytes += int64(len(key)) + int64(len(val)) } - deltas, err := c.ComputeModuleHashInfos([]DBPairs{{Dir: dir, Pairs: pairs}}) + deltas, err := ComputeModuleHashInfos( + pool, moduleOf, []DatabaseMutations{{DBName: dbName, Mutations: mutations}}, cfg.ChunkSize) require.NoError(t, err) require.Len(t, deltas, 1) - d := deltas[ModuleKey{Dir: dir, Module: "m"}] + d := deltas[ModuleKey{DBName: dbName, Module: "m"}] require.NotNil(t, d) require.Equal(t, wantKeys, d.KeyCount) require.Equal(t, wantBytes, d.Bytes) diff --git a/sei-db/state_db/sc/flatkv/lthash_agreement_test.go b/sei-db/state_db/sc/flatkv/lthash_agreement_test.go index a10d0781c1..b082743057 100644 --- a/sei-db/state_db/sc/flatkv/lthash_agreement_test.go +++ b/sei-db/state_db/sc/flatkv/lthash_agreement_test.go @@ -416,12 +416,12 @@ func (m *stateModel) expect() *expectedState { } for _, dir := range dataDBDirs { byKey := byDB[dir] - pairs := make([]lthash.KVPairWithLastValue, 0, len(byKey)) + pairs := make([]lthash.KeyMutation, 0, len(byKey)) for physKey, value := range byKey { - pairs = append(pairs, lthash.KVPairWithLastValue{Key: []byte(physKey), Value: value}) + pairs = append(pairs, lthash.KeyMutation{Key: []byte(physKey), Value: value}) out.rows[physKey] = value } - root, _ := lthash.ComputeLtHash(nil, pairs) + root := lthash.ComputeLtHash(nil, pairs) out.perDB[dir] = root out.root.MixIn(root) } @@ -439,13 +439,13 @@ func requireModelAgrees(t *testing.T, s *CommitStore, m *stateModel, because str want := m.expect() for _, dir := range dataDBDirs { - require.True(t, want.perDB[dir].Equal(s.perDBWorkingLtHash[dir]), + require.True(t, want.perDB[dir].Equal(s.maintainedHashes().PerDB[dir]), "%s: %s per-DB root disagrees with the model\n model: %x\n store: %x", - because, dir, want.perDB[dir].Checksum(), s.perDBWorkingLtHash[dir].Checksum()) + because, dir, want.perDB[dir].Checksum(), s.maintainedHashes().PerDB[dir].Checksum()) } - require.True(t, want.root.Equal(s.workingLtHash), + require.True(t, want.root.Equal(s.maintainedHashes().Global), "%s: store-wide root disagrees with the model\n model: %x\n store: %x", - because, want.root.Checksum(), s.workingLtHash.Checksum()) + because, want.root.Checksum(), s.maintainedHashes().Global.Checksum()) requireRowsEqual(t, s, want, because) } @@ -502,14 +502,13 @@ func requireStoresAgree(t *testing.T, want *CommitStore, got *CommitStore, becau func (sc *storeComparator) requireAgree(t *testing.T, want *CommitStore, got *CommitStore, because string) { t.Helper() - wantHash, wantVersion := want.RootHash() - gotHash, gotVersion := got.RootHash() + wantVersion, gotVersion := want.Version(), got.Version() require.Equalf(t, wantVersion, gotVersion, "%s: version", because) - require.Equalf(t, wantHash, gotHash, + require.Equalf(t, rootHash(want), rootHash(got), "%s: store-wide root at version %d", because, wantVersion) for _, dir := range dataDBDirs { - require.Truef(t, want.perDBWorkingLtHash[dir].Equal(got.perDBWorkingLtHash[dir]), + require.Truef(t, want.maintainedHashes().PerDB[dir].Equal(got.maintainedHashes().PerDB[dir]), "%s: %s per-DB root", because, dir) } sc.requireModuleBookkeepingAgrees(t, want, got, because) @@ -535,15 +534,15 @@ func (sc *storeComparator) requireModuleBookkeepingAgrees( var problems []string for _, dir := range dataDBDirs { modules := make(map[string]bool) - for module := range want.perDBModuleWorkingLtHash[dir] { + for module := range want.maintainedHashes().PerModule[dir] { modules[module] = true } - for module := range got.perDBModuleWorkingLtHash[dir] { + for module := range got.maintainedHashes().PerModule[dir] { modules[module] = true } for _, module := range sortedStrings(modules) { - wantHash, wantOK := want.perDBModuleWorkingLtHash[dir][module] - gotHash, gotOK := got.perDBModuleWorkingLtHash[dir][module] + wantHash, wantOK := want.maintainedHashes().PerModule[dir][module] + gotHash, gotOK := got.maintainedHashes().PerModule[dir][module] if wantOK != gotOK { present, absent := wantHash, "second" if !wantOK { @@ -569,8 +568,8 @@ func (sc *storeComparator) requireModuleBookkeepingAgrees( } } - wantStats := want.perDBModuleWorkingStats[dir] - gotStats := got.perDBModuleWorkingStats[dir] + wantStats := want.maintainedHashes().PerModuleStats[dir] + gotStats := got.maintainedHashes().PerModuleStats[dir] statModules := make(map[string]bool) for module := range wantStats { statModules[module] = true diff --git a/sei-db/state_db/sc/flatkv/lthash_correctness_test.go b/sei-db/state_db/sc/flatkv/lthash_correctness_test.go index ae071f2a0b..25610e78bf 100644 --- a/sei-db/state_db/sc/flatkv/lthash_correctness_test.go +++ b/sei-db/state_db/sc/flatkv/lthash_correctness_test.go @@ -29,7 +29,7 @@ func fullScanLtHash(t *testing.T, s *CommitStore) *lthash.LtHash { // Independent ground truth means reading the databases directly, which only agrees with the // maintained hashes once the committed block has actually been flushed there. requireFlushedToDisk(t, s) - var pairs []lthash.KVPairWithLastValue + var pairs []lthash.KeyMutation scanDB := func(db types.KeyValueDB) { iter, err := db.NewIter(&types.IterOptions{}) @@ -41,7 +41,7 @@ func fullScanLtHash(t *testing.T, s *CommitStore) *lthash.LtHash { } key := bytes.Clone(iter.Key()) value := bytes.Clone(iter.Value()) - pairs = append(pairs, lthash.KVPairWithLastValue{ + pairs = append(pairs, lthash.KeyMutation{ Key: key, Value: value, }) @@ -54,7 +54,7 @@ func fullScanLtHash(t *testing.T, s *CommitStore) *lthash.LtHash { scanDB(db) } - result, _ := lthash.ComputeLtHash(nil, pairs) + result := lthash.ComputeLtHash(nil, pairs) return result } @@ -273,7 +273,7 @@ func verifyLtHashAtHeight(t *testing.T, s *CommitStore, height int64) { t.Helper() require.Equal(t, height, s.Version(), "unexpected version") - incremental := s.workingLtHash + incremental := s.maintainedHashes().Global scan := fullScanLtHash(t, s) require.True(t, incremental.Equal(scan), @@ -592,9 +592,9 @@ func TestLtHashPersistenceAfterReopen(t *testing.T) { require.Equal(t, int64(10), s2.Version()) scan := fullScanLtHash(t, s2) - require.True(t, s2.workingLtHash.Equal(scan), + require.True(t, s2.maintainedHashes().Global.Equal(scan), fmt.Sprintf("LtHash mismatch after reopen:\n persisted checksum: %x\n fullscan checksum: %x", - s2.workingLtHash.Checksum(), scan.Checksum())) + s2.maintainedHashes().Global.Checksum(), scan.Checksum())) } // ============================================================================= @@ -613,7 +613,7 @@ func TestFullScanLtHashIncludesMisc(t *testing.T) { commitAndCheck(t, s) groundTruth := fullScanLtHash(t, s) - require.Equal(t, s.workingLtHash.Checksum(), groundTruth.Checksum(), + require.Equal(t, s.maintainedHashes().Global.Checksum(), groundTruth.Checksum(), "full scan including miscDB should match incremental LtHash") } @@ -1096,7 +1096,7 @@ func TestRootHashReportsTheCommittedBlock(t *testing.T) { s := setupTestStore(t) defer s.Close() - empty, emptyVersion := s.RootHash() + empty, emptyVersion := rootHashAndVersion(s) require.Equal(t, int64(0), emptyVersion, "a store with no commits describes height 0") // Block 1: create state. @@ -1110,12 +1110,12 @@ func TestRootHashReportsTheCommittedBlock(t *testing.T) { // A block that has not been sealed has no hash to report, so the store still describes the // previous height. - staged, stagedVersion := s.RootHash() + staged, stagedVersion := rootHashAndVersion(s) require.Equal(t, empty, staged, "staging a block must not move the hash") require.Equal(t, emptyVersion, stagedVersion) commitAndCheck(t, s) - hash, hashVersion := s.RootHash() + hash, hashVersion := rootHashAndVersion(s) require.NotEqual(t, empty, hash, "committing a block that changes state changes the hash") require.Equal(t, int64(1), hashVersion) require.Empty(t, s.pendingChangeSets, "the commit consumes the pending block") @@ -1125,7 +1125,7 @@ func TestRootHashReportsTheCommittedBlock(t *testing.T) { v, err := s.Commit(1) require.NoError(t, err) require.Equal(t, int64(1), v) - again, againVersion := s.RootHash() + again, againVersion := rootHashAndVersion(s) require.Equal(t, hash, again) require.Equal(t, hashVersion, againVersion) @@ -1142,7 +1142,7 @@ func TestRootHashReportsTheCommittedBlock(t *testing.T) { before := rootHash(s) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{namedCS()})) commitAndCheck(t, s) - after, afterVersion := s.RootHash() + after, afterVersion := rootHashAndVersion(s) require.Equal(t, before, after, "an empty block must not change the hash") require.Equal(t, int64(3), afterVersion) } @@ -1190,7 +1190,7 @@ func TestLtHashReadOnlyMatchesParent(t *testing.T) { // Full-scan the read-only store's DBs roStore := ro.(*CommitStore) scan := fullScanLtHash(t, roStore) - require.True(t, roStore.workingLtHash.Equal(scan), + require.True(t, roStore.maintainedHashes().Global.Equal(scan), "read-only LtHash should match full scan of its own DBs") require.NoError(t, s.Close()) @@ -1507,6 +1507,6 @@ func TestLtHashLargeBatch(t *testing.T) { func verifyLtHashConsistency(t *testing.T, s *CommitStore) { t.Helper() expected := fullScanLtHash(t, s) - require.Equal(t, expected.Checksum(), s.workingLtHash.Checksum(), + require.Equal(t, expected.Checksum(), s.maintainedHashes().Global.Checksum(), "workingLtHash should match fullScanLtHash after recovery") } diff --git a/sei-db/state_db/sc/flatkv/lthash_golden_test.go b/sei-db/state_db/sc/flatkv/lthash_golden_test.go index 74b4f17181..066c608d82 100644 --- a/sei-db/state_db/sc/flatkv/lthash_golden_test.go +++ b/sei-db/state_db/sc/flatkv/lthash_golden_test.go @@ -24,9 +24,9 @@ import ( // answer that changed. This does, because the expected values were computed by a build that no longer // exists and are read back from testdata rather than recomputed. // -// The recorded archive is produced by the same code path that reports hashes in production — -// CommitStore.HashCategories and CommitStore.RecordHashes into a hashlog.HashLogger — so the format is -// a CSV of one row per block, and comparing two runs is hashlog.CompareHashesInRange. +// The recorded archive is produced by the same code path that reports hashes in production — the +// finalization goroutine reporting into a hashlog.HashLogger the store was built with — so the format +// is a CSV of one row per block, and comparing two runs is hashlog.CompareHashesInRange. // goldenRecord regenerates the committed archive instead of checking against it. Off by default, and // refused outright on CI: see recordGoldenArchive. @@ -114,12 +114,12 @@ func requireArchivesAgree(t *testing.T, recorded string, fresh string) { func writeGoldenRun(t *testing.T, dir string, cfg *config.Config) { t.Helper() - store := setupTestStoreWithConfig(t, cfg) - defer func() { require.NoError(t, store.Close()) }() - - logger := newGoldenHashLogger(t, dir, store.HashCategories()) + logger := newGoldenHashLogger(t, dir, hashCategories()) defer func() { require.NoError(t, logger.Close()) }() + store := setupTestStoreWithHashLogger(t, cfg, logger) + defer func() { require.NoError(t, store.Close()) }() + workload := newFixedSizeAgreementWorkload( rand.New(rand.NewSource(goldenSeed)), //nolint:gosec // deterministic test data only goldenOpsPerBlock) @@ -135,8 +135,10 @@ func writeGoldenRun(t *testing.T, dir string, cfg *config.Config) { block := uint64(height) //nolint:gosec // heights start at 1 and only increase logger.ReportChangeset(block, changeSets) - require.NoError(t, store.RecordHashes(logger, block), "record hashes for block %d", height) } + + // Hashes are reported off the commit path, so the run is not complete until they have caught up. + require.NoError(t, store.FlushHashes()) } // newGoldenHashLogger opens a logger that records the store's hash categories plus the changeset column diff --git a/sei-db/state_db/sc/flatkv/perdb_lthash_test.go b/sei-db/state_db/sc/flatkv/perdb_lthash_test.go index 76364bfc98..36b55943e4 100644 --- a/sei-db/state_db/sc/flatkv/perdb_lthash_test.go +++ b/sei-db/state_db/sc/flatkv/perdb_lthash_test.go @@ -26,18 +26,18 @@ func testFullScanDBLtHash(t *testing.T, db types.KeyValueDB) *lthash.LtHash { require.NoError(t, err) defer iter.Close() - var pairs []lthash.KVPairWithLastValue + var pairs []lthash.KeyMutation for ; iter.Valid(); iter.Next() { if ktype.IsMetaKey(iter.Key()) { continue } - pairs = append(pairs, lthash.KVPairWithLastValue{ + pairs = append(pairs, lthash.KeyMutation{ Key: bytes.Clone(iter.Key()), Value: bytes.Clone(iter.Value()), }) } require.NoError(t, iter.Error()) - result, _ := lthash.ComputeLtHash(nil, pairs) + result := lthash.ComputeLtHash(nil, pairs) if result == nil { return lthash.New() } @@ -66,9 +66,9 @@ func verifyPerDBLtHash(t *testing.T, s *CommitStore) { t.Helper() scanned := fullScanPerDBLtHash(t, s) for dbDir, scanHash := range scanned { - require.True(t, s.perDBWorkingLtHash[dbDir].Equal(scanHash), + require.True(t, s.maintainedHashes().PerDB[dbDir].Equal(scanHash), "per-DB LtHash mismatch for %s:\n working: %x\n fullscan: %x", - dbDir, s.perDBWorkingLtHash[dbDir].Checksum(), scanHash.Checksum()) + dbDir, s.maintainedHashes().PerDB[dbDir].Checksum(), scanHash.Checksum()) } } @@ -117,7 +117,7 @@ func TestPerDBLtHashSkewRecovery(t *testing.T) { wantRoot := bytes.Clone(rootHash(s1)) wantPerDB := make(map[string][32]byte, len(dataDBDirs)) for _, dbDir := range dataDBDirs { - wantPerDB[dbDir] = s1.perDBWorkingLtHash[dbDir].Checksum() + wantPerDB[dbDir] = s1.maintainedHashes().PerDB[dbDir].Checksum() } // Rewind accountDB's version record to 1, leaving its data — and every other DB — at 2. The // store must open at 1 and replay block 2. The rewind goes into the working dir, which is what a @@ -139,7 +139,7 @@ func TestPerDBLtHashSkewRecovery(t *testing.T) { require.Equal(t, wantRoot, rootHash(s2), "replaying an already-applied block must reproduce the same global root") for _, dbDir := range dataDBDirs { - require.Equal(t, wantPerDB[dbDir], s2.perDBWorkingLtHash[dbDir].Checksum(), + require.Equal(t, wantPerDB[dbDir], s2.maintainedHashes().PerDB[dbDir].Checksum(), "%s per-DB root must be bit-identical after replay", dbDir) } require.Equal(t, int64(2), s2.Version()) @@ -181,7 +181,7 @@ func TestPerDBLtHashPersistenceAfterReopen(t *testing.T) { verifyLtHashAtHeight(t, s2, 10) for _, dbDir := range dataDBDirs { - wh := s2.perDBWorkingLtHash[dbDir] + wh := s2.maintainedHashes().PerDB[dbDir] meta := s2.localMeta[dbDir] require.NotNil(t, meta.LtHash, "LocalMeta LtHash should be loaded for %s", dbDir) @@ -252,12 +252,12 @@ func TestPerDBLtHashSumEqualsGlobal(t *testing.T) { sumHash := lthash.New() for _, dbDir := range []string{accountDBDir, codeDBDir, storageDBDir, miscDBDir} { - sumHash.MixIn(s.perDBWorkingLtHash[dbDir]) + sumHash.MixIn(s.maintainedHashes().PerDB[dbDir]) } - require.True(t, s.workingLtHash.Equal(sumHash), + require.True(t, s.maintainedHashes().Global.Equal(sumHash), "sum of per-DB LtHashes should equal global LtHash:\n global: %x\n sum: %x", - s.workingLtHash.Checksum(), sumHash.Checksum()) + s.maintainedHashes().Global.Checksum(), sumHash.Checksum()) } // Test: per-DB hashes are correct after catchup with WAL replay. @@ -283,7 +283,7 @@ func TestPerDBLtHashCatchupReplay(t *testing.T) { verifyPerDBLtHash(t, s1) expectedPerDB := make(map[string][32]byte, 4) - for dbDir, h := range s1.perDBWorkingLtHash { + for dbDir, h := range s1.maintainedHashes().PerDB { expectedPerDB[dbDir] = h.Checksum() } require.NoError(t, s1.Close()) @@ -299,7 +299,7 @@ func TestPerDBLtHashCatchupReplay(t *testing.T) { require.Equal(t, int64(5), s2.Version()) for dbDir, expectedCS := range expectedPerDB { - actualCS := s2.perDBWorkingLtHash[dbDir].Checksum() + actualCS := s2.maintainedHashes().PerDB[dbDir].Checksum() require.Equal(t, expectedCS, actualCS, "per-DB LtHash mismatch for %s after catchup", dbDir) } @@ -313,7 +313,7 @@ func TestPerDBLtHashEmptyBlocks(t *testing.T) { commitMixedState(t, s, 1) checksums := make(map[string][32]byte) - for dbDir, h := range s.perDBWorkingLtHash { + for dbDir, h := range s.maintainedHashes().PerDB { checksums[dbDir] = h.Checksum() } @@ -323,7 +323,7 @@ func TestPerDBLtHashEmptyBlocks(t *testing.T) { } for dbDir, expected := range checksums { - actual := s.perDBWorkingLtHash[dbDir].Checksum() + actual := s.maintainedHashes().PerDB[dbDir].Checksum() require.Equal(t, expected, actual, "empty blocks should not change per-DB LtHash for %s", dbDir) } @@ -359,7 +359,7 @@ func TestPerDBLtHashAfterImport(t *testing.T) { verifyLtHashAtHeight(t, s, 1) for _, dbDir := range dataDBDirs { - wh := s.perDBWorkingLtHash[dbDir] + wh := s.maintainedHashes().PerDB[dbDir] meta := s.localMeta[dbDir] require.NotNil(t, meta.LtHash, "LocalMeta LtHash should exist after import for %s", dbDir) @@ -425,7 +425,7 @@ func TestPerDBLtHashPersistedInLocalMeta(t *testing.T) { require.NoError(t, err, "LocalMeta should be readable for %s", dbDirName) require.NotNil(t, meta.LtHash, "LocalMeta LtHash should be non-nil for %s", dbDirName) - require.True(t, s.perDBWorkingLtHash[dbDirName].Equal(meta.LtHash), + require.True(t, s.maintainedHashes().PerDB[dbDirName].Equal(meta.LtHash), "LocalMeta LtHash should match working hash for %s", dbDirName) } @@ -479,13 +479,13 @@ func TestPerDBLtHashPartialKeyTypeOperations(t *testing.T) { commitAndCheck(t, s) zeroChecksum := lthash.New().Checksum() - require.NotEqual(t, zeroChecksum, s.perDBWorkingLtHash[storageDBDir].Checksum(), + require.NotEqual(t, zeroChecksum, s.maintainedHashes().PerDB[storageDBDir].Checksum(), "storageDB hash should be non-zero") - require.Equal(t, zeroChecksum, s.perDBWorkingLtHash[accountDBDir].Checksum(), + require.Equal(t, zeroChecksum, s.maintainedHashes().PerDB[accountDBDir].Checksum(), "accountDB hash should remain zero") - require.Equal(t, zeroChecksum, s.perDBWorkingLtHash[codeDBDir].Checksum(), + require.Equal(t, zeroChecksum, s.maintainedHashes().PerDB[codeDBDir].Checksum(), "codeDB hash should remain zero") - require.Equal(t, zeroChecksum, s.perDBWorkingLtHash[miscDBDir].Checksum(), + require.Equal(t, zeroChecksum, s.maintainedHashes().PerDB[miscDBDir].Checksum(), "miscDB hash should remain zero") } @@ -500,7 +500,7 @@ func TestPerDBLtHashDeleteLastKeyZerosHash(t *testing.T) { require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) commitAndCheck(t, s) - nonZeroHash := s.perDBWorkingLtHash[storageDBDir].Checksum() + nonZeroHash := s.maintainedHashes().PerDB[storageDBDir].Checksum() zeroChecksum := lthash.New().Checksum() require.NotEqual(t, zeroChecksum, nonZeroHash) @@ -510,7 +510,7 @@ func TestPerDBLtHashDeleteLastKeyZerosHash(t *testing.T) { commitAndCheck(t, s) // After deleting all keys from a DB, its hash should return to zero. - require.Equal(t, zeroChecksum, s.perDBWorkingLtHash[storageDBDir].Checksum(), + require.Equal(t, zeroChecksum, s.maintainedHashes().PerDB[storageDBDir].Checksum(), "storageDB hash should be zero after deleting all keys") // Verify via full scan. @@ -526,9 +526,9 @@ func TestPerDBLtHashSumInvariantAcrossAllOperations(t *testing.T) { t.Helper() globalHash := lthash.New() for _, dir := range dataDBDirs { - globalHash.MixIn(s.perDBWorkingLtHash[dir]) + globalHash.MixIn(s.maintainedHashes().PerDB[dir]) } - require.Equal(t, s.workingLtHash.Checksum(), globalHash.Checksum(), + require.Equal(t, s.maintainedHashes().Global.Checksum(), globalHash.Checksum(), "sum(perDB) should equal global workingLtHash: %s", msg) } @@ -628,9 +628,9 @@ func TestPerDBLtHashLevelsUpStoresAtDifferentHeights(t *testing.T) { verifyPerDBLtHash(t, s1) wantPerDB := make(map[string]*lthash.LtHash, len(dataDBDirs)) for _, dbDir := range dataDBDirs { - wantPerDB[dbDir] = s1.perDBWorkingLtHash[dbDir].Clone() + wantPerDB[dbDir] = s1.maintainedHashes().PerDB[dbDir].Clone() } - wantGlobal := s1.workingLtHash.Clone() + wantGlobal := s1.maintainedHashes().Global.Clone() require.NoError(t, s1.Close()) // Rewind only the storage database's recorded height, leaving the others at 3. On reopen the stores @@ -656,10 +656,10 @@ func TestPerDBLtHashLevelsUpStoresAtDifferentHeights(t *testing.T) { // Every store ends level, at the height they collectively reached before the forged skew. require.Equal(t, int64(3), s2.Version()) for _, dbDir := range dataDBDirs { - require.True(t, wantPerDB[dbDir].Equal(s2.perDBWorkingLtHash[dbDir]), + require.True(t, wantPerDB[dbDir].Equal(s2.maintainedHashes().PerDB[dbDir]), "per-DB LtHash for %s must be restored exactly, not double-mixed:\n want: %x\n got: %x", - dbDir, wantPerDB[dbDir].Checksum(), s2.perDBWorkingLtHash[dbDir].Checksum()) + dbDir, wantPerDB[dbDir].Checksum(), s2.maintainedHashes().PerDB[dbDir].Checksum()) } - require.True(t, wantGlobal.Equal(s2.workingLtHash), "global LtHash must be restored exactly") + require.True(t, wantGlobal.Equal(s2.maintainedHashes().Global), "global LtHash must be restored exactly") verifyPerDBLtHash(t, s2) } diff --git a/sei-db/state_db/sc/flatkv/permodule_lthash_test.go b/sei-db/state_db/sc/flatkv/permodule_lthash_test.go index c948a1b5ec..418d483ebe 100644 --- a/sei-db/state_db/sc/flatkv/permodule_lthash_test.go +++ b/sei-db/state_db/sc/flatkv/permodule_lthash_test.go @@ -34,14 +34,14 @@ func fullScanModuleLtHash(t *testing.T, db types.KeyValueDB) map[string]*lthash. require.NoError(t, err) defer iter.Close() - byModule := make(map[string][]lthash.KVPairWithLastValue) + byModule := make(map[string][]lthash.KeyMutation) for ; iter.Valid(); iter.Next() { if ktype.IsMetaKey(iter.Key()) { continue } module, _, err := ktype.StripModulePrefix(iter.Key()) require.NoError(t, err) - byModule[module] = append(byModule[module], lthash.KVPairWithLastValue{ + byModule[module] = append(byModule[module], lthash.KeyMutation{ Key: bytes.Clone(iter.Key()), Value: bytes.Clone(iter.Value()), }) @@ -50,7 +50,7 @@ func fullScanModuleLtHash(t *testing.T, db types.KeyValueDB) map[string]*lthash. out := make(map[string]*lthash.LtHash, len(byModule)) for module, pairs := range byModule { - h, _ := lthash.ComputeLtHash(nil, pairs) + h := lthash.ComputeLtHash(nil, pairs) if h == nil { h = lthash.New() } @@ -68,7 +68,7 @@ func verifyModuleLtHash(t *testing.T, s *CommitStore) { for _, dir := range dataDBDirs { db := s.rawDBFor(dir) scanned := fullScanModuleLtHash(t, db) - working := s.perDBModuleWorkingLtHash[dir] + working := s.maintainedHashes().PerModule[dir] // Every scanned module must have a matching working hash. for module, scanHash := range scanned { @@ -85,9 +85,9 @@ func verifyModuleLtHash(t *testing.T, s *CommitStore) { for _, wh := range working { sum.MixIn(wh) } - require.True(t, s.perDBWorkingLtHash[dir].Equal(sum), + require.True(t, s.maintainedHashes().PerDB[dir].Equal(sum), "sum of per-module hashes should equal per-DB root for %s:\n root: %x\n sum: %x", - dir, s.perDBWorkingLtHash[dir].Checksum(), sum.Checksum()) + dir, s.maintainedHashes().PerDB[dir].Checksum(), sum.Checksum()) } } @@ -131,7 +131,7 @@ func TestPerModuleLtHashIncrementalEqualsFullScan(t *testing.T) { verifyModuleLtHash(t, s) // miscDB should now carry three modules: evm, gov, bank. - misc := s.perDBModuleWorkingLtHash[miscDBDir] + misc := s.maintainedHashes().PerModule[miscDBDir] require.Contains(t, misc, keys.EVMStoreKey) require.Contains(t, misc, "gov") require.Contains(t, misc, "bank") @@ -139,10 +139,10 @@ func TestPerModuleLtHashIncrementalEqualsFullScan(t *testing.T) { // account/code/storage only ever carry the evm module, and that module's // hash equals the per-DB root. for _, dir := range []string{accountDBDir, codeDBDir, storageDBDir} { - mod := s.perDBModuleWorkingLtHash[dir] + mod := s.maintainedHashes().PerModule[dir] require.Len(t, mod, 1, "%s should only track the evm module", dir) require.Contains(t, mod, keys.EVMStoreKey) - require.True(t, mod[keys.EVMStoreKey].Equal(s.perDBWorkingLtHash[dir]), + require.True(t, mod[keys.EVMStoreKey].Equal(s.maintainedHashes().PerDB[dir]), "%s evm module hash should equal per-DB root", dir) } } @@ -167,7 +167,7 @@ func TestPerModuleLtHashPersistenceAfterReopen(t *testing.T) { verifyModuleLtHash(t, s1) expected := make(map[string]map[string][32]byte) - for dir, mods := range s1.perDBModuleWorkingLtHash { + for dir, mods := range s1.maintainedHashes().PerModule { expected[dir] = make(map[string][32]byte) for module, h := range mods { expected[dir][module] = h.Checksum() @@ -189,7 +189,7 @@ func TestPerModuleLtHashPersistenceAfterReopen(t *testing.T) { // Working per-module hashes rehydrated from disk must match pre-close. for dir, mods := range expected { for module, cs := range mods { - got := s2.perDBModuleWorkingLtHash[dir][module] + got := s2.maintainedHashes().PerModule[dir][module] require.NotNil(t, got, "module %s/%s missing after reopen", dir, module) require.Equal(t, cs, got.Checksum(), "per-module hash mismatch after reopen for %s/%s", dir, module) @@ -228,14 +228,14 @@ func TestPerModuleLtHashDeleteModuleZerosHash(t *testing.T) { commitAndCheck(t, s) zero := lthash.New().Checksum() - require.NotEqual(t, zero, s.perDBModuleWorkingLtHash[miscDBDir]["gov"].Checksum(), + require.NotEqual(t, zero, s.maintainedHashes().PerModule[miscDBDir]["gov"].Checksum(), "gov module hash should be non-zero after write") del := moduleCS("gov", &proto.KVPair{Key: govKey, Delete: true}) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{del})) commitAndCheck(t, s) - require.Equal(t, zero, s.perDBModuleWorkingLtHash[miscDBDir]["gov"].Checksum(), + require.Equal(t, zero, s.maintainedHashes().PerModule[miscDBDir]["gov"].Checksum(), "gov module hash should be zero after deleting all its keys") verifyModuleLtHash(t, s) } @@ -272,9 +272,9 @@ func TestPerModuleLtHashAfterImport(t *testing.T) { verifyModuleLtHash(t, s) - require.Contains(t, s.perDBModuleWorkingLtHash[miscDBDir], "gov") - require.Contains(t, s.perDBModuleWorkingLtHash[accountDBDir], keys.EVMStoreKey) - require.Contains(t, s.perDBModuleWorkingLtHash[storageDBDir], keys.EVMStoreKey) + require.Contains(t, s.maintainedHashes().PerModule[miscDBDir], "gov") + require.Contains(t, s.maintainedHashes().PerModule[accountDBDir], keys.EVMStoreKey) + require.Contains(t, s.maintainedHashes().PerModule[storageDBDir], keys.EVMStoreKey) require.NoError(t, s.Close()) } @@ -315,7 +315,7 @@ func TestPerModuleLtHashStateSyncImportSurvivesRestart(t *testing.T) { verifyModuleLtHash(t, s1) expected := make(map[string]map[string][32]byte) - for dir, mods := range s1.perDBModuleWorkingLtHash { + for dir, mods := range s1.maintainedHashes().PerModule { expected[dir] = make(map[string][32]byte) for module, h := range mods { expected[dir][module] = h.Checksum() @@ -337,10 +337,10 @@ func TestPerModuleLtHashStateSyncImportSurvivesRestart(t *testing.T) { verifyModuleLtHash(t, s2) for dir, mods := range expected { - require.Equal(t, len(mods), len(s2.perDBModuleWorkingLtHash[dir]), + require.Equal(t, len(mods), len(s2.maintainedHashes().PerModule[dir]), "module count mismatch after restart for %s", dir) for module, cs := range mods { - got := s2.perDBModuleWorkingLtHash[dir][module] + got := s2.maintainedHashes().PerModule[dir][module] require.NotNil(t, got, "module %s/%s missing after restart", dir, module) require.Equal(t, cs, got.Checksum(), "per-module hash mismatch after restart for %s/%s", dir, module) @@ -348,9 +348,9 @@ func TestPerModuleLtHashStateSyncImportSurvivesRestart(t *testing.T) { } // miscDB must have persisted both cosmos modules across the restart. - require.Contains(t, s2.perDBModuleWorkingLtHash[miscDBDir], "gov") - require.Contains(t, s2.perDBModuleWorkingLtHash[miscDBDir], "bank") + require.Contains(t, s2.maintainedHashes().PerModule[miscDBDir], "gov") + require.Contains(t, s2.maintainedHashes().PerModule[miscDBDir], "bank") // account/storage only ever carry the evm module. - require.Contains(t, s2.perDBModuleWorkingLtHash[accountDBDir], keys.EVMStoreKey) - require.Contains(t, s2.perDBModuleWorkingLtHash[storageDBDir], keys.EVMStoreKey) + require.Contains(t, s2.maintainedHashes().PerModule[accountDBDir], keys.EVMStoreKey) + require.Contains(t, s2.maintainedHashes().PerModule[storageDBDir], keys.EVMStoreKey) } diff --git a/sei-db/state_db/sc/flatkv/permodule_stats_test.go b/sei-db/state_db/sc/flatkv/permodule_stats_test.go index 9516d3e315..9302ba4cdf 100644 --- a/sei-db/state_db/sc/flatkv/permodule_stats_test.go +++ b/sei-db/state_db/sc/flatkv/permodule_stats_test.go @@ -56,7 +56,7 @@ func verifyModuleStats(t *testing.T, s *CommitStore) { for _, dir := range dataDBDirs { db := s.rawDBFor(dir) scanned := fullScanModuleStats(t, db) - working := s.perDBModuleWorkingStats[dir] + working := s.maintainedHashes().PerModuleStats[dir] for module, want := range scanned { require.Equal(t, want, working[module], @@ -83,7 +83,7 @@ func TestPerModuleStatsIncrementalEqualsFullScan(t *testing.T) { } // Sanity: miscDB tracks evm + gov + bank, each with the expected key count. - misc := s.perDBModuleWorkingStats[miscDBDir] + misc := s.maintainedHashes().PerModuleStats[miscDBDir] require.Equal(t, int64(5), misc[keys.EVMStoreKey].KeyCount, "one evm-misc key per round") require.Equal(t, int64(10), misc["gov"].KeyCount, "two gov keys per round") require.Equal(t, int64(5), misc["bank"].KeyCount, "one bank key per round") @@ -98,7 +98,7 @@ func TestPerModuleStatsAddUpdateDeleteTransitions(t *testing.T) { govKey := []byte{0x01, 0x2A} physKeyLen := int64(len(ktype.ModulePhysicalKey("gov", govKey))) - stats := func() lthash.ModuleStats { return s.perDBModuleWorkingStats[miscDBDir]["gov"] } + stats := func() lthash.ModuleStats { return s.maintainedHashes().PerModuleStats[miscDBDir]["gov"] } // Add: one key with a short value. Footprint must exceed the physical key // length (key bytes are always counted, plus a non-empty serialized value). @@ -152,7 +152,7 @@ func TestPerModuleStatsPersistenceAfterReopen(t *testing.T) { verifyModuleStats(t, s1) expected := make(map[string]map[string]lthash.ModuleStats) - for dir, mods := range s1.perDBModuleWorkingStats { + for dir, mods := range s1.maintainedHashes().PerModuleStats { expected[dir] = make(map[string]lthash.ModuleStats) for module, st := range mods { expected[dir][module] = st @@ -173,7 +173,7 @@ func TestPerModuleStatsPersistenceAfterReopen(t *testing.T) { for dir, mods := range expected { for module, want := range mods { - require.Equal(t, want, s2.perDBModuleWorkingStats[dir][module], + require.Equal(t, want, s2.maintainedHashes().PerModuleStats[dir][module], "working stats mismatch after reopen for %s/%s", dir, module) } } @@ -225,12 +225,12 @@ func TestPerModuleStatsAfterImportSurvivesRestart(t *testing.T) { require.NoError(t, imp.Close()) verifyModuleStats(t, s1) - require.Equal(t, int64(5), s1.perDBModuleWorkingStats[storageDBDir][keys.EVMStoreKey].KeyCount) - require.Equal(t, int64(5), s1.perDBModuleWorkingStats[accountDBDir][keys.EVMStoreKey].KeyCount) - require.Equal(t, int64(5), s1.perDBModuleWorkingStats[miscDBDir]["gov"].KeyCount) + require.Equal(t, int64(5), s1.maintainedHashes().PerModuleStats[storageDBDir][keys.EVMStoreKey].KeyCount) + require.Equal(t, int64(5), s1.maintainedHashes().PerModuleStats[accountDBDir][keys.EVMStoreKey].KeyCount) + require.Equal(t, int64(5), s1.maintainedHashes().PerModuleStats[miscDBDir]["gov"].KeyCount) expected := make(map[string]map[string]lthash.ModuleStats) - for dir, mods := range s1.perDBModuleWorkingStats { + for dir, mods := range s1.maintainedHashes().PerModuleStats { expected[dir] = make(map[string]lthash.ModuleStats) for module, st := range mods { expected[dir][module] = st @@ -250,7 +250,7 @@ func TestPerModuleStatsAfterImportSurvivesRestart(t *testing.T) { verifyModuleStats(t, s2) for dir, mods := range expected { for module, want := range mods { - require.Equal(t, want, s2.perDBModuleWorkingStats[dir][module], + require.Equal(t, want, s2.maintainedHashes().PerModuleStats[dir][module], "stats mismatch after restart for %s/%s", dir, module) } } diff --git a/sei-db/state_db/sc/flatkv/snapshot.go b/sei-db/state_db/sc/flatkv/snapshot.go index 4c16d58c18..8565de2352 100644 --- a/sei-db/state_db/sc/flatkv/snapshot.go +++ b/sei-db/state_db/sc/flatkv/snapshot.go @@ -15,6 +15,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/metrics" "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" "github.com/sei-protocol/sei-chain/sei-db/state_db/statewal" "go.opentelemetry.io/otel/metric" ) @@ -389,6 +390,13 @@ func (s *CommitStore) outOfBandSnapshot() (err error) { return errReadOnly } + // A block's hash metadata is written when the finalizer records it, in the same atomic batch as the + // rows it describes. Checkpointing before that lands would capture the rows and not the metadata, + // and the snapshot would reopen with its databases disagreeing with their own bookkeeping. + if err := s.FlushHashes(); err != nil { + return fmt.Errorf("await pending hashes: %w", err) + } + // Let the cadence-driven writer finish whatever it has in flight. It writes into the same snapshot // tree this is about to publish into, and only one writer of that tree may run at a time. // @@ -401,11 +409,11 @@ func (s *CommitStore) outOfBandSnapshot() (err error) { } } - blockView, err := s.lastSealed.get() + blockView, err := s.lastSealed.Get() if err != nil { return fmt.Errorf("read latest sealed view: %w", err) } - version := blockView.blockHeight + version := blockView.BlockHeight() obs := s.observeOp("snapshot", otelMetrics.SnapshotWriteLatency, "version", version) defer obs.done(&err, func() { @@ -418,7 +426,7 @@ func (s *CommitStore) outOfBandSnapshot() (err error) { // Error is fatal; leaking reservations doesn't make it worse. return fmt.Errorf("checkpoint databases at version %d: %w", version, err) } - if err := blockView.release(); err != nil { + if err := blockView.Release(); err != nil { return fmt.Errorf("release latest sealed view: %w", err) } pruned, err := publishSnapshot( @@ -445,16 +453,16 @@ func (s *CommitStore) outOfBandSnapshot() (err error) { func checkpointDatabases( ctx context.Context, dir string, - blockView *storeView, + blockView *sview.StoreView, dbs map[string]types.Checkpointable, phaseTimer *metrics.PhaseTimer, ) (_ string, err error) { - version := blockView.blockHeight + version := blockView.BlockHeight() // The databases are already flushing this block in the background; this waits for them to finish. // On return Pebble holds exactly this block, and stays there while the reservations are held. phaseTimer.SetPhase("snapshot_await_flush") - if flushErr := blockView.awaitFlush(ctx); flushErr != nil { + if flushErr := blockView.AwaitFlush(ctx); flushErr != nil { return "", fmt.Errorf("await flush at version %d: %w", version, flushErr) } phaseTimer.SetPhase("snapshot_copy_databases") diff --git a/sei-db/state_db/sc/flatkv/snapshot_writer.go b/sei-db/state_db/sc/flatkv/snapshot_writer.go index 4defc7feed..21efcd5ccc 100644 --- a/sei-db/state_db/sc/flatkv/snapshot_writer.go +++ b/sei-db/state_db/sc/flatkv/snapshot_writer.go @@ -12,6 +12,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/metrics" "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" ) // ErrSnapshotWriterClosed is reported (wrapped) by calls that observe the writer shutting down @@ -113,12 +114,12 @@ func newSnapshotWriter( // Offer hands a committed block to the writer, which decides if it should be written to disk. // -// The writer takes its own reservation on every view for as long as it needs one, and hands it back +// The writer takes its own reservation on every view for as long as it needs one, and releases it // whether it writes a snapshot, declines to, or fails. The caller only has to hold a reservation of its // own until this returns, and so does not have to know whether the writer keeps the block past the call. -func (w *SnapshotWriter) Offer(blockView *storeView) error { - version := blockView.blockHeight - if err := blockView.reserve(); err != nil { +func (w *SnapshotWriter) Offer(blockView *sview.StoreView) error { + version := blockView.BlockHeight() + if err := blockView.Reserve(); err != nil { return fmt.Errorf("reserve version %d for snapshot: %w", version, err) } @@ -251,7 +252,7 @@ func (w *SnapshotWriter) reportQueueDepth() { // stopped or one of them fails. func (w *SnapshotWriter) run() { defer close(w.exited) - // Whatever is still queued is owed a hand-back, so nothing is left holding a reservation that would + // Whatever is still queued is owed a release, so nothing is left holding a reservation that would // stall its database for good. defer w.discardQueued() @@ -326,21 +327,21 @@ func (w *SnapshotWriter) handlePruneCutLine(cutLine uint64) error { // Possibly checkpoint a block. Releases reservation when finished regardless of choice. func (w *SnapshotWriter) maybeCheckpointBlock(request *snapshotRequest) (err error) { - // The only hand-back for a block that reached the goroutine, covering written, declined and failed + // The only release for a block that reached the goroutine, covering written, declined and failed // alike. A reservation left held stalls its view manager's flushes indefinitely. defer func() { if relErr := request.release(); relErr != nil { err = errors.Join(err, fmt.Errorf( - "hand back reservations for version %d: %w", request.blockView.blockHeight, relErr)) + "release reservations for version %d: %w", request.blockView.BlockHeight(), relErr)) } }() - if !w.shouldSnapshot(request.blockView.blockHeight) { + if !w.shouldSnapshot(request.blockView.BlockHeight()) { w.phaseTimer.SetPhase("release_declined_block") return nil } if err := w.writeCheckpoint(request); err != nil { - return fmt.Errorf("write snapshot at version %d: %w", request.blockView.blockHeight, err) + return fmt.Errorf("write snapshot at version %d: %w", request.blockView.BlockHeight(), err) } return nil } @@ -356,8 +357,8 @@ func (w *SnapshotWriter) discardQueued() { switch request := message.(type) { case *snapshotRequest: if err := request.release(); err != nil { - logger.Error("failed to hand back reservations of a discarded snapshot", - "version", request.blockView.blockHeight, "err", err) + logger.Error("failed to release reservations of a discarded snapshot", + "version", request.blockView.BlockHeight(), "err", err) } case *cloneRequest: request.responseChan <- fmt.Errorf("clone snapshot for version %d: %w", @@ -381,7 +382,7 @@ func (w *SnapshotWriter) writeCheckpoint(request *snapshotRequest) (err error) { metric.WithAttributes(successAttr(err))) if err != nil { logger.Error("FlatKV snapshot failed", - "version", request.blockView.blockHeight, "elapsed", time.Since(start), "err", err) + "version", request.blockView.BlockHeight(), "elapsed", time.Since(start), "err", err) } }() @@ -394,19 +395,19 @@ func (w *SnapshotWriter) writeCheckpoint(request *snapshotRequest) (err error) { tmpPath, err := checkpointDatabases( workCtx, w.dir, request.blockView, w.dbs, w.phaseTimer) if err != nil { - return fmt.Errorf("snapshot version %d: %w", request.blockView.blockHeight, err) + return fmt.Errorf("snapshot version %d: %w", request.blockView.BlockHeight(), err) } w.phaseTimer.SetPhase("publish_snapshot") pruned, err := publishSnapshot( - workCtx, w.dir, w.keepRecent, w.externalPruning, request.blockView.blockHeight, tmpPath) + workCtx, w.dir, w.keepRecent, w.externalPruning, request.blockView.BlockHeight(), tmpPath) if err != nil { - return fmt.Errorf("publish snapshot at version %d: %w", request.blockView.blockHeight, err) + return fmt.Errorf("publish snapshot at version %d: %w", request.blockView.BlockHeight(), err) } - otelMetrics.CurrentSnapshotHeight.Record(w.ctx, request.blockView.blockHeight) + otelMetrics.CurrentSnapshotHeight.Record(w.ctx, request.blockView.BlockHeight()) logger.Info("FlatKV snapshot created", - "version", request.blockView.blockHeight, "pruned", pruned, "elapsed", time.Since(start)) + "version", request.blockView.BlockHeight(), "pruned", pruned, "elapsed", time.Since(start)) return nil } diff --git a/sei-db/state_db/sc/flatkv/snapshot_writer_messages.go b/sei-db/state_db/sc/flatkv/snapshot_writer_messages.go index 01d41e5fb8..74b191e85e 100644 --- a/sei-db/state_db/sc/flatkv/snapshot_writer_messages.go +++ b/sei-db/state_db/sc/flatkv/snapshot_writer_messages.go @@ -1,6 +1,10 @@ package flatkv -import "fmt" +import ( + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" +) // This file contains the messages that can be sent to the snapshot writer's goroutine. @@ -8,17 +12,17 @@ import "fmt" // snapshot. type snapshotRequest struct { // blockView is the view of every database at the height this snapshot would capture. It carries a - // reservation this request owns and must hand back exactly once — a second Release() on a view bricks + // reservation this request owns and must release exactly once — a second Release() on a view bricks // its manager. - blockView *storeView + blockView *sview.StoreView } -// release() hands back the reservations this request holds, so the databases can resume writing out -// later blocks. The goroutine owns this for a request it received; Offer() owns it only for one it took +// Releases the reservations this request holds, so the databases can resume writing out later +// blocks. The goroutine owns this for a request it received; Offer() owns it only for one it took // reservations for but could not enqueue. func (r *snapshotRequest) release() error { - if err := r.blockView.release(); err != nil { - return fmt.Errorf("release views at version %d: %w", r.blockView.blockHeight, err) + if err := r.blockView.Release(); err != nil { + return fmt.Errorf("release views at version %d: %w", r.blockView.BlockHeight(), err) } return nil } diff --git a/sei-db/state_db/sc/flatkv/snapshot_writer_test.go b/sei-db/state_db/sc/flatkv/snapshot_writer_test.go index 5e8e88b698..34635904ed 100644 --- a/sei-db/state_db/sc/flatkv/snapshot_writer_test.go +++ b/sei-db/state_db/sc/flatkv/snapshot_writer_test.go @@ -18,6 +18,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" ) // The bulk of this package's suite reaches the writer through commitAndCheck, which flushes it so a @@ -27,7 +28,7 @@ import ( var _ view.View = (*fakeView)(nil) -// fakeView is a view whose flush and hand-back outcomes the test chooses, and which counts both. The +// fakeView is a view whose flush and release outcomes the test chooses, and which counts both. The // methods a SnapshotWriter never reaches panic, so a use this stub was not written for is loud rather // than silently wrong. type fakeView struct { @@ -40,6 +41,10 @@ type fakeView struct { // Returned by Reserve. A non-nil value also suppresses the reserve count. reserveErr error + // Returned by every Release call. The count still advances, so a test can tell a release that was + // attempted and failed from one that never happened. + releaseErr error + // Counts successful Reserve calls. reserves atomic.Int64 @@ -61,7 +66,7 @@ func (v *fakeView) Reserve() error { func (v *fakeView) Release() error { v.releases.Add(1) - return nil + return v.releaseErr } func (v *fakeView) Get([]byte, bool) ([]byte, bool, error) { @@ -82,26 +87,40 @@ func (v *fakeView) Finalize([]*proto.KVPair) error { // fakeViews returns a store view at version backed by one stub per database, as a commit would hand // to the writer, alongside the stubs so a test can inspect what the writer did to them. -func fakeViews(t *testing.T, version int64) (*storeView, map[string]*fakeView) { +func fakeViews(t *testing.T, version int64) (*sview.StoreView, map[string]*fakeView) { t.Helper() stubs := make(map[string]*fakeView, len(dataDBDirs)) for _, name := range dataDBDirs { stubs[name] = &fakeView{name: name} } - blockView, err := newStoreView(version, + blockView, err := sview.NewStoreView(version, + stubs[accountDBDir], stubs[codeDBDir], stubs[storageDBDir], stubs[miscDBDir]) + require.NoError(t, err) + return blockView, stubs +} + +// bricksOnRelease returns a store view at version whose every view fails to hand a reservation back, for +// the paths that have to report such a failure rather than only logging it. +func bricksOnRelease(t *testing.T, version int64) (*sview.StoreView, map[string]*fakeView) { + t.Helper() + stubs := make(map[string]*fakeView, len(dataDBDirs)) + for _, name := range dataDBDirs { + stubs[name] = &fakeView{name: name, releaseErr: errors.New("view manager is bricked")} + } + blockView, err := sview.NewStoreView(version, stubs[accountDBDir], stubs[codeDBDir], stubs[storageDBDir], stubs[miscDBDir]) require.NoError(t, err) return blockView, stubs } -// requireAllReleased asserts the writer handed back every reservation it took. A reservation left held +// requireAllReleased asserts the writer released every reservation it took. A reservation left held // stalls its database's flushes forever, so this is the invariant every path must preserve. func requireAllReleased(t *testing.T, stubs map[string]*fakeView) { t.Helper() for name, stub := range stubs { require.NotZero(t, stub.reserves.Load(), "%s: the writer must take its own reservation", name) require.Equal(t, stub.reserves.Load(), stub.releases.Load(), - "%s: the writer must hand back every reservation it took", name) + "%s: the writer must release every reservation it took", name) } } @@ -253,7 +272,7 @@ func TestSnapshotWriterCloseWakesBlockedOffer(t *testing.T) { // Close waits for an in-flight checkpoint rather than abandoning it, because that checkpoint holds // handles to databases the caller is about to close. Whatever is still queued behind it is discarded, -// with its reservations handed back. +// with its reservations released. func TestSnapshotWriterCloseWaitsForCheckpointAndDiscardsQueue(t *testing.T) { db := &fakeCheckpointDB{started: make(chan struct{}), release: make(chan struct{})} w := newTestWriter(t, 1, 4, db) @@ -275,7 +294,7 @@ func TestSnapshotWriterCloseWaitsForCheckpointAndDiscardsQueue(t *testing.T) { requireAllReleased(t, queuedStubs) } -// A block the cadence does not select is handed back unwritten, by the goroutine rather than the +// A block the cadence does not select is released unwritten, by the goroutine rather than the // caller. Flush is how a test observes that the goroutine has got that far. func TestSnapshotWriterReleasesBlocksItDoesNotSnapshot(t *testing.T) { db := &fakeCheckpointDB{started: make(chan struct{})} diff --git a/sei-db/state_db/sc/flatkv/state_view.go b/sei-db/state_db/sc/flatkv/state_view.go index 6e4b3791a3..bc9207a27b 100644 --- a/sei-db/state_db/sc/flatkv/state_view.go +++ b/sei-db/state_db/sc/flatkv/state_view.go @@ -9,6 +9,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" ) @@ -16,24 +17,24 @@ var _ giga.StateView = (*flatKVStateView)(nil) // flatKVStateView serves the Giga read API from one committed block. type flatKVStateView struct { - // The block being read. Close() hands back the reservation it carries. - blockView *storeView + // The block being read. Close() releases the reservation it carries. + blockView *sview.StoreView - // Guards the hand-back, so a second Close does not release a reservation this view no longer owns. + // Guards the release, so a second Close does not release a reservation this view no longer owns. closeOnce sync.Once } // GetBlockHeight returns the block height of this view. func (v *flatKVStateView) GetBlockHeight() int64 { - return v.blockView.blockHeight + return v.blockView.BlockHeight() } -// Close hands back the reservation this view holds. The view must not be read afterwards. +// Close releases the reservation this view holds. The view must not be read afterwards. // Idempotent. func (v *flatKVStateView) Close() { v.closeOnce.Do(func() { - if err := v.blockView.release(); err != nil { - panic(fmt.Sprintf("flatkv: close state view at height %d: %v", v.blockView.blockHeight, err)) + if err := v.blockView.Release(); err != nil { + panic(fmt.Sprintf("flatkv: close state view at height %d: %v", v.blockView.BlockHeight(), err)) } }) } @@ -151,10 +152,11 @@ func (v *flatKVStateView) GetCodeSize(addr giga.Address) int { // accountData returns the account row for the 20-byte address in keyBytes, or nil when no account // exists in this block. func (v *flatKVStateView) accountData(keyBytes []byte) *vtype.AccountData { - raw, found := v.readRow(v.blockView.accountStoreView, ktype.EVMPhysicalKey(ktype.EVMKeyAccount, keyBytes)) + raw, found := v.readRow(v.blockView.AccountView(), ktype.EVMPhysicalKey(ktype.EVMKeyAccount, keyBytes)) account, err := parseRow(raw, found, vtype.DeserializeAccountData) if err != nil { - panic(fmt.Sprintf("flatkv: parse account %x at height %d: %v", keyBytes, v.blockView.blockHeight, err)) + panic(fmt.Sprintf("flatkv: parse account %x at height %d: %v", + keyBytes, v.blockView.BlockHeight(), err)) } if account == nil || account.IsDelete() { return nil @@ -164,10 +166,11 @@ func (v *flatKVStateView) accountData(keyBytes []byte) *vtype.AccountData { // storageData returns the storage row for the addr||slot in keyBytes, or nil when the slot is unset. func (v *flatKVStateView) storageData(keyBytes []byte) *vtype.StorageData { - raw, found := v.readRow(v.blockView.storageStoreView, ktype.EVMPhysicalKey(keys.EVMKeyStorage, keyBytes)) + raw, found := v.readRow(v.blockView.StorageView(), ktype.EVMPhysicalKey(keys.EVMKeyStorage, keyBytes)) storage, err := parseRow(raw, found, vtype.DeserializeStorageData) if err != nil { - panic(fmt.Sprintf("flatkv: parse storage %x at height %d: %v", keyBytes, v.blockView.blockHeight, err)) + panic(fmt.Sprintf("flatkv: parse storage %x at height %d: %v", + keyBytes, v.blockView.BlockHeight(), err)) } if storage == nil || storage.IsDelete() { return nil @@ -177,10 +180,11 @@ func (v *flatKVStateView) storageData(keyBytes []byte) *vtype.StorageData { // codeData returns the code row for the 20-byte address in keyBytes, or nil when it has no code. func (v *flatKVStateView) codeData(keyBytes []byte) *vtype.CodeData { - raw, found := v.readRow(v.blockView.codeStoreView, ktype.EVMPhysicalKey(keys.EVMKeyCode, keyBytes)) + raw, found := v.readRow(v.blockView.CodeView(), ktype.EVMPhysicalKey(keys.EVMKeyCode, keyBytes)) code, err := parseRow(raw, found, vtype.DeserializeCodeData) if err != nil { - panic(fmt.Sprintf("flatkv: parse code for %x at height %d: %v", keyBytes, v.blockView.blockHeight, err)) + panic(fmt.Sprintf("flatkv: parse code for %x at height %d: %v", + keyBytes, v.blockView.BlockHeight(), err)) } if code == nil || code.IsDelete() { return nil @@ -190,11 +194,11 @@ func (v *flatKVStateView) codeData(keyBytes []byte) *vtype.CodeData { // miscValue returns the value stored under keyBytes in the named module, and whether it was found. func (v *flatKVStateView) miscValue(module string, keyBytes []byte) ([]byte, bool) { - raw, found := v.readRow(v.blockView.miscStoreView, ktype.ModulePhysicalKey(module, keyBytes)) + raw, found := v.readRow(v.blockView.MiscView(), ktype.ModulePhysicalKey(module, keyBytes)) misc, err := parseRow(raw, found, vtype.DeserializeMiscData) if err != nil { panic(fmt.Sprintf("flatkv: parse misc %s/%x at height %d: %v", - module, keyBytes, v.blockView.blockHeight, err)) + module, keyBytes, v.blockView.BlockHeight(), err)) } if misc == nil || misc.IsDelete() { return nil, false @@ -208,7 +212,7 @@ func (v *flatKVStateView) readRow(dbView view.View, physKey []byte) ([]byte, boo value, found, err := dbView.Get(physKey, true) if err != nil { panic(fmt.Sprintf("flatkv: %s read of key %x at height %d: %v", - dbView.Name(), physKey, v.blockView.blockHeight, err)) + dbView.Name(), physKey, v.blockView.BlockHeight(), err)) } return value, found } diff --git a/sei-db/state_db/sc/flatkv/state_view_test.go b/sei-db/state_db/sc/flatkv/state_view_test.go index 11f7885ea9..483d1fb3d6 100644 --- a/sei-db/state_db/sc/flatkv/state_view_test.go +++ b/sei-db/state_db/sc/flatkv/state_view_test.go @@ -90,7 +90,7 @@ func TestOpenViewIsIsolatedFromLaterCommits(t *testing.T) { require.Equal(t, uint64(14), latest.GetNonce(gigaAddr(addr))) } -// Every OpenView takes a reservation that only Close hands back, and an unreleased view stalls its +// Every OpenView takes a reservation that only Close releases, and an unreleased view stalls its // store's flushes forever. So a leak here does not fail an assertion — it hangs the flush below. func TestOpenViewCloseReturnsReservation(t *testing.T) { s := setupTestStore(t) @@ -121,7 +121,7 @@ func TestOpenViewOnClosedStoreReportsTheStoreIsNotOpen(t *testing.T) { func() { s.OpenView() }) } -// A second Close must not hand back a reservation this view no longer owns. The damaging case is +// A second Close must not release a reservation this view no longer owns. The damaging case is // silent: while the store still has the same block installed, the extra release takes that // reservation's count to zero with no error reported, retiring a view the store believes is live. The // commits below are what surface it — sealing the next block reserves the installed view, which now diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index 676bbc7962..ee3bdaace3 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -25,6 +25,8 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" "github.com/sei-protocol/sei-chain/sei-db/state_db/statewal" "github.com/sei-protocol/seilog" @@ -75,47 +77,28 @@ type CommitStore struct { // the metadata lands in the same atomic batch as the data it describes and a database on disk can // never disagree with its own bookkeeping. This map is the in-memory copy of what was written, and // is adopted only once every store has accepted the seal. - localMeta map[string]*ktype.LocalMeta + localMeta map[string]*LocalMeta // The height of the most recently committed block. The next Commit must be exactly this plus one. committedVersion int64 - // The root LtHash as of committedVersion — the value reported to anyone asking for the committed - // hash. It does not move until a Commit has succeeded on all four stores. - committedLtHash *lthash.LtHash + // The hash state read off disk at load, which the hash engine is seeded from and which every + // hash query answers from until the first block has been finalized. Rebuilt by every path that + // reopens the databases underneath the engine. + loadedHashes *lthash.BlockHash - // The root LtHash including the most recently sealed block. Commit folds that block in and then copies - // the result into committedLtHash. Writes buffered by ApplyChangeSets are not reflected here until - // that seal, so a block still being applied has no hash. - // - // LtHash is homomorphic: a new value is mixed in and the value it replaced is mixed out, in any - // order. That is what lets a block be folded in from its own changed values rather than by re-hashing - // all of state, and it is the property that will eventually allow hashing to move off the execution - // thread — a Merkle root could not be deferred that way. The seal is what supplies those changed - // values, as the diff of the block's view against the previous one, which is why there is no hash - // before it. - workingLtHash *lthash.LtHash - - // Per-DB working LTHash tracking. Authoritative copies live in each - // DB's LocalMeta (atomically committed with data). On startup the - // working hashes are loaded from LocalMeta. - perDBWorkingLtHash map[string]*lthash.LtHash - - // Per-DB, per-module working LtHash: dbDir -> module name -> hash. - // The per-DB root (perDBWorkingLtHash[dir]) is the homomorphic sum of - // the module hashes here. account/code/storage DBs only ever carry the - // "evm" module; miscDB may carry several (evm plus cosmos modules). - // Persisted alongside the per-DB root in each DB's LocalMeta and reloaded - // on startup. This is bookkeeping metadata only: it does not feed the - // global evm_lattice/AppHash. - perDBModuleWorkingLtHash map[string]map[string]*lthash.LtHash - - // Per-DB, per-module working stats: dbDir -> module name -> key-count / - // byte totals. Accumulated alongside perDBModuleWorkingLtHash using the - // same key-membership rule, persisted in each DB's LocalMeta, and reloaded - // on startup. Consensus-irrelevant bookkeeping; per-DB / global totals are - // derived on demand. - perDBModuleWorkingStats map[string]map[string]lthash.ModuleStats + // hashEngine folds each sealed block into the running lattice hash, off the execution goroutine. + // Built by openStores once the stores exist and torn down by closeStores. Nil on a read-only store, + // which never commits — such a store answers hash queries from what it loaded. + hashEngine *lthash.HashEngine + + // finalizer records each block's hashes onto that block's own views, and is the sole consumer of + // the engine's stream. Same lifecycle as hashEngine. + finalizer *FinalizationManager + + // hashLogger receives each block's hashes as it is finalized. Held here because restartHashing + // rebuilds the finalizer, which is what reports to it. Never nil. + hashLogger hashlog.HashLogger // The four data stores below mediate every read and write of their databases. The block being // applied accumulates its writes inside each store, so a read through a store already sees what @@ -142,7 +125,7 @@ type CommitStore struct { // The views of the most recently committed block, one reservation held for as long as they stay // installed, which is what keeps any later block out of pebble. Nil outside the window in which the // view managers exist. - lastSealed *atomicStoreView + lastSealed *sview.AtomicStoreView // The state WAL. Injected at construction: non-nil ⇒ FlatKV writes/replays/prunes it; nil ⇒ the outer // context owns the whole WAL pipeline and FlatKV no-ops every WAL operation. FlatKV owns Close of whatever @@ -188,11 +171,10 @@ type CommitStore struct { // Uses a fixed-size pool, same lifecycle as readPool / miscPool. ltHashPool threading.Pool - // ltCalc encapsulates the lattice-hash pipeline (old-value reads, per-key - // hashing, and worker-combine into final per-DB / per-module hashes) over - // ltHashPool. The commit path is serialized by s.mu, so the calculator has - // a single caller at a time. - ltCalc *lthash.HashCalculator + // moduleOf names the module a physical key belongs to, for bucketing a block's pairs into per-module + // hashes. A field rather than a direct call to moduleOfKey, and read on every call rather than + // captured, so that a test can inject a failing one into an open store. + moduleOf lthash.ModuleParser } // routePhysicalKey names the database directory a physical DB key belongs to. @@ -241,8 +223,13 @@ func NewCommitStore( ctx context.Context, cfg *config.Config, stateWAL statewal.StateWAL, + // Receives each block's hashes as it is finalized. Nil records nothing. + hl hashlog.HashLogger, ) (*CommitStore, error) { + if hl == nil { + hl = hashlog.NewNoOpHashLogger() + } cfg = resolveConfig(cfg) if err := cfg.Validate(); err != nil { @@ -261,25 +248,21 @@ func NewCommitStore( ltHashPoolSize := lthashWorkerCount(cfg, coreCount) ltHashPool := threading.NewFixedPool("flatkv-lthash", ltHashPoolSize, ltHashPoolSize) - ltCalc := lthash.NewHashCalculator(ltHashPool, dataDBDirs, moduleOfKey) return &CommitStore{ - ctx: ctx, - cancel: cancel, - config: *cfg, - localMeta: make(map[string]*ktype.LocalMeta), - pendingChangeSets: make([]*proto.NamedChangeSet, 0), - committedLtHash: lthash.New(), - workingLtHash: lthash.New(), - perDBWorkingLtHash: make(map[string]*lthash.LtHash), - perDBModuleWorkingLtHash: newPerDBModuleLtHashMap(), - perDBModuleWorkingStats: newPerDBModuleStatsMap(), - phaseTimer: metrics.NewPhaseTimer(flatkvMeter, "seidb_main_thread"), - readPool: readPool, - miscPool: miscPool, - ltHashPool: ltHashPool, - ltCalc: ltCalc, - wal: stateWAL, + ctx: ctx, + cancel: cancel, + hashLogger: hl, + config: *cfg, + localMeta: make(map[string]*LocalMeta), + pendingChangeSets: make([]*proto.NamedChangeSet, 0), + loadedHashes: lthash.NewBlockHash(dataDBDirs), + phaseTimer: metrics.NewPhaseTimer(flatkvMeter, "seidb_main_thread"), + readPool: readPool, + miscPool: miscPool, + ltHashPool: ltHashPool, + moduleOf: moduleOfKey, + wal: stateWAL, }, nil } @@ -354,7 +337,6 @@ func (s *CommitStore) resetPools() { ltHashPoolSize := lthashWorkerCount(&s.config, coreCount) s.ltHashPool = threading.NewFixedPool("flatkv-lthash", ltHashPoolSize, ltHashPoolSize) - s.ltCalc = lthash.NewHashCalculator(s.ltHashPool, dataDBDirs, moduleOfKey) } func (s *CommitStore) flatkvDir() string { @@ -429,7 +411,9 @@ func (s *CommitStore) LoadVersionReadOnly(targetVersion int64) (opened giga.Live // The view gets an independent context, not one derived from s.ctx: callers close this store while // still reading from the view, and a derived context would cancel those reads. - ro, err := NewCommitStore(context.Background(), &s.config, nil) + // No logger: a read-only store replays blocks to reach its target height, and reporting them would + // duplicate rows the committing store already logged. + ro, err := NewCommitStore(context.Background(), &s.config, nil, nil) if err != nil { return nil, fmt.Errorf("failed to create readonly store: %w", err) } @@ -813,7 +797,7 @@ func (s *CommitStore) openRawDBs() (dbs rawDBs, retErr error) { // loadLocalMeta reads each data database's persisted metadata into localMeta. func (s *CommitStore) loadLocalMeta(dbs rawDBs) error { - s.localMeta = make(map[string]*ktype.LocalMeta) + s.localMeta = make(map[string]*LocalMeta) for _, dir := range dataDBDirs { meta, err := loadLocalMeta(dbs.forDir(dir)) if err != nil { @@ -885,6 +869,10 @@ func (s *CommitStore) openStores(dbs rawDBs) (retErr error) { return err } + if err := s.startHashing(); err != nil { + return err + } + if !s.readOnly { // Built last, and only here: it checkpoints the databases the view managers above own, so it must // not outlive them. closeStores drains it before those managers go away. @@ -960,6 +948,12 @@ func (s *CommitStore) rawDBFor(name string) seidbtypes.KeyValueDB { func (s *CommitStore) closeStores() error { var errs []error + // Hashing stops before the writer, which can be waiting for a block to flush — something only + // finalization makes possible. + if err := s.stopHashing(); err != nil { + errs = append(errs, err) + } + // The writer must stop before anything below runs: closing a view manager closes the database it // owns, and a checkpoint in progress would then be reading a closed handle. This is the choke point // every teardown path reaches — Close directly, Rollback and resetForImport through closeDBsOnly — @@ -971,7 +965,7 @@ func (s *CommitStore) closeStores() error { s.snapshotWriter = nil } - // Hand back the reservations on the last sealed block and forget the handles. They belong to the + // Release the reservations on the last sealed block and forget the handles. They belong to the // stores being torn down here, so keeping them would leave a reopened store (rollback, restore) // awaiting a flush on views whose store is already gone. if s.lastSealed != nil { @@ -1034,26 +1028,23 @@ func (s *CommitStore) loadGlobalMetadata() error { return nil } -// hydratePerDBState populates the working per-DB and per-module hash state from -// each data DB's LocalMeta. It rejects a DB whose per-module hashes do not sum -// to its recorded root. +// hydratePerDBState rebuilds loadedHashes from each data DB's LocalMeta. It rejects a DB whose +// per-module hashes do not sum to its recorded root. func (s *CommitStore) hydratePerDBState() error { + s.loadedHashes = lthash.NewBlockHash(dataDBDirs) for _, dbDir := range dataDBDirs { meta := s.localMeta[dbDir] if err := validatePerModuleMetadata(dbDir, meta); err != nil { return err } if meta != nil && meta.LtHash != nil { - s.perDBWorkingLtHash[dbDir] = meta.LtHash.Clone() + s.loadedHashes.PerDB[dbDir] = meta.LtHash.Clone() } else { - s.perDBWorkingLtHash[dbDir] = lthash.New() + s.loadedHashes.PerDB[dbDir] = lthash.New() } if meta != nil { - s.perDBModuleWorkingLtHash[dbDir] = cloneModuleHashes(meta.ModuleLtHashes) - s.perDBModuleWorkingStats[dbDir] = cloneModuleStats(meta.ModuleStats) - } else { - s.perDBModuleWorkingLtHash[dbDir] = make(map[string]*lthash.LtHash) - s.perDBModuleWorkingStats[dbDir] = make(map[string]lthash.ModuleStats) + s.loadedHashes.PerModule[dbDir] = cloneModuleHashes(meta.ModuleLtHashes) + s.loadedHashes.PerModuleStats[dbDir] = cloneModuleStats(meta.ModuleStats) } } return nil @@ -1063,9 +1054,7 @@ func (s *CommitStore) hydratePerDBState() error { // reached and the committed LtHash to the homomorphic sum of their roots. func (s *CommitStore) deriveGlobalState() { version := s.localMeta[dataDBDirs[0]].CommittedVersion - global := lthash.New() for _, dbDir := range dataDBDirs { - global.MixIn(s.perDBWorkingLtHash[dbDir]) if v := s.localMeta[dbDir].CommittedVersion; v < version { version = v } @@ -1081,8 +1070,107 @@ func (s *CommitStore) deriveGlobalState() { } s.committedVersion = version - s.committedLtHash = global - s.workingLtHash = global.Clone() + s.loadedHashes.BlockNumber = version + s.loadedHashes.Global = lthash.SumDBHashes(dataDBDirs, s.loadedHashes.PerDB) +} + +// startHashing builds the hash engine and the finalizer that consumes it, both seeded from what load +// read off disk. They are built together and only here, so neither outlives the stores it reads. +// +// 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 { + // Called through a closure rather than passed directly, so the field stays the live source of truth + // and a test can swap it on an open store. + moduleParser := func(key []byte) (string, error) { return s.moduleOf(key) } + + engine, err := lthash.NewHashEngine( + s.ctx, &s.config.HashEngineConfig, s.ltHashPool, dataDBDirs, moduleParser, s.loadedHashes) + if err != nil { + return fmt.Errorf("create hash engine: %w", err) + } + s.hashEngine = engine + s.finalizer = newFinalizationManager( + s.ctx, + s.hashEngine.AwaitHash(), + s.loadedHashes, + s.config.FinalizationQueueSize, + s.config.HashChanSize, + s.hashLogger, + ) + + if s.readOnly { + // Nothing consumes a read-only store's stream — HashChan reports it as closed — so it is drained + // here. Left unread, replaying past the channel's depth would block on a hash no one wants. The + // goroutine ends when the finalizer closes the stream. + published := s.finalizer.HashChan() + go func() { + for range published { //nolint:revive // discarding is the point + } + }() + } + return nil +} + +// stopHashing closes the hash engine and the finalizer, in that order. +// +// The order is load-bearing: the engine publishes the blocks it drains and the finalizer is its only +// reader, so stopping the finalizer first would leave the engine blocked forever. +func (s *CommitStore) stopHashing() error { + var errs []error + if s.hashEngine != nil { + if err := s.hashEngine.Close(); err != nil { + errs = append(errs, fmt.Errorf("close hash engine: %w", err)) + } + s.hashEngine = nil + } + if s.finalizer != nil { + if err := s.finalizer.Close(); err != nil { + errs = append(errs, fmt.Errorf("close finalization manager: %w", err)) + } + s.finalizer = nil + } + return errors.Join(errs...) +} + +// restartHashing rebuilds the hash engine and the finalizer from what the store has just loaded, for a +// caller that has replaced the databases underneath them. +// +// Discarding and rebuilding rather than reseeding in place: the caller has already quiesced the store, +// so there is nothing in flight to preserve, and the pair has to move together anyway — the finalizer +// captures the engine's stream when it is built. +func (s *CommitStore) restartHashing() error { + if s.hashEngine == nil { + return nil + } + if err := s.stopHashing(); err != nil { + return err + } + return s.startHashing() +} + +// reloadLocalMeta re-reads each data database's recorded metadata from disk, for a caller that needs +// what the finalizer has written rather than what load saw. +// +// It waits for both barriers first, because without them the read is meaningless: a block's metadata is +// written by the finalizer, on its own goroutine, into the same batch as the block's data — and that +// batch reaches disk asynchronously after that. Waiting here rather than at each caller is what stops +// the next one reading a database that has not caught up yet. +func (s *CommitStore) reloadLocalMeta() error { + if err := s.FlushHashes(); err != nil { + return fmt.Errorf("flush hashes before reading local meta: %w", err) + } + if err := s.flushLatestVersion(); err != nil { + return fmt.Errorf("flush to disk before reading local meta: %w", err) + } + for _, dir := range dataDBDirs { + meta, err := loadLocalMeta(s.rawDBFor(dir)) + if err != nil { + return fmt.Errorf("reload %s local meta: %w", dir, err) + } + s.localMeta[dir] = meta + } + return nil } // requireAlignedDataDBs returns an error unless every data DB sits at the store's committed version. @@ -1092,6 +1180,11 @@ func (s *CommitStore) deriveGlobalState() { // This is what makes summing the per-DB roots into the store root sound: the sum only describes a real // state if every DB contributed at the same version. func (s *CommitStore) requireAlignedDataDBs() error { + // s.localMeta describes what load saw, not what the replay above has since recorded. + if err := s.reloadLocalMeta(); err != nil { + return fmt.Errorf("flatkv: reload local meta before checking data DB alignment: %w", err) + } + misaligned := make([]string, 0, len(dataDBDirs)) for _, dbDir := range dataDBDirs { if meta := s.localMeta[dbDir]; meta.CommittedVersion != s.committedVersion { @@ -1116,18 +1209,80 @@ func (s *CommitStore) PendingVersion() int64 { return s.pendingBlockHeight } -// RootHash returns the Blake3-256 digest of the committed LtHash and the height that digest -// describes. +// PublishedHash returns the most recent block hash the store has published: its height, its lattice +// hash root, and each database's root. // -// The hash is computed from the snapshots a commit produces, so a block that has not been committed -// has no hash: while one is being applied this reports the previous block's hash and height. A caller -// that needs a block's own hash commits it first and checks the height it gets back. -func (s *CommitStore) RootHash() ([]byte, int64) { +// On a committing store this is whatever the pipeline has reached, which lags the committed version. On +// a store that has just been loaded, and on a read-only store, it is the height that was loaded. Use +// FlushHashes first to make it describe the version just committed. +func (s *CommitStore) PublishedHash() *lthash.BlockHash { s.mu.RLock() defer s.mu.RUnlock() - checksum := s.committedLtHash.Checksum() - return checksum[:], s.committedVersion + if s.finalizer != nil { + return s.finalizer.PublishedHash() + } + return s.loadedHashes +} + +// HashChan returns a channel producing the hash of each block. Exactly one hash per block committed, in +// block order, with no gaps or duplicates. It is closed once the store stops hashing. +// +// The channel has finite depth, so failure to dequeue hashes for long enough blocks commit. Every +// deployment therefore needs a consumer. +func (s *CommitStore) HashChan() <-chan *lthash.BlockHash { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.finalizer != nil { + return s.finalizer.HashChan() + } + // A read-only store never commits and so never publishes. A closed channel lets a consumer range + // over it and finish, rather than blocking forever on a stream that will never carry anything. + empty := make(chan *lthash.BlockHash) + close(empty) + return empty +} + +// FlushHashes blocks until the store has published a hash for every block committed so far, and +// recorded each one's metadata alongside the block it describes. +func (s *CommitStore) FlushHashes() error { + s.mu.RLock() + engine, finalizer := s.hashEngine, s.finalizer + s.mu.RUnlock() + + if engine == nil { + return nil + } + // The engine first: its output is the finalizer's input, so waiting on the finalizer alone would + // return before blocks still inside the engine had reached it. + if err := engine.Flush(); err != nil { + return fmt.Errorf("flush hashes: %w", err) + } + if err := finalizer.Flush(); err != nil { + return fmt.Errorf("flush hashes: %w", err) + } + return nil +} + +// CommitPendingBlock commits the block currently being applied, if any, so that it has a hash. A no-op +// on a store with no pending writes, which is every store between blocks and every read-only store. +// +// A block that has not been committed has no hash — the hash is computed from the views a commit +// produces — so a caller wanting one mid-block is asking for the block to be committed. This is that +// request, made explicitly. Post-Cosmos nothing asks for a hash mid-block and this goes away. +func (s *CommitStore) CommitPendingBlock() error { + if s.readOnly { + return nil + } + pending := s.PendingVersion() + if pending == 0 { + return nil + } + if _, err := s.Commit(pending); err != nil { + return fmt.Errorf("commit pending block %d: %w", pending, err) + } + return nil } func (s *CommitStore) Importer(version int64) (types.Importer, error) { @@ -1225,11 +1380,7 @@ func (s *CommitStore) resetForImport() error { } s.committedVersion = 0 - s.committedLtHash = lthash.New() - s.workingLtHash = lthash.New() - s.perDBWorkingLtHash = newPerDBLtHashMap() - s.perDBModuleWorkingLtHash = newPerDBModuleLtHashMap() - s.perDBModuleWorkingStats = newPerDBModuleStatsMap() + s.loadedHashes = lthash.NewBlockHash(dataDBDirs) return nil } diff --git a/sei-db/state_db/sc/flatkv/store_init_repair_test.go b/sei-db/state_db/sc/flatkv/store_init_repair_test.go index fba27a8820..85a14ab522 100644 --- a/sei-db/state_db/sc/flatkv/store_init_repair_test.go +++ b/sei-db/state_db/sc/flatkv/store_init_repair_test.go @@ -288,7 +288,7 @@ func TestIdentityRootsAtNonZeroVersionOpen(t *testing.T) { require.NoError(t, s.CommitStateChanges(2, []*proto.NamedChangeSet{ makeChangeSet(evmStorageKey(addrN(0x01), slotN(0x01)), nil, true), })) - require.True(t, s.committedLtHash.IsZero(), "fixture precondition: the store root is the identity") + require.True(t, s.maintainedHashes().Global.IsZero(), "fixture precondition: the store root is the identity") reopened := reopenStore(t, s, cfg) defer reopened.Close() diff --git a/sei-db/state_db/sc/flatkv/store_lifecycle.go b/sei-db/state_db/sc/flatkv/store_lifecycle.go index 3e94eb3e07..a7523a645b 100644 --- a/sei-db/state_db/sc/flatkv/store_lifecycle.go +++ b/sei-db/state_db/sc/flatkv/store_lifecycle.go @@ -7,7 +7,6 @@ import ( "path/filepath" "strings" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -27,7 +26,7 @@ func (s *CommitStore) closeDBsOnly() error { if err := s.closeStores(); err != nil { return fmt.Errorf("stores close: %w", err) } - s.localMeta = make(map[string]*ktype.LocalMeta) + s.localMeta = make(map[string]*LocalMeta) return nil } @@ -60,10 +59,6 @@ func (s *CommitStore) Close() error { s.ltHashPool.Close() s.ltHashPool = nil } - // Calculator is bound to ltHashPool; drop it so a post-Close use cannot - // submit to a closed pool. resetPools recreates both together. - s.ltCalc = nil - err := errors.Join(storeErr, s.closeDBsOnly()) // FlatKV owns Close of whatever WAL instance it currently holds (the injected one, or a replacement made diff --git a/sei-db/state_db/sc/flatkv/store_meta.go b/sei-db/state_db/sc/flatkv/store_meta.go index 913713b07d..33332fc8d0 100644 --- a/sei-db/state_db/sc/flatkv/store_meta.go +++ b/sei-db/state_db/sc/flatkv/store_meta.go @@ -14,6 +14,35 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/statewal" ) +// LocalMeta stores one data DB's own view of its committed state, held at +// _meta/version, _meta/hash and _meta/x:/hash. +// +// The version and the root are written together or not at all, so a DB either +// reports both or has never had metadata written to it: a brand-new DB reports +// neither, a seeded DB reports a version with the identity root, and a DB that +// has committed a block reports its real root. +type LocalMeta struct { + // CommittedVersion is the version this DB last committed. It reads as 0 when + // no metadata has been written, which is indistinguishable from a genuine 0. + CommittedVersion int64 + + // LtHash is this DB's root over its own keys. nil only when no metadata has + // been written; writeLocalMetaToBatch refuses to record a version without one. + LtHash *lthash.LtHash + + // ModuleLtHashes holds the LtHash of each module's keys within this DB, + // keyed by module name (e.g. "evm", "gov"). The per-DB root (LtHash) + // equals the homomorphic sum of these module hashes. nil/empty when the + // DB has never been written (fresh store). + ModuleLtHashes map[string]*lthash.LtHash + + // ModuleStats holds the auxiliary key-count / byte totals of each module's + // keys within this DB, keyed by module name and mirroring ModuleLtHashes. + // Consensus-irrelevant; per-DB / global totals are derived on demand. + // nil/empty when the DB has never been written (fresh store). + ModuleStats map[string]lthash.ModuleStats +} + // versionToBytes encodes a non-negative version as 8-byte big-endian. // Panics on negative input to catch programming errors early. // Only called from internal commit/test paths — never with untrusted input. @@ -28,8 +57,8 @@ func versionToBytes(v int64) []byte { // loadLocalMeta loads per-DB metadata by reading separate keys. A DB missing its version record is // reported as one that has never been written, and rejected if it carries any other metadata. -func loadLocalMeta(db types.KeyValueDB) (*ktype.LocalMeta, error) { - meta := &ktype.LocalMeta{} +func loadLocalMeta(db types.KeyValueDB) (*LocalMeta, error) { + meta := &LocalMeta{} versionData, err := db.Get(ktype.MetaVersionKey) if err != nil { @@ -45,7 +74,7 @@ func loadLocalMeta(db types.KeyValueDB) (*ktype.LocalMeta, error) { if err := requireNoMetadata(db); err != nil { return nil, err } - return &ktype.LocalMeta{CommittedVersion: 0}, nil + return &LocalMeta{CommittedVersion: 0}, nil } return nil, fmt.Errorf("could not read meta version: %w", err) } @@ -234,7 +263,7 @@ func encodeLocalMeta( // per-DB root and thus the global store hash / AppHash. // // Fail loudly at load instead of corrupting consensus-critical state. -func validatePerModuleMetadata(dbDir string, meta *ktype.LocalMeta) error { +func validatePerModuleMetadata(dbDir string, meta *LocalMeta) error { if meta == nil || meta.LtHash == nil { return nil } @@ -354,8 +383,8 @@ func (s *CommitStore) SetInitialVersion(initialVersion int64) error { seededVersion := initialVersion - 1 for _, dir := range dataDBDirs { - if s.perDBWorkingLtHash[dir] == nil { - s.perDBWorkingLtHash[dir] = lthash.New() + if s.loadedHashes.PerDB[dir] == nil { + s.loadedHashes.PerDB[dir] = lthash.New() } } @@ -364,15 +393,22 @@ func (s *CommitStore) SetInitialVersion(initialVersion int64) error { } for _, dir := range dataDBDirs { - s.localMeta[dir] = &ktype.LocalMeta{ + s.localMeta[dir] = &LocalMeta{ CommittedVersion: seededVersion, - LtHash: s.perDBWorkingLtHash[dir].Clone(), - ModuleLtHashes: cloneModuleHashes(s.perDBModuleWorkingLtHash[dir]), - ModuleStats: cloneModuleStats(s.perDBModuleWorkingStats[dir]), + LtHash: s.loadedHashes.PerDB[dir].Clone(), + ModuleLtHashes: cloneModuleHashes(s.loadedHashes.PerModule[dir]), + ModuleStats: cloneModuleStats(s.loadedHashes.PerModuleStats[dir]), } } s.committedVersion = seededVersion + s.loadedHashes.BlockNumber = seededVersion + + // The engine must carry back what this established, or the first real block would be measured + // against different state than was persisted. + if err := s.restartHashing(); err != nil { + return fmt.Errorf("flatkv: SetInitialVersion: %w", err) + } // The seal only stages the records; the view managers flush asynchronously. Wait for them so the seed is // durable across a restart, as this method promises. For a non-genesis seed the snapshot below supplies diff --git a/sei-db/state_db/sc/flatkv/store_meta_test.go b/sei-db/state_db/sc/flatkv/store_meta_test.go index df8d566459..a32cf2b437 100644 --- a/sei-db/state_db/sc/flatkv/store_meta_test.go +++ b/sei-db/state_db/sc/flatkv/store_meta_test.go @@ -62,12 +62,12 @@ func TestLoadLocalMeta(t *testing.T) { // bookkeeping) are rejected; both would otherwise silently corrupt the // per-DB root — and thus the global store hash / AppHash — on the first write. func TestValidatePerModuleMetadata(t *testing.T) { - nonZero, _ := lthash.ComputeLtHash(nil, []lthash.KVPairWithLastValue{ + nonZero := lthash.ComputeLtHash(nil, []lthash.KeyMutation{ {Key: []byte("k"), Value: []byte("v")}, }) require.False(t, nonZero.IsZero(), "precondition: crafted root must be non-identity") - other, _ := lthash.ComputeLtHash(nil, []lthash.KVPairWithLastValue{ + other := lthash.ComputeLtHash(nil, []lthash.KeyMutation{ {Key: []byte("other"), Value: []byte("w")}, }) require.False(t, other.IsZero()) @@ -79,20 +79,20 @@ func TestValidatePerModuleMetadata(t *testing.T) { cases := []struct { name string - meta *ktype.LocalMeta + meta *LocalMeta wantErrSub string // empty => expect success }{ {"nil meta", nil, ""}, - {"nil root", &ktype.LocalMeta{}, ""}, - {"identity root, no modules", &ktype.LocalMeta{LtHash: lthash.New()}, ""}, + {"nil root", &LocalMeta{}, ""}, + {"identity root, no modules", &LocalMeta{LtHash: lthash.New()}, ""}, { "non-identity root with matching modules", - &ktype.LocalMeta{LtHash: nonZero.Clone(), ModuleLtHashes: map[string]*lthash.LtHash{"EVM": nonZero.Clone()}}, + &LocalMeta{LtHash: nonZero.Clone(), ModuleLtHashes: map[string]*lthash.LtHash{"EVM": nonZero.Clone()}}, "", }, { "multi-module root with matching modules", - &ktype.LocalMeta{ + &LocalMeta{ LtHash: combined.Clone(), ModuleLtHashes: map[string]*lthash.LtHash{ "EVM": nonZero.Clone(), @@ -101,15 +101,15 @@ func TestValidatePerModuleMetadata(t *testing.T) { }, "", }, - {"non-identity root without modules", &ktype.LocalMeta{LtHash: nonZero.Clone()}, "predates per-module hashing"}, + {"non-identity root without modules", &LocalMeta{LtHash: nonZero.Clone()}, "predates per-module hashing"}, { "modules do not sum to root", - &ktype.LocalMeta{LtHash: combined.Clone(), ModuleLtHashes: map[string]*lthash.LtHash{"EVM": nonZero.Clone()}}, + &LocalMeta{LtHash: combined.Clone(), ModuleLtHashes: map[string]*lthash.LtHash{"EVM": nonZero.Clone()}}, "do not sum to per-DB root", }, { "identity root with non-zero modules", - &ktype.LocalMeta{LtHash: lthash.New(), ModuleLtHashes: map[string]*lthash.LtHash{"EVM": nonZero.Clone()}}, + &LocalMeta{LtHash: lthash.New(), ModuleLtHashes: map[string]*lthash.LtHash{"EVM": nonZero.Clone()}}, "do not sum to per-DB root", }, } @@ -189,7 +189,9 @@ func TestStoreSealBlockUpdatesLocalMeta(t *testing.T) { v := commitAndCheck(t, s) require.Equal(t, int64(1), v) - // LocalMeta should be updated + // LocalMeta should be updated. Read it back: the finalizer writes it, so the store's in-memory copy + // is only what load saw. + require.NoError(t, s.reloadLocalMeta()) require.Equal(t, int64(1), s.localMeta[storageDBDir].CommittedVersion) // Verify it's persisted in DB @@ -371,9 +373,9 @@ func TestDerivedGlobalStatePersistence(t *testing.T) { require.Equal(t, int64(2), meta.CommittedVersion, "%s version record", ndb.dir) derived.MixIn(meta.LtHash) } - require.Equal(t, s.committedLtHash.Checksum(), derived.Checksum()) + require.Equal(t, s.maintainedHashes().Global.Checksum(), derived.Checksum()) - expectedHash := s.committedLtHash.Checksum() + expectedHash := s.maintainedHashes().Global.Checksum() require.NoError(t, s.Close()) cfg2 := config.DefaultConfig() @@ -385,7 +387,7 @@ func TestDerivedGlobalStatePersistence(t *testing.T) { defer s2.Close() require.Equal(t, int64(2), s2.committedVersion) - require.Equal(t, expectedHash, s2.committedLtHash.Checksum(), + require.Equal(t, expectedHash, s2.maintainedHashes().Global.Checksum(), "global LtHash should survive reopen") } diff --git a/sei-db/state_db/sc/flatkv/store_read.go b/sei-db/state_db/sc/flatkv/store_read.go index 266d58e9a7..e66b574d5e 100644 --- a/sei-db/state_db/sc/flatkv/store_read.go +++ b/sei-db/state_db/sc/flatkv/store_read.go @@ -11,10 +11,10 @@ import ( ) // OpenView returns a read-only view of the most recently committed block. It is the Giga StateDB entry -// point for reads served out of SC. The caller must Close the view, which is what hands back the +// point for reads served out of SC. The caller must Close the view, which is what releases the // reservation holding the block readable. func (s *CommitStore) OpenView() giga.StateView { - blockView, err := s.lastSealed.get() + blockView, err := s.lastSealed.Get() if err != nil { panic(fmt.Sprintf("flatkv: OpenView: %v", err)) } diff --git a/sei-db/state_db/sc/flatkv/store_replay.go b/sei-db/state_db/sc/flatkv/store_replay.go index b756615c1c..c46163bbd5 100644 --- a/sei-db/state_db/sc/flatkv/store_replay.go +++ b/sei-db/state_db/sc/flatkv/store_replay.go @@ -244,7 +244,6 @@ func (s *CommitStore) applyAndCommit( return fmt.Errorf("commit v%d: %w", version, err) } s.committedVersion = version - s.committedLtHash = s.workingLtHash.Clone() s.clearPendingBlock() return nil } diff --git a/sei-db/state_db/sc/flatkv/store_replay_test.go b/sei-db/state_db/sc/flatkv/store_replay_test.go index f448315438..9b63ba8ec1 100644 --- a/sei-db/state_db/sc/flatkv/store_replay_test.go +++ b/sei-db/state_db/sc/flatkv/store_replay_test.go @@ -358,7 +358,7 @@ func TestReplaySkipDoesNotRewindRecordedHeight(t *testing.T) { require.Equal(t, int64(4), s.Version()) // What each database recorded at block 4, which is the state it must keep. - before := make(map[string]*ktype.LocalMeta, len(dataDBDirs)) + before := make(map[string]*LocalMeta, len(dataDBDirs)) for _, dir := range dataDBDirs { meta, err := loadLocalMeta(s.rawDBFor(dir)) require.NoError(t, err) diff --git a/sei-db/state_db/sc/flatkv/store_test.go b/sei-db/state_db/sc/flatkv/store_test.go index 958139bd52..ca04e645e3 100644 --- a/sei-db/state_db/sc/flatkv/store_test.go +++ b/sei-db/state_db/sc/flatkv/store_test.go @@ -111,7 +111,7 @@ func TestNewCommitStoreLeavesCallerConfigUntouched(t *testing.T) { before := *cfg - s, err := NewCommitStore(t.Context(), cfg, nil) + s, err := NewCommitStore(t.Context(), cfg, nil, nil) require.NoError(t, err) defer s.Close() @@ -377,7 +377,7 @@ func TestStoreRootHashChanges(t *testing.T) { defer s.Close() // Initial hash - hash1, version1 := s.RootHash() + hash1, version1 := rootHashAndVersion(s) require.NotNil(t, hash1) require.Equal(t, 32, len(hash1)) // Blake3-256 require.Equal(t, int64(0), version1) @@ -393,7 +393,7 @@ func TestStoreRootHashChanges(t *testing.T) { committed := commitAndCheck(t, s) // Committing a block that changes state changes the hash, and the height moves with it. - hash2, version2 := s.RootHash() + hash2, version2 := rootHashAndVersion(s) require.NotEqual(t, hash1, hash2) require.Equal(t, committed, version2) } @@ -403,7 +403,7 @@ func TestStoreRootHashUnchangedByApply(t *testing.T) { defer s.Close() // Initial hash - hash1, version1 := s.RootHash() + hash1, version1 := rootHashAndVersion(s) require.NotNil(t, hash1) require.Equal(t, 32, len(hash1)) // Blake3-256 @@ -416,7 +416,7 @@ func TestStoreRootHashUnchangedByApply(t *testing.T) { require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) // A block that has not been sealed has no hash, so the store still describes the previous height. - hash2, version2 := s.RootHash() + hash2, version2 := rootHashAndVersion(s) require.Equal(t, hash1, hash2, "staging a block must not move the hash") require.Equal(t, version1, version2) } @@ -433,14 +433,14 @@ func TestStoreRootHashStableAfterCommit(t *testing.T) { require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) committed := commitAndCheck(t, s) - committedHash, committedVersion := s.RootHash() + committedHash, committedVersion := rootHashAndVersion(s) require.Equal(t, committed, committedVersion) // Staging the next block must leave the committed hash exactly where it is. next := makeChangeSet(key, padLeft32(0x78), false) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{next})) - stagedHash, stagedVersion := s.RootHash() + stagedHash, stagedVersion := rootHashAndVersion(s) require.Equal(t, committedHash, stagedHash) require.Equal(t, committedVersion, stagedVersion) } @@ -477,7 +477,7 @@ func TestFileLockPreventsDoubleOpen(t *testing.T) { // conflict would instead surface at construction, from the WAL's own directory lock.) cfg = config.DefaultTestConfig(t) cfg.DataDir = filepath.Join(dir, flatkvRootDir) - s2, err := NewCommitStore(t.Context(), cfg, nil) + s2, err := NewCommitStore(t.Context(), cfg, nil, nil) require.NoError(t, err) err = s2.LoadLatest() require.Error(t, err, "second open on same dir should fail due to file lock") @@ -977,7 +977,7 @@ func TestCleanupOrphanedReadOnlyDirsHoldsWriterLock(t *testing.T) { // nil WAL on the second store so its construction does not take the WAL's changelog-directory lock; // this isolates the flatkv writer LOCK that CleanupOrphanedReadOnlyDirs must find held by s1. - s2, err := NewCommitStore(t.Context(), cfg, nil) + s2, err := NewCommitStore(t.Context(), cfg, nil, nil) require.NoError(t, err) defer func() { require.NoError(t, s2.Close()) }() @@ -1282,13 +1282,16 @@ func TestCrashRecoverySkewedPerDBVersions(t *testing.T) { require.Equal(t, int64(6), s.Version()) // Save the correct per-DB LtHash for accountDB before skewing version. - savedAccountLtHash := s.perDBWorkingLtHash[accountDBDir].Clone() + savedAccountLtHash := s.maintainedHashes().PerDB[accountDBDir].Clone() // Skew accountDB's local meta version to 4 while keeping the correct // LtHash. This simulates a crash where the version watermark wasn't // persisted but the actual data and hash are intact. batch := s.rawDBFor(accountDBDir).NewBatch() - require.NoError(t, writeLocalMetaToBatch(batch, 4, savedAccountLtHash, s.perDBModuleWorkingLtHash[accountDBDir], s.perDBModuleWorkingStats[accountDBDir])) + maintained := s.maintainedHashes() + require.NoError(t, writeLocalMetaToBatch( + batch, 4, savedAccountLtHash, + maintained.PerModule[accountDBDir], maintained.PerModuleStats[accountDBDir])) require.NoError(t, batch.Commit(types.WriteOptions{Sync: true})) _ = batch.Close() @@ -1338,11 +1341,14 @@ func TestCrashRecoveryGlobalMetadataAheadOfDataDBs(t *testing.T) { } // Save the correct storageDB per-DB LtHash before skewing. - savedStorageLtHash := s.perDBWorkingLtHash[storageDBDir].Clone() + savedStorageLtHash := s.maintainedHashes().PerDB[storageDBDir].Clone() // Simulate crash: storageDB only flushed v3 (version watermark behind). batch := s.rawDBFor(storageDBDir).NewBatch() - require.NoError(t, writeLocalMetaToBatch(batch, 3, savedStorageLtHash, s.perDBModuleWorkingLtHash[storageDBDir], s.perDBModuleWorkingStats[storageDBDir])) + maintained := s.maintainedHashes() + require.NoError(t, writeLocalMetaToBatch( + batch, 3, savedStorageLtHash, + maintained.PerModule[storageDBDir], maintained.PerModuleStats[storageDBDir])) require.NoError(t, batch.Commit(types.WriteOptions{Sync: true})) _ = batch.Close() @@ -1585,7 +1591,7 @@ func TestCrashRecoveryCorruptedAccountValueInDB(t *testing.T) { // Reopen without a WAL. With one, replay would rewrite this account from block 1's changeset and // heal the row before anything read it — correct system behavior, but it would leave this test with // nothing to observe. A nil WAL leaves the corruption in place so the read path is what meets it. - s2, err := NewCommitStore(t.Context(), cfg, nil) + s2, err := NewCommitStore(t.Context(), cfg, nil, nil) require.NoError(t, err) defer s2.Close() require.NoError(t, s2.LoadLatest()) diff --git a/sei-db/state_db/sc/flatkv/store_write.go b/sei-db/state_db/sc/flatkv/store_write.go index e27c4dd3bb..9a35bf6f81 100644 --- a/sei-db/state_db/sc/flatkv/store_write.go +++ b/sei-db/state_db/sc/flatkv/store_write.go @@ -3,16 +3,13 @@ package flatkv import ( "errors" "fmt" - "strings" - "sync" "time" "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" "go.opentelemetry.io/otel/metric" ) @@ -104,9 +101,10 @@ func (s *CommitStore) Commit(version int64) (committed int64, err error) { return version, fmt.Errorf("seal block: %w", err) } - // Step 3: Update in-memory committed state, only once every store accepted the seal. + // Step 3: Update in-memory committed state, only once every store accepted the seal. The block's + // hash is not part of this: it is computed and recorded asynchronously, and read back through + // PublishedHash or HashChan. s.committedVersion = version - s.committedLtHash = s.workingLtHash.Clone() // Step 4: Clear per-block bookkeeping s.clearPendingBlock() @@ -151,12 +149,13 @@ func (s *CommitStore) clearPendingBlock() { s.pendingBlockHeight = 0 } -// sealBlock marks the block as closed for new writes, hashes it, and records each database's metadata. -// -// alreadyHave is the catch-up skip list: the height each store had already reached when replay started, -// or nil outside a replay. A store listed at or above version keeps the metadata it already has, since -// recording this block's height would move that store backwards. -func (s *CommitStore) sealBlock(version int64, alreadyHave map[string]int64) error { +// sealBlock marks the block as closed for new writes and hands it to the hashing pipeline. +func (s *CommitStore) sealBlock( + version int64, + // The replay skip list: the height each database had already reached when replay started, or nil + // outside replay. A database listed at or above version keeps the metadata it already has. + alreadyHave map[string]int64, +) error { s.phaseTimer.SetPhase("commit_seal_stores") blockView, err := s.commitStores(version) @@ -164,56 +163,41 @@ func (s *CommitStore) sealBlock(version int64, alreadyHave map[string]int64) err return err } - previous, err := s.lastSealed.get() + previous, err := s.lastSealed.Get() if err != nil { // Error is fatal; leaking reservations doesn't make it worse. return fmt.Errorf("read previous block's view: %w", err) } - if err := s.hashSealedBlock(blockView, previous); err != nil { - // Error is fatal; leaking reservations doesn't make it worse. - return fmt.Errorf("hash sealed block: %w", err) - } - if err := previous.release(); err != nil { + if err := s.lastSealed.Set(blockView); err != nil { // Error is fatal; leaking reservations doesn't make it worse. - return fmt.Errorf("release previous block's reservations: %w", err) + return fmt.Errorf("install block %d: %w", version, err) } - s.phaseTimer.SetPhase("commit_finalize_stores") - for _, dbView := range blockView.viewSlice { - if err := s.finalizeStore(dbView, version, alreadyHave); err != nil { - // Error is fatal; leaking reservations doesn't make it worse. - return fmt.Errorf("finalize %s: %w", dbView.Name(), err) - } + s.phaseTimer.SetPhase("commit_offer_finalization") + if err := s.finalizer.Offer(version, blockView, alreadyHave); err != nil { + // Error is fatal; leaking reservations doesn't make it worse. + return err } - if err := s.lastSealed.set(blockView); err != nil { + s.phaseTimer.SetPhase("commit_schedule_hash") + if err := s.hashEngine.ScheduleHash(blockView, previous); err != nil { // Error is fatal; leaking reservations doesn't make it worse. - return fmt.Errorf("install block %d: %w", version, err) - } - if err := blockView.release(); err != nil { - return fmt.Errorf("release this block's reservations: %w", err) + return err } - // Adopt the freshly persisted per-DB metadata only once every store has accepted it. A store that - // kept its own metadata above keeps its in-memory copy too. - for _, dir := range dataDBDirs { - if alreadyHave[dir] >= version { - continue - } - s.localMeta[dir] = &ktype.LocalMeta{ - CommittedVersion: version, - LtHash: s.perDBWorkingLtHash[dir].Clone(), - ModuleLtHashes: cloneModuleHashes(s.perDBModuleWorkingLtHash[dir]), - ModuleStats: cloneModuleStats(s.perDBModuleWorkingStats[dir]), - } + if err := previous.Release(); err != nil { + return fmt.Errorf("release previous block's view: %w", err) + } + if err := blockView.Release(); err != nil { + return fmt.Errorf("release block %d's view: %w", version, err) } return nil } // commitStores() seals the current block on every store as one view at version. The returned view // carries the reservation each store's Commit() handed out, and the caller owns it. -func (s *CommitStore) commitStores(version int64) (*storeView, error) { +func (s *CommitStore) commitStores(version int64) (*sview.StoreView, error) { commit := func(store view.ViewManager) (view.View, error) { start := time.Now() dbView, err := store.Commit() @@ -244,134 +228,13 @@ func (s *CommitStore) commitStores(version int64) (*storeView, error) { // Error is fatal; leaking reservations doesn't make it worse. return nil, err } - return newStoreView(version, account, code, storage, misc) -} - -// hashSealedBlock folds the block that was just sealed into the store's hashes. -// -// The new values are each data store's view diff. The old values are those same keys read back -// from previous, the view of the block before it. -func (s *CommitStore) hashSealedBlock(current *storeView, previous *storeView) error { - s.phaseTimer.SetPhase("commit_compute_lt_hash") - - changed, err := s.changedValuesByStore(current, previous) - if err != nil { - return fmt.Errorf("gather changed values: %w", err) - } - res, err := s.ltCalc.Compute( - changed, - s.perDBWorkingLtHash, - s.perDBModuleWorkingLtHash, - s.perDBModuleWorkingStats) - if err != nil { - return fmt.Errorf("compute lt hash: %w", err) - } - - s.perDBWorkingLtHash = res.PerDB - s.perDBModuleWorkingLtHash = res.PerModule - s.perDBModuleWorkingStats = res.PerModuleStats - s.workingLtHash = res.Global - return nil -} - -// changedValuesByStore returns every key the block changed, with its new value and the value it held -// before, one set per data store. -// -// The stores are read concurrently on the misc pool; each one is an independent view diff followed -// by a batch read of the previous view. -// -// The store-wide root is rebuilt from scratch on every seal — HashCalculator.Compute sums the four -// per-database roots and never mixes in the previous store-wide value. -func (s *CommitStore) changedValuesByStore(current *storeView, previous *storeView) ([]lthash.DBPairs, error) { - pairs := []struct { - current view.View - previous view.View - }{ - {current.accountStoreView, previous.accountStoreView}, - {current.codeStoreView, previous.codeStoreView}, - {current.storageStoreView, previous.storageStoreView}, - {current.miscStoreView, previous.miscStoreView}, - } - - changed := make([][]lthash.KVPairWithLastValue, len(pairs)) - errs := make([]error, len(pairs)) - - var wg sync.WaitGroup - for i, pair := range pairs { - idx, currentView, previousView := i, pair.current, pair.previous - wg.Add(1) - s.miscPool.Submit(func() { - defer wg.Done() - changed[idx], errs[idx] = changedValues(currentView, previousView) - if errs[idx] != nil { - errs[idx] = fmt.Errorf("%s changed values: %w", currentView.Name(), errs[idx]) - } - }) - } - wg.Wait() - - out := make([]lthash.DBPairs, 0, len(pairs)) - for i, pair := range pairs { - if errs[i] != nil { - return nil, errs[i] - } - if len(changed[i]) == 0 { - continue - } - out = append(out, lthash.DBPairs{Dir: pair.current.Name(), Pairs: changed[i]}) - } - return out, nil -} - -// changedValues returns one data store's changed keys, each with its new value and the value it held -// before, from the store's sealed diff and the view preceding it. -// -// A nil value in the diff is a deletion. Keys under the reserved metadata prefix are dropped: they are -// the store's bookkeeping, and folding them in would make the hash depend on its own recorded value. -func changedValues(current view.View, previous view.View) ([]lthash.KVPairWithLastValue, error) { - diff, err := current.GetDiff() - if err != nil { - return nil, fmt.Errorf("read diff: %w", err) - } - if len(diff) == 0 { - return nil, nil - } - - changedKeys := make([][]byte, 0, len(diff)) - for key := range diff { - if strings.HasPrefix(key, config.MetaKeyPrefix) { - continue - } - changedKeys = append(changedKeys, []byte(key)) - } - if len(changedKeys) == 0 { - return nil, nil - } - - var old map[string][]byte - if previous != nil { - if old, err = previous.BatchGet(changedKeys); err != nil { - return nil, fmt.Errorf("read previous values: %w", err) - } - } - - out := make([]lthash.KVPairWithLastValue, 0, len(changedKeys)) - for _, key := range changedKeys { - value := diff[string(key)] - out = append(out, lthash.KVPairWithLastValue{ - Key: key, - Value: value, - LastValue: old[string(key)], - Delete: value == nil, - }) - } - return out, nil + return sview.NewStoreView(version, account, code, storage, misc) } // offerToSnapshotWriter() hands the most recently committed block to the writer, which decides whether // it becomes a snapshot. The writer takes its own reservation, so this one lasts only for the call. func (s *CommitStore) offerToSnapshotWriter() error { - blockView, err := s.lastSealed.get() + blockView, err := s.lastSealed.Get() if err != nil { return fmt.Errorf("read latest sealed view: %w", err) } @@ -379,7 +242,7 @@ func (s *CommitStore) offerToSnapshotWriter() error { // Error is fatal; leaking reservations doesn't make it worse. return err } - if err := blockView.release(); err != nil { + if err := blockView.Release(); err != nil { return fmt.Errorf("release latest sealed view: %w", err) } return nil @@ -388,7 +251,7 @@ func (s *CommitStore) offerToSnapshotWriter() error { // replaceSealedView() installs blockView, discarding whatever was installed before. The startup // lifecycle seals use it because they may install a block no later than the current one, which set() // refuses. The caller keeps its own reservations. -func (s *CommitStore) replaceSealedView(blockView *storeView) error { +func (s *CommitStore) replaceSealedView(blockView *sview.StoreView) error { if s.lastSealed != nil { if err := s.lastSealed.Close(); err != nil { // Error is fatal; leaking reservations doesn't make it worse. @@ -397,9 +260,9 @@ func (s *CommitStore) replaceSealedView(blockView *storeView) error { s.lastSealed = nil } - installed, err := newAtomicStoreView(blockView) + installed, err := sview.NewAtomicStoreView(blockView) if err != nil { - return fmt.Errorf("install sealed view at height %d: %w", blockView.blockHeight, err) + return fmt.Errorf("install sealed view at height %d: %w", blockView.BlockHeight(), err) } s.lastSealed = installed return nil @@ -411,18 +274,18 @@ func (s *CommitStore) replaceSealedView(blockView *storeView) error { // // Since we continue to hold the reservation on that block, later blocks are prevented from being flushed // down to pebble. So on return the pebble instances hold exactly the most recently committed block, and -// stay there until the reservation is handed back — which is what anyone reading the databases directly, +// stay there until the reservation is released — which is what anyone reading the databases directly, // rather than through the stores, depends on. func (s *CommitStore) flushLatestVersion() error { - blockView, err := s.lastSealed.get() + blockView, err := s.lastSealed.Get() if err != nil { return fmt.Errorf("read latest sealed view: %w", err) } - if err := blockView.awaitFlush(s.ctx); err != nil { + if err := blockView.AwaitFlush(s.ctx); err != nil { // Error is fatal; leaking reservations doesn't make it worse. return fmt.Errorf("await flush: %w", err) } - if err := blockView.release(); err != nil { + if err := blockView.Release(); err != nil { return fmt.Errorf("release latest sealed view: %w", err) } return nil @@ -430,20 +293,27 @@ func (s *CommitStore) flushLatestVersion() error { // finalizeStore finalizes one store's sealed block, recording the LocalMeta that describes it. // -// A store that already reached this height records nothing. Its writes were skipped, so its hash still -// describes the later height it holds; writing this block's height alongside that hash would persist a -// pair that describes no single moment. Finalizing with an empty write set still makes the sealed -// version flushable, which is the only thing finalization is required to do. -func (s *CommitStore) finalizeStore(dbView view.View, version int64, alreadyHave map[string]int64) error { +// Finalizing with an empty write set still makes the sealed version flushable, which is the only thing +// finalization is required to do. +func finalizeStore( + dbView view.View, + version int64, + // The replay skip list. A store listed at or above version records nothing: its writes were skipped, + // so its hash still describes the later height it holds, and writing this block's height alongside + // that hash would persist a pair that describes no single moment. + alreadyHave map[string]int64, + // The block's hashes, which this store's own entry is read out of. + hashes *lthash.BlockHash, +) error { if alreadyHave[dbView.Name()] >= version { return dbView.Finalize(nil) } writes, err := encodeLocalMeta( version, - s.perDBWorkingLtHash[dbView.Name()], - s.perDBModuleWorkingLtHash[dbView.Name()], - s.perDBModuleWorkingStats[dbView.Name()], + hashes.PerDB[dbView.Name()], + hashes.PerModule[dbView.Name()], + hashes.PerModuleStats[dbView.Name()], ) if err != nil { return fmt.Errorf("encode %s local meta at version %d: %w", dbView.Name(), version, err) @@ -468,10 +338,10 @@ func (s *CommitStore) FinalizeImport(version int64) error { syncOpt := types.WriteOptions{Sync: true} for _, dir := range dataDBDirs { db := s.rawDBFor(dir) - moduleHashes := s.perDBModuleWorkingLtHash[dir] - moduleStats := s.perDBModuleWorkingStats[dir] + moduleHashes := s.loadedHashes.PerModule[dir] + moduleStats := s.loadedHashes.PerModuleStats[dir] batch := db.NewBatch() - err := writeLocalMetaToBatch(batch, version, s.perDBWorkingLtHash[dir], moduleHashes, moduleStats) + err := writeLocalMetaToBatch(batch, version, s.loadedHashes.PerDB[dir], moduleHashes, moduleStats) if err != nil { _ = batch.Close() return fmt.Errorf("%s local meta: %w", dir, err) @@ -481,21 +351,24 @@ func (s *CommitStore) FinalizeImport(version int64) error { return fmt.Errorf("%s commit: %w", dir, err) } _ = batch.Close() - s.localMeta[dir] = &ktype.LocalMeta{ + s.localMeta[dir] = &LocalMeta{ CommittedVersion: version, - LtHash: s.perDBWorkingLtHash[dir].Clone(), + LtHash: s.loadedHashes.PerDB[dir].Clone(), ModuleLtHashes: cloneModuleHashes(moduleHashes), ModuleStats: cloneModuleStats(moduleStats), } } - globalHash := lthash.New() - for _, dir := range dataDBDirs { - globalHash.MixIn(s.perDBWorkingLtHash[dir]) - } - s.workingLtHash = globalHash + s.loadedHashes.Global = lthash.SumDBHashes(dataDBDirs, s.loadedHashes.PerDB) + s.loadedHashes.BlockNumber = version s.committedVersion = version - s.committedLtHash = s.workingLtHash.Clone() + + // The engine's accumulator described the databases this import has just replaced wholesale, so it is + // replaced too. Without this the first block committed afterwards would be folded onto state that no + // longer exists. + if err := s.restartHashing(); err != nil { + return fmt.Errorf("after import: %w", err) + } // Imported data goes straight to Pebble, so no view describes it and the sealed view is still the // one open() installed. Sealing here is what leaves the store's committed version and its sealed @@ -512,7 +385,7 @@ func (s *CommitStore) FinalizeImport(version int64) error { // It is how SetInitialVersion persists a seed. Every write goes through the view manager that owns its // database, as a block's finalization writes do, so seeding needs no access to the databases themselves. // -// The reservation hand-back matters as much as the writes: a view must be released before the next one +// Releasing the reservation matters as much as the writes: a view must be released before the next one // can flush, so a seal that kept the baseline's reservation would stall every flush after it, and the // checkpoint SetInitialVersion takes next would wait forever. func (s *CommitStore) sealSeededVersion(seededVersion int64) error { @@ -521,8 +394,8 @@ func (s *CommitStore) sealSeededVersion(seededVersion int64) error { return fmt.Errorf("seal seeded version: %w", err) } - for _, dbView := range blockView.viewSlice { - if err := s.finalizeStore(dbView, seededVersion, nil); err != nil { + for _, dbView := range blockView.Views() { + if err := finalizeStore(dbView, seededVersion, nil, s.loadedHashes); err != nil { // Error is fatal; leaking reservations doesn't make it worse. return fmt.Errorf("%s finalize seeded version: %w", dbView.Name(), err) } @@ -532,7 +405,7 @@ func (s *CommitStore) sealSeededVersion(seededVersion int64) error { // Error is fatal; leaking reservations doesn't make it worse. return fmt.Errorf("install seeded version: %w", err) } - if err := blockView.release(); err != nil { + if err := blockView.Release(); err != nil { return fmt.Errorf("release seeded version's reservations: %w", err) } return nil @@ -546,7 +419,7 @@ func (s *CommitStore) sealBaseline() error { return fmt.Errorf("seal baseline: %w", err) } - for _, dbView := range blockView.viewSlice { + for _, dbView := range blockView.Views() { if err := dbView.Finalize(nil); err != nil { // Error is fatal; leaking reservations doesn't make it worse. return fmt.Errorf("%s finalize baseline: %w", dbView.Name(), err) @@ -557,7 +430,7 @@ func (s *CommitStore) sealBaseline() error { // Error is fatal; leaking reservations doesn't make it worse. return fmt.Errorf("install baseline: %w", err) } - if err := blockView.release(); err != nil { + if err := blockView.Release(); err != nil { return fmt.Errorf("release baseline reservations: %w", err) } return nil diff --git a/sei-db/state_db/sc/flatkv/store_write_test.go b/sei-db/state_db/sc/flatkv/store_write_test.go index 3e6389328b..368386c83d 100644 --- a/sei-db/state_db/sc/flatkv/store_write_test.go +++ b/sei-db/state_db/sc/flatkv/store_write_test.go @@ -13,7 +13,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/sview" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" ) @@ -406,7 +406,9 @@ func TestStoreWriteMiscKeys(t *testing.T) { commitAndCheck(t, s) - // Verify miscDB LocalMeta is updated + // Verify miscDB LocalMeta is updated. Read it back: the finalizer writes it, so the store's + // in-memory copy is only what load saw. + require.NoError(t, s.reloadLocalMeta()) require.Equal(t, int64(1), s.localMeta[miscDBDir].CommittedVersion) // Verify data persisted (via Store.Get which deserializes) @@ -635,7 +637,7 @@ func TestCommitFailsWhenPeriodicSnapshotFails(t *testing.T) { "the error must name the snapshot as the cause rather than being swallowed") } -// The store's contract makes every error fatal, so a hand-back failure during teardown has to reach the +// The store's contract makes every error fatal, so a release failure during teardown has to reach the // caller of Close rather than only the log. func TestCloseReportsReleaseFailure(t *testing.T) { s := setupTestStore(t) @@ -644,7 +646,7 @@ func TestCloseReportsReleaseFailure(t *testing.T) { // left holding anything when they are torn down below. require.NoError(t, s.lastSealed.Close()) sealed, _ := bricksOnRelease(t, s.Version()) - installed, err := newAtomicStoreView(sealed) + installed, err := sview.NewAtomicStoreView(sealed) require.NoError(t, err) s.lastSealed = installed @@ -1550,6 +1552,9 @@ func countLiveEntries(t *testing.T, db types.KeyValueDB) int { func requireAllLocalMetaAt(t *testing.T, s *CommitStore, ver int64) { t.Helper() + // A block's metadata is written by the finalizer, so the store's in-memory copy is only what load + // saw. Read back what was actually recorded. + require.NoError(t, s.reloadLocalMeta()) require.Equal(t, ver, s.localMeta[storageDBDir].CommittedVersion) require.Equal(t, ver, s.localMeta[accountDBDir].CommittedVersion) require.Equal(t, ver, s.localMeta[codeDBDir].CommittedVersion) @@ -1800,18 +1805,22 @@ func TestApplyChangeSetsKeepsPendingCleanOnLaterParseError(t *testing.T) { // (the AppHash input) stayed put. _, err = s.Commit(s.Version() + 1) require.NoError(t, err) - require.True(t, s.committedLtHash.Equal(before.global)) + require.True(t, s.maintainedHashes().Global.Equal(before.global)) _, ok := s.Get(keys.EVMStoreKey, keys.BuildEVMKey(keys.EVMKeyNonce, addr[:])) require.False(t, ok, "nonce row from the failed apply must not be persisted") _, ok = s.Get(keys.EVMStoreKey, storageKey) require.False(t, ok, "storage row from the failed apply must not be persisted") } -// TestCommitFailsCleanlyOnHashError pins that a hash failure does not leave the store believing it -// committed. -func TestCommitFailsCleanlyOnHashError(t *testing.T) { +// A hash failure is no longer a commit failure: hashing happens after the block is committed, so the +// commit succeeds and the failure surfaces where the hash does. +// +// What must not happen is the failure being lost. It has to reach both a caller waiting for hashes to +// catch up and a consumer reading the stream, and no hash may be published after it — once a block has +// failed, the running accumulator describes nothing a later block could be derived from. +func TestHashFailureSurfacesOnTheStream(t *testing.T) { s := setupTestStore(t) - defer s.Close() + defer func() { _ = s.Close() }() seedAddr := addrN(0xAC) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ @@ -1819,28 +1828,32 @@ func TestCommitFailsCleanlyOnHashError(t *testing.T) { {Name: "gov", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{{Key: []byte("params"), Value: []byte{0x03}}}}}, })) commitAndCheck(t, s) - committed := s.Version() - before := captureWorkingHashes(s) - s.ltCalc = lthash.NewHashCalculator(s.ltHashPool, dataDBDirs, func([]byte) (string, error) { - return "", fmt.Errorf("injected moduleOf failure") - }) + hashes := s.HashChan() + require.NoError(t, (<-hashes).Error, "the good block hashes normally") - addr := addrN(0xDD) - slot := slotN(0x03) - storageKey := keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, slot)) + s.moduleOf = func([]byte) (string, error) { + return "", fmt.Errorf("injected moduleOf failure") + } + storageKey := keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addrN(0xDD), slotN(0x03))) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ makeChangeSet(storageKey, padLeft32(0xEE), false), })) - _, err := s.Commit(s.Version() + 1) - require.Error(t, err) - require.Contains(t, err.Error(), "injected moduleOf failure") + committed, err := s.Commit(s.Version() + 1) + require.NoError(t, err, "hashing runs after the commit, so the commit itself still succeeds") + require.Equal(t, int64(2), committed) - // The store must not look like the block landed. - require.Equal(t, committed, s.Version(), "a failed commit must not advance the version") - requireWorkingHashesUnchanged(t, s, before) + failed := <-hashes + require.Error(t, failed.Error, "the failure must reach the stream") + require.ErrorContains(t, failed.Error, "injected moduleOf failure") + + _, open := <-hashes + require.False(t, open, "nothing may be published after a failed block") + + require.ErrorContains(t, s.FlushHashes(), "injected moduleOf failure", + "a caller waiting for hashes must be told they failed, not that they are done") } func TestApplyChangeSetsEVMKeyEmptySkipped(t *testing.T) { diff --git a/sei-db/state_db/sc/flatkv/atomic_store_view.go b/sei-db/state_db/sc/flatkv/sview/atomic_store_view.go similarity index 62% rename from sei-db/state_db/sc/flatkv/atomic_store_view.go rename to sei-db/state_db/sc/flatkv/sview/atomic_store_view.go index 2fb43344e3..d9be4d54bb 100644 --- a/sei-db/state_db/sc/flatkv/atomic_store_view.go +++ b/sei-db/state_db/sc/flatkv/sview/atomic_store_view.go @@ -1,41 +1,41 @@ -package flatkv +package sview import ( "fmt" "sync" ) -// atomicStoreView holds one storeView and hands it out to readers on any thread. It owns exactly one +// AtomicStoreView holds one StoreView and hands it out to readers on any thread. It owns exactly one // reservation on the view it holds, from construction until Close(). // -// All methods are safe to call concurrently. set() rejects a view that does not advance the installed +// All methods are safe to call concurrently. Set() rejects a view that does not advance the installed // height, so that height strictly increases. // -// The view get() returns stays readable for as long as its caller holds the reservation get() took, +// The view Get() returns stays readable for as long as its caller holds the reservation Get() took, // and is unaffected by views installed afterwards. -type atomicStoreView struct { +type AtomicStoreView struct { // Guards currentView. mu sync.RWMutex // The most recently installed view. Nil once closed. - currentView *storeView + currentView *StoreView } -// newAtomicStoreView() installs initialView, which must be non-nil. -func newAtomicStoreView(initialView *storeView) (*atomicStoreView, error) { +// NewAtomicStoreView() installs initialView, which must be non-nil. +func NewAtomicStoreView(initialView *StoreView) (*AtomicStoreView, error) { if initialView == nil { return nil, fmt.Errorf("initial view is nil") } - if err := initialView.reserve(); err != nil { + if err := initialView.Reserve(); err != nil { return nil, fmt.Errorf("reserve initial view: %w", err) } - return &atomicStoreView{currentView: initialView}, nil + return &AtomicStoreView{currentView: initialView}, nil } -// get() returns the installed view with a reservation the caller owns and must release exactly once. +// Get() returns the installed view with a reservation the caller owns and must release exactly once. // // Safe to call on a nil receiver, which reports an error rather than panicking. -func (asv *atomicStoreView) get() (*storeView, error) { +func (asv *AtomicStoreView) Get() (*StoreView, error) { if asv == nil { return nil, fmt.Errorf("no sealed block: the store is not open") } @@ -46,15 +46,15 @@ func (asv *atomicStoreView) get() (*storeView, error) { if asv.currentView == nil { return nil, fmt.Errorf("atomic store view is closed") } - if err := asv.currentView.reserve(); err != nil { + if err := asv.currentView.Reserve(); err != nil { return nil, fmt.Errorf("reserve view at height %d: %w", asv.currentView.blockHeight, err) } return asv.currentView, nil } -// set() installs newView, which must describe a later block than the view already installed. The +// Set() installs newView, which must describe a later block than the view already installed. The // caller keeps its own reservation on newView and remains responsible for releasing it. -func (asv *atomicStoreView) set(newView *storeView) error { +func (asv *AtomicStoreView) Set(newView *StoreView) error { if newView == nil { return fmt.Errorf("new view is nil") } @@ -70,23 +70,23 @@ func (asv *atomicStoreView) set(newView *storeView) error { asv.currentView.blockHeight, newView.blockHeight) } - // Reserved before the installed view is handed back, so a failure here leaves that view installed + // Reserved before the installed view is released, so a failure here leaves that view installed // and still readable rather than stranding readers on a view at zero reservations. - if err := newView.reserve(); err != nil { + if err := newView.Reserve(); err != nil { return fmt.Errorf("reserve view at height %d: %w", newView.blockHeight, err) } previous := asv.currentView asv.currentView = newView - if err := previous.release(); err != nil { + if err := previous.Release(); err != nil { return fmt.Errorf("release view at height %d: %w", previous.blockHeight, err) } return nil } -// Close() hands back the reservation on the installed view and retires this atomicStoreView. get() and -// set() both fail afterwards. Idempotent. -func (asv *atomicStoreView) Close() error { +// Close() releases the reservation on the installed view and retires this AtomicStoreView. Get() and +// Set() both fail afterwards. Idempotent. +func (asv *AtomicStoreView) Close() error { asv.mu.Lock() defer asv.mu.Unlock() @@ -96,7 +96,7 @@ func (asv *atomicStoreView) Close() error { previous := asv.currentView asv.currentView = nil - if err := previous.release(); err != nil { + if err := previous.Release(); err != nil { return fmt.Errorf("release view at height %d: %w", previous.blockHeight, err) } return nil diff --git a/sei-db/state_db/sc/flatkv/atomic_store_view_test.go b/sei-db/state_db/sc/flatkv/sview/atomic_store_view_test.go similarity index 82% rename from sei-db/state_db/sc/flatkv/atomic_store_view_test.go rename to sei-db/state_db/sc/flatkv/sview/atomic_store_view_test.go index 281c40ee02..639ef96000 100644 --- a/sei-db/state_db/sc/flatkv/atomic_store_view_test.go +++ b/sei-db/state_db/sc/flatkv/sview/atomic_store_view_test.go @@ -1,4 +1,4 @@ -package flatkv +package sview import ( "errors" @@ -9,13 +9,12 @@ import ( "github.com/stretchr/testify/require" ) -// These tests use the fakeView stub and the fakeViews helper defined in snapshot_writer_test.go, and // requireBalanced from store_view_test.go. // An atomic store view with nothing in it would force every later call to answer "no view", which is // the case the constructor exists to rule out. func TestNewAtomicStoreViewRequiresAView(t *testing.T) { - _, err := newAtomicStoreView(nil) + _, err := NewAtomicStoreView(nil) require.ErrorContains(t, err, "initial view is nil") } @@ -24,22 +23,22 @@ func TestNewAtomicStoreViewRequiresAView(t *testing.T) { // reserved and so can no longer be read. func TestAtomicStoreViewSetKeepsInstalledViewWhenReserveFails(t *testing.T) { installed, installedStubs := fakeViews(t, 1) - asv, err := newAtomicStoreView(installed) + asv, err := NewAtomicStoreView(installed) require.NoError(t, err) - bad, err := newStoreView(2, + bad, err := NewStoreView(2, &fakeView{name: accountDBDir}, &fakeView{name: codeDBDir, reserveErr: errors.New("manager is bricked")}, &fakeView{name: storageDBDir}, &fakeView{name: miscDBDir}) require.NoError(t, err) - require.ErrorContains(t, asv.set(bad), "manager is bricked") + require.ErrorContains(t, asv.Set(bad), "manager is bricked") - blockView, err := asv.get() + blockView, err := asv.Get() require.NoError(t, err, "the installed view must still be readable after a failed set") require.Equal(t, int64(1), blockView.blockHeight, "the installed view must not have been displaced") - require.NoError(t, blockView.release()) + require.NoError(t, blockView.Release()) require.NoError(t, asv.Close()) requireBalanced(t, installedStubs) @@ -49,12 +48,12 @@ func TestAtomicStoreViewSetKeepsInstalledViewWhenReserveFails(t *testing.T) { // backwards means building a new atomic store view, which is what the startup-lifecycle seals do. func TestAtomicStoreViewRefusesToGoBackwards(t *testing.T) { installed, _ := fakeViews(t, 5) - asv, err := newAtomicStoreView(installed) + asv, err := NewAtomicStoreView(installed) require.NoError(t, err) for _, height := range []int64{4, 5} { earlier, stubs := fakeViews(t, height) - require.ErrorContains(t, asv.set(earlier), "view height must advance", + require.ErrorContains(t, asv.Set(earlier), "view height must advance", "height %d is not above the installed height 5", height) for name, stub := range stubs { require.Zero(t, stub.reserves.Load(), "%s: a refused view must not be reserved", name) @@ -62,27 +61,27 @@ func TestAtomicStoreViewRefusesToGoBackwards(t *testing.T) { } later, laterStubs := fakeViews(t, 6) - require.NoError(t, asv.set(later)) + require.NoError(t, asv.Set(later)) require.NoError(t, asv.Close()) requireBalanced(t, laterStubs) } -// Close is the terminal release: it hands back the reservation the atomic store view owns, which is +// Close is the terminal release: it releases the reservation the atomic store view owns, which is // what lets the view managers underneath it shut down. Nothing may be handed out afterwards. func TestAtomicStoreViewIsUnusableAfterClose(t *testing.T) { installed, stubs := fakeViews(t, 1) - asv, err := newAtomicStoreView(installed) + asv, err := NewAtomicStoreView(installed) require.NoError(t, err) require.NoError(t, asv.Close()) require.NoError(t, asv.Close(), "Close must be idempotent") - _, err = asv.get() + _, err = asv.Get() require.ErrorContains(t, err, "closed") later, _ := fakeViews(t, 2) - require.ErrorContains(t, asv.set(later), "closed") + require.ErrorContains(t, asv.Set(later), "closed") requireBalanced(t, stubs) for name, stub := range stubs { @@ -99,11 +98,11 @@ func TestAtomicStoreViewServesReadersWhileAdvancing(t *testing.T) { const readers = 8 initial, initialStubs := fakeViews(t, 1) - asv, err := newAtomicStoreView(initial) + asv, err := NewAtomicStoreView(initial) require.NoError(t, err) // Built up front so the writer goroutine does nothing but install them. - blockViews := make([]*storeView, 0, blocks) + blockViews := make([]*StoreView, 0, blocks) allStubs := []map[string]*fakeView{initialStubs} for height := int64(2); height <= blocks; height++ { blockView, stubs := fakeViews(t, height) @@ -120,7 +119,7 @@ func TestAtomicStoreViewServesReadersWhileAdvancing(t *testing.T) { defer wg.Done() defer close(stop) for _, blockView := range blockViews { - if err := asv.set(blockView); err != nil { + if err := asv.Set(blockView); err != nil { failures <- fmt.Errorf("set height %d: %w", blockView.blockHeight, err) return } @@ -138,7 +137,7 @@ func TestAtomicStoreViewServesReadersWhileAdvancing(t *testing.T) { default: } - blockView, err := asv.get() + blockView, err := asv.Get() if err != nil { failures <- fmt.Errorf("get: %w", err) return @@ -147,7 +146,7 @@ func TestAtomicStoreViewServesReadersWhileAdvancing(t *testing.T) { failures <- fmt.Errorf("reader was handed height %d", blockView.blockHeight) return } - if err := blockView.release(); err != nil { + if err := blockView.Release(); err != nil { failures <- fmt.Errorf("release height %d: %w", blockView.blockHeight, err) return } diff --git a/sei-db/state_db/sc/flatkv/store_view.go b/sei-db/state_db/sc/flatkv/sview/store_view.go similarity index 58% rename from sei-db/state_db/sc/flatkv/store_view.go rename to sei-db/state_db/sc/flatkv/sview/store_view.go index 3f9001472d..68541066d3 100644 --- a/sei-db/state_db/sc/flatkv/store_view.go +++ b/sei-db/state_db/sc/flatkv/sview/store_view.go @@ -1,4 +1,4 @@ -package flatkv +package sview import ( "context" @@ -7,11 +7,11 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" ) -// storeView is a read only view of all of FlatKV's stores at a single block height. +// StoreView is a read only view of all of FlatKV's stores at a single block height. // -// It holds no reservation of its own. Reading through it requires a reservation, taken with reserve() +// It holds no reservation of its own. Reading through it requires a reservation, taken with Reserve() // or handed over by whoever took one already. -type storeView struct { +type StoreView struct { // A read only view of the account store. accountStoreView view.View @@ -25,21 +25,21 @@ type storeView struct { miscStoreView view.View // Every store's view, for the operations that treat them uniformly. In no particular order; a - // caller that wants a specific store reads the field for it. + // caller that wants a specific store reads the accessor for it. viewSlice []view.View // The block height this view is targeted on. blockHeight int64 } -// newStoreView() describes the state of every store at blockHeight. -func newStoreView( +// NewStoreView() describes the state of every store at blockHeight. +func NewStoreView( blockHeight int64, accountStoreView view.View, codeStoreView view.View, storageStoreView view.View, miscStoreView view.View, -) (*storeView, error) { +) (*StoreView, error) { if accountStoreView == nil { return nil, fmt.Errorf("account view is nil") } @@ -53,7 +53,7 @@ func newStoreView( return nil, fmt.Errorf("misc view is nil") } - return &storeView{ + return &StoreView{ accountStoreView: accountStoreView, codeStoreView: codeStoreView, storageStoreView: storageStoreView, @@ -65,9 +65,40 @@ func newStoreView( }, nil } -// reserve() takes one reservation on every store's view. A failure stops there, leaving what it +// BlockHeight() returns the block height this view is targeted on. +func (sv *StoreView) BlockHeight() int64 { + return sv.blockHeight +} + +// AccountView() returns the account store's view. +func (sv *StoreView) AccountView() view.View { + return sv.accountStoreView +} + +// CodeView() returns the code store's view. +func (sv *StoreView) CodeView() view.View { + return sv.codeStoreView +} + +// StorageView() returns the storage store's view. +func (sv *StoreView) StorageView() view.View { + return sv.storageStoreView +} + +// MiscView() returns the misc store's view. +func (sv *StoreView) MiscView() view.View { + return sv.miscStoreView +} + +// Views() returns every store's view, for the operations that treat them uniformly. The order is +// unspecified, and the returned slice must not be modified. +func (sv *StoreView) Views() []view.View { + return sv.viewSlice +} + +// Reserve() takes one reservation on every store's view. A failure stops there, leaving what it // already took held: a view manager error is unrecoverable, so the node is going down anyway. -func (sv *storeView) reserve() error { +func (sv *StoreView) Reserve() error { for _, dbView := range sv.viewSlice { if err := dbView.Reserve(); err != nil { return fmt.Errorf("reserve %s view at height %d: %w", dbView.Name(), sv.blockHeight, err) @@ -76,9 +107,9 @@ func (sv *storeView) reserve() error { return nil } -// release() hands back one reservation on every store's view. A failure stops there, for the same -// reason reserve() does. -func (sv *storeView) release() error { +// Releases one reservation on every store's view. A failure stops there, for the same reason +// Reserve() does. +func (sv *StoreView) Release() error { for _, dbView := range sv.viewSlice { if err := dbView.Release(); err != nil { return fmt.Errorf("release %s view at height %d: %w", dbView.Name(), sv.blockHeight, err) @@ -87,9 +118,9 @@ func (sv *storeView) release() error { return nil } -// awaitFlush() blocks until every store has written this view's block to disk. The caller must hold a +// AwaitFlush() blocks until every store has written this view's block to disk. The caller must hold a // reservation across the call. -func (sv *storeView) awaitFlush(ctx context.Context) error { +func (sv *StoreView) AwaitFlush(ctx context.Context) error { for _, dbView := range sv.viewSlice { if err := dbView.AwaitFlush(ctx); err != nil { return fmt.Errorf("await flush of %s at height %d: %w", dbView.Name(), sv.blockHeight, err) diff --git a/sei-db/state_db/sc/flatkv/store_view_test.go b/sei-db/state_db/sc/flatkv/sview/store_view_test.go similarity index 83% rename from sei-db/state_db/sc/flatkv/store_view_test.go rename to sei-db/state_db/sc/flatkv/sview/store_view_test.go index e6c4616948..615afabcad 100644 --- a/sei-db/state_db/sc/flatkv/store_view_test.go +++ b/sei-db/state_db/sc/flatkv/sview/store_view_test.go @@ -1,4 +1,4 @@ -package flatkv +package sview import ( "context" @@ -11,8 +11,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/proto" ) -// These tests use the fakeView stub and the fakeViews helper defined in snapshot_writer_test.go. - var _ view.View = (*stubView)(nil) // stubView is a view whose Release outcome the test chooses. Only Name, Reserve and Release are @@ -64,25 +62,25 @@ func (s *stubView) AwaitFlush(ctx context.Context) error { // bricksOnRelease builds a store view over stubs that all fail to release, and returns the stubs so a // test can count the attempts. -func bricksOnRelease(t *testing.T, version int64) (*storeView, map[string]*stubView) { +func bricksOnRelease(t *testing.T, version int64) (*StoreView, map[string]*stubView) { t.Helper() stubs := make(map[string]*stubView, len(dataDBDirs)) for _, name := range dataDBDirs { stubs[name] = &stubView{name: name, releaseErr: errors.New("view manager is bricked")} } - blockView, err := newStoreView(version, + blockView, err := NewStoreView(version, stubs[accountDBDir], stubs[codeDBDir], stubs[storageDBDir], stubs[miscDBDir]) require.NoError(t, err) return blockView, stubs } -// requireBalanced asserts every reservation taken on these views was handed back. A reservation left -// held stalls its store's flushes forever, and one handed back twice bricks its manager. +// requireBalanced asserts every reservation taken on these views was released. A reservation left +// held stalls its store's flushes forever, and one released twice bricks its manager. func requireBalanced(t *testing.T, stubs map[string]*fakeView) { t.Helper() for name, stub := range stubs { require.Equal(t, stub.reserves.Load(), stub.releases.Load(), - "%s: took %d reservations and handed back %d", + "%s: took %d reservations and released %d", name, stub.reserves.Load(), stub.releases.Load()) } } @@ -96,19 +94,19 @@ func TestNewStoreViewRejectsNilViews(t *testing.T) { } account, code, storage, misc := present() - _, err := newStoreView(1, nil, code, storage, misc) + _, err := NewStoreView(1, nil, code, storage, misc) require.ErrorContains(t, err, "account view is nil") account, code, storage, misc = present() - _, err = newStoreView(1, account, nil, storage, misc) + _, err = NewStoreView(1, account, nil, storage, misc) require.ErrorContains(t, err, "code view is nil") account, code, storage, misc = present() - _, err = newStoreView(1, account, code, nil, misc) + _, err = NewStoreView(1, account, code, nil, misc) require.ErrorContains(t, err, "storage view is nil") account, code, storage, misc = present() - _, err = newStoreView(1, account, code, storage, nil) + _, err = NewStoreView(1, account, code, storage, nil) require.ErrorContains(t, err, "misc view is nil") } @@ -118,10 +116,10 @@ func TestStoreViewReserveStopsAtFirstFailure(t *testing.T) { bad := &fakeView{name: codeDBDir, reserveErr: errors.New("manager is bricked")} rest := &fakeView{name: storageDBDir} - blockView, err := newStoreView(1, &fakeView{name: accountDBDir}, bad, rest, &fakeView{name: miscDBDir}) + blockView, err := NewStoreView(1, &fakeView{name: accountDBDir}, bad, rest, &fakeView{name: miscDBDir}) require.NoError(t, err) - err = blockView.reserve() + err = blockView.Reserve() require.Error(t, err) require.ErrorContains(t, err, "manager is bricked") require.ErrorContains(t, err, "reserve code view at height 1", "the error must name the store that failed") @@ -133,8 +131,8 @@ func TestStoreViewReserveStopsAtFirstFailure(t *testing.T) { func TestStoreViewReleaseStopsAtFirstFailure(t *testing.T) { blockView, stubs := bricksOnRelease(t, 1) - err := blockView.release() - require.Error(t, err, "a failed hand-back must be returned, not swallowed") + err := blockView.Release() + require.Error(t, err, "a failed release must be returned, not swallowed") require.ErrorContains(t, err, "view manager is bricked") attempted := 0 diff --git a/sei-db/state_db/sc/flatkv/sview/testutil_test.go b/sei-db/state_db/sc/flatkv/sview/testutil_test.go new file mode 100644 index 0000000000..1d25266c0f --- /dev/null +++ b/sei-db/state_db/sc/flatkv/sview/testutil_test.go @@ -0,0 +1,93 @@ +package sview + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" + "github.com/sei-protocol/sei-chain/sei-db/proto" +) + +// A StoreView is four named views and nothing more, so these tests supply their own names rather than +// depending on flatKV's store layout. They match the names flatKV uses, so a failure message here reads +// the same as one from the store above. +const ( + accountDBDir = "account" + codeDBDir = "code" + storageDBDir = "storage" + miscDBDir = "misc" +) + +var dataDBDirs = []string{accountDBDir, codeDBDir, storageDBDir, miscDBDir} + +var _ view.View = (*fakeView)(nil) + +// fakeView is a view whose reserve and flush outcomes the test chooses, and which counts reservations +// both ways. The methods a StoreView never reaches panic, so a use this stub was not written for is loud +// rather than silently wrong. +type fakeView struct { + // Reported by Name. + name string + + // Returned by AwaitFlush. + awaitFlushErr error + + // Returned by Reserve. A non-nil value also suppresses the reserve count. + reserveErr error + + // Counts successful Reserve calls. + reserves atomic.Int64 + + // Counts Release calls. + releases atomic.Int64 +} + +func (v *fakeView) Name() string { return v.name } + +func (v *fakeView) AwaitFlush(context.Context) error { return v.awaitFlushErr } + +func (v *fakeView) Reserve() error { + if v.reserveErr != nil { + return v.reserveErr + } + v.reserves.Add(1) + return nil +} + +func (v *fakeView) Release() error { + v.releases.Add(1) + return nil +} + +func (v *fakeView) Get([]byte, bool) ([]byte, bool, error) { + panic("fakeView: unexpected Get") +} + +func (v *fakeView) BatchGet([][]byte) (map[string][]byte, error) { + panic("fakeView: unexpected BatchGet") +} + +func (v *fakeView) GetDiff() (map[string][]byte, error) { + panic("fakeView: unexpected GetDiff") +} + +func (v *fakeView) Finalize([]*proto.KVPair) error { + panic("fakeView: unexpected Finalize") +} + +// fakeViews returns a store view at version backed by one stub per database, alongside the stubs so a +// test can inspect what was done to them. +func fakeViews(t *testing.T, version int64) (*StoreView, map[string]*fakeView) { + t.Helper() + stubs := make(map[string]*fakeView, len(dataDBDirs)) + for _, name := range dataDBDirs { + stubs[name] = &fakeView{name: name} + } + blockView, err := NewStoreView(version, + stubs[accountDBDir], stubs[codeDBDir], stubs[storageDBDir], stubs[miscDBDir]) + require.NoError(t, err) + return blockView, stubs +} diff --git a/sei-db/state_db/sc/flatkv/testutil_test.go b/sei-db/state_db/sc/flatkv/testutil_test.go index 6aabbd5ddd..84e78ef9b8 100644 --- a/sei-db/state_db/sc/flatkv/testutil_test.go +++ b/sei-db/state_db/sc/flatkv/testutil_test.go @@ -2,6 +2,7 @@ package flatkv import ( "encoding/binary" + "fmt" "maps" "path/filepath" "testing" @@ -16,6 +17,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" "github.com/stretchr/testify/require" ) @@ -147,6 +149,17 @@ func setupTestStore(t *testing.T) *CommitStore { return s } +// setupTestStoreWithHashLogger creates a test store that reports each finalized block's hashes to hl. +func setupTestStoreWithHashLogger(t *testing.T, cfg *config.Config, hl hashlog.HashLogger) *CommitStore { + t.Helper() + stateWAL, err := OpenStateWAL(cfg) + require.NoError(t, err) + s, err := NewCommitStore(t.Context(), cfg, stateWAL, hl) + require.NoError(t, err) + require.NoError(t, s.LoadLatest()) + return s +} + // setupTestStoreWithConfig creates a test store with custom config func setupTestStoreWithConfig(t *testing.T, cfg *config.Config) *CommitStore { t.Helper() @@ -178,11 +191,33 @@ func commitAndCheck(t *testing.T, s *CommitStore) int64 { return v } -// rootHash returns the store's committed root hash, discarding the height it describes. Tests that -// care about the height assert on it directly rather than through this. +// rootHash returns the store's root hash once hashing has caught up with what was committed. +// +// Hashing is asynchronous, so nearly every assertion about a hash needs that barrier first; putting it +// here rather than at each call site is what keeps the suite from racing the pipeline. func rootHash(s giga.LiveStateStore) []byte { - hash, _ := s.RootHash() - return hash + if err := s.FlushHashes(); err != nil { + panic(fmt.Sprintf("flatkv: flush hashes before reading the root: %v", err)) + } + checksum := s.PublishedHash().Global.Checksum() + return checksum[:] +} + +// rootHashAndVersion is rootHash paired with the height it describes, for the tests that assert on +// both. Reading them after the same flush is what makes them describe one moment. +func rootHashAndVersion(s giga.LiveStateStore) ([]byte, int64) { + return rootHash(s), s.Version() +} + +// maintainedHashes returns the hash state the store maintains, with the pipeline caught up first. +// +// This is what the store's synchronous accumulator fields used to be, and it is a method on the store +// so a test can read it the same way. +func (s *CommitStore) maintainedHashes() *lthash.BlockHash { + if err := s.FlushHashes(); err != nil { + panic(fmt.Sprintf("flatkv: flush hashes before reading maintained state: %v", err)) + } + return s.PublishedHash() } // ---------- helpers to build prefix-encoded changeset pairs ---------- @@ -305,24 +340,24 @@ type workingHashes struct { } func captureWorkingHashes(s *CommitStore) workingHashes { - perDB := make(map[string]*lthash.LtHash, len(s.perDBWorkingLtHash)) - for dir, h := range s.perDBWorkingLtHash { + perDB := make(map[string]*lthash.LtHash, len(s.maintainedHashes().PerDB)) + for dir, h := range s.maintainedHashes().PerDB { perDB[dir] = h.Clone() } - perModule := make(map[string]map[string]*lthash.LtHash, len(s.perDBModuleWorkingLtHash)) - for dir, mods := range s.perDBModuleWorkingLtHash { + perModule := make(map[string]map[string]*lthash.LtHash, len(s.maintainedHashes().PerModule)) + for dir, mods := range s.maintainedHashes().PerModule { cloned := make(map[string]*lthash.LtHash, len(mods)) for module, h := range mods { cloned[module] = h.Clone() } perModule[dir] = cloned } - perModuleStats := make(map[string]map[string]lthash.ModuleStats, len(s.perDBModuleWorkingStats)) - for dir, mods := range s.perDBModuleWorkingStats { + perModuleStats := make(map[string]map[string]lthash.ModuleStats, len(s.maintainedHashes().PerModuleStats)) + for dir, mods := range s.maintainedHashes().PerModuleStats { perModuleStats[dir] = maps.Clone(mods) } return workingHashes{ - global: s.workingLtHash.Clone(), + global: s.maintainedHashes().Global.Clone(), perDB: perDB, perModule: perModule, perModuleStats: perModuleStats, @@ -334,16 +369,17 @@ func requireWorkingHashesUnchanged(t *testing.T, s *CommitStore, before workingH // Compute clones prev* before folding; a regression that mutates those // clones in place or swaps them onto the store on the error path must // fail these checks. Global equality alone cannot catch a per-module rewrite. - require.True(t, s.workingLtHash.Equal(before.global), "workingLtHash mutated on failed Apply") - require.Equal(t, len(before.perDB), len(s.perDBWorkingLtHash), "perDBWorkingLtHash dir set changed") + require.True(t, s.maintainedHashes().Global.Equal(before.global), "workingLtHash mutated on failed Apply") + require.Equal(t, len(before.perDB), len(s.maintainedHashes().PerDB), "perDBWorkingLtHash dir set changed") for dir, want := range before.perDB { - got := s.perDBWorkingLtHash[dir] + got := s.maintainedHashes().PerDB[dir] require.NotNil(t, got, "perDBWorkingLtHash[%s] missing", dir) require.True(t, got.Equal(want), "perDBWorkingLtHash[%s] mutated on failed Apply", dir) } - require.Equal(t, len(before.perModule), len(s.perDBModuleWorkingLtHash), "perDBModuleWorkingLtHash dir set changed") + require.Equal(t, len(before.perModule), len(s.maintainedHashes().PerModule), + "maintained per-module dir set changed") for dir, wantMods := range before.perModule { - gotMods := s.perDBModuleWorkingLtHash[dir] + gotMods := s.maintainedHashes().PerModule[dir] require.Equal(t, len(wantMods), len(gotMods), "perDBModuleWorkingLtHash[%s] module set changed", dir) for module, want := range wantMods { got := gotMods[module] @@ -351,7 +387,8 @@ func requireWorkingHashesUnchanged(t *testing.T, s *CommitStore, before workingH require.True(t, got.Equal(want), "perDBModuleWorkingLtHash[%s][%s] mutated on failed Apply", dir, module) } } - require.Equal(t, before.perModuleStats, s.perDBModuleWorkingStats, "perDBModuleWorkingStats mutated on failed Apply") + require.Equal(t, before.perModuleStats, s.maintainedHashes().PerModuleStats, + "maintained per-module stats mutated on failed Apply") } // stagedRow reads a physical key back through its store and decodes it. The store reports whatever diff --git a/sei-db/state_db/sc/flatkv/verify.go b/sei-db/state_db/sc/flatkv/verify.go index 12dd667650..1402b08750 100644 --- a/sei-db/state_db/sc/flatkv/verify.go +++ b/sei-db/state_db/sc/flatkv/verify.go @@ -38,33 +38,43 @@ func verifyLtHashInternal(cs *CommitStore) error { ) } + // Hashing is asynchronous, so the maintained state has to be caught up with the committed height + // before the scan can be compared against it. + if err := cs.FlushHashes(); err != nil { + return fmt.Errorf("VerifyLtHash: flush hashes before verifying: %w", err) + } + // verifyPersistedDBMetadata reads the databases rather than the stores, so whatever the view managers have // staged has to reach pebble before it can see it. if err := cs.flushLatestVersion(); err != nil { return fmt.Errorf("VerifyLtHash: flush before reading persisted metadata: %w", err) } + // Read once, so every comparison below describes the same moment. + maintained := cs.PublishedHash() + // Recompute each DB's per-module hashes and stats from disk, validate the // maintained per-module metadata against them, and accumulate the global // root as the homomorphic sum of the derived per-DB roots. - global := lthash.New() + perDB := make(map[string]*lthash.LtHash, len(dataDBDirs)) for _, store := range cs.stores { scanHash, scanStats, err := scanStoreByModule(store) if err != nil { return fmt.Errorf("VerifyLtHash: scan %s: %w", store.Name(), err) } - dbRoot, err := cs.verifyDBModuleMetadata(store.Name(), scanHash, scanStats) + dbRoot, err := cs.verifyDBModuleMetadata(store.Name(), maintained, scanHash, scanStats) if err != nil { return err } if err := cs.verifyPersistedDBMetadata(store.Name(), dbRoot); err != nil { return err } - global.MixIn(dbRoot) + perDB[store.Name()] = dbRoot } + global := lthash.SumDBHashes(dataDBDirs, perDB) - // The scan reflects committed state, so committedLtHash is the reference. - if gc, cc := global.Checksum(), cs.committedLtHash.Checksum(); gc != cc { + // The scan reflects committed state, so the maintained root is the reference. + if gc, cc := global.Checksum(), maintained.Global.Checksum(); gc != cc { return fmt.Errorf( "VerifyLtHash: global mismatch at version %d\n committed: %x\n full-scan: %x", cs.committedVersion, cc, gc, @@ -88,7 +98,7 @@ func scanStoreByModule( } defer func() { _ = iter.Close() }() - byModule := make(map[string][]lthash.KVPairWithLastValue) + byModule := make(map[string][]lthash.KeyMutation) stats := make(map[string]lthash.ModuleStats) for ; iter.Valid(); iter.Next() { // Match foldChunk / serializeKV: empty key or empty value is not a @@ -101,7 +111,7 @@ func scanStoreByModule( if err != nil { return nil, nil, fmt.Errorf("route key %x: %w", iter.Key(), err) } - byModule[module] = append(byModule[module], lthash.KVPairWithLastValue{ + byModule[module] = append(byModule[module], lthash.KeyMutation{ Key: bytes.Clone(iter.Key()), Value: bytes.Clone(iter.Value()), }) @@ -116,7 +126,7 @@ func scanStoreByModule( hashes := make(map[string]*lthash.LtHash, len(byModule)) for module, pairs := range byModule { - h, _ := lthash.ComputeLtHash(nil, pairs) + h := lthash.ComputeLtHash(nil, pairs) if h == nil { h = lthash.New() } @@ -162,11 +172,12 @@ func (cs *CommitStore) verifyPersistedDBMetadata(dir string, scanRoot *lthash.Lt // that is not zeroed, or the per-module sum not equaling the per-DB root. func (cs *CommitStore) verifyDBModuleMetadata( dir string, + maintained *lthash.BlockHash, scanHash map[string]*lthash.LtHash, scanStats map[string]lthash.ModuleStats, ) (*lthash.LtHash, error) { - workingHash := cs.perDBModuleWorkingLtHash[dir] - workingStats := cs.perDBModuleWorkingStats[dir] + workingHash := maintained.PerModule[dir] + workingStats := maintained.PerModuleStats[dir] // Every module on disk must match the maintained hash and stats. for module, h := range scanHash { @@ -217,7 +228,7 @@ func (cs *CommitStore) verifyDBModuleMetadata( // The maintained per-module hashes must homomorphically sum to the // maintained per-DB root, and that root must equal the scan. - root := cs.perDBWorkingLtHash[dir] + root := maintained.PerDB[dir] sum := lthash.SumModuleHashes(workingHash) if root == nil || !root.Equal(sum) { return nil, fmt.Errorf( diff --git a/sei-db/state_db/sc/flatkv/verify_test.go b/sei-db/state_db/sc/flatkv/verify_test.go index 5f970c2aef..17d498bf69 100644 --- a/sei-db/state_db/sc/flatkv/verify_test.go +++ b/sei-db/state_db/sc/flatkv/verify_test.go @@ -14,38 +14,38 @@ import ( // with no on-disk keys and no maintained hash cannot slip past verification. // The hash-keyed residue loop alone would miss it. func TestVerifyDBModuleMetadataOrphanStats(t *testing.T) { - cs := &CommitStore{ - committedVersion: 1, - perDBWorkingLtHash: map[string]*lthash.LtHash{storageDBDir: lthash.New()}, - perDBModuleWorkingLtHash: map[string]map[string]*lthash.LtHash{storageDBDir: {}}, - perDBModuleWorkingStats: map[string]map[string]lthash.ModuleStats{ + cs := &CommitStore{committedVersion: 1} + maintained := <hash.BlockHash{ + PerDB: map[string]*lthash.LtHash{storageDBDir: lthash.New()}, + PerModule: map[string]map[string]*lthash.LtHash{storageDBDir: {}}, + PerModuleStats: map[string]map[string]lthash.ModuleStats{ storageDBDir: { "orphan": {KeyCount: 3, Bytes: 99}, }, }, } - _, err := cs.verifyDBModuleMetadata(storageDBDir, nil, nil) + _, err := cs.verifyDBModuleMetadata(storageDBDir, maintained, nil, nil) require.Error(t, err) require.Contains(t, err.Error(), "per-module stats") require.Contains(t, err.Error(), "orphan") } func TestVerifyDBModuleMetadataZeroResidueOK(t *testing.T) { - cs := &CommitStore{ - committedVersion: 1, - perDBWorkingLtHash: map[string]*lthash.LtHash{ + cs := &CommitStore{committedVersion: 1} + maintained := <hash.BlockHash{ + PerDB: map[string]*lthash.LtHash{ storageDBDir: lthash.New(), }, - perDBModuleWorkingLtHash: map[string]map[string]*lthash.LtHash{ + PerModule: map[string]map[string]*lthash.LtHash{ storageDBDir: {"gone": lthash.New()}, }, - perDBModuleWorkingStats: map[string]map[string]lthash.ModuleStats{ + PerModuleStats: map[string]map[string]lthash.ModuleStats{ storageDBDir: {"gone": {}}, }, } - root, err := cs.verifyDBModuleMetadata(storageDBDir, nil, nil) + root, err := cs.verifyDBModuleMetadata(storageDBDir, maintained, nil, nil) require.NoError(t, err) require.True(t, root.IsZero()) } diff --git a/sei-db/state_db/sc/flatkv/wal_testutil_test.go b/sei-db/state_db/sc/flatkv/wal_testutil_test.go index 99abc0f370..0b2edae761 100644 --- a/sei-db/state_db/sc/flatkv/wal_testutil_test.go +++ b/sei-db/state_db/sc/flatkv/wal_testutil_test.go @@ -19,7 +19,7 @@ func newCommitStoreWithWAL(ctx context.Context, cfg *config.Config) (*CommitStor if err != nil { return nil, err } - return NewCommitStore(ctx, cfg, stateWAL) + return NewCommitStore(ctx, cfg, stateWAL, nil) } // resetWALForTest closes the store's WAL, removes its directory and reopens an empty one in place, leaving the diff --git a/sei-db/state_db/sc/migration/migration_test_framework_test.go b/sei-db/state_db/sc/migration/migration_test_framework_test.go index 38bf2c103e..dff4e189cb 100644 --- a/sei-db/state_db/sc/migration/migration_test_framework_test.go +++ b/sei-db/state_db/sc/migration/migration_test_framework_test.go @@ -626,7 +626,7 @@ func NewTestFlatKVCommitStore(t *testing.T, dir string) *flatkv.CommitStore { if err != nil { t.Fatalf("NewTestFlatKVCommitStore: OpenStateWAL: %v", err) } - s, err := flatkv.NewCommitStore(t.Context(), cfg, stateWAL) + s, err := flatkv.NewCommitStore(t.Context(), cfg, stateWAL, nil) if err != nil { t.Fatalf("NewTestFlatKVCommitStore: NewCommitStore: %v", err) } diff --git a/sei-db/tools/cmd/seidb/operations/dump_flatkv.go b/sei-db/tools/cmd/seidb/operations/dump_flatkv.go index 23521a6064..c13dd69849 100644 --- a/sei-db/tools/cmd/seidb/operations/dump_flatkv.go +++ b/sei-db/tools/cmd/seidb/operations/dump_flatkv.go @@ -337,21 +337,21 @@ const lthashBatchCap = 8192 // committed LtHash. type bucketLtHasher struct { acc *lthash.LtHash - batch []lthash.KVPairWithLastValue + batch []lthash.KeyMutation count uint64 } func newBucketLtHasher() *bucketLtHasher { return &bucketLtHasher{ acc: lthash.New(), - batch: make([]lthash.KVPairWithLastValue, 0, lthashBatchCap), + batch: make([]lthash.KeyMutation, 0, lthashBatchCap), } } // add buffers one (key, value) pair. The iterator may reuse the underlying // slices on Next(), so both are cloned before being retained in the batch. func (h *bucketLtHasher) add(key, val []byte) { - h.batch = append(h.batch, lthash.KVPairWithLastValue{ + h.batch = append(h.batch, lthash.KeyMutation{ Key: bytes.Clone(key), Value: bytes.Clone(val), }) @@ -365,7 +365,7 @@ func (h *bucketLtHasher) flush() { if len(h.batch) == 0 { return } - delta, _ := lthash.ComputeLtHash(nil, h.batch) + delta := lthash.ComputeLtHash(nil, h.batch) h.acc.MixIn(delta) h.batch = h.batch[:0] } @@ -389,7 +389,10 @@ func printFlatKVLtHash(hashers map[string]*bucketLtHasher, version int64) { // root. A PASS means the physical bytes on disk hash to exactly the root the store reports at this // version. Returns an error on mismatch so the CLI exits non-zero. func verifyFlatKVLtHash(store giga.LiveStateStore, hashers map[string]*bucketLtHasher) error { - committedTotal, _ := store.RootHash() + // A dump reads a store at rest, so the published hash already describes everything it holds. + published := store.PublishedHash() + committedChecksum := published.Global.Checksum() + committedTotal := committedChecksum[:] // A store holding no state reports the checksum of the zero LtHash. Treat that as "nothing to // verify against" rather than a spurious failure. diff --git a/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go b/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go index 6ef734aa40..44c15b85f8 100644 --- a/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go +++ b/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go @@ -136,12 +136,12 @@ func TestDumpFlatKVFromStoreSingleBucket(t *testing.T) { func TestBucketLtHasherMatchesSingleShot(t *testing.T) { // More than one batch so the incremental MixIn path is exercised. n := lthashBatchCap*2 + 17 - all := make([]lthash.KVPairWithLastValue, 0, n) + all := make([]lthash.KeyMutation, 0, n) hashers := map[string]*bucketLtHasher{ flatkvBucketAccount: newBucketLtHasher(), flatkvBucketStorage: newBucketLtHasher(), } - bucketPairs := map[string][]lthash.KVPairWithLastValue{} + bucketPairs := map[string][]lthash.KeyMutation{} for i := 0; i < n; i++ { bucket := flatkvBucketAccount @@ -151,20 +151,20 @@ func TestBucketLtHasherMatchesSingleShot(t *testing.T) { key := []byte{byte(bucket[0]), byte(i), byte(i >> 8), byte(i >> 16)} val := []byte{byte(i), 0xAB, byte(i >> 8)} hashers[bucket].add(key, val) - bucketPairs[bucket] = append(bucketPairs[bucket], lthash.KVPairWithLastValue{Key: key, Value: val}) - all = append(all, lthash.KVPairWithLastValue{Key: key, Value: val}) + bucketPairs[bucket] = append(bucketPairs[bucket], lthash.KeyMutation{Key: key, Value: val}) + all = append(all, lthash.KeyMutation{Key: key, Value: val}) } total := lthash.New() for bucket, h := range hashers { h.flush() - single, _ := lthash.ComputeLtHash(nil, bucketPairs[bucket]) + single := lthash.ComputeLtHash(nil, bucketPairs[bucket]) require.Equal(t, single.Checksum(), h.acc.Checksum(), "batched bucket hash for %s must equal single-shot ComputeLtHash", bucket) total.MixIn(h.acc) } - unionSingle, _ := lthash.ComputeLtHash(nil, all) + unionSingle := lthash.ComputeLtHash(nil, all) require.Equal(t, unionSingle.Checksum(), total.Checksum(), "MixIn of per-bucket hashes must equal the LtHash over the union of all pairs") } diff --git a/sei-db/tools/cmd/seidb/operations/flatkv_open.go b/sei-db/tools/cmd/seidb/operations/flatkv_open.go index c6719c2c3b..6e76d40ee8 100644 --- a/sei-db/tools/cmd/seidb/operations/flatkv_open.go +++ b/sei-db/tools/cmd/seidb/operations/flatkv_open.go @@ -98,7 +98,7 @@ func openFlatKVReadOnly(dbDir string, height int64) (*openedFlatKV, error) { _ = os.RemoveAll(tempDir) return nil, fmt.Errorf("failed to open FlatKV state WAL: %w", err) } - primary, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL) + primary, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL, nil) if err != nil { _ = stateWAL.Close() _ = os.RemoveAll(tempDir) diff --git a/sei-db/tools/cmd/seidb/operations/flatkv_open_test.go b/sei-db/tools/cmd/seidb/operations/flatkv_open_test.go index c811377d84..603f7024d2 100644 --- a/sei-db/tools/cmd/seidb/operations/flatkv_open_test.go +++ b/sei-db/tools/cmd/seidb/operations/flatkv_open_test.go @@ -303,7 +303,7 @@ func newDiskBackedFlatKVStore(t *testing.T, snapshotInterval uint32) (*flatkv.Co cfg.SnapshotKeepRecent = 100 stateWAL, err := flatkv.OpenStateWAL(cfg) require.NoError(t, err) - store, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL) + store, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL, nil) require.NoError(t, err) err = store.LoadLatest() require.NoError(t, err) diff --git a/sei-db/tools/cmd/seidb/operations/flatkv_state_size_test.go b/sei-db/tools/cmd/seidb/operations/flatkv_state_size_test.go index f31b489b99..3f92e5421f 100644 --- a/sei-db/tools/cmd/seidb/operations/flatkv_state_size_test.go +++ b/sei-db/tools/cmd/seidb/operations/flatkv_state_size_test.go @@ -223,7 +223,7 @@ func newTestFlatKVStore(t *testing.T) *flatkv.CommitStore { cfg := flatkvconfig.DefaultTestConfig(t) stateWAL, err := flatkv.OpenStateWAL(cfg) require.NoError(t, err) - s, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL) + s, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL, nil) require.NoError(t, err) err = s.LoadLatest() require.NoError(t, err) diff --git a/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl.go b/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl.go index 1273580f10..6a8674a2bc 100644 --- a/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl.go +++ b/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl.go @@ -202,7 +202,7 @@ func importMemiavlModulesToFlatKV(ctx context.Context, homeDir string, modules [ if err != nil { return fmt.Errorf("failed to open FlatKV state WAL: %w", err) } - store, err := flatkv.NewCommitStore(ctx, cfg, stateWAL) + store, err := flatkv.NewCommitStore(ctx, cfg, stateWAL, nil) if err != nil { _ = stateWAL.Close() return fmt.Errorf("failed to create FlatKV store: %w", err) diff --git a/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl_test.go b/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl_test.go index 1c605babdc..ac61b8c5b6 100644 --- a/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl_test.go +++ b/sei-db/tools/cmd/seidb/operations/import_flatkv_from_memiavl_test.go @@ -357,7 +357,7 @@ func newTestFlatKVStoreAtHome(t *testing.T, homeDir string) *flatkv.CommitStore cfg.DataDir = utils.GetFlatKVPath(homeDir) stateWAL, err := flatkv.OpenStateWAL(cfg) require.NoError(t, err) - store, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL) + store, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL, nil) require.NoError(t, err) err = store.LoadLatest() require.NoError(t, err) From e93649545c99badea31cb97b6bd100bf367d7c06 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 2 Sep 2026 15:11:57 -0500 Subject: [PATCH 4/4] minor fixes --- sei-cosmos/storev2/rootmulti/hashlog.go | 10 +-- sei-db/state_db/giga/live_state_store.go | 7 +- sei-db/state_db/sc/composite/flatkv_hash.go | 61 ++++++++++++++-- sei-db/state_db/sc/composite/store_test.go | 6 +- sei-db/state_db/sc/flatkv/hashlog.go | 27 +++++++- sei-db/state_db/sc/flatkv/hashlog_test.go | 56 +++++++++++++-- sei-db/state_db/sc/flatkv/store.go | 34 +++++---- sei-db/state_db/sc/flatkv/store_write_test.go | 32 ++++++++- sei-db/state_db/sc/hashlog/hash_logger.go | 22 +++--- .../state_db/sc/hashlog/hash_logger_impl.go | 69 ++++++++++++------- .../sc/hashlog/hash_logger_impl_test.go | 38 +++++++++- 11 files changed, 292 insertions(+), 70 deletions(-) diff --git a/sei-cosmos/storev2/rootmulti/hashlog.go b/sei-cosmos/storev2/rootmulti/hashlog.go index be5bad3d9a..93692b15e5 100644 --- a/sei-cosmos/storev2/rootmulti/hashlog.go +++ b/sei-cosmos/storev2/rootmulti/hashlog.go @@ -84,10 +84,12 @@ func (rs *Store) desiredHashCategories() map[string]struct{} { return categories } -// openHashLogger constructs the logger once. It starts with no caller columns (just the changeset -// column); syncHashCategories then registers the live categories, which the logger handles as runtime -// column changes (each new column rotates to a fresh file, but the empty initial files are dropped and -// their indexes reused, so the first file with data starts at index 0). +// openHashLogger constructs the logger once, with no caller columns beyond the changeset column. +// +// The columns arrive afterwards, from two directions: a backend registers the ones it reports when it is +// handed this logger, and syncHashCategories registers whatever else the live backend set calls for and +// removes what it drops. The logger treats each change as a file rotation, but an empty file is dropped +// and its index reused, so the first file with data still starts at index 0. func openHashLogger(scDir string, hashLoggerConfig config.HashLoggerConfig) (hashlog.HashLogger, error) { loggerVersion := hashLoggerConfig.Version if loggerVersion == "" { diff --git a/sei-db/state_db/giga/live_state_store.go b/sei-db/state_db/giga/live_state_store.go index 29e24d5437..b62dacbb6c 100644 --- a/sei-db/state_db/giga/live_state_store.go +++ b/sei-db/state_db/giga/live_state_store.go @@ -132,8 +132,11 @@ type LiveStateStore interface { // block order, with no gaps or duplicates, closed once the store stops hashing. // // The channel has finite depth, so failure to dequeue hashes for long enough blocks commit. Every - // deployment therefore needs a consumer. - HashChan() <-chan *lthash.BlockHash + // store that returns one therefore needs a consumer. + // + // A store that will never carry a stream reports why instead of handing back one that stays empty: + // one that is not open, and one that hashes only in order to replay and so consumes its own. + HashChan() (<-chan *lthash.BlockHash, error) // FlushHashes blocks until the store has published a hash for every block committed so far, and // recorded each one's metadata alongside the block it describes. diff --git a/sei-db/state_db/sc/composite/flatkv_hash.go b/sei-db/state_db/sc/composite/flatkv_hash.go index 89ca9d8e67..6d09fa28e4 100644 --- a/sei-db/state_db/sc/composite/flatkv_hash.go +++ b/sei-db/state_db/sc/composite/flatkv_hash.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) // flatKVHashCache answers Cosmos's synchronous hash questions from flatKV's asynchronous hash stream. @@ -49,6 +50,21 @@ func (c *flatKVHashCache) hashAtVersion(store giga.LiveStateStore, version int64 // awaitHeight reports the hash for height, reading the stream until it arrives. func (c *flatKVHashCache) awaitHeight(store giga.LiveStateStore, height int64) ([]byte, error) { + // Taken once and used by both the drain below and the wait at the end. A store with no stream can + // still answer from its published hash, so the refusal is carried rather than returned, and reported + // only where waiting on a stream is the last resort left. + stream, streamErr := store.HashChan() + + // Whatever the stream already holds is taken before any answer below is considered. Every published + // hash has to leave the stream exactly once — it has finite depth and blocks commit once full — and + // the published hash consulted below can satisfy a height whose stream entry is still queued, which + // would strand that entry for good. + if streamErr == nil { + if err := c.takeQueued(stream); err != nil { + return nil, err + } + } + if hash, ok := c.hashes[height]; ok { c.forget(height) return hash, nil @@ -67,11 +83,12 @@ func (c *flatKVHashCache) awaitHeight(store giga.LiveStateStore, height int64) ( height, max(published.BlockNumber, c.highest)) } - for hash := range store.HashChan() { - checksum := hash.Global.Checksum() - c.hashes[hash.BlockNumber] = checksum[:] - if hash.BlockNumber > c.highest { - c.highest = hash.BlockNumber + if streamErr != nil { + return nil, fmt.Errorf("no flatkv hash stream to wait on for block %d: %w", height, streamErr) + } + for hash := range stream { + if err := c.accept(hash); err != nil { + return nil, err } if hash.BlockNumber >= height { break @@ -86,6 +103,40 @@ func (c *flatKVHashCache) awaitHeight(store giga.LiveStateStore, height int64) ( return result, nil } +// takeQueued moves every hash the stream is already holding into the cache, without waiting for one that +// has not been published yet. A closed stream reads as holding nothing. +func (c *flatKVHashCache) takeQueued(stream <-chan *lthash.BlockHash) error { + for { + select { + case hash, open := <-stream: + if !open { + return nil + } + if err := c.accept(hash); err != nil { + return err + } + default: + return nil + } + } +} + +// accept records one block's hash off the stream, reporting instead the failure a failed block carries. +func (c *flatKVHashCache) accept(hash *lthash.BlockHash) error { + if hash.Error != nil { + // Reported rather than left to the stream closing behind it: nothing is published after a failed + // block, so reading on would block until the stream closed and then report only that the hash never + // arrived, losing the reason. A failed block carries no hashes to read. + return fmt.Errorf("flatkv failed to hash block %d: %w", hash.BlockNumber, hash.Error) + } + checksum := hash.Global.Checksum() + c.hashes[hash.BlockNumber] = checksum[:] + if hash.BlockNumber > c.highest { + c.highest = hash.BlockNumber + } + return nil +} + // forget drops every height at or below the one just answered. The stream is one-directional, so // nothing below can be asked for again. func (c *flatKVHashCache) forget(height int64) { diff --git a/sei-db/state_db/sc/composite/store_test.go b/sei-db/state_db/sc/composite/store_test.go index 6dad650c49..a4364719fd 100644 --- a/sei-db/state_db/sc/composite/store_test.go +++ b/sei-db/state_db/sc/composite/store_test.go @@ -52,8 +52,10 @@ func (f *failingEVMStore) RawGlobalIterator() (dbm.Iterator, error) { return nil func (f *failingEVMStore) Iterator(string, []byte, []byte, bool) (dbm.Iterator, error) { return nil, nil } -func (f *failingEVMStore) PublishedHash() *lthash.BlockHash { return lthash.NewBlockHash(nil) } -func (f *failingEVMStore) HashChan() <-chan *lthash.BlockHash { return nil } +func (f *failingEVMStore) PublishedHash() *lthash.BlockHash { return lthash.NewBlockHash(nil) } +func (f *failingEVMStore) HashChan() (<-chan *lthash.BlockHash, error) { + return nil, fmt.Errorf("flatkv unavailable") +} func (f *failingEVMStore) FlushHashes() error { return nil } func (f *failingEVMStore) CommitPendingBlock() error { return nil } func (f *failingEVMStore) Version() int64 { return 0 } diff --git a/sei-db/state_db/sc/flatkv/hashlog.go b/sei-db/state_db/sc/flatkv/hashlog.go index 8a0a156131..6092607527 100644 --- a/sei-db/state_db/sc/flatkv/hashlog.go +++ b/sei-db/state_db/sc/flatkv/hashlog.go @@ -1,6 +1,11 @@ package flatkv -import "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" +import ( + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" +) // Hash logger category names owned by the flatKV backend. flatKVDBHashPrefix is joined with a data DB // directory name (e.g. "flatKV/db/account"). @@ -16,8 +21,7 @@ func (s *CommitStore) HashCategories() []string { return hashCategories() } -// hashCategories returns the same set without needing a store, for a caller that must open the logger -// before the store that reports to it. +// hashCategories returns the same set without needing a store. func hashCategories() []string { categories := make([]string, 0, len(dataDBDirs)+1) categories = append(categories, FlatKVRootHashType) @@ -27,6 +31,23 @@ func hashCategories() []string { return categories } +// registerHashCategories puts this backend's columns on hl, so that the hashes reported to it later are +// accepted rather than rejected as unknown. +// +// It runs when a store takes the logger, which is the only point early enough. Hashes are reported from +// the finalization goroutine, and the first of those can land during the WAL replay that opening the +// store performs — before any commit-path code has had the chance to register a column. A rejected +// report latches and silences reporting for the life of the store, so this cannot be left to a caller +// that may report first. +func registerHashCategories(hl hashlog.HashLogger) error { + for _, category := range hashCategories() { + if err := hl.RegisterHashType(category); err != nil { + return fmt.Errorf("register hash category %q: %w", category, err) + } + } + return nil +} + // Reports one block's hashes: the global root and each data database's per-DB checksum, under the // height the hash describes rather than the height being committed. // diff --git a/sei-db/state_db/sc/flatkv/hashlog_test.go b/sei-db/state_db/sc/flatkv/hashlog_test.go index 0ae896bdca..89ac2cb64d 100644 --- a/sei-db/state_db/sc/flatkv/hashlog_test.go +++ b/sei-db/state_db/sc/flatkv/hashlog_test.go @@ -9,6 +9,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/ktype" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" ) // captureLogger is a HashLogger test double that records registered categories and reported hashes. @@ -42,16 +43,17 @@ func (c *captureLogger) ReportChangeset(uint64, []*proto.NamedChangeSet) { c.cha func (c *captureLogger) Close() error { return nil } func TestFlatKVHashReporting(t *testing.T) { - // The logger precedes the store, which reports to it as each block is finalized. + // The categories are not registered here: the store registers what it reports when it takes the + // logger, so a test that registered them itself would be staging an arrangement production never + // produces. logger := newCaptureLogger() - for _, category := range hashCategories() { - require.NoError(t, logger.RegisterHashType(category)) - } - require.Len(t, logger.registered, 5) s := setupTestStoreWithHashLogger(t, config.DefaultTestConfig(t), logger) defer func() { require.NoError(t, s.Close()) }() + // Constructing the store is what puts the columns on the logger, before any block is finalized. + require.Len(t, logger.registered, 5) + // Write some EVM storage so the account/storage DBs have non-empty LtHashes. key := evmStorageKey(ktype.Address{0x11}, ktype.Slot{0x22}) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{makeChangeSet(key, padLeft32(0x33), false)})) @@ -94,3 +96,47 @@ func TestFlatKVHashReporting(t *testing.T) { } require.True(t, sum.Equal(s.maintainedHashes().Global)) } + +// TestFlatKVHashesReachARealArchive drives a real hash logger, opened the way the node opens it, and +// requires flatKV's hashes to be readable back off disk afterwards. +// +// The logger is configured with no caller columns, which is what rootmulti's openHashLogger does. Every +// other test in this package supplies a double, and a double cannot tell whether the column a hash is +// reported under exists — so this is the only place the registration path is exercised end to end. +func TestFlatKVHashesReachARealArchive(t *testing.T) { + const blocks = 2 + + archiveDir := t.TempDir() + hl, err := hashlog.NewHashLogger(hashlog.DefaultHashLoggerConfig(archiveDir, "flatkv-archive-test")) + require.NoError(t, err) + + s := setupTestStoreWithHashLogger(t, config.DefaultTestConfig(t), hl) + defer func() { require.NoError(t, s.Close()) }() + + for height := int64(1); height <= blocks; height++ { + key := evmStorageKey(ktype.Address{0x11}, ktype.Slot{byte(height)}) + changeSets := []*proto.NamedChangeSet{makeChangeSet(key, padLeft32(byte(height)), false)} + require.NoError(t, s.ApplyChangeSets(height, changeSets), "apply block %d", height) + _, err := s.Commit(height) + require.NoError(t, err, "commit block %d", height) + + // The changeset column is the logger's own and only the caller can supply it. Without it no + // block is ever complete and none reaches disk. baseapp plays this part in production. + hl.ReportChangeset(uint64(height), changeSets) + } + + // Hashes are reported off the commit path, and the archive is only sealed by Close. + require.NoError(t, s.FlushHashes()) + require.NoError(t, hl.Close()) + + for height := uint64(1); height <= blocks; height++ { + reports, err := hashlog.ReadHashForBlock(archiveDir, height) + require.NoError(t, err) + require.Len(t, reports, 1, "block %d should appear exactly once in the archive", height) + + for _, category := range hashCategories() { + require.NotEmpty(t, reports[0].Hashes[category], + "block %d recorded no %s hash", height, category) + } + } +} diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index ee3bdaace3..365a099bfd 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -236,6 +236,10 @@ func NewCommitStore( return nil, fmt.Errorf("failed to validate config: %w", err) } + if err := registerHashCategories(hl); err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(ctx) coreCount := runtime.NumCPU() @@ -1100,9 +1104,9 @@ func (s *CommitStore) startHashing() error { ) if s.readOnly { - // Nothing consumes a read-only store's stream — HashChan reports it as closed — so it is drained - // here. Left unread, replaying past the channel's depth would block on a hash no one wants. The - // goroutine ends when the finalizer closes the stream. + // A read-only store's stream is drained here, and HashChan refuses to hand it out, because the two + // have to agree on who reads it. Left unread, replaying past the channel's depth would block on a + // hash no one wants. The goroutine ends when the finalizer closes the stream. published := s.finalizer.HashChan() go func() { for range published { //nolint:revive // discarding is the point @@ -1228,20 +1232,24 @@ func (s *CommitStore) PublishedHash() *lthash.BlockHash { // HashChan returns a channel producing the hash of each block. Exactly one hash per block committed, in // block order, with no gaps or duplicates. It is closed once the store stops hashing. // -// The channel has finite depth, so failure to dequeue hashes for long enough blocks commit. Every -// deployment therefore needs a consumer. -func (s *CommitStore) HashChan() <-chan *lthash.BlockHash { +// The channel has finite depth, so failure to dequeue hashes for long enough blocks commit. A store that +// hands one out therefore needs a consumer. +// +// The two stores that have no stream to hand out say so rather than returning one that stays empty: a +// caller cannot tell an empty stream from a store that hashed its blocks and stopped. +func (s *CommitStore) HashChan() (<-chan *lthash.BlockHash, error) { s.mu.RLock() defer s.mu.RUnlock() - if s.finalizer != nil { - return s.finalizer.HashChan() + if s.readOnly { + // Such a store does hash blocks — it replays them to reach its target height — but it consumes + // that stream itself, so there is none to give away. Its height is available from PublishedHash. + return nil, fmt.Errorf("flatkv: a read-only store consumes its own hash stream") + } + if s.finalizer == nil { + return nil, fmt.Errorf("flatkv: the store is not open, so it is not hashing") } - // A read-only store never commits and so never publishes. A closed channel lets a consumer range - // over it and finish, rather than blocking forever on a stream that will never carry anything. - empty := make(chan *lthash.BlockHash) - close(empty) - return empty + return s.finalizer.HashChan(), nil } // FlushHashes blocks until the store has published a hash for every block committed so far, and diff --git a/sei-db/state_db/sc/flatkv/store_write_test.go b/sei-db/state_db/sc/flatkv/store_write_test.go index 368386c83d..2e142be808 100644 --- a/sei-db/state_db/sc/flatkv/store_write_test.go +++ b/sei-db/state_db/sc/flatkv/store_write_test.go @@ -1829,7 +1829,8 @@ func TestHashFailureSurfacesOnTheStream(t *testing.T) { })) commitAndCheck(t, s) - hashes := s.HashChan() + hashes, err := s.HashChan() + require.NoError(t, err) require.NoError(t, (<-hashes).Error, "the good block hashes normally") s.moduleOf = func([]byte) (string, error) { @@ -1856,6 +1857,35 @@ func TestHashFailureSurfacesOnTheStream(t *testing.T) { "a caller waiting for hashes must be told they failed, not that they are done") } +// A read-only store does hash blocks — it replays them to reach its target height — but it reads that +// stream itself, so it has none to hand out. Handing back a live channel that stays empty would leave a +// consumer waiting forever, and an empty one is indistinguishable from a store that finished. +func TestReadOnlyStoreRefusesItsHashChan(t *testing.T) { + s := setupTestStore(t) + defer func() { _ = s.Close() }() + + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ + makeChangeSet(evmStorageKey(ktype.Address{0x11}, ktype.Slot{0x22}), padLeft32(0x33), false), + })) + commitAndCheck(t, s) + + stream, err := s.HashChan() + require.NoError(t, err, "a committing store hands out its stream") + require.NotNil(t, stream) + + ro, err := s.LoadVersionReadOnly(0) + require.NoError(t, err) + defer func() { _ = ro.Close() }() + + roStream, err := ro.HashChan() + require.Error(t, err, "a read-only store must refuse rather than return a stream that stays empty") + require.ErrorContains(t, err, "read-only") + require.Nil(t, roStream) + + // The height is still readable, which is what a caller wanting a read-only store's hash actually needs. + require.Equal(t, ro.Version(), ro.PublishedHash().BlockNumber) +} + func TestApplyChangeSetsEVMKeyEmptySkipped(t *testing.T) { s := setupTestStore(t) defer s.Close() diff --git a/sei-db/state_db/sc/hashlog/hash_logger.go b/sei-db/state_db/sc/hashlog/hash_logger.go index 66e85d425c..8c969a21f2 100644 --- a/sei-db/state_db/sc/hashlog/hash_logger.go +++ b/sei-db/state_db/sc/hashlog/hash_logger.go @@ -12,6 +12,10 @@ import "github.com/sei-protocol/sei-chain/sei-db/proto" // computes itself from the raw change sets (see ReportChangeset). A block is considered complete, and is written to // disk, once a hash has been reported for every configured type. // +// Every method is safe to call from any goroutine, concurrently. The logger holds no state a caller touches +// directly: each entry point either reads an atomic or hands its argument to a background goroutine over a +// channel, and the column set belongs to the logger's own control loop. +// // Slice ownership: every slice handed to this logger — the hash passed to ReportHash, and the change set (and // all of its nested keys and values) passed to ReportChangeset — is retained and read asynchronously on background // goroutines after the call returns. The caller is free to keep reading these slices, but MUST NOT mutate them @@ -41,9 +45,11 @@ type HashLogger interface { // the current file, seals it, and opens a fresh file whose header includes the new column. Registering a // type that is already present is a no-op (no rotation). The reserved changeset type is rejected, as are // names containing characters outside the legal allow-list. Returns nil once the change has been applied - // (the call blocks until then), so a subsequent ReportHash for the new column is accepted. + // (the call blocks until then). // - // Callers must not invoke the Register/Unregister/Report methods concurrently from multiple goroutines. + // Registering is how a caller declares a column before reporting to it, so that the column is on the + // first file's header and no early block is written without it. It is not a precondition of ReportHash, + // which creates a column it does not recognise. RegisterHashType(hashType string) error // Unregister a previously registered caller-reported hash type, removing its column. Like @@ -51,12 +57,12 @@ type HashLogger interface { // not present is a no-op; the reserved changeset column cannot be removed. UnregisterHashType(hashType string) error - // Report a hash for a block under the given type. The type must be one of the types this logger was - // configured to record (via HashLoggerConfig.HashTypes or RegisterHashType), otherwise an error is - // returned. The changeset hash type is reserved for the - // logger-computed changeset column (use ReportChangeset) and is also rejected when changeset hashing is enabled. A - // subsystem that is disabled should report a nil hash for its type rather than skipping the call, so that - // the block can still be completed. + // Report a hash for a block under the given type. A type the logger does not already record becomes a + // recorded column, logged once as the wiring mistake it is; a name outside the legal allow-list is + // logged and dropped instead, since column names are written into the CSV unquoted. The changeset hash + // type is reserved for the logger-computed changeset column (use ReportChangeset) and is rejected when + // changeset hashing is enabled. A subsystem that is disabled should report a nil hash for its type + // rather than skipping the call, so that the block can still be completed. ReportHash(blockNumber uint64, hashType string, hash []byte) error // Shut down the HashLogger and release any resources. Flushes pending writes before returning. Only blocks diff --git a/sei-db/state_db/sc/hashlog/hash_logger_impl.go b/sei-db/state_db/sc/hashlog/hash_logger_impl.go index e46e91b5a6..040958b93d 100644 --- a/sei-db/state_db/sc/hashlog/hash_logger_impl.go +++ b/sei-db/state_db/sc/hashlog/hash_logger_impl.go @@ -93,17 +93,6 @@ type hashLoggerImpl struct { // The software version embedded in each file name (sanitized to be filename-safe at construction). version string - // The ordered set of hash columns recorded per block; the changeset column is prepended when changeset hashing is - // enabled. Mutated only by the control loop (handling a ctrlColumnChange), so the loop reads len(hashTypes) for - // block completion without synchronization. Register/UnregisterHashType change it through that message. - hashTypes []string - - // The membership set over hashTypes, for O(1) validation of caller-supplied hash types in ReportHash. Written - // only by the control loop (handling ctrlColumnChange) and read by the caller in Register/Unregister/ReportHash. - // These callers are serialized (Register/Unregister block on the loop's ack via the done channel, establishing - // happens-before), so the read is race-free as long as callers do not invoke the API concurrently. - hashTypeSet map[string]struct{} - // When true, changeset hashing is disabled: no hasher thread, ReportChangeset is a no-op, and no changeset column is // recorded or awaited. changesetHashingDisabled bool @@ -184,6 +173,13 @@ type hashLoggerImpl struct { // The following fields are the control loop's private bookkeeping, owned exclusively by the control loop // goroutine, so they need no synchronization. + // The ordered set of hash columns recorded per block; the changeset column is prepended when changeset hashing + // is enabled. The loop reads len(hashTypes) to decide whether a block is complete. + hashTypes []string + + // The membership set over hashTypes, for deciding whether a reported column already exists. + hashTypeSet map[string]struct{} + // Blocks being assembled, keyed by block number. pendingBlocks map[uint64]*HashLog @@ -362,9 +358,6 @@ func (h *hashLoggerImpl) RegisterHashType(hashType string) error { return fmt.Errorf("hash type %q contains illegal characters (must match %s)", hashType, legalHashTypeRegex.String()) } - if _, ok := h.hashTypeSet[hashType]; ok { - return nil // already registered; idempotent no-op (no rotation) - } return h.sendColumnChange(hashType, true) } @@ -375,17 +368,14 @@ func (h *hashLoggerImpl) UnregisterHashType(hashType string) error { if !h.changesetHashingDisabled && hashType == ChangesetHashType { return fmt.Errorf("hash type %q is the logger-computed changeset column and cannot be removed", hashType) } - if _, ok := h.hashTypeSet[hashType]; !ok { - return nil // not registered; idempotent no-op (no rotation) - } return h.sendColumnChange(hashType, false) } // sendColumnChange forwards a column add/remove to the control loop and waits for it to be applied (the -// loop flushes/seals/rotates and updates hashTypes/hashTypeSet before acking). The synchronous handshake -// guarantees that a subsequent ReportHash for the new column is accepted, and establishes happens-before -// for the caller's later reads of hashTypeSet. If the logger is shutting down before the change is -// applied, it returns the relevant context error so the caller knows the registration did not land. +// loop flushes/seals/rotates and updates hashTypes/hashTypeSet before acking). Waiting is what lets a +// caller declare a column before reporting anything, so the first file's header already carries it and no +// early block is written without it. If the logger is shutting down before the change is applied, it +// returns the relevant context error so the caller knows the registration did not land. func (h *hashLoggerImpl) sendColumnChange(hashType string, add bool) error { if h.closed.Load() { return fmt.Errorf("hash logger is closed") @@ -437,9 +427,9 @@ func (h *hashLoggerImpl) ReportHash(blockNumber uint64, hashType string, hash [] if !h.changesetHashingDisabled && hashType == ChangesetHashType { return fmt.Errorf("hash type %q is reserved for the logger-computed changeset; use ReportChangeset", hashType) } - if _, ok := h.hashTypeSet[hashType]; !ok { - return fmt.Errorf("unknown hash type %q", hashType) - } + // An unregistered type is not rejected here: whether it is registered is the control loop's to know, and + // asking would mean reading the loop's state from this goroutine. The loop creates the column instead. + // // Blocking send to the control loop, which normally drains controlChan quickly; it can backpressure only // if the downstream writer is itself stalled on a slow disk. h.sendControl(controlMessage{kind: ctrlHashReport, blockNumber: blockNumber, hashType: hashType, hash: hash}) @@ -595,12 +585,43 @@ func (h *hashLoggerImpl) handleColumnChange(hashType string, add bool) { // handleHashReport records a caller-reported hash, discarding it if the block has already been flushed. func (h *hashLoggerImpl) handleHashReport(blockNumber uint64, hashType string, hash []byte) { + // Adopting the column first, and re-checking the high water after, is what keeps this block from being + // written twice: adopting flushes every block that is complete under the old column set, which can + // include this one, and ensurePending would then rebuild the entry that flush just emitted. + if !h.adoptReportedColumn(hashType) { + return + } if h.hasFlushedAtLeastOnce && blockNumber <= h.flushedHighWater { return // already on disk: a duplicate/late report, or a re-execution without reopening the logger } h.ensurePending(blockNumber).Hashes[hashType] = hash } +// adoptReportedColumn makes hashType a recorded column if it is not one already, reporting whether a hash +// may be recorded under it. +// +// A caller reporting a column that was never registered is a wiring mistake, and this is a logging +// utility: losing the hash would trade an observability problem for a blind spot. Adding the column keeps +// the hash and self-heals a registration that never happened, and it bounds the complaint to one line per +// column rather than one per block. +// +// An illegal name is the exception, and is dropped. Column names are joined into the CSV header and rows +// with no quoting, so a name carrying a separator would shift every column in the archive. +func (h *hashLoggerImpl) adoptReportedColumn(hashType string) bool { + if _, ok := h.hashTypeSet[hashType]; ok { + return true + } + if !legalHashTypeRegex.MatchString(hashType) { + logger.Error("discarding a hash reported under an illegal column name", + "hashType", hashType, "mustMatch", legalHashTypeRegex.String()) + return false + } + logger.Warn("recording a hash reported under a column that was never registered; adding it", + "hashType", hashType) + h.handleColumnChange(hashType, true) + return true +} + // handleChangesetRequest records that a block is awaiting a changeset hash and holds the work for dispatch to // the hasher. func (h *hashLoggerImpl) handleChangesetRequest(blockNumber uint64, cs []*proto.NamedChangeSet) { diff --git a/sei-db/state_db/sc/hashlog/hash_logger_impl_test.go b/sei-db/state_db/sc/hashlog/hash_logger_impl_test.go index 8c169cc97d..23b4ac7f93 100644 --- a/sei-db/state_db/sc/hashlog/hash_logger_impl_test.go +++ b/sei-db/state_db/sc/hashlog/hash_logger_impl_test.go @@ -67,13 +67,45 @@ func TestImplEmitsInBlockOrderDespiteLaggingType(t *testing.T) { } } -func TestImplReportHashUnknownType(t *testing.T) { +// A hash reported under a column nobody registered is kept, not lost: the column is created and the block +// completes on the wider set. Losing the hash would turn a caller's wiring mistake into a blind spot in the +// very record used to diagnose it. +func TestImplReportHashAdoptsUnregisteredType(t *testing.T) { dir := t.TempDir() l, err := NewHashLogger(testConfig(dir)) require.NoError(t, err) - defer func() { require.NoError(t, l.Close()) }() - require.ErrorContains(t, l.ReportHash(1, "nonexistent", []byte{0x01}), "unknown hash type") + require.NoError(t, l.ReportHash(1, "unregistered", []byte{0x01})) + require.NoError(t, l.ReportHash(1, "a", []byte{0x02})) + require.NoError(t, l.ReportHash(1, "b", []byte{0x03})) + require.NoError(t, l.Close()) + + logs := readAllLogs(t, dir) + require.Len(t, logs, 1) + require.Equal(t, uint64(1), logs[0].BlockNumber) + require.Equal(t, []byte{0x01}, logs[0].Hashes["unregistered"]) + require.Equal(t, []byte{0x02}, logs[0].Hashes["a"]) + require.Equal(t, []byte{0x03}, logs[0].Hashes["b"]) +} + +// An illegal column name is the one report that is dropped: names are joined into the CSV header and rows +// unquoted, so one carrying a separator would shift every column in the archive. +func TestImplReportHashDropsIllegalTypeName(t *testing.T) { + dir := t.TempDir() + l, err := NewHashLogger(testConfig(dir)) + require.NoError(t, err) + + require.NoError(t, l.ReportHash(1, "has,comma", []byte{0x01})) + require.NoError(t, l.ReportHash(1, "a", []byte{0x02})) + require.NoError(t, l.ReportHash(1, "b", []byte{0x03})) + require.NoError(t, l.Close()) + + // The block still completes on its declared columns alone, and the bogus name is nowhere. + logs := readAllLogs(t, dir) + require.Len(t, logs, 1) + require.Equal(t, []byte{0x02}, logs[0].Hashes["a"]) + require.Equal(t, []byte{0x03}, logs[0].Hashes["b"]) + require.NotContains(t, logs[0].Hashes, "has,comma") } func TestImplReportHashRejectsReservedChangesetType(t *testing.T) {