From 5f66f0310bb9fcbc62bdb712b5976bd0a83a5ad1 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 1 Sep 2026 09:30:34 -0500 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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) { From b023c674212d2ec3a5bac7990a7617b1f2fc4318 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Fri, 4 Sep 2026 12:41:14 -0500 Subject: [PATCH 05/19] fix problem with draining hash from flatKV --- sei-db/state_db/sc/composite/store.go | 63 +++++++++++-------- .../sc/composite/store_migration_test.go | 46 ++++++++++++++ .../sc/flatkv/finalization_manager.go | 41 +++++++++++- .../sc/flatkv/finalization_manager_test.go | 50 +++++++++++++++ sei-db/state_db/sc/flatkv/store_replay.go | 35 +++++++++++ .../state_db/sc/flatkv/store_replay_test.go | 43 +++++++++++++ 6 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 sei-db/state_db/sc/flatkv/finalization_manager_test.go diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index fe39e1e5c1..a03d330b12 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -330,8 +330,7 @@ func (cs *CompositeCommitStore) SetInitialVersion(initialVersion int64) error { // does. No commit info observed here actually changes — memiavl reports its pre-commit version either // way and a seeded flatkv still hashes to the identity — so this is the rule holding uniformly rather // than a case with a test behind it. - cs.refreshLastCommitInfo() - return nil + return cs.refreshLastCommitInfo() } // LoadVersion implements types.Committer. @@ -411,8 +410,7 @@ func (cs *CompositeCommitStore) LoadLatest() error { } // After the router, because the gating this reads gets its answer from migration metadata through // the backends the router was just built against. - cs.refreshLastCommitInfo() - return nil + return cs.refreshLastCommitInfo() } // LoadVersionReadOnly returns an isolated read-only composite view at targetVersion (0 = latest). This store @@ -481,7 +479,9 @@ func (cs *CompositeCommitStore) LoadVersionReadOnly(targetVersion int64) (_ type if err := ro.buildRouter(); err != nil { return nil, fmt.Errorf("failed to build router for read-only handle: %w", err) } - ro.refreshLastCommitInfo() + if err := ro.refreshLastCommitInfo(); err != nil { + return nil, fmt.Errorf("failed to build commit info for read-only handle: %w", err) + } return ro, nil } @@ -820,6 +820,14 @@ func (cs *CompositeCommitStore) Commit(version int64) (int64, error) { if err != nil { return 0, fmt.Errorf("failed to commit flatkv: %w", err) } + // Taken whether or not this block's hash reaches the AppHash. shouldAppendLatticeHash answers a + // consensus question; taking the hash is a lifecycle obligation of a backend that publishes one. + // flatKV's stream has finite depth and blocks commit once full, so a committing flatKV whose + // hashes nobody reads halts the node. This is the one place every flatKV block commit passes + // through, which is why the obligation is discharged here rather than at the readers below. + if _, err := cs.latticeHash(flatkvVersion); err != nil { + return 0, fmt.Errorf("failed to obtain flatkv hash for block %d: %w", flatkvVersion, err) + } } // Reset the per-block migration-advance gate so the next block's @@ -839,7 +847,9 @@ func (cs *CompositeCommitStore) Commit(version int64) (int64, error) { // Every active backend has committed this block and they agree on its height, which is the only // moment their combined commit info describes one block. - cs.refreshLastCommitInfo() + if err := cs.refreshLastCommitInfo(); err != nil { + return 0, fmt.Errorf("failed to refresh commit info after committing block %d: %w", version, err) + } committed := cosmosVersion if committed < 0 { @@ -1132,14 +1142,21 @@ func (cs *CompositeCommitStore) WorkingCommitInfo(version int64) *proto.CommitIn } if cs.shouldAppendLatticeHash() { - return cs.appendEvmLatticeHash(ci, cs.mustLatticeHash(version)) + hash, err := cs.latticeHash(version) + if err != nil { + // types.Committer pins this signature, so this is the one lattice-hash caller with nowhere + // to return to. A store that cannot produce a hash cannot produce a trustworthy one either, + // and letting the chain proceed on a stale hash is the worse failure. + panic(fmt.Sprintf("composite: failed to obtain flatkv hash for block %d: %v", version, err)) + } + return cs.appendEvmLatticeHash(ci, hash) } return ci } // latticeHash returns flatKV's lattice hash for the height the chain is building, sealing that block -// first if it is still being applied. +// first if it is still being applied. It returns nil when no flatKV backend is configured. // // 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 @@ -1155,24 +1172,15 @@ func (cs *CompositeCommitStore) WorkingCommitInfo(version int64) *proto.CommitIn // 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) latticeHash(version int64) ([]byte, error) { + if cs.flatKV == nil { + return nil, nil + } if cs.flatKVHashes == nil { cs.flatKVHashes = newFlatKVHashCache() } return cs.flatKVHashes.hashAtVersion(cs.flatKV, 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 -} - // LastCommitInfo returns the commit info for the block the backends last committed, or nil before the // store is loaded. The result is a copy and may be retained and modified freely. func (cs *CompositeCommitStore) LastCommitInfo() *proto.CommitInfo { @@ -1184,7 +1192,9 @@ func (cs *CompositeCommitStore) LastCommitInfo() *proto.CommitInfo { // Every point that moves the committed height must call this: Commit, the two load paths, Rollback and // SetInitialVersion. The stored value is the only thing LastCommitInfo reports, so a mutation that // skips the call serves a stale block until the next one that does not. -func (cs *CompositeCommitStore) refreshLastCommitInfo() { +// +// It reports the failure to obtain flatKV's hash, which leaves the stored commit info untouched. +func (cs *CompositeCommitStore) refreshLastCommitInfo() error { var ci *proto.CommitInfo if cs.shouldIncludeMemiavlInfos() { ci = cs.memIAVL.LastCommitInfo() @@ -1195,11 +1205,16 @@ func (cs *CompositeCommitStore) refreshLastCommitInfo() { } if cs.shouldAppendLatticeHash() { - ci = cs.appendEvmLatticeHash(ci, cs.mustLatticeHash(ci.Version)) + hash, err := cs.latticeHash(ci.Version) + if err != nil { + return fmt.Errorf("obtain flatkv hash for block %d: %w", ci.Version, err) + } + ci = cs.appendEvmLatticeHash(ci, hash) } // 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. cs.lastCommitInfo = cloneCommitInfo(ci) + return nil } // cloneCommitInfo deep-copies ci, hashes included, so the result survives a commit or a reopen of the @@ -1363,9 +1378,7 @@ func (cs *CompositeCommitStore) Rollback(targetVersion int64) error { // After the latch resets above, so the rebuilt info reflects the rolled-back metadata rather than // the gating that was latched at the pre-rollback height. - cs.refreshLastCommitInfo() - - return nil + return cs.refreshLastCommitInfo() } // exportNeedsMetadataGating reports whether the configured mode allows 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 22d10e407a..3a8c8ab8b1 100644 --- a/sei-db/state_db/sc/composite/store_migration_test.go +++ b/sei-db/state_db/sc/composite/store_migration_test.go @@ -2,6 +2,7 @@ package composite import ( "encoding/hex" + "fmt" "sort" "testing" @@ -1218,3 +1219,48 @@ func TestComposite_MigrateBank_RollbackAcrossCompletionBoundary(t *testing.T) { requireCommitInfoEqual(t, canonicalTarget, cs.LastCommitInfo(), "post-restart commit info across the bank-completion boundary must re-include memiavl") } + +// TestMigrateEVMBeforeTheBoundaryDrainsTheHashStream pins the drain a committing flatkv needs while it +// is still outside the AppHash. +// +// With the migration paused no block advances the boundary, so no commit info carries evm_lattice — but +// flatkv commits, and publishes a hash, every block regardless. Left unread the stream fills, publish +// blocks, and block commit stops for good. The stream is shrunk here so that halt lands within the +// block count rather than a thousand blocks later. +func TestMigrateEVMBeforeTheBoundaryDrainsTheHashStream(t *testing.T) { + dir := t.TempDir() + + cfg := config.DefaultStateCommitConfig() + cfg.WriteMode = types.MigrateEVM + cfg.FlatKVConfig.HashChanSize = 2 + cfg.FlatKVConfig.FinalizationQueueSize = 1 + + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + require.NoError(t, err) + // 0 leaves the migration paused: nothing pulls keys forward, so the boundary metadata that opens the + // lattice gate is never written. + require.NoError(t, cs.SetMigrationBatchSize(0)) + require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) + require.NoError(t, cs.LoadLatest()) + defer cs.Close() + + require.NotNil(t, cs.flatKV, "MigrateEVM must allocate a flatkv store") + + const blocks = 16 + for i := 0; i < blocks; i++ { + require.NoError(t, cs.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ + {Key: []byte(fmt.Sprintf("evm_%d", i)), Value: []byte{byte(i)}}, + }}}, + })) + next := cs.Version() + 1 + require.False(t, containsLatticeStoreInfo(cs.WorkingCommitInfo(next).StoreInfos), + "a paused migration must keep evm_lattice out of the AppHash at block %d", next) + _, err := cs.Commit(next) + require.NoError(t, err) + } + + require.Equal(t, int64(blocks), cs.Version()) + require.False(t, containsLatticeStoreInfo(cs.LastCommitInfo().StoreInfos), + "a paused migration must keep evm_lattice out of the AppHash") +} diff --git a/sei-db/state_db/sc/flatkv/finalization_manager.go b/sei-db/state_db/sc/flatkv/finalization_manager.go index 3b71f72a71..bbe18a3ef4 100644 --- a/sei-db/state_db/sc/flatkv/finalization_manager.go +++ b/sei-db/state_db/sc/flatkv/finalization_manager.go @@ -6,12 +6,16 @@ import ( "fmt" "sync" "sync/atomic" + "time" "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" ) +// stallReportInterval is how often a publish that the hash stream has no room for reports itself. +const stallReportInterval = 30 * time.Second + // 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. // @@ -334,7 +338,42 @@ func (fm *FinalizationManager) drainHashes() { func (fm *FinalizationManager) publish(hash *lthash.BlockHash) { select { case fm.publishedHashChan <- hash: - case <-fm.ctx.Done(): + return + default: + } + fm.publishStalled(hash) +} + +// publishStalled publishes a hash the stream had no room for, reporting it every stallReportInterval +// until it lands or the manager stops. +// +// The reporting exists to make misuse of the stream's threading requirement obvious rather than let it +// deadlock silently. A store that hands out HashChan() needs a consumer: the stream has finite depth, +// and a full one blocks this goroutine, then the queue behind it, and finally Offer, which stops block +// commit. Nothing about that halt names its cause — a stalled node presents a stack sitting in Offer, +// several frames from the channel nobody is reading — and this is the one place that can tell. It +// repeats where reportingFailed logs once because a stalled publish is a halt rather than a +// degradation, and whoever investigates arrives long after the first line scrolled away. +func (fm *FinalizationManager) publishStalled(hash *lthash.BlockHash) { + stalledSince := time.Now() + ticker := time.NewTicker(stallReportInterval) + defer ticker.Stop() + + for { + select { + case fm.publishedHashChan <- hash: + logger.Warn("flatkv hash stream took a stalled block; commits are moving again", + "version", hash.BlockNumber, "stalledFor", time.Since(stalledSince)) + return + case <-fm.ctx.Done(): + return + case <-ticker.C: + logger.Error("flatkv hash stream is full and nothing is draining it, so block commit has "+ + "stopped; a store that hands out HashChan() needs a consumer reading it", + "version", hash.BlockNumber, + "stalledFor", time.Since(stalledSince), + "streamDepth", cap(fm.publishedHashChan)) + } } } diff --git a/sei-db/state_db/sc/flatkv/finalization_manager_test.go b/sei-db/state_db/sc/flatkv/finalization_manager_test.go new file mode 100644 index 0000000000..a42ae3dcd4 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/finalization_manager_test.go @@ -0,0 +1,50 @@ +package flatkv + +import ( + "testing" + "time" + + "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" + "github.com/stretchr/testify/require" +) + +// TestPublishWaitsForRoomOnAFullHashStream pins what publish does when the stream has no room: it holds +// the hash until a consumer makes space rather than dropping it or returning early. +// +// The wait is what stalls block commit when nobody is reading, which publishStalled reports. This covers +// the waiting, not the reporting. +func TestPublishWaitsForRoomOnAFullHashStream(t *testing.T) { + engineHashChan := make(chan *lthash.BlockHash) + fm := newFinalizationManager( + t.Context(), engineHashChan, <hash.BlockHash{BlockNumber: 0}, 1, 1, hashlog.NewNoOpHashLogger()) + + // Registered before the close below so it runs after it: Close drains the engine's stream, which + // only terminates once that stream is closed. + defer func() { require.NoError(t, fm.Close()) }() + defer close(engineHashChan) + + // Depth 1, so this fills the stream and the next publish has nowhere to go. + fm.publish(<hash.BlockHash{BlockNumber: 1}) + + published := make(chan struct{}) + go func() { + defer close(published) + fm.publish(<hash.BlockHash{BlockNumber: 2}) + }() + + select { + case <-published: + t.Fatal("publish returned while the stream was still full") + case <-time.After(100 * time.Millisecond): + } + + require.Equal(t, int64(1), (<-fm.HashChan()).BlockNumber) + + select { + case <-published: + case <-time.After(30 * time.Second): + t.Fatal("publish never completed after the stream drained") + } + require.Equal(t, int64(2), (<-fm.HashChan()).BlockNumber) +} diff --git a/sei-db/state_db/sc/flatkv/store_replay.go b/sei-db/state_db/sc/flatkv/store_replay.go index c46163bbd5..fbf9d90d59 100644 --- a/sei-db/state_db/sc/flatkv/store_replay.go +++ b/sei-db/state_db/sc/flatkv/store_replay.go @@ -212,6 +212,9 @@ func replayBlocks( if err := dest.applyAndCommit(int64(block), changesets, alreadyHave); err != nil { return 0, fmt.Errorf("replay block %d: %w", block, err) } + if err := dest.discardReplayedHashes(); err != nil { + return 0, fmt.Errorf("drain hashes while replaying block %d: %w", block, err) + } replayed++ // Liveness, not context: only the loop can report that a multi-hour replay is still moving. if replayed%1000 == 0 { @@ -221,6 +224,38 @@ func replayBlocks( return replayed, nil } +// discardReplayedHashes takes whatever the hash stream is holding and drops it, reporting instead the +// failure a failed block carries. +// +// Replay seals a block per WAL record and every sealed block publishes a hash, but during replay +// nothing is reading them: the store is still inside open(), so the consumer that drains the stream in +// service does not exist yet. Left unread, a replay longer than the stream is deep blocks in Offer and +// never returns. The hashes are dropped rather than kept because nothing asked for these blocks, and +// PublishedHash still reports the height replay lands on. +// +// A read-only store is exempt: it drains its own stream from startHashing, and a second reader here +// would race that one. +func (s *CommitStore) discardReplayedHashes() error { + if s.readOnly || s.finalizer == nil { + return nil + } + + stream := s.finalizer.HashChan() + for { + select { + case hash, open := <-stream: + if !open { + return nil + } + if hash.Error != nil { + return fmt.Errorf("hash block %d: %w", hash.BlockNumber, hash.Error) + } + default: + return nil + } + } +} + // applyAndCommit replays a single block into the store: it applies the changesets, seals the block on // every store, advances the committed version and clones the working LtHash to committed. It never // touches the WAL — the data being applied was itself read from a WAL, so re-writing it would 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 9b63ba8ec1..a7f46dd328 100644 --- a/sei-db/state_db/sc/flatkv/store_replay_test.go +++ b/sei-db/state_db/sc/flatkv/store_replay_test.go @@ -460,3 +460,46 @@ func TestReplayConvergesOnPartialAccountFieldWrites(t *testing.T) { require.Equal(t, wantAccount, gotAccount) require.NoError(t, VerifyLtHash(s3)) } + +// TestReplayDrainsHashStreamPastItsDepth pins the drain that keeps a writable WAL replay from wedging. +// +// Replay seals a block per WAL record and every sealed block publishes a hash, but the store is still +// inside open(), so nothing outside it is reading the stream yet. Left unread, a replay longer than the +// stream is deep blocks in Offer and never returns. +func TestReplayDrainsHashStreamPastItsDepth(t *testing.T) { + dir := t.TempDir() + + // The WAL is built at the default stream depth: the setup commits have no consumer either, and they + // are not what this test is about. + cfg := config.DefaultTestConfig(t) + cfg.DataDir = filepath.Join(dir, flatkvRootDir) + + s, err := newCommitStoreWithWAL(t.Context(), cfg) + require.NoError(t, err) + require.NoError(t, s.LoadLatest()) + + const blocks = 24 + for i := byte(1); i <= blocks; i++ { + commitStorageEntry(t, s, ktype.Address{i}, ktype.Slot{i}, []byte{i}) + } + require.Equal(t, int64(blocks), s.Version()) + expected := append([]byte(nil), rootHash(s)...) + + // Lower the watermark far enough that replay has many more blocks to re-apply than the stream below + // can hold. + rewindVersionRecords(t, s, 4) + require.NoError(t, s.Close()) + + replayCfg := config.DefaultTestConfig(t) + replayCfg.DataDir = cfg.DataDir + replayCfg.HashChanSize = 4 + replayCfg.FinalizationQueueSize = 2 + + reopened, err := newCommitStoreWithWAL(t.Context(), replayCfg) + require.NoError(t, err) + defer reopened.Close() + + require.NoError(t, reopened.LoadLatest()) + require.Equal(t, int64(blocks), reopened.Version()) + require.Equal(t, expected, rootHash(reopened)) +} From 6894b7d8f460be9889e06e4a0327ff5275336a26 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 8 Sep 2026 14:02:29 -0500 Subject: [PATCH 06/19] simplify hash API --- giga/evmonly/giga_store_test.go | 5 + giga/evmonly/memory_store.go | 7 + .../storev2/rootmulti/flatkv_helpers_test.go | 6 +- .../rootmulti/flatkv_migration_test.go | 2 +- sei-db/bootstrap/storage_manager.go | 2 +- .../state_db/bench/cryptosim/block_hashes.go | 114 ++++++++ .../bench/cryptosim/block_hashes_test.go | 107 ++++++++ sei-db/state_db/bench/cryptosim/cryptosim.go | 9 +- .../bench/cryptosim/cryptosim_config.go | 10 + .../bench/cryptosim/cryptosim_metrics.go | 18 ++ sei-db/state_db/bench/cryptosim/database.go | 29 +- .../bench/cryptosim/transaction_test.go | 11 +- .../bench/wrappers/combined_wrapper.go | 7 + .../bench/wrappers/composite_wrapper.go | 7 + .../bench/wrappers/db_implementations.go | 2 +- sei-db/state_db/bench/wrappers/db_wrapper.go | 6 + .../state_db/bench/wrappers/flatkv_wrapper.go | 11 + .../wrappers/historical_offload_wrapper.go | 7 + .../bench/wrappers/memiavl_wrapper.go | 7 + .../state_db/bench/wrappers/noop_wrapper.go | 7 + .../bench/wrappers/state_store_wrapper.go | 7 + .../state_db/bench/wrappers/wrappers_test.go | 5 + sei-db/state_db/giga/live_state_store.go | 24 +- sei-db/state_db/giga/state_db.go | 19 +- .../giga/state_db_hash_listener_test.go | 75 ++++++ sei-db/state_db/giga/state_db_impl.go | 11 + sei-db/state_db/giga/state_db_impl_test.go | 2 +- sei-db/state_db/sc/composite/flatkv_hash.go | 148 ----------- sei-db/state_db/sc/composite/hashlog.go | 17 +- .../composite/random_test_framework_test.go | 2 +- sei-db/state_db/sc/composite/store.go | 106 ++++++-- .../sc/composite/store_init_repair_test.go | 2 +- .../sc/composite/store_migration_test.go | 1 - sei-db/state_db/sc/composite/store_test.go | 11 +- sei-db/state_db/sc/flatkv/config/config.go | 7 - .../sc/flatkv/config/flatkv_test_config.go | 1 - .../sc/flatkv/finalization_manager.go | 136 ++-------- .../sc/flatkv/finalization_manager_test.go | 50 ---- sei-db/state_db/sc/flatkv/hash_listeners.go | 69 +++++ .../state_db/sc/flatkv/hash_listeners_test.go | 249 ++++++++++++++++++ sei-db/state_db/sc/flatkv/hashlog.go | 82 ------ sei-db/state_db/sc/flatkv/hashlog_test.go | 142 ---------- .../state_db/sc/flatkv/import_export_test.go | 2 +- .../state_db/sc/flatkv/lthash_golden_test.go | 8 +- sei-db/state_db/sc/flatkv/store.go | 71 ++--- sei-db/state_db/sc/flatkv/store_constants.go | 20 +- sei-db/state_db/sc/flatkv/store_replay.go | 35 --- .../state_db/sc/flatkv/store_replay_test.go | 1 - sei-db/state_db/sc/flatkv/store_test.go | 8 +- sei-db/state_db/sc/flatkv/store_write_test.go | 67 +++-- .../sc/flatkv/{verify.go => test_verify.go} | 0 sei-db/state_db/sc/flatkv/testutil_test.go | 9 +- .../state_db/sc/flatkv/wal_testutil_test.go | 2 +- .../sc/hashlog/flatkv_listener_test.go | 104 ++++++++ sei-db/state_db/sc/hashlog/hash_logger.go | 14 +- .../state_db/sc/hashlog/hash_logger_impl.go | 28 ++ .../state_db/sc/hashlog/noop_hash_logger.go | 12 +- sei-db/state_db/sc/memiavl/hashlog_test.go | 6 + .../migration_test_framework_test.go | 2 +- .../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 +- 64 files changed, 1190 insertions(+), 749 deletions(-) create mode 100644 sei-db/state_db/bench/cryptosim/block_hashes.go create mode 100644 sei-db/state_db/bench/cryptosim/block_hashes_test.go create mode 100644 sei-db/state_db/giga/state_db_hash_listener_test.go delete mode 100644 sei-db/state_db/sc/composite/flatkv_hash.go delete mode 100644 sei-db/state_db/sc/flatkv/finalization_manager_test.go create mode 100644 sei-db/state_db/sc/flatkv/hash_listeners.go create mode 100644 sei-db/state_db/sc/flatkv/hash_listeners_test.go delete mode 100644 sei-db/state_db/sc/flatkv/hashlog.go delete mode 100644 sei-db/state_db/sc/flatkv/hashlog_test.go rename sei-db/state_db/sc/flatkv/{verify.go => test_verify.go} (100%) create mode 100644 sei-db/state_db/sc/hashlog/flatkv_listener_test.go diff --git a/giga/evmonly/giga_store_test.go b/giga/evmonly/giga_store_test.go index e0440c4b41..ab97806854 100644 --- a/giga/evmonly/giga_store_test.go +++ b/giga/evmonly/giga_store_test.go @@ -12,6 +12,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/proto" gigastore "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) type recordingGigaStore struct { @@ -33,6 +34,10 @@ func (s *recordingGigaStore) OpenView() gigastore.StateView { return s.snapshot } +func (s *recordingGigaStore) RegisterHashListener(gigastore.HashListener) (lthash.BlockHash, error) { + return lthash.BlockHash{}, nil +} + func (s *recordingGigaStore) OpenViewAt(int64) (gigastore.StateView, bool) { return nil, false } diff --git a/giga/evmonly/memory_store.go b/giga/evmonly/memory_store.go index 15ea2e14c5..bc7858e192 100644 --- a/giga/evmonly/memory_store.go +++ b/giga/evmonly/memory_store.go @@ -13,6 +13,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/proto" gigastore "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) // MemoryStoreChangeSetName identifies MemoryStore's direct key/value format. @@ -365,6 +366,12 @@ func (s *MemoryStore) OpenViewAt(blockNum int64) (gigastore.StateView, bool) { return &memoryStoreSnapshot{store: s, height: blockNum}, true } +// RegisterHashListener reports that this store hashes nothing. It keeps state in maps rather than +// in a lattice, so there is no block hash for a listener to be given. +func (s *MemoryStore) RegisterHashListener(_ gigastore.HashListener) (lthash.BlockHash, error) { + return lthash.BlockHash{}, fmt.Errorf("evmonly: an in-memory store computes no block hashes") +} + type memoryStoreSnapshot struct { store *MemoryStore height int64 diff --git a/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go b/sei-cosmos/storev2/rootmulti/flatkv_helpers_test.go index 5305287db1..29b5407424 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, nil) + evmStore, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL) 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, nil) + store, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL) 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, nil) + s, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL) 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 196d81d886..eea3fb307f 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, nil) + s, err := flatkv.NewCommitStore(context.Background(), &flatkvCfg, stateWAL) require.NoError(t, err) err = s.LoadLatest() require.NoError(t, err) diff --git a/sei-db/bootstrap/storage_manager.go b/sei-db/bootstrap/storage_manager.go index 5083594f64..7f67071790 100644 --- a/sei-db/bootstrap/storage_manager.go +++ b/sei-db/bootstrap/storage_manager.go @@ -102,7 +102,7 @@ func (m *GigaStorageManager) openDBs(ctx context.Context, cfg config.GigaStorage } // StateDB writes the WAL; a store that held one would record every block twice. - sc, err := flatkv.NewCommitStore(ctx, cfg.FlatKVConfig, nil, nil) + sc, err := flatkv.NewCommitStore(ctx, cfg.FlatKVConfig, nil) if err != nil { return fmt.Errorf("open state commit store: %w", err) } diff --git a/sei-db/state_db/bench/cryptosim/block_hashes.go b/sei-db/state_db/bench/cryptosim/block_hashes.go new file mode 100644 index 0000000000..b10fac4961 --- /dev/null +++ b/sei-db/state_db/bench/cryptosim/block_hashes.go @@ -0,0 +1,114 @@ +package cryptosim + +import ( + "context" + "fmt" + "time" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" +) + +// hashWaitTimeout bounds how long the benchmark waits for one block's hash. Nothing bounds how long +// hashing may legitimately take, so this is reached only by a database that has stopped hashing +// altogether — which it does by failing, and a failed database will never produce the hash being +// waited for. +const hashWaitTimeout = 5 * time.Minute + +// blockHashWaiter holds the hashes a database has published but the benchmark has not taken yet, so +// that the benchmark runs a bounded number of blocks ahead of hashing and then waits. +// +// A benchmark that never took a hash would measure a database that commits blocks without finishing +// them. Taking exactly one hash per block, a fixed number of blocks late, is what turns hashing that +// cannot keep up into time the main thread spends waiting. +// +// The listener half is called from the database's own goroutine; every other method belongs to the +// main thread. +type blockHashWaiter struct { + // The hashes published but not yet taken. One deeper than the window, because the hash of the + // block just committed can already be here when the one from lagBlocks back is taken, and a + // publisher blocking then would be blocking while still inside its allowance. + hashes chan *lthash.BlockHash + + // How many blocks the benchmark commits before it starts taking hashes, and so how late a block's + // hash may be. + lagBlocks int + + // Blocks committed since this waiter was built. Only those publish hashes: the database may have + // been opened at a height an earlier run reached. + committed int + + // The block the next hash taken must describe, or 0 until the first one has been taken. + nextExpected int64 + + // How long to wait for one hash before reporting a database that has stopped hashing. + waitTimeout time.Duration + + metrics *CryptosimMetrics +} + +// newBlockHashWaiter returns a waiter that lets the benchmark run lagBlocks ahead of hashing. +func newBlockHashWaiter(lagBlocks int, metrics *CryptosimMetrics) *blockHashWaiter { + return &blockHashWaiter{ + hashes: make(chan *lthash.BlockHash, lagBlocks+1), + lagBlocks: lagBlocks, + waitTimeout: hashWaitTimeout, + metrics: metrics, + } +} + +// listen takes one block's hash from the database, blocking while the benchmark is further ahead +// than its window allows. +// +// Blocking here is the backpressure: it stops a database finalizing blocks faster than the benchmark +// accepts their hashes. The context is the release, cancelled when the database shuts down, since a +// send with no taker left would otherwise never return. +func (w *blockHashWaiter) listen(ctx context.Context, _ int64, hash *lthash.BlockHash) error { + select { + case w.hashes <- hash: + return nil + case <-ctx.Done(): + return fmt.Errorf("the database is shutting down: %w", ctx.Err()) + } +} + +// awaitBlock accounts for one committed block and, once the benchmark is a full window ahead, takes +// one block's hash, waiting for it to arrive. +func (w *blockHashWaiter) awaitBlock() error { + w.committed++ + if w.committed <= w.lagBlocks { + return nil + } + + hash, err := w.takeHash() + if err != nil { + return err + } + + // The database publishes one hash per block in block order, so the block this describes is + // predictable, and a hash for any other block means blocks have been lost or repeated. + if w.nextExpected != 0 && hash.BlockNumber != w.nextExpected { + return fmt.Errorf("expected the hash of block %d, got block %d", w.nextExpected, hash.BlockNumber) + } + w.nextExpected = hash.BlockNumber + 1 + return nil +} + +// takeHash waits for the next block's hash, reporting a database that has stopped producing them. +func (w *blockHashWaiter) takeHash() (*lthash.BlockHash, error) { + w.metrics.SetMainThreadPhase("awaiting_hash") + startedWaiting := time.Now() + defer func() { + w.metrics.RecordBlockHashWaitDuration(time.Since(startedWaiting)) + }() + + timeout := time.NewTimer(w.waitTimeout) + defer timeout.Stop() + + select { + case hash := <-w.hashes: + return hash, nil + case <-timeout.C: + return nil, fmt.Errorf("no block hash arrived in %s: the database has stopped hashing, "+ + "%d blocks behind the block just committed", w.waitTimeout, w.lagBlocks) + } +} diff --git a/sei-db/state_db/bench/cryptosim/block_hashes_test.go b/sei-db/state_db/bench/cryptosim/block_hashes_test.go new file mode 100644 index 0000000000..0a0e7c5f85 --- /dev/null +++ b/sei-db/state_db/bench/cryptosim/block_hashes_test.go @@ -0,0 +1,107 @@ +package cryptosim + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" +) + +// publish hands the waiter the hash of one block, as the database's dispatch would. +func publish(t *testing.T, w *blockHashWaiter, blockNumber int64) { + t.Helper() + require.NoError(t, w.listen(t.Context(), blockNumber, <hash.BlockHash{BlockNumber: blockNumber})) +} + +// Hashing lags execution, so a benchmark that waited for the first block's hash before committing the +// second would serialize the two and measure something no node does. +func TestTheFirstBlocksRunAheadWithoutTakingAHash(t *testing.T) { + waiter := newBlockHashWaiter(3, nil) + + // No hash has been published, so any of these taking one would block here rather than return. + for block := 0; block < 3; block++ { + require.NoError(t, waiter.awaitBlock()) + } +} + +// One hash per block after the window is what holds the lag at the configured distance: taking fewer +// would let the benchmark drift arbitrarily far ahead of hashing. +func TestOneHashIsTakenPerBlockAfterTheWindow(t *testing.T) { + waiter := newBlockHashWaiter(3, nil) + for block := int64(1); block <= 4; block++ { + publish(t, waiter, block) + } + + // Blocks 1 to 3 fill the window, and the fourth is the first to take a hash — block 1's. + for block := 0; block < 4; block++ { + require.NoError(t, waiter.awaitBlock()) + } + require.Len(t, waiter.hashes, 3, "exactly one hash may be taken per block committed") + require.Equal(t, int64(2), waiter.nextExpected) +} + +// The wait is the point: a database that cannot hash as fast as the benchmark commits has to slow the +// benchmark down, rather than the benchmark reporting a rate the database cannot really sustain. +func TestABlockWaitsForAHashThatHasNotArrived(t *testing.T) { + waiter := newBlockHashWaiter(1, nil) + require.NoError(t, waiter.awaitBlock()) + + awaited := make(chan error, 1) + go func() { awaited <- waiter.awaitBlock() }() + + select { + case <-awaited: + t.Fatal("the block returned before its hash arrived") + case <-time.After(100 * time.Millisecond): + } + + publish(t, waiter, 1) + + select { + case err := <-awaited: + require.NoError(t, err) + case <-time.After(30 * time.Second): + t.Fatal("the block never returned after its hash arrived") + } +} + +// The hashes are promised one per block in order. A gap means a block was lost, which the benchmark +// has to report rather than average away. +func TestAGapInTheHashesFailsTheRun(t *testing.T) { + waiter := newBlockHashWaiter(1, nil) + publish(t, waiter, 1) + + require.NoError(t, waiter.awaitBlock()) + require.NoError(t, waiter.awaitBlock(), "the first hash taken sets the sequence") + + publish(t, waiter, 3) + require.ErrorContains(t, waiter.awaitBlock(), "expected the hash of block 2, got block 3") +} + +// A database that has stopped hashing will never produce the hash being waited for. The wait has to +// end in a report rather than a hang, or a failed benchmark looks like a slow one. +func TestAHashThatNeverArrivesIsReported(t *testing.T) { + waiter := newBlockHashWaiter(1, nil) + waiter.waitTimeout = 50 * time.Millisecond + + require.NoError(t, waiter.awaitBlock()) + require.ErrorContains(t, waiter.awaitBlock(), "the database has stopped hashing") +} + +// The listener blocks while the benchmark is too far ahead, and that block runs on the database's own +// goroutine. Shutting the database down has to release it, or its Close never returns. +func TestACancelledContextReleasesABlockedListener(t *testing.T) { + waiter := newBlockHashWaiter(1, nil) + + // Capacity is the window plus one, so these fill it and the next send has nowhere to go. + publish(t, waiter, 1) + publish(t, waiter, 2) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorContains(t, + waiter.listen(ctx, 3, <hash.BlockHash{BlockNumber: 3}), "shutting down") +} diff --git a/sei-db/state_db/bench/cryptosim/cryptosim.go b/sei-db/state_db/bench/cryptosim/cryptosim.go index 4b22b354b4..9a276ea1a5 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim.go @@ -160,7 +160,14 @@ func NewCryptoSim( start := time.Now() - database := NewDatabase(config, db, metrics, 0) + database, err := NewDatabase(config, db, metrics, 0) + if err != nil { + cancel() + if closeErr := db.Close(); closeErr != nil { + fmt.Printf("failed to close database during error recovery: %v\n", closeErr) + } + return nil, fmt.Errorf("failed to create database: %w", err) + } dataGenerator, err := NewDataGenerator(config, database, rand, metrics) if err != nil { diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config.go b/sei-db/state_db/bench/cryptosim/cryptosim_config.go index e52306753a..286701df9a 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config.go @@ -83,6 +83,12 @@ type CryptoSimConfig struct { // The number of transactions that will be processed in each "block". TransactionsPerBlock int + // How many blocks the benchmark may run ahead of block hashing. Databases hash committed blocks + // asynchronously, and the benchmark takes one block's hash per block committed once it is this far + // ahead — so a block's hash must arrive no later than this many blocks after it was committed, and + // the benchmark waits when it does not. A database that publishes no block hashes waits on nothing. + HashLagBlocks int + // The directory to store the benchmark data. DataDir string @@ -253,6 +259,7 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { AccountBalanceSize: 32, Erc20InteractionsPerAccount: 10, TransactionsPerBlock: 1024, + HashLagBlocks: 32, Seed: 1337, CannedRandomSize: 1024 * 1024 * 1024, // 1GB Backend: wrappers.FlatKV, @@ -343,6 +350,9 @@ func (c *CryptoSimConfig) Validate() error { return fmt.Errorf("Erc20InteractionsPerAccount must be at least %d (got %d)", minErc20InteractionsPerAcct, c.Erc20InteractionsPerAccount) } + if c.HashLagBlocks < 1 { + return fmt.Errorf("HashLagBlocks must be at least 1 (got %d)", c.HashLagBlocks) + } if c.TransactionsPerBlock < 1 { return fmt.Errorf("TransactionsPerBlock must be at least 1 (got %d)", c.TransactionsPerBlock) } diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go index cbba13ebb7..89b06fd184 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_metrics.go @@ -46,6 +46,7 @@ type CryptosimMetrics struct { ctx context.Context blocksFinalizedTotal metric.Int64Counter + blockHashWaitDuration metric.Float64Histogram transactionsProcessedTotal metric.Int64Counter totalAccounts metric.Int64Gauge hotAccounts metric.Int64Gauge @@ -184,6 +185,13 @@ func NewCryptosimMetrics( metric.WithUnit("s"), ) + blockHashWaitDuration, _ := meter.Float64Histogram( + "cryptosim_block_hash_wait_duration_seconds", + metric.WithDescription("Time the main thread spent waiting for a committed block's hash"), + metric.WithExplicitBucketBoundaries(receiptWriteLatencyBuckets...), + metric.WithUnit("s"), + ) + receiptBlockWriteDuration, _ := meter.Float64Histogram( "cryptosim_receipt_block_write_duration_seconds", metric.WithDescription("Time to write a block of receipts to the parquet store"), @@ -282,6 +290,7 @@ func NewCryptosimMetrics( m := &CryptosimMetrics{ ctx: ctx, blocksFinalizedTotal: blocksFinalizedTotal, + blockHashWaitDuration: blockHashWaitDuration, transactionsProcessedTotal: transactionsProcessedTotal, totalAccounts: totalAccounts, hotAccounts: hotAccounts, @@ -567,6 +576,15 @@ func (m *CryptosimMetrics) SetMainThreadPhase(phase string) { m.mainThreadPhase.SetPhase(phase) } +// RecordBlockHashWaitDuration records how long the main thread waited for a block's hash. A run that +// never waits records zeroes; time accumulating here is hashing failing to keep up with execution. +func (m *CryptosimMetrics) RecordBlockHashWaitDuration(latency time.Duration) { + if m == nil || m.blockHashWaitDuration == nil { + return + } + m.blockHashWaitDuration.Record(context.Background(), latency.Seconds()) +} + func (m *CryptosimMetrics) RecordReceiptBlockWriteDuration(latency time.Duration) { if m == nil || m.receiptBlockWriteDuration == nil { return diff --git a/sei-db/state_db/bench/cryptosim/database.go b/sei-db/state_db/bench/cryptosim/database.go index db71f07eb6..fcb15248af 100644 --- a/sei-db/state_db/bench/cryptosim/database.go +++ b/sei-db/state_db/bench/cryptosim/database.go @@ -34,6 +34,10 @@ type Database struct { // The metrics for the benchmark. metrics *CryptosimMetrics + + // Takes one block hash per block committed, so that the benchmark cannot outrun hashing. Nil when + // the database publishes no block hashes. + hashes *blockHashWaiter } // Creates a new database for the cryptosim benchmark. @@ -42,14 +46,26 @@ func NewDatabase( db wrappers.DBWrapper, metrics *CryptosimMetrics, initialNextBlockNumber uint64, -) *Database { - return &Database{ +) (*Database, error) { + database := &Database{ config: config, db: db, batch: NewSyncMap[string, []byte](), metrics: metrics, nextBlockNumber: initialNextBlockNumber, } + + // Registered here because this is before the first block is committed, and that is the only place + // a listener can be sure of being handed every block's hash. + waiter := newBlockHashWaiter(config.HashLagBlocks, metrics) + registered, err := db.RegisterHashListener(waiter.listen) + if err != nil { + return nil, fmt.Errorf("failed to register a block hash listener: %w", err) + } + if registered { + database.hashes = waiter + } + return database, nil } // Insert a key-value pair into the database/cache. @@ -194,6 +210,15 @@ func (d *Database) FinalizeBlock( } d.metrics.ReportDBCommit() + // Committing a block is not finishing it: the hash of a block committed a bounded number of + // blocks ago is taken here, and waited for when hashing has fallen behind execution. + if d.hashes != nil { + if err := d.hashes.awaitBlock(); err != nil { + return fmt.Errorf("failed to obtain a block hash after committing block %d: %w", + d.db.Version(), err) + } + } + d.metrics.SetMainThreadPhase("executing") return nil diff --git a/sei-db/state_db/bench/cryptosim/transaction_test.go b/sei-db/state_db/bench/cryptosim/transaction_test.go index 657176d7d3..c4a7ec3dc7 100644 --- a/sei-db/state_db/bench/cryptosim/transaction_test.go +++ b/sei-db/state_db/bench/cryptosim/transaction_test.go @@ -8,6 +8,7 @@ import ( commonmetrics "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/bench/wrappers" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -48,6 +49,10 @@ func (r *readTrackingWrapper) GetPhaseTimer() *commonmetrics.PhaseTimer { return nil } +func (r *readTrackingWrapper) RegisterHashListener(_ giga.HashListener) (bool, error) { + return false, nil +} + func TestTransactionExecuteSkipsReadsWhenDisabled(t *testing.T) { t.Parallel() @@ -55,7 +60,8 @@ func TestTransactionExecuteSkipsReadsWhenDisabled(t *testing.T) { cfg.DisableTransactionReads = true wrapper := &readTrackingWrapper{} - db := NewDatabase(cfg, wrapper, nil, 0) + db, err := NewDatabase(cfg, wrapper, nil, 0) + require.NoError(t, err) txn := &transaction{ erc20Contract: []byte("erc20"), @@ -70,8 +76,7 @@ func TestTransactionExecuteSkipsReadsWhenDisabled(t *testing.T) { newDstAccountSlot: []byte("dst-slot-value"), } - err := txn.Execute(db, []byte("fee"), nil) - require.NoError(t, err) + require.NoError(t, txn.Execute(db, []byte("fee"), nil)) require.Zero(t, wrapper.readCalls) _, found, err := db.Get([]byte("src")) diff --git a/sei-db/state_db/bench/wrappers/combined_wrapper.go b/sei-db/state_db/bench/wrappers/combined_wrapper.go index 2163726747..0663b612c6 100644 --- a/sei-db/state_db/bench/wrappers/combined_wrapper.go +++ b/sei-db/state_db/bench/wrappers/combined_wrapper.go @@ -6,6 +6,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/metrics" dbTypes "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -66,6 +67,12 @@ func (c *combinedWrapper) Importer(version int64) (scTypes.Importer, error) { return c.sc.Importer(version) } +// RegisterHashListener forwards to the SC backend. The SS backend commits the same changesets but +// computes no block hash of its own. +func (c *combinedWrapper) RegisterHashListener(listener giga.HashListener) (bool, error) { + return c.sc.RegisterHashListener(listener) +} + func (c *combinedWrapper) GetPhaseTimer() *metrics.PhaseTimer { return nil } diff --git a/sei-db/state_db/bench/wrappers/composite_wrapper.go b/sei-db/state_db/bench/wrappers/composite_wrapper.go index 7d4f2e295b..bc72b0a205 100644 --- a/sei-db/state_db/bench/wrappers/composite_wrapper.go +++ b/sei-db/state_db/bench/wrappers/composite_wrapper.go @@ -3,6 +3,7 @@ package wrappers 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/giga" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/composite" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -53,6 +54,12 @@ func (c *compositeWrapper) Read(key []byte) (data []byte, found bool, err error) return data, data != nil, nil } +// RegisterHashListener reports that this DB publishes no block hashes. The composite store consumes +// flatKV's hashes itself, in order to answer Cosmos synchronously, so it admits no second consumer. +func (c *compositeWrapper) RegisterHashListener(_ giga.HashListener) (bool, error) { + return false, nil +} + func (c *compositeWrapper) GetPhaseTimer() *metrics.PhaseTimer { return nil } diff --git a/sei-db/state_db/bench/wrappers/db_implementations.go b/sei-db/state_db/bench/wrappers/db_implementations.go index b6d7f06c67..de629e14ad 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, nil) + cs, err := flatkv.NewCommitStore(ctx, config, stateWAL) if err != nil { _ = stateWAL.Close() return nil, fmt.Errorf("failed to create FlatKV commit store: %w", err) diff --git a/sei-db/state_db/bench/wrappers/db_wrapper.go b/sei-db/state_db/bench/wrappers/db_wrapper.go index b1167da2d0..7ef68cac59 100644 --- a/sei-db/state_db/bench/wrappers/db_wrapper.go +++ b/sei-db/state_db/bench/wrappers/db_wrapper.go @@ -3,6 +3,7 @@ package wrappers 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/giga" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -37,4 +38,9 @@ type DBWrapper interface { // // If the underlying DB does not support phase timers, return nil. GetPhaseTimer() *metrics.PhaseTimer + + // RegisterHashListener subscribes listener to the hash of each block this DB commits, one per + // block in block order. It reports false when the DB publishes no block hashes, in which case + // there is nothing for a benchmark to wait on. + RegisterHashListener(listener giga.HashListener) (registered bool, err error) } diff --git a/sei-db/state_db/bench/wrappers/flatkv_wrapper.go b/sei-db/state_db/bench/wrappers/flatkv_wrapper.go index 38d7e1a23e..db7e1cd50b 100644 --- a/sei-db/state_db/bench/wrappers/flatkv_wrapper.go +++ b/sei-db/state_db/bench/wrappers/flatkv_wrapper.go @@ -1,6 +1,8 @@ package wrappers import ( + "fmt" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/common/metrics" "github.com/sei-protocol/sei-chain/sei-db/proto" @@ -70,6 +72,15 @@ func (f *flatKVWrapper) Read(key []byte) (data []byte, found bool, err error) { return val, ok, nil } +// RegisterHashListener subscribes listener to flatKV's block hashes. The hash the store returns is +// dropped: a benchmark waits on the blocks it is about to commit, not the one already behind it. +func (f *flatKVWrapper) RegisterHashListener(listener giga.HashListener) (bool, error) { + if _, err := f.base.RegisterHashListener(listener); err != nil { + return false, fmt.Errorf("register a hash listener on flatkv: %w", err) + } + return true, nil +} + func (f *flatKVWrapper) GetPhaseTimer() *metrics.PhaseTimer { return f.base.GetPhaseTimer() } diff --git a/sei-db/state_db/bench/wrappers/historical_offload_wrapper.go b/sei-db/state_db/bench/wrappers/historical_offload_wrapper.go index ad80d63a0b..f682fb9332 100644 --- a/sei-db/state_db/bench/wrappers/historical_offload_wrapper.go +++ b/sei-db/state_db/bench/wrappers/historical_offload_wrapper.go @@ -10,6 +10,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/giga" scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/offload" ) @@ -175,6 +176,12 @@ func (h *historicalOffloadWrapper) Importer(_ int64) (scTypes.Importer, error) { return nil, fmt.Errorf("import not supported for historical offload wrapper") } +// RegisterHashListener reports that this DB publishes no block hashes. An offload stream computes +// none. +func (h *historicalOffloadWrapper) RegisterHashListener(_ giga.HashListener) (bool, error) { + return false, nil +} + func (h *historicalOffloadWrapper) GetPhaseTimer() *metrics.PhaseTimer { return nil } diff --git a/sei-db/state_db/bench/wrappers/memiavl_wrapper.go b/sei-db/state_db/bench/wrappers/memiavl_wrapper.go index 444f050a63..ae7ac8e7c2 100644 --- a/sei-db/state_db/bench/wrappers/memiavl_wrapper.go +++ b/sei-db/state_db/bench/wrappers/memiavl_wrapper.go @@ -3,6 +3,7 @@ package wrappers 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/giga" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -59,6 +60,12 @@ func (m *memIAVLWrapper) Read(key []byte) (data []byte, found bool, err error) { return data, data != nil, nil } +// RegisterHashListener reports that this DB publishes no block hashes. memIAVL's root is a +// Cosmos-layer aggregation over its per-module hashes rather than a hash the store hands out. +func (m *memIAVLWrapper) RegisterHashListener(_ giga.HashListener) (bool, error) { + return false, nil +} + func (m *memIAVLWrapper) GetPhaseTimer() *metrics.PhaseTimer { return nil } diff --git a/sei-db/state_db/bench/wrappers/noop_wrapper.go b/sei-db/state_db/bench/wrappers/noop_wrapper.go index 8c1138bb1a..dab6018cc3 100644 --- a/sei-db/state_db/bench/wrappers/noop_wrapper.go +++ b/sei-db/state_db/bench/wrappers/noop_wrapper.go @@ -6,6 +6,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/giga" scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -50,6 +51,12 @@ func (n *noOpWrapper) Importer(_ int64) (scTypes.Importer, error) { return nil, fmt.Errorf("import not supported for no-op wrapper") } +// RegisterHashListener reports that this DB publishes no block hashes. A store that persists nothing +// hashes nothing. +func (n *noOpWrapper) RegisterHashListener(_ giga.HashListener) (bool, error) { + return false, nil +} + func (n *noOpWrapper) GetPhaseTimer() *metrics.PhaseTimer { return nil } diff --git a/sei-db/state_db/bench/wrappers/state_store_wrapper.go b/sei-db/state_db/bench/wrappers/state_store_wrapper.go index 1cb3b92603..2486a46051 100644 --- a/sei-db/state_db/bench/wrappers/state_store_wrapper.go +++ b/sei-db/state_db/bench/wrappers/state_store_wrapper.go @@ -7,6 +7,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/metrics" dbTypes "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -66,6 +67,12 @@ func (s *stateStoreWrapper) Importer(_ int64) (scTypes.Importer, error) { return nil, fmt.Errorf("import not supported for state store wrapper") } +// RegisterHashListener reports that this DB publishes no block hashes. The historical state store +// computes none. +func (s *stateStoreWrapper) RegisterHashListener(_ giga.HashListener) (bool, error) { + return false, nil +} + func (s *stateStoreWrapper) GetPhaseTimer() *metrics.PhaseTimer { return nil } diff --git a/sei-db/state_db/bench/wrappers/wrappers_test.go b/sei-db/state_db/bench/wrappers/wrappers_test.go index 6d2ac73040..6b56b1b8b0 100644 --- a/sei-db/state_db/bench/wrappers/wrappers_test.go +++ b/sei-db/state_db/bench/wrappers/wrappers_test.go @@ -9,6 +9,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/metrics" dbTypes "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -127,6 +128,10 @@ func (m *mockStateStore) Close() error { return nil } +func (m *mockDBWrapper) RegisterHashListener(_ giga.HashListener) (bool, error) { + return false, nil +} + func TestCombinedWrapperApplyChangeSetsUsesAsyncSS(t *testing.T) { sc := &mockDBWrapper{commitVersion: 7} ss := &mockStateStore{latestVersion: 7} diff --git a/sei-db/state_db/giga/live_state_store.go b/sei-db/state_db/giga/live_state_store.go index b62dacbb6c..6d02efc7b3 100644 --- a/sei-db/state_db/giga/live_state_store.go +++ b/sei-db/state_db/giga/live_state_store.go @@ -128,18 +128,20 @@ type LiveStateStore interface { // 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. + // RegisterHashListener registers a callback that gets called for each hash the store produces: + // exactly one per block committed, in block order, with no gaps or duplicates. Returning an error + // from the listener bricks the store, and every later call reports that error. // - // The channel has finite depth, so failure to dequeue hashes for long enough blocks commit. Every - // store that returns one therefore needs a consumer. + // This method returns the most recent hash dispatched at the moment the listener is registered. If + // the first hash the listener observes is for block N, the mostRecentHash returned will have been + // block N-1. A nil listener registers nothing and only reports that hash. // - // 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) + // A store that hashes only in order to replay its way to a height — a read-only store — refuses, + // since a listener there would never be called. PublishedHash is that caller's answer. + RegisterHashListener(listener HashListener) (mostRecentHash lthash.BlockHash, err 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. + // FlushHashes blocks until every block committed so far has been hashed and its hash handed to + // every registered listener. FlushHashes() error // CommitPendingBlock commits the block currently being applied, if any, so that it has a hash. A @@ -150,10 +152,6 @@ type LiveStateStore interface { // 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 - // Version returns the latest committed version. Version() int64 diff --git a/sei-db/state_db/giga/state_db.go b/sei-db/state_db/giga/state_db.go index 5815bd1fe3..6ccd7e4403 100644 --- a/sei-db/state_db/giga/state_db.go +++ b/sei-db/state_db/giga/state_db.go @@ -1,6 +1,14 @@ package giga -import "github.com/sei-protocol/sei-chain/sei-db/proto" +import ( + "context" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" +) + +// A callback function for getting the hash of each block. +type HashListener func(ctx context.Context, blockNum int64, hash *lthash.BlockHash) error // StateDB is the top-level API used by the Giga EVM executor for // read and write. Writes commit into both SC and SS; reads can be served for @@ -19,4 +27,13 @@ type StateDB interface { // view exists at that height. When true, the caller must Close the // returned view when done. OpenViewAt(blockNum int64) (StateView, bool) + + // Register a callback function that that gets called for each hash produced by the database. Will be called for + // each block in order with no gaps. Returning an error from the listener bricks the DB and will eventually + // crash the node. + // + // This method returns the most recent hash observed at the moment the listener is registered. If the + // first hash the listener observes is for block N, the mostRecentHash returned will have been block N-1. + // This may be useful at startup time to determine the initial hash of the database. + RegisterHashListener(listener HashListener) (mostRecentHash lthash.BlockHash, err error) } diff --git a/sei-db/state_db/giga/state_db_hash_listener_test.go b/sei-db/state_db/giga/state_db_hash_listener_test.go new file mode 100644 index 0000000000..56e4b1ff1f --- /dev/null +++ b/sei-db/state_db/giga/state_db_hash_listener_test.go @@ -0,0 +1,75 @@ +package giga_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" + "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" +) + +// Registration has to reach the layer that hashes blocks. A StateDB that answered for itself would +// hand back a hash nothing produced, and a listener that was never called. +func TestRegisterHashListenerReachesTheLiveStateDB(t *testing.T) { + stateDB, _, liveStateDB := newTestStateDB(t) + + var seen []int64 + mostRecent, err := stateDB.RegisterHashListener( + func(_ context.Context, blockNumber int64, _ *lthash.BlockHash) error { + seen = append(seen, blockNumber) + return nil + }) + require.NoError(t, err) + require.Equal(t, int64(0), mostRecent.BlockNumber, "a fresh store has hashed nothing") + + require.NoError(t, stateDB.CommitStateChanges(1, changeset("key", "one"))) + require.NoError(t, stateDB.CommitStateChanges(2, changeset("key", "two"))) + require.NoError(t, liveStateDB.FlushHashes()) + + require.Equal(t, []int64{1, 2}, seen) +} + +// The hash logger is wired in as a listener, and this is that wiring end to end: blocks committed +// through the StateDB have to come back out of an archive on disk. +func TestCommittedBlockHashesReachARealHashLogArchive(t *testing.T) { + const blocks = 2 + + stateDB, _, liveStateDB := newTestStateDB(t) + + // The columns are declared here, at construction, which is the only place they are registered. + archiveDir := t.TempDir() + cfg := hashlog.DefaultHashLoggerConfig(archiveDir, "giga-archive-test") + cfg.HashTypes = flatkv.HashTypes() + hl, err := hashlog.NewHashLogger(cfg) + require.NoError(t, err) + + _, err = stateDB.RegisterHashListener(hl.HashListener) + require.NoError(t, err) + + for height := int64(1); height <= blocks; height++ { + cs := changeset("key", "value") + require.NoError(t, stateDB.CommitStateChanges(height, cs)) + + // 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. The executor plays this part in production. + hl.ReportChangeset(uint64(height), cs) + } + + // Hashing runs off the commit path, and the archive is only sealed by Close. + require.NoError(t, liveStateDB.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 _, hashType := range flatkv.HashTypes() { + require.NotEmpty(t, reports[0].Hashes[hashType], + "block %d recorded no %s hash", height, hashType) + } + } +} diff --git a/sei-db/state_db/giga/state_db_impl.go b/sei-db/state_db/giga/state_db_impl.go index 015edced1e..242a732eab 100644 --- a/sei-db/state_db/giga/state_db_impl.go +++ b/sei-db/state_db/giga/state_db_impl.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" "github.com/sei-protocol/sei-chain/sei-db/state_db/statewal" ) @@ -64,3 +65,13 @@ func (s *stateDB) OpenViewAt(blockNum int64) (StateView, bool) { panic(fmt.Sprintf( "giga: OpenViewAt(%d) is not implemented: the historical state DB is not wired in", blockNum)) } + +// RegisterHashListener forwards to the live state DB, which is the layer that hashes blocks and so +// is the layer that dispatches them. +func (s *stateDB) RegisterHashListener(listener HashListener) (lthash.BlockHash, error) { + mostRecentHash, err := s.liveStateDB.RegisterHashListener(listener) + if err != nil { + return mostRecentHash, fmt.Errorf("register hash listener on the live state DB: %w", err) + } + return mostRecentHash, nil +} 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 acbf7bd2d9..0aeb9f6f17 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, nil) + liveStateDB, err := flatkv.NewCommitStore(t.Context(), config.DefaultTestConfig(t), 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/flatkv_hash.go b/sei-db/state_db/sc/composite/flatkv_hash.go deleted file mode 100644 index 6d09fa28e4..0000000000 --- a/sei-db/state_db/sc/composite/flatkv_hash.go +++ /dev/null @@ -1,148 +0,0 @@ -package composite - -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. -// -// 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) { - // 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 - } - - // 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)) - } - - 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 - } - } - - 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 -} - -// 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) { - 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 9332069ff7..bd8721cd02 100644 --- a/sei-db/state_db/sc/composite/hashlog.go +++ b/sei-db/state_db/sc/composite/hashlog.go @@ -5,25 +5,22 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" ) -// HashCategories returns the union of the live backends' hash logger categories. An absent backend -// contributes nothing, so the set tracks which backends are active (used upstream to detect when the -// logger's category set must change). Note: the memIAVL root ("memIAVL/root") is not included here — it -// is a simple-merkle aggregation owned by the cosmos layer (see MemIAVLCommitInfo). +// HashCategories returns memIAVL's hash logger categories, or nothing when memIAVL is absent, so the +// set tracks whether it is active (used upstream to detect when the logger's category set must +// change). Note: the memIAVL root ("memIAVL/root") is not included here — it is a simple-merkle +// aggregation owned by the cosmos layer (see MemIAVLCommitInfo). +// +// flatKV is absent: this store records its hashes for the AppHash rather than logging them, so a +// flatKV column here would be one nothing reports, and a block missing a column is never written. func (cs *CompositeCommitStore) HashCategories() []string { var categories []string if cs.memIAVL != nil { categories = append(categories, cs.memIAVL.HashCategories()...) } - if cs.flatKV != nil { - categories = append(categories, cs.flatKV.HashCategories()...) - } return categories } // 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 { return nil 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 00b8744bc9..0c6117e662 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 @@ -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, nil) + evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL) 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 a03d330b12..15b614e808 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/flatkv/lthash" "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" @@ -45,10 +46,9 @@ 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 + // flatKVHash is the last hash flatKV handed over, written by the listener registered on it and + // read on the commit path once per block. + flatKVHash atomic.Pointer[lthash.BlockHash] // Manages routing of traffic between the memiavl and flatkv backends. // Built (and rebuilt) inside LoadVersion against the just-opened @@ -169,6 +169,11 @@ func NewCompositeCommitStore( if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid state commit config: %w", err) } + if hl == nil { + // Normalized here so that every path that reaches for the listener has a logger to take it + // from, rather than each of them nil-checking. flatKV used to do this on this store's behalf. + hl = hashlog.NewNoOpHashLogger() + } alignFlatKVSnapshotWithMemIAVL(&cfg) @@ -197,7 +202,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, hl) + fkv, err := flatkv.NewCommitStore(ctx, &cfg.FlatKVConfig, stateWAL) if err != nil { _ = stateWAL.Close() return nil, fmt.Errorf("failed to create FlatKV commit store: %w", err) @@ -205,15 +210,42 @@ func NewCompositeCommitStore( flatKV = fkv } - return &CompositeCommitStore{ + store := &CompositeCommitStore{ memIAVL: memIAVL, - flatKV: flatKV, homeDir: homeDir, config: cfg, currentWriteMode: cfg.WriteMode, ctx: ctx, hashLogger: hl, - }, nil + } + if flatKV != nil { + if err := store.adoptFlatKV(flatKV); err != nil { + return nil, err + } + } + return store, nil +} + +// adoptFlatKV installs store as this composite's flatKV backend, registering the listener that keeps +// the hash the commit path reads current and seeding it with the hash that registration reports. +func (cs *CompositeCommitStore) adoptFlatKV(store giga.LiveStateStore) error { + cs.flatKV = store + + mostRecent, err := store.RegisterHashListener(cs.recordFlatKVHash) + if err != nil { + return fmt.Errorf("failed to register the flatkv hash listener: %w", err) + } + // Stored only if the listener has not run yet. Registration and the hash it returns are atomic + // against dispatch, so a block dispatched after it is newer than this seed and must win. + cs.flatKVHash.CompareAndSwap(nil, &mostRecent) + return nil +} + +// recordFlatKVHash keeps flatKVHash current. It is the listener registered on every flatKV instance +// this store adopts. +func (cs *CompositeCommitStore) recordFlatKVHash(_ context.Context, _ int64, hash *lthash.BlockHash) error { + cs.flatKVHash.Store(hash) + return nil } // alignFlatKVSnapshotWithMemIAVL keeps the two backends' snapshot cadence in @@ -466,12 +498,17 @@ func (cs *CompositeCommitStore) LoadVersionReadOnly(targetVersion int64) (_ type // inherits cs.ctx so cancellation of the parent context cascades, but buildRouter installs its own // child cancel so closing this handle does not affect the parent. ro := &CompositeCommitStore{ - memIAVL: memIAVLCommitter, - flatKV: flatKVStore, - homeDir: cs.homeDir, - config: cs.config, - ctx: cs.ctx, - derived: true, + memIAVL: memIAVLCommitter, + homeDir: cs.homeDir, + config: cs.config, + ctx: cs.ctx, + hashLogger: cs.hashLogger, + derived: true, + } + if flatKVStore != nil { + if err := ro.adoptFlatKV(flatKVStore); err != nil { + return nil, err + } } if err := ro.resolveCurrentWriteMode(false); err != nil { return nil, fmt.Errorf("failed to resolve effective write mode for read-only handle: %w", err) @@ -715,7 +752,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, cs.hashLogger) + created, err := flatkv.NewCommitStore(cs.ctx, &flatKVConfig, stateWAL) if err != nil { _ = stateWAL.Close() return nil, fmt.Errorf("failed to create FlatKV commit store: %w", err) @@ -749,8 +786,7 @@ func (cs *CompositeCommitStore) materializeFlatKV() error { cs.memIAVL.Version(), err) } } - cs.flatKV = loaded - return nil + return cs.adoptFlatKV(loaded) } // ApplyChangeSets applies changesets to the appropriate backends based on config. @@ -1175,10 +1211,31 @@ func (cs *CompositeCommitStore) latticeHash(version int64) ([]byte, error) { if cs.flatKV == nil { return nil, nil } - if cs.flatKVHashes == nil { - cs.flatKVHashes = newFlatKVHashCache() + // A block that has not been committed has no hash, so asking for one is asking for the commit. + if err := cs.flatKV.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 := cs.flatKV.Version(); committed < version { + version = committed + } + + // Hashing is asynchronous, so this is where the answer is waited for. + if err := cs.flatKV.FlushHashes(); err != nil { + return nil, fmt.Errorf("wait for the flatkv hash of block %d: %w", version, err) + } + + hash := cs.flatKVHash.Load() + if hash.BlockNumber != version { + // Block version+1 has not been handed to flatKV yet, so the hash just flushed is version's. + // Asserted rather than assumed: this value reaches the AppHash, where a hash for the wrong + // height is indistinguishable from the right one. + return nil, fmt.Errorf("flatkv last published block %d, not block %d", hash.BlockNumber, version) } - return cs.flatKVHashes.hashAtVersion(cs.flatKV, version) + checksum := hash.Global.Checksum() + return checksum[:], nil } // LastCommitInfo returns the commit info for the block the backends last committed, or nil before the @@ -1368,10 +1425,6 @@ 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 @@ -1531,7 +1584,10 @@ func (cs *CompositeCommitStore) Importer(version int64) (types.Importer, error) _ = created.Close() return nil, fmt.Errorf("failed to create flatkv importer: %w", err) } - cs.flatKV = created + if err := cs.adoptFlatKV(created); err != nil { + _ = created.Close() + return nil, err + } return imp, nil } } 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 5de6b984ee..dde47fce9c 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 @@ -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, nil) + store, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, wal) 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_migration_test.go b/sei-db/state_db/sc/composite/store_migration_test.go index 3a8c8ab8b1..c5f4c21983 100644 --- a/sei-db/state_db/sc/composite/store_migration_test.go +++ b/sei-db/state_db/sc/composite/store_migration_test.go @@ -1232,7 +1232,6 @@ func TestMigrateEVMBeforeTheBoundaryDrainsTheHashStream(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MigrateEVM - cfg.FlatKVConfig.HashChanSize = 2 cfg.FlatKVConfig.FinalizationQueueSize = 1 cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) diff --git a/sei-db/state_db/sc/composite/store_test.go b/sei-db/state_db/sc/composite/store_test.go index a4364719fd..9159c226af 100644 --- a/sei-db/state_db/sc/composite/store_test.go +++ b/sei-db/state_db/sc/composite/store_test.go @@ -53,8 +53,8 @@ func (f *failingEVMStore) Iterator(string, []byte, []byte, bool) (dbm.Iterator, return nil, 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) RegisterHashListener(giga.HashListener) (lthash.BlockHash, error) { + return lthash.BlockHash{}, fmt.Errorf("flatkv unavailable") } func (f *failingEVMStore) FlushHashes() error { return nil } func (f *failingEVMStore) CommitPendingBlock() error { return nil } @@ -65,7 +65,6 @@ 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 } @@ -1329,7 +1328,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, nil) + evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL) require.NoError(t, err) err = evmStore.LoadLatest() require.NoError(t, err) @@ -1393,7 +1392,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, nil) + evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL) require.NoError(t, err) err = evmStore.LoadLatest() require.NoError(t, err) @@ -1827,7 +1826,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, nil) + evmStore, err := flatkv.NewCommitStore(t.Context(), &flatkvCfg, flatkvWAL) require.NoError(t, err) err = evmStore.LoadLatest() require.NoError(t, err) diff --git a/sei-db/state_db/sc/flatkv/config/config.go b/sei-db/state_db/sc/flatkv/config/config.go index 18735d1040..98ccc9521c 100644 --- a/sei-db/state_db/sc/flatkv/config/config.go +++ b/sei-db/state_db/sc/flatkv/config/config.go @@ -120,12 +120,6 @@ type Config struct { // 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 @@ -162,7 +156,6 @@ func DefaultConfig() *Config { 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 b7e66f0312..87cc950929 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 @@ -45,6 +45,5 @@ func DefaultTestConfig(t *testing.T) *Config { 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 index bbe18a3ef4..579baecec7 100644 --- a/sei-db/state_db/sc/flatkv/finalization_manager.go +++ b/sei-db/state_db/sc/flatkv/finalization_manager.go @@ -6,22 +6,17 @@ import ( "fmt" "sync" "sync/atomic" - "time" "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" ) -// stallReportInterval is how often a publish that the hash stream has no room for reports itself. -const stallReportInterval = 30 * time.Second - // 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. +// metadata has been written, and hashes go out to the store's registered listeners, 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. @@ -33,36 +28,26 @@ type FinalizationManager struct { // 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. + // rather than a delivery. 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 is cancelled when the manager is stopping, and is handed to each listener so that one + // blocking on a block's hash is released by teardown. 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 + // listeners receives each block's hash once it has been finalized. Owned by the store, so it + // outlives this manager. Never nil. + listeners *hashListenerRegistry } // newFinalizationManager starts a manager consuming the hash engine's stream. @@ -76,19 +61,16 @@ func newFinalizationManager( 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, + // Receives each block's hash once it has been finalized. + listeners *hashListenerRegistry, ) *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, + engineHashChan: engineHashChan, + messageChan: make(chan any, max(queueSize, 1)), + ctx: ctx, + cancel: cancel, + listeners: listeners, } fm.latest.Store(loaded) fm.wg.Add(1) @@ -133,15 +115,8 @@ 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. +// Flush blocks until the manager has finalized every block offered so far and dispatched each of +// their hashes to every registered listener. func (fm *FinalizationManager) Flush() error { request := newFinalizationFlushRequest() if err := fm.enqueue(request); err != nil { @@ -183,7 +158,6 @@ func (fm *FinalizationManager) enqueue(message any) error { // 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 { @@ -196,12 +170,7 @@ func (fm *FinalizationManager) run() { 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() - } + failed = !fm.handle(message) case <-fm.ctx.Done(): fm.abandon() return @@ -215,14 +184,13 @@ func (fm *FinalizationManager) handle(message any) bool { 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: + // Answering here is what makes a flush mean the listeners have the hashes: every block queued + // ahead of this request has already been dispatched, on this goroutine, before it is reached. close(request.doneChan) return true default: @@ -231,8 +199,8 @@ func (fm *FinalizationManager) handle(message any) bool { } } -// finalize writes one block's hashes onto its own views, releases its reservation, and publishes the -// hash. +// finalize writes one block's hashes onto its own views, releases its reservation, and hands the hash +// to the listeners. // 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 @@ -262,16 +230,14 @@ func (fm *FinalizationManager) finalize(pending *pendingFinalization) (stopped b } } - // 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. + // The reservation is only needed while the writes above happen. Released before the hash goes out + // so the databases resume flushing even while a listener is still working. 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 + return false, fm.listeners.dispatch(fm.ctx, hash) } // discard finalizes a block's views with nothing recorded and releases its reservation, for a block @@ -330,58 +296,6 @@ func (fm *FinalizationManager) drainHashes() { } } -// 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: - return - default: - } - fm.publishStalled(hash) -} - -// publishStalled publishes a hash the stream had no room for, reporting it every stallReportInterval -// until it lands or the manager stops. -// -// The reporting exists to make misuse of the stream's threading requirement obvious rather than let it -// deadlock silently. A store that hands out HashChan() needs a consumer: the stream has finite depth, -// and a full one blocks this goroutine, then the queue behind it, and finally Offer, which stops block -// commit. Nothing about that halt names its cause — a stalled node presents a stack sitting in Offer, -// several frames from the channel nobody is reading — and this is the one place that can tell. It -// repeats where reportingFailed logs once because a stalled publish is a halt rather than a -// degradation, and whoever investigates arrives long after the first line scrolled away. -func (fm *FinalizationManager) publishStalled(hash *lthash.BlockHash) { - stalledSince := time.Now() - ticker := time.NewTicker(stallReportInterval) - defer ticker.Stop() - - for { - select { - case fm.publishedHashChan <- hash: - logger.Warn("flatkv hash stream took a stalled block; commits are moving again", - "version", hash.BlockNumber, "stalledFor", time.Since(stalledSince)) - return - case <-fm.ctx.Done(): - return - case <-ticker.C: - logger.Error("flatkv hash stream is full and nothing is draining it, so block commit has "+ - "stopped; a store that hands out HashChan() needs a consumer reading it", - "version", hash.BlockNumber, - "stalledFor", time.Since(stalledSince), - "streamDepth", cap(fm.publishedHashChan)) - } - } -} - -// 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) diff --git a/sei-db/state_db/sc/flatkv/finalization_manager_test.go b/sei-db/state_db/sc/flatkv/finalization_manager_test.go deleted file mode 100644 index a42ae3dcd4..0000000000 --- a/sei-db/state_db/sc/flatkv/finalization_manager_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package flatkv - -import ( - "testing" - "time" - - "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" - "github.com/stretchr/testify/require" -) - -// TestPublishWaitsForRoomOnAFullHashStream pins what publish does when the stream has no room: it holds -// the hash until a consumer makes space rather than dropping it or returning early. -// -// The wait is what stalls block commit when nobody is reading, which publishStalled reports. This covers -// the waiting, not the reporting. -func TestPublishWaitsForRoomOnAFullHashStream(t *testing.T) { - engineHashChan := make(chan *lthash.BlockHash) - fm := newFinalizationManager( - t.Context(), engineHashChan, <hash.BlockHash{BlockNumber: 0}, 1, 1, hashlog.NewNoOpHashLogger()) - - // Registered before the close below so it runs after it: Close drains the engine's stream, which - // only terminates once that stream is closed. - defer func() { require.NoError(t, fm.Close()) }() - defer close(engineHashChan) - - // Depth 1, so this fills the stream and the next publish has nowhere to go. - fm.publish(<hash.BlockHash{BlockNumber: 1}) - - published := make(chan struct{}) - go func() { - defer close(published) - fm.publish(<hash.BlockHash{BlockNumber: 2}) - }() - - select { - case <-published: - t.Fatal("publish returned while the stream was still full") - case <-time.After(100 * time.Millisecond): - } - - require.Equal(t, int64(1), (<-fm.HashChan()).BlockNumber) - - select { - case <-published: - case <-time.After(30 * time.Second): - t.Fatal("publish never completed after the stream drained") - } - require.Equal(t, int64(2), (<-fm.HashChan()).BlockNumber) -} diff --git a/sei-db/state_db/sc/flatkv/hash_listeners.go b/sei-db/state_db/sc/flatkv/hash_listeners.go new file mode 100644 index 0000000000..e7f7893bad --- /dev/null +++ b/sei-db/state_db/sc/flatkv/hash_listeners.go @@ -0,0 +1,69 @@ +package flatkv + +import ( + "context" + "fmt" + "sync" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" +) + +// hashListenerRegistry holds the listeners each block's hash is dispatched to, and the most recent +// hash they were given. +// +// The store owns it rather than the finalization manager that dispatches through it, so that +// registrations outlive a manager rebuilt underneath them — which is what rollback and restore do. +// +// Every method is safe to call from any goroutine. +type hashListenerRegistry struct { + // mu guards both fields below as one. A registration that interleaved with a dispatch would + // either miss a block or be told the wrong block to expect next. + mu sync.Mutex + + // The listeners, in registration order. + listeners []giga.HashListener + + // The most recent hash handed to the listeners, which is the block a listener registering now + // is told its first delivery follows. Nil until a block has been dispatched. + lastDispatched *lthash.BlockHash +} + +// newHashListenerRegistry returns an empty registry. +func newHashListenerRegistry() *hashListenerRegistry { + return &hashListenerRegistry{} +} + +// register adds a listener and reports the most recent hash dispatched before it was added, or +// current when nothing has been dispatched yet. +func (r *hashListenerRegistry) register( + listener giga.HashListener, + // The height the store stands at, for a store that has dispatched nothing. + current *lthash.BlockHash, +) lthash.BlockHash { + r.mu.Lock() + defer r.mu.Unlock() + + // A caller that only wants the hash passes nil rather than a callback it does not need. + if listener != nil { + r.listeners = append(r.listeners, listener) + } + if r.lastDispatched == nil { + return *current + } + return *r.lastDispatched +} + +// dispatch hands one block's hash to every registered listener, reporting the first refusal. +func (r *hashListenerRegistry) dispatch(ctx context.Context, hash *lthash.BlockHash) error { + r.mu.Lock() + defer r.mu.Unlock() + + for _, listener := range r.listeners { + if err := listener(ctx, hash.BlockNumber, hash); err != nil { + return fmt.Errorf("a hash listener refused block %d: %w", hash.BlockNumber, err) + } + } + r.lastDispatched = hash + return nil +} diff --git a/sei-db/state_db/sc/flatkv/hash_listeners_test.go b/sei-db/state_db/sc/flatkv/hash_listeners_test.go new file mode 100644 index 0000000000..86152842e5 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/hash_listeners_test.go @@ -0,0 +1,249 @@ +package flatkv + +import ( + "context" + "fmt" + "testing" + + "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" +) + +// tightHashPipelineConfig returns a config that lets only one block wait to be finalized, so that a +// store which stops finalizing wedges within a block or two rather than sixty-four. +func tightHashPipelineConfig(t *testing.T) *config.Config { + t.Helper() + cfg := config.DefaultTestConfig(t) + cfg.FinalizationQueueSize = 1 + return cfg +} + +// commitBlocks commits count blocks, each writing one storage slot. +func commitBlocks(t *testing.T, s *CommitStore, count int) { + t.Helper() + for i := 0; i < count; i++ { + height := s.Version() + 1 + require.NoError(t, s.ApplyChangeSets(height, []*proto.NamedChangeSet{ + makeChangeSet(evmStorageKey(ktype.Address{0x11}, ktype.Slot{byte(height)}), padLeft32(byte(height)), false), + }), "apply block %d", height) + _, err := s.Commit(height) + require.NoError(t, err, "commit block %d", height) + } +} + +// recordBlocks returns a listener that records the block number of every hash it is handed, and the +// slice it records into. The slice is only safe to read once FlushHashes has returned. +func recordBlocks() (func(context.Context, int64, *lthash.BlockHash) error, *[]int64) { + blocks := &[]int64{} + return func(_ context.Context, blockNumber int64, _ *lthash.BlockHash) error { + *blocks = append(*blocks, blockNumber) + return nil + }, blocks +} + +// A store hashes every block whether or not anything asked for one, so nobody listening has to be the +// ordinary case rather than something that eventually wedges commit. +func TestCommittingWithNoListeners(t *testing.T) { + s := setupTestStoreWithConfig(t, tightHashPipelineConfig(t)) + defer func() { require.NoError(t, s.Close()) }() + + const blocks = 16 + commitBlocks(t, s, blocks) + + require.Equal(t, int64(blocks), s.Version()) + require.NoError(t, s.FlushHashes()) +} + +// The one thing a listener is promised: every block, once, in order. A listener that skipped a block +// could not maintain anything derived from the chain of hashes. +func TestAListenerSeesEveryBlockInOrder(t *testing.T) { + s := setupTestStoreWithConfig(t, tightHashPipelineConfig(t)) + defer func() { require.NoError(t, s.Close()) }() + + listener, seen := recordBlocks() + mostRecent, err := s.RegisterHashListener(listener) + require.NoError(t, err) + require.Equal(t, int64(0), mostRecent.BlockNumber, "a fresh store has hashed nothing") + + const blocks = 8 + commitBlocks(t, s, blocks) + require.NoError(t, s.FlushHashes()) + + require.Equal(t, []int64{1, 2, 3, 4, 5, 6, 7, 8}, *seen) +} + +// FlushHashes is how a caller waits for hashing to catch up, and a hash that has been computed but +// not handed on has not caught up as far as a listener is concerned. +func TestFlushHashesWaitsForEveryBlockToBeDispatched(t *testing.T) { + s := setupTestStoreWithConfig(t, tightHashPipelineConfig(t)) + defer func() { require.NoError(t, s.Close()) }() + + listener, seen := recordBlocks() + _, err := s.RegisterHashListener(listener) + require.NoError(t, err) + + const blocks = 4 + commitBlocks(t, s, blocks) + require.NoError(t, s.FlushHashes()) + + // Read with nothing polling and no second flush: if dispatch were still in flight this would be + // short, and the assertion would be the one thing standing between that and a silent gap. + require.Len(t, *seen, blocks) +} + +// The hash reported at registration is what tells a caller where the listener picks up. Reporting the +// height the store has reached rather than the height it has dispatched would hide a block. +func TestRegisterReportsTheBlockTheFirstDeliveryFollows(t *testing.T) { + s := setupTestStoreWithConfig(t, tightHashPipelineConfig(t)) + defer func() { require.NoError(t, s.Close()) }() + + commitBlocks(t, s, 3) + require.NoError(t, s.FlushHashes()) + + listener, seen := recordBlocks() + mostRecent, err := s.RegisterHashListener(listener) + require.NoError(t, err) + require.Equal(t, int64(3), mostRecent.BlockNumber) + require.Equal(t, rootHash(s), checksumOf(mostRecent.Global)) + + commitBlocks(t, s, 2) + require.NoError(t, s.FlushHashes()) + + require.Equal(t, []int64{4, 5}, *seen, "a listener starts at the block after the one it was told") +} + +// Listeners are independent: one of them consuming a hash must not take it away from another. +func TestEveryListenerSeesEveryBlock(t *testing.T) { + s := setupTestStoreWithConfig(t, tightHashPipelineConfig(t)) + defer func() { require.NoError(t, s.Close()) }() + + first, seenByFirst := recordBlocks() + second, seenBySecond := recordBlocks() + _, err := s.RegisterHashListener(first) + require.NoError(t, err) + _, err = s.RegisterHashListener(second) + require.NoError(t, err) + + commitBlocks(t, s, 3) + require.NoError(t, s.FlushHashes()) + + require.Equal(t, []int64{1, 2, 3}, *seenByFirst) + require.Equal(t, []int64{1, 2, 3}, *seenBySecond) +} + +// A listener that refuses a block is a caller that cannot keep up with the state it is deriving. The +// store has no way to make that good, so it stops rather than carrying on past it. +func TestAListenerThatFailsBricksTheStore(t *testing.T) { + s := setupTestStoreWithConfig(t, tightHashPipelineConfig(t)) + defer func() { _ = s.Close() }() + + _, err := s.RegisterHashListener(func(context.Context, int64, *lthash.BlockHash) error { + return fmt.Errorf("injected listener failure") + }) + require.NoError(t, err) + + commitBlocks(t, s, 1) + + require.ErrorContains(t, s.FlushHashes(), "injected listener failure", + "a caller waiting for hashes must be told a listener refused one") + + height := s.Version() + 1 + require.NoError(t, s.ApplyChangeSets(height, []*proto.NamedChangeSet{ + makeChangeSet(evmStorageKey(ktype.Address{0x11}, ktype.Slot{0x99}), padLeft32(0x99), false), + })) + _, err = s.Commit(height) + require.ErrorContains(t, err, "injected listener failure", + "a store whose listener failed must refuse the next block rather than commit past it") +} + +// A caller that wants the current hash and no deliveries passes nil. Refusing it would make such a +// caller invent a callback it has no use for. +func TestANilHashListenerRegistersNothing(t *testing.T) { + s := setupTestStoreWithConfig(t, tightHashPipelineConfig(t)) + defer func() { require.NoError(t, s.Close()) }() + + commitBlocks(t, s, 2) + require.NoError(t, s.FlushHashes()) + + mostRecent, err := s.RegisterHashListener(nil) + require.NoError(t, err) + require.Equal(t, int64(2), mostRecent.BlockNumber, "a nil listener still reports the current hash") + + // Nothing was registered, so the block below has nobody to deliver to and must still commit. + commitBlocks(t, s, 1) + require.NoError(t, s.FlushHashes()) + require.Equal(t, int64(3), s.Version()) +} + +// A rollback rebuilds the hash pipeline underneath the listeners. They belong to the store rather +// than to that pipeline, so they survive it — a rollback that silently dropped a listener would leave +// whatever it feeds frozen at the pre-rollback height. +// +// A rollback re-delivers the heights it replays on its way back to the target, so the listener sees +// them a second time. Nothing observes that in practice — see RegisterHashListener. +func TestRegistrationsSurviveARollback(t *testing.T) { + s := setupTestStoreWithConfig(t, tightHashPipelineConfig(t)) + defer func() { require.NoError(t, s.Close()) }() + + listener, seen := recordBlocks() + _, err := s.RegisterHashListener(listener) + require.NoError(t, err) + + commitBlocks(t, s, 5) + require.NoError(t, s.FlushHashes()) + require.Equal(t, []int64{1, 2, 3, 4, 5}, *seen) + + require.NoError(t, s.Rollback(3)) + + commitBlocks(t, s, 2) + require.NoError(t, s.FlushHashes()) + + require.Equal(t, []int64{1, 2, 3, 4, 5, 1, 2, 3, 4, 5}, *seen, + "the listener registered before the rollback must still be given the blocks after it") +} + +// Each database records the hash it was handed in the same batch as the block's data, so the hash a +// listener is given and the hash on disk are the same claim about the same block. They are compared +// here because a listener acting on one while the store persists the other would be undetectable. +func TestDispatchedPerDBHashesMatchWhatEachDatabaseRecorded(t *testing.T) { + s := setupTestStore(t) + defer func() { require.NoError(t, s.Close()) }() + + var dispatched *lthash.BlockHash + _, err := s.RegisterHashListener(func(_ context.Context, _ int64, hash *lthash.BlockHash) error { + dispatched = hash + return nil + }) + require.NoError(t, err) + + commitBlocks(t, s, 1) + require.NoError(t, s.FlushHashes()) + require.NotNil(t, dispatched, "the committed block must have been dispatched") + + require.Equal(t, rootHash(s), checksumOf(dispatched.Global)) + + // 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 { + require.Equal(t, checksumOf(s.localMeta[dir].LtHash), checksumOf(dispatched.PerDB[dir]), + "the %s hash dispatched must be the one that database recorded", dir) + } + + // Homomorphic invariant: the per-DB LtHashes sum to the dispatched global LtHash. + sum := lthash.New() + for _, dir := range dataDBDirs { + sum.MixIn(s.localMeta[dir].LtHash) + } + require.True(t, sum.Equal(dispatched.Global)) +} + +// checksumOf returns an LtHash's checksum as a slice, for comparison. +func checksumOf(hash *lthash.LtHash) []byte { + checksum := hash.Checksum() + return checksum[:] +} diff --git a/sei-db/state_db/sc/flatkv/hashlog.go b/sei-db/state_db/sc/flatkv/hashlog.go deleted file mode 100644 index 6092607527..0000000000 --- a/sei-db/state_db/sc/flatkv/hashlog.go +++ /dev/null @@ -1,82 +0,0 @@ -package flatkv - -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"). -const ( - FlatKVRootHashType = "flatKV/root" - flatKVDBHashPrefix = "flatKV/db/" -) - -// HashCategories returns the hash logger categories this store reports: the global flatKV root plus one -// 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. -func hashCategories() []string { - categories := make([]string, 0, len(dataDBDirs)+1) - categories = append(categories, FlatKVRootHashType) - for _, dir := range dataDBDirs { - categories = append(categories, flatKVDBHashPrefix+dir) - } - 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. -// -// 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 - } - 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 dbChecksum []byte - if dbHash := hash.PerDB[dir]; dbHash != nil { - checksum := dbHash.Checksum() - dbChecksum = checksum[:] - } - category := flatKVDBHashPrefix + dir - 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 - } - } -} diff --git a/sei-db/state_db/sc/flatkv/hashlog_test.go b/sei-db/state_db/sc/flatkv/hashlog_test.go deleted file mode 100644 index 89ac2cb64d..0000000000 --- a/sei-db/state_db/sc/flatkv/hashlog_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package flatkv - -import ( - "testing" - - "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" - "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. -type captureLogger struct { - registered map[string]struct{} - hashes map[string][]byte - changesets int -} - -func newCaptureLogger() *captureLogger { - return &captureLogger{registered: map[string]struct{}{}, hashes: map[string][]byte{}} -} - -func (c *captureLogger) RegisterHashType(hashType string) error { - c.registered[hashType] = struct{}{} - return nil -} - -func (c *captureLogger) UnregisterHashType(hashType string) error { - delete(c.registered, hashType) - return nil -} - -func (c *captureLogger) ReportHash(_ uint64, hashType string, hash []byte) error { - c.hashes[hashType] = hash - return nil -} - -func (c *captureLogger) ReportChangeset(uint64, []*proto.NamedChangeSet) { c.changesets++ } - -func (c *captureLogger) Close() error { return nil } - -func TestFlatKVHashReporting(t *testing.T) { - // 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() - - 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)})) - _, err := s.Commit(s.Version() + 1) - require.NoError(t, err) - - // Categories: the global root plus one per data DB. - require.Equal(t, []string{ - "flatKV/root", - "flatKV/db/account", - "flatKV/db/code", - "flatKV/db/storage", - "flatKV/db/misc", - }, s.HashCategories()) - - require.NoError(t, s.FlushHashes()) - - // Every category is reported, and the root matches CommittedRootHash. - for _, category := range s.HashCategories() { - _, ok := logger.hashes[category] - require.True(t, ok, "expected a hash for %q", category) - } - require.Equal(t, rootHash(s), logger.hashes["flatKV/root"]) - - // 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]) - } - - // Homomorphic invariant: the per-DB LtHashes sum to the committed global LtHash. - sum := lthash.New() - for _, dir := range dataDBDirs { - sum.MixIn(s.localMeta[dir].LtHash) - } - 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/import_export_test.go b/sei-db/state_db/sc/flatkv/import_export_test.go index cb722d17ee..c0192a7085 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, nil) + s, err := NewCommitStore(t.Context(), cfg, nil) require.NoError(t, err) defer s.Close() require.NoError(t, s.LoadLatest()) 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 066c608d82..d493423bb1 100644 --- a/sei-db/state_db/sc/flatkv/lthash_golden_test.go +++ b/sei-db/state_db/sc/flatkv/lthash_golden_test.go @@ -25,8 +25,8 @@ import ( // 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 — 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. +// store dispatching each block's hash to a hashlog.HashLogger's listener — 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,10 +114,10 @@ func requireArchivesAgree(t *testing.T, recorded string, fresh string) { func writeGoldenRun(t *testing.T, dir string, cfg *config.Config) { t.Helper() - logger := newGoldenHashLogger(t, dir, hashCategories()) + logger := newGoldenHashLogger(t, dir, HashTypes()) defer func() { require.NoError(t, logger.Close()) }() - store := setupTestStoreWithHashLogger(t, cfg, logger) + store := setupTestStoreReportingTo(t, cfg, logger) defer func() { require.NoError(t, store.Close()) }() workload := newFixedSizeAgreementWorkload( diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index 7804be1597..5c944f7fc0 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -27,7 +27,6 @@ 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/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" @@ -97,9 +96,10 @@ type CommitStore struct { // 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 + // hashListeners holds the callbacks each finalized block's hash is dispatched to. Held here + // rather than on the finalizer that dispatches through it, so that a registration survives the + // finalizer being rebuilt by restartHashing. Never nil. + hashListeners *hashListenerRegistry // 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 @@ -229,23 +229,14 @@ 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 { 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() @@ -262,7 +253,7 @@ func NewCommitStore( return &CommitStore{ ctx: ctx, cancel: cancel, - hashLogger: hl, + hashListeners: newHashListenerRegistry(), config: *cfg, localMeta: make(map[string]*LocalMeta), pendingChangeSets: make([]*proto.NamedChangeSet, 0), @@ -438,9 +429,7 @@ 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. - // 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) + ro, err := NewCommitStore(context.Background(), &s.config, nil) if err != nil { return nil, fmt.Errorf("failed to create readonly store: %w", err) } @@ -1123,20 +1112,8 @@ func (s *CommitStore) startHashing() error { s.hashEngine.AwaitHash(), s.loadedHashes, s.config.FinalizationQueueSize, - s.config.HashChanSize, - s.hashLogger, + s.hashListeners, ) - - if s.readOnly { - // 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 - } - }() - } return nil } @@ -1253,31 +1230,23 @@ func (s *CommitStore) PublishedHash() *lthash.BlockHash { 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. +// RegisterHashListener registers a callback the store hands the hash of each committed block to: +// exactly one per block, in block order, with no gaps or duplicates. It reports the most recent hash +// dispatched, which is the block the listener's first delivery follows. // -// 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 hash reported is the height the store stands at until a block has been dispatched. A nil +// listener registers nothing and only reports that hash, which is how a caller that wants the height +// and no deliveries asks for it. A read-only store takes a listener and never calls it: it hashes +// only inside the call that builds it. // -// 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.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") - } - return s.finalizer.HashChan(), nil +// A rollback re-executes heights, so across one the reported hash can sit ahead of the listener's +// next delivery and hashes arrive out of order. Runtime rollback is scheduled for deprecation. +func (s *CommitStore) RegisterHashListener(listener giga.HashListener) (lthash.BlockHash, error) { + return s.hashListeners.register(listener, s.PublishedHash()), nil } -// 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 blocks until every block committed so far has been hashed and its hash handed to every +// registered listener. func (s *CommitStore) FlushHashes() error { s.mu.RLock() engine, finalizer := s.hashEngine, s.finalizer diff --git a/sei-db/state_db/sc/flatkv/store_constants.go b/sei-db/state_db/sc/flatkv/store_constants.go index 9b05324f52..285dc11b60 100644 --- a/sei-db/state_db/sc/flatkv/store_constants.go +++ b/sei-db/state_db/sc/flatkv/store_constants.go @@ -1,6 +1,10 @@ package flatkv -import "errors" +import ( + "errors" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" +) const ( // Top-level directory names @@ -28,3 +32,17 @@ var dataDBDirs = []string{accountDBDir, codeDBDir, storageDBDir, miscDBDir} // errReadOnly is returned by every method that would modify a store opened read-only. var errReadOnly = errors.New("flatkv: store is read-only") + +// HashTypes returns the hash log columns this store's hashes are recorded under: the store-wide +// root, and one per data database. A node declares them when it constructs the hash logger +// (hashlog.HashLoggerConfig.HashTypes), which is the only place a column is registered. +// +// The set is fixed, because what flatKV holds is known. +func HashTypes() []string { + hashTypes := make([]string, 0, len(dataDBDirs)+1) + hashTypes = append(hashTypes, hashlog.FlatKVRootHashType) + for _, dataDB := range dataDBDirs { + hashTypes = append(hashTypes, hashlog.FlatKVDBHashPrefix+dataDB) + } + return hashTypes +} diff --git a/sei-db/state_db/sc/flatkv/store_replay.go b/sei-db/state_db/sc/flatkv/store_replay.go index fbf9d90d59..c46163bbd5 100644 --- a/sei-db/state_db/sc/flatkv/store_replay.go +++ b/sei-db/state_db/sc/flatkv/store_replay.go @@ -212,9 +212,6 @@ func replayBlocks( if err := dest.applyAndCommit(int64(block), changesets, alreadyHave); err != nil { return 0, fmt.Errorf("replay block %d: %w", block, err) } - if err := dest.discardReplayedHashes(); err != nil { - return 0, fmt.Errorf("drain hashes while replaying block %d: %w", block, err) - } replayed++ // Liveness, not context: only the loop can report that a multi-hour replay is still moving. if replayed%1000 == 0 { @@ -224,38 +221,6 @@ func replayBlocks( return replayed, nil } -// discardReplayedHashes takes whatever the hash stream is holding and drops it, reporting instead the -// failure a failed block carries. -// -// Replay seals a block per WAL record and every sealed block publishes a hash, but during replay -// nothing is reading them: the store is still inside open(), so the consumer that drains the stream in -// service does not exist yet. Left unread, a replay longer than the stream is deep blocks in Offer and -// never returns. The hashes are dropped rather than kept because nothing asked for these blocks, and -// PublishedHash still reports the height replay lands on. -// -// A read-only store is exempt: it drains its own stream from startHashing, and a second reader here -// would race that one. -func (s *CommitStore) discardReplayedHashes() error { - if s.readOnly || s.finalizer == nil { - return nil - } - - stream := s.finalizer.HashChan() - for { - select { - case hash, open := <-stream: - if !open { - return nil - } - if hash.Error != nil { - return fmt.Errorf("hash block %d: %w", hash.BlockNumber, hash.Error) - } - default: - return nil - } - } -} - // applyAndCommit replays a single block into the store: it applies the changesets, seals the block on // every store, advances the committed version and clones the working LtHash to committed. It never // touches the WAL — the data being applied was itself read from a WAL, so re-writing it would 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 a7f46dd328..f66b369856 100644 --- a/sei-db/state_db/sc/flatkv/store_replay_test.go +++ b/sei-db/state_db/sc/flatkv/store_replay_test.go @@ -492,7 +492,6 @@ func TestReplayDrainsHashStreamPastItsDepth(t *testing.T) { replayCfg := config.DefaultTestConfig(t) replayCfg.DataDir = cfg.DataDir - replayCfg.HashChanSize = 4 replayCfg.FinalizationQueueSize = 2 reopened, err := newCommitStoreWithWAL(t.Context(), replayCfg) diff --git a/sei-db/state_db/sc/flatkv/store_test.go b/sei-db/state_db/sc/flatkv/store_test.go index ca04e645e3..95b2103885 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, nil) + s, err := NewCommitStore(t.Context(), cfg, nil) require.NoError(t, err) defer s.Close() @@ -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, nil) + s2, err := NewCommitStore(t.Context(), cfg, 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, nil) + s2, err := NewCommitStore(t.Context(), cfg, nil) require.NoError(t, err) defer func() { require.NoError(t, s2.Close()) }() @@ -1591,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, nil) + s2, err := NewCommitStore(t.Context(), cfg, nil) require.NoError(t, err) defer s2.Close() require.NoError(t, s2.LoadLatest()) 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 2e142be808..495ada66bb 100644 --- a/sei-db/state_db/sc/flatkv/store_write_test.go +++ b/sei-db/state_db/sc/flatkv/store_write_test.go @@ -1,6 +1,7 @@ package flatkv import ( + "context" "encoding/binary" "fmt" "os" @@ -13,6 +14,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" ) @@ -1815,13 +1817,21 @@ func TestApplyChangeSetsKeepsPendingCleanOnLaterParseError(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) { +// What must not happen is the failure being lost. It has to reach a caller waiting for hashes to catch +// up and the block after it, and no hash may be dispatched once a block has failed — the running +// accumulator then describes nothing a later block could be derived from. +func TestHashFailureSurfacesToACallerAndStopsDispatch(t *testing.T) { s := setupTestStore(t) defer func() { _ = s.Close() }() + // Registered before the first block, since a listener only ever sees the blocks after it. + dispatched := make(chan int64, 8) + _, err := s.RegisterHashListener(func(_ context.Context, blockNumber int64, _ *lthash.BlockHash) error { + dispatched <- blockNumber + return nil + }) + require.NoError(t, err) + seedAddr := addrN(0xAC) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ makeChangeSet(keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(seedAddr, slotN(0x09))), padLeft32(0x99), false), @@ -1829,9 +1839,8 @@ func TestHashFailureSurfacesOnTheStream(t *testing.T) { })) commitAndCheck(t, s) - hashes, err := s.HashChan() - require.NoError(t, err) - require.NoError(t, (<-hashes).Error, "the good block hashes normally") + require.NoError(t, s.FlushHashes()) + require.Equal(t, int64(1), <-dispatched, "the good block hashes normally") s.moduleOf = func([]byte) (string, error) { return "", fmt.Errorf("injected moduleOf failure") @@ -1846,21 +1855,22 @@ func TestHashFailureSurfacesOnTheStream(t *testing.T) { require.NoError(t, err, "hashing runs after the commit, so the commit itself still succeeds") require.Equal(t, int64(2), committed) - 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") + require.Empty(t, dispatched, "a block that failed to hash has no hash to dispatch") + + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ + makeChangeSet(storageKey, padLeft32(0xEF), false), + })) + _, err = s.Commit(s.Version() + 1) + require.ErrorContains(t, err, "injected moduleOf failure", + "the block after a failed one must be refused rather than committed on hashes nobody has") } -// 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) { +// A read-only store does hash blocks — it replays them to reach its target height — but it does so +// inside the call that builds it, so a listener on one is never called. What such a caller is after is +// the height, and registration reports it. +func TestAReadOnlyStoreReportsItsHeight(t *testing.T) { s := setupTestStore(t) defer func() { _ = s.Close() }() @@ -1869,21 +1879,22 @@ func TestReadOnlyStoreRefusesItsHashChan(t *testing.T) { })) 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) + delivered := make(chan int64, 4) + mostRecent, err := ro.RegisterHashListener( + func(_ context.Context, blockNumber int64, _ *lthash.BlockHash) error { + delivered <- blockNumber + return nil + }) + require.NoError(t, err) + require.Equal(t, ro.Version(), mostRecent.BlockNumber, + "registration must report the height the read-only store was opened at") - // 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) + require.NoError(t, ro.FlushHashes()) + require.Empty(t, delivered, "a read-only store commits nothing, so it delivers nothing") } func TestApplyChangeSetsEVMKeyEmptySkipped(t *testing.T) { diff --git a/sei-db/state_db/sc/flatkv/verify.go b/sei-db/state_db/sc/flatkv/test_verify.go similarity index 100% rename from sei-db/state_db/sc/flatkv/verify.go rename to sei-db/state_db/sc/flatkv/test_verify.go diff --git a/sei-db/state_db/sc/flatkv/testutil_test.go b/sei-db/state_db/sc/flatkv/testutil_test.go index 84e78ef9b8..e5bc728c40 100644 --- a/sei-db/state_db/sc/flatkv/testutil_test.go +++ b/sei-db/state_db/sc/flatkv/testutil_test.go @@ -149,14 +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 { +// setupTestStoreReportingTo creates a test store with hl registered as a listener, which is how a +// node puts flatKV's hashes on a hash log. +func setupTestStoreReportingTo(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) + s, err := NewCommitStore(t.Context(), cfg, stateWAL) require.NoError(t, err) require.NoError(t, s.LoadLatest()) + _, err = s.RegisterHashListener(hl.HashListener) + require.NoError(t, err) return s } 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 0b2edae761..99abc0f370 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, nil) + return NewCommitStore(ctx, cfg, stateWAL) } // 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/hashlog/flatkv_listener_test.go b/sei-db/state_db/sc/hashlog/flatkv_listener_test.go new file mode 100644 index 0000000000..a938d3ee5e --- /dev/null +++ b/sei-db/state_db/sc/hashlog/flatkv_listener_test.go @@ -0,0 +1,104 @@ +package hashlog + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" +) + +// flatKVTestHashTypes names the columns a flatKV store's hashes go into. Spelled out rather than +// taken from flatkv.HashTypes, which this package cannot import: a column name that drifts from the +// store's own list has to fail here rather than agree with it by construction. +func flatKVTestHashTypes() []string { + return []string{ + FlatKVRootHashType, + FlatKVDBHashPrefix + "account", + FlatKVDBHashPrefix + "code", + FlatKVDBHashPrefix + "storage", + FlatKVDBHashPrefix + "misc", + } +} + +// distinctLtHash returns an LtHash unlike any other seed's, so that a hash recorded under the wrong +// column is visible rather than matching by accident. +func distinctLtHash(t *testing.T, seed byte) *lthash.LtHash { + t.Helper() + hash, err := lthash.Unmarshal(bytes.Repeat([]byte{seed}, lthash.LtHashBytes)) + require.NoError(t, err) + return hash +} + +// checksumOf returns an LtHash's checksum as a slice, which is the form a hash is recorded in. +func checksumOf(hash *lthash.LtHash) []byte { + checksum := hash.Checksum() + return checksum[:] +} + +// flatKVBlockHash returns a block hash with a distinct root and a distinct hash for each of flatKV's +// data databases. +func flatKVBlockHash(t *testing.T, blockNumber int64) *lthash.BlockHash { + t.Helper() + return <hash.BlockHash{ + BlockNumber: blockNumber, + Global: distinctLtHash(t, 0x01), + PerDB: map[string]*lthash.LtHash{ + "account": distinctLtHash(t, 0x10), + "code": distinctLtHash(t, 0x11), + "storage": distinctLtHash(t, 0x12), + "misc": distinctLtHash(t, 0x13), + }, + } +} + +// The listener's whole job, checked through a real logger and read back off disk: every column the +// node declares holds the hash it names. +// +// A column left empty is what a mismatch between FlatKVHashTypes and what the store publishes looks +// like, and an empty column is worse than a missing one — it reads as a hash of nothing. +func TestTheListenerFillsEveryColumn(t *testing.T) { + const block = 7 + + archiveDir := t.TempDir() + cfg := DefaultHashLoggerConfig(archiveDir, "flatkv-listener-test") + cfg.HashTypes = flatKVTestHashTypes() + hl, err := NewHashLogger(cfg) + require.NoError(t, err) + + hash := flatKVBlockHash(t, block) + require.NoError(t, hl.HashListener(t.Context(), block, hash)) + + // The changeset column is the logger's own, and a block is only written once every column has an + // answer. A nil changeset is how a caller that has none completes the block. + hl.ReportChangeset(block, nil) + require.NoError(t, hl.Close()) + + reports, err := ReadHashForBlock(archiveDir, block) + require.NoError(t, err) + require.Len(t, reports, 1) + + recorded := reports[0].Hashes + for _, hashType := range flatKVTestHashTypes() { + require.NotEmpty(t, recorded[hashType], "column %s holds no hash", hashType) + } + require.Equal(t, checksumOf(hash.Global), recorded[FlatKVRootHashType]) + for dataDB, dbHash := range hash.PerDB { + require.Equal(t, checksumOf(dbHash), recorded[FlatKVDBHashPrefix+dataDB], + "column for %s holds another database's hash", dataDB) + } +} + +// A refusal is reported rather than swallowed: the store registering this listener is what decides +// what a failed hash log costs it. +func TestTheListenerReportsALoggerThatRefuses(t *testing.T) { + cfg := DefaultHashLoggerConfig(t.TempDir(), "flatkv-listener-test") + cfg.HashTypes = flatKVTestHashTypes() + hl, err := NewHashLogger(cfg) + require.NoError(t, err) + require.NoError(t, hl.Close()) + + err = hl.HashListener(t.Context(), 1, flatKVBlockHash(t, 1)) + require.ErrorContains(t, err, "closed") +} diff --git a/sei-db/state_db/sc/hashlog/hash_logger.go b/sei-db/state_db/sc/hashlog/hash_logger.go index 8c969a21f2..0ab4b5a4ef 100644 --- a/sei-db/state_db/sc/hashlog/hash_logger.go +++ b/sei-db/state_db/sc/hashlog/hash_logger.go @@ -1,6 +1,11 @@ package hashlog -import "github.com/sei-protocol/sei-chain/sei-db/proto" +import ( + "context" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" +) // Logs the hash of each block. // @@ -65,6 +70,13 @@ type HashLogger interface { // rather than skipping the call, so that the block can still be completed. ReportHash(blockNumber uint64, hashType string, hash []byte) error + // Report one block's flatKV hashes: the store-wide root and each data database's root. The + // signature matches giga.HashListener, so this method registers as one directly. + // + // The columns reported here are fixed, and a node declares them when it constructs the logger + // (see HashLoggerConfig.HashTypes). Nothing registers a column per block. + HashListener(ctx context.Context, blockNumber int64, hash *lthash.BlockHash) error + // Shut down the HashLogger and release any resources. Flushes pending writes before returning. Only blocks // that are complete (a hash has been reported for every configured type) are written; a block still missing a // hash type at shutdown is discarded rather than written as a partial record. 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 040958b93d..358d84ef0b 100644 --- a/sei-db/state_db/sc/hashlog/hash_logger_impl.go +++ b/sei-db/state_db/sc/hashlog/hash_logger_impl.go @@ -12,6 +12,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" "github.com/sei-protocol/seilog" ) @@ -19,6 +20,14 @@ var _ HashLogger = (*hashLoggerImpl)(nil) var logger = seilog.NewLogger("db", "state-db", "sc", "hashlog") +// Column names for the hashes a flatKV store publishes: the store-wide root, and one per data +// database, formed by joining the prefix with that database's name. flatkv.HashTypes builds the list +// a node declares from these. +const ( + FlatKVRootHashType = "flatKV/root" + FlatKVDBHashPrefix = "flatKV/db/" +) + // The kind of message sent to the control loop. type controlMsgKind int @@ -436,6 +445,25 @@ func (h *hashLoggerImpl) ReportHash(blockNumber uint64, hashType string, hash [] return nil } +// HashListener records one block's flatKV hashes: the store-wide root and each data database's root. +// Its signature is giga.HashListener, so it registers as one directly: +// stateDB.RegisterHashListener(hashLogger.HashListener). +func (h *hashLoggerImpl) HashListener(_ context.Context, blockNumber int64, hash *lthash.BlockHash) error { + block := uint64(blockNumber) //nolint:gosec // commit versions are non-negative + + root := hash.Global.Checksum() + if err := h.ReportHash(block, FlatKVRootHashType, root[:]); err != nil { + return fmt.Errorf("record the flatkv root hash of block %d: %w", block, err) + } + for dataDB, dbHash := range hash.PerDB { + checksum := dbHash.Checksum() + if err := h.ReportHash(block, FlatKVDBHashPrefix+dataDB, checksum[:]); err != nil { + return fmt.Errorf("record the flatkv %s hash of block %d: %w", dataDB, block, err) + } + } + return nil +} + func (h *hashLoggerImpl) Close() error { h.closeOnce.Do(func() { // Reject further Report* calls (best effort; senderCtx is the real backstop). diff --git a/sei-db/state_db/sc/hashlog/noop_hash_logger.go b/sei-db/state_db/sc/hashlog/noop_hash_logger.go index 75a99647df..8b097340ec 100644 --- a/sei-db/state_db/sc/hashlog/noop_hash_logger.go +++ b/sei-db/state_db/sc/hashlog/noop_hash_logger.go @@ -1,6 +1,11 @@ package hashlog -import "github.com/sei-protocol/sei-chain/sei-db/proto" +import ( + "context" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" +) var _ HashLogger = (*noOpHashLogger)(nil) @@ -32,6 +37,11 @@ func (n *noOpHashLogger) ReportHash(uint64, string, []byte) error { return nil } +func (n *noOpHashLogger) HashListener(context.Context, int64, *lthash.BlockHash) error { + // intentional no-op + return nil +} + func (n *noOpHashLogger) Close() error { // intentional no-op return nil diff --git a/sei-db/state_db/sc/memiavl/hashlog_test.go b/sei-db/state_db/sc/memiavl/hashlog_test.go index 55627a932d..8d33c9703c 100644 --- a/sei-db/state_db/sc/memiavl/hashlog_test.go +++ b/sei-db/state_db/sc/memiavl/hashlog_test.go @@ -1,11 +1,13 @@ package memiavl import ( + "context" "testing" "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/lthash" ) // captureLogger is a HashLogger test double that records registered categories and reported hashes. @@ -35,6 +37,10 @@ func (c *captureLogger) ReportHash(_ uint64, hashType string, hash []byte) error func (c *captureLogger) ReportChangeset(uint64, []*proto.NamedChangeSet) {} +// HashListener is unused here: memIAVL reports its hashes synchronously through RecordHashes, and a +// listener is for the store that publishes hashes asynchronously. +func (c *captureLogger) HashListener(context.Context, int64, *lthash.BlockHash) error { return nil } + func (c *captureLogger) Close() error { return nil } func TestMemIAVLHashReporting(t *testing.T) { 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 dff4e189cb..38bf2c103e 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, nil) + s, err := flatkv.NewCommitStore(t.Context(), cfg, stateWAL) if err != nil { t.Fatalf("NewTestFlatKVCommitStore: NewCommitStore: %v", err) } diff --git a/sei-db/tools/cmd/seidb/operations/flatkv_open.go b/sei-db/tools/cmd/seidb/operations/flatkv_open.go index 6e76d40ee8..c6719c2c3b 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, nil) + primary, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL) 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 603f7024d2..c811377d84 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, nil) + store, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL) 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 3f92e5421f..f31b489b99 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, nil) + s, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL) 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 6a8674a2bc..1273580f10 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, nil) + store, err := flatkv.NewCommitStore(ctx, cfg, stateWAL) 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 ac61b8c5b6..1c605babdc 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, nil) + store, err := flatkv.NewCommitStore(context.Background(), cfg, stateWAL) require.NoError(t, err) err = store.LoadLatest() require.NoError(t, err) From 483446cfe6145936a15d6e68d011717354f56668 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 8 Sep 2026 14:27:01 -0500 Subject: [PATCH 07/19] bugfix --- sei-db/state_db/sc/composite/store.go | 8 +++----- sei-db/state_db/sc/flatkv/hash_listeners_test.go | 8 +++++--- sei-db/state_db/sc/flatkv/store.go | 14 ++++++-------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index 15b614e808..4df2efa941 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -226,8 +226,8 @@ func NewCompositeCommitStore( return store, nil } -// adoptFlatKV installs store as this composite's flatKV backend, registering the listener that keeps -// the hash the commit path reads current and seeding it with the hash that registration reports. +// adoptFlatKV installs store as this composite's flatKV backend and starts tracking the hash it +// publishes for each block. func (cs *CompositeCommitStore) adoptFlatKV(store giga.LiveStateStore) error { cs.flatKV = store @@ -235,9 +235,7 @@ func (cs *CompositeCommitStore) adoptFlatKV(store giga.LiveStateStore) error { if err != nil { return fmt.Errorf("failed to register the flatkv hash listener: %w", err) } - // Stored only if the listener has not run yet. Registration and the hash it returns are atomic - // against dispatch, so a block dispatched after it is newer than this seed and must win. - cs.flatKVHash.CompareAndSwap(nil, &mostRecent) + cs.flatKVHash.Store(&mostRecent) return nil } diff --git a/sei-db/state_db/sc/flatkv/hash_listeners_test.go b/sei-db/state_db/sc/flatkv/hash_listeners_test.go index 86152842e5..900afa1dcf 100644 --- a/sei-db/state_db/sc/flatkv/hash_listeners_test.go +++ b/sei-db/state_db/sc/flatkv/hash_listeners_test.go @@ -183,8 +183,9 @@ func TestANilHashListenerRegistersNothing(t *testing.T) { // than to that pipeline, so they survive it — a rollback that silently dropped a listener would leave // whatever it feeds frozen at the pre-rollback height. // -// A rollback re-delivers the heights it replays on its way back to the target, so the listener sees -// them a second time. Nothing observes that in practice — see RegisterHashListener. +// A rollback re-announces the height it restarts hashing from and re-delivers what it replays on the +// way back to the target, so the listener sees those heights a second time. Nothing observes that in +// practice — see RegisterHashListener. func TestRegistrationsSurviveARollback(t *testing.T) { s := setupTestStoreWithConfig(t, tightHashPipelineConfig(t)) defer func() { require.NoError(t, s.Close()) }() @@ -202,7 +203,8 @@ func TestRegistrationsSurviveARollback(t *testing.T) { commitBlocks(t, s, 2) require.NoError(t, s.FlushHashes()) - require.Equal(t, []int64{1, 2, 3, 4, 5, 1, 2, 3, 4, 5}, *seen, + // 0 is the height the rollback reopened at, then 1 to 3 are replayed, then 4 and 5 re-executed. + require.Equal(t, []int64{1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5}, *seen, "the listener registered before the rollback must still be given the blocks after it") } diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index 5c944f7fc0..3a54f1a53e 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -1114,6 +1114,12 @@ func (s *CommitStore) startHashing() error { s.config.FinalizationQueueSize, s.hashListeners, ) + + // The listeners are told the height hashing starts from, since a store that opened, seeded or + // rolled back reached it without any block being committed. + if err := s.hashListeners.dispatch(s.ctx, s.loadedHashes); err != nil { + return fmt.Errorf("dispatch the loaded hash of block %d: %w", s.loadedHashes.BlockNumber, err) + } return nil } @@ -1233,14 +1239,6 @@ func (s *CommitStore) PublishedHash() *lthash.BlockHash { // RegisterHashListener registers a callback the store hands the hash of each committed block to: // exactly one per block, in block order, with no gaps or duplicates. It reports the most recent hash // dispatched, which is the block the listener's first delivery follows. -// -// The hash reported is the height the store stands at until a block has been dispatched. A nil -// listener registers nothing and only reports that hash, which is how a caller that wants the height -// and no deliveries asks for it. A read-only store takes a listener and never calls it: it hashes -// only inside the call that builds it. -// -// A rollback re-executes heights, so across one the reported hash can sit ahead of the listener's -// next delivery and hashes arrive out of order. Runtime rollback is scheduled for deprecation. func (s *CommitStore) RegisterHashListener(listener giga.HashListener) (lthash.BlockHash, error) { return s.hashListeners.register(listener, s.PublishedHash()), nil } From 2e8b204f3c30c8961d60f317b4991b09b7f6ffd1 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 8 Sep 2026 14:43:33 -0500 Subject: [PATCH 08/19] fix flush deadlock --- .../state_db/sc/flatkv/lthash/hash_engine.go | 13 +++++++-- .../sc/flatkv/lthash/hash_engine_test.go | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) 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 e6da0e9fe1..82fe629e81 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_engine.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine.go @@ -40,6 +40,10 @@ type HashEngine struct { // combiner sums each block's leaf hashes onto the block before it, and owns the running state. combiner *hashCombiner + // ctx is cancelled when the engine is stopping, to release a caller waiting on work it will no + // longer reach. + ctx context.Context + // cancel stops the gatherer and the combiner. Called by Close, and by the store's own context. cancel context.CancelFunc @@ -79,7 +83,7 @@ func NewHashEngine( } ctx, cancel := context.WithCancel(parent) - he := &HashEngine{cancel: cancel} + he := &HashEngine{ctx: ctx, 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) @@ -131,7 +135,12 @@ func (he *HashEngine) Flush() error { if err := he.enqueue(request); err != nil { return fmt.Errorf("flush hash engine: %w", err) } - <-request.doneChan + select { + case <-request.doneChan: + case <-he.ctx.Done(): + // A stopping engine never reaches this request. The blocks behind it are abandoned rather than + // hashed, which Close reports, and their rows are still in the WAL for replay to recover. + } if err := he.errorIfBricked(); err != nil { return fmt.Errorf("flush hash engine: %w", err) } 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 index d0d0c34468..29864bd09c 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_engine_test.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "testing" + "time" "github.com/stretchr/testify/require" @@ -357,3 +358,31 @@ func TestHashEngineRefusesWorkAfterFailure(t *testing.T) { } require.ErrorContains(t, engine.Close(), "injected diff failure") } + +// A flush waits to be told the engine has dealt with everything queued ahead of it, which a stopping +// engine never will. Its caller has to be released by the shutdown rather than left parked. +func TestFlushReturnsOnceTheEngineIsStopped(t *testing.T) { + pool := threading.NewFixedPool("lthash-flush-shutdown-test", 4, 64) + t.Cleanup(pool.Close) + + // The engine gets its own context so the test can stop it; newTestEngine ties one to t.Context(). + ctx, cancel := context.WithCancel(t.Context()) + engine, err := NewHashEngine( + ctx, DefaultConfig(), pool, engineDBNames, engineModuleOf, NewBlockHash(engineDBNames)) + require.NoError(t, err) + + current, previous, _ := blockViews(t, 1, blockDiff(1, 4), nil) + require.NoError(t, engine.ScheduleHash(current, previous)) + + cancel() + + flushed := make(chan error, 1) + go func() { flushed <- engine.Flush() }() + + select { + case err := <-flushed: + require.NoError(t, err, "a flush released by shutdown reports no failure of its own") + case <-time.After(30 * time.Second): + t.Fatal("Flush never returned after the engine was stopped") + } +} From 230bc0999b5c19ed93c24839939b3ca449089dab Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 8 Sep 2026 14:51:58 -0500 Subject: [PATCH 09/19] fix bug --- .../sc/composite/flatkv_hash_log_test.go | 68 +++++++++++++++++++ sei-db/state_db/sc/composite/hashlog.go | 15 ++-- sei-db/state_db/sc/composite/store.go | 10 ++- 3 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 sei-db/state_db/sc/composite/flatkv_hash_log_test.go diff --git a/sei-db/state_db/sc/composite/flatkv_hash_log_test.go b/sei-db/state_db/sc/composite/flatkv_hash_log_test.go new file mode 100644 index 0000000000..52aecc456e --- /dev/null +++ b/sei-db/state_db/sc/composite/flatkv_hash_log_test.go @@ -0,0 +1,68 @@ +package composite + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/common/keys" + "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/flatkv" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" +) + +// flatKV hashes asynchronously and this store is the only consumer of those hashes on the Cosmos +// path, so the hash log gets them here or nowhere. The columns are compared against the ones this +// store declares, since a hash written under a name nothing declares is a column no reader looks at, +// and a declared column nothing writes holds every block out of the archive. +func TestFlatKVHashesReachTheHashLog(t *testing.T) { + const blocks = 2 + + archiveDir := t.TempDir() + loggerConfig := hashlog.DefaultHashLoggerConfig(archiveDir, "composite-hash-log-test") + loggerConfig.HashTypes = flatkv.HashTypes() + hl, err := hashlog.NewHashLogger(loggerConfig) + require.NoError(t, err) + + cfg := config.DefaultStateCommitConfig() + cfg.WriteMode = types.FlatKVOnly + + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, hl) + require.NoError(t, err) + require.NoError(t, cs.LoadLatest()) + defer func() { _ = cs.Close() }() + + categories := cs.HashCategories() + require.Equal(t, flatkv.HashTypes(), categories, + "FlatKVOnly declares flatKV's columns and nothing else") + + for height := int64(1); height <= blocks; height++ { + require.NoError(t, cs.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ + {Key: []byte("key"), Value: []byte{byte(height)}}, + }}}, + })) + committed, err := cs.Commit(height) + require.NoError(t, err) + require.Equal(t, height, committed) + + // The changeset column is the logger's own and only the caller can supply it. Without it no + // block is complete and none reaches disk. baseapp plays this part in production. + hl.ReportChangeset(uint64(height), nil) + } + + 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 categories { + require.NotEmpty(t, reports[0].Hashes[category], + "block %d recorded no %s hash", height, category) + } + } +} diff --git a/sei-db/state_db/sc/composite/hashlog.go b/sei-db/state_db/sc/composite/hashlog.go index bd8721cd02..c579d45dc6 100644 --- a/sei-db/state_db/sc/composite/hashlog.go +++ b/sei-db/state_db/sc/composite/hashlog.go @@ -2,21 +2,22 @@ package composite import ( "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" ) -// HashCategories returns memIAVL's hash logger categories, or nothing when memIAVL is absent, so the -// set tracks whether it is active (used upstream to detect when the logger's category set must -// change). Note: the memIAVL root ("memIAVL/root") is not included here — it is a simple-merkle -// aggregation owned by the cosmos layer (see MemIAVLCommitInfo). -// -// flatKV is absent: this store records its hashes for the AppHash rather than logging them, so a -// flatKV column here would be one nothing reports, and a block missing a column is never written. +// HashCategories returns the union of the live backends' hash logger categories. An absent backend +// contributes nothing, so the set tracks which backends are active (used upstream to detect when the +// logger's category set must change). Note: the memIAVL root ("memIAVL/root") is not included +// here — it is a simple-merkle aggregation owned by the cosmos layer (see MemIAVLCommitInfo). func (cs *CompositeCommitStore) HashCategories() []string { var categories []string if cs.memIAVL != nil { categories = append(categories, cs.memIAVL.HashCategories()...) } + if cs.flatKV != nil { + categories = append(categories, flatkv.HashTypes()...) + } return categories } diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index 4df2efa941..5722183d94 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -75,7 +75,7 @@ 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 records flatKV's per-block hashes. Never nil. hashLogger hashlog.HashLogger // currentWriteMode is the write mode actually driving routing and @@ -226,8 +226,8 @@ func NewCompositeCommitStore( return store, nil } -// adoptFlatKV installs store as this composite's flatKV backend and starts tracking the hash it -// publishes for each block. +// adoptFlatKV installs store as this composite's flatKV backend, starts tracking the hash it +// publishes for each block, and puts those hashes on the hash log. func (cs *CompositeCommitStore) adoptFlatKV(store giga.LiveStateStore) error { cs.flatKV = store @@ -236,6 +236,10 @@ func (cs *CompositeCommitStore) adoptFlatKV(store giga.LiveStateStore) error { return fmt.Errorf("failed to register the flatkv hash listener: %w", err) } cs.flatKVHash.Store(&mostRecent) + + if _, err := store.RegisterHashListener(cs.hashLogger.HashListener); err != nil { + return fmt.Errorf("failed to register the flatkv hash log listener: %w", err) + } return nil } From 8949ec442bcfd9f863b207b599d6362344eeba5a Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 8 Sep 2026 15:28:05 -0500 Subject: [PATCH 10/19] cleanup --- sei-cosmos/storev2/rootmulti/flatkv_workload_test.go | 4 +++- sei-db/state_db/giga/types/live_state_store.go | 11 +++-------- sei-db/state_db/sc/composite/store_test.go | 7 +++++-- sei-db/state_db/sc/flatkv/store.go | 12 ++++-------- sei-db/state_db/sc/flatkv/store_write.go | 4 ++-- sei-db/state_db/sc/flatkv/test_verify.go | 2 +- sei-db/state_db/sc/flatkv/testutil_test.go | 9 +++++++-- sei-db/tools/cmd/seidb/operations/dump_flatkv.go | 8 ++++++-- 8 files changed, 31 insertions(+), 26 deletions(-) diff --git a/sei-cosmos/storev2/rootmulti/flatkv_workload_test.go b/sei-cosmos/storev2/rootmulti/flatkv_workload_test.go index cab7a35e7a..3a9ea0da8c 100644 --- a/sei-cosmos/storev2/rootmulti/flatkv_workload_test.go +++ b/sei-cosmos/storev2/rootmulti/flatkv_workload_test.go @@ -57,7 +57,9 @@ func TestFlatKVFullScanLtHashVerification(t *testing.T) { require.NoError(t, flatkv.VerifyLtHash(ro), "full-scan LtHash verification failed") - roHash := ro.PublishedHash().Global.Checksum() + current, err := ro.RegisterHashListener(nil) + require.NoError(t, err) + roHash := current.Global.Checksum() require.Equal(t, expectedLatticeHash, roHash[:], "flatkv's published root should match evm_lattice in CommitInfo") } diff --git a/sei-db/state_db/giga/types/live_state_store.go b/sei-db/state_db/giga/types/live_state_store.go index 3496f88c63..ca605087fe 100644 --- a/sei-db/state_db/giga/types/live_state_store.go +++ b/sei-db/state_db/giga/types/live_state_store.go @@ -122,12 +122,6 @@ type LiveStateStore interface { ascending bool, ) (dbm.Iterator, error) - // 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 - // RegisterHashListener registers a callback that gets called for each hash the store produces: // exactly one per block committed, in block order, with no gaps or duplicates. Returning an error // from the listener bricks the store, and every later call reports that error. @@ -136,8 +130,9 @@ type LiveStateStore interface { // the first hash the listener observes is for block N, the mostRecentHash returned will have been // block N-1. A nil listener registers nothing and only reports that hash. // - // A store that hashes only in order to replay its way to a height — a read-only store — refuses, - // since a listener there would never be called. PublishedHash is that caller's answer. + // A read-only store takes a listener and never calls it: it hashes only inside the call that + // builds it. The hash it reports is the height it was opened at, which is what such a caller is + // after. RegisterHashListener(listener HashListener) (mostRecentHash lthash.BlockHash, err error) // FlushHashes blocks until every block committed so far has been hashed and its hash handed to diff --git a/sei-db/state_db/sc/composite/store_test.go b/sei-db/state_db/sc/composite/store_test.go index 87d0bb0df7..3c0ea9b80a 100644 --- a/sei-db/state_db/sc/composite/store_test.go +++ b/sei-db/state_db/sc/composite/store_test.go @@ -55,7 +55,6 @@ 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) RegisterHashListener(gigatypes.HashListener) (lthash.BlockHash, error) { return lthash.BlockHash{}, fmt.Errorf("flatkv unavailable") } @@ -78,7 +77,11 @@ func flatKVRootHash(cs *CompositeCommitStore) []byte { if err := cs.flatKV.FlushHashes(); err != nil { panic(fmt.Sprintf("composite: flush flatkv hashes: %v", err)) } - checksum := cs.flatKV.PublishedHash().Global.Checksum() + current, err := cs.flatKV.RegisterHashListener(nil) + if err != nil { + panic(fmt.Sprintf("composite: read the flatkv hash: %v", err)) + } + checksum := current.Global.Checksum() return checksum[:] } diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index 0c128c2d27..017ff210f3 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -1220,13 +1220,9 @@ func (s *CommitStore) PendingVersion() int64 { return s.pendingBlockHeight } -// PublishedHash returns the most recent block hash the store has published: its height, its lattice -// hash root, and each database's root. -// -// 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 { +// currentHash returns the hash of the height this store stands at: what the pipeline has reached on +// a committing store, and what was loaded before it has hashed anything. +func (s *CommitStore) currentHash() *lthash.BlockHash { s.mu.RLock() defer s.mu.RUnlock() @@ -1240,7 +1236,7 @@ func (s *CommitStore) PublishedHash() *lthash.BlockHash { // exactly one per block, in block order, with no gaps or duplicates. It reports the most recent hash // dispatched, which is the block the listener's first delivery follows. func (s *CommitStore) RegisterHashListener(listener gigatypes.HashListener) (lthash.BlockHash, error) { - return s.hashListeners.register(listener, s.PublishedHash()), nil + return s.hashListeners.register(listener, s.currentHash()), nil } // FlushHashes blocks until every block committed so far has been hashed and its hash handed to every diff --git a/sei-db/state_db/sc/flatkv/store_write.go b/sei-db/state_db/sc/flatkv/store_write.go index 9a35bf6f81..754a661c3a 100644 --- a/sei-db/state_db/sc/flatkv/store_write.go +++ b/sei-db/state_db/sc/flatkv/store_write.go @@ -102,8 +102,8 @@ func (s *CommitStore) Commit(version int64) (committed int64, err error) { } // 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. + // hash is not part of this: it is computed and recorded asynchronously, and handed to the + // registered listeners once it is. s.committedVersion = version // Step 4: Clear per-block bookkeeping diff --git a/sei-db/state_db/sc/flatkv/test_verify.go b/sei-db/state_db/sc/flatkv/test_verify.go index 10c013184f..1b25c18c02 100644 --- a/sei-db/state_db/sc/flatkv/test_verify.go +++ b/sei-db/state_db/sc/flatkv/test_verify.go @@ -51,7 +51,7 @@ func verifyLtHashInternal(cs *CommitStore) error { } // Read once, so every comparison below describes the same moment. - maintained := cs.PublishedHash() + maintained := cs.currentHash() // Recompute each DB's per-module hashes and stats from disk, validate the // maintained per-module metadata against them, and accumulate the global diff --git a/sei-db/state_db/sc/flatkv/testutil_test.go b/sei-db/state_db/sc/flatkv/testutil_test.go index 0a3ba27859..97f1edfc8e 100644 --- a/sei-db/state_db/sc/flatkv/testutil_test.go +++ b/sei-db/state_db/sc/flatkv/testutil_test.go @@ -202,7 +202,12 @@ func rootHash(s gigatypes.LiveStateStore) []byte { if err := s.FlushHashes(); err != nil { panic(fmt.Sprintf("flatkv: flush hashes before reading the root: %v", err)) } - checksum := s.PublishedHash().Global.Checksum() + // A nil listener asks the store for the height it stands at without subscribing to anything. + current, err := s.RegisterHashListener(nil) + if err != nil { + panic(fmt.Sprintf("flatkv: read the current hash: %v", err)) + } + checksum := current.Global.Checksum() return checksum[:] } @@ -220,7 +225,7 @@ 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() + return s.currentHash() } // ---------- helpers to build prefix-encoded changeset pairs ---------- diff --git a/sei-db/tools/cmd/seidb/operations/dump_flatkv.go b/sei-db/tools/cmd/seidb/operations/dump_flatkv.go index c7d25c401c..499f832517 100644 --- a/sei-db/tools/cmd/seidb/operations/dump_flatkv.go +++ b/sei-db/tools/cmd/seidb/operations/dump_flatkv.go @@ -389,8 +389,12 @@ 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 gigatypes.LiveStateStore, hashers map[string]*bucketLtHasher) error { - // A dump reads a store at rest, so the published hash already describes everything it holds. - published := store.PublishedHash() + // A dump reads a store at rest, so the height the store stands at already describes everything it + // holds. A nil listener asks for it without subscribing to anything. + published, err := store.RegisterHashListener(nil) + if err != nil { + return fmt.Errorf("read the flatkv hash: %w", err) + } committedChecksum := published.Global.Checksum() committedTotal := committedChecksum[:] From 14ba2330fa35a5e11e30576184e09e174b8d83bd Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 8 Sep 2026 15:49:10 -0500 Subject: [PATCH 11/19] fix bug --- sei-cosmos/storev2/rootmulti/store.go | 4 +- .../bench/wrappers/db_implementations.go | 2 +- .../sc/composite/commit_info_stored_test.go | 2 +- .../sc/composite/flatkv_hash_log_test.go | 75 +++++++++++- sei-db/state_db/sc/composite/hashlog.go | 21 +++- .../composite/random_test_framework_test.go | 4 +- sei-db/state_db/sc/composite/store.go | 40 ++----- .../state_db/sc/composite/store_auto_test.go | 12 +- .../sc/composite/store_init_repair_test.go | 2 +- .../state_db/sc/composite/store_load_test.go | 4 +- .../sc/composite/store_migration_test.go | 22 ++-- sei-db/state_db/sc/composite/store_test.go | 112 +++++++++--------- 12 files changed, 180 insertions(+), 120 deletions(-) diff --git a/sei-cosmos/storev2/rootmulti/store.go b/sei-cosmos/storev2/rootmulti/store.go index a413f2635f..2544784ba0 100644 --- a/sei-cosmos/storev2/rootmulti/store.go +++ b/sei-cosmos/storev2/rootmulti/store.go @@ -126,8 +126,6 @@ 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 { @@ -141,7 +139,7 @@ func NewStore( } ctx := context.Background() - scStore, err := composite.NewCompositeCommitStore(ctx, scDir, scConfig, hashLogger) + scStore, err := composite.NewCompositeCommitStore(ctx, scDir, scConfig) if err != nil { panic(err) } diff --git a/sei-db/state_db/bench/wrappers/db_implementations.go b/sei-db/state_db/bench/wrappers/db_implementations.go index de629e14ad..c17e2b3af2 100644 --- a/sei-db/state_db/bench/wrappers/db_implementations.go +++ b/sei-db/state_db/bench/wrappers/db_implementations.go @@ -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, nil) + cs, err := composite.NewCompositeCommitStore(ctx, dbDir, cfg) if err != nil { return nil, fmt.Errorf("failed to create composite commit store: %w", err) } 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 5b6041c275..b3e8e37bda 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(), nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, storedInfoConfig()) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) require.NoError(t, cs.LoadLatest()) diff --git a/sei-db/state_db/sc/composite/flatkv_hash_log_test.go b/sei-db/state_db/sc/composite/flatkv_hash_log_test.go index 52aecc456e..c8eeacfef6 100644 --- a/sei-db/state_db/sc/composite/flatkv_hash_log_test.go +++ b/sei-db/state_db/sc/composite/flatkv_hash_log_test.go @@ -17,6 +17,10 @@ import ( // path, so the hash log gets them here or nowhere. The columns are compared against the ones this // store declares, since a hash written under a name nothing declares is a column no reader looks at, // and a declared column nothing writes holds every block out of the archive. +// +// The hashes go in through RecordHashes rather than a listener, which is what keeps a row complete: +// a listener also hears the heights flatKV reaches while opening and replaying, which no block of +// this archive has the rest of the columns for. func TestFlatKVHashesReachTheHashLog(t *testing.T) { const blocks = 2 @@ -29,7 +33,7 @@ func TestFlatKVHashesReachTheHashLog(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, hl) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.LoadLatest()) defer func() { _ = cs.Close() }() @@ -48,8 +52,9 @@ func TestFlatKVHashesReachTheHashLog(t *testing.T) { require.NoError(t, err) require.Equal(t, height, committed) - // The changeset column is the logger's own and only the caller can supply it. Without it no - // block is complete and none reaches disk. baseapp plays this part in production. + // Both of these are rootmulti's part in production, right after Commit: the backends' hashes, + // and the changeset column, which is the logger's own and only a caller can supply. + require.NoError(t, cs.RecordHashes(hl, uint64(height))) hl.ReportChangeset(uint64(height), nil) } @@ -66,3 +71,67 @@ func TestFlatKVHashesReachTheHashLog(t *testing.T) { } } } + +// A store reaching a height is not a block being committed. flatKV announces the height it starts +// hashing from and every block it replays, and a row for one of those would carry flatKV's columns +// and nothing else — the app hash, the changeset and the rest only exist for a block the node +// commits. Such a row reads back as a divergence, which is why nothing may be written for it. +func TestReopeningWritesNoRowForTheLoadedHeight(t *testing.T) { + dir := t.TempDir() + cfg := config.DefaultStateCommitConfig() + cfg.WriteMode = types.FlatKVOnly + + first, err := NewCompositeCommitStore(t.Context(), dir, cfg) + require.NoError(t, err) + require.NoError(t, first.LoadLatest()) + commitOneRecordedBlock(t, first, 1, nil) + require.NoError(t, first.Close()) + + // A second logger over the same archive, as a restart produces. + archiveDir := t.TempDir() + loggerConfig := hashlog.DefaultHashLoggerConfig(archiveDir, "composite-reopen-test") + loggerConfig.HashTypes = flatkv.HashTypes() + // A row missing columns is written only when the buffer overflows, so the bound is dropped to + // where one extra pending block reaches it. At the default a restart hides the row until a + // thousand blocks later, and a clean shutdown discards it instead. + loggerConfig.MaxBufferedBlocks = 1 + hl, err := hashlog.NewHashLogger(loggerConfig) + require.NoError(t, err) + + reopened, err := NewCompositeCommitStore(t.Context(), dir, cfg) + require.NoError(t, err) + require.NoError(t, reopened.LoadLatest()) + defer func() { _ = reopened.Close() }() + require.Equal(t, int64(1), reopened.Version(), "the reopened store stands on the block committed above") + + commitOneRecordedBlock(t, reopened, 2, hl) + require.NoError(t, hl.Close()) + + loaded, err := hashlog.ReadHashForBlock(archiveDir, 1) + require.NoError(t, err) + require.Empty(t, loaded, "the height the store reopened at is not a block this run committed") + + committed, err := hashlog.ReadHashForBlock(archiveDir, 2) + require.NoError(t, err) + require.Len(t, committed, 1, "the block this run committed is recorded") +} + +// commitOneRecordedBlock commits one block writing a single EVM key, recording it on hl when one is +// given, the way rootmulti does right after Commit. +func commitOneRecordedBlock(t *testing.T, cs *CompositeCommitStore, height int64, hl hashlog.HashLogger) { + t.Helper() + require.NoError(t, cs.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ + {Key: []byte("key"), Value: []byte{byte(height)}}, + }}}, + })) + committed, err := cs.Commit(height) + require.NoError(t, err) + require.Equal(t, height, committed) + + if hl == nil { + return + } + require.NoError(t, cs.RecordHashes(hl, uint64(height))) + hl.ReportChangeset(uint64(height), nil) +} diff --git a/sei-db/state_db/sc/composite/hashlog.go b/sei-db/state_db/sc/composite/hashlog.go index c579d45dc6..1543591941 100644 --- a/sei-db/state_db/sc/composite/hashlog.go +++ b/sei-db/state_db/sc/composite/hashlog.go @@ -1,6 +1,8 @@ package composite import ( + "fmt" + "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashlog" @@ -21,12 +23,23 @@ func (cs *CompositeCommitStore) HashCategories() []string { return categories } -// RecordHashes reports memIAVL's hashes for blockNumber. Call right after Commit. +// RecordHashes reports both backends' hashes for blockNumber. Call right after Commit. func (cs *CompositeCommitStore) RecordHashes(hl hashlog.HashLogger, blockNumber uint64) error { - if cs.memIAVL == nil { - return nil + if cs.memIAVL != nil { + if err := cs.memIAVL.RecordHashes(hl, blockNumber); err != nil { + return err + } + } + if cs.flatKV != nil { + // Keyed on the block cosmos committed rather than the hash's own height, which is what keeps + // this row complete: a block whose writes never reached flatKV leaves its hash on the height + // before, and the AppHash reports that same hash for this block. + //nolint:gosec // commit versions are non-negative + if err := hl.HashListener(cs.ctx, int64(blockNumber), cs.flatKVHash.Load()); err != nil { + return fmt.Errorf("record flatkv hashes for block %d: %w", blockNumber, err) + } } - return cs.memIAVL.RecordHashes(hl, blockNumber) + return nil } // 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 e14adbd13f..4a29ffd81a 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 @@ -1532,7 +1532,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize(keys.MemIAVLStoreKeys)) err = cs.LoadLatest() @@ -1579,7 +1579,7 @@ func stateSyncClone( require.NoError(t, exporter.Close()) dstDir := t.TempDir() - dst, err := NewCompositeCommitStore(t.Context(), dstDir, cfg, nil) + dst, err := NewCompositeCommitStore(t.Context(), dstDir, cfg) require.NoError(t, err) require.NoError(t, dst.Initialize(keys.MemIAVLStoreKeys)) // Open then close the writable handle so the importer takes over a diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index 8404b1806f..fc94ec0050 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -19,7 +19,6 @@ import ( gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "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" "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" @@ -75,9 +74,6 @@ type CompositeCommitStore struct { // config holds the store configuration config config.StateCommitConfig - // hashLogger records flatKV's per-block hashes. Never nil. - 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 @@ -163,18 +159,10 @@ 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) } - if hl == nil { - // Normalized here so that every path that reaches for the listener has a logger to take it - // from, rather than each of them nil-checking. flatKV used to do this on this store's behalf. - hl = hashlog.NewNoOpHashLogger() - } - alignFlatKVSnapshotWithMemIAVL(&cfg) var memIAVL *memiavl.CommitStore @@ -216,7 +204,6 @@ func NewCompositeCommitStore( config: cfg, currentWriteMode: cfg.WriteMode, ctx: ctx, - hashLogger: hl, } if flatKV != nil { if err := store.adoptFlatKV(flatKV); err != nil { @@ -226,8 +213,8 @@ func NewCompositeCommitStore( return store, nil } -// adoptFlatKV installs store as this composite's flatKV backend, starts tracking the hash it -// publishes for each block, and puts those hashes on the hash log. +// adoptFlatKV installs store as this composite's flatKV backend and starts tracking the hash it +// publishes for each block. func (cs *CompositeCommitStore) adoptFlatKV(store gigatypes.LiveStateStore) error { cs.flatKV = store @@ -236,10 +223,6 @@ func (cs *CompositeCommitStore) adoptFlatKV(store gigatypes.LiveStateStore) erro return fmt.Errorf("failed to register the flatkv hash listener: %w", err) } cs.flatKVHash.Store(&mostRecent) - - if _, err := store.RegisterHashListener(cs.hashLogger.HashListener); err != nil { - return fmt.Errorf("failed to register the flatkv hash log listener: %w", err) - } return nil } @@ -500,12 +483,11 @@ func (cs *CompositeCommitStore) LoadVersionReadOnly(targetVersion int64) (_ type // inherits cs.ctx so cancellation of the parent context cascades, but buildRouter installs its own // child cancel so closing this handle does not affect the parent. ro := &CompositeCommitStore{ - memIAVL: memIAVLCommitter, - homeDir: cs.homeDir, - config: cs.config, - ctx: cs.ctx, - hashLogger: cs.hashLogger, - derived: true, + memIAVL: memIAVLCommitter, + homeDir: cs.homeDir, + config: cs.config, + ctx: cs.ctx, + derived: true, } if flatKVStore != nil { if err := ro.adoptFlatKV(flatKVStore); err != nil { @@ -858,11 +840,9 @@ func (cs *CompositeCommitStore) Commit(version int64) (int64, error) { if err != nil { return 0, fmt.Errorf("failed to commit flatkv: %w", err) } - // Taken whether or not this block's hash reaches the AppHash. shouldAppendLatticeHash answers a - // consensus question; taking the hash is a lifecycle obligation of a backend that publishes one. - // flatKV's stream has finite depth and blocks commit once full, so a committing flatKV whose - // hashes nobody reads halts the node. This is the one place every flatKV block commit passes - // through, which is why the obligation is discharged here rather than at the readers below. + // Taken whether or not this block's hash reaches the AppHash: shouldAppendLatticeHash answers a + // consensus question, while this refreshes the hash that both the AppHash below and the hash log + // read. It is done here because this is the one place every flatKV block commit passes through. if _, err := cs.latticeHash(flatkvVersion); err != nil { return 0, fmt.Errorf("failed to obtain flatkv hash for block %d: %w", flatkvVersion, err) } 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 98e4147d45..37f554deb9 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(), nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, autoConfig()) 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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) 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(), nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), autoConfig()) 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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) 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, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) 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(), nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), autoConfig()) 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 dde47fce9c..827ce45a8a 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, nil) + reopened, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) defer func() { _ = reopened.Close() }() require.NoError(t, reopened.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) 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 0f0a41a2f0..bb5a3e944d 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(), nil) + reopened, err := NewCompositeCommitStore(t.Context(), dir, autoExportConfig()) 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, nil) + fresh, err := NewCompositeCommitStore(t.Context(), dir, cfg) 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 c5f4c21983..6616362c91 100644 --- a/sei-db/state_db/sc/composite/store_migration_test.go +++ b/sei-db/state_db/sc/composite/store_migration_test.go @@ -240,7 +240,7 @@ func driveMigrationWorkload( // commit and the post-reopen version checks become flaky. memCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -264,7 +264,7 @@ func driveMigrationWorkload( migCfg.WriteMode = types.MigrateEVM migCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err = NewCompositeCommitStore(t.Context(), dir, migCfg, nil) + cs, err = NewCompositeCommitStore(t.Context(), dir, migCfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(keysToMigratePerBlock)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -292,7 +292,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(batch)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -309,7 +309,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -451,7 +451,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -509,7 +509,7 @@ func TestComposite_MigrateEVM_PruneZeroStorageSlotsDuringMigration(t *testing.T) finalCfg := evmMigratedConfig() finalCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err = NewCompositeCommitStore(t.Context(), dir, finalCfg, nil) + cs, err = NewCompositeCommitStore(t.Context(), dir, finalCfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -703,7 +703,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -792,7 +792,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, memCfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -860,7 +860,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, finalCfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -945,7 +945,7 @@ func openCompositeForRollback( cfg.FlatKVConfig.SnapshotInterval = snap.flatkvInterval cfg.FlatKVConfig.SnapshotKeepRecent = 5 - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(batch)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -1234,7 +1234,7 @@ func TestMigrateEVMBeforeTheBoundaryDrainsTheHashStream(t *testing.T) { cfg.WriteMode = types.MigrateEVM cfg.FlatKVConfig.FinalizationQueueSize = 1 - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) // 0 leaves the migration paused: nothing pulls keys forward, so the boundary metadata that opens the // lattice gate is never written. diff --git a/sei-db/state_db/sc/composite/store_test.go b/sei-db/state_db/sc/composite/store_test.go index 3c0ea9b80a..5956e8bba7 100644 --- a/sei-db/state_db/sc/composite/store_test.go +++ b/sei-db/state_db/sc/composite/store_test.go @@ -95,7 +95,7 @@ func TestCompositeStoreBasicOperations(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -145,7 +145,7 @@ func TestEmptyChangesets(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -167,7 +167,7 @@ func TestLoadVersionCopyExisting(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -205,7 +205,7 @@ func TestWorkingAndLastCommitInfo(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -279,7 +279,7 @@ func TestLatticeHashCommitInfo(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = tt.writeMode - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -431,7 +431,7 @@ func TestMemiavlOnlyToMigrateEVMPreservesLastCommitInfoBeforeFirstCommit(t *test cosmosCfg := config.DefaultStateCommitConfig() cosmosCfg.WriteMode = types.MemiavlOnly - cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg, nil) + cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -468,7 +468,7 @@ func TestMemiavlOnlyToMigrateEVMPreservesLastCommitInfoBeforeFirstCommit(t *test // height. migrateCfg := config.DefaultStateCommitConfig() migrateCfg.WriteMode = types.MigrateEVM - cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(100)) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -512,7 +512,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -548,7 +548,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -596,7 +596,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, nil) + cs1, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs1.SetMigrationBatchSize(1000)) require.NoError(t, cs1.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -629,7 +629,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, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs2.LoadLatest() @@ -645,7 +645,7 @@ func TestRollback(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -682,7 +682,7 @@ func TestGetVersions(t *testing.T) { dir := t.TempDir() cfg := config.DefaultStateCommitConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) @@ -706,7 +706,7 @@ func TestGetVersions(t *testing.T) { } require.NoError(t, cs.Close()) - cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey})) @@ -729,7 +729,7 @@ func TestGetLatestVersionMemiavlOnly(t *testing.T) { // CompositeCommitStore.GetLatestVersion for the full rationale. cfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) err = cs.LoadLatest() @@ -760,7 +760,7 @@ func TestGetLatestVersionFlatKVOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) err = cs.LoadLatest() require.NoError(t, err) @@ -794,7 +794,7 @@ func TestGetLatestVersionBothBackendsAligned(t *testing.T) { // CompositeCommitStore.GetLatestVersion for the full rationale. cfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -833,7 +833,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -873,7 +873,7 @@ func TestLoadVersionFlatKVOnlyReadWrite(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.Nil(t, cs.memIAVL, "FlatKVOnly must not allocate memIAVL") require.NotNil(t, cs.flatKV, "FlatKVOnly must allocate flatKV") @@ -905,7 +905,7 @@ func TestLoadVersionFlatKVOnlyReadOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) err = cs.LoadLatest() require.NoError(t, err) @@ -943,7 +943,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -975,7 +975,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -998,7 +998,7 @@ func TestLoadVersionDoesNotMountMigrationStoreInMemiavlOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey})) err = cs.LoadLatest() @@ -1076,7 +1076,7 @@ func TestExportImportEVMMigrated(t *testing.T) { // --- Source store: write cosmos + EVM data --- srcDir := t.TempDir() - src, err := NewCompositeCommitStore(t.Context(), srcDir, cfg, nil) + src, err := NewCompositeCommitStore(t.Context(), srcDir, cfg) require.NoError(t, err) require.NoError(t, src.Initialize([]string{"bank", keys.EVMStoreKey})) err = src.LoadLatest() @@ -1125,7 +1125,7 @@ func TestExportImportEVMMigrated(t *testing.T) { // --- Destination store: import --- dstDir := t.TempDir() - dst, err := NewCompositeCommitStore(t.Context(), dstDir, cfg, nil) + dst, err := NewCompositeCommitStore(t.Context(), dstDir, cfg) require.NoError(t, err) require.NoError(t, dst.Initialize([]string{"bank", keys.EVMStoreKey})) err = dst.LoadLatest() @@ -1165,7 +1165,7 @@ func TestExportMemiavlOnlyHasNoFlatKVModule(t *testing.T) { cfg.MemIAVLConfig.AsyncCommitBuffer = 0 dir := t.TempDir() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{"bank"})) err = cs.LoadLatest() @@ -1203,7 +1203,7 @@ func TestExporterFailsLoudOnFlatKVLoadFailure(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.MemIAVLConfig.AsyncCommitBuffer = 0 cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -1293,7 +1293,7 @@ func TestReconcileVersionsAfterCrash(t *testing.T) { cfg := evmMigratedConfig() dir := t.TempDir() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1346,7 +1346,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, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs2.LoadLatest() @@ -1372,7 +1372,7 @@ func TestReconcileVersionsThenContinueCommitting(t *testing.T) { cfg := evmMigratedConfig() dir := t.TempDir() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1406,7 +1406,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, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs2.LoadLatest() @@ -1437,7 +1437,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, nil) + cs3, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs3.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs3.LoadLatest() @@ -1468,7 +1468,7 @@ func setupComposite(t *testing.T, writeMode types.WriteMode) *CompositeCommitSto cfg := config.DefaultStateCommitConfig() cfg.WriteMode = writeMode - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.StakingStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1720,7 +1720,7 @@ func TestCompositeEVMMigratedEVMReadsAreVisible(t *testing.T) { dir := t.TempDir() cfg := evmMigratedConfig() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1796,7 +1796,7 @@ func TestReconcileVersionsCosmosAheadByMultiple(t *testing.T) { cfg := evmMigratedConfig() dir := t.TempDir() - cs, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs.LoadLatest() @@ -1840,7 +1840,7 @@ func TestReconcileVersionsCosmosAheadByMultiple(t *testing.T) { require.NoError(t, err) require.NoError(t, evmStore.Close()) - cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, cfg) require.NoError(t, err) require.NoError(t, cs2.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs2.LoadLatest() @@ -1870,7 +1870,7 @@ func TestMigrationEntrySeedingMemiavlToMigrateEVM(t *testing.T) { cosmosCfg := config.DefaultStateCommitConfig() cosmosCfg.WriteMode = types.MemiavlOnly - cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg, nil) + cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -1897,7 +1897,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, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(100)) require.NoError(t, cs2.Initialize([]string{"bank", keys.EVMStoreKey})) @@ -1939,7 +1939,7 @@ func TestMigrateEVMReopenPreservesPreFlipLastCommitInfo(t *testing.T) { memCfg.WriteMode = types.MemiavlOnly memCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs1, err := NewCompositeCommitStore(t.Context(), dir, memCfg, nil) + cs1, err := NewCompositeCommitStore(t.Context(), dir, memCfg) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -1969,7 +1969,7 @@ func TestMigrateEVMReopenPreservesPreFlipLastCommitInfo(t *testing.T) { migrateCfg.WriteMode = types.MigrateEVM migrateCfg.MemIAVLConfig.AsyncCommitBuffer = 0 - cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(1)) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) @@ -2015,7 +2015,7 @@ func TestMigrationEntrySeedingIsIdempotentAcrossRestarts(t *testing.T) { cosmosCfg := config.DefaultStateCommitConfig() cosmosCfg.WriteMode = types.MemiavlOnly - cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg, nil) + cs1, err := NewCompositeCommitStore(t.Context(), dir, cosmosCfg) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -2033,7 +2033,7 @@ func TestMigrationEntrySeedingIsIdempotentAcrossRestarts(t *testing.T) { migrateCfg := config.DefaultStateCommitConfig() migrateCfg.WriteMode = types.MigrateEVM - cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(100)) require.NoError(t, cs2.Initialize([]string{"bank", keys.EVMStoreKey})) @@ -2045,7 +2045,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, nil) + cs3, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) require.NoError(t, err) require.NoError(t, cs3.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs3.LoadLatest() @@ -2062,7 +2062,7 @@ func TestInitializeIsNoOpInFlatKVOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.Nil(t, cs.memIAVL, "FlatKVOnly must not allocate a memIAVL backend") require.NotPanics(t, func() { @@ -2077,7 +2077,7 @@ func TestSetInitialVersionMemiavlOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) err = cs.LoadLatest() @@ -2103,7 +2103,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) @@ -2142,7 +2142,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, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.NoError(t, cs.SetMigrationBatchSize(100)) require.NoError(t, cs.Initialize([]string{"bank", keys.EVMStoreKey})) @@ -2169,7 +2169,7 @@ func TestInitializeRejectsUnknownStoreNames(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MigrateEVM - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2193,7 +2193,7 @@ func TestInitializeAcceptsUnknownStoreNamesInMemiavlOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2228,7 +2228,7 @@ func TestInitializeAcceptsUnknownStoreNamesInFlatKVOnly(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.FlatKVOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) require.Nil(t, cs.memIAVL, "FlatKVOnly must not allocate a memIAVL backend") defer func() { _ = cs.Close() }() @@ -2259,7 +2259,7 @@ func TestInitializeAcceptsAllMemIAVLStoreKeys(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2278,7 +2278,7 @@ func TestCopyProducesUsableSnapshot(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = types.MemiavlOnly - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2349,7 +2349,7 @@ func TestInitializeRejectsMigrationStoreName(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = tc.mode - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2480,7 +2480,7 @@ func TestGetChildStoreByName_NameValidation(t *testing.T) { cfg := config.DefaultStateCommitConfig() cfg.WriteMode = tc.mode - cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg, nil) + cs, err := NewCompositeCommitStore(t.Context(), t.TempDir(), cfg) require.NoError(t, err) defer func() { _ = cs.Close() }() @@ -2531,7 +2531,7 @@ func TestLoadVersionReadOnlyDuringMigrateEVMTransition(t *testing.T) { v0Cfg := config.DefaultStateCommitConfig() v0Cfg.WriteMode = types.MemiavlOnly - cs1, err := NewCompositeCommitStore(t.Context(), dir, v0Cfg, nil) + cs1, err := NewCompositeCommitStore(t.Context(), dir, v0Cfg) require.NoError(t, err) require.NoError(t, cs1.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) err = cs1.LoadLatest() @@ -2554,7 +2554,7 @@ func TestLoadVersionReadOnlyDuringMigrateEVMTransition(t *testing.T) { // flagged. migrateCfg := config.DefaultStateCommitConfig() migrateCfg.WriteMode = types.MigrateEVM - cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg, nil) + cs2, err := NewCompositeCommitStore(t.Context(), dir, migrateCfg) require.NoError(t, err) require.NoError(t, cs2.SetMigrationBatchSize(100)) require.NoError(t, cs2.Initialize([]string{keys.BankStoreKey, keys.EVMStoreKey})) From 3f16720a49302b158218d39e551966d8a5c6eb14 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 9 Sep 2026 08:35:38 -0500 Subject: [PATCH 12/19] bugfix --- .../sc/flatkv/finalization_manager.go | 120 +++++------------- .../sc/flatkv/finalization_manager_test.go | 43 +++++++ sei-db/state_db/sc/flatkv/snapshot_writer.go | 23 ++-- .../sc/flatkv/snapshot_writer_test.go | 38 +++++- 4 files changed, 125 insertions(+), 99 deletions(-) create mode 100644 sei-db/state_db/sc/flatkv/finalization_manager_test.go diff --git a/sei-db/state_db/sc/flatkv/finalization_manager.go b/sei-db/state_db/sc/flatkv/finalization_manager.go index 579baecec7..58e6d12907 100644 --- a/sei-db/state_db/sc/flatkv/finalization_manager.go +++ b/sei-db/state_db/sc/flatkv/finalization_manager.go @@ -21,8 +21,8 @@ import ( // 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 is the engine's stream, one hash per block scheduled, in block order. This manager + // is its sole consumer. engineHashChan <-chan *lthash.BlockHash // queue carries sealed blocks and control messages, in block order. @@ -122,7 +122,12 @@ func (fm *FinalizationManager) Flush() error { if err := fm.enqueue(request); err != nil { return fmt.Errorf("flush finalization manager: %w", err) } - <-request.doneChan + select { + case <-request.doneChan: + case <-fm.ctx.Done(): + // A stopping manager never reaches this request. The blocks behind it are abandoned rather than + // finalized, which Close reports, and their rows are still in the WAL for replay to recover. + } if err := fm.errorIfBricked(); err != nil { return fmt.Errorf("flush finalization manager: %w", err) } @@ -132,9 +137,8 @@ func (fm *FinalizationManager) Flush() error { // 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. +// Blocks that have been offered but not yet finalized are abandoned rather than finished; the WAL +// still holds them 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 { @@ -146,33 +150,36 @@ func (fm *FinalizationManager) Close() error { return nil } -// enqueue puts a message on the queue, blocking while it is full. +// enqueue puts a message on the queue, blocking while it is full and failing once the manager stops. 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 + select { + case fm.messageChan <- message: + return nil + case <-fm.ctx.Done(): + return fmt.Errorf("finalization manager is stopping: %w", fm.ctx.Err()) + } } -// run finalizes blocks until the manager is stopped or a block fails. +// run finalizes blocks until the manager is stopped or a block fails. It cancels the manager's context +// on the way out, whatever the reason: everything waiting on this manager waits under that context, and +// this goroutine is the only thing that can release it. func (fm *FinalizationManager) run() { defer fm.wg.Done() + defer fm.cancel() - 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 !fm.handle(message) { + // Whatever is still queued is left as it was offered. An unfinalized view never + // flushes, so those blocks' rows stay out of the databases and each one's recorded + // version keeps matching what it holds. + return } - failed = !fm.handle(message) case <-fm.ctx.Done(): - fm.abandon() return } } @@ -206,27 +213,20 @@ func (fm *FinalizationManager) finalize(pending *pendingFinalization) (stopped b 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) + // failure: the block is abandoned where it is, and replay recovers it from the WAL. + return true, nil } if hash.Error != nil { - return false, errors.Join( - fmt.Errorf("hash block %d: %w", pending.blockNumber, hash.Error), - fm.discard(pending)) + return false, fmt.Errorf("hash block %d: %w", pending.blockNumber, hash.Error) } 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)) + return false, fmt.Errorf("finalization is out of step: holding block %d, hashed block %d", + pending.blockNumber, hash.BlockNumber) } 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()) + return false, fmt.Errorf("finalize %s at block %d: %w", dbView.Name(), pending.blockNumber, err) } } @@ -240,62 +240,6 @@ func (fm *FinalizationManager) finalize(pending *pendingFinalization) (stopped b return false, fm.listeners.dispatch(fm.ctx, hash) } -// 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 - } -} - // brick latches err as the manager's fatal error and stops it. func (fm *FinalizationManager) brick(err error) { fm.fatalErr.CompareAndSwap(nil, &err) diff --git a/sei-db/state_db/sc/flatkv/finalization_manager_test.go b/sei-db/state_db/sc/flatkv/finalization_manager_test.go new file mode 100644 index 0000000000..bf7606f0e0 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/finalization_manager_test.go @@ -0,0 +1,43 @@ +package flatkv + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" +) + +// A flush waits behind every block offered before it, so a manager stopped while one of those blocks +// still awaits its hash has to release the caller rather than leave it parked for good. +func TestFinalizationFlushReturnsOnceTheManagerIsStopped(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + + // Nothing ever publishes on this, so the block below never gets a hash. + engineHashChan := make(chan *lthash.BlockHash) + fm := newFinalizationManager(ctx, engineHashChan, nil, 1, newHashListenerRegistry()) + + // Queued directly rather than through Offer: a block's view is only touched once its hash arrives, + // which here it never does, so this block needs no store behind it. + require.NoError(t, fm.enqueue(&pendingFinalization{blockNumber: 1})) + + flushed := make(chan error, 1) + go func() { flushed <- fm.Flush() }() + + select { + case err := <-flushed: + t.Fatalf("Flush returned while block 1 was still unhashed: %v", err) + case <-time.After(100 * time.Millisecond): + } + + cancel() + + select { + case err := <-flushed: + require.NoError(t, err, "a flush released by shutdown reports no failure of its own") + case <-time.After(30 * time.Second): + t.Fatal("Flush never returned after the manager was stopped") + } +} diff --git a/sei-db/state_db/sc/flatkv/snapshot_writer.go b/sei-db/state_db/sc/flatkv/snapshot_writer.go index 3e0a336c13..f651d568a2 100644 --- a/sei-db/state_db/sc/flatkv/snapshot_writer.go +++ b/sei-db/state_db/sc/flatkv/snapshot_writer.go @@ -220,8 +220,8 @@ func (w *SnapshotWriter) Flush() error { } } -// Close stops the writer and waits for its goroutine to exit, which may include finishing blocks that -// are still queued. Reports the latched error if the writer failed. Idempotent. +// Close stops the writer and waits for its goroutine to exit, abandoning whatever it was writing and +// whatever is still queued. Reports the latched error if the writer failed. Idempotent. func (w *SnapshotWriter) Close() error { w.stop() // The goroutine closes exited from a deferred call on every exit path, so this cannot strand. @@ -298,6 +298,11 @@ func (w *SnapshotWriter) run() { err = w.handleMessage(message) } if err != nil { + if w.ctx.Err() != nil { + // Stopped mid-work. What it abandoned was never published, so there is nothing to + // report and nothing to repair. + return + } w.brick(err) return } @@ -435,21 +440,19 @@ func (w *SnapshotWriter) writeCheckpoint(request *snapshotRequest) (err error) { } }() - // Work already under way is not abandoned when the writer is told to stop. w.ctx is cancelled to - // release callers blocked on the queue, but Close is documented to let an in-flight snapshot finish, - // and the databases it is reading are closed only after the drain. Handing it a cancellable context - // would instead abort its AwaitFlush and brick the writer on the way out. - workCtx := context.WithoutCancel(w.ctx) - + // Work under way is abandoned when the writer is told to stop, rather than finished: the block it + // is checkpointing may be one the finalizer abandoned on the way out, whose views never flush, and + // waiting for that flush would hang the store's own Close. What it abandons is a "-tmp" directory + // that was never published, which open removes (see removeTmpDirs). tmpPath, err := checkpointDatabases( - workCtx, w.dir, request.blockView, w.dbs, w.phaseTimer) + w.ctx, w.dir, request.blockView, w.dbs, w.phaseTimer) if err != nil { 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) + w.ctx, 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) } 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 06f1301914..9dbf19632e 100644 --- a/sei-db/state_db/sc/flatkv/snapshot_writer_test.go +++ b/sei-db/state_db/sc/flatkv/snapshot_writer_test.go @@ -40,6 +40,10 @@ type fakeView struct { // Returned by AwaitFlush. awaitFlushErr error + // When non-nil, AwaitFlush waits on it, standing in for a block whose flush never comes because + // its finalization was abandoned. A test that never closes it holds the flush open for good. + awaitFlushBlocked chan struct{} + // Returned by Reserve. A non-nil value also suppresses the reserve count. reserveErr error @@ -56,7 +60,16 @@ type fakeView struct { func (v *fakeView) Name() string { return v.name } -func (v *fakeView) AwaitFlush(context.Context) error { return v.awaitFlushErr } +func (v *fakeView) AwaitFlush(ctx context.Context) error { + if v.awaitFlushBlocked != nil { + select { + case <-v.awaitFlushBlocked: + case <-ctx.Done(): + return ctx.Err() + } + } + return v.awaitFlushErr +} func (v *fakeView) Reserve() error { if v.reserveErr != nil { @@ -469,3 +482,26 @@ func TestStoreWritesSnapshotAsynchronously(t *testing.T) { require.NoError(t, err) require.Equal(t, snapshotName(2), target, "current must point at the snapshot the writer published") } + +// A block whose finalization was abandoned never flushes, and the store abandons blocks exactly when +// it is closing. A checkpoint still waiting for that flush would then keep the writer's own Close +// waiting for good, and the view manager that could release the wait is closed after it. +// +// Stopping therefore abandons the checkpoint: what it was writing was never published, and open +// removes it. +func TestSnapshotWriterCloseAbandonsACheckpointAwaitingAFlushThatNeverComes(t *testing.T) { + db := &fakeCheckpointDB{started: make(chan struct{}), release: make(chan struct{})} + // The copy is not what holds this checkpoint up; the flush before it is. + close(db.release) + w := newTestWriter(t, 1, 4, db) + + blockView, stubs := fakeViews(t, 7) + for _, stub := range stubs { + stub.awaitFlushBlocked = make(chan struct{}) + } + require.NoError(t, w.Offer(blockView)) + + closed := make(chan error, 1) + go func() { closed <- w.Close() }() + require.NoError(t, requireReturns(t, closed, "Close with a checkpoint awaiting a flush")) +} From 5e9b9bfbb512898f9c2301ab110a2327fcd27075 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 9 Sep 2026 09:03:43 -0500 Subject: [PATCH 13/19] fix godoc --- sei-db/state_db/sc/flatkv/config/config.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/config/config.go b/sei-db/state_db/sc/flatkv/config/config.go index 98ccc9521c..67e4e19c4b 100644 --- a/sei-db/state_db/sc/flatkv/config/config.go +++ b/sei-db/state_db/sc/flatkv/config/config.go @@ -108,8 +108,6 @@ type Config struct { // The number of threads in this pool is equal to MiscThreadsPerCore * runtime.NumCPU() + MiscConstantThreadCount. MiscConstantThreadCount int - // 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 @@ -120,8 +118,9 @@ type Config struct { // database's flush frontier, so this bounds how much of the pipeline stays resident. FinalizationQueueSize uint32 `mapstructure:"finalization-queue-size"` - // LtHashThreadsPerCore * runtime.NumCPU() (clamped to at least 1). LtHash - // computation is CPU-bound, so ~1 worker per core is a sensible default. + // Controls the number of workers in the dedicated lattice-hash pool used to compute per-module + // LtHashes. The number of workers in this pool is equal to LtHashThreadsPerCore * runtime.NumCPU(), + // clamped to at least 1. LtHashThreadsPerCore float64 } From 06773cd66b4843e47bfd11b1ff9989b5f7db59f0 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 9 Sep 2026 09:46:21 -0500 Subject: [PATCH 14/19] minor fixes --- .../sc/flatkv/finalization_manager.go | 6 +- .../sc/flatkv/finalization_manager_test.go | 2 +- .../sc/flatkv/lthash/block_gatherer.go | 13 +++- .../state_db/sc/flatkv/lthash/hash_engine.go | 17 +++-- .../sc/flatkv/lthash/hash_engine_test.go | 69 ++++++++++++++++++- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/finalization_manager.go b/sei-db/state_db/sc/flatkv/finalization_manager.go index 58e6d12907..a667448123 100644 --- a/sei-db/state_db/sc/flatkv/finalization_manager.go +++ b/sei-db/state_db/sc/flatkv/finalization_manager.go @@ -126,7 +126,11 @@ func (fm *FinalizationManager) Flush() error { case <-request.doneChan: case <-fm.ctx.Done(): // A stopping manager never reaches this request. The blocks behind it are abandoned rather than - // finalized, which Close reports, and their rows are still in the WAL for replay to recover. + // finalized, and their rows are still in the WAL for replay to recover. + if err := fm.errorIfBricked(); err != nil { + return fmt.Errorf("flush finalization manager: %w", err) + } + return fmt.Errorf("flush finalization manager: manager is stopping: %w", fm.ctx.Err()) } if err := fm.errorIfBricked(); err != nil { return fmt.Errorf("flush finalization manager: %w", err) diff --git a/sei-db/state_db/sc/flatkv/finalization_manager_test.go b/sei-db/state_db/sc/flatkv/finalization_manager_test.go index bf7606f0e0..bc8db4954b 100644 --- a/sei-db/state_db/sc/flatkv/finalization_manager_test.go +++ b/sei-db/state_db/sc/flatkv/finalization_manager_test.go @@ -36,7 +36,7 @@ func TestFinalizationFlushReturnsOnceTheManagerIsStopped(t *testing.T) { select { case err := <-flushed: - require.NoError(t, err, "a flush released by shutdown reports no failure of its own") + require.Error(t, err, "a flush released by shutdown reports that it never flushed") case <-time.After(30 * time.Second): t.Fatal("Flush never returned after the manager was stopped") } diff --git a/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go b/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go index 52727e763d..f20d5cb2fa 100644 --- a/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go +++ b/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go @@ -26,6 +26,10 @@ type blockGatherer struct { // Cancelled when the engine is stopping, to release a send that the combiner is no longer reading. ctx context.Context + // cancel stops the engine, called when run() returns so that a caller waiting on this queue is + // released. + cancel context.CancelFunc + // brick latches a failure on the engine, which reports it from Close(). brick func(error) @@ -38,6 +42,8 @@ func newBlockGatherer( hasher *leafHasher, // Cancelled when the engine is stopping, to release a send the combiner is no longer reading. ctx context.Context, + // Stops the engine, called when run() returns. + cancel context.CancelFunc, // Latches a failure on the engine, which reports it from Close(). brick func(error), ) *blockGatherer { @@ -46,6 +52,7 @@ func newBlockGatherer( scheduledBlockChan: make(chan any, cfg.ScheduleQueueSize), combineJobChan: make(chan any, cfg.CombineQueueSize), ctx: ctx, + cancel: cancel, brick: brick, } g.wg.Go(g.run) @@ -53,9 +60,13 @@ func newBlockGatherer( } // run reads each block's changed values, submits its leaf hashing to the pool, and passes the block to -// the combiner. +// the combiner. It stops the engine on the way out, whatever the reason: a schedule waits under the +// engine's context, and this goroutine is the only thing that can release it. func (g *blockGatherer) run() { defer g.teardown() + // Cancelled before the drain rather than after it, so that a schedule parked on a full queue is + // released by the cancellation instead of being woken by the drain, which nothing follows. + defer g.cancel() for { select { 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 82fe629e81..eeeb9e8878 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_engine.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine.go @@ -84,7 +84,8 @@ func NewHashEngine( ctx, cancel := context.WithCancel(parent) he := &HashEngine{ctx: ctx, cancel: cancel} - he.gatherer = newBlockGatherer(cfg, newLeafHasher(pool, moduleParser, cfg.ChunkSize), ctx, he.brick) + he.gatherer = newBlockGatherer( + cfg, newLeafHasher(pool, moduleParser, cfg.ChunkSize), ctx, cancel, he.brick) he.combiner = newHashCombiner( dbNames, seed, he.gatherer.combineJobChan, ctx, cfg.HashChanSize, he.brick) return he, nil @@ -139,7 +140,11 @@ func (he *HashEngine) Flush() error { case <-request.doneChan: case <-he.ctx.Done(): // A stopping engine never reaches this request. The blocks behind it are abandoned rather than - // hashed, which Close reports, and their rows are still in the WAL for replay to recover. + // hashed, and their rows are still in the WAL for replay to recover. + if err := he.errorIfBricked(); err != nil { + return fmt.Errorf("flush hash engine: %w", err) + } + return fmt.Errorf("flush hash engine: engine is stopping: %w", he.ctx.Err()) } if err := he.errorIfBricked(); err != nil { return fmt.Errorf("flush hash engine: %w", err) @@ -170,8 +175,12 @@ 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 + select { + case he.gatherer.scheduledBlockChan <- message: + return nil + case <-he.ctx.Done(): + return fmt.Errorf("hash engine is stopping: %w", he.ctx.Err()) + } } // brick latches err as the engine's fatal error and stops it. 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 index cfc20d9c90..71d1654c50 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_engine_test.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine_test.go @@ -311,6 +311,73 @@ func TestHashEngineCloseAbandonsAndReleases(t *testing.T) { } } +// A schedule parked on a full queue has to be released once the engine stops, and the block it could +// not hand over must go back unreserved: a view left reserved can never flush. +func TestScheduleHashIsReleasedWhenTheEngineStops(t *testing.T) { + pool := threading.NewFixedPool("lthash-schedule-shutdown-test", 4, 64) + t.Cleanup(pool.Close) + + // One block deep at every stage, so the pipeline saturates within a handful of blocks while nothing + // reads AwaitHash. Its own context, so the test can stop it; newTestEngine ties one to t.Context(). + cfg := DefaultConfig() + cfg.ScheduleQueueSize = 1 + cfg.CombineQueueSize = 1 + cfg.HashChanSize = 1 + ctx, cancel := context.WithCancel(t.Context()) + engine, err := NewHashEngine(ctx, cfg, pool, engineDBNames, engineModuleOf, NewBlockHash(engineDBNames)) + require.NoError(t, err) + // Registered after the pool's cleanup so it runs before it: the engine's goroutines submit leaf + // hashing to the pool, and Close is what waits for them to stop. + t.Cleanup(func() { require.NoError(t, engine.Close()) }) + + // Built here rather than in the goroutine below, which must not touch t. + const blocks = 8 + type pipeBlock struct { + current *sview.StoreView + previous *sview.StoreView + views []*pipeView + } + pending := make([]pipeBlock, 0, blocks) + for height := int64(1); height <= blocks; height++ { + current, previous, views := blockViews(t, height, blockDiff(height, 4), nil) + pending = append(pending, pipeBlock{current: current, previous: previous, views: views}) + } + + results := make(chan error, blocks) + go func() { + for _, block := range pending { + results <- engine.ScheduleHash(block.current, block.previous) + } + }() + + // Once a schedule stops reporting, the pipeline is full and that call is parked on the send. + accepted := 0 + for parked := false; !parked; { + select { + case err := <-results: + require.NoError(t, err, "a running engine must take block %d", accepted+1) + accepted++ + case <-time.After(500 * time.Millisecond): + parked = true + } + } + require.Less(t, accepted, blocks, "the pipeline never filled, so no schedule was left parked") + + cancel() + + select { + case err := <-results: + require.Error(t, err, "a schedule parked on a full queue must be released once the engine stops") + case <-time.After(30 * time.Second): + t.Fatal("ScheduleHash never returned after the engine was stopped") + } + + for _, v := range pending[accepted].views { + require.Equal(t, v.reserves, v.releases, + "%s: a block the engine could not take must hold none of its reservations", v.name) + } +} + // 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) { @@ -384,7 +451,7 @@ func TestFlushReturnsOnceTheEngineIsStopped(t *testing.T) { select { case err := <-flushed: - require.NoError(t, err, "a flush released by shutdown reports no failure of its own") + require.Error(t, err, "a flush released by shutdown reports that it never flushed") case <-time.After(30 * time.Second): t.Fatal("Flush never returned after the engine was stopped") } From 0cf7aac64555e76fa3eb10136f7ec82ad2007149 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 9 Sep 2026 10:22:46 -0500 Subject: [PATCH 15/19] Merge branch 'main' into cjl/replace-db-wrapper --- giga/evmonly/giga_store_test.go | 2 - giga/evmonly/memory_store.go | 7 - sei-db/bootstrap/recovery.go | 42 +- sei-db/bootstrap/recovery_test.go | 495 +++- sei-db/bootstrap/storage_manager.go | 10 +- sei-db/bootstrap/storage_manager_test.go | 16 + sei-db/db_engine/pebbledb/db.go | 9 +- sei-db/db_engine/pebbledb/mvcc/db.go | 7 +- sei-db/db_engine/pebbledb/pebble_metrics.go | 2093 +++-------------- sei-db/state_db/giga/state_db.go | 352 +++ sei-db/state_db/giga/state_db_impl.go | 557 ----- sei-db/state_db/giga/state_db_replay.go | 283 +++ sei-db/state_db/giga/state_db_replay_test.go | 148 ++ ...state_db_impl_test.go => state_db_test.go} | 100 +- .../state_db/giga/types/live_state_store.go | 8 - sei-db/state_db/giga/types/state_db.go | 7 - sei-db/state_db/sc/composite/store_test.go | 3 - sei-db/state_db/sc/flatkv/snapshot.go | 189 +- sei-db/state_db/sc/flatkv/snapshot_test.go | 90 +- sei-db/state_db/sc/flatkv/store.go | 81 +- .../sc/flatkv/store_init_repair_test.go | 28 + sei-db/state_db/sc/flatkv/store_meta.go | 82 + .../sc/flatkv/store_version_probe_test.go | 66 + sei-db/state_db/ss/evm/checkpoint.go | 32 - sei-db/state_db/ss/evm/recovery.go | 271 ++- sei-db/state_db/ss/evm/recovery_test.go | 107 + sei-db/state_db/ss/evm/store.go | 13 + sei-db/state_db/ss/snapshot/manager.go | 107 +- sei-db/state_db/ss/snapshot/manager_test.go | 43 +- 29 files changed, 2514 insertions(+), 2734 deletions(-) create mode 100644 sei-db/state_db/giga/state_db.go delete mode 100644 sei-db/state_db/giga/state_db_impl.go create mode 100644 sei-db/state_db/giga/state_db_replay.go create mode 100644 sei-db/state_db/giga/state_db_replay_test.go rename sei-db/state_db/giga/{state_db_impl_test.go => state_db_test.go} (72%) diff --git a/giga/evmonly/giga_store_test.go b/giga/evmonly/giga_store_test.go index e3f20cb193..4e395e4538 100644 --- a/giga/evmonly/giga_store_test.go +++ b/giga/evmonly/giga_store_test.go @@ -42,8 +42,6 @@ func (s *recordingGigaStore) OpenViewAt(int64) (gigatypes.StateView, bool) { return nil, false } -func (s *recordingGigaStore) RollbackTo(int64) error { return errors.ErrUnsupported } - func (s *recordingGigaStore) Close() error { return nil } type memoryGigaSnapshot struct { diff --git a/giga/evmonly/memory_store.go b/giga/evmonly/memory_store.go index 460d6bcbbc..079c804391 100644 --- a/giga/evmonly/memory_store.go +++ b/giga/evmonly/memory_store.go @@ -372,13 +372,6 @@ func (s *MemoryStore) RegisterHashListener(_ gigatypes.HashListener) (lthash.Blo return lthash.BlockHash{}, fmt.Errorf("evmonly: an in-memory store computes no block hashes") } -// RollbackTo reports that this store cannot rewind. Its committed overlays are what keep open and -// historical views stable, and discarding them is outside what a test and load-generation store stands -// in for. -func (s *MemoryStore) RollbackTo(blockNum int64) error { - return fmt.Errorf("evmonly: MemoryStore cannot roll back to block %d", blockNum) -} - // Close releases nothing. This store holds no handle outside its own maps, which go with it. func (s *MemoryStore) Close() error { return nil } diff --git a/sei-db/bootstrap/recovery.go b/sei-db/bootstrap/recovery.go index 426a48a8da..ce6798a101 100644 --- a/sei-db/bootstrap/recovery.go +++ b/sei-db/bootstrap/recovery.go @@ -28,10 +28,7 @@ func (m *GigaStorageManager) OpenDBWithRecovery(ctx context.Context) error { if err != nil { return err } - if err := m.openStateDB(ctx); err != nil { - return err - } - if err := m.recoverStores(targetHeight); err != nil { + if err := m.recoverStores(ctx, targetHeight); err != nil { return err } // The receipt store opens last because its rollback runs against its files: it is the one store @@ -39,20 +36,25 @@ func (m *GigaStorageManager) OpenDBWithRecovery(ctx context.Context) error { return m.openReceiptStore() } -// recoverStores aligns the block height of the receipt store, the live state (SC) and the historical -// state (SS, if enabled) on target, cutting the state WAL back to target as it rolls state back. +// recoverStores puts the receipt store, the state commit store and the EVM state store (when enabled) +// on target, cutting the state WAL back to it as well. The state stores are rolled back as they open, +// so this is what leaves the manager holding them. // // A target of 0 is no height to converge on, and every store is left as it was found: rolling back to // it would drop every receipt the node holds along with every block in its WAL. This is the single -// guard for that, which is why the two rollbacks below it carry none of their own. -func (m *GigaStorageManager) recoverStores(target int64) error { +// guard for that, which is why the rollbacks below it carry none of their own. +// +// State goes first because it is the rollback that refuses: a target its snapshots and WAL cannot span +// leaves the node down for an operator to retry at a higher one, and receipts cut to the lower target +// would no longer be there to reach. +func (m *GigaStorageManager) recoverStores(ctx context.Context, target int64) error { if target == 0 { - return nil + return m.openStateDB(ctx) } - if err := m.recoverReceipt(target); err != nil { + if err := m.openStateDBAt(ctx, target); err != nil { return err } - return m.stateDB.RollbackTo(target) + return m.recoverReceipt(target) } // openBlockStore opens the block ledger consensus reads and writes. @@ -144,8 +146,8 @@ func recoveryTarget(blockHeight, stateHeight, receiptHeight uint64) uint64 { return target } -// openStateDB opens the live state (SC), the historical state (SS, if enabled) and the state WAL they -// share, where it finds them. recoverStores is what puts them on a height. +// openStateDB opens the state commit store, the EVM state store (when enabled) and the state WAL, +// leaving them on the height the WAL holds. func (m *GigaStorageManager) openStateDB(ctx context.Context) error { stateDB, err := giga.NewStateDB(ctx, m.cfg.FlatKVConfig, m.cfg.SSConfig, m.cfg.CheckpointConfig) if err != nil { @@ -155,6 +157,20 @@ func (m *GigaStorageManager) openStateDB(ctx context.Context) error { return nil } +// openStateDBAt opens the same three stores on target, rolling them back to it first. +// +// The rollback is part of the open because cutting the state WAL's tail needs the WAL closed, so an +// already-open state DB would have to close and reopen it. +func (m *GigaStorageManager) openStateDBAt(ctx context.Context, target int64) error { + stateDB, err := giga.NewStateDBWithRollback( + ctx, m.cfg.FlatKVConfig, m.cfg.SSConfig, m.cfg.CheckpointConfig, target) + if err != nil { + return err + } + m.stateDB = stateDB + return nil +} + // recoverReceipt drops every receipt above target, working on the store's files rather than through an // open store. A store already at or below target is left alone. // diff --git a/sei-db/bootstrap/recovery_test.go b/sei-db/bootstrap/recovery_test.go index 7001e84f74..4c10cc72e5 100644 --- a/sei-db/bootstrap/recovery_test.go +++ b/sei-db/bootstrap/recovery_test.go @@ -1,7 +1,10 @@ package bootstrap import ( + "fmt" "math/big" + "os" + "path/filepath" "testing" "time" @@ -14,6 +17,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/controller" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/evm" "github.com/sei-protocol/sei-chain/sei-db/state_db/statewal" evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" @@ -73,43 +77,69 @@ func writeWALOnly(t *testing.T, wal statewal.StateWAL, block uint64, changesets require.NoError(t, wal.Flush()) } -func applySSThrough(t *testing.T, manager *GigaStorageManager, through byte) { +// waitSSWrites blocks until SS has applied every block committed so far. A commit hands SS its block +// asynchronously and does not wait, so a test reading SS straight after one waits here instead. +func waitSSWrites(manager *GigaStorageManager) { + manager.SS().WaitForPendingWrites() +} + +// snapshotSSEveryBlock puts SS on a fresh every-block schedule so a later rollback has a snapshot to +// land on. The schedule is its own rather than the node-wide one SC is on, whose interval would +// otherwise decide these heights. +func snapshotSSEveryBlock(manager *GigaStorageManager) { + manager.SS().SetCheckpointScheduler(controller.NewCheckpointScheduler(config.CheckpointConfig{BlockInterval: 1})) +} + +// waitSSSnapshot waits until SS has published a snapshot at or above height. +func waitSSSnapshot(t *testing.T, manager *GigaStorageManager, height int64) { + t.Helper() + require.Eventually(t, func() bool { return manager.SS().Snapshots().Newest() >= height }, + 10*time.Second, 10*time.Millisecond, "the snapshot a rollback restores from must be published") +} + +// commitBlocksWithSSSnapshots commits blocks 1 through through and waits for an SS snapshot at each, +// so a later rollback has a boundary to land on. +// +// Every block gets its own schedule, because a snapshot reports itself done a moment after it becomes +// visible: a schedule still holding the previous height turns the next one down, and stands by that no +// for good. Production wants that — the height it skips to is the next block's — but a helper asking +// for a snapshot at every height has to be free of it. +func commitBlocksWithSSSnapshots(t *testing.T, manager *GigaStorageManager, through byte) { t.Helper() for block := byte(1); block <= through; block++ { - require.NoError(t, manager.SS().ApplyChangesetSync(int64(block), evmBlock(block, block))) + snapshotSSEveryBlock(manager) + require.NoError(t, manager.StateDB().CommitStateChanges(int64(block), evmBlock(block, block))) + waitSSSnapshot(t, manager, int64(block)) } } -// snapshotSSAt commits block height through SS's commit path so the checkpoint schedule snapshots it. -// CommitBlock is what offers a version to the schedule — the apply methods are raw writes that take no -// snapshot — and a BlockInterval of 1 makes every offered version a boundary. Publication happens off -// the commit path, so the snapshot has to be waited for. -func snapshotSSAt(t *testing.T, manager *GigaStorageManager, height byte) { +// reconverge re-runs what a restart does: it closes every store recovery touches, recovers them onto +// target — which is what opens the state DB again — and reopens the receipt store on the far side. +func reconverge(t *testing.T, manager *GigaStorageManager, target int64) { t.Helper() - manager.SS().SetCheckpointScheduler(controller.NewCheckpointScheduler(config.CheckpointConfig{BlockInterval: 1})) - require.NoError(t, manager.SS().CommitBlock(int64(height), evmBlock(height, height))) - require.Eventually(t, func() bool { return manager.SS().Snapshots().Newest() >= int64(height) }, - 10*time.Second, 10*time.Millisecond, "the snapshot a rollback restores from must be published") + require.NoError(t, reconvergeErr(t, manager, target)) + require.NoError(t, manager.openReceiptStore()) } -// reconverge re-runs what a restart does: it closes every store recovery touches, opens a StateDB -// again, recovers every store onto target, and reopens the receipt store on the far side. -func reconverge(t *testing.T, manager *GigaStorageManager, target int64) { +// reconvergeErr is reconverge up to the point recovery can fail, for a test that expects it to. The +// receipt store is left closed, since a failed recovery leaves the manager with no state DB. +func reconvergeErr(t *testing.T, manager *GigaStorageManager, target int64) error { t.Helper() // Closing first is what a restart does, and it is also required: recovery takes file locks the - // open stores hold — the StateDB's for the state it opens, the receipt store's for the rollback - // that runs against its files. + // open stores hold — the state WAL's directory lock for the reads and the tail cut that precede + // opening it, the receipt store's for the rollback that runs against its files. closeStateDB(t, manager) closeReceiptDB(t, manager) - require.NoError(t, manager.openStateDB(t.Context())) - require.NoError(t, manager.recoverStores(target)) - require.NoError(t, manager.openReceiptStore()) + return manager.recoverStores(t.Context(), target) } -// closeStateDB closes the two halves of state and their WAL and drops them from the manager, leaving it -// as it was before the StateDB opened. Manager.Close tolerates that, so a test may still defer it. +// closeStateDB closes the stores the StateDB owns and drops it from the manager, leaving the manager as +// it was before the StateDB opened. Manager.Close tolerates that, so a test may still defer it. func closeStateDB(t *testing.T, manager *GigaStorageManager) { t.Helper() + if manager.StateDB() == nil { + return + } require.NoError(t, manager.StateDB().Close()) manager.stateDB = nil } @@ -135,6 +165,11 @@ func snapshotSCAt(t *testing.T, manager *GigaStorageManager, height byte) { require.NoError(t, manager.SC().FlushSnapshots()) } +// disableSS turns the EVM state store off. +func disableSS(cfg *config.GigaStorageConfig) { + cfg.SSConfig.Enable = false +} + func requireWALTail(t *testing.T, manager *GigaStorageManager, want uint64) { t.Helper() stored, _, last, err := manager.StateWAL().GetStoredRange() @@ -147,6 +182,19 @@ func requireWALTail(t *testing.T, manager *GigaStorageManager, want uint64) { require.Equal(t, want, last) } +// replaceWALWithBlocks empties the state WAL and writes blocks from through through, so the WAL's first +// block can sit above 1. The StateDB must already be closed. +func replaceWALWithBlocks(t *testing.T, cfg *config.GigaStorageConfig, from, through byte) { + t.Helper() + require.NoError(t, statewal.PruneAfter(flatkv.StateWALConfig(cfg.FlatKVConfig.DataDir), 0)) + wal, err := flatkv.OpenStateWAL(cfg.FlatKVConfig) + require.NoError(t, err) + defer func() { require.NoError(t, wal.Close()) }() + for block := from; block <= through; block++ { + writeWALOnly(t, wal, uint64(block), evmBlock(block, block)) + } +} + func TestRecoveryTarget(t *testing.T) { for _, tc := range []struct { name string @@ -177,11 +225,9 @@ func TestRecoverStoresAtAZeroTargetLeavesReceiptsAlone(t *testing.T) { manager, _ := openManager(t, nil) commitBlocks(t, manager, 3) writeReceipts(t, manager, 5) - closeReceiptDB(t, manager) - require.NoError(t, manager.recoverStores(0)) + reconverge(t, manager, 0) - require.NoError(t, manager.openReceiptStore()) require.Equal(t, int64(5), manager.ReceiptDB().LatestVersion(), "a zero target must leave the receipt store where it was found") } @@ -204,7 +250,7 @@ func TestFindTargetRecoveryHeightIsZeroWithoutABlockLedger(t *testing.T) { // the target. Committing the block after it is what proves the truncation: an untruncated WAL still // holds that block and refuses to write it a second time. func TestRecoverStateDropsWALBlocksAboveTheTarget(t *testing.T) { - manager, _ := openManager(t, nil) + manager, _ := openManager(t, disableSS) commitBlocks(t, manager, 5) reconverge(t, manager, 3) @@ -213,6 +259,214 @@ func TestRecoverStateDropsWALBlocksAboveTheTarget(t *testing.T) { require.NoError(t, manager.StateDB().CommitStateChanges(4, evmBlock(4, 4))) } +// A plain open replays SC up to the WAL's head. SC comes up on the version its own files hold, which a +// commit whose WAL write outlived the crash that stopped its state write leaves one block back, and +// committing from behind the WAL is rejected outright: that block is already written. +func TestOpenReplaysSCUpToTheWALHead(t *testing.T) { + manager, _ := openManager(t, nil) + commitBlocks(t, manager, 2) + writeWALOnly(t, manager.StateWAL(), 3, evmBlock(3, 3)) + closeStateDB(t, manager) + + require.NoError(t, manager.openStateDB(t.Context())) + + require.Equal(t, int64(3), manager.SC().Version()) + require.NoError(t, manager.StateDB().CommitStateChanges(4, evmBlock(4, 4))) +} + +// A plain open discards a working copy holding blocks the WAL no longer has and rebuilds it from the +// snapshot, then replays back up. Those blocks were never servable, so the WAL's head is the height the +// store comes up on, and the state above it goes. +func TestOpenRebuildsSCAboveTheWALHead(t *testing.T) { + manager, cfg := openManager(t, disableSS) + commitBlocks(t, manager, 3) + closeStateDB(t, manager) + require.NoError(t, statewal.PruneAfter(flatkv.StateWALConfig(cfg.FlatKVConfig.DataDir), 2)) + + require.NoError(t, manager.openStateDB(t.Context())) + + require.Equal(t, int64(2), manager.SC().Version()) + require.NoError(t, manager.StateDB().CommitStateChanges(3, evmBlock(3, 3))) +} + +// The WAL is written unflushed, so a crash can lose its tail while a snapshot published above that tail +// survives. Rebuilding the working copy does not reach that: the current link names the version above, +// so the store opens there however often it is rebuilt, and the blocks it holds are ones the WAL can +// no longer replay to. +func TestOpenRewindsSCFromASnapshotAboveTheWALHead(t *testing.T) { + manager, cfg := openManager(t, disableSS) + commitBlocks(t, manager, 4) + snapshotSCAt(t, manager, 5) + require.Equal(t, int64(5), manager.SC().Version()) + closeStateDB(t, manager) + require.NoError(t, statewal.PruneAfter(flatkv.StateWALConfig(cfg.FlatKVConfig.DataDir), 3)) + + require.NoError(t, manager.openStateDB(t.Context())) + + require.Equal(t, int64(3), manager.SC().Version()) + require.NoError(t, manager.StateDB().CommitStateChanges(4, evmBlock(4, 4))) +} + +// SS reaches the same place through its databases rather than a snapshot, since it keeps no working +// copy to rebuild. +func TestOpenRewindsSSAboveTheWALHead(t *testing.T) { + manager, cfg := openManager(t, nil) + commitBlocksWithSSSnapshots(t, manager, 3) + require.Equal(t, int64(3), manager.SS().GetLatestVersion()) + closeStateDB(t, manager) + require.NoError(t, statewal.PruneAfter(flatkv.StateWALConfig(cfg.FlatKVConfig.DataDir), 2)) + + require.NoError(t, manager.openStateDB(t.Context())) + + require.Equal(t, int64(2), manager.SS().GetLatestVersion()) + require.NoError(t, manager.StateDB().CommitStateChanges(3, evmBlock(3, 3))) +} + +// An SS above the WAL head with no snapshot at or below it is emptied and replayed from block 1 rather +// than refused. SS takes its snapshots from the shared checkpoint schedule, whose default is a ten +// minute interval, so a node has none at all until its first checkpoint lands; refusing inside that +// window is a node that will not start on any restart until an operator deletes the EVM directory by +// hand. The WAL still holds every block, so the replay reconstructs the store exactly. +func TestOpenSSAboveTheWALHeadRebuildsItFromBlockOne(t *testing.T) { + manager, cfg := openManager(t, nil) + commitBlocks(t, manager, 3) + waitSSWrites(manager) + require.Equal(t, int64(3), manager.SS().GetLatestVersion()) + require.Zero(t, manager.SS().Snapshots().Newest(), "fixture precondition: SS has no snapshot to land on") + closeStateDB(t, manager) + require.NoError(t, statewal.PruneAfter(flatkv.StateWALConfig(cfg.FlatKVConfig.DataDir), 2)) + + require.NoError(t, manager.openStateDB(t.Context())) + + require.Equal(t, int64(2), manager.SS().GetLatestVersion()) + rebuilt, err := manager.SS().Get(evm.EVMStoreKey, 2, evmNonceKey(2)) + require.NoError(t, err) + require.Equal(t, evmNonce(2), rebuilt, "the replay must have put the blocks below the head back") + above, err := manager.SS().Get(evm.EVMStoreKey, 2, evmNonceKey(3)) + require.NoError(t, err) + require.Nil(t, above, "the block above the head must not have survived") + require.NoError(t, manager.StateDB().CommitStateChanges(3, evmBlock(3, 3))) +} + +// An SC above the WAL head with no snapshot at or below it cannot be rewound. Rebuilding the working +// copy from an empty snapshot would delete the history it holds; refusing leaves that history in place. +func TestOpenSCAboveTheWALHeadWithoutASnapshotIsRefused(t *testing.T) { + manager, cfg := openManager(t, disableSS) + commitBlocks(t, manager, 3) + closeStateDB(t, manager) + require.NoError(t, os.RemoveAll(scSnapshotDir(cfg.FlatKVConfig.DataDir, 0))) + require.NoError(t, statewal.PruneAfter(flatkv.StateWALConfig(cfg.FlatKVConfig.DataDir), 2)) + + err := manager.openStateDB(t.Context()) + + require.ErrorContains(t, err, "no snapshot") + // The operator is here because a node will not start. Naming a rollback would send them looking for + // one nobody ran, rather than at the WAL head the open refused. + require.ErrorContains(t, err, "cannot open on the state WAL's head 2") + require.NotContains(t, err.Error(), "roll back") + + openedAt, _, err := flatkv.StoredVersions(cfg.FlatKVConfig.DataDir) + require.NoError(t, err) + require.Equal(t, int64(3), openedAt, "a refused open must not have rebuilt SC from an empty snapshot") +} + +// scSnapshotDir returns where SC keeps the snapshot for version under dataDir. +func scSnapshotDir(dataDir string, version int64) string { + return filepath.Join(dataDir, fmt.Sprintf("snapshot-%020d", version)) +} + +// A snapshot above the target has to go even when SC sits at or below the target, which is the one +// state the rewind cannot sweep up: nothing moves SC, and it is the rewind that otherwise removes the +// snapshots above where it lands. +// +// An interrupted rewind is what leaves that state. SC repoints its current link before it removes the +// snapshots above, so a crash in between abandons a branch while SC reads as merely behind. A later +// rollback landing on one of that branch's snapshots would replay over it. +func TestRecoverDropsASnapshotAboveASCAlreadyAtTheTarget(t *testing.T) { + manager, cfg := openManager(t, disableSS) + dataDir := cfg.FlatKVConfig.DataDir + commitBlocks(t, manager, 2) + snapshotSCAt(t, manager, 3) + closeStateDB(t, manager) + closeReceiptDB(t, manager) + // The snapshot the interrupted rewind failed to remove. Its contents do not matter, only that a + // later rollback could land on it. + require.NoError(t, os.CopyFS(scSnapshotDir(dataDir, 5), os.DirFS(scSnapshotDir(dataDir, 3)))) + require.DirExists(t, scSnapshotDir(dataDir, 5), "the fixture must plant the snapshot it expects gone") + + require.NoError(t, manager.recoverStores(t.Context(), 3)) + + require.NoDirExists(t, scSnapshotDir(dataDir, 5), + "a rollback that never moved SC still has to drop the branch abandoned above it") + require.Equal(t, int64(3), manager.SC().Version()) +} + +// Snapshot retention eventually reclaims the oldest snapshots, so a target can fall below every one SC +// has left. Nothing can put SC on it then, and the refusal has to say so rather than report the cleanup +// step it happened to fail in. +func TestRecoverBelowEverySCSnapshotIsRefused(t *testing.T) { + manager, cfg := openManager(t, disableSS) + commitBlocks(t, manager, 4) + snapshotSCAt(t, manager, 5) + closeStateDB(t, manager) + closeReceiptDB(t, manager) + require.NoError(t, os.RemoveAll(scSnapshotDir(cfg.FlatKVConfig.DataDir, 0))) + + err := manager.recoverStores(t.Context(), 3) + + require.ErrorContains(t, err, "cannot roll back to 3") + require.ErrorContains(t, err, "the state commit store cannot reach 3") +} + +// A target above where SC sits is not a rollback for it: the WAL's tail is cut to the target and SC +// replays forward from the height it already holds. Its snapshot is far below and stays untouched, +// since landing on it would discard everything committed since and replay the lot back. +func TestRecoverReplaysForwardFromTheStoreOwnHeight(t *testing.T) { + manager, cfg := openManager(t, disableSS) + snapshotSCAt(t, manager, 1) + manager.SC().SetCheckpointScheduler(controller.NewCheckpointScheduler( + config.CheckpointConfig{BlockInterval: 1_000_000})) + for block := byte(2); block <= 7; block++ { + require.NoError(t, manager.StateDB().CommitStateChanges(int64(block), evmBlock(block, block))) + } + // Block 8 goes to SC under an address the WAL's own block 8 does not carry. It marks the working + // copy: a rebuild from the snapshot replays the WAL's block 8 instead and the address is gone. + require.NoError(t, manager.SC().CommitStateChanges(8, evmBlock(108, 108))) + writeWALOnly(t, manager.StateWAL(), 8, evmBlock(8, 8)) + writeWALOnly(t, manager.StateWAL(), 9, evmBlock(9, 9)) + writeWALOnly(t, manager.StateWAL(), 10, evmBlock(10, 10)) + require.Equal(t, int64(8), manager.SC().Version()) + + reconverge(t, manager, 9) + + require.Equal(t, int64(9), manager.SC().Version()) + requireWALTail(t, manager, 9) + require.DirExists(t, scSnapshotDir(cfg.FlatKVConfig.DataDir, 1)) + marker, ok := manager.SC().OpenView().Get(evm.EVMStoreKey, evmNonceKey(108)) + require.True(t, ok, "SC replayed forward from 8, so the working copy it held there is still open") + require.Equal(t, evmNonce(108), marker) +} + +// A crash that leaves the WAL a block ahead of the rest of the node makes the target the height the +// stores are already on. Rewinding them to a snapshot for it costs a replay of everything since, and +// for an SS with no snapshot to land on it is not a rewind at all but a wipe. +func TestRecoverLeavesAStoreAlreadyOnTheTargetAlone(t *testing.T) { + manager, _ := openManager(t, nil) + commitBlocks(t, manager, 4) + // A key no WAL block carries, so a wipe loses it where a replay would not put it back. + require.NoError(t, manager.SS().ApplyChangesetSync(4, evmBlock(9, 9))) + writeWALOnly(t, manager.StateWAL(), 5, evmBlock(5, 5)) + + reconverge(t, manager, 4) + + require.Equal(t, int64(4), manager.SC().Version()) + require.Equal(t, int64(4), manager.SS().GetLatestVersion()) + survived, err := manager.SS().Get(evm.EVMStoreKey, 4, evmNonceKey(9)) + require.NoError(t, err) + require.Equal(t, evmNonce(9), survived, + "a rollback to the height SS is already on must not clear it") +} + func TestRecoverSCReplaysAMissedWALBlock(t *testing.T) { manager, _ := openManager(t, nil) commitBlocks(t, manager, 2) @@ -225,7 +479,7 @@ func TestRecoverSCReplaysAMissedWALBlock(t *testing.T) { } func TestRecoverSCRollsBackToTheTarget(t *testing.T) { - manager, _ := openManager(t, nil) + manager, _ := openManager(t, disableSS) commitBlocks(t, manager, 3) reconverge(t, manager, 2) @@ -233,6 +487,80 @@ func TestRecoverSCRollsBackToTheTarget(t *testing.T) { require.Equal(t, int64(2), manager.SC().Version()) } +// A rollback target below every WAL block empties the WAL. The stores still have to land on the +// snapshot at the target rather than keep a working copy the empty WAL can no longer account for. +func TestRecoverToATargetThatEmptiesTheWALLandsOnTheSnapshot(t *testing.T) { + manager, cfg := openManager(t, nil) + snapshotSSEveryBlock(manager) + snapshotSCAt(t, manager, 1) + waitSSSnapshot(t, manager, 1) + huge := controller.NewCheckpointScheduler(config.CheckpointConfig{BlockInterval: 1_000_000}) + manager.SC().SetCheckpointScheduler(huge) + manager.SS().SetCheckpointScheduler(huge) + for block := byte(2); block <= 5; block++ { + require.NoError(t, manager.StateDB().CommitStateChanges(int64(block), evmBlock(block, block))) + } + closeStateDB(t, manager) + replaceWALWithBlocks(t, cfg, 2, 5) + + reconverge(t, manager, 1) + + require.Equal(t, int64(1), manager.SC().Version()) + require.Equal(t, int64(1), manager.SS().GetLatestVersion()) + requireWALTail(t, manager, 0) + require.NoError(t, manager.StateDB().CommitStateChanges(2, evmBlock(2, 2))) +} + +// A target the WAL no longer spans is refused before snapshots, the WAL tail or the receipt head move, +// so a second attempt at a reachable height still has the history it needs. Receipts are the ones a +// retry cannot recover: recoverReceipt drops bodies and range-deletes the tag index, which no replay +// puts back, so it has to run after the rollback that refuses rather than before it. +func TestRecoverRefusesATargetTheWALCannotSpan(t *testing.T) { + manager, cfg := openManager(t, disableSS) + snapshotSCAt(t, manager, 1) + manager.SC().SetCheckpointScheduler(controller.NewCheckpointScheduler( + config.CheckpointConfig{BlockInterval: 1_000_000})) + for block := byte(2); block <= 5; block++ { + require.NoError(t, manager.StateDB().CommitStateChanges(int64(block), evmBlock(block, block))) + } + writeReceipts(t, manager, 5) + closeStateDB(t, manager) + replaceWALWithBlocks(t, cfg, 3, 5) + + require.ErrorContains(t, reconvergeErr(t, manager, 2), "replay must start at block 2") + + require.NoError(t, manager.openStateDB(t.Context())) + require.NoError(t, manager.openReceiptStore()) + require.Equal(t, int64(5), manager.SC().Version(), "a refused rollback must not have moved SC") + requireWALTail(t, manager, 5) + require.Equal(t, int64(5), manager.ReceiptDB().LatestVersion(), + "a refused rollback must not have cut receipts it can no longer reach") +} + +// An empty WAL is no evidence about where state belongs, so a plain open leaves both stores holding the +// blocks they committed above their newest snapshot. Dropping them down to that snapshot is what a +// rollback does, from a target; doing it here would take SC down on its own and leave the two stores at +// different heights, with nothing left to reconcile them. +// +// Committing afterwards is the check that they are still aligned, not merely reporting the same height. +func TestOpenWithAnEmptyWALLeavesBothStoresAlone(t *testing.T) { + manager, cfg := openManager(t, nil) + huge := controller.NewCheckpointScheduler(config.CheckpointConfig{BlockInterval: 1_000_000}) + manager.SC().SetCheckpointScheduler(huge) + manager.SS().SetCheckpointScheduler(huge) + for block := byte(1); block <= 3; block++ { + require.NoError(t, manager.StateDB().CommitStateChanges(int64(block), evmBlock(block, block))) + } + closeStateDB(t, manager) + require.NoError(t, statewal.PruneAfter(flatkv.StateWALConfig(cfg.FlatKVConfig.DataDir), 0)) + + require.NoError(t, manager.openStateDB(t.Context())) + + require.Equal(t, int64(3), manager.SC().Version()) + require.Equal(t, int64(3), manager.SS().GetLatestVersion()) + require.NoError(t, manager.StateDB().CommitStateChanges(4, evmBlock(4, 4))) +} + // A commit store held above the WAL head by a snapshot of its own is rewound to a snapshot boundary at // or below the target and replayed back up to it, rather than left where it is. Only a snapshot can put // SC above the head, since a truncated WAL is otherwise what its load lands on; taking one above the @@ -241,7 +569,7 @@ func TestRecoverSCRollsBackToTheTarget(t *testing.T) { // Committing afterwards is the check that the rewind left both the store and the WAL writable at the // height it converged on, not merely reporting that height. func TestRecoverSCAboveTheWALHeadRewindsToASnapshotAndReplays(t *testing.T) { - manager, _ := openManager(t, nil) + manager, _ := openManager(t, disableSS) commitBlocks(t, manager, 2) snapshotSCAt(t, manager, 3) @@ -255,21 +583,21 @@ func TestRecoverSCAboveTheWALHeadRewindsToASnapshotAndReplays(t *testing.T) { func TestRecoverSSReplaysEVMChangesets(t *testing.T) { manager, _ := openManager(t, nil) commitBlocks(t, manager, 2) - require.Zero(t, manager.SS().GetLatestVersion()) + waitSSWrites(manager) + require.Equal(t, int64(2), manager.SS().GetLatestVersion()) + writeWALOnly(t, manager.StateWAL(), 3, evmBlock(3, 3)) - reconverge(t, manager, 2) + reconverge(t, manager, 3) - require.Equal(t, int64(2), manager.SS().GetLatestVersion()) - value, err := manager.SS().Get(evm.EVMStoreKey, 2, evmNonceKey(2)) + require.Equal(t, int64(3), manager.SS().GetLatestVersion()) + value, err := manager.SS().Get(evm.EVMStoreKey, 3, evmNonceKey(3)) require.NoError(t, err) - require.Equal(t, evmNonce(2), value) + require.Equal(t, evmNonce(3), value) } func TestRecoverSSRollsBackToTheTarget(t *testing.T) { manager, _ := openManager(t, nil) - commitBlocks(t, manager, 3) - snapshotSSAt(t, manager, 1) - applySSThrough(t, manager, 3) + commitBlocksWithSSSnapshots(t, manager, 3) reconverge(t, manager, 2) @@ -284,31 +612,28 @@ func TestRecoverSSRollsBackToTheTarget(t *testing.T) { // leaves the retention arithmetic reading a newest version the node has rejected. func TestRecoverSSRemovesSnapshotsAboveTheTarget(t *testing.T) { manager, _ := openManager(t, nil) - commitBlocks(t, manager, 3) - snapshotSSAt(t, manager, 1) - snapshotSSAt(t, manager, 3) + commitBlocksWithSSSnapshots(t, manager, 3) require.Equal(t, int64(3), manager.SS().Snapshots().Newest()) reconverge(t, manager, 2) - require.Equal(t, int64(1), manager.SS().Snapshots().Newest(), + require.Equal(t, int64(2), manager.SS().Snapshots().Newest(), "a snapshot above the target must not survive the rollback") require.Equal(t, int64(2), manager.SS().GetLatestVersion()) } -// RollbackTo on a live StateDB rewinds both halves of state and the WAL that feeds them, so the write -// head lands on the target. Committing the block after the target is what proves the WAL was truncated -// rather than only the stores rewound: a WAL still holding that block refuses to write it a second time. -// -// Nothing here reads the manager's WAL reference, because the truncation replaced the handle it holds. -func TestStateDBRollbackToRewindsBothHalvesAndTheWAL(t *testing.T) { +// Opening at a target rewinds SC, SS and the WAL that feeds them, so the write head lands on the +// target. Committing the block after the target is what proves the WAL was truncated rather than only +// the stores rewound: a WAL still holding that block refuses to write it a second time. +func TestOpenAtATargetRewindsEveryStoreAndTheWAL(t *testing.T) { manager, _ := openManager(t, nil) - commitBlocks(t, manager, 5) + commitBlocksWithSSSnapshots(t, manager, 5) - require.NoError(t, manager.StateDB().RollbackTo(2)) + reconverge(t, manager, 2) require.Equal(t, int64(2), manager.SC().Version()) require.Equal(t, int64(2), manager.SS().GetLatestVersion()) + requireWALTail(t, manager, 2) require.NoError(t, manager.StateDB().CommitStateChanges(3, evmBlock(3, 3))) require.Equal(t, int64(3), manager.SC().Version()) @@ -318,52 +643,76 @@ func TestStateDBRollbackToRewindsBothHalvesAndTheWAL(t *testing.T) { // moves anything. Every step of a rollback is irreversible while the replay that needs the blocks runs // last, so a shortfall found there would have already cut the WAL and dropped the snapshots that a // second attempt at a reachable height would need. -func TestStateDBRollbackToAboveTheWALHeadFails(t *testing.T) { +func TestOpenAtATargetAboveTheWALHeadFails(t *testing.T) { manager, _ := openManager(t, nil) commitBlocks(t, manager, 3) - require.ErrorContains(t, manager.StateDB().RollbackTo(5), "needs blocks 4-5, but the state WAL only holds 1-3") + require.ErrorContains(t, reconvergeErr(t, manager, 5), "the state WAL ends at 3") + // A failed open leaves nothing behind to read the result through, so the check is what a plain + // open finds: the stores as they were. + require.NoError(t, manager.openStateDB(t.Context())) require.Equal(t, int64(3), manager.SC().Version(), "a refused rollback must not have moved anything") requireWALTail(t, manager, 3) } -// A rollback establishes that both halves can reach the target before it moves either of them, because -// every step it takes is irreversible and the replays that need the WAL run last. -// -// SS is the half that can fail outright here: it restores from its own snapshots and has no base to fall -// back on, so one sitting above the target with no snapshot at or below it cannot be rewound at all. -// Discovering that after SC had been rewound, its snapshots above the target deleted and the WAL cut back -// would leave a node that will not start and no longer holds the blocks a second attempt would need. -func TestStateDBRollbackToRefusesAnUnreachableTargetBeforeMovingAnything(t *testing.T) { +// An SS above the target with no snapshot at or below it is emptied and replayed from block 1, the same +// route the plain open takes. A rollback reaches this more readily than an open does: recovery converges +// on the lowest of the block, state and receipt heads, so any crash leaving one of them a block behind +// the WAL lands below an SS that has not checkpointed yet. +func TestRecoverAboveSSWithoutASnapshotRebuildsItFromBlockOne(t *testing.T) { manager, _ := openManager(t, nil) commitBlocks(t, manager, 3) - applySSThrough(t, manager, 3) - require.ErrorContains(t, manager.StateDB().RollbackTo(2), "no snapshot at or below the target") + reconverge(t, manager, 2) - require.Equal(t, int64(3), manager.SC().Version(), "SC must not have been rewound") - require.Equal(t, int64(3), manager.SS().GetLatestVersion(), "SS must not have been rewound") - requireWALTail(t, manager, 3) - require.NoError(t, manager.StateDB().CommitStateChanges(4, evmBlock(4, 4)), - "a refused rollback must leave the state DB writable") + require.Equal(t, int64(2), manager.SC().Version()) + require.Equal(t, int64(2), manager.SS().GetLatestVersion()) + above, err := manager.SS().Get(evm.EVMStoreKey, 2, evmNonceKey(3)) + require.NoError(t, err) + require.Nil(t, above, "the block above the target must not have survived") + requireWALTail(t, manager, 2) + require.NoError(t, manager.StateDB().CommitStateChanges(3, evmBlock(3, 3))) +} + +// Refusing is still right once the WAL has had a retention cut: with no snapshot at or below the target +// and no block 1 to replay from, neither route reaches it, and emptying SS would drop history nothing +// can put back. +func TestRecoverAboveSSWithoutASnapshotOrBlockOneIsRefused(t *testing.T) { + manager, cfg := openManager(t, nil) + commitBlocks(t, manager, 3) + // SC gets a snapshot on the target so that it reaches the target on its own. Without one it lands + // on 0, which this WAL cannot replay from either, and SC refuses before SS is ever asked. + snapshotSCAt(t, manager, 4) + require.NoError(t, manager.StateDB().CommitStateChanges(5, evmBlock(5, 5))) + closeStateDB(t, manager) + replaceWALWithBlocks(t, cfg, 3, 5) + + require.ErrorContains(t, reconvergeErr(t, manager, 4), + "no replay from block 1 is available to rebuild it from") + + require.NoError(t, manager.openStateDB(t.Context())) + require.Equal(t, int64(5), manager.SS().GetLatestVersion(), + "a refused rollback must not have emptied the EVM state store") + requireWALTail(t, manager, 5) } -// A target of 0 has to be refused by RollbackTo itself, because neither rewind it delegates to is -// reached: each skips a store already at or below the target, and every store is at or below 0. +// A target of 0 has to be refused by the constructor that takes one, and refused there rather than by a +// rewind deep inside it. // -// The fixture is the state NewStateDB leaves — SC opened on snapshot 0, replaying nothing, with the WAL -// holding blocks — so every step between the two rewinds runs. Ungated, the WAL prune empties the WAL, -// the snapshot removal takes every snapshot, and the landing check passes because both halves really -// are on 0. The only thing standing between that and a wipe is recoverStores' own target check, and -// RollbackTo is exported on the StateDB contract. -func TestStateDBRollbackToZeroIsRefused(t *testing.T) { +// This fixture's WAL holds a block, so ungated the rollback would reach rewindSC and fail on that +// function's own refusal to rewind to 0. On an empty WAL nothing would refuse it at all: rewindTo reads +// a head of 0 as nothing to rewind, and the caller would get a plain open. Recovery routes a target of 0 +// to the plain open and never reaches this, so the guard is what covers a caller naming 0 outright. +func TestOpenAtAZeroTargetIsRefused(t *testing.T) { manager, _ := openManager(t, nil) writeWALOnly(t, manager.StateWAL(), 1, evmBlock(1, 1)) require.Zero(t, manager.SC().Version(), "fixture precondition: SC must read as 0 under a populated WAL") + closeStateDB(t, manager) - require.ErrorContains(t, manager.StateDB().RollbackTo(0), "nothing to roll back to") + require.ErrorContains(t, manager.openStateDBAt(t.Context(), 0), "nothing to roll back to") + require.NoError(t, manager.openStateDB(t.Context())) requireWALTail(t, manager, 1) } diff --git a/sei-db/bootstrap/storage_manager.go b/sei-db/bootstrap/storage_manager.go index 6d69b3e1b6..38cb24e794 100644 --- a/sei-db/bootstrap/storage_manager.go +++ b/sei-db/bootstrap/storage_manager.go @@ -31,7 +31,7 @@ type GigaStorageManager struct { receiptDB receipt.ReceiptStore // stateDB owns the state commit store, the EVM state store and the state WAL they share, along - // with the checkpoint schedule the two halves run on. + // with the checkpoint schedule those stores run on. stateDB *giga.StateDB // gc is nil until startGarbageCollector succeeds. @@ -96,8 +96,8 @@ func (m *GigaStorageManager) BlockStore() *blockstore.Store { return m.blockStor // ReceiptDB returns the receipt store, or nil when receipts are disabled. func (m *GigaStorageManager) ReceiptDB() receipt.ReceiptStore { return m.receiptDB } -// StateDB returns the Giga state DB over the WAL and the two halves of state, or nil when the open did -// not reach it. +// StateDB returns the Giga state DB over the state WAL, the state commit store and the EVM state +// store, or nil when the open did not reach it. func (m *GigaStorageManager) StateDB() *giga.StateDB { return m.stateDB } // StateWAL returns the state WAL that StateDB writes, or nil before the StateDB is open. @@ -149,8 +149,8 @@ func (m *GigaStorageManager) Close() error { return errors.Join(errs, m.closeState()) } -// closeState closes the two halves of state and the WAL they share, which the StateDB owns. It is nil -// when the open failed before reaching it, and closes its own partial state when it failed partway. +// closeState closes the stores the StateDB owns. The StateDB is nil when the open failed before +// reaching it, and closes whatever it had opened when it failed partway. func (m *GigaStorageManager) closeState() error { if m.stateDB == nil { return nil diff --git a/sei-db/bootstrap/storage_manager_test.go b/sei-db/bootstrap/storage_manager_test.go index 0de4505c85..92cedfaa40 100644 --- a/sei-db/bootstrap/storage_manager_test.go +++ b/sei-db/bootstrap/storage_manager_test.go @@ -11,6 +11,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/controller" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/evm" ) // openManager opens a manager over a fresh home directory, applying tweak to the default config @@ -205,8 +206,11 @@ func TestStateDBCommitsToWALAndLiveSC(t *testing.T) { }, }} require.NoError(t, manager.StateDB().CommitStateChanges(1, cs)) + waitSSWrites(manager) require.Equal(t, int64(1), manager.SC().Version()) + require.Equal(t, int64(1), manager.SS().GetLatestVersion(), + "a committed block must advance the EVM state store even when it carries no EVM keys") ok, first, last, err := manager.StateWAL().GetStoredRange() require.NoError(t, err) require.True(t, ok) @@ -214,6 +218,18 @@ func TestStateDBCommitsToWALAndLiveSC(t *testing.T) { require.Equal(t, uint64(1), last) } +func TestStateDBCommitsEVMChangesToSS(t *testing.T) { + manager, _ := openManager(t, nil) + + require.NoError(t, manager.StateDB().CommitStateChanges(1, evmBlock(1, 1))) + waitSSWrites(manager) + + require.Equal(t, int64(1), manager.SS().GetLatestVersion()) + value, err := manager.SS().Get(evm.EVMStoreKey, 1, evmNonceKey(1)) + require.NoError(t, err) + require.Equal(t, evmNonce(1), value) +} + // TestEveryStoreJoinsThePruneCycle pins which stores the shared cut line covers. // // The cut line is a minimum over the floors the collector is handed, so a store left out cannot hold diff --git a/sei-db/db_engine/pebbledb/db.go b/sei-db/db_engine/pebbledb/db.go index 686e9bad38..95e723c8af 100644 --- a/sei-db/db_engine/pebbledb/db.go +++ b/sei-db/db_engine/pebbledb/db.go @@ -27,7 +27,8 @@ type pebbleDB struct { var _ types.KeyValueDB = (*pebbleDB)(nil) -// Open opens (or creates) a Pebble-backed DB at path, returning a KeyValueDB +// Open opens (or creates) a Pebble-backed DB at path, returning a KeyValueDB. +// ctx is unused: metrics collection is stopped by Close, not by cancellation. func Open( ctx context.Context, config *PebbleDBConfig, @@ -85,14 +86,14 @@ func Open( return nil, err } - ctx, cancel := context.WithCancel(ctx) + var metricsCancel func() if config.EnableMetrics { - NewPebbleMetrics(ctx, db, filepath.Base(config.DataDir), config.MetricsScrapeInterval) + metricsCancel = NewPebbleMetrics(db, filepath.Base(config.DataDir), config.MetricsScrapeInterval) } return &pebbleDB{ db: db, - metricsCancel: cancel, + metricsCancel: metricsCancel, operationMetrics: NewOperationMetrics(config.EnableReadWriteMetrics, filepath.Base(config.DataDir)), }, nil } diff --git a/sei-db/db_engine/pebbledb/mvcc/db.go b/sei-db/db_engine/pebbledb/mvcc/db.go index b645300944..71799e5e0a 100644 --- a/sei-db/db_engine/pebbledb/mvcc/db.go +++ b/sei-db/db_engine/pebbledb/mvcc/db.go @@ -253,11 +253,8 @@ func OpenDB(dataDir string, config config.StateStoreConfig) (types.StateStore, e database.asyncWriteWG.Add(1) go database.writeAsyncInBackground() - // Start background metrics collection for Pebble-internal stats - // (compaction, flush, sstable, memtable, WAL, cache). - metricsCtx, metricsCancel := context.WithCancel(context.Background()) - database.metricsCancel = metricsCancel - pebbledbmetrics.NewPebbleMetrics(metricsCtx, db, dbName, 10*time.Second) + // Refresh Pebble-internal stats (compaction, flush, sstable, memtable, WAL, cache). + database.metricsCancel = pebbledbmetrics.NewPebbleMetrics(db, dbName, 10*time.Second) return database, nil } diff --git a/sei-db/db_engine/pebbledb/pebble_metrics.go b/sei-db/db_engine/pebbledb/pebble_metrics.go index 54f66c589e..b3d9514e64 100644 --- a/sei-db/db_engine/pebbledb/pebble_metrics.go +++ b/sei-db/db_engine/pebbledb/pebble_metrics.go @@ -2,7 +2,8 @@ package pebbledb import ( "context" - "math" + "sync" + "sync/atomic" "time" "go.opentelemetry.io/otel" @@ -10,1730 +11,410 @@ import ( "go.opentelemetry.io/otel/metric" "github.com/cockroachdb/pebble/v2" - - smetrics "github.com/sei-protocol/sei-chain/sei-db/common/metrics" ) const pebbleMeterName = "seidb_pebble" -// PebbleMetrics scrapes metrics from a Pebble DB and records them via OTel instruments. -// Instrument names match sei-db/db_engine/pebbledb/mvcc for dashboard compatibility. -// The databaseName is used as the "db" attribute on all recorded metrics. -// -// Multiple instances are safe: OTel instrument registration is idempotent, so each -// NewPebbleMetrics call receives references to the same underlying instruments. -// The "db" attribute distinguishes series (e.g. pebble_compaction_count{db="state"}). -type PebbleMetrics struct { - db *pebble.DB - databaseName string - - getLatency metric.Float64Histogram - applyChangesetLatency metric.Float64Histogram - applyChangesetAsyncLatency metric.Float64Histogram - pruneLatency metric.Float64Histogram - importLatency metric.Float64Histogram - batchWriteLatency metric.Float64Histogram - - compactionCount metric.Int64Counter - compactionDuration metric.Float64Histogram - compactionBytesRead metric.Int64Counter - compactionBytesWritten metric.Int64Counter - compactionEstimatedDebt metric.Int64Gauge - compactionInProgressBytes metric.Int64Gauge - compactionNumInProgress metric.Int64Gauge - compactionCancelledCount metric.Int64Counter - compactionCancelledBytes metric.Int64Counter - compactionFailedCount metric.Int64Counter - compactionDefaultCount metric.Int64Counter - compactionDeleteOnlyCount metric.Int64Counter - compactionElisionOnlyCount metric.Int64Counter - compactionCopyCount metric.Int64Counter - compactionMoveCount metric.Int64Counter - compactionReadCount metric.Int64Counter - compactionTombstoneDensityCount metric.Int64Counter - compactionRewriteCount metric.Int64Counter - compactionMultiLevelCount metric.Int64Counter - compactionBlobFileRewriteCount metric.Int64Counter - compactionCounterLevelCount metric.Int64Counter - compactionNumProblemSpans metric.Int64Gauge - compactionMarkedFiles metric.Int64Gauge - - ingestCount metric.Int64Counter - - flushCount metric.Int64Counter - flushDuration metric.Float64Histogram - flushBytesWritten metric.Int64Counter - flushNumInProgress metric.Int64Gauge - flushAsIngestCount metric.Int64Counter - flushAsIngestTableCount metric.Int64Counter - flushAsIngestBytes metric.Int64Counter - flushIdleDuration metric.Float64Gauge - - filterHits metric.Int64Counter - filterMisses metric.Int64Counter - - sstableCount metric.Int64Gauge - sstableTotalSize metric.Int64Gauge - sstableSublevels metric.Int64Gauge - sstableScore metric.Float64Gauge - sstableFillFactor metric.Float64Gauge - sstableVirtualCount metric.Int64Gauge - sstableVirtualSize metric.Int64Gauge - sstableBytesIngested metric.Int64Counter - sstableBytesMoved metric.Int64Counter - sstableBytesRead metric.Int64Counter - sstableBytesFlushed metric.Int64Counter - sstableTablesCompacted metric.Int64Counter - sstableTablesFlushed metric.Int64Counter - sstableTablesIngested metric.Int64Counter - sstableTablesMoved metric.Int64Counter - sstableCompensatedFillFactor metric.Float64Gauge - sstableEstimatedReferencesSize metric.Int64Gauge - sstableTablesDeleted metric.Int64Counter - sstableTablesExcised metric.Int64Counter - sstableBlobBytesReadEstimate metric.Int64Counter - sstableBlobBytesCompacted metric.Int64Counter - sstableBlobBytesFlushed metric.Int64Counter - sstableMultiLevelBytesInTop metric.Int64Counter - sstableMultiLevelBytesIn metric.Int64Counter - sstableMultiLevelBytesRead metric.Int64Counter - sstableValueBlocksSize metric.Int64Gauge - sstableBytesWrittenDataBlocks metric.Int64Counter - sstableBytesWrittenValueBlocks metric.Int64Counter - - memtableCount metric.Int64Gauge - memtableTotalSize metric.Int64Gauge - memtableZombieSize metric.Int64Gauge - memtableZombieCount metric.Int64Gauge - - walSize metric.Int64Gauge - walFiles metric.Int64Gauge - walObsoleteFiles metric.Int64Gauge - walObsoletePhysicalSize metric.Int64Gauge - walPhysicalSize metric.Int64Gauge - walBytesIn metric.Int64Counter - walBytesWritten metric.Int64Counter - - tableObsoleteSize metric.Int64Gauge - tableObsoleteCount metric.Int64Gauge - tableZombieSize metric.Int64Gauge - tableZombieCount metric.Int64Gauge - tableLiveSize metric.Int64Gauge - tableLiveCount metric.Int64Gauge - tableBackingCount metric.Int64Gauge - tableBackingSize metric.Int64Gauge - tableCompressedUnknown metric.Int64Gauge - tableCompressedSnappy metric.Int64Gauge - tableCompressedZstd metric.Int64Gauge - tableCompressedMinLZ metric.Int64Gauge - tableCompressedNone metric.Int64Gauge - tableLocalObsoleteSize metric.Int64Gauge - tableLocalObsoleteCount metric.Int64Gauge - tableLocalZombieSize metric.Int64Gauge - tableLocalZombieCount metric.Int64Gauge - tableGarbagePointDeletionsEstimate metric.Int64Gauge - tableGarbageRangeDeletionsEstimate metric.Int64Gauge - tableInitialStatsComplete metric.Int64Gauge - tablePendingStatsCount metric.Int64Gauge - - blobFilesLiveCount metric.Int64Gauge - blobFilesLiveSize metric.Int64Gauge - blobFilesValueSize metric.Int64Gauge - blobFilesReferencedValueSize metric.Int64Gauge - blobFilesObsoleteCount metric.Int64Gauge - blobFilesObsoleteSize metric.Int64Gauge - blobFilesZombieCount metric.Int64Gauge - blobFilesZombieSize metric.Int64Gauge - blobFilesLocalLiveSize metric.Int64Gauge - blobFilesLocalLiveCount metric.Int64Gauge - blobFilesLocalObsoleteSize metric.Int64Gauge - blobFilesLocalObsoleteCount metric.Int64Gauge - blobFilesLocalZombieSize metric.Int64Gauge - blobFilesLocalZombieCount metric.Int64Gauge - - fileCacheSize metric.Int64Gauge - fileCacheTableCount metric.Int64Gauge - fileCacheBlobFileCount metric.Int64Gauge - fileCacheHits metric.Int64Counter - fileCacheMisses metric.Int64Counter - - // prev* track last scraped cumulative values so we Add(delta) not Add(total). - prevCompactionCount int64 - prevCompactionCancelledCount int64 - prevCompactionCancelledBytes int64 - prevCompactionFailedCount int64 - prevCompactionDefaultCount int64 - prevCompactionDeleteOnlyCount int64 - prevCompactionElisionOnlyCount int64 - prevCompactionCopyCount int64 - prevCompactionMoveCount int64 - prevCompactionReadCount int64 - prevCompactionTombstoneDensityCount int64 - prevCompactionRewriteCount int64 - prevCompactionMultiLevelCount int64 - prevCompactionBlobFileRewriteCount int64 - prevCompactionCounterLevelCount int64 - prevIngestCount int64 - prevFlushCount int64 - prevFlushBytesWritten int64 - prevFlushAsIngestCount int64 - prevFlushAsIngestTableCount int64 - prevFlushAsIngestBytes int64 - prevFilterHits int64 - prevFilterMisses int64 - prevWalBytesIn int64 - prevWalBytesWritten int64 - prevWalFailoverDirSwitchCount int64 - prevKeysMissizedTombstonesCount int64 - prevSnapshotPinnedKeys int64 - prevSnapshotPinnedSize int64 - prevFileCacheHits int64 - prevFileCacheMisses int64 - prevCacheHits int64 - prevCacheMisses int64 +// numLevels is how many LSM levels a Pebble snapshot reports. +const numLevels = len(pebble.Metrics{}.Levels) - // prev*ByLevel hold previous cumulative values per level (index = level). - prevCompactionBytesReadByLevel []int64 - prevCompactionBytesWrittenByLevel []int64 - prevSstableBytesIngestedByLevel []int64 - prevSstableBytesMovedByLevel []int64 - prevSstableBytesReadByLevel []int64 - prevSstableBytesFlushedByLevel []int64 - prevSstableTablesCompactedByLevel []int64 - prevSstableTablesFlushedByLevel []int64 - prevSstableTablesIngestedByLevel []int64 - prevSstableTablesMovedByLevel []int64 - prevSstableTablesDeletedByLevel []int64 - prevSstableTablesExcisedByLevel []int64 - prevSstableBlobBytesReadEstimateByLevel []int64 - prevSstableBlobBytesCompactedByLevel []int64 - prevSstableBlobBytesFlushedByLevel []int64 - prevSstableMultiLevelBytesInTopByLevel []int64 - prevSstableMultiLevelBytesInByLevel []int64 - prevSstableMultiLevelBytesReadByLevel []int64 - prevSstableBytesWrittenDataBlocksByLevel []int64 - prevSstableBytesWrittenValueBlocksByLevel []int64 +type pebbleMetrics struct { + meter metric.Meter + insts []metric.Observable - walFailoverDirSwitchCount metric.Int64Counter - walFailoverPrimaryDuration metric.Float64Gauge - walFailoverSecondaryDuration metric.Float64Gauge + dbAttrs metric.ObserveOption + levelAttrs [numLevels]metric.ObserveOption - numVirtual metric.Int64Gauge - virtualSize metric.Int64Gauge - remoteTablesCount metric.Int64Gauge - remoteTablesSize metric.Int64Gauge - - keysRangeKeySetsCount metric.Int64Gauge - keysTombstoneCount metric.Int64Gauge - keysMissizedTombstonesCount metric.Int64Counter - - snapshotCount metric.Int64Gauge - snapshotPinnedKeys metric.Int64Counter - snapshotPinnedSize metric.Int64Counter - snapshotEarliestSeqNum metric.Int64Gauge - - tableIters metric.Int64Gauge - uptimeSeconds metric.Float64Gauge - readAmp metric.Int64Gauge - diskSpaceUsage metric.Int64Gauge - - cacheHits metric.Int64Counter - cacheMisses metric.Int64Counter - cacheSize metric.Int64Gauge - - batchSize metric.Int64Histogram - pendingChangesQueueDepth metric.Int64Gauge - iteratorIterations metric.Float64Histogram + snapshot atomic.Pointer[pebble.Metrics] + report []func(metric.Observer, *pebble.Metrics) } -// NewPebbleMetrics creates a PebbleMetrics that scrapes metrics from the given Pebble DB -// and records them to OTel. A background goroutine runs every scrapeInterval until -// ctx is cancelled. The databaseName is attached as the "db" attribute to all recorded -// metrics, enabling multi-DB setups to distinguish series in Prometheus/Grafana. -// -// Multiple instances (e.g. one per DB) are safe: OTel returns the same instruments -// for duplicate registrations, and the "db" attribute separates series. -func NewPebbleMetrics( - ctx context.Context, - db *pebble.DB, - databaseName string, - scrapeInterval time.Duration, -) *PebbleMetrics { +// NewPebbleMetrics registers OTel observables over a Pebble metrics snapshot +func NewPebbleMetrics(db *pebble.DB, databaseName string, refreshInterval time.Duration) func() { meter := otel.Meter(pebbleMeterName) - - getLatency, _ := meter.Float64Histogram( - "pebble_get_latency", - metric.WithDescription("Time taken to get a key from PebbleDB"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(smetrics.LatencyBuckets...), - ) - applyChangesetLatency, _ := meter.Float64Histogram( - "pebble_apply_changeset_latency", - metric.WithDescription("Time taken to apply changeset to PebbleDB"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(smetrics.LatencyBuckets...), - ) - applyChangesetAsyncLatency, _ := meter.Float64Histogram( - "pebble_apply_changeset_async_latency", - metric.WithDescription("Time taken to queue changeset for async write"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(smetrics.LatencyBuckets...), - ) - pruneLatency, _ := meter.Float64Histogram( - "pebble_prune_latency", - metric.WithDescription("Time taken to prune old versions from PebbleDB"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(smetrics.LatencyBuckets...), - ) - importLatency, _ := meter.Float64Histogram( - "pebble_import_latency", - metric.WithDescription("Time taken to import snapshot data to PebbleDB"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(smetrics.LatencyBuckets...), - ) - batchWriteLatency, _ := meter.Float64Histogram( - "pebble_batch_write_latency", - metric.WithDescription("Time taken to write a batch to PebbleDB"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(smetrics.LatencyBuckets...), - ) - - compactionCount, _ := meter.Int64Counter( - "pebble_compaction_count", - metric.WithDescription("Total number of compactions"), - metric.WithUnit("{count}"), - ) - compactionDuration, _ := meter.Float64Histogram( - "pebble_compaction_duration", - metric.WithDescription("Duration of compaction operations"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(smetrics.LatencyBuckets...), - ) - compactionBytesRead, _ := meter.Int64Counter( - "pebble_compaction_bytes_read", - metric.WithDescription("Total bytes read during compaction"), - metric.WithUnit("By"), - ) - compactionBytesWritten, _ := meter.Int64Counter( - "pebble_compaction_bytes_written", - metric.WithDescription("Total bytes written during compaction"), - metric.WithUnit("By"), - ) - compactionEstimatedDebt, _ := meter.Int64Gauge( - "pebble_compaction_estimated_debt", - metric.WithDescription("Estimated bytes to compact for LSM to reach stable state"), - metric.WithUnit("By"), - ) - compactionInProgressBytes, _ := meter.Int64Gauge( - "pebble_compaction_in_progress_bytes", - metric.WithDescription("Bytes in sstables being written by in-progress compactions"), - metric.WithUnit("By"), - ) - compactionNumInProgress, _ := meter.Int64Gauge( - "pebble_compaction_num_in_progress", - metric.WithDescription("Number of compactions in progress"), - metric.WithUnit("{count}"), - ) - compactionCancelledCount, _ := meter.Int64Counter( - "pebble_compaction_cancelled_count", - metric.WithDescription("Number of compactions that were cancelled"), - metric.WithUnit("{count}"), - ) - compactionCancelledBytes, _ := meter.Int64Counter( - "pebble_compaction_cancelled_bytes", - metric.WithDescription("Bytes written by cancelled compactions"), - metric.WithUnit("By"), - ) - compactionFailedCount, _ := meter.Int64Counter( - "pebble_compaction_failed_count", - metric.WithDescription("Number of compactions that hit an error"), - metric.WithUnit("{count}"), - ) - compactionDefaultCount, _ := meter.Int64Counter( - "pebble_compaction_default_count", - metric.WithDescription("Default compactions"), - metric.WithUnit("{count}"), - ) - compactionDeleteOnlyCount, _ := meter.Int64Counter( - "pebble_compaction_delete_only_count", - metric.WithDescription("Delete-only compactions"), - metric.WithUnit("{count}"), - ) - compactionElisionOnlyCount, _ := meter.Int64Counter( - "pebble_compaction_elision_only_count", - metric.WithDescription("Elision-only compactions"), - metric.WithUnit("{count}"), - ) - compactionCopyCount, _ := meter.Int64Counter( - "pebble_compaction_copy_count", - metric.WithDescription("Copy compactions"), - metric.WithUnit("{count}"), - ) - compactionMoveCount, _ := meter.Int64Counter( - "pebble_compaction_move_count", - metric.WithDescription("Move compactions"), - metric.WithUnit("{count}"), - ) - compactionReadCount, _ := meter.Int64Counter( - "pebble_compaction_read_count", - metric.WithDescription("Read compactions"), - metric.WithUnit("{count}"), - ) - compactionTombstoneDensityCount, _ := meter.Int64Counter( - "pebble_compaction_tombstone_density_count", - metric.WithDescription("Tombstone-density compactions"), - metric.WithUnit("{count}"), - ) - compactionRewriteCount, _ := meter.Int64Counter( - "pebble_compaction_rewrite_count", - metric.WithDescription("Rewrite compactions"), - metric.WithUnit("{count}"), - ) - compactionMultiLevelCount, _ := meter.Int64Counter( - "pebble_compaction_multi_level_count", - metric.WithDescription("Multi-level compactions"), - metric.WithUnit("{count}"), - ) - compactionBlobFileRewriteCount, _ := meter.Int64Counter( - "pebble_compaction_blob_file_rewrite_count", - metric.WithDescription("Blob file rewrite compactions"), - metric.WithUnit("{count}"), - ) - compactionCounterLevelCount, _ := meter.Int64Counter( - "pebble_compaction_counter_level_count", - metric.WithDescription("Counter-level compactions"), - metric.WithUnit("{count}"), - ) - compactionNumProblemSpans, _ := meter.Int64Gauge( - "pebble_compaction_num_problem_spans", - metric.WithDescription("Problem spans blocking compactions"), - metric.WithUnit("{count}"), - ) - compactionMarkedFiles, _ := meter.Int64Gauge( - "pebble_compaction_marked_files", - metric.WithDescription("Files marked for compaction"), - metric.WithUnit("{count}"), - ) - - ingestCount, _ := meter.Int64Counter( - "pebble_ingest_count", - metric.WithDescription("Total number of ingestions"), - metric.WithUnit("{count}"), - ) - - flushCount, _ := meter.Int64Counter( - "pebble_flush_count", - metric.WithDescription("Total number of memtable flushes"), - metric.WithUnit("{count}"), - ) - flushDuration, _ := meter.Float64Histogram( - "pebble_flush_duration", - metric.WithDescription("Duration of memtable flush operations"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(smetrics.LatencyBuckets...), - ) - flushBytesWritten, _ := meter.Int64Counter( - "pebble_flush_bytes_written", - metric.WithDescription("Total bytes written during memtable flushes"), - metric.WithUnit("By"), - ) - flushNumInProgress, _ := meter.Int64Gauge( - "pebble_flush_num_in_progress", - metric.WithDescription("Number of flushes in progress"), - metric.WithUnit("{count}"), - ) - flushAsIngestCount, _ := meter.Int64Counter( - "pebble_flush_as_ingest_count", - metric.WithDescription("Flush operations handling ingested tables"), - metric.WithUnit("{count}"), - ) - flushAsIngestTableCount, _ := meter.Int64Counter( - "pebble_flush_as_ingest_table_count", - metric.WithDescription("Tables ingested as flushables"), - metric.WithUnit("{count}"), - ) - flushAsIngestBytes, _ := meter.Int64Counter( - "pebble_flush_as_ingest_bytes", - metric.WithDescription("Bytes flushed for flushables from ingestion"), - metric.WithUnit("By"), - ) - flushIdleDuration, _ := meter.Float64Gauge( - "pebble_flush_idle_duration", - metric.WithDescription("Idle duration before memtable flushes"), - metric.WithUnit("s"), - ) - - filterHits, _ := meter.Int64Counter( - "pebble_filter_hits", - metric.WithDescription("Bloom filter hits (block reads avoided)"), - metric.WithUnit("{count}"), - ) - filterMisses, _ := meter.Int64Counter( - "pebble_filter_misses", - metric.WithDescription("Bloom filter misses"), - metric.WithUnit("{count}"), - ) - - sstableCount, _ := meter.Int64Gauge( - "pebble_sstable_count", - metric.WithDescription("Current number of SSTables at each level"), - metric.WithUnit("{count}"), - ) - sstableTotalSize, _ := meter.Int64Gauge( - "pebble_sstable_total_size", - metric.WithDescription("Total size of SSTables at each level"), - metric.WithUnit("By"), - ) - sstableSublevels, _ := meter.Int64Gauge( - "pebble_sstable_sublevels", - metric.WithDescription("Number of sublevels (read amplification); L0 only has non-0/1"), - metric.WithUnit("{count}"), - ) - sstableScore, _ := meter.Float64Gauge( - "pebble_sstable_score", - metric.WithDescription("Level compaction score (0 if no compaction needed)"), - metric.WithUnit("1"), - ) - sstableFillFactor, _ := meter.Float64Gauge( - "pebble_sstable_fill_factor", - metric.WithDescription("Level fill factor (size vs ideal size)"), - metric.WithUnit("1"), - ) - sstableVirtualCount, _ := meter.Int64Gauge( - "pebble_sstable_virtual_count", - metric.WithDescription("Number of virtual sstables at level"), - metric.WithUnit("{count}"), - ) - sstableVirtualSize, _ := meter.Int64Gauge( - "pebble_sstable_virtual_size", - metric.WithDescription("Size of virtual sstables at level"), - metric.WithUnit("By"), - ) - sstableBytesIngested, _ := meter.Int64Counter( - "pebble_sstable_bytes_ingested", - metric.WithDescription("Sstable bytes ingested at level"), - metric.WithUnit("By"), - ) - sstableBytesMoved, _ := meter.Int64Counter( - "pebble_sstable_bytes_moved", - metric.WithDescription("Sstable bytes moved by move compaction at level"), - metric.WithUnit("By"), - ) - sstableBytesRead, _ := meter.Int64Counter( - "pebble_sstable_bytes_read", - metric.WithDescription("Bytes read for compactions at level"), - metric.WithUnit("By"), - ) - sstableBytesFlushed, _ := meter.Int64Counter( - "pebble_sstable_bytes_flushed", - metric.WithDescription("Bytes written to sstables during flushes at level"), - metric.WithUnit("By"), - ) - sstableTablesCompacted, _ := meter.Int64Counter( - "pebble_sstable_tables_compacted", - metric.WithDescription("Sstables compacted to this level"), - metric.WithUnit("{count}"), - ) - sstableTablesFlushed, _ := meter.Int64Counter( - "pebble_sstable_tables_flushed", - metric.WithDescription("Sstables flushed to this level"), - metric.WithUnit("{count}"), - ) - sstableTablesIngested, _ := meter.Int64Counter( - "pebble_sstable_tables_ingested", - metric.WithDescription("Sstables ingested into level"), - metric.WithUnit("{count}"), - ) - sstableTablesMoved, _ := meter.Int64Counter( - "pebble_sstable_tables_moved", - metric.WithDescription("Sstables moved to level by move compaction"), - metric.WithUnit("{count}"), - ) - sstableCompensatedFillFactor, _ := meter.Float64Gauge( - "pebble_sstable_compensated_fill_factor", - metric.WithDescription("Level compensated fill factor"), - metric.WithUnit("1"), - ) - sstableEstimatedReferencesSize, _ := meter.Int64Gauge( - "pebble_sstable_estimated_references_size", - metric.WithDescription("Est. physical size of blob refs at level"), - metric.WithUnit("By"), - ) - sstableTablesDeleted, _ := meter.Int64Counter( - "pebble_sstable_tables_deleted", - metric.WithDescription("Sstables deleted by delete-only compaction at level"), - metric.WithUnit("{count}"), - ) - sstableTablesExcised, _ := meter.Int64Counter( - "pebble_sstable_tables_excised", - metric.WithDescription("Sstables excised by delete-only compaction at level"), - metric.WithUnit("{count}"), - ) - sstableBlobBytesReadEstimate, _ := meter.Int64Counter( - "pebble_sstable_blob_bytes_read_estimate", - metric.WithDescription("Est. physical bytes read for blob refs at level"), - metric.WithUnit("By"), - ) - sstableBlobBytesCompacted, _ := meter.Int64Counter( - "pebble_sstable_blob_bytes_compacted", - metric.WithDescription("Blob bytes written during compaction at level"), - metric.WithUnit("By"), - ) - sstableBlobBytesFlushed, _ := meter.Int64Counter( - "pebble_sstable_blob_bytes_flushed", - metric.WithDescription("Blob bytes written during flush at level"), - metric.WithUnit("By"), - ) - sstableMultiLevelBytesInTop, _ := meter.Int64Counter( - "pebble_sstable_multi_level_bytes_in_top", - metric.WithDescription("Bytes from top level in multilevel compaction"), - metric.WithUnit("By"), - ) - sstableMultiLevelBytesIn, _ := meter.Int64Counter( - "pebble_sstable_multi_level_bytes_in", - metric.WithDescription("Bytes in for multilevel compaction"), - metric.WithUnit("By"), - ) - sstableMultiLevelBytesRead, _ := meter.Int64Counter( - "pebble_sstable_multi_level_bytes_read", - metric.WithDescription("Bytes read for multilevel compaction"), - metric.WithUnit("By"), - ) - sstableValueBlocksSize, _ := meter.Int64Gauge( - "pebble_sstable_value_blocks_size", - metric.WithDescription("Value blocks size at level"), - metric.WithUnit("By"), - ) - sstableBytesWrittenDataBlocks, _ := meter.Int64Counter( - "pebble_sstable_bytes_written_data_blocks", - metric.WithDescription("Bytes written to data blocks at level"), - metric.WithUnit("By"), - ) - sstableBytesWrittenValueBlocks, _ := meter.Int64Counter( - "pebble_sstable_bytes_written_value_blocks", - metric.WithDescription("Bytes written to value blocks at level"), - metric.WithUnit("By"), - ) - - memtableCount, _ := meter.Int64Gauge( - "pebble_memtable_count", - metric.WithDescription("Current number of memtables"), - metric.WithUnit("{count}"), - ) - memtableTotalSize, _ := meter.Int64Gauge( - "pebble_memtable_total_size", - metric.WithDescription("Total size of all memtables"), - metric.WithUnit("By"), - ) - memtableZombieSize, _ := meter.Int64Gauge( - "pebble_memtable_zombie_size", - metric.WithDescription("Bytes in zombie memtables (released but in use by iterators)"), - metric.WithUnit("By"), - ) - memtableZombieCount, _ := meter.Int64Gauge( - "pebble_memtable_zombie_count", - metric.WithDescription("Count of zombie memtables"), - metric.WithUnit("{count}"), - ) - - walSize, _ := meter.Int64Gauge( - "pebble_wal_size", - metric.WithDescription("Current size of Write-Ahead Log"), - metric.WithUnit("By"), - ) - walFiles, _ := meter.Int64Gauge( - "pebble_wal_files", - metric.WithDescription("Number of live WAL files"), - metric.WithUnit("{count}"), - ) - walObsoleteFiles, _ := meter.Int64Gauge( - "pebble_wal_obsolete_files", - metric.WithDescription("Number of obsolete WAL files"), - metric.WithUnit("{count}"), - ) - walObsoletePhysicalSize, _ := meter.Int64Gauge( - "pebble_wal_obsolete_physical_size", - metric.WithDescription("Physical size of obsolete WAL files"), - metric.WithUnit("By"), - ) - walPhysicalSize, _ := meter.Int64Gauge( - "pebble_wal_physical_size", - metric.WithDescription("Physical size of WAL files on disk"), - metric.WithUnit("By"), - ) - walBytesIn, _ := meter.Int64Counter( - "pebble_wal_bytes_in", - metric.WithDescription("Logical bytes written to WAL"), - metric.WithUnit("By"), - ) - walBytesWritten, _ := meter.Int64Counter( - "pebble_wal_bytes_written", - metric.WithDescription("Bytes written to WAL"), - metric.WithUnit("By"), - ) - - tableObsoleteSize, _ := meter.Int64Gauge( - "pebble_table_obsolete_size", - metric.WithDescription("Bytes in obsolete tables no longer referenced"), - metric.WithUnit("By"), - ) - tableObsoleteCount, _ := meter.Int64Gauge( - "pebble_table_obsolete_count", - metric.WithDescription("Count of obsolete tables"), - metric.WithUnit("{count}"), - ) - tableZombieSize, _ := meter.Int64Gauge( - "pebble_table_zombie_size", - metric.WithDescription("Bytes in zombie tables (released but in use by iterators)"), - metric.WithUnit("By"), - ) - tableZombieCount, _ := meter.Int64Gauge( - "pebble_table_zombie_count", - metric.WithDescription("Count of zombie tables"), - metric.WithUnit("{count}"), - ) - tableLiveSize, _ := meter.Int64Gauge( - "pebble_table_live_size", - metric.WithDescription("Bytes in live tables"), - metric.WithUnit("By"), - ) - tableLiveCount, _ := meter.Int64Gauge( - "pebble_table_live_count", - metric.WithDescription("Count of live tables"), - metric.WithUnit("{count}"), - ) - tableBackingCount, _ := meter.Int64Gauge( - "pebble_table_backing_count", - metric.WithDescription("Sstables backing virtual tables"), - metric.WithUnit("{count}"), - ) - tableBackingSize, _ := meter.Int64Gauge( - "pebble_table_backing_size", - metric.WithDescription("Size of sstables backing virtual tables"), - metric.WithUnit("By"), - ) - tableCompressedUnknown, _ := meter.Int64Gauge( - "pebble_table_compressed_unknown", - metric.WithDescription("Sstables with unknown compression"), - metric.WithUnit("{count}"), - ) - tableCompressedSnappy, _ := meter.Int64Gauge( - "pebble_table_compressed_snappy", - metric.WithDescription("Snappy-compressed sstables"), - metric.WithUnit("{count}"), - ) - tableCompressedZstd, _ := meter.Int64Gauge( - "pebble_table_compressed_zstd", - metric.WithDescription("Zstd-compressed sstables"), - metric.WithUnit("{count}"), - ) - tableCompressedMinLZ, _ := meter.Int64Gauge( - "pebble_table_compressed_minlz", - metric.WithDescription("MinLZ-compressed sstables"), - metric.WithUnit("{count}"), - ) - tableCompressedNone, _ := meter.Int64Gauge( - "pebble_table_compressed_none", - metric.WithDescription("Uncompressed sstables"), - metric.WithUnit("{count}"), - ) - tableLocalObsoleteSize, _ := meter.Int64Gauge( - "pebble_table_local_obsolete_size", - metric.WithDescription("Local obsolete table size"), - metric.WithUnit("By"), - ) - tableLocalObsoleteCount, _ := meter.Int64Gauge( - "pebble_table_local_obsolete_count", - metric.WithDescription("Local obsolete table count"), - metric.WithUnit("{count}"), - ) - tableLocalZombieSize, _ := meter.Int64Gauge( - "pebble_table_local_zombie_size", - metric.WithDescription("Local zombie table size"), - metric.WithUnit("By"), - ) - tableLocalZombieCount, _ := meter.Int64Gauge( - "pebble_table_local_zombie_count", - metric.WithDescription("Local zombie table count"), - metric.WithUnit("{count}"), - ) - tableGarbagePointDeletionsEstimate, _ := meter.Int64Gauge( - "pebble_table_garbage_point_deletions_estimate", - metric.WithDescription("Est. bytes reclaimable from point deletes"), - metric.WithUnit("By"), - ) - tableGarbageRangeDeletionsEstimate, _ := meter.Int64Gauge( - "pebble_table_garbage_range_deletions_estimate", - metric.WithDescription("Est. bytes reclaimable from range deletes"), - metric.WithUnit("By"), - ) - tableInitialStatsComplete, _ := meter.Int64Gauge( - "pebble_table_initial_stats_complete", - metric.WithDescription("1 if initial stats collection complete"), - metric.WithUnit("1"), - ) - tablePendingStatsCount, _ := meter.Int64Gauge( - "pebble_table_pending_stats_count", - metric.WithDescription("New sstables awaiting stats collection"), - metric.WithUnit("{count}"), - ) - blobFilesLiveCount, _ := meter.Int64Gauge( - "pebble_blob_files_live_count", - metric.WithDescription("Live blob file count"), - metric.WithUnit("{count}"), - ) - blobFilesLiveSize, _ := meter.Int64Gauge( - "pebble_blob_files_live_size", - metric.WithDescription("Live blob file physical size"), - metric.WithUnit("By"), - ) - blobFilesValueSize, _ := meter.Int64Gauge( - "pebble_blob_files_value_size", - metric.WithDescription("Uncompressed value size in live blobs"), - metric.WithUnit("By"), - ) - blobFilesReferencedValueSize, _ := meter.Int64Gauge( - "pebble_blob_files_referenced_value_size", - metric.WithDescription("Referenced value size in live blobs"), - metric.WithUnit("By"), - ) - blobFilesObsoleteCount, _ := meter.Int64Gauge( - "pebble_blob_files_obsolete_count", - metric.WithDescription("Obsolete blob file count"), - metric.WithUnit("{count}"), - ) - blobFilesObsoleteSize, _ := meter.Int64Gauge( - "pebble_blob_files_obsolete_size", - metric.WithDescription("Obsolete blob file size"), - metric.WithUnit("By"), - ) - blobFilesZombieCount, _ := meter.Int64Gauge( - "pebble_blob_files_zombie_count", - metric.WithDescription("Zombie blob file count"), - metric.WithUnit("{count}"), - ) - blobFilesZombieSize, _ := meter.Int64Gauge( - "pebble_blob_files_zombie_size", - metric.WithDescription("Zombie blob file size"), - metric.WithUnit("By"), - ) - blobFilesLocalLiveSize, _ := meter.Int64Gauge( - "pebble_blob_files_local_live_size", - metric.WithDescription("Local live blob file size"), - metric.WithUnit("By"), - ) - blobFilesLocalLiveCount, _ := meter.Int64Gauge( - "pebble_blob_files_local_live_count", - metric.WithDescription("Local live blob file count"), - metric.WithUnit("{count}"), - ) - blobFilesLocalObsoleteSize, _ := meter.Int64Gauge( - "pebble_blob_files_local_obsolete_size", - metric.WithDescription("Local obsolete blob file size"), - metric.WithUnit("By"), - ) - blobFilesLocalObsoleteCount, _ := meter.Int64Gauge( - "pebble_blob_files_local_obsolete_count", - metric.WithDescription("Local obsolete blob file count"), - metric.WithUnit("{count}"), - ) - blobFilesLocalZombieSize, _ := meter.Int64Gauge( - "pebble_blob_files_local_zombie_size", - metric.WithDescription("Local zombie blob file size"), - metric.WithUnit("By"), - ) - blobFilesLocalZombieCount, _ := meter.Int64Gauge( - "pebble_blob_files_local_zombie_count", - metric.WithDescription("Local zombie blob file count"), - metric.WithUnit("{count}"), - ) - fileCacheSize, _ := meter.Int64Gauge( - "pebble_file_cache_size", - metric.WithDescription("Bytes in file cache"), - metric.WithUnit("By"), - ) - fileCacheTableCount, _ := meter.Int64Gauge( - "pebble_file_cache_table_count", - metric.WithDescription("Tables in file cache"), - metric.WithUnit("{count}"), - ) - fileCacheBlobFileCount, _ := meter.Int64Gauge( - "pebble_file_cache_blob_file_count", - metric.WithDescription("Blob files in file cache"), - metric.WithUnit("{count}"), - ) - fileCacheHits, _ := meter.Int64Counter( - "pebble_file_cache_hits", - metric.WithDescription("File cache hits"), - metric.WithUnit("{count}"), - ) - fileCacheMisses, _ := meter.Int64Counter( - "pebble_file_cache_misses", - metric.WithDescription("File cache misses"), - metric.WithUnit("{count}"), - ) - walFailoverDirSwitchCount, _ := meter.Int64Counter( - "pebble_wal_failover_dir_switch_count", - metric.WithDescription("WAL directory switches (failover/failback)"), - metric.WithUnit("{count}"), - ) - walFailoverPrimaryDuration, _ := meter.Float64Gauge( - "pebble_wal_failover_primary_duration", - metric.WithDescription("Cumulative WAL write duration on primary"), - metric.WithUnit("s"), - ) - walFailoverSecondaryDuration, _ := meter.Float64Gauge( - "pebble_wal_failover_secondary_duration", - metric.WithDescription("Cumulative WAL write duration on secondary"), - metric.WithUnit("s"), - ) - numVirtual, _ := meter.Int64Gauge( - "pebble_num_virtual", - metric.WithDescription("Total virtual sstable count"), - metric.WithUnit("{count}"), - ) - virtualSize, _ := meter.Int64Gauge( - "pebble_virtual_size", - metric.WithDescription("Total virtual sstable size"), - metric.WithUnit("By"), - ) - remoteTablesCount, _ := meter.Int64Gauge( - "pebble_remote_tables_count", - metric.WithDescription("Remote tables count"), - metric.WithUnit("{count}"), - ) - remoteTablesSize, _ := meter.Int64Gauge( - "pebble_remote_tables_size", - metric.WithDescription("Remote tables size"), - metric.WithUnit("By"), - ) - - keysRangeKeySetsCount, _ := meter.Int64Gauge( - "pebble_keys_range_key_sets_count", - metric.WithDescription("Approximate count of internal range key set keys"), - metric.WithUnit("{count}"), - ) - keysTombstoneCount, _ := meter.Int64Gauge( - "pebble_keys_tombstone_count", - metric.WithDescription("Approximate count of internal tombstones"), - metric.WithUnit("{count}"), - ) - keysMissizedTombstonesCount, _ := meter.Int64Counter( - "pebble_keys_missized_tombstones_count", - metric.WithDescription("Missized DELSIZED keys encountered by compactions"), - metric.WithUnit("{count}"), - ) - - snapshotCount, _ := meter.Int64Gauge( - "pebble_snapshot_count", - metric.WithDescription("Number of currently open snapshots"), - metric.WithUnit("{count}"), - ) - snapshotPinnedKeys, _ := meter.Int64Counter( - "pebble_snapshot_pinned_keys", - metric.WithDescription("Keys written that would've been elided without open snapshots"), - metric.WithUnit("{count}"), - ) - snapshotPinnedSize, _ := meter.Int64Counter( - "pebble_snapshot_pinned_size", - metric.WithDescription("Size of keys/values written due to open snapshots"), - metric.WithUnit("By"), - ) - snapshotEarliestSeqNum, _ := meter.Int64Gauge( - "pebble_snapshot_earliest_seq_num", - metric.WithDescription("Sequence number of earliest open snapshot"), - metric.WithUnit("{count}"), - ) - - tableIters, _ := meter.Int64Gauge( - "pebble_table_iters", - metric.WithDescription("Count of open sstable iterators"), - metric.WithUnit("{count}"), - ) - uptimeSeconds, _ := meter.Float64Gauge( - "pebble_uptime_seconds", - metric.WithDescription("Seconds since DB was opened"), - metric.WithUnit("s"), - ) - readAmp, _ := meter.Int64Gauge( - "pebble_read_amp", - metric.WithDescription("Read amplification"), - metric.WithUnit("{count}"), - ) - diskSpaceUsage, _ := meter.Int64Gauge( - "pebble_disk_space_usage", - metric.WithDescription("Total disk space used by the DB"), - metric.WithUnit("By"), - ) - - cacheHits, _ := meter.Int64Counter( - "pebble_cache_hits", - metric.WithDescription("Total number of cache hits"), - metric.WithUnit("{count}"), - ) - cacheMisses, _ := meter.Int64Counter( - "pebble_cache_misses", - metric.WithDescription("Total number of cache misses"), - metric.WithUnit("{count}"), - ) - cacheSize, _ := meter.Int64Gauge( - "pebble_cache_size", - metric.WithDescription("Current cache size"), - metric.WithUnit("By"), - ) - - batchSize, _ := meter.Int64Histogram( - "pebble_batch_size", - metric.WithDescription("Size of batches written to PebbleDB"), - metric.WithUnit("By"), - metric.WithExplicitBucketBoundaries(smetrics.ByteSizeBuckets...), - ) - pendingChangesQueueDepth, _ := meter.Int64Gauge( - "pebble_pending_changes_queue_depth", - metric.WithDescription("Number of pending changesets in async write queue"), - metric.WithUnit("{count}"), - ) - iteratorIterations, _ := meter.Float64Histogram( - "pebble_iterator_iterations", - metric.WithDescription("Number of iterations per iterator"), - metric.WithUnit("{count}"), - metric.WithExplicitBucketBoundaries(smetrics.CountBuckets...), - ) - - pm := &PebbleMetrics{ - db: db, databaseName: databaseName, - - getLatency: getLatency, - applyChangesetLatency: applyChangesetLatency, - applyChangesetAsyncLatency: applyChangesetAsyncLatency, - pruneLatency: pruneLatency, - importLatency: importLatency, - batchWriteLatency: batchWriteLatency, - - compactionCount: compactionCount, - compactionDuration: compactionDuration, - compactionBytesRead: compactionBytesRead, - compactionBytesWritten: compactionBytesWritten, - compactionEstimatedDebt: compactionEstimatedDebt, - compactionInProgressBytes: compactionInProgressBytes, - compactionNumInProgress: compactionNumInProgress, - compactionCancelledCount: compactionCancelledCount, - compactionCancelledBytes: compactionCancelledBytes, - compactionFailedCount: compactionFailedCount, - compactionDefaultCount: compactionDefaultCount, - compactionDeleteOnlyCount: compactionDeleteOnlyCount, - compactionElisionOnlyCount: compactionElisionOnlyCount, - compactionCopyCount: compactionCopyCount, - compactionMoveCount: compactionMoveCount, - compactionReadCount: compactionReadCount, - compactionTombstoneDensityCount: compactionTombstoneDensityCount, - compactionRewriteCount: compactionRewriteCount, - compactionMultiLevelCount: compactionMultiLevelCount, - compactionBlobFileRewriteCount: compactionBlobFileRewriteCount, - compactionCounterLevelCount: compactionCounterLevelCount, - compactionNumProblemSpans: compactionNumProblemSpans, - compactionMarkedFiles: compactionMarkedFiles, - - ingestCount: ingestCount, - - flushCount: flushCount, - flushDuration: flushDuration, - flushBytesWritten: flushBytesWritten, - flushNumInProgress: flushNumInProgress, - flushAsIngestCount: flushAsIngestCount, - flushAsIngestTableCount: flushAsIngestTableCount, - flushAsIngestBytes: flushAsIngestBytes, - flushIdleDuration: flushIdleDuration, - - filterHits: filterHits, - filterMisses: filterMisses, - - sstableCount: sstableCount, - sstableTotalSize: sstableTotalSize, - sstableSublevels: sstableSublevels, - sstableScore: sstableScore, - sstableFillFactor: sstableFillFactor, - sstableVirtualCount: sstableVirtualCount, - sstableVirtualSize: sstableVirtualSize, - sstableBytesIngested: sstableBytesIngested, - sstableBytesMoved: sstableBytesMoved, - sstableBytesRead: sstableBytesRead, - sstableBytesFlushed: sstableBytesFlushed, - sstableTablesCompacted: sstableTablesCompacted, - sstableTablesFlushed: sstableTablesFlushed, - sstableTablesIngested: sstableTablesIngested, - sstableTablesMoved: sstableTablesMoved, - sstableCompensatedFillFactor: sstableCompensatedFillFactor, - sstableEstimatedReferencesSize: sstableEstimatedReferencesSize, - sstableTablesDeleted: sstableTablesDeleted, - sstableTablesExcised: sstableTablesExcised, - sstableBlobBytesReadEstimate: sstableBlobBytesReadEstimate, - sstableBlobBytesCompacted: sstableBlobBytesCompacted, - sstableBlobBytesFlushed: sstableBlobBytesFlushed, - sstableMultiLevelBytesInTop: sstableMultiLevelBytesInTop, - sstableMultiLevelBytesIn: sstableMultiLevelBytesIn, - sstableMultiLevelBytesRead: sstableMultiLevelBytesRead, - sstableValueBlocksSize: sstableValueBlocksSize, - sstableBytesWrittenDataBlocks: sstableBytesWrittenDataBlocks, - sstableBytesWrittenValueBlocks: sstableBytesWrittenValueBlocks, - - memtableCount: memtableCount, - memtableTotalSize: memtableTotalSize, - memtableZombieSize: memtableZombieSize, - memtableZombieCount: memtableZombieCount, - - walSize: walSize, - walFiles: walFiles, - walObsoleteFiles: walObsoleteFiles, - walObsoletePhysicalSize: walObsoletePhysicalSize, - walPhysicalSize: walPhysicalSize, - walBytesIn: walBytesIn, - walBytesWritten: walBytesWritten, - - tableObsoleteSize: tableObsoleteSize, - tableObsoleteCount: tableObsoleteCount, - tableZombieSize: tableZombieSize, - tableZombieCount: tableZombieCount, - tableLiveSize: tableLiveSize, - tableLiveCount: tableLiveCount, - tableBackingCount: tableBackingCount, - tableBackingSize: tableBackingSize, - tableCompressedUnknown: tableCompressedUnknown, - tableCompressedSnappy: tableCompressedSnappy, - tableCompressedZstd: tableCompressedZstd, - tableCompressedMinLZ: tableCompressedMinLZ, - tableCompressedNone: tableCompressedNone, - tableLocalObsoleteSize: tableLocalObsoleteSize, - tableLocalObsoleteCount: tableLocalObsoleteCount, - tableLocalZombieSize: tableLocalZombieSize, - tableLocalZombieCount: tableLocalZombieCount, - tableGarbagePointDeletionsEstimate: tableGarbagePointDeletionsEstimate, - tableGarbageRangeDeletionsEstimate: tableGarbageRangeDeletionsEstimate, - tableInitialStatsComplete: tableInitialStatsComplete, - tablePendingStatsCount: tablePendingStatsCount, - blobFilesLiveCount: blobFilesLiveCount, - blobFilesLiveSize: blobFilesLiveSize, - blobFilesValueSize: blobFilesValueSize, - blobFilesReferencedValueSize: blobFilesReferencedValueSize, - blobFilesObsoleteCount: blobFilesObsoleteCount, - blobFilesObsoleteSize: blobFilesObsoleteSize, - blobFilesZombieCount: blobFilesZombieCount, - blobFilesZombieSize: blobFilesZombieSize, - blobFilesLocalLiveSize: blobFilesLocalLiveSize, - blobFilesLocalLiveCount: blobFilesLocalLiveCount, - blobFilesLocalObsoleteSize: blobFilesLocalObsoleteSize, - blobFilesLocalObsoleteCount: blobFilesLocalObsoleteCount, - blobFilesLocalZombieSize: blobFilesLocalZombieSize, - blobFilesLocalZombieCount: blobFilesLocalZombieCount, - fileCacheSize: fileCacheSize, - fileCacheTableCount: fileCacheTableCount, - fileCacheBlobFileCount: fileCacheBlobFileCount, - fileCacheHits: fileCacheHits, - fileCacheMisses: fileCacheMisses, - walFailoverDirSwitchCount: walFailoverDirSwitchCount, - walFailoverPrimaryDuration: walFailoverPrimaryDuration, - walFailoverSecondaryDuration: walFailoverSecondaryDuration, - numVirtual: numVirtual, - virtualSize: virtualSize, - remoteTablesCount: remoteTablesCount, - remoteTablesSize: remoteTablesSize, - - keysRangeKeySetsCount: keysRangeKeySetsCount, - keysTombstoneCount: keysTombstoneCount, - keysMissizedTombstonesCount: keysMissizedTombstonesCount, - - snapshotCount: snapshotCount, - snapshotPinnedKeys: snapshotPinnedKeys, - snapshotPinnedSize: snapshotPinnedSize, - snapshotEarliestSeqNum: snapshotEarliestSeqNum, - - tableIters: tableIters, - uptimeSeconds: uptimeSeconds, - readAmp: readAmp, - diskSpaceUsage: diskSpaceUsage, - - cacheHits: cacheHits, - cacheMisses: cacheMisses, - cacheSize: cacheSize, - - batchSize: batchSize, - pendingChangesQueueDepth: pendingChangesQueueDepth, - iteratorIterations: iteratorIterations, - } - - go pm.collectLoop(ctx, scrapeInterval) - return pm + dbAttr := attribute.String("db", databaseName) + p := &pebbleMetrics{meter: meter, dbAttrs: metric.WithAttributes(dbAttr)} + for level := range p.levelAttrs { + p.levelAttrs[level] = metric.WithAttributes(dbAttr, attribute.Int("level", level)) + } + p.declareDB() + p.declareLevels() + + p.snapshot.Store(db.Metrics()) + observe := func(_ context.Context, o metric.Observer) error { + m := p.snapshot.Load() + for _, report := range p.report { + report(o, m) + } + return nil + } + + reg, err := meter.RegisterCallback(observe, p.insts...) + if err != nil { + otel.Handle(err) + return func() {} + } + + ticker := time.NewTicker(refreshInterval) + stop, stopped := make(chan struct{}), make(chan struct{}) + go func() { + defer close(stopped) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + p.snapshot.Store(db.Metrics()) + } + } + }() + // Waiting for the refresher to exit before unregistering is what lets the + // caller close db as soon as this returns: no observation can be in flight. + return sync.OnceFunc(func() { + close(stop) + <-stopped + _ = reg.Unregister() + }) } -// collectLoop runs a ticker that periodically calls recordFromPebble. It exits when ctx is cancelled. -func (pm *PebbleMetrics) collectLoop(ctx context.Context, interval time.Duration) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - pm.recordFromPebble(ctx) - } - } +// counter declares a whole-DB series whose value Pebble only ever raises. +func (p *pebbleMetrics) counter(name, unit, desc string, val func(*pebble.Metrics) float64) { + inst, _ := p.meter.Float64ObservableCounter(name, metric.WithDescription(desc), metric.WithUnit(unit)) + p.insts = append(p.insts, inst) + p.report = append(p.report, func(o metric.Observer, m *pebble.Metrics) { + o.ObserveFloat64(inst, val(m), p.dbAttrs) + }) } -func uint64ToInt64Clamped(v uint64) int64 { - if v > math.MaxInt64 { - return math.MaxInt64 - } - return int64(v) +// gauge declares a whole-DB series whose value can fall as well as rise. +func (p *pebbleMetrics) gauge(name, unit, desc string, val func(*pebble.Metrics) float64) { + inst, _ := p.meter.Float64ObservableGauge(name, metric.WithDescription(desc), metric.WithUnit(unit)) + p.insts = append(p.insts, inst) + p.report = append(p.report, func(o metric.Observer, m *pebble.Metrics) { + o.ObserveFloat64(inst, val(m), p.dbAttrs) + }) } -// addDelta computes the difference between current and prev, updates prev to current, -// and adds the positive delta to the counter. Used to convert cumulative scraped -// values into rate/counter increments. -func addDelta(ctx context.Context, counter metric.Int64Counter, current int64, prev *int64, opts ...metric.AddOption) { - delta := current - *prev - *prev = current - if delta > 0 { - counter.Add(ctx, delta, opts...) - } -} - -// recordFromPebble fetches the current metrics from the Pebble DB via Metrics(), then -// records compaction, flush, level, memtable, WAL, and cache metrics with the configured -// database name as the "db" attribute. -func (pm *PebbleMetrics) recordFromPebble(ctx context.Context) { - if pm.db == nil { - return - } - m := pm.db.Metrics() - dbAttr := attribute.String("db", pm.databaseName) - - if pm.compactionCount != nil { - addDelta(ctx, pm.compactionCount, m.Compact.Count, - &pm.prevCompactionCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionDuration != nil { - pm.compactionDuration.Record(ctx, m.Compact.Duration.Seconds(), metric.WithAttributes(dbAttr)) - } - if pm.compactionEstimatedDebt != nil { - pm.compactionEstimatedDebt.Record(ctx, - uint64ToInt64Clamped(m.Compact.EstimatedDebt), metric.WithAttributes(dbAttr)) - } - if pm.compactionInProgressBytes != nil { - pm.compactionInProgressBytes.Record(ctx, m.Compact.InProgressBytes, metric.WithAttributes(dbAttr)) - } - if pm.compactionNumInProgress != nil { - pm.compactionNumInProgress.Record(ctx, m.Compact.NumInProgress, metric.WithAttributes(dbAttr)) - } - if pm.compactionCancelledCount != nil { - addDelta(ctx, pm.compactionCancelledCount, m.Compact.CancelledCount, - &pm.prevCompactionCancelledCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionCancelledBytes != nil { - addDelta(ctx, pm.compactionCancelledBytes, m.Compact.CancelledBytes, - &pm.prevCompactionCancelledBytes, metric.WithAttributes(dbAttr)) - } - if pm.compactionFailedCount != nil { - addDelta(ctx, pm.compactionFailedCount, m.Compact.FailedCount, - &pm.prevCompactionFailedCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionDefaultCount != nil { - addDelta(ctx, pm.compactionDefaultCount, m.Compact.DefaultCount, - &pm.prevCompactionDefaultCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionDeleteOnlyCount != nil { - addDelta(ctx, pm.compactionDeleteOnlyCount, m.Compact.DeleteOnlyCount, - &pm.prevCompactionDeleteOnlyCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionElisionOnlyCount != nil { - addDelta(ctx, pm.compactionElisionOnlyCount, m.Compact.ElisionOnlyCount, - &pm.prevCompactionElisionOnlyCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionCopyCount != nil { - addDelta(ctx, pm.compactionCopyCount, m.Compact.CopyCount, - &pm.prevCompactionCopyCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionMoveCount != nil { - addDelta(ctx, pm.compactionMoveCount, m.Compact.MoveCount, - &pm.prevCompactionMoveCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionReadCount != nil { - addDelta(ctx, pm.compactionReadCount, m.Compact.ReadCount, - &pm.prevCompactionReadCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionTombstoneDensityCount != nil { - addDelta(ctx, pm.compactionTombstoneDensityCount, m.Compact.TombstoneDensityCount, - &pm.prevCompactionTombstoneDensityCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionRewriteCount != nil { - addDelta(ctx, pm.compactionRewriteCount, m.Compact.RewriteCount, - &pm.prevCompactionRewriteCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionMultiLevelCount != nil { - addDelta(ctx, pm.compactionMultiLevelCount, m.Compact.MultiLevelCount, - &pm.prevCompactionMultiLevelCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionBlobFileRewriteCount != nil { - addDelta(ctx, pm.compactionBlobFileRewriteCount, m.Compact.BlobFileRewriteCount, - &pm.prevCompactionBlobFileRewriteCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionCounterLevelCount != nil { - addDelta(ctx, pm.compactionCounterLevelCount, m.Compact.CounterLevelCount, - &pm.prevCompactionCounterLevelCount, metric.WithAttributes(dbAttr)) - } - if pm.compactionNumProblemSpans != nil { - pm.compactionNumProblemSpans.Record(ctx, int64(m.Compact.NumProblemSpans), metric.WithAttributes(dbAttr)) - } - if pm.compactionMarkedFiles != nil { - pm.compactionMarkedFiles.Record(ctx, int64(m.Compact.MarkedFiles), metric.WithAttributes(dbAttr)) - } - - if pm.ingestCount != nil { - addDelta(ctx, pm.ingestCount, uint64ToInt64Clamped(m.Ingest.Count), - &pm.prevIngestCount, metric.WithAttributes(dbAttr)) - } - - if pm.flushCount != nil { - addDelta(ctx, pm.flushCount, m.Flush.Count, &pm.prevFlushCount, metric.WithAttributes(dbAttr)) - } - if pm.flushDuration != nil { - pm.flushDuration.Record(ctx, - m.Flush.WriteThroughput.WorkDuration.Seconds(), metric.WithAttributes(dbAttr)) - } - if pm.flushBytesWritten != nil { - addDelta(ctx, pm.flushBytesWritten, m.Flush.WriteThroughput.Bytes, - &pm.prevFlushBytesWritten, metric.WithAttributes(dbAttr)) - } - if pm.flushNumInProgress != nil { - pm.flushNumInProgress.Record(ctx, m.Flush.NumInProgress, metric.WithAttributes(dbAttr)) - } - if pm.flushAsIngestCount != nil { - addDelta(ctx, pm.flushAsIngestCount, uint64ToInt64Clamped(m.Flush.AsIngestCount), - &pm.prevFlushAsIngestCount, metric.WithAttributes(dbAttr)) - } - if pm.flushAsIngestTableCount != nil { - addDelta(ctx, pm.flushAsIngestTableCount, uint64ToInt64Clamped(m.Flush.AsIngestTableCount), - &pm.prevFlushAsIngestTableCount, metric.WithAttributes(dbAttr)) - } - if pm.flushAsIngestBytes != nil { - addDelta(ctx, pm.flushAsIngestBytes, uint64ToInt64Clamped(m.Flush.AsIngestBytes), - &pm.prevFlushAsIngestBytes, metric.WithAttributes(dbAttr)) - } - if pm.flushIdleDuration != nil { - pm.flushIdleDuration.Record(ctx, - m.Flush.WriteThroughput.IdleDuration.Seconds(), metric.WithAttributes(dbAttr)) - } - - if pm.filterHits != nil { - addDelta(ctx, pm.filterHits, m.Filter.Hits, &pm.prevFilterHits, metric.WithAttributes(dbAttr)) - } - if pm.filterMisses != nil { - addDelta(ctx, pm.filterMisses, m.Filter.Misses, &pm.prevFilterMisses, metric.WithAttributes(dbAttr)) - } - - for level := 0; level < len(m.Levels); level++ { - lm := m.Levels[level] - levelAttr := attribute.Int("level", level) - attrs := metric.WithAttributes(dbAttr, levelAttr) - - // Grow prev slices if needed. - for level >= len(pm.prevCompactionBytesReadByLevel) { - pm.prevCompactionBytesReadByLevel = append(pm.prevCompactionBytesReadByLevel, 0) - pm.prevCompactionBytesWrittenByLevel = append(pm.prevCompactionBytesWrittenByLevel, 0) - pm.prevSstableBytesIngestedByLevel = append(pm.prevSstableBytesIngestedByLevel, 0) - pm.prevSstableBytesMovedByLevel = append(pm.prevSstableBytesMovedByLevel, 0) - pm.prevSstableBytesReadByLevel = append(pm.prevSstableBytesReadByLevel, 0) - pm.prevSstableBytesFlushedByLevel = append(pm.prevSstableBytesFlushedByLevel, 0) - pm.prevSstableTablesCompactedByLevel = append(pm.prevSstableTablesCompactedByLevel, 0) - pm.prevSstableTablesFlushedByLevel = append(pm.prevSstableTablesFlushedByLevel, 0) - pm.prevSstableTablesIngestedByLevel = append(pm.prevSstableTablesIngestedByLevel, 0) - pm.prevSstableTablesMovedByLevel = append(pm.prevSstableTablesMovedByLevel, 0) - pm.prevSstableTablesDeletedByLevel = append(pm.prevSstableTablesDeletedByLevel, 0) - pm.prevSstableTablesExcisedByLevel = append(pm.prevSstableTablesExcisedByLevel, 0) - pm.prevSstableBlobBytesReadEstimateByLevel = append(pm.prevSstableBlobBytesReadEstimateByLevel, 0) - pm.prevSstableBlobBytesCompactedByLevel = append(pm.prevSstableBlobBytesCompactedByLevel, 0) - pm.prevSstableBlobBytesFlushedByLevel = append(pm.prevSstableBlobBytesFlushedByLevel, 0) - pm.prevSstableMultiLevelBytesInTopByLevel = append(pm.prevSstableMultiLevelBytesInTopByLevel, 0) - pm.prevSstableMultiLevelBytesInByLevel = append(pm.prevSstableMultiLevelBytesInByLevel, 0) - pm.prevSstableMultiLevelBytesReadByLevel = append(pm.prevSstableMultiLevelBytesReadByLevel, 0) - pm.prevSstableBytesWrittenDataBlocksByLevel = append(pm.prevSstableBytesWrittenDataBlocksByLevel, 0) - pm.prevSstableBytesWrittenValueBlocksByLevel = append(pm.prevSstableBytesWrittenValueBlocksByLevel, 0) - } - - if pm.sstableCount != nil { - pm.sstableCount.Record(ctx, lm.TablesCount, attrs) - } - if pm.sstableTotalSize != nil { - pm.sstableTotalSize.Record(ctx, lm.TablesSize, attrs) - } - if pm.sstableSublevels != nil { - pm.sstableSublevels.Record(ctx, int64(lm.Sublevels), attrs) - } - if pm.sstableScore != nil { - pm.sstableScore.Record(ctx, lm.Score, attrs) - } - if pm.sstableFillFactor != nil { - pm.sstableFillFactor.Record(ctx, lm.FillFactor, attrs) - } - if pm.sstableVirtualCount != nil { - pm.sstableVirtualCount.Record(ctx, uint64ToInt64Clamped(lm.VirtualTablesCount), attrs) - } - if pm.sstableVirtualSize != nil { - pm.sstableVirtualSize.Record(ctx, uint64ToInt64Clamped(lm.VirtualTablesSize), attrs) - } - if pm.compactionBytesRead != nil { - addDelta(ctx, pm.compactionBytesRead, uint64ToInt64Clamped(lm.TableBytesIn), - &pm.prevCompactionBytesReadByLevel[level], attrs) +// levelCounter declares a per-level series whose value Pebble only ever raises. +func (p *pebbleMetrics) levelCounter(name, unit, desc string, val func(*pebble.LevelMetrics) float64) { + inst, _ := p.meter.Float64ObservableCounter(name, metric.WithDescription(desc), metric.WithUnit(unit)) + p.insts = append(p.insts, inst) + p.report = append(p.report, func(o metric.Observer, m *pebble.Metrics) { + for level := range m.Levels { + o.ObserveFloat64(inst, val(&m.Levels[level]), p.levelAttrs[level]) } - if pm.compactionBytesWritten != nil { - addDelta(ctx, pm.compactionBytesWritten, uint64ToInt64Clamped(lm.TableBytesCompacted), - &pm.prevCompactionBytesWrittenByLevel[level], attrs) - } - if pm.sstableBytesIngested != nil { - addDelta(ctx, pm.sstableBytesIngested, uint64ToInt64Clamped(lm.TableBytesIngested), - &pm.prevSstableBytesIngestedByLevel[level], attrs) - } - if pm.sstableBytesMoved != nil { - addDelta(ctx, pm.sstableBytesMoved, uint64ToInt64Clamped(lm.TableBytesMoved), - &pm.prevSstableBytesMovedByLevel[level], attrs) - } - if pm.sstableBytesRead != nil { - addDelta(ctx, pm.sstableBytesRead, uint64ToInt64Clamped(lm.TableBytesRead), - &pm.prevSstableBytesReadByLevel[level], attrs) - } - if pm.sstableBytesFlushed != nil { - addDelta(ctx, pm.sstableBytesFlushed, uint64ToInt64Clamped(lm.TableBytesFlushed), - &pm.prevSstableBytesFlushedByLevel[level], attrs) - } - if pm.sstableTablesCompacted != nil { - addDelta(ctx, pm.sstableTablesCompacted, uint64ToInt64Clamped(lm.TablesCompacted), - &pm.prevSstableTablesCompactedByLevel[level], attrs) - } - if pm.sstableTablesFlushed != nil { - addDelta(ctx, pm.sstableTablesFlushed, uint64ToInt64Clamped(lm.TablesFlushed), - &pm.prevSstableTablesFlushedByLevel[level], attrs) - } - if pm.sstableTablesIngested != nil { - addDelta(ctx, pm.sstableTablesIngested, uint64ToInt64Clamped(lm.TablesIngested), - &pm.prevSstableTablesIngestedByLevel[level], attrs) - } - if pm.sstableTablesMoved != nil { - addDelta(ctx, pm.sstableTablesMoved, uint64ToInt64Clamped(lm.TablesMoved), - &pm.prevSstableTablesMovedByLevel[level], attrs) - } - if pm.sstableCompensatedFillFactor != nil { - pm.sstableCompensatedFillFactor.Record(ctx, lm.CompensatedFillFactor, attrs) - } - if pm.sstableEstimatedReferencesSize != nil { - pm.sstableEstimatedReferencesSize.Record(ctx, uint64ToInt64Clamped(lm.EstimatedReferencesSize), attrs) - } - if pm.sstableTablesDeleted != nil { - addDelta(ctx, pm.sstableTablesDeleted, uint64ToInt64Clamped(lm.TablesDeleted), - &pm.prevSstableTablesDeletedByLevel[level], attrs) - } - if pm.sstableTablesExcised != nil { - addDelta(ctx, pm.sstableTablesExcised, uint64ToInt64Clamped(lm.TablesExcised), - &pm.prevSstableTablesExcisedByLevel[level], attrs) - } - if pm.sstableBlobBytesReadEstimate != nil { - addDelta(ctx, pm.sstableBlobBytesReadEstimate, uint64ToInt64Clamped(lm.BlobBytesReadEstimate), - &pm.prevSstableBlobBytesReadEstimateByLevel[level], attrs) - } - if pm.sstableBlobBytesCompacted != nil { - addDelta(ctx, pm.sstableBlobBytesCompacted, uint64ToInt64Clamped(lm.BlobBytesCompacted), - &pm.prevSstableBlobBytesCompactedByLevel[level], attrs) - } - if pm.sstableBlobBytesFlushed != nil { - addDelta(ctx, pm.sstableBlobBytesFlushed, uint64ToInt64Clamped(lm.BlobBytesFlushed), - &pm.prevSstableBlobBytesFlushedByLevel[level], attrs) - } - if pm.sstableMultiLevelBytesInTop != nil { - addDelta(ctx, pm.sstableMultiLevelBytesInTop, uint64ToInt64Clamped(lm.MultiLevel.TableBytesInTop), - &pm.prevSstableMultiLevelBytesInTopByLevel[level], attrs) - } - if pm.sstableMultiLevelBytesIn != nil { - addDelta(ctx, pm.sstableMultiLevelBytesIn, uint64ToInt64Clamped(lm.MultiLevel.TableBytesIn), - &pm.prevSstableMultiLevelBytesInByLevel[level], attrs) - } - if pm.sstableMultiLevelBytesRead != nil { - addDelta(ctx, pm.sstableMultiLevelBytesRead, uint64ToInt64Clamped(lm.MultiLevel.TableBytesRead), - &pm.prevSstableMultiLevelBytesReadByLevel[level], attrs) - } - if pm.sstableValueBlocksSize != nil { - pm.sstableValueBlocksSize.Record(ctx, uint64ToInt64Clamped(lm.Additional.ValueBlocksSize), attrs) - } - if pm.sstableBytesWrittenDataBlocks != nil { - addDelta(ctx, pm.sstableBytesWrittenDataBlocks, - uint64ToInt64Clamped(lm.Additional.BytesWrittenDataBlocks), - &pm.prevSstableBytesWrittenDataBlocksByLevel[level], attrs) - } - if pm.sstableBytesWrittenValueBlocks != nil { - addDelta(ctx, pm.sstableBytesWrittenValueBlocks, - uint64ToInt64Clamped(lm.Additional.BytesWrittenValueBlocks), - &pm.prevSstableBytesWrittenValueBlocksByLevel[level], attrs) - } - } - - if pm.memtableCount != nil { - pm.memtableCount.Record(ctx, m.MemTable.Count, metric.WithAttributes(dbAttr)) - } - if pm.memtableTotalSize != nil { - pm.memtableTotalSize.Record(ctx, uint64ToInt64Clamped(m.MemTable.Size), metric.WithAttributes(dbAttr)) - } - if pm.memtableZombieSize != nil { - pm.memtableZombieSize.Record(ctx, - uint64ToInt64Clamped(m.MemTable.ZombieSize), metric.WithAttributes(dbAttr)) - } - if pm.memtableZombieCount != nil { - pm.memtableZombieCount.Record(ctx, m.MemTable.ZombieCount, metric.WithAttributes(dbAttr)) - } - - if pm.walSize != nil { - pm.walSize.Record(ctx, uint64ToInt64Clamped(m.WAL.Size), metric.WithAttributes(dbAttr)) - } - if pm.walFiles != nil { - pm.walFiles.Record(ctx, m.WAL.Files, metric.WithAttributes(dbAttr)) - } - if pm.walObsoleteFiles != nil { - pm.walObsoleteFiles.Record(ctx, m.WAL.ObsoleteFiles, metric.WithAttributes(dbAttr)) - } - if pm.walObsoletePhysicalSize != nil { - pm.walObsoletePhysicalSize.Record(ctx, - uint64ToInt64Clamped(m.WAL.ObsoletePhysicalSize), metric.WithAttributes(dbAttr)) - } - if pm.walPhysicalSize != nil { - pm.walPhysicalSize.Record(ctx, uint64ToInt64Clamped(m.WAL.PhysicalSize), metric.WithAttributes(dbAttr)) - } - if pm.walBytesIn != nil { - addDelta(ctx, pm.walBytesIn, - uint64ToInt64Clamped(m.WAL.BytesIn), &pm.prevWalBytesIn, metric.WithAttributes(dbAttr)) - } - if pm.walBytesWritten != nil { - addDelta(ctx, pm.walBytesWritten, uint64ToInt64Clamped(m.WAL.BytesWritten), - &pm.prevWalBytesWritten, metric.WithAttributes(dbAttr)) - } + }) +} - if pm.tableObsoleteSize != nil { - pm.tableObsoleteSize.Record(ctx, - uint64ToInt64Clamped(m.Table.ObsoleteSize), metric.WithAttributes(dbAttr)) - } - if pm.tableObsoleteCount != nil { - pm.tableObsoleteCount.Record(ctx, m.Table.ObsoleteCount, metric.WithAttributes(dbAttr)) - } - if pm.tableZombieSize != nil { - pm.tableZombieSize.Record(ctx, - uint64ToInt64Clamped(m.Table.ZombieSize), metric.WithAttributes(dbAttr)) - } - if pm.tableZombieCount != nil { - pm.tableZombieCount.Record(ctx, m.Table.ZombieCount, metric.WithAttributes(dbAttr)) - } - if pm.tableLiveSize != nil { - pm.tableLiveSize.Record(ctx, - uint64ToInt64Clamped(m.Table.Local.LiveSize), metric.WithAttributes(dbAttr)) - } - if pm.tableLiveCount != nil { - pm.tableLiveCount.Record(ctx, - uint64ToInt64Clamped(m.Table.Local.LiveCount), metric.WithAttributes(dbAttr)) - } - if pm.tableBackingCount != nil { - pm.tableBackingCount.Record(ctx, - uint64ToInt64Clamped(m.Table.BackingTableCount), metric.WithAttributes(dbAttr)) - } - if pm.tableBackingSize != nil { - pm.tableBackingSize.Record(ctx, uint64ToInt64Clamped(m.Table.BackingTableSize), metric.WithAttributes(dbAttr)) - } - if pm.tableCompressedUnknown != nil { - pm.tableCompressedUnknown.Record(ctx, m.Table.CompressedCountUnknown, metric.WithAttributes(dbAttr)) - } - if pm.tableCompressedSnappy != nil { - pm.tableCompressedSnappy.Record(ctx, m.Table.CompressedCountSnappy, metric.WithAttributes(dbAttr)) - } - if pm.tableCompressedZstd != nil { - pm.tableCompressedZstd.Record(ctx, m.Table.CompressedCountZstd, metric.WithAttributes(dbAttr)) - } - if pm.tableCompressedMinLZ != nil { - pm.tableCompressedMinLZ.Record(ctx, m.Table.CompressedCountMinLZ, metric.WithAttributes(dbAttr)) - } - if pm.tableCompressedNone != nil { - pm.tableCompressedNone.Record(ctx, m.Table.CompressedCountNone, metric.WithAttributes(dbAttr)) - } - if pm.tableLocalObsoleteSize != nil { - pm.tableLocalObsoleteSize.Record(ctx, - uint64ToInt64Clamped(m.Table.Local.ObsoleteSize), metric.WithAttributes(dbAttr)) - } - if pm.tableLocalObsoleteCount != nil { - pm.tableLocalObsoleteCount.Record(ctx, - uint64ToInt64Clamped(m.Table.Local.ObsoleteCount), metric.WithAttributes(dbAttr)) - } - if pm.tableLocalZombieSize != nil { - pm.tableLocalZombieSize.Record(ctx, - uint64ToInt64Clamped(m.Table.Local.ZombieSize), metric.WithAttributes(dbAttr)) - } - if pm.tableLocalZombieCount != nil { - pm.tableLocalZombieCount.Record(ctx, - uint64ToInt64Clamped(m.Table.Local.ZombieCount), metric.WithAttributes(dbAttr)) - } - if pm.tableGarbagePointDeletionsEstimate != nil { - pm.tableGarbagePointDeletionsEstimate.Record(ctx, - uint64ToInt64Clamped(m.Table.Garbage.PointDeletionsBytesEstimate), metric.WithAttributes(dbAttr)) - } - if pm.tableGarbageRangeDeletionsEstimate != nil { - pm.tableGarbageRangeDeletionsEstimate.Record(ctx, - uint64ToInt64Clamped(m.Table.Garbage.RangeDeletionsBytesEstimate), metric.WithAttributes(dbAttr)) - } - if pm.tableInitialStatsComplete != nil { - v := int64(0) - if m.Table.InitialStatsCollectionComplete { - v = 1 +// levelGauge declares a per-level series whose value can fall as well as rise. +func (p *pebbleMetrics) levelGauge(name, unit, desc string, val func(*pebble.LevelMetrics) float64) { + inst, _ := p.meter.Float64ObservableGauge(name, metric.WithDescription(desc), metric.WithUnit(unit)) + p.insts = append(p.insts, inst) + p.report = append(p.report, func(o metric.Observer, m *pebble.Metrics) { + for level := range m.Levels { + o.ObserveFloat64(inst, val(&m.Levels[level]), p.levelAttrs[level]) } - pm.tableInitialStatsComplete.Record(ctx, v, metric.WithAttributes(dbAttr)) - } - if pm.tablePendingStatsCount != nil { - pm.tablePendingStatsCount.Record(ctx, m.Table.PendingStatsCollectionCount, metric.WithAttributes(dbAttr)) - } - if pm.blobFilesLiveCount != nil { - pm.blobFilesLiveCount.Record(ctx, uint64ToInt64Clamped(m.BlobFiles.LiveCount), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesLiveSize != nil { - pm.blobFilesLiveSize.Record(ctx, uint64ToInt64Clamped(m.BlobFiles.LiveSize), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesValueSize != nil { - pm.blobFilesValueSize.Record(ctx, uint64ToInt64Clamped(m.BlobFiles.ValueSize), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesReferencedValueSize != nil { - pm.blobFilesReferencedValueSize.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.ReferencedValueSize), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesObsoleteCount != nil { - pm.blobFilesObsoleteCount.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.ObsoleteCount), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesObsoleteSize != nil { - pm.blobFilesObsoleteSize.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.ObsoleteSize), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesZombieCount != nil { - pm.blobFilesZombieCount.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.ZombieCount), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesZombieSize != nil { - pm.blobFilesZombieSize.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.ZombieSize), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesLocalLiveSize != nil { - pm.blobFilesLocalLiveSize.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.Local.LiveSize), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesLocalLiveCount != nil { - pm.blobFilesLocalLiveCount.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.Local.LiveCount), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesLocalObsoleteSize != nil { - pm.blobFilesLocalObsoleteSize.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.Local.ObsoleteSize), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesLocalObsoleteCount != nil { - pm.blobFilesLocalObsoleteCount.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.Local.ObsoleteCount), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesLocalZombieSize != nil { - pm.blobFilesLocalZombieSize.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.Local.ZombieSize), metric.WithAttributes(dbAttr)) - } - if pm.blobFilesLocalZombieCount != nil { - pm.blobFilesLocalZombieCount.Record(ctx, - uint64ToInt64Clamped(m.BlobFiles.Local.ZombieCount), metric.WithAttributes(dbAttr)) - } - if pm.fileCacheSize != nil { - pm.fileCacheSize.Record(ctx, m.FileCache.Size, metric.WithAttributes(dbAttr)) - } - if pm.fileCacheTableCount != nil { - pm.fileCacheTableCount.Record(ctx, m.FileCache.TableCount, metric.WithAttributes(dbAttr)) - } - if pm.fileCacheBlobFileCount != nil { - pm.fileCacheBlobFileCount.Record(ctx, m.FileCache.BlobFileCount, metric.WithAttributes(dbAttr)) - } - if pm.fileCacheHits != nil { - addDelta(ctx, pm.fileCacheHits, m.FileCache.Hits, &pm.prevFileCacheHits, metric.WithAttributes(dbAttr)) - } - if pm.fileCacheMisses != nil { - addDelta(ctx, pm.fileCacheMisses, - m.FileCache.Misses, &pm.prevFileCacheMisses, metric.WithAttributes(dbAttr)) - } - if pm.walFailoverDirSwitchCount != nil { - addDelta(ctx, pm.walFailoverDirSwitchCount, m.WAL.Failover.DirSwitchCount, - &pm.prevWalFailoverDirSwitchCount, metric.WithAttributes(dbAttr)) - } - if pm.walFailoverPrimaryDuration != nil { - pm.walFailoverPrimaryDuration.Record(ctx, - m.WAL.Failover.PrimaryWriteDuration.Seconds(), metric.WithAttributes(dbAttr)) - } - if pm.walFailoverSecondaryDuration != nil { - pm.walFailoverSecondaryDuration.Record(ctx, - m.WAL.Failover.SecondaryWriteDuration.Seconds(), metric.WithAttributes(dbAttr)) - } - if pm.numVirtual != nil { - pm.numVirtual.Record(ctx, uint64ToInt64Clamped(m.NumVirtual()), metric.WithAttributes(dbAttr)) - } - if pm.virtualSize != nil { - pm.virtualSize.Record(ctx, uint64ToInt64Clamped(m.VirtualSize()), metric.WithAttributes(dbAttr)) - } - rtCount, rtSize := m.RemoteTablesTotal() - if pm.remoteTablesCount != nil { - pm.remoteTablesCount.Record(ctx, uint64ToInt64Clamped(rtCount), metric.WithAttributes(dbAttr)) - } - if pm.remoteTablesSize != nil { - pm.remoteTablesSize.Record(ctx, uint64ToInt64Clamped(rtSize), metric.WithAttributes(dbAttr)) - } - - if pm.keysRangeKeySetsCount != nil { - pm.keysRangeKeySetsCount.Record(ctx, - uint64ToInt64Clamped(m.Keys.RangeKeySetsCount), metric.WithAttributes(dbAttr)) - } - if pm.keysTombstoneCount != nil { - pm.keysTombstoneCount.Record(ctx, - uint64ToInt64Clamped(m.Keys.TombstoneCount), metric.WithAttributes(dbAttr)) - } - if pm.keysMissizedTombstonesCount != nil { - addDelta(ctx, pm.keysMissizedTombstonesCount, uint64ToInt64Clamped(m.Keys.MissizedTombstonesCount), - &pm.prevKeysMissizedTombstonesCount, metric.WithAttributes(dbAttr)) - } - - if pm.snapshotCount != nil { - pm.snapshotCount.Record(ctx, int64(m.Snapshots.Count), metric.WithAttributes(dbAttr)) - } - if pm.snapshotPinnedKeys != nil { - addDelta(ctx, pm.snapshotPinnedKeys, uint64ToInt64Clamped(m.Snapshots.PinnedKeys), - &pm.prevSnapshotPinnedKeys, metric.WithAttributes(dbAttr)) - } - if pm.snapshotPinnedSize != nil { - addDelta(ctx, pm.snapshotPinnedSize, uint64ToInt64Clamped(m.Snapshots.PinnedSize), - &pm.prevSnapshotPinnedSize, metric.WithAttributes(dbAttr)) - } - if pm.snapshotEarliestSeqNum != nil { - pm.snapshotEarliestSeqNum.Record(ctx, - uint64ToInt64Clamped(uint64(m.Snapshots.EarliestSeqNum)), metric.WithAttributes(dbAttr)) - } + }) +} - if pm.tableIters != nil { - pm.tableIters.Record(ctx, m.TableIters, metric.WithAttributes(dbAttr)) - } - if pm.uptimeSeconds != nil { - pm.uptimeSeconds.Record(ctx, m.Uptime.Seconds(), metric.WithAttributes(dbAttr)) - } - if pm.readAmp != nil { - pm.readAmp.Record(ctx, int64(m.ReadAmp()), metric.WithAttributes(dbAttr)) - } - if pm.diskSpaceUsage != nil { - pm.diskSpaceUsage.Record(ctx, uint64ToInt64Clamped(m.DiskSpaceUsage()), metric.WithAttributes(dbAttr)) - } +// declareDB declares the series read from a whole-DB snapshot. +func (p *pebbleMetrics) declareDB() { + p.counter("pebble_compaction_count", "{count}", "Total number of compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.Count) }) + p.counter("pebble_compaction_duration", "s", "Cumulative compaction duration since DB open", + func(m *pebble.Metrics) float64 { return m.Compact.Duration.Seconds() }) + p.gauge("pebble_compaction_estimated_debt", "By", "Estimated bytes to compact for LSM to reach stable state", + func(m *pebble.Metrics) float64 { return float64(m.Compact.EstimatedDebt) }) + p.gauge("pebble_compaction_in_progress_bytes", "By", "Bytes in sstables being written by in-progress compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.InProgressBytes) }) + p.gauge("pebble_compaction_num_in_progress", "{count}", "Number of compactions in progress", + func(m *pebble.Metrics) float64 { return float64(m.Compact.NumInProgress) }) + p.counter("pebble_compaction_cancelled_count", "{count}", "Number of compactions that were cancelled", + func(m *pebble.Metrics) float64 { return float64(m.Compact.CancelledCount) }) + p.counter("pebble_compaction_cancelled_bytes", "By", "Bytes written by cancelled compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.CancelledBytes) }) + p.counter("pebble_compaction_failed_count", "{count}", "Number of compactions that hit an error", + func(m *pebble.Metrics) float64 { return float64(m.Compact.FailedCount) }) + p.counter("pebble_compaction_default_count", "{count}", "Default compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.DefaultCount) }) + p.counter("pebble_compaction_delete_only_count", "{count}", "Delete-only compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.DeleteOnlyCount) }) + p.counter("pebble_compaction_elision_only_count", "{count}", "Elision-only compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.ElisionOnlyCount) }) + p.counter("pebble_compaction_copy_count", "{count}", "Copy compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.CopyCount) }) + p.counter("pebble_compaction_move_count", "{count}", "Move compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.MoveCount) }) + p.counter("pebble_compaction_read_count", "{count}", "Read compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.ReadCount) }) + p.counter("pebble_compaction_tombstone_density_count", "{count}", "Tombstone-density compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.TombstoneDensityCount) }) + p.counter("pebble_compaction_rewrite_count", "{count}", "Rewrite compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.RewriteCount) }) + p.counter("pebble_compaction_multi_level_count", "{count}", "Multi-level compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.MultiLevelCount) }) + p.counter("pebble_compaction_blob_file_rewrite_count", "{count}", "Blob file rewrite compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.BlobFileRewriteCount) }) + p.counter("pebble_compaction_counter_level_count", "{count}", "Counter-level compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.CounterLevelCount) }) + p.gauge("pebble_compaction_num_problem_spans", "{count}", "Problem spans blocking compactions", + func(m *pebble.Metrics) float64 { return float64(m.Compact.NumProblemSpans) }) + p.gauge("pebble_compaction_marked_files", "{count}", "Files marked for compaction", + func(m *pebble.Metrics) float64 { return float64(m.Compact.MarkedFiles) }) + + p.counter("pebble_ingest_count", "{count}", "Total number of ingestions", + func(m *pebble.Metrics) float64 { return float64(m.Ingest.Count) }) + + p.counter("pebble_flush_count", "{count}", "Total number of memtable flushes", + func(m *pebble.Metrics) float64 { return float64(m.Flush.Count) }) + p.counter("pebble_flush_duration", "s", "Cumulative memtable flush work duration since DB open", + func(m *pebble.Metrics) float64 { return m.Flush.WriteThroughput.WorkDuration.Seconds() }) + p.counter("pebble_flush_bytes_written", "By", "Total bytes written during memtable flushes", + func(m *pebble.Metrics) float64 { return float64(m.Flush.WriteThroughput.Bytes) }) + p.gauge("pebble_flush_num_in_progress", "{count}", "Number of flushes in progress", + func(m *pebble.Metrics) float64 { return float64(m.Flush.NumInProgress) }) + p.counter("pebble_flush_as_ingest_count", "{count}", "Flush operations handling ingested tables", + func(m *pebble.Metrics) float64 { return float64(m.Flush.AsIngestCount) }) + p.counter("pebble_flush_as_ingest_table_count", "{count}", "Tables ingested as flushables", + func(m *pebble.Metrics) float64 { return float64(m.Flush.AsIngestTableCount) }) + p.counter("pebble_flush_as_ingest_bytes", "By", "Bytes flushed for flushables from ingestion", + func(m *pebble.Metrics) float64 { return float64(m.Flush.AsIngestBytes) }) + p.gauge("pebble_flush_idle_duration", "s", "Idle duration before memtable flushes", + func(m *pebble.Metrics) float64 { return m.Flush.WriteThroughput.IdleDuration.Seconds() }) + + p.counter("pebble_filter_hits", "{count}", "Bloom filter hits (block reads avoided)", + func(m *pebble.Metrics) float64 { return float64(m.Filter.Hits) }) + p.counter("pebble_filter_misses", "{count}", "Bloom filter misses", + func(m *pebble.Metrics) float64 { return float64(m.Filter.Misses) }) + + p.gauge("pebble_memtable_count", "{count}", "Current number of memtables", + func(m *pebble.Metrics) float64 { return float64(m.MemTable.Count) }) + p.gauge("pebble_memtable_total_size", "By", "Total size of all memtables", + func(m *pebble.Metrics) float64 { return float64(m.MemTable.Size) }) + p.gauge("pebble_memtable_zombie_size", "By", "Bytes in zombie memtables (released but in use by iterators)", + func(m *pebble.Metrics) float64 { return float64(m.MemTable.ZombieSize) }) + p.gauge("pebble_memtable_zombie_count", "{count}", "Count of zombie memtables", + func(m *pebble.Metrics) float64 { return float64(m.MemTable.ZombieCount) }) + + p.gauge("pebble_wal_size", "By", "Current size of Write-Ahead Log", + func(m *pebble.Metrics) float64 { return float64(m.WAL.Size) }) + p.gauge("pebble_wal_files", "{count}", "Number of live WAL files", + func(m *pebble.Metrics) float64 { return float64(m.WAL.Files) }) + p.gauge("pebble_wal_obsolete_files", "{count}", "Number of obsolete WAL files", + func(m *pebble.Metrics) float64 { return float64(m.WAL.ObsoleteFiles) }) + p.gauge("pebble_wal_obsolete_physical_size", "By", "Physical size of obsolete WAL files", + func(m *pebble.Metrics) float64 { return float64(m.WAL.ObsoletePhysicalSize) }) + p.gauge("pebble_wal_physical_size", "By", "Physical size of WAL files on disk", + func(m *pebble.Metrics) float64 { return float64(m.WAL.PhysicalSize) }) + p.counter("pebble_wal_bytes_in", "By", "Logical bytes written to WAL", + func(m *pebble.Metrics) float64 { return float64(m.WAL.BytesIn) }) + p.counter("pebble_wal_bytes_written", "By", "Bytes written to WAL", + func(m *pebble.Metrics) float64 { return float64(m.WAL.BytesWritten) }) + p.counter("pebble_wal_failover_dir_switch_count", "{count}", "WAL directory switches (failover/failback)", + func(m *pebble.Metrics) float64 { return float64(m.WAL.Failover.DirSwitchCount) }) + p.gauge("pebble_wal_failover_primary_duration", "s", "Cumulative WAL write duration on primary", + func(m *pebble.Metrics) float64 { return m.WAL.Failover.PrimaryWriteDuration.Seconds() }) + p.gauge("pebble_wal_failover_secondary_duration", "s", "Cumulative WAL write duration on secondary", + func(m *pebble.Metrics) float64 { return m.WAL.Failover.SecondaryWriteDuration.Seconds() }) + + p.gauge("pebble_table_obsolete_size", "By", "Bytes in obsolete tables no longer referenced", + func(m *pebble.Metrics) float64 { return float64(m.Table.ObsoleteSize) }) + p.gauge("pebble_table_obsolete_count", "{count}", "Count of obsolete tables", + func(m *pebble.Metrics) float64 { return float64(m.Table.ObsoleteCount) }) + p.gauge("pebble_table_zombie_size", "By", "Bytes in zombie tables (released but in use by iterators)", + func(m *pebble.Metrics) float64 { return float64(m.Table.ZombieSize) }) + p.gauge("pebble_table_zombie_count", "{count}", "Count of zombie tables", + func(m *pebble.Metrics) float64 { return float64(m.Table.ZombieCount) }) + p.gauge("pebble_table_live_size", "By", "Bytes in live tables", + func(m *pebble.Metrics) float64 { return float64(m.Table.Local.LiveSize) }) + p.gauge("pebble_table_live_count", "{count}", "Count of live tables", + func(m *pebble.Metrics) float64 { return float64(m.Table.Local.LiveCount) }) + p.gauge("pebble_table_backing_count", "{count}", "Sstables backing virtual tables", + func(m *pebble.Metrics) float64 { return float64(m.Table.BackingTableCount) }) + p.gauge("pebble_table_backing_size", "By", "Size of sstables backing virtual tables", + func(m *pebble.Metrics) float64 { return float64(m.Table.BackingTableSize) }) + p.gauge("pebble_table_compressed_unknown", "{count}", "Sstables with unknown compression", + func(m *pebble.Metrics) float64 { return float64(m.Table.CompressedCountUnknown) }) + p.gauge("pebble_table_compressed_snappy", "{count}", "Snappy-compressed sstables", + func(m *pebble.Metrics) float64 { return float64(m.Table.CompressedCountSnappy) }) + p.gauge("pebble_table_compressed_zstd", "{count}", "Zstd-compressed sstables", + func(m *pebble.Metrics) float64 { return float64(m.Table.CompressedCountZstd) }) + p.gauge("pebble_table_compressed_minlz", "{count}", "MinLZ-compressed sstables", + func(m *pebble.Metrics) float64 { return float64(m.Table.CompressedCountMinLZ) }) + p.gauge("pebble_table_compressed_none", "{count}", "Uncompressed sstables", + func(m *pebble.Metrics) float64 { return float64(m.Table.CompressedCountNone) }) + p.gauge("pebble_table_local_obsolete_size", "By", "Local obsolete table size", + func(m *pebble.Metrics) float64 { return float64(m.Table.Local.ObsoleteSize) }) + p.gauge("pebble_table_local_obsolete_count", "{count}", "Local obsolete table count", + func(m *pebble.Metrics) float64 { return float64(m.Table.Local.ObsoleteCount) }) + p.gauge("pebble_table_local_zombie_size", "By", "Local zombie table size", + func(m *pebble.Metrics) float64 { return float64(m.Table.Local.ZombieSize) }) + p.gauge("pebble_table_local_zombie_count", "{count}", "Local zombie table count", + func(m *pebble.Metrics) float64 { return float64(m.Table.Local.ZombieCount) }) + p.gauge("pebble_table_garbage_point_deletions_estimate", "By", "Est. bytes reclaimable from point deletes", + func(m *pebble.Metrics) float64 { return float64(m.Table.Garbage.PointDeletionsBytesEstimate) }) + p.gauge("pebble_table_garbage_range_deletions_estimate", "By", "Est. bytes reclaimable from range deletes", + func(m *pebble.Metrics) float64 { return float64(m.Table.Garbage.RangeDeletionsBytesEstimate) }) + p.gauge("pebble_table_initial_stats_complete", "1", "1 if initial stats collection complete", + func(m *pebble.Metrics) float64 { + if m.Table.InitialStatsCollectionComplete { + return 1 + } + return 0 + }) + p.gauge("pebble_table_pending_stats_count", "{count}", "New sstables awaiting stats collection", + func(m *pebble.Metrics) float64 { return float64(m.Table.PendingStatsCollectionCount) }) + + p.gauge("pebble_blob_files_live_count", "{count}", "Live blob file count", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.LiveCount) }) + p.gauge("pebble_blob_files_live_size", "By", "Live blob file physical size", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.LiveSize) }) + p.gauge("pebble_blob_files_value_size", "By", "Uncompressed value size in live blobs", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.ValueSize) }) + p.gauge("pebble_blob_files_referenced_value_size", "By", "Referenced value size in live blobs", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.ReferencedValueSize) }) + p.gauge("pebble_blob_files_obsolete_count", "{count}", "Obsolete blob file count", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.ObsoleteCount) }) + p.gauge("pebble_blob_files_obsolete_size", "By", "Obsolete blob file size", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.ObsoleteSize) }) + p.gauge("pebble_blob_files_zombie_count", "{count}", "Zombie blob file count", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.ZombieCount) }) + p.gauge("pebble_blob_files_zombie_size", "By", "Zombie blob file size", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.ZombieSize) }) + p.gauge("pebble_blob_files_local_live_size", "By", "Local live blob file size", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.Local.LiveSize) }) + p.gauge("pebble_blob_files_local_live_count", "{count}", "Local live blob file count", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.Local.LiveCount) }) + p.gauge("pebble_blob_files_local_obsolete_size", "By", "Local obsolete blob file size", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.Local.ObsoleteSize) }) + p.gauge("pebble_blob_files_local_obsolete_count", "{count}", "Local obsolete blob file count", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.Local.ObsoleteCount) }) + p.gauge("pebble_blob_files_local_zombie_size", "By", "Local zombie blob file size", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.Local.ZombieSize) }) + p.gauge("pebble_blob_files_local_zombie_count", "{count}", "Local zombie blob file count", + func(m *pebble.Metrics) float64 { return float64(m.BlobFiles.Local.ZombieCount) }) + + p.gauge("pebble_file_cache_size", "By", "Bytes in file cache", + func(m *pebble.Metrics) float64 { return float64(m.FileCache.Size) }) + p.gauge("pebble_file_cache_table_count", "{count}", "Tables in file cache", + func(m *pebble.Metrics) float64 { return float64(m.FileCache.TableCount) }) + p.gauge("pebble_file_cache_blob_file_count", "{count}", "Blob files in file cache", + func(m *pebble.Metrics) float64 { return float64(m.FileCache.BlobFileCount) }) + p.counter("pebble_file_cache_hits", "{count}", "File cache hits", + func(m *pebble.Metrics) float64 { return float64(m.FileCache.Hits) }) + p.counter("pebble_file_cache_misses", "{count}", "File cache misses", + func(m *pebble.Metrics) float64 { return float64(m.FileCache.Misses) }) + + p.gauge("pebble_num_virtual", "{count}", "Total virtual sstable count", + func(m *pebble.Metrics) float64 { return float64(m.NumVirtual()) }) + p.gauge("pebble_virtual_size", "By", "Total virtual sstable size", + func(m *pebble.Metrics) float64 { return float64(m.VirtualSize()) }) + p.gauge("pebble_remote_tables_count", "{count}", "Remote tables count", + func(m *pebble.Metrics) float64 { + count, _ := m.RemoteTablesTotal() + return float64(count) + }) + p.gauge("pebble_remote_tables_size", "By", "Remote tables size", + func(m *pebble.Metrics) float64 { + _, size := m.RemoteTablesTotal() + return float64(size) + }) + + p.gauge("pebble_keys_range_key_sets_count", "{count}", "Approximate count of internal range key set keys", + func(m *pebble.Metrics) float64 { return float64(m.Keys.RangeKeySetsCount) }) + p.gauge("pebble_keys_tombstone_count", "{count}", "Approximate count of internal tombstones", + func(m *pebble.Metrics) float64 { return float64(m.Keys.TombstoneCount) }) + p.counter("pebble_keys_missized_tombstones_count", "{count}", "Missized DELSIZED keys encountered by compactions", + func(m *pebble.Metrics) float64 { return float64(m.Keys.MissizedTombstonesCount) }) + + p.gauge("pebble_snapshot_count", "{count}", "Number of currently open snapshots", + func(m *pebble.Metrics) float64 { return float64(m.Snapshots.Count) }) + p.counter("pebble_snapshot_pinned_keys", "{count}", "Keys written that would've been elided without open snapshots", + func(m *pebble.Metrics) float64 { return float64(m.Snapshots.PinnedKeys) }) + p.counter("pebble_snapshot_pinned_size", "By", "Size of keys/values written due to open snapshots", + func(m *pebble.Metrics) float64 { return float64(m.Snapshots.PinnedSize) }) + p.gauge("pebble_snapshot_earliest_seq_num", "{count}", "Sequence number of earliest open snapshot", + func(m *pebble.Metrics) float64 { return float64(m.Snapshots.EarliestSeqNum) }) + + p.gauge("pebble_table_iters", "{count}", "Count of open sstable iterators", + func(m *pebble.Metrics) float64 { return float64(m.TableIters) }) + p.gauge("pebble_uptime_seconds", "s", "Seconds since DB was opened", + func(m *pebble.Metrics) float64 { return m.Uptime.Seconds() }) + p.gauge("pebble_read_amp", "{count}", "Read amplification", + func(m *pebble.Metrics) float64 { return float64(m.ReadAmp()) }) + p.gauge("pebble_disk_space_usage", "By", "Total disk space used by the DB", + func(m *pebble.Metrics) float64 { return float64(m.DiskSpaceUsage()) }) + + p.counter("pebble_cache_hits", "{count}", "Total number of cache hits", + func(m *pebble.Metrics) float64 { return float64(m.BlockCache.Hits) }) + p.counter("pebble_cache_misses", "{count}", "Total number of cache misses", + func(m *pebble.Metrics) float64 { return float64(m.BlockCache.Misses) }) + p.gauge("pebble_cache_size", "By", "Current cache size", + func(m *pebble.Metrics) float64 { return float64(m.BlockCache.Size) }) +} - if pm.cacheHits != nil { - addDelta(ctx, pm.cacheHits, m.BlockCache.Hits, &pm.prevCacheHits, metric.WithAttributes(dbAttr)) - } - if pm.cacheMisses != nil { - addDelta(ctx, pm.cacheMisses, m.BlockCache.Misses, &pm.prevCacheMisses, metric.WithAttributes(dbAttr)) - } - if pm.cacheSize != nil { - pm.cacheSize.Record(ctx, m.BlockCache.Size, metric.WithAttributes(dbAttr)) - } +// declareLevels declares the series read per LSM level, each reported with a +// "level" attribute. +func (p *pebbleMetrics) declareLevels() { + p.levelGauge("pebble_sstable_count", "{count}", "Current number of SSTables at each level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TablesCount) }) + p.levelGauge("pebble_sstable_total_size", "By", "Total size of SSTables at each level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TablesSize) }) + p.levelGauge("pebble_sstable_sublevels", "{count}", "Number of sublevels (read amplification); L0 only has non-0/1", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.Sublevels) }) + p.levelGauge("pebble_sstable_score", "1", "Level compaction score (0 if no compaction needed)", + func(lm *pebble.LevelMetrics) float64 { return lm.Score }) + p.levelGauge("pebble_sstable_fill_factor", "1", "Level fill factor (size vs ideal size)", + func(lm *pebble.LevelMetrics) float64 { return lm.FillFactor }) + p.levelGauge("pebble_sstable_virtual_count", "{count}", "Number of virtual sstables at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.VirtualTablesCount) }) + p.levelGauge("pebble_sstable_virtual_size", "By", "Size of virtual sstables at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.VirtualTablesSize) }) + p.levelCounter("pebble_compaction_bytes_read", "By", "Total bytes read during compaction", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TableBytesIn) }) + p.levelCounter("pebble_compaction_bytes_written", "By", "Total bytes written during compaction", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TableBytesCompacted) }) + p.levelCounter("pebble_sstable_bytes_ingested", "By", "Sstable bytes ingested at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TableBytesIngested) }) + p.levelCounter("pebble_sstable_bytes_moved", "By", "Sstable bytes moved by move compaction at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TableBytesMoved) }) + p.levelCounter("pebble_sstable_bytes_read", "By", "Bytes read for compactions at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TableBytesRead) }) + p.levelCounter("pebble_sstable_bytes_flushed", "By", "Bytes written to sstables during flushes at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TableBytesFlushed) }) + p.levelCounter("pebble_sstable_tables_compacted", "{count}", "Sstables compacted to this level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TablesCompacted) }) + p.levelCounter("pebble_sstable_tables_flushed", "{count}", "Sstables flushed to this level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TablesFlushed) }) + p.levelCounter("pebble_sstable_tables_ingested", "{count}", "Sstables ingested into level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TablesIngested) }) + p.levelCounter("pebble_sstable_tables_moved", "{count}", "Sstables moved to level by move compaction", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TablesMoved) }) + p.levelGauge("pebble_sstable_compensated_fill_factor", "1", "Level compensated fill factor", + func(lm *pebble.LevelMetrics) float64 { return lm.CompensatedFillFactor }) + p.levelGauge("pebble_sstable_estimated_references_size", "By", "Est. physical size of blob refs at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.EstimatedReferencesSize) }) + p.levelCounter("pebble_sstable_tables_deleted", "{count}", "Sstables deleted by delete-only compaction at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TablesDeleted) }) + p.levelCounter("pebble_sstable_tables_excised", "{count}", "Sstables excised by delete-only compaction at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.TablesExcised) }) + p.levelCounter("pebble_sstable_blob_bytes_read_estimate", "By", "Est. physical bytes read for blob refs at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.BlobBytesReadEstimate) }) + p.levelCounter("pebble_sstable_blob_bytes_compacted", "By", "Blob bytes written during compaction at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.BlobBytesCompacted) }) + p.levelCounter("pebble_sstable_blob_bytes_flushed", "By", "Blob bytes written during flush at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.BlobBytesFlushed) }) + p.levelCounter("pebble_sstable_multi_level_bytes_in_top", "By", "Bytes from top level in multilevel compaction", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.MultiLevel.TableBytesInTop) }) + p.levelCounter("pebble_sstable_multi_level_bytes_in", "By", "Bytes in for multilevel compaction", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.MultiLevel.TableBytesIn) }) + p.levelCounter("pebble_sstable_multi_level_bytes_read", "By", "Bytes read for multilevel compaction", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.MultiLevel.TableBytesRead) }) + p.levelGauge("pebble_sstable_value_blocks_size", "By", "Value blocks size at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.Additional.ValueBlocksSize) }) + p.levelCounter("pebble_sstable_bytes_written_data_blocks", "By", "Bytes written to data blocks at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.Additional.BytesWrittenDataBlocks) }) + p.levelCounter("pebble_sstable_bytes_written_value_blocks", "By", "Bytes written to value blocks at level", + func(lm *pebble.LevelMetrics) float64 { return float64(lm.Additional.BytesWrittenValueBlocks) }) } diff --git a/sei-db/state_db/giga/state_db.go b/sei-db/state_db/giga/state_db.go new file mode 100644 index 0000000000..1e328ca994 --- /dev/null +++ b/sei-db/state_db/giga/state_db.go @@ -0,0 +1,352 @@ +package giga + +import ( + "context" + "errors" + "fmt" + + "github.com/sei-protocol/seilog" + + "github.com/sei-protocol/sei-chain/sei-db/common/utils" + "github.com/sei-protocol/sei-chain/sei-db/config" + "github.com/sei-protocol/sei-chain/sei-db/controller" + "github.com/sei-protocol/sei-chain/sei-db/proto" + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" + flatkvconfig "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/lthash" + "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/evm" + "github.com/sei-protocol/sei-chain/sei-db/state_db/statewal" +) + +var logger = seilog.NewLogger("db", "state-db", "giga") + +var _ gigatypes.StateDB = (*StateDB)(nil) + +// StateDB writes a committed block to the state WAL, the state commit store (SC) and the EVM state +// store (SS), and serves current-block reads from SC. +// +// It opens all three stores, brings them onto one height, and closes them. SC and SS run without a WAL +// of their own, so every block either of them replays is read from the WAL here. +type StateDB struct { + // Where the state commit store and the state WAL live. + flatkvCfg *flatkvconfig.Config + + // Where the EVM state store lives, and whether it is enabled at all. + ssCfg config.StateStoreConfig + + // The state WAL a committed block is written to. + wal statewal.StateWAL + + // The state commit store, which both receives writes and serves current-block reads. + sc *flatkv.CommitStore + + // ss is nil when the EVM state store is disabled. + ss *evm.EVMStateStore + + // The checkpoint schedule SC and SS take their snapshot boundaries from. + checkpointer *controller.CheckpointScheduler +} + +// NewStateDB opens SC, SS and the state WAL from their configs and puts SC and SS on one checkpoint +// schedule. +// +// Both stores are put on the WAL's head — replayed up to it, and rewound onto it when a lost WAL tail +// left them above it — so the returned StateDB commits the block after it. NewStateDBWithRollback opens +// them on an earlier height instead. +// +// The returned StateDB owns all three stores and closes them on Close. A failed call closes whatever it +// had already opened. +func NewStateDB( + ctx context.Context, + flatkvCfg *flatkvconfig.Config, + ssCfg config.StateStoreConfig, + checkpointCfg config.CheckpointConfig, +) (db *StateDB, retErr error) { + s := &StateDB{flatkvCfg: flatkvCfg, ssCfg: ssCfg} + defer s.closeOnFailure(&retErr) + + wal, err := s.storedWALRange() + if err != nil { + return nil, err + } + // Before either store opens, the rewinds it may run needing their files closed. + if err := s.discardStateAboveTheWAL(wal); err != nil { + return nil, err + } + if err := s.openSS(); err != nil { + return nil, err + } + if err := s.openSC(ctx); err != nil { + return nil, err + } + if err := s.openWAL(); err != nil { + return nil, err + } + s.startCheckpointSchedule(checkpointCfg) + + if err := s.catchUpToWAL(); err != nil { + return nil, err + } + return s, nil +} + +// NewStateDBWithRollback rolls SC, SS and the state WAL back to target and then opens them, so the +// returned StateDB commits target+1. It cuts the WAL's tail to target and puts whichever of SC and SS +// sits above target on its newest snapshot at or below it, all while the stores are closed, then opens +// them the ordinary way and checks both landed on target. +// +// target must be positive, and a target the surviving snapshots and the WAL cannot span is refused. A +// refusal leaves the WAL uncut, so no target this one could reach is lost, but one from SS comes back +// with SC already rewound. +func NewStateDBWithRollback( + ctx context.Context, + flatkvCfg *flatkvconfig.Config, + ssCfg config.StateStoreConfig, + checkpointCfg config.CheckpointConfig, + target int64, +) (*StateDB, error) { + if target <= 0 { + // An empty WAL has a head of 0, which rewindTo reads as nothing to rewind, so without this a + // caller asking for a rollback would get a plain open instead. + return nil, fmt.Errorf("rollback target %d is invalid: version 0 means no state, so there is "+ + "nothing to roll back to", target) + } + + // rewindTo only moves files, so it needs no store open, only where they live. + offline := &StateDB{flatkvCfg: flatkvCfg, ssCfg: ssCfg} + if err := offline.rewindTo(target); err != nil { + return nil, err + } + db, err := NewStateDB(ctx, flatkvCfg, ssCfg, checkpointCfg) + if err != nil { + return nil, err + } + if err := db.matchHeight(target); err != nil { + return nil, errors.Join(fmt.Errorf("cannot roll back to %d: %w", target, err), db.Close()) + } + return db, nil +} + +// closeOnFailure closes the stores a failed open had reached, so a caller that gets an error holds no +// store this StateDB left open. It is deferred against the constructor's named error. +func (s *StateDB) closeOnFailure(retErr *error) { + if *retErr == nil { + return + } + if err := s.Close(); err != nil { + *retErr = errors.Join(*retErr, fmt.Errorf("close a partially opened state DB: %w", err)) + } +} + +// openWAL opens the state WAL this StateDB commits blocks to. +func (s *StateDB) openWAL() error { + wal, err := flatkv.OpenStateWAL(s.flatkvCfg) + if err != nil { + return fmt.Errorf("open state WAL: %w", err) + } + s.wal = wal + return nil +} + +// openSC opens SC with no WAL of its own, on the version its files hold: the working copy, or the +// snapshot a rollback has just repointed it at. It replays nothing, so it comes up at or below the +// WAL's head and catchUpTo carries it forward from there. +func (s *StateDB) openSC(ctx context.Context) error { + sc, err := flatkv.NewCommitStore(ctx, s.flatkvCfg, nil) + if err != nil { + return fmt.Errorf("open state commit store: %w", err) + } + s.sc = sc + // Every readonly-* directory under the store is deleted, so this has to run before the process + // opens a read-only view of its own: after that, the ones a crashed process left are no longer + // the only ones there. + if err := s.sc.CleanupOrphanedReadOnlyDirs(); err != nil { + return fmt.Errorf("clean up orphaned state commit read-only dirs: %w", err) + } + if err := s.sc.LoadWorkingCopy(); err != nil { + return fmt.Errorf("load the state commit store: %w", err) + } + return nil +} + +// openSS opens the EVM state store and its snapshot manager, leaving it nil when the store is disabled. +func (s *StateDB) openSS() error { + if !s.ssCfg.Enable { + return nil + } + ss, err := evm.NewEVMStateStore(s.ssCfg.EVMDBDirectory, s.ssCfg) + if err != nil { + return fmt.Errorf("open EVM state store: %w", err) + } + s.ss = ss + if err := s.ss.StartSnapshots(s.ssSnapshotRoot(), s.ssCfg, nil); err != nil { + return fmt.Errorf("start EVM state store snapshot manager: %w", err) + } + return nil +} + +// startCheckpointSchedule puts SC and SS on one snapshot cadence. It runs before either store is on a +// height, so the blocks SC replays offer themselves to the schedule as live commits do. +func (s *StateDB) startCheckpointSchedule(cfg config.CheckpointConfig) { + s.checkpointer = controller.NewCheckpointScheduler(cfg) + s.sc.SetCheckpointScheduler(s.checkpointer) + if s.ss != nil { + s.ss.SetCheckpointScheduler(s.checkpointer) + } +} + +// ssSnapshotRoot returns the directory SS keeps its snapshots in. +func (s *StateDB) ssSnapshotRoot() string { + return utils.GetStateStoreSnapshotsSiblingPath(s.ssCfg.EVMDBDirectory) +} + +// storedWALRange is the block range a state WAL holds on disk: the lowest and highest blocks in it, +// both 0 when it holds none. +type storedWALRange struct { + first, last int64 +} + +// walConfig returns the config that locates the state WAL on disk. +func (s *StateDB) walConfig() *statewal.Config { + return flatkv.StateWALConfig(s.flatkvCfg.DataDir) +} + +// storedWALRange reads the state WAL's block range from its directory. It takes that directory's +// exclusive lock, so it is only for the window before the WAL opens; GetStoredRange on the open handle +// answers the same question afterwards. +func (s *StateDB) storedWALRange() (storedWALRange, error) { + stored, first, last, err := statewal.GetRange(s.walConfig()) + if err != nil { + return storedWALRange{}, fmt.Errorf("read state WAL range: %w", err) + } + if !stored { + return storedWALRange{}, nil + } + //nolint:gosec // a block number never approaches the int64 ceiling + return storedWALRange{first: int64(first), last: int64(last)}, nil +} + +// openWALRange reads the block range from the open WAL handle, which storedWALRange's directory lock +// rules out reading once the WAL is open. +func (s *StateDB) openWALRange() (storedWALRange, error) { + stored, first, last, err := s.wal.GetStoredRange() + if err != nil { + return storedWALRange{}, fmt.Errorf("read state WAL range: %w", err) + } + if !stored { + return storedWALRange{}, nil + } + //nolint:gosec // a block number never approaches the int64 ceiling + return storedWALRange{first: int64(first), last: int64(last)}, nil +} + +// truncateWAL drops every WAL block above target so the next commit is target+1. A live WAL prunes only +// from its start, so this cuts the tail through the directory, which requires that no WAL be open on it. +func (s *StateDB) truncateWAL(target int64) error { + //nolint:gosec // target > 0 here, checked by NewStateDBWithRollback + if err := statewal.PruneAfter(s.walConfig(), uint64(target)); err != nil { + return fmt.Errorf("truncate state WAL to %d: %w", target, err) + } + return nil +} + +// Close closes SC, SS and the state WAL, reporting every failure rather than stopping at the first. +// The WAL closes last, since SC replays through it. +func (s *StateDB) Close() error { + var errs error + if s.ss != nil { + if err := s.ss.Close(); err != nil { + errs = errors.Join(errs, fmt.Errorf("close EVM state store: %w", err)) + } + } + if s.sc != nil { + if err := s.sc.Close(); err != nil { + errs = errors.Join(errs, fmt.Errorf("close state commit store: %w", err)) + } + } + if s.wal != nil { + if err := s.wal.Close(); err != nil { + errs = errors.Join(errs, fmt.Errorf("close state WAL: %w", err)) + } + } + return errs +} + +// SC returns the state commit store. +func (s *StateDB) SC() *flatkv.CommitStore { return s.sc } + +// SS returns the EVM state store, or nil when it is disabled. +func (s *StateDB) SS() *evm.EVMStateStore { return s.ss } + +// WAL returns the state WAL. It is the one this StateDB opened, and is not replaced for the StateDB's +// lifetime. +func (s *StateDB) WAL() statewal.StateWAL { return s.wal } + +// CheckpointScheduler returns the schedule SC and SS take their snapshot boundaries from. +func (s *StateDB) CheckpointScheduler() *controller.CheckpointScheduler { return s.checkpointer } + +// PrunableStores returns the opened stores that can join a prune cycle. +func (s *StateDB) PrunableStores() []controller.PrunableStore { + stores := make([]controller.PrunableStore, 0, 3) + if s.sc != nil { + stores = append(stores, s.sc) + } + if s.wal != nil { + stores = append(stores, s.wal) + } + if s.ss != nil { + stores = append(stores, s.ss) + } + return stores +} + +func (s *StateDB) CommitStateChanges(blockNum int64, changeset []*proto.NamedChangeSet) error { + if blockNum < 0 { + // The WAL numbers blocks with a uint64, so a negative height converts to a block far in the + // future that the WAL has no way to recognize as a mistake. + return fmt.Errorf("commit block %d: block number must not be negative", blockNum) + } + + // No need to flush WAL, since this WAL isn't used for crash recoverability safety (that's the BlockDB's job). + if err := s.wal.Write(uint64(blockNum), changeset); err != nil { + return fmt.Errorf("write block %d to state WAL: %w", blockNum, err) + } + if err := s.wal.SignalEndOfBlock(); err != nil { + return fmt.Errorf("end block %d in state WAL: %w", blockNum, err) + } + + if err := s.sc.CommitStateChanges(blockNum, changeset); err != nil { + return fmt.Errorf("commit block %d to live state DB: %w", blockNum, err) + } + // SS takes the block asynchronously and is not waited on: the WAL is written first, so a shutdown + // that loses the queue leaves SS behind the WAL, which is the gap catchUpTo replays on the next open. + if s.ss != nil { + if err := s.ss.CommitBlock(blockNum, changeset); err != nil { + return fmt.Errorf("commit block %d to the EVM state store: %w", blockNum, err) + } + } + + return nil +} + +func (s *StateDB) OpenView() gigatypes.StateView { + return s.sc.OpenView() +} + +// OpenViewAt panics. Serving a past height requires the historical state DB, which is not wired into +// StateDB. +func (s *StateDB) OpenViewAt(blockNum int64) (gigatypes.StateView, bool) { + panic(fmt.Sprintf( + "giga: OpenViewAt(%d) is not implemented: the historical state DB is not wired in", blockNum)) +} + +// RegisterHashListener forwards to the state commit store, which is the layer that hashes blocks and +// so is the layer that dispatches them. +func (s *StateDB) RegisterHashListener(listener gigatypes.HashListener) (lthash.BlockHash, error) { + mostRecentHash, err := s.sc.RegisterHashListener(listener) + if err != nil { + return mostRecentHash, fmt.Errorf("register hash listener on the state commit store: %w", err) + } + return mostRecentHash, nil +} diff --git a/sei-db/state_db/giga/state_db_impl.go b/sei-db/state_db/giga/state_db_impl.go deleted file mode 100644 index b9b2c3788f..0000000000 --- a/sei-db/state_db/giga/state_db_impl.go +++ /dev/null @@ -1,557 +0,0 @@ -package giga - -import ( - "context" - "errors" - "fmt" - "math" - - "github.com/sei-protocol/seilog" - - "github.com/sei-protocol/sei-chain/sei-db/common/utils" - "github.com/sei-protocol/sei-chain/sei-db/config" - "github.com/sei-protocol/sei-chain/sei-db/controller" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" - flatkvconfig "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/lthash" - "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/evm" - "github.com/sei-protocol/sei-chain/sei-db/state_db/statewal" -) - -var logger = seilog.NewLogger("db", "state-db", "giga") - -var _ gigatypes.StateDB = (*StateDB)(nil) - -// StateDB fans a committed block out to the state WAL and the two halves of state, and serves -// current-block reads from the state commit store. -// -// It owns all three stores: it opens them, converges them onto one height, and closes them. The WAL in -// particular it owns outright — SC and SS each run without one, so this is the only writer, and the -// replay that brings either of them onto a height reads through this WAL rather than theirs. -type StateDB struct { - // The state WAL a committed block is written to. - wal statewal.StateWAL - - // What the stores were opened from, and what locates the WAL. A tail truncation is offline, so the - // handle above has to be closed and reopened from this rather than mutated. - flatkvCfg *flatkvconfig.Config - - // The state commit store, which both receives writes and serves current-block reads. - sc *flatkv.CommitStore - - // ss is nil when the EVM state store is disabled, which leaves it out of the fan-out and out of - // convergence. - ss *evm.EVMStateStore - - // The checkpoint schedule both halves of state take their snapshot boundaries from. - checkpointer *controller.CheckpointScheduler -} - -// NewStateDB opens the state commit store, the state WAL and the EVM state store from their configs -// and puts the two halves of state on one checkpoint schedule. -// -// It opens them where it finds them and converges nothing, so the two halves may sit at different -// heights and the returned StateDB is not yet ready to commit. RollbackTo puts them on a height and is -// what makes it ready; the caller names that height, because only it knows what the stores outside -// this StateDB can serve. -// -// SC is constructed with no WAL of its own, since this StateDB writes the WAL on its behalf, and it is -// loaded before the WAL is opened: a store carrying no WAL resolves its own version by reading the WAL -// directory out of band, which takes that directory's exclusive lock. ss is left unopened when -// ssCfg.Enable is false. -// -// The returned StateDB owns all three stores and closes them on Close. A failed call closes whatever it -// had already opened. -func NewStateDB( - ctx context.Context, - flatkvCfg *flatkvconfig.Config, - ssCfg config.StateStoreConfig, - checkpointCfg config.CheckpointConfig, -) (db *StateDB, retErr error) { - s := &StateDB{flatkvCfg: flatkvCfg} - defer func() { - if retErr != nil { - if closeErr := s.Close(); closeErr != nil { - retErr = errors.Join(retErr, fmt.Errorf("close a partially opened state DB: %w", closeErr)) - } - } - }() - - if err := s.openSC(ctx); err != nil { - return nil, err - } - if err := s.openWAL(); err != nil { - return nil, err - } - if err := s.openSS(ssCfg); err != nil { - return nil, err - } - s.startCheckpointSchedule(checkpointCfg) - - if err := s.sc.CleanupOrphanedReadOnlyDirs(); err != nil { - return nil, fmt.Errorf("clean up orphaned state commit read-only dirs: %w", err) - } - return s, nil -} - -// openWAL opens the state WAL this StateDB writes both halves of state through. -func (s *StateDB) openWAL() error { - wal, err := flatkv.OpenStateWAL(s.flatkvCfg) - if err != nil { - return fmt.Errorf("open state WAL: %w", err) - } - s.wal = wal - return nil -} - -// openSC opens the state commit store with no WAL of its own and loads it. The load must precede the -// WAL being opened, since a store with no WAL reads the WAL directory out of band to resolve its -// version, and that read takes the directory's exclusive lock. -func (s *StateDB) openSC(ctx context.Context) error { - sc, err := flatkv.NewCommitStore(ctx, s.flatkvCfg, nil) - if err != nil { - return fmt.Errorf("open state commit store: %w", err) - } - s.sc = sc - if err := s.sc.LoadLatest(); err != nil { - return fmt.Errorf("load the state commit store: %w", err) - } - return nil -} - -// openSS opens the EVM state store and its snapshot manager, leaving it nil when the store is disabled. -func (s *StateDB) openSS(cfg config.StateStoreConfig) error { - if !cfg.Enable { - return nil - } - ss, err := evm.NewEVMStateStore(cfg.EVMDBDirectory, cfg) - if err != nil { - return fmt.Errorf("open EVM state store: %w", err) - } - s.ss = ss - if err := s.ss.StartSnapshots(utils.GetStateStoreSnapshotsSiblingPath(ss.Dir()), cfg, nil); err != nil { - return fmt.Errorf("start EVM state store snapshot manager: %w", err) - } - return nil -} - -// startCheckpointSchedule puts both halves of state on one snapshot cadence. It is wired before either -// half is on a height, so SC's catch-up commits ask it as live blocks do, while SS replays outside its -// commit path and asks nothing. -func (s *StateDB) startCheckpointSchedule(cfg config.CheckpointConfig) { - s.checkpointer = controller.NewCheckpointScheduler(cfg) - s.sc.SetCheckpointScheduler(s.checkpointer) - if s.ss != nil { - s.ss.SetCheckpointScheduler(s.checkpointer) - } -} - -// SC returns the state commit store. -func (s *StateDB) SC() *flatkv.CommitStore { return s.sc } - -// SS returns the EVM state store, or nil when it is disabled. -func (s *StateDB) SS() *evm.EVMStateStore { return s.ss } - -// WAL returns the state WAL. A tail truncation replaces the handle, so this must be re-read after any -// call to RollbackTo rather than cached across one. -func (s *StateDB) WAL() statewal.StateWAL { return s.wal } - -// CheckpointScheduler returns the schedule both halves of state take their snapshot boundaries from. -func (s *StateDB) CheckpointScheduler() *controller.CheckpointScheduler { return s.checkpointer } - -// PrunableStores returns the opened stores that can join a prune cycle. -func (s *StateDB) PrunableStores() []controller.PrunableStore { - stores := make([]controller.PrunableStore, 0, 3) - if s.sc != nil { - stores = append(stores, s.sc) - } - if s.wal != nil { - stores = append(stores, s.wal) - } - if s.ss != nil { - stores = append(stores, s.ss) - } - return stores -} - -func (s *StateDB) CommitStateChanges(blockNum int64, changeset []*proto.NamedChangeSet) error { - if blockNum < 0 { - // The WAL numbers blocks with a uint64, so a negative height converts to a block far in the - // future that the WAL has no way to recognize as a mistake. - return fmt.Errorf("commit block %d: block number must not be negative", blockNum) - } - - // No need to flush WAL, since this WAL isn't used for crash recoverability safety (that's the BlockDB's job). - if err := s.wal.Write(uint64(blockNum), changeset); err != nil { - return fmt.Errorf("write block %d to state WAL: %w", blockNum, err) - } - if err := s.wal.SignalEndOfBlock(); err != nil { - return fmt.Errorf("end block %d in state WAL: %w", blockNum, err) - } - - if err := s.sc.CommitStateChanges(blockNum, changeset); err != nil { - return fmt.Errorf("commit block %d to live state DB: %w", blockNum, err) - } - // TODO: Commit changes to SS - - return nil -} - -func (s *StateDB) OpenView() gigatypes.StateView { - return s.sc.OpenView() -} - -// OpenViewAt panics. Serving a past height requires the historical state DB, which is not wired into -// StateDB. -func (s *StateDB) OpenViewAt(blockNum int64) (gigatypes.StateView, bool) { - panic(fmt.Sprintf( - "giga: OpenViewAt(%d) is not implemented: the historical state DB is not wired in", blockNum)) -} - -// RegisterHashListener forwards to the state commit store, which is the layer that hashes blocks and -// so is the layer that dispatches them. -func (s *StateDB) RegisterHashListener(listener gigatypes.HashListener) (lthash.BlockHash, error) { - mostRecentHash, err := s.sc.RegisterHashListener(listener) - if err != nil { - return mostRecentHash, fmt.Errorf("register hash listener on the state commit store: %w", err) - } - return mostRecentHash, nil -} - -// Close closes the two halves of state and the WAL they were recovered from, reporting every failure -// rather than stopping at the first. SS goes first and the WAL last, since SC replays through the WAL. -func (s *StateDB) Close() error { - var errs error - if s.ss != nil { - if err := s.ss.Close(); err != nil { - errs = errors.Join(errs, fmt.Errorf("close EVM state store: %w", err)) - } - } - if s.sc != nil { - if err := s.sc.Close(); err != nil { - errs = errors.Join(errs, fmt.Errorf("close state commit store: %w", err)) - } - } - if s.wal != nil { - if err := s.wal.Close(); err != nil { - errs = errors.Join(errs, fmt.Errorf("close state WAL: %w", err)) - } - } - return errs -} - -// walHead returns the last block the state WAL holds. ok is false on an empty WAL. -func (s *StateDB) walHead() (int64, bool, error) { - stored, _, last, err := s.wal.GetStoredRange() - if err != nil { - return 0, false, fmt.Errorf("read state WAL range: %w", err) - } - if !stored { - return 0, false, nil - } - if last > math.MaxInt64 { - return 0, false, fmt.Errorf("state WAL last block %d exceeds max int64", last) - } - return int64(last), true, nil //nolint:gosec // bounds checked above -} - -// RollbackTo puts both halves of state on blockNum, rewinding a half above it and replaying a half -// below it. blockNum must be positive, and a blockNum the surviving snapshots and the WAL cannot span -// is refused before anything moves. A half that holds no history takes no part and is left empty to -// fill forward from blockNum. -// -// The state WAL ends at blockNum afterwards, on a handle that replaces the one this opened with, so a -// reference a caller holds to it is invalid after this call. It must not run once the prune cycle has -// taken the WAL, which would leave the collector pruning through a closed instance. -func (s *StateDB) RollbackTo(blockNum int64) error { - if blockNum <= 0 { - // Neither rewind below can catch this, since each skips a store already at or below the target - // and 0 is at or below every version. The steps between them are destructive at 0: the WAL - // prune empties the WAL, and the snapshot removal takes every snapshot. - return fmt.Errorf("rollback target %d is invalid: version 0 means no state, so there is nothing "+ - "to roll back to", blockNum) - } - if err := s.requireReachable(blockNum); err != nil { - return err - } - if err := s.rewindSC(blockNum); err != nil { - return err - } - if err := s.dropSCSnapshotsAbove(blockNum); err != nil { - return err - } - if err := s.truncateWAL(blockNum); err != nil { - return err - } - if err := s.rewindSS(blockNum); err != nil { - return err - } - if err := s.catchUpSC(blockNum); err != nil { - return err - } - if err := s.catchUpSS(blockNum); err != nil { - return err - } - return s.matchHeight(blockNum) -} - -// requireReachable establishes that target can be reached, and names what is missing when it cannot. -// -// It runs before the rollback moves anything because every step of one is irreversible — snapshots above -// the target are deleted and the WAL is cut back to it — while the replay that needs the blocks in -// between runs last. Failing there leaves a node that will not start and no longer holds what a second -// attempt at a different height would need. -func (s *StateDB) requireReachable(target int64) error { - base, err := s.rollbackBase(target) - if err != nil { - return err - } - if base >= target { - return nil - } - - stored, first, last, err := s.wal.GetStoredRange() - if err != nil { - return fmt.Errorf("read state WAL range: %w", err) - } - //nolint:gosec // base >= 0 and target > 0 - from, to := uint64(base)+1, uint64(target) - if !stored { - return fmt.Errorf("cannot roll back to %d: replaying onto %d needs blocks %d-%d, but the state "+ - "WAL is empty", target, base, from, to) - } - if first > from || last < to { - return fmt.Errorf("cannot roll back to %d: replaying onto %d needs blocks %d-%d, but the state "+ - "WAL only holds %d-%d", target, base, from, to, first, last) - } - return nil -} - -// rollbackBase returns the height a rollback to target replays forward from: the lower of the heights -// the two halves start at, which for a half sitting above target is the snapshot its rewind lands on. -// -// A half with no snapshot at or below target is what makes a target unreachable outright, since there is -// no state to replay onto, and it is reported here rather than by the rewind that would have found it -// after the other half had already moved. -func (s *StateDB) rollbackBase(target int64) (int64, error) { - base := s.sc.Version() - if base > target { - landing, err := s.sc.SnapshotAtOrBelow(target) - if err != nil { - return 0, fmt.Errorf("cannot roll back to %d: the state commit store is on %d and has no "+ - "snapshot at or below the target: %w", target, base, err) - } - base = landing - } - // An SS holding nothing is not rewound and replays either the whole WAL or none of it, so it asks - // nothing of the WAL that this could refuse. catchUpSS decides between those two. - if s.ss == nil || s.ss.GetLatestVersion() == 0 { - return base, nil - } - - ssBase := s.ss.GetLatestVersion() - if ssBase > target { - landing, err := s.ss.SnapshotAtOrBelow(target) - if err != nil { - return 0, fmt.Errorf("cannot roll back to %d: the EVM state store is on %d and has no "+ - "snapshot at or below the target: %w", target, ssBase, err) - } - ssBase = landing - } - return min(base, ssBase), nil -} - -// ssFillsForward reports whether SS holds no history that the WAL can still rebuild, which leaves it -// out of convergence: it stays empty and starts filling at the block after the target. -// -// A store that holds nothing has nothing to disagree with, which is the treatment recoveryTarget gives -// an empty receipt store. Replaying one the WAL still covers is better, since it comes out holding real -// history, so this is the answer only for a WAL that has had a retention cut — where the alternative is -// refusing to start and calling a store that is merely new data loss. -func (s *StateDB) ssFillsForward() (bool, error) { - if s.ss == nil || s.ss.GetLatestVersion() > 0 { - return false, nil - } - stored, first, _, err := s.wal.GetStoredRange() - if err != nil { - return false, fmt.Errorf("read state WAL range: %w", err) - } - return !stored || first > 1, nil -} - -// matchHeight checks both halves of state against blockNum and reports the one that is not on it. -// -// An SS still holding nothing is one catchUpSS left to fill forward, since any replay of it would have -// landed on blockNum, so it is not held to the height it was deliberately left off. -func (s *StateDB) matchHeight(blockNum int64) error { - if got := s.sc.Version(); got != blockNum { - return fmt.Errorf("rollback to %d left the state commit store on %d", blockNum, got) - } - if s.ss == nil || s.ss.GetLatestVersion() == 0 { - return nil - } - if got := s.ss.GetLatestVersion(); got != blockNum { - return fmt.Errorf("rollback to %d left the EVM state store on %d", blockNum, got) - } - return nil -} - -// rewindSC drops SC back to a snapshot boundary at or below target when it sits above target, leaving -// catchUpSC to replay the WAL from there. A store already at or below target is left alone. -// -// SC moves between snapshot boundaries on its own and replays nothing itself, which is what keeps the -// WAL on this side of the split. -func (s *StateDB) rewindSC(target int64) error { - if s.sc.Version() <= target { - return nil - } - if _, err := s.sc.RewindToSnapshotAtOrBelow(target); err != nil { - return fmt.Errorf("rewind the state commit store to a snapshot at or below %d: %w", target, err) - } - return nil -} - -// dropSCSnapshotsAbove removes SC's snapshots above target, whether or not the rewind above ran. -// -// It is unconditional because rewindSC skips a store already at or below target, and an interrupted -// rewind leaves exactly that: SC reads as the snapshot it was repointed at, with the branch above it -// still on disk. Left there, a later rollback seeks a snapshot at or below its own target, lands on one -// from the branch this rollback abandoned, and replays this branch's blocks over it. -func (s *StateDB) dropSCSnapshotsAbove(target int64) error { - if err := s.sc.RemoveSnapshotsAbove(target); err != nil { - return fmt.Errorf("remove state commit snapshots above %d: %w", target, err) - } - return nil -} - -// rewindSS drops SS back to a snapshot at or below target when it sits above target, leaving catchUpSS -// to replay the WAL from there. A store already at or below target is left alone. -// -// Like SC, SS moves between snapshot boundaries on its own and replays nothing itself, which is what -// keeps the WAL on this side of the split. -func (s *StateDB) rewindSS(target int64) error { - if s.ss == nil || s.ss.GetLatestVersion() <= target { - return nil - } - if _, err := s.ss.RewindToSnapshotAtOrBelow(target); err != nil { - return fmt.Errorf("rewind the EVM state store to a snapshot at or below %d: %w", target, err) - } - return nil -} - -// truncateWAL drops every WAL block above target so the next commit is target+1, and is a no-op when -// the WAL already ends at or below target. -// -// The truncation runs against the directory rather than the open WAL, since a live WAL prunes only from -// its start, so the handle is closed and replaced by one over the truncated directory. -func (s *StateDB) truncateWAL(target int64) error { - head, ok, err := s.walHead() - if err != nil { - return err - } - if !ok || head <= target { - return nil - } - - if err := s.wal.Close(); err != nil { - return fmt.Errorf("close state WAL before truncating it to %d: %w", target, err) - } - //nolint:gosec // a WAL head above target means target >= 0 - if err := statewal.PruneAfter(flatkv.StateWALConfig(s.flatkvCfg.DataDir), uint64(target)); err != nil { - return fmt.Errorf("truncate state WAL to %d: %w", target, err) - } - if err := s.openWAL(); err != nil { - return fmt.Errorf("reopen state WAL truncated to %d: %w", target, err) - } - return nil -} - -// catchUpSC replays the WAL blocks above SC's version into it, up to target. -// -// SC owns no WAL, so committing these blocks appends nothing: re-writing blocks that were read from the -// WAL is the double-append this split exists to prevent. They do run SC's commit path otherwise, so the -// checkpoint schedule is asked at each of them. -func (s *StateDB) catchUpSC(target int64) error { - from := s.sc.Version() - if from >= target { - return nil - } - return s.replay(from, target, func(block int64, changesets []*proto.NamedChangeSet) error { - return s.sc.CommitStateChanges(block, changesets) - }) -} - -// catchUpSS replays the WAL blocks above SS's version into it, up to target. A store holding nothing -// the WAL can rebuild is left empty to fill forward instead. -// -// It goes through replay rather than replaying itself, so the check that the WAL still holds the blocks -// being asked for covers both halves of state: a WAL pruned past SS's head would otherwise leave it -// missing blocks while reporting the target as its own. -func (s *StateDB) catchUpSS(target int64) error { - if s.ss == nil { - return nil - } - from := s.ss.GetLatestVersion() - if from >= target { - return nil - } - fillForward, err := s.ssFillsForward() - if err != nil { - return err - } - if fillForward { - logger.Info("EVM state store left empty to fill forward: it holds no history and the state WAL "+ - "no longer reaches block 1", "target", target) - return nil - } - return s.replay(from, target, s.ss.ApplyReplayedBlock) -} - -// replay feeds apply every WAL block in (from, target], in order. -// -// Blocks are contiguous and the first is 1, so replay always starts at from+1. A WAL beginning later -// than that is missing history the destination still needs rather than simply holding a shorter range: -// starting at the WAL's own first block instead would skip those blocks and commit a state matching no -// chain history. Retention never drops a block a store still needs, so reaching that is data loss. -func (s *StateDB) replay(from, target int64, apply func(int64, []*proto.NamedChangeSet) error) error { - stored, first, last, err := s.wal.GetStoredRange() - if err != nil { - return fmt.Errorf("read state WAL range: %w", err) - } - if !stored { - return nil - } - - start := uint64(from) + 1 //nolint:gosec // callers replay forward from a version >= 0 - end := min(last, uint64(target)) //nolint:gosec // target > from >= 0 - if end < start { - return nil - } - if first > start { - return fmt.Errorf("state WAL starts at block %d but replay must start at block %d: blocks %d-%d "+ - "are missing (data loss or corruption)", first, start, start, first-1) - } - - it, err := s.wal.Iterator(start, end) - if err != nil { - return fmt.Errorf("state WAL iterator [%d,%d]: %w", start, end, err) - } - defer func() { _ = it.Close() }() - - for { - hasNext, err := it.Next() - if err != nil { - return fmt.Errorf("iterate state WAL: %w", err) - } - if !hasNext { - break - } - block, changesets := it.Entry() - if err := apply(int64(block), changesets); err != nil { //nolint:gosec // block <= end - return fmt.Errorf("replay block %d: %w", block, err) - } - } - return nil -} diff --git a/sei-db/state_db/giga/state_db_replay.go b/sei-db/state_db/giga/state_db_replay.go new file mode 100644 index 0000000000..0f67e142f5 --- /dev/null +++ b/sei-db/state_db/giga/state_db_replay.go @@ -0,0 +1,283 @@ +package giga + +import ( + "fmt" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" + "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/evm" +) + +// rewindTo puts whichever of SC and SS holds state above target on its newest snapshot at or below it, +// drops every snapshot of both above target, and cuts the WAL's tail to it. All three stores must be +// closed, and a store holding nothing above target is left where it is, for the replay to carry forward. +// +// A target the surviving snapshots and the WAL cannot span is refused before the WAL is cut, so every +// target this one could reach is still reachable on a retry. SS is asked once SC has moved, so a +// refusal from SS leaves SC on its snapshot and a retry replays from there. +func (s *StateDB) rewindTo(target int64) error { + wal, err := s.storedWALRange() + if err != nil { + return err + } + if wal.last < target { + return fmt.Errorf("cannot roll back to %d: the state WAL ends at %d, so no replay reaches the "+ + "target", target, wal.last) + } + + // First, so that a refusal from SC comes back with every snapshot still on disk. Once SC has moved, + // its own snapshots above where it landed are gone. + if err := s.discardStateAbove(wal, target); err != nil { + return fmt.Errorf("cannot roll back to %d: %w", target, err) + } + if err := s.dropSnapshotsAbove(target); err != nil { + return err + } + // Last, so that an interruption leaves the WAL still above target and a restart comes back here. + return s.truncateWAL(target) +} + +// discardStateAboveTheWAL puts each store back on the WAL's head when it sits above it, onto its +// newest snapshot at or below the head for the replay to carry forward. Every store must be closed. +// +// A commit writes the WAL unflushed, so a crash can lose its tail while the state committed above that +// tail survives. Those blocks are re-executed from the block store, which a store still holding them +// cannot accept, so the state above the WAL is dropped rather than kept. +func (s *StateDB) discardStateAboveTheWAL(wal storedWALRange) error { + head := wal.last + if head == 0 { + // An empty WAL says nothing about where state belongs: one pruned away behind a snapshot leaves + // the state it covered as the only record of it. + return nil + } + if err := s.discardStateAbove(wal, head); err != nil { + // Named for the open, not for a rollback: nobody asked for one, and an operator sent looking for + // the rollback they did not run is an operator not looking at the WAL head that refused. + return fmt.Errorf("cannot open on the state WAL's head %d: %w", head, err) + } + return nil +} + +// discardStateAbove puts whichever of SC and SS holds state above target onto its newest snapshot at or +// below it. Both stores must be closed, and a store holding nothing above target is left where it is, +// for the replay to carry forward. +// +// Each store is handed the WAL's first block and refuses, without moving, a target this WAL cannot +// replay it back up to. SC is put back first, so a refusal from SS can leave SC already rewound. +func (s *StateDB) discardStateAbove(wal storedWALRange, target int64) error { + if _, err := flatkv.DiscardStateAbove(s.flatkvCfg.DataDir, target, wal.first); err != nil { + return fmt.Errorf("the state commit store cannot reach %d: %w", target, err) + } + if !s.ssCfg.Enable { + return nil + } + if _, err := evm.DiscardStateAbove( + s.ssCfg, s.ssSnapshotRoot(), target, wal.first); err != nil { + return fmt.Errorf("the EVM state store cannot reach %d: %w", target, err) + } + return nil +} + +// dropSnapshotsAbove removes the snapshots of SC and SS above target. +// +// It runs whether or not either store is above target, because an interrupted rollback leaves exactly a +// store that is not: it reads as the snapshot it was repointed at, with the branch above it still on +// disk. Left there, a later rollback lands on a snapshot from the branch this one abandoned. +func (s *StateDB) dropSnapshotsAbove(target int64) error { + if err := flatkv.DropSnapshotsAbove(s.flatkvCfg.DataDir, target); err != nil { + return fmt.Errorf("cannot roll back the state commit store to %d: %w", target, err) + } + if !s.ssCfg.Enable { + return nil + } + if err := evm.DropSnapshotsAbove(s.ssSnapshotRoot(), target); err != nil { + return fmt.Errorf("cannot roll back the EVM state store to %d: %w", target, err) + } + return nil +} + +// catchUpToWAL replays the WAL into SC and SS up to the last block it holds, which is the height state +// committed to. An empty WAL leaves both stores where they are. +// +// A commit writes the WAL before either store, so a crash between the two leaves one of them a block +// behind. Committing from behind the WAL is rejected, so this is what makes an opened StateDB able to +// commit. +func (s *StateDB) catchUpToWAL() error { + wal, err := s.openWALRange() + if err != nil { + return err + } + if wal.last == 0 { + // Neither store is carried forward, for the reason discardStateAboveTheWAL gives: with no head to + // measure against, a working copy above the current snapshot is the only record of the blocks it + // holds, and dropping it on one store alone would leave the two at different heights. A rollback + // that empties the WAL brings both down in rewindTo, where the target says where they belong. + // + // An interrupted commit is still repaired, since the disagreement it leaves needs no head to be + // recognised. Nothing below reaches the repair catchUpTo runs. + if err := s.sc.RebuildIfTorn(); err != nil { + return fmt.Errorf("repair the state commit store's working copy: %w", err) + } + return nil + } + + head := wal.last + if err := s.catchUpTo(head); err != nil { + return err + } + if err := s.matchHeight(head); err != nil { + // Named for the open, as discardStateAboveTheWAL's refusals are: no rollback ran, and an + // operator sent looking for one is an operator not looking at the head that was not reached. + return fmt.Errorf("cannot open on the state WAL's head %d: %w", head, err) + } + return nil +} + +// catchUpTo replays the WAL into SC and SS up to target. +// +// One pass feeds both. It spans from the lower of their two versions, and each block goes only to the +// store still below it, so the WAL is read once rather than once per store. +func (s *StateDB) catchUpTo(target int64) error { + // Ahead of the pass, which is what erases the evidence it works from, and here rather than in the + // open because every replay of this WAL comes through this function. + if err := s.sc.RebuildIfUnreachable(target); err != nil { + return fmt.Errorf("rebuild the state commit store's working copy: %w", err) + } + scFrom := s.sc.Version() + ssFrom, ssReplays, err := s.ssReplayStart(target) + if err != nil { + return err + } + from := scFrom + if ssReplays { + from = min(from, ssFrom) + } + + if err := s.replay(from, target, func(block int64, changesets []*proto.NamedChangeSet) error { + if block > scFrom { + // SC owns no WAL, so re-committing a block read from this one appends nothing. It does run + // SC's commit path, so the checkpoint schedule is asked at each block SC takes. + if err := s.sc.CommitStateChanges(block, changesets); err != nil { + return err + } + } + if ssReplays && block > ssFrom { + return s.ss.ApplyReplayedBlock(block, changesets) + } + return nil + }); err != nil { + return err + } + return nil +} + +// replay feeds apply every WAL block in (from, target], in order. +// +// Blocks are contiguous from block 1, so a replay always starts at from+1. A WAL that begins later is +// missing history the destination needs: starting at the WAL's own first block would skip those blocks +// and commit a state matching no chain history, so it is reported as data loss. +func (s *StateDB) replay(from, target int64, apply func(int64, []*proto.NamedChangeSet) error) error { + stored, first, last, err := s.wal.GetStoredRange() + if err != nil { + return fmt.Errorf("read state WAL range: %w", err) + } + if !stored { + return nil + } + + start := uint64(from) + 1 //nolint:gosec // callers replay forward from a version >= 0 + end := min(last, uint64(target)) //nolint:gosec // target > from >= 0 + if end < start { + return nil + } + if first > start { + return fmt.Errorf("state WAL starts at block %d but replay must start at block %d: blocks %d-%d "+ + "are missing (data loss or corruption)", first, start, start, first-1) + } + + it, err := s.wal.Iterator(start, end) + if err != nil { + return fmt.Errorf("state WAL iterator [%d,%d]: %w", start, end, err) + } + defer func() { _ = it.Close() }() + + for { + hasNext, err := it.Next() + if err != nil { + return fmt.Errorf("iterate state WAL: %w", err) + } + if !hasNext { + break + } + block, changesets := it.Entry() + if err := apply(int64(block), changesets); err != nil { //nolint:gosec // block <= end + return fmt.Errorf("replay block %d: %w", block, err) + } + } + return nil +} + +// ssReplayStart returns the version SS replays forward from, and whether it replays at all. SS is left +// out when it is disabled, already on target, or empty with a WAL that can no longer rebuild it. +func (s *StateDB) ssReplayStart(target int64) (from int64, replays bool, err error) { + if s.ss == nil { + return 0, false, nil + } + from = s.ss.GetLatestVersion() + if from >= target { + return 0, false, nil + } + fillForward, err := s.ssFillsForward() + if err != nil { + return 0, false, err + } + if fillForward { + logger.Info("EVM state store left empty to fill forward: it holds no history and the state WAL "+ + "no longer reaches block 1", "target", target) + return 0, false, nil + } + return from, true, nil +} + +// ssFillsForward reports whether the open SS is left out of the replay to fill forward, which is the +// treatment recoveryTarget gives an empty receipt store. It covers a store with no history of its own +// behind a WAL that has had a retention cut, where no replay rebuilds it and the alternative is +// refusing to start over a store that is merely new. +func (s *StateDB) ssFillsForward() (bool, error) { + if s.ss == nil { + return false, nil + } + wal, err := s.openWALRange() + if err != nil { + return false, err + } + return s.ss.GetLatestVersion() == 0 && (wal.last == 0 || wal.first > 1), nil +} + +// matchHeight checks SC and SS against blockNum and reports the one that is not on it. An SS left empty +// to fill forward is not held to blockNum. +// +// The error names no path, since both the open and a rollback converge here; each caller supplies the +// height it asked for. +func (s *StateDB) matchHeight(blockNum int64) error { + if got := s.sc.Version(); got != blockNum { + return fmt.Errorf("the state commit store landed on %d", got) + } + if s.ss == nil { + return nil + } + got := s.ss.GetLatestVersion() + if got == blockNum { + return nil + } + if got == 0 { + fillForward, err := s.ssFillsForward() + if err != nil { + return err + } + if fillForward { + return nil + } + } + return fmt.Errorf("the EVM state store landed on %d", got) +} diff --git a/sei-db/state_db/giga/state_db_replay_test.go b/sei-db/state_db/giga/state_db_replay_test.go new file mode 100644 index 0000000000..eae73dc871 --- /dev/null +++ b/sei-db/state_db/giga/state_db_replay_test.go @@ -0,0 +1,148 @@ +package giga + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sei-protocol/sei-chain/sei-db/common/utils" + "github.com/sei-protocol/sei-chain/sei-db/config" + flatkvconfig "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" + "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/evm" + "github.com/sei-protocol/sei-chain/sei-db/state_db/statewal" + "github.com/stretchr/testify/require" +) + +// A node that keeps no EVM state store never reaches it, so nothing probes a store it does not have. +// The directory is one an earlier run with SS on could have left, and the WAL reaches block 1, so a +// rollback that read it would come back with a rewind to run. +func TestDiscardStateAboveLeavesANodeThatKeepsNoEVMStoreAlone(t *testing.T) { + const target = int64(7) + dir := t.TempDir() + s := &StateDB{ + flatkvCfg: flatkvconfig.DefaultTestConfig(t), + ssCfg: config.StateStoreConfig{Enable: false, EVMDBDirectory: dir}, + } + + require.NoError(t, s.discardStateAbove(storedWALRange{first: 1, last: 9}, target)) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Empty(t, entries, + "a rollback must not write to the directory of a store this node has not opened") +} + +// An interrupted rewind of a separate-DB SS leaves some of its databases holding blocks above the +// target and the rest empty. The head is the lowest of them, so the store reads as below the target and +// stopping there would leave those blocks for a replay that cannot delete them. The rewind has to be +// seen through instead, which for a store with no snapshot means emptying it. +func TestDiscardStateAboveSeesAnInterruptedRewindThrough(t *testing.T) { + const target = int64(3) + ssCfg := config.DefaultStateStoreConfig() + ssCfg.EVMDBDirectory = filepath.Join(t.TempDir(), "ss") + ssCfg.SeparateEVMSubDBs = true + writeTornSS(t, ssCfg, 5) + root := utils.GetStateStoreSnapshotsSiblingPath(ssCfg.EVMDBDirectory) + + landsOn, err := evm.DiscardStateAbove(ssCfg, root, target, 1) + require.NoError(t, err) + + require.Zero(t, landsOn, "the databases still holding block 5 have to be emptied, not left for the replay") + _, highest, err := evm.StoredVersions(ssCfg) + require.NoError(t, err) + require.Zero(t, highest, "no database may still record a block above the target") +} + +// writeTornSS leaves a separate-DB EVM state store with its databases disagreeing, as an interrupted +// rewind between two of them does: all but the first record version, and that one reopens empty. +func writeTornSS(t *testing.T, ssCfg config.StateStoreConfig, version int64) { + t.Helper() + ss, err := evm.NewEVMStateStore(ssCfg.EVMDBDirectory, ssCfg) + require.NoError(t, err) + require.NoError(t, ss.SetLatestVersion(version)) + require.NoError(t, ss.Close()) + require.NoError(t, os.RemoveAll( + filepath.Join(ssCfg.EVMDBDirectory, evm.StoreTypeName(evm.AllEVMStoreTypes()[0])))) +} + +// gapWAL reports a stored range beginning above the block a replay has to start from, which is what a +// WAL pruned past a store's head looks like. +type gapWAL struct { + statewal.StateWAL + first, last uint64 +} + +func (w *gapWAL) GetStoredRange() (bool, uint64, uint64, error) { return true, w.first, w.last, nil } + +// A WAL pruned past a store has dropped blocks that store still needs. Applying only the blocks the WAL +// happens to hold and then reporting the target as reached is silent divergence, so the replay refuses +// instead. +// +// SC and SS both have to reach this check, which is why they replay through one function rather than +// each walking the WAL: the store that goes around it is the one that diverges quietly. +func TestCatchUpRefusesAWALMissingTheBlocksAStoreNeeds(t *testing.T) { + const missingBlocks = "missing (data loss or corruption)" + + t.Run("the state commit store", func(t *testing.T) { + _, _, sc := newTestStateDB(t) + s := &StateDB{wal: &gapWAL{first: 3, last: 4}, sc: sc} + + require.ErrorContains(t, s.catchUpTo(4), missingBlocks) + }) + + t.Run("the EVM state store", func(t *testing.T) { + _, _, sc := newTestStateDB(t) + s := &StateDB{wal: &gapWAL{first: 3, last: 4}, sc: sc, ss: &evm.EVMStateStore{}} + + // The store holds nothing, so the gap is its whole history rather than a hole in it. Refusing + // here would report data loss for a store that is merely new, and would do it on every node + // past its first retention cut, so it is left out of the replay to fill forward from the target. + _, replays, err := s.ssReplayStart(4) + + require.NoError(t, err) + require.False(t, replays) + require.Zero(t, s.ss.GetLatestVersion()) + }) +} + +// A store left to fill forward is not held to the target afterwards. Holding it there would fail the +// rollback over exactly the state the catch-up had just decided was the right outcome. +func TestMatchHeightExcusesAStoreLeftToFillForward(t *testing.T) { + _, _, sc := newTestStateDB(t) + for block := int64(1); block <= 4; block++ { + require.NoError(t, sc.CommitStateChanges(block, changeset("k", "v"))) + } + s := &StateDB{wal: &gapWAL{first: 3, last: 4}, sc: sc, ss: &evm.EVMStateStore{}} + + require.NoError(t, s.matchHeight(4)) +} + +// An empty SS is only excused when the WAL cannot rebuild it. Excusing every version-0 store would +// treat a wiped history as a brand-new one whenever the WAL still reaches block 1. +func TestMatchHeightDoesNotExcuseAnEmptyStoreTheWALCanRebuild(t *testing.T) { + _, _, sc := newTestStateDB(t) + for block := int64(1); block <= 4; block++ { + require.NoError(t, sc.CommitStateChanges(block, changeset("k", "v"))) + } + s := &StateDB{wal: &gapWAL{first: 1, last: 4}, sc: sc, ss: &evm.EVMStateStore{}} + + err := s.matchHeight(4) + + require.ErrorContains(t, err, "EVM state store") + // Both the open and a rollback converge here, so the height belongs to whichever asked. Naming a + // rollback would send an operator whose node will not start looking for one nobody ran. + require.NotContains(t, err.Error(), "roll back") + require.NotContains(t, err.Error(), "4") +} + +// A store that holds nothing is only left empty when the WAL cannot rebuild it. One the WAL still +// reaches back far enough for comes out of recovery holding real history, which is strictly better, and +// is how a store that lagged the WAL is populated on restart. +func TestCatchUpRebuildsAnEmptyStoreTheWALStillCovers(t *testing.T) { + _, _, sc := newTestStateDB(t) + s := &StateDB{wal: &gapWAL{first: 1, last: 4}, sc: sc, ss: &evm.EVMStateStore{}} + + fillForward, err := s.ssFillsForward() + require.NoError(t, err) + require.False(t, fillForward, "a WAL starting at block 1 can rebuild an empty store") +} diff --git a/sei-db/state_db/giga/state_db_impl_test.go b/sei-db/state_db/giga/state_db_test.go similarity index 72% rename from sei-db/state_db/giga/state_db_impl_test.go rename to sei-db/state_db/giga/state_db_test.go index 9178a56ebf..5f6d0020a7 100644 --- a/sei-db/state_db/giga/state_db_impl_test.go +++ b/sei-db/state_db/giga/state_db_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/proto" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" @@ -24,9 +25,9 @@ const ( walEndOfBlockCall = "wal.SignalEndOfBlock" ) -// fakeStateWAL stands in for the state WAL so a test can watch the WAL half of the fan-out and fail it -// on demand. It embeds StateWAL without implementing it, so any method the fan-out is not expected to -// call panics on the nil interface rather than answering with a zero value. +// fakeStateWAL stands in for the state WAL so a test can watch what StateDB writes to it, and fail it +// on demand. It embeds StateWAL without implementing it, so any method StateDB is not expected to call +// panics on the nil interface rather than answering with a zero value. type fakeStateWAL struct { statewal.StateWAL @@ -64,74 +65,14 @@ func (w *fakeStateWAL) SignalEndOfBlock() error { func newTestStateDB(t *testing.T) (gigatypes.StateDB, *fakeStateWAL, *flatkv.CommitStore) { t.Helper() - liveStateDB, err := flatkv.NewCommitStore(t.Context(), flatkvconfig.DefaultTestConfig(t), nil) + cfg := flatkvconfig.DefaultTestConfig(t) + liveStateDB, err := flatkv.NewCommitStore(t.Context(), cfg, nil) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, liveStateDB.Close()) }) require.NoError(t, liveStateDB.LoadLatest()) wal := &fakeStateWAL{} - return &StateDB{wal: wal, sc: liveStateDB}, wal, liveStateDB -} - -// gapWAL reports a stored range beginning above the block a replay has to start from, which is what a -// WAL pruned past a store's head looks like. -type gapWAL struct { - statewal.StateWAL - first, last uint64 -} - -func (w *gapWAL) GetStoredRange() (bool, uint64, uint64, error) { return true, w.first, w.last, nil } - -// A WAL pruned past a half of state has dropped blocks that half still needs. Applying only the blocks -// the WAL happens to hold and then reporting the target as reached is silent divergence, so the replay -// refuses instead. -// -// Both halves have to reach this check, which is why they replay through one function rather than each -// walking the WAL: the half that goes around it is the half that diverges quietly. -func TestCatchUpRefusesAWALMissingTheBlocksAStoreNeeds(t *testing.T) { - const missingBlocks = "missing (data loss or corruption)" - - t.Run("the state commit store", func(t *testing.T) { - _, _, sc := newTestStateDB(t) - s := &StateDB{wal: &gapWAL{first: 3, last: 4}, sc: sc} - - require.ErrorContains(t, s.catchUpSC(4), missingBlocks) - }) - - t.Run("the EVM state store", func(t *testing.T) { - _, _, sc := newTestStateDB(t) - s := &StateDB{wal: &gapWAL{first: 3, last: 4}, sc: sc, ss: &evm.EVMStateStore{}} - - // The store holds nothing, so the gap is its whole history rather than a hole in it. Refusing - // here would report data loss for a store that is merely new, and would do it on every node - // past its first retention cut, so it is left empty to fill forward from the target. - require.NoError(t, s.catchUpSS(4)) - require.Zero(t, s.ss.GetLatestVersion()) - }) -} - -// A half left to fill forward is not held to the target afterwards. Holding it there would fail the -// rollback over exactly the state catchUpSS had just decided was the right outcome. -func TestMatchHeightExcusesAStoreLeftToFillForward(t *testing.T) { - _, _, sc := newTestStateDB(t) - for block := int64(1); block <= 4; block++ { - require.NoError(t, sc.CommitStateChanges(block, changeset("k", "v"))) - } - s := &StateDB{wal: &gapWAL{first: 3, last: 4}, sc: sc, ss: &evm.EVMStateStore{}} - - require.NoError(t, s.matchHeight(4)) -} - -// A store that holds nothing is only left empty when the WAL cannot rebuild it. One the WAL still -// reaches back far enough for comes out of recovery holding real history, which is strictly better, and -// is how SS is populated at all while the live commit path does not write it. -func TestCatchUpRebuildsAnEmptyStoreTheWALStillCovers(t *testing.T) { - _, _, sc := newTestStateDB(t) - s := &StateDB{wal: &gapWAL{first: 1, last: 4}, sc: sc, ss: &evm.EVMStateStore{}} - - fillForward, err := s.ssFillsForward() - require.NoError(t, err) - require.False(t, fillForward, "a WAL starting at block 1 can rebuild an empty store") + return &StateDB{wal: wal, sc: liveStateDB, flatkvCfg: cfg}, wal, liveStateDB } // changeset builds a changeset setting key to value in the test module. @@ -162,6 +103,33 @@ func TestCommitStateChangesReachesWALAndLiveStateDB(t *testing.T) { require.Equal(t, []byte("value"), value) } +// The EVM state store is a layer of the same fan-out: a commit that reaches WAL and SC but not SS +// leaves historical EVM reads a block behind with every block. +func TestCommitStateChangesReachesTheEVMStateStore(t *testing.T) { + stateDB, _, _ := newTestStateDB(t) + ss, err := evm.NewEVMStateStore(t.TempDir(), config.DefaultStateStoreConfig()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, ss.Close()) }) + stateDB.(*StateDB).ss = ss + + key := append([]byte{0x0a}, make([]byte, 20)...) + value := append(make([]byte, 7), byte(1)) + cs := []*proto.NamedChangeSet{{ + Name: evm.EVMStoreKey, + Changeset: proto.ChangeSet{ + Pairs: []*proto.KVPair{{Key: key, Value: value}}, + }, + }} + require.NoError(t, stateDB.CommitStateChanges(1, cs)) + // The commit hands SS its block asynchronously and does not wait, so the read does. + ss.WaitForPendingWrites() + + require.Equal(t, int64(1), ss.GetLatestVersion()) + got, err := ss.Get(evm.EVMStoreKey, 1, key) + require.NoError(t, err) + require.Equal(t, value, got) +} + // The WAL yields a block to readers only once it has been told the block is over, and discards an // un-ended one on Close. A commit that writes without ending leaves nothing anyone can read back. func TestCommitStateChangesEndsTheBlockInTheWAL(t *testing.T) { diff --git a/sei-db/state_db/giga/types/live_state_store.go b/sei-db/state_db/giga/types/live_state_store.go index ca605087fe..f768692ce7 100644 --- a/sei-db/state_db/giga/types/live_state_store.go +++ b/sei-db/state_db/giga/types/live_state_store.go @@ -168,14 +168,6 @@ type LiveStateStore interface { // anything is modified. Rollback(targetVersion int64) error - // RewindToSnapshotAtOrBelow rewinds this store to the highest snapshot at or below version and - // reports the version it landed on, discarding committed state and snapshots above that point. - // - // It is Rollback for a store constructed without a WAL: it moves only between snapshot boundaries, - // so it needs none, and replaying forward from the version it returns is the caller's to do. The - // store must be quiesced and stays open for writing at the returned version. - RewindToSnapshotAtOrBelow(version int64) (int64, error) - // Exporter creates an exporter for the given version (0 = current). Exporter(version int64) (sctypes.Exporter, error) diff --git a/sei-db/state_db/giga/types/state_db.go b/sei-db/state_db/giga/types/state_db.go index aa5b5932c2..ef4aa80cfb 100644 --- a/sei-db/state_db/giga/types/state_db.go +++ b/sei-db/state_db/giga/types/state_db.go @@ -40,13 +40,6 @@ type StateDB interface { // This may be useful at startup time to determine the initial hash of the database. RegisterHashListener(listener HashListener) (mostRecentHash lthash.BlockHash, err error) - // RollbackTo rewinds committed state to blockNum, discarding every block above it. The store must - // be quiesced: no commit, read or open view may be in flight. - // - // An implementation over a WAL prunes and reopens it, so any WAL reference the caller holds is - // closed by this call. For that reason it must not run once the prune cycle has taken the WAL. - RollbackTo(blockNum int64) error - // Close releases everything this StateDB was built over, reporting every failure rather than // stopping at the first. Close() error diff --git a/sei-db/state_db/sc/composite/store_test.go b/sei-db/state_db/sc/composite/store_test.go index 5956e8bba7..c8aefc2578 100644 --- a/sei-db/state_db/sc/composite/store_test.go +++ b/sei-db/state_db/sc/composite/store_test.go @@ -34,9 +34,6 @@ func (f *failingEVMStore) LoadLatest() error { return fmt.Errorf("flatkv unavail func (f *failingEVMStore) LoadVersionReadOnly(int64) (gigatypes.LiveStateStore, error) { return nil, fmt.Errorf("flatkv unavailable") } -func (f *failingEVMStore) RewindToSnapshotAtOrBelow(int64) (int64, error) { - return 0, fmt.Errorf("flatkv unavailable") -} func (f *failingEVMStore) ApplyChangeSets(int64, []*proto.NamedChangeSet) error { return nil } diff --git a/sei-db/state_db/sc/flatkv/snapshot.go b/sei-db/state_db/sc/flatkv/snapshot.go index da76a8a207..87ab415dcf 100644 --- a/sei-db/state_db/sc/flatkv/snapshot.go +++ b/sei-db/state_db/sc/flatkv/snapshot.go @@ -652,30 +652,13 @@ func (s *CommitStore) rollbackBaseVersion(dir string, targetVersion int64) (int6 return baseVersion, nil } -// Rollback restores state to targetVersion by rewinding to the highest -// snapshot <= targetVersion, replaying WAL to reach the target, and -// truncating all WAL entries and snapshots beyond that point. +// Rollback rewinds the store to targetVersion, discarding the committed state, the WAL blocks and the +// snapshots above it, and keeps committing from targetVersion+1. A target the snapshots and the WAL +// cannot reach is refused before anything is modified. // -// An unreachable target is rejected before anything is modified. -// -// Not safe to call concurrently with commits, reads or exports: it closes, -// prunes and reopens the store's WAL, reassigning s.wal, so the caller must -// have quiesced the store. This is how it is used today — recovery at -// LoadVersion time — and long term rollback becomes a construction-time -// concern rather than an action on a live store. -// -// Crash safety: the WAL is truncated BEFORE catchup writes any data to -// PebbleDB. If the process crashes after truncation but before catchup -// completes, the next restart will simply re-run catchup against the -// already-truncated WAL, converging to targetVersion. -// -// A failure while resetting the WAL leaves the store mid-rollback: "current" and the working directory are -// already at the rollback snapshot while the WAL still holds the blocks past targetVersion, and s.wal is -// closed. Retrying in-process does not work, because establishing reachability reads the WAL's stored range -// and that now fails as closed. No block is lost: the un-pruned WAL still holds them, so a restart replays -// back to the old tail and the rollback can be retried. The errors from that window say so. Snapshots above -// the target are already gone by then, which costs a cached checkpoint the next snapshot rebuilds, not -// history. +// The store must be quiesced: no commit, read or export may be in flight, and it closes and reopens its +// own WAL. A failure partway has to be retried after a restart rather than in process, though no block +// is lost. func (s *CommitStore) Rollback(targetVersion int64) (err error) { obs := s.observeOp("Rollback", otelMetrics.RollbackLatency, "targetVersion", targetVersion) @@ -759,6 +742,13 @@ func (s *CommitStore) repointAtSnapshot(dir string, version int64) error { if err := s.closeDBsOnly(); err != nil { return fmt.Errorf("close before rewinding to snapshot %d: %w", version, err) } + return repointAtSnapshot(dir, version) +} + +// repointAtSnapshot points the current link at the snapshot named by version and discards the working +// copy, so the next open clones the working copy from that snapshot. The databases under dir must be +// closed. +func repointAtSnapshot(dir string, version int64) error { if err := updateCurrentSymlink(dir, snapshotName(version)); err != nil { return fmt.Errorf("update current symlink to snapshot %d: %w", version, err) } @@ -770,62 +760,117 @@ func (s *CommitStore) repointAtSnapshot(dir string, version int64) error { return nil } -// SnapshotAtOrBelow returns the highest snapshot version at or below version, which is where a rewind -// to version lands. It reads only, so a caller can establish that a target is reachable before a -// rewind moves anything. -func (s *CommitStore) SnapshotAtOrBelow(version int64) (int64, error) { - return seekSnapshot(s.flatkvDir(), version) -} - -// RewindToSnapshotAtOrBelow rewinds this store to the highest snapshot at or below version and reports -// the version it landed on, discarding committed state and snapshots above that point. It needs no WAL: -// it moves only between snapshot boundaries, and replaying forward from the version it returns is the -// caller's to do. +// DiscardStateAbove puts the closed store under dir on its newest snapshot at or below target when it +// holds any state above target, and reports the version its files hold once it returns. A store holding +// nothing above target is left alone, reported at the version it opens on, for a replay to carry it +// forward. // -// It is Rollback for a store whose WAL an outer context owns, split so that no WAL crosses this API. The -// store must be quiesced, and it stays open for writing at the returned version. -func (s *CommitStore) RewindToSnapshotAtOrBelow(version int64) (landed int64, retErr error) { - obs := s.observeOp("RewindToSnapshotAtOrBelow", otelMetrics.RollbackLatency, "targetVersion", version) - defer obs.done(&retErr, func() { - otelMetrics.CurrentVersion.Record(s.ctx, s.committedVersion) - }) +// earliestReplayableBlock is the first block the caller can replay, or 0 when it can replay none. A +// store that would land too low for that replay to carry it back to target is refused, as is one above +// target with no snapshot at or below it. Neither refusal moves anything, so a caller that gets an +// error still has every snapshot it started with. The databases under dir must be closed. +func DiscardStateAbove(dir string, target, earliestReplayableBlock int64) (landsOn int64, err error) { + opensAt, highest, err := StoredVersions(dir) + if err != nil { + return 0, fmt.Errorf("read the versions it holds: %w", err) + } + // The highest version any one database records, not the version the store opens on: that one is the + // lowest of them, so an interrupted commit or restore reads as merely behind while the rows above + // target survive a replay that only writes forward. + rewinds := highest > target + landsOn = opensAt + if rewinds { + // Sought before the rewind rather than by it, so a store with nowhere to land is refused with its + // files still where they are. + if landsOn, err = seekSnapshot(dir, target); err != nil { + return 0, fmt.Errorf("seek snapshot at or below version %d: %w", target, err) + } + } + if err := requireReplayable(landsOn, target, earliestReplayableBlock); err != nil { + return 0, err + } + if !rewinds { + return landsOn, nil + } + return RewindClosedStoreTo(dir, target) +} - if s.readOnly { - return 0, errReadOnly +// requireReplayable returns an error when a store landing on landsOn cannot be carried back up to +// target, because the caller's earliest replayable block is above the first one such a replay needs. +// earliestReplayableBlock is 0 when the caller can replay nothing. +func requireReplayable(landsOn, target, earliestReplayableBlock int64) error { + if landsOn >= target { + return nil + } + start := landsOn + 1 + if earliestReplayableBlock == 0 { + return fmt.Errorf("it would land on version %d, so replay must start at block %d, but no blocks "+ + "are available to replay", landsOn, start) + } + if earliestReplayableBlock > start { + return fmt.Errorf("it would land on version %d, so replay must start at block %d, but the "+ + "earliest block available is %d", landsOn, start, earliestReplayableBlock) } - if version < 1 { + return nil +} + +// RewindClosedStoreTo puts the files of the closed store under dir on the highest snapshot at or below +// target and reports that version, discarding the working copy and every snapshot above it. The next +// open of that store lands on the reported version, with the blocks from there to target left for the +// caller to replay. +// +// It is Rollback for a store whose WAL an outer context owns and cuts, split so that no WAL crosses this +// API and so that the store opens once, already on the version it will replay from. The databases under +// dir must be closed, which is what makes it safe to run before the store is constructed. +func RewindClosedStoreTo(dir string, target int64) (landed int64, err error) { + if target < 1 { // Left to run, this would land on the initial snapshot and then delete every snapshot above it, // which is the whole set. return 0, fmt.Errorf("rewind target %d is invalid: version 0 means no state, so there is nothing "+ - "to rewind to", version) + "to rewind to", target) } - dir := s.flatkvDir() - baseVersion, err := seekSnapshot(dir, version) + baseVersion, err := seekSnapshot(dir, target) if err != nil { - return 0, fmt.Errorf("seek snapshot at or below version %d: %w", version, err) - } - if baseVersion == s.committedVersion { - return baseVersion, nil + return 0, fmt.Errorf("seek snapshot at or below version %d: %w", target, err) } - - if err := s.repointAtSnapshot(dir, baseVersion); err != nil { + if err := repointAtSnapshot(dir, baseVersion); err != nil { return 0, err } if err := removeSnapshotsAbove(dir, baseVersion); err != nil { return 0, err } - if err := s.open(); err != nil { - return 0, fmt.Errorf("open after rewinding to snapshot %d: %w", baseVersion, err) - } - if s.committedVersion != baseVersion { - return 0, fmt.Errorf("rewind to snapshot %d reached version %d instead", baseVersion, s.committedVersion) - } - logger.Info("FlatKV rewound to snapshot", "version", baseVersion, "elapsed", obs.elapsed()) + logger.Info("FlatKV rewound a closed store to a snapshot", "version", baseVersion, "target", target) return baseVersion, nil } +// DropSnapshotsAbove deletes every snapshot of the closed store under dir above target, repointing the +// current link first when it names one of them. It leaves the current link alone when it already names +// a snapshot at or below target. +// +// The databases under dir must be closed, which is what makes it safe to run before the store is +// constructed. +func DropSnapshotsAbove(dir string, target int64) error { + current, err := currentSnapshotVersion(dir) + if err != nil { + return err + } + if current > target { + // The link cannot be left naming a snapshot the removal below deletes, and a working copy built + // on one of them is above target too, so it goes with them. + base, err := seekSnapshot(dir, target) + if err != nil { + return fmt.Errorf("seek snapshot at or below version %d: %w", target, err) + } + if err := repointAtSnapshot(dir, base); err != nil { + return err + } + logger.Info("FlatKV repointed a closed store below a rollback target", "version", base, "target", target) + } + return removeSnapshotsAbove(dir, target) +} + // removeSnapshotsAbove deletes every snapshot directory above targetVersion. // // A failure here is returned rather than logged, which is why the step runs before the WAL is pruned: an @@ -852,30 +897,6 @@ func removeSnapshotsAbove(dir string, targetVersion int64) error { return errors.Join(errs...) } -// RemoveSnapshotsAbove deletes every snapshot above version, leaving the store's committed version and -// its databases untouched. It is idempotent, so it can be run to finish a rewind that was interrupted -// before its own cleanup did. -// -// It refuses while the current link names a snapshot above version, since removing that snapshot would -// leave the link dangling, and the next open resolves a dangling link to an empty working directory -// rather than to a failure. Rewind the store first: a store at or below version has a current link at -// or below it too. -func (s *CommitStore) RemoveSnapshotsAbove(version int64) error { - if s.readOnly { - return errReadOnly - } - dir := s.flatkvDir() - _, current, err := currentSnapshotDir(dir) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("read the current snapshot to remove snapshots above %d: %w", version, err) - } - if err == nil && current > version { - return fmt.Errorf("cannot remove snapshots above %d: the current snapshot is %d, and removing "+ - "it would leave the current link dangling", version, current) - } - return removeSnapshotsAbove(dir, version) -} - // tryTruncateWAL truncates WAL entries older than the earliest snapshot, keeping enough entries for // rollback to any retained snapshot. Skipped when there is no snapshot to truncate against. // diff --git a/sei-db/state_db/sc/flatkv/snapshot_test.go b/sei-db/state_db/sc/flatkv/snapshot_test.go index dab356c76a..7c41e0fdd2 100644 --- a/sei-db/state_db/sc/flatkv/snapshot_test.go +++ b/sei-db/state_db/sc/flatkv/snapshot_test.go @@ -680,16 +680,19 @@ func TestRollbackRejectsVersionZero(t *testing.T) { requireRollbackRejected(t, rollbackFixture(t), 0, "nothing to roll back to") } -// TestRewindToSnapshotAtOrBelowRejectsVersionZero verifies the snapshot-only rewind refuses version 0 as +// TestRewindClosedStoreToRejectsVersionZero verifies the closed-store rewind refuses version 0 as // Rollback does. A store keeps a snapshot at 0, so 0 is a version this would otherwise land on and then // delete every snapshot above — which is all of them. -func TestRewindToSnapshotAtOrBelowRejectsVersionZero(t *testing.T) { +func TestRewindClosedStoreToRejectsVersionZero(t *testing.T) { s := rollbackFixture(t) + before := snapshotVersionsOnDisk(t, s) + require.NoError(t, s.Close()) - _, err := s.RewindToSnapshotAtOrBelow(0) + _, err := RewindClosedStoreTo(s.flatkvDir(), 0) require.ErrorContains(t, err, "nothing to rewind to") - require.Equal(t, int64(5), s.Version(), "a refused rewind must leave the store where it was") + require.Equal(t, before, snapshotVersionsOnDisk(t, s), + "a refused rewind must leave the snapshots where they were") } // rollbackFixtureMidChainWALStart returns a store seeded to begin at block 10, so its snapshot sits at 9 and @@ -1198,8 +1201,8 @@ func interruptedRewindFixture(t *testing.T) *CommitStore { } require.Equal(t, []int64{3, 6}, snapshotVersionsOnDisk(t, s)) - // The first half of RewindToSnapshotAtOrBelow(5), then the reopen a restart performs. What the - // removal that would have followed never got to do is the point of the tests below. + // The first half of a rewind to 5, then the reopen a restart performs. What the removal that would + // have followed never got to do is the point of the tests below. require.NoError(t, s.repointAtSnapshot(s.flatkvDir(), 3)) require.NoError(t, s.open()) require.Equal(t, int64(3), s.Version(), "fixture precondition: the store reads as the base snapshot") @@ -1208,34 +1211,79 @@ func interruptedRewindFixture(t *testing.T) *CommitStore { return s } -// TestRemoveSnapshotsAboveFinishesAnInterruptedRewind covers the repair an unconditional removal buys. +// TestRewindClosedStoreToFinishesAnInterruptedRewind covers the repair rewinding unconditionally buys. // -// A store left mid-rewind reads as the base snapshot, which is at or below the target, so the rewind is -// skipped when it is retried and never removes the branch it abandoned. A later rollback would then seek -// a snapshot at or below its own target, land on one from that abandoned branch, and replay over it. -func TestRemoveSnapshotsAboveFinishesAnInterruptedRewind(t *testing.T) { +// A store left mid-rewind reads as the base snapshot, which is at or below the target, so a rewind that +// asked the store where it was would skip and never remove the branch it abandoned. A later rollback +// would then seek a snapshot at or below its own target, land on one from that abandoned branch, and +// replay over it. +func TestRewindClosedStoreToFinishesAnInterruptedRewind(t *testing.T) { s := interruptedRewindFixture(t) + require.NoError(t, s.Close()) - require.NoError(t, s.RemoveSnapshotsAbove(5)) + landed, err := RewindClosedStoreTo(s.flatkvDir(), 5) + require.NoError(t, err) + require.Equal(t, int64(3), landed) require.Equal(t, []int64{3}, snapshotVersionsOnDisk(t, s), "the discarded branch must not survive the rollback that abandoned it") - require.Equal(t, int64(3), s.Version(), "removing snapshots must not move the store") } -// TestRemoveSnapshotsAboveRefusesToDangleCurrent verifies the removal refuses to delete the snapshot the -// current link names. Deleting it leaves the link dangling, which createWorkingDir resolves to an empty -// working directory rather than to a failure, so the store would come up holding no state at all. -func TestRemoveSnapshotsAboveRefusesToDangleCurrent(t *testing.T) { +// DropSnapshotsAbove is the cleanup half of a rewind, and runs whether or not the store sits above the +// target: an interrupted rewind leaves a store that does not, reading as the base it was repointed at +// with the abandoned branch still on disk for a later rollback to land on. +func TestDropSnapshotsAboveFinishesAnInterruptedRewind(t *testing.T) { + s := interruptedRewindFixture(t) + dir := s.flatkvDir() + require.NoError(t, s.Close()) + + require.NoError(t, DropSnapshotsAbove(dir, 5)) + + require.Equal(t, []int64{3}, snapshotVersionsOnDisk(t, s), + "the discarded branch must not survive the rollback that abandoned it") + require.FileExists(t, filepath.Join(dir, workingDirName, snapshotBaseFile), + "a store at or below the target keeps the working copy it would open on") +} + +// A store above the target comes off it, since the current link cannot be left naming a snapshot the +// cleanup removes. +func TestDropSnapshotsAboveRepointsAStoreAboveTheTarget(t *testing.T) { + s := rollbackFixture(t) + dir := s.flatkvDir() + _, current, err := currentSnapshotDir(dir) + require.NoError(t, err) + require.Positive(t, current, "fixture precondition: current must name a snapshot above the target below") + require.NoError(t, s.Close()) + + require.NoError(t, DropSnapshotsAbove(dir, current-1)) + + _, after, err := currentSnapshotDir(dir) + require.NoError(t, err) + require.Less(t, after, current) + require.NotContains(t, snapshotVersionsOnDisk(t, s), current) +} + +// TestRewindClosedStoreToMovesCurrentOffTheDiscardedBranch verifies the rewind repoints current before it +// deletes anything. Deleting the snapshot current names leaves the link dangling, which createWorkingDir +// resolves to an empty working directory rather than to a failure, so the store would come up holding no +// state at all. +func TestRewindClosedStoreToMovesCurrentOffTheDiscardedBranch(t *testing.T) { s := rollbackFixture(t) - _, current, err := currentSnapshotDir(s.flatkvDir()) + dir := s.flatkvDir() + _, current, err := currentSnapshotDir(dir) require.NoError(t, err) require.Positive(t, current, "fixture precondition: current must name a snapshot above the target below") + require.NoError(t, s.Close()) - require.ErrorContains(t, s.RemoveSnapshotsAbove(current-1), "would leave the current link dangling") + landed, err := RewindClosedStoreTo(dir, current-1) - require.Contains(t, snapshotVersionsOnDisk(t, s), current, - "a refused removal must leave the snapshot in place") + require.NoError(t, err) + require.Less(t, landed, current) + _, after, err := currentSnapshotDir(dir) + require.NoError(t, err) + require.Equal(t, landed, after, "current must name the snapshot the rewind landed on") + require.NotContains(t, snapshotVersionsOnDisk(t, s), current, + "the snapshot above the target must be gone, and current must no longer name it") } func TestRemoveSnapshotsAboveKeepsTargetAndBelow(t *testing.T) { diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index 017ff210f3..a4c7fcc991 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -394,6 +394,43 @@ func (s *CommitStore) LoadLatest() (retErr error) { return nil } +// LoadWorkingCopy opens the database on the version its files already hold — the working copy, or the +// snapshot the current link names when there is no working copy to resume — and leaves this store open +// for writing without replaying any WAL block. +// +// It is LoadLatest for a store whose WAL an outer context owns, and it touches no WAL at all: replaying +// one forward from the version this opens on is that context's, as is RebuildIfUnreachable, the repair +// that has to precede it. +func (s *CommitStore) LoadWorkingCopy() (retErr error) { + obs := s.observeOp("LoadWorkingCopy", otelMetrics.OpenLatency). + withAttrs(attribute.Bool("read_only", false)) + defer obs.done(&retErr, func() { + otelMetrics.CurrentVersion.Record(s.ctx, s.committedVersion) + logger.Info("FlatKV LoadWorkingCopy complete", "version", s.committedVersion, "elapsed", obs.elapsed()) + }) + + if s.readOnly { + return errReadOnly + } + + _ = s.closeDBsOnly() + + // The lock is released on failure only when this call took it, since open does not track one it + // found already held. + lockHeldBefore := s.fileLock != nil + defer func() { + if retErr != nil && !lockHeldBefore && s.fileLock != nil { + _ = s.fileLock.Unlock() + s.fileLock = nil + } + }() + + if err := s.open(); err != nil { + return fmt.Errorf("open FlatKV store: %w", err) + } + return nil +} + // LoadVersionReadOnly returns an isolated read-only view of the database at targetVersion (0 = latest). // This store is left untouched and keeps committing; the caller owns the view and must Close it. // @@ -577,7 +614,42 @@ func (s *CommitStore) openTo(catchupTarget int64) error { } // rebuildIfAnyDataDBIsUnreachable discards the working copy when a data DB records a version that -// neither the snapshots nor the WAL can account for, leaving the store at a version replay can reach. +// neither the snapshots nor this store's own WAL can account for. +func (s *CommitStore) rebuildIfAnyDataDBIsUnreachable() error { + reachable, err := latestVersion(s.flatkvDir(), s.wal) + if err != nil { + return err + } + return s.rebuildIfAnyDataDBIsAbove(reachable) +} + +// RebuildIfUnreachable discards the working copy when a data DB records a version that neither this +// store's snapshots nor a WAL ending at walHead can account for. +// +// It is the repair inside LoadLatest, for a store whose WAL an outer context owns: that context hands +// the head over instead, so nothing here opens the WAL. It has to run before that context replays, +// which erases the evidence it works from. +func (s *CommitStore) RebuildIfUnreachable(walHead int64) error { + snapshotVersion, err := currentSnapshotVersion(s.flatkvDir()) + if err != nil { + return err + } + return s.rebuildIfAnyDataDBIsAbove(max(snapshotVersion, walHead)) +} + +// RebuildIfTorn discards the working copy when its data DBs disagree, one recording a block the others +// do not, which is what an interrupted commit leaves. +// +// It is the repair for a store whose WAL holds no blocks, where RebuildIfUnreachable would measure the +// working copy against the current snapshot and discard a copy that is legitimately ahead of it. The +// height the data DBs agree on is the yardstick here instead. The rebuild still comes back from the +// current snapshot, so the blocks between it and that height are replayed or re-executed afterwards. +func (s *CommitStore) RebuildIfTorn() error { + return s.rebuildIfAnyDataDBIsAbove(s.committedVersion) +} + +// rebuildIfAnyDataDBIsAbove rebuilds the working copy from the current snapshot when a data DB records +// a version above reachable, leaving the store at a version replay can reach. // // It must run before replay, because replay erases the evidence: applying an older block to a DB that // is past it rewrites that DB's version record downward to match the others while leaving the later @@ -586,12 +658,7 @@ func (s *CommitStore) openTo(catchupTarget int64) error { // The blocks discarded here were never servable. A block present in one data DB and absent from the // WAL is a block no consistent state includes, so rebuilding from the snapshot loses nothing that // could have been read back — which is why this repairs rather than refusing and taking the node down. -func (s *CommitStore) rebuildIfAnyDataDBIsUnreachable() error { - reachable, err := latestVersion(s.flatkvDir(), s.wal) - if err != nil { - return err - } - +func (s *CommitStore) rebuildIfAnyDataDBIsAbove(reachable int64) error { unreachable := make([]string, 0, len(dataDBDirs)) for _, dbDir := range dataDBDirs { if meta := s.localMeta[dbDir]; meta.CommittedVersion > reachable { 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 938aa71505..a6997dc3e2 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 @@ -87,6 +87,34 @@ func TestRebuildTriggersOnlyAboveTheReachableVersion(t *testing.T) { require.Equal(t, int64(3), s.Version(), "replay then carries it to the WAL tail") } +// TestRebuildIfTornTriggersOnDisagreementNotOnTheSnapshot pins the distinction RebuildIfTorn draws for +// a store whose WAL holds no blocks, where the reachable ceiling collapses to the current snapshot. +// Data DBs that agree are the only record of the blocks they hold and are kept however far above that +// snapshot they sit; one recording a block the others do not sends the working copy back to it. +func TestRebuildIfTornTriggersOnDisagreementNotOnTheSnapshot(t *testing.T) { + cfg := config.DefaultTestConfig(t) + cfg.DataDir = filepath.Join(t.TempDir(), flatkvRootDir) + + s, err := newCommitStoreWithWAL(t.Context(), cfg) + require.NoError(t, err) + defer s.Close() + require.NoError(t, s.LoadLatest()) + for i := int64(1); i <= 3; i++ { + require.NoError(t, s.CommitStateChanges(i, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte{byte(i)})})) + } + + snapVersion, err := currentSnapshotVersion(cfg.DataDir) + require.NoError(t, err) + require.Less(t, snapVersion, int64(3), "the working copy has to sit above the snapshot to be at risk") + + require.NoError(t, s.RebuildIfTorn()) + requireAllDataDBsAt(t, s, 3, "an aligned working copy is the only record of blocks the WAL lost") + + s.localMeta[accountDBDir].CommittedVersion = 4 + require.NoError(t, s.RebuildIfTorn()) + requireAllDataDBsAt(t, s, snapVersion, "a torn working copy comes back from the snapshot") +} + // TestUntouchedDataDBsOpenAtZero is the baseline of the classification: four DBs that have never had // metadata written agree at 0, so nothing is misaligned and no repair is attempted. func TestUntouchedDataDBsOpenAtZero(t *testing.T) { diff --git a/sei-db/state_db/sc/flatkv/store_meta.go b/sei-db/state_db/sc/flatkv/store_meta.go index 3170c4ff1b..a9d79843c2 100644 --- a/sei-db/state_db/sc/flatkv/store_meta.go +++ b/sei-db/state_db/sc/flatkv/store_meta.go @@ -2,9 +2,13 @@ package flatkv import ( "encoding/binary" + "errors" "fmt" "math" "os" + "path/filepath" + + "github.com/cockroachdb/pebble/v2" errorutils "github.com/sei-protocol/sei-chain/sei-db/common/errors" "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" @@ -435,6 +439,84 @@ func GetLatestVersion(dir string) (int64, error) { return latestVersion(dir, nil) } +// StoredVersions returns where the closed store under dir sits: the version LoadWorkingCopy would open +// it at, and the highest version any one of its data DBs records. Both ignore the WAL, and a directory +// that has never been opened reads as 0 for both. +// +// The two part only after an interrupted commit, where one data DB records a block the others do not. +// The store opens at the height they agree on, below that block, while the rows written for it sit in +// the working copy above, so a rollback to the height the store opens at still has state to discard. +func StoredVersions(dir string) (opensAt, highest int64, err error) { + snapshotVersion, err := currentSnapshotVersion(dir) + if err != nil { + return 0, 0, err + } + lowestDB, highestDB, err := dataDBVersions(dir) + if err != nil { + return 0, 0, err + } + return max(snapshotVersion, lowestDB), max(snapshotVersion, highestDB), nil +} + +// dataDBVersions returns the lowest and highest committed versions recorded in the working copy's data +// DBs, both 0 when there is no working copy. +func dataDBVersions(dir string) (lowest, highest int64, err error) { + workDir := filepath.Join(dir, workingDirName) + if _, err := os.Stat(workDir); err != nil { + if os.IsNotExist(err) { + return 0, 0, nil + } + return 0, 0, fmt.Errorf("stat the state commit working copy under %q: %w", workDir, err) + } + for i, dbDir := range dataDBDirs { + version, err := readCommittedVersion(filepath.Join(workDir, dbDir)) + if err != nil { + return 0, 0, err + } + if i == 0 { + lowest, highest = version, version + continue + } + lowest, highest = min(lowest, version), max(highest, version) + } + return lowest, highest, nil +} + +// readCommittedVersion returns the version record in the Pebble DB at dbDir, or 0 when that DB is +// missing or has never been written. +func readCommittedVersion(dbDir string) (int64, error) { + if _, err := os.Stat(dbDir); err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("stat %q: %w", dbDir, err) + } + db, err := pebble.Open(dbDir, &pebble.Options{ + ReadOnly: true, + FormatMajorVersion: pebble.FormatVirtualSSTables, + }) + if err != nil { + // The directory can exist while the database in it does not: createWorkingDir makes an empty one + // for every data DB the snapshot it clones from does not have, and only the store's own open + // creates the databases. A read-only open does not create, so it reports that as an error. + if errors.Is(err, pebble.ErrDBDoesNotExist) { + return 0, nil + } + return 0, fmt.Errorf("open %q to read its version: %w", dbDir, err) + } + defer func() { _ = db.Close() }() + + val, closer, err := db.Get(ktype.MetaVersionKey) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return 0, nil + } + return 0, fmt.Errorf("read the version record in %q: %w", dbDir, err) + } + defer func() { _ = closer.Close() }() + return decodeVersion(ktype.MetaVersionKey, val) +} + // latestVersion resolves the version a store on dir will open at, reading the WAL range through wal // when that is non-nil and out-of-band otherwise. func latestVersion(dir string, wal statewal.StateWAL) (int64, error) { diff --git a/sei-db/state_db/sc/flatkv/store_version_probe_test.go b/sei-db/state_db/sc/flatkv/store_version_probe_test.go index dadf21cdf6..8011da87d3 100644 --- a/sei-db/state_db/sc/flatkv/store_version_probe_test.go +++ b/sei-db/state_db/sc/flatkv/store_version_probe_test.go @@ -1,6 +1,7 @@ package flatkv import ( + "os" "path/filepath" "testing" @@ -8,6 +9,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/statewal" ) // GetLatestVersion answers, without opening the store, the version a store opened on that directory @@ -150,3 +152,67 @@ func TestCommitStoreGetLatestVersionUsesMemoryWhileOpen(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(1), got) } + +func TestStoredVersionsNeverOpenedDirIsZero(t *testing.T) { + dir := filepath.Join(t.TempDir(), flatkvRootDir) + opensAt, highest, err := StoredVersions(dir) + require.NoError(t, err) + require.Zero(t, opensAt) + require.Zero(t, highest) +} + +// A working copy cloned from a snapshot holding no data DBs of its own has the directories without the +// databases in them, since only the store's own open creates those. The probe opens read-only, which +// does not create, so it has to read that as version 0 the way it reads a directory that is not there +// at all: failing instead refuses the open, and the state survives a restart, so the node would never +// start again. +func TestStoredVersionsPartiallyCreatedWorkingCopyIsZero(t *testing.T) { + dir := filepath.Join(t.TempDir(), flatkvRootDir) + snapDir := filepath.Join(dir, snapshotPrefix+"0") + require.NoError(t, os.MkdirAll(snapDir, 0o750)) + require.NoError(t, createWorkingDir(snapDir, filepath.Join(dir, workingDirName))) + + opensAt, highest, err := StoredVersions(dir) + + require.NoError(t, err) + require.Zero(t, opensAt) + require.Zero(t, highest) +} + +// A working copy above the WAL tail is still the version LoadWorkingCopy opens at. GetLatestVersion +// follows the WAL, which is the wrong signal for whether a rewind of that working copy needs a snapshot. +func TestStoredVersionsIgnoreTheWALTail(t *testing.T) { + s, cfg := newProbeStore(t) + for i := int64(1); i <= 3; i++ { + require.NoError(t, s.CommitStateChanges(i, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte{byte(i)})})) + } + require.NoError(t, s.Close()) + require.NoError(t, statewal.PruneAfter(StateWALConfig(cfg.DataDir), 1)) + + opensAt, highest, err := StoredVersions(cfg.DataDir) + require.NoError(t, err) + require.Equal(t, int64(3), opensAt, "the working copy still holds block 3") + require.Equal(t, int64(3), highest) + + latest, err := GetLatestVersion(cfg.DataDir) + require.NoError(t, err) + require.Equal(t, int64(1), latest, "LoadLatest would land on the WAL tail") +} + +// A commit interrupted partway through its four data DBs is where the two versions have to differ: the +// store opens at the height every DB agrees on, while the blocks the DBs that did commit wrote are +// still in the working copy. Rolling back to that agreed height reads as nothing to do by the opening +// height and has to discard those rows, so the rewind measures itself against the highest instead. +func TestStoredVersionsSeeAnInterruptedCommit(t *testing.T) { + s, cfg := newProbeStore(t) + for i := int64(1); i <= 3; i++ { + require.NoError(t, s.CommitStateChanges(i, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte{byte(i)})})) + } + rewindVersionRecords(t, s, 2, accountDBDir) + require.NoError(t, s.Close()) + + opensAt, highest, err := StoredVersions(cfg.DataDir) + require.NoError(t, err) + require.Equal(t, int64(2), opensAt, "the height every data DB agrees on") + require.Equal(t, int64(3), highest, "the other data DBs still record block 3") +} diff --git a/sei-db/state_db/ss/evm/checkpoint.go b/sei-db/state_db/ss/evm/checkpoint.go index 649e377ddb..4d32270832 100644 --- a/sei-db/state_db/ss/evm/checkpoint.go +++ b/sei-db/state_db/ss/evm/checkpoint.go @@ -154,35 +154,3 @@ func (s *EVMStateStore) stopCheckpoints() { s.checkpoint.mu.Unlock() s.checkpoint.publishing.Wait() } - -// quiesceCheckpoints refuses further snapshots and waits for the accepted ones to finish publishing, -// returning the call that lets them resume. A publish in flight reads and stamps the databases, so it -// has to finish before anything closes or replaces them. -// -// It is stopCheckpoints for an operation the store outlives. A store already stopped stays stopped. -func (s *EVMStateStore) quiesceCheckpoints() (resume func()) { - s.checkpoint.mu.Lock() - stopped := s.checkpoint.stopped - s.checkpoint.stopped = true - s.checkpoint.mu.Unlock() - s.checkpoint.publishing.Wait() - - return func() { - s.checkpoint.mu.Lock() - defer s.checkpoint.mu.Unlock() - s.checkpoint.stopped = stopped - } -} - -// rewindLastOffered drops the newest version handed to the schedule to version, so a height at or -// below the one a rewind landed on can be offered again. -// -// Without it a rewind stands the store's snapshotting down until it climbs back past the height it -// rewound from, since a version at or below lastOffered is refused as a repeat offer. -func (s *EVMStateStore) rewindLastOffered(version int64) { - s.checkpoint.mu.Lock() - defer s.checkpoint.mu.Unlock() - if version < s.checkpoint.lastOffered { - s.checkpoint.lastOffered = version - } -} diff --git a/sei-db/state_db/ss/evm/recovery.go b/sei-db/state_db/ss/evm/recovery.go index 2ffc9821d3..e932ca7f70 100644 --- a/sei-db/state_db/ss/evm/recovery.go +++ b/sei-db/state_db/ss/evm/recovery.go @@ -6,6 +6,7 @@ import ( "path/filepath" "github.com/sei-protocol/sei-chain/sei-db/common/utils" + "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/proto" sssnapshot "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/snapshot" ) @@ -33,92 +34,248 @@ func (s *EVMStateStore) ApplyReplayedBlock(block int64, changesets []*proto.Name return s.SetLatestVersion(block) } -// SnapshotAtOrBelow returns the newest snapshot version at or below version, which is where a rewind -// to version lands. It reads only, so a caller can establish that a target is reachable before a -// rewind moves anything. -func (s *EVMStateStore) SnapshotAtOrBelow(version int64) (int64, error) { - return s.rollbackBaseVersion(version) +// DiscardStateAbove puts the closed store under dir on its newest snapshot at or below target when it +// holds any state above target, and reports the version its files hold once it returns. A store holding +// nothing above target is left alone, reported at the version it opens on, for a replay to carry it +// forward. +// +// earliestReplayableBlock is the first block the caller can replay, or 0 when it can replay none. A +// store that would land too low for that replay to carry it back to target is refused, and so is one +// above target with no way down at all. Neither refusal moves anything, so a caller that gets an error +// still has every snapshot it started with. The databases must be closed, and root is the store's +// snapshot directory. +// +// A store above target with no snapshot to land on has a second way down that SC does not: emptying it, +// which reconstructs it exactly when the caller can replay from block 1. A store already sitting at 0 +// that the caller cannot replay from block 1 is left there to fill forward, rather than refused for +// being merely new. +func DiscardStateAbove( + cfg config.StateStoreConfig, root string, target, earliestReplayableBlock int64, +) (landsOn int64, err error) { + opensAt, highest, err := StoredVersions(cfg) + if err != nil { + return 0, fmt.Errorf("read the versions it holds: %w", err) + } + rebuildsFromEmpty := earliestReplayableBlock == 1 + + // The highest version any one database records, not the version the store opens on: that one is the + // lowest of them, so an interrupted rewind reads as merely behind while the rows above target + // survive a replay that only writes forward. + if highest <= target { + if opensAt == 0 && !rebuildsFromEmpty { + return 0, nil + } + if err := requireReplayable(opensAt, target, earliestReplayableBlock); err != nil { + return 0, err + } + return opensAt, nil + } + + // Sought before either route runs, so a store with nowhere to go is refused with its files still + // where they are. + base, found, err := snapshotAtOrBelow(root, target) + if err != nil { + return 0, err + } + if !found && !rebuildsFromEmpty { + return 0, fmt.Errorf("it holds block %d and has no snapshot at or below %d, and no replay from "+ + "block 1 is available to rebuild it from", highest, target) + } + if !found { + base = 0 + } + if err := requireReplayable(base, target, earliestReplayableBlock); err != nil { + return 0, err + } + if !found { + if err := ResetClosedStore(cfg.EVMDBDirectory, root, cfg.SeparateEVMSubDBs); err != nil { + return 0, err + } + return 0, nil + } + return RewindClosedStoreTo(cfg.EVMDBDirectory, root, cfg.SeparateEVMSubDBs, target) +} + +// requireReplayable returns an error when a store landing on landsOn cannot be carried back up to +// target, because the caller's earliest replayable block is above the first one such a replay needs. +// earliestReplayableBlock is 0 when the caller can replay nothing. +func requireReplayable(landsOn, target, earliestReplayableBlock int64) error { + if landsOn >= target { + return nil + } + start := landsOn + 1 + if earliestReplayableBlock == 0 { + return fmt.Errorf("it would land on version %d, so replay must start at block %d, but no blocks "+ + "are available to replay", landsOn, start) + } + if earliestReplayableBlock > start { + return fmt.Errorf("it would land on version %d, so replay must start at block %d, but the "+ + "earliest block available is %d", landsOn, start, earliestReplayableBlock) + } + return nil } -// RewindToSnapshotAtOrBelow rewinds this store to the newest snapshot at or below version and reports -// the version it landed on, discarding snapshots above that point. It needs no WAL: it moves only -// between snapshot boundaries, and replaying forward from the version it returns is the caller's to do. +// StoredVersions reports where the closed store's databases sit: the version the store opens on, which +// is the lowest of them, and the highest version any one of them records. A directory that has never +// been written reads as 0. // -// The store must have a snapshot at or below version, which is the one way this differs from the state -// commit store's rewind: SS restores from its own snapshots and has no base to fall back on. -func (s *EVMStateStore) RewindToSnapshotAtOrBelow(version int64) (int64, error) { - if version <= 0 { - return 0, fmt.Errorf("invalid rollback target %d", version) +// The two differ only after an interrupted commit, restore or reset, which leaves some databases +// holding a block the rest do not. +func StoredVersions(cfg config.StateStoreConfig) (opensAt, highest int64, err error) { + if _, err := os.Stat(cfg.EVMDBDirectory); err != nil { + if os.IsNotExist(err) { + return 0, 0, nil + } + return 0, 0, err } - base, err := s.rollbackBaseVersion(version) + store, err := NewEVMStateStore(cfg.EVMDBDirectory, cfg) if err != nil { - return 0, err + return 0, 0, err } + opensAt, highest = store.GetLatestVersion(), store.HighestDBVersion() + if err := store.Close(); err != nil { + return 0, 0, fmt.Errorf("close it again: %w", err) + } + return opensAt, highest, nil +} - // A publish in flight reads and stamps the databases this is about to close and replace. - resume := s.quiesceCheckpoints() - defer resume() +// snapshotAtOrBelow returns the newest snapshot version under root at or below target, which is where +// RewindClosedStoreTo lands a store, and reports whether root has one. +// +// Version 0 is a snapshot like any other, which is why the answer is a version and a flag. +func snapshotAtOrBelow(root string, target int64) (version int64, found bool, err error) { + versions, err := sssnapshot.ListSnapshotVersions(root) + if err != nil { + return 0, false, fmt.Errorf("list the EVM state store snapshots under %q: %w", root, err) + } + for _, candidate := range versions { + if candidate <= target { + version, found = candidate, true + } + } + return version, found, nil +} + +// DropSnapshotsAbove deletes every snapshot under root above target and repoints the current link at +// the newest one left. It leaves the store's databases alone, so a store already at or below target +// keeps the history it holds. +func DropSnapshotsAbove(root string, target int64) error { + if _, err := sssnapshot.RewindTo(root, target); err != nil { + return fmt.Errorf("remove EVM state store snapshots above %d: %w", target, err) + } + return nil +} + +// RewindClosedStoreTo puts the files of the closed store under dir on the newest snapshot at or below +// target and reports that version, deleting the snapshots above it. The next open of that store lands +// on the reported version, with the blocks from there to target left for the caller to replay. +// +// It refuses a target that has no snapshot at or below it, and leaves the databases untouched. A +// caller whose store is already at or below the target must not call this. The databases under dir +// must be closed. root is the store's snapshot directory, and separateDBs its layout. +func RewindClosedStoreTo(dir, root string, separateDBs bool, target int64) (landed int64, err error) { + if target < 1 { + return 0, fmt.Errorf("rewind target %d is invalid: version 0 means no state, so there is nothing "+ + "to rewind to", target) + } - if err := s.closeDBs(); err != nil { - return 0, fmt.Errorf("close EVM state store before rewinding to snapshot %d: %w", base, err) + base, found, err := snapshotAtOrBelow(root, target) + if err != nil { + return 0, err + } + if !found { + return 0, fmt.Errorf("cannot rewind the EVM state store to %d: no snapshot at or below target", target) } + // Before the restore, not after: it is what decides which way an interrupted rewind points. The // databases still hold a version above the target until the restore lands, so a crash here leaves - // the next rewind to redo it. Restoring first would leave the databases at base and the discarded - // snapshots on disk, and a store already at the target is one the next rewind skips. - if err := s.snapshotMgr.RemoveSnapshotsAbove(version); err != nil { - return 0, fmt.Errorf("remove snapshots above %d: %w", version, err) - } - if err := s.restoreSnapshot(base); err != nil { - return 0, fmt.Errorf("restore snapshot %d: %w", base, err) + // the next rewind to redo it. Restoring first would leave the databases at base with the discarded + // snapshots on disk, and nothing afterwards to say the branch they belong to was abandoned. + if _, err := sssnapshot.RewindTo(root, target); err != nil { + return 0, fmt.Errorf("remove EVM state store snapshots above %d: %w", target, err) } - if err := s.openDBs(); err != nil { - return 0, fmt.Errorf("reopen EVM state store after rewinding to snapshot %d: %w", base, err) + if err := restoreSnapshot(dir, root, separateDBs, base); err != nil { + return 0, fmt.Errorf("restore EVM state store snapshot %d: %w", base, err) } - s.rewindLastOffered(base) + logger.Info("EVM state store rewound a closed store to a snapshot", "version", base, "target", target) return base, nil } -func (s *EVMStateStore) rollbackBaseVersion(target int64) (int64, error) { - if s.snapshotMgr == nil { - return 0, fmt.Errorf("no snapshot at or below the target") - } - versions, err := s.snapshotMgr.Versions() - if err != nil { - return 0, fmt.Errorf("list snapshots: %w", err) +// ResetClosedStore empties the closed store under dir and deletes every snapshot under root, so the +// next open creates a store at version 0 and a replay rebuilds it from block 1. root is the store's +// snapshot directory, and separateDBs its layout. +// +// It is the route onto a target for a store above it with no snapshot to land on, which RewindClosedStoreTo +// refuses. The caller owns the WAL and so is the one that knows a replay from block 1 is available. +func ResetClosedStore(dir, root string, separateDBs bool) error { + // Before the databases, for the reason RewindClosedStoreTo gives: they hold a version until they are + // removed, so a crash in between leaves the next open to redo the reset rather than to land on a + // snapshot from the branch this one abandoned. + if _, err := sssnapshot.RewindTo(root, 0); err != nil { + return fmt.Errorf("remove the EVM state store snapshots under %q: %w", root, err) } - var base int64 - for _, version := range versions { - if version <= target { - base = version + // Separate-DB mode empties them one at a time, so an interruption partway leaves the rest holding + // the branch being discarded while the head, the lowest of them, reads as 0. HighestDBVersion is + // what shows that, and is what a caller has to plan its next rewind from. + for _, dbDir := range storeDBDirs(dir, separateDBs) { + if err := removePebbleDir(dbDir); err != nil { + return err } } - if base == 0 { - return 0, fmt.Errorf("no snapshot at or below the target") + logger.Info("EVM state store emptied for a replay to rebuild it", "dir", dir) + return nil +} + +// storeDBDirs returns the pebble directories a store of this layout keeps under dir. +func storeDBDirs(dir string, separateDBs bool) []string { + if !separateDBs { + return []string{dir} + } + storeTypes := AllEVMStoreTypes() + dirs := make([]string, 0, len(storeTypes)) + for _, storeType := range storeTypes { + dirs = append(dirs, subDBPath(dir, storeType)) } - return base, nil + return dirs +} + +// removePebbleDir deletes dst along with anything an interrupted restore staged beside it. +// +// The leftovers go first: promoteInterruptedRestore moves one into an absent dst, so the other order +// leaves a window where a crash resurrects the store this is removing. +func removePebbleDir(dst string) error { + for _, path := range []string{dst + restoreTmpSuffix, dst + restoreBakSuffix, dst} { + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("remove %q while emptying the EVM state store: %w", path, err) + } + } + return nil } -// restoreSnapshot replaces this store's databases with the contents of the snapshot at version. +// restoreSnapshot replaces the databases under dir with the contents of the snapshot at version. // // A unified store is one directory, and the single window where an interruption leaves none is healed // on the next open. Separate-DB mode replaces each sub-DB in turn, and an interruption partway leaves -// them on different branches with no recovery: the head reads as the lowest of them, so the store looks -// merely behind, and replaying forward cannot delete the rows an untouched sub-DB holds above it. That -// mode is off by default. -func (s *EVMStateStore) restoreSnapshot(version int64) error { - src := filepath.Join(s.snapshotMgr.Root(), sssnapshot.SnapshotDirName(version)) - if s.separateDBs { - for _, storeType := range AllEVMStoreTypes() { - if err := replacePebbleDir(subDBPath(src, storeType), subDBPath(s.dir, storeType)); err != nil { - return err - } +// them on different branches: the head reads as the lowest of them, so the store looks merely behind, +// while a sub-DB the restore had not reached still holds rows above it that replaying forward cannot +// delete. HighestDBVersion is what shows those, and is what a caller has to plan its next rewind from. +func restoreSnapshot(dir, root string, separateDBs bool, version int64) error { + if version < 1 { + return fmt.Errorf("restore snapshot version %d is invalid: a rewind lands on a real snapshot", version) + } + src := filepath.Join(root, sssnapshot.SnapshotDirName(version)) + if !separateDBs { + return replacePebbleDir(src, dir) + } + for _, storeType := range AllEVMStoreTypes() { + if err := replacePebbleDir(subDBPath(src, storeType), subDBPath(dir, storeType)); err != nil { + return err } - return nil } - return replacePebbleDir(src, s.dir) + return nil } +// replacePebbleDir swaps the contents of src into dst through a staged copy. func replacePebbleDir(src, dst string) error { tmp := dst + restoreTmpSuffix bak := dst + restoreBakSuffix diff --git a/sei-db/state_db/ss/evm/recovery_test.go b/sei-db/state_db/ss/evm/recovery_test.go index 2e0c02d11f..a6f227df9a 100644 --- a/sei-db/state_db/ss/evm/recovery_test.go +++ b/sei-db/state_db/ss/evm/recovery_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + sssnapshot "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/snapshot" "github.com/stretchr/testify/require" ) @@ -88,3 +89,109 @@ func TestHealInterruptedRestore(t *testing.T) { requireNoLeftovers(t, dst) }) } + +// A rewind with nothing to land on must not clear the live databases. The store above the target still +// holds history; wiping it is not a rewind. +func TestRewindClosedStoreToRefusesWhenThereIsNoSnapshot(t *testing.T) { + dir := filepath.Join(t.TempDir(), "db") + writeMarkedDir(t, dir, "live") + + _, err := RewindClosedStoreTo(dir, t.TempDir(), false, 1) + + require.ErrorContains(t, err, "no snapshot at or below target") + require.Equal(t, "live", markerOf(t, dir), "a refused rewind must leave the live store in place") +} + +func TestResetClosedStore(t *testing.T) { + t.Run("empties a unified store and every snapshot", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "db") + root := t.TempDir() + writeMarkedDir(t, dir, "live") + writeSnapshots(t, root, 10, 20) + + require.NoError(t, ResetClosedStore(dir, root, false)) + + require.NoDirExists(t, dir, "the next open must create a store at version 0") + requireNoSnapshots(t, root) + }) + + t.Run("empties every sub-DB of a separate-DB store", func(t *testing.T) { + dir := t.TempDir() + for _, storeType := range AllEVMStoreTypes() { + writeMarkedDir(t, subDBPath(dir, storeType), "live") + } + + require.NoError(t, ResetClosedStore(dir, t.TempDir(), true)) + + for _, storeType := range AllEVMStoreTypes() { + require.NoDirExists(t, subDBPath(dir, storeType), + "a sub-DB left behind would read as state above a store the replay rebuilds from block 1") + } + }) + + // promoteInterruptedRestore moves a leftover into an absent directory, so one surviving the reset + // would have the next open resurrect the store this emptied. + t.Run("clears the copies an interrupted restore staged", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "db") + writeMarkedDir(t, dir, "live") + writeMarkedDir(t, dir+restoreTmpSuffix, "staged") + writeMarkedDir(t, dir+restoreBakSuffix, "displaced") + + require.NoError(t, ResetClosedStore(dir, t.TempDir(), false)) + + require.NoDirExists(t, dir) + requireNoLeftovers(t, dir) + require.NoError(t, healInterruptedRestore(dir)) + require.NoDirExists(t, dir, "the heal on the next open must have nothing to promote") + }) + + t.Run("leaves a store that has never been written empty", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "db") + + require.NoError(t, ResetClosedStore(dir, filepath.Join(t.TempDir(), "snapshots"), false)) + + require.NoDirExists(t, dir) + }) +} + +// A separate-DB store is emptied one sub-DB at a time, so an interruption partway leaves the rest still +// holding the branch the reset was discarding. The head is the lowest of them, so the recreated empty +// sub-DB has the store read as new, and a caller that trusted it would leave those rows in place for a +// replay that only ever writes forward. +func TestHighestDBVersionSeesAnInterruptedReset(t *testing.T) { + dir := t.TempDir() + cfg := testConfig() + cfg.SeparateEVMSubDBs = true + + store, err := NewEVMStateStore(dir, cfg) + require.NoError(t, err) + require.Greater(t, len(store.managedDBs), 1) + require.NoError(t, store.SetLatestVersion(5)) + require.NoError(t, store.Close()) + + require.NoError(t, removePebbleDir(subDBPath(dir, AllEVMStoreTypes()[0]))) + + reopened, err := NewEVMStateStore(dir, cfg) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + + require.Zero(t, reopened.GetLatestVersion(), "the sub-DB the reset removed reopens empty") + require.Equal(t, int64(5), reopened.HighestDBVersion(), + "the sub-DBs it had not reached still record the block the reset was discarding") +} + +func writeSnapshots(t *testing.T, root string, versions ...int64) { + t.Helper() + for _, version := range versions { + writeMarkedDir(t, filepath.Join(root, sssnapshot.SnapshotDirName(version)), "snapshot") + } +} + +// requireNoSnapshots asserts the reset left nothing for a later rewind to land on: every snapshot of a +// store emptied to be replayed from block 1 belongs to the branch that reset abandoned. +func requireNoSnapshots(t *testing.T, root string) { + t.Helper() + versions, err := sssnapshot.ListSnapshotVersions(root) + require.NoError(t, err) + require.Empty(t, versions) +} diff --git a/sei-db/state_db/ss/evm/store.go b/sei-db/state_db/ss/evm/store.go index 4eacf552ba..3557ba7b4b 100644 --- a/sei-db/state_db/ss/evm/store.go +++ b/sei-db/state_db/ss/evm/store.go @@ -209,6 +209,19 @@ func (s *EVMStateStore) GetLatestVersion() int64 { return minVersion } +// HighestDBVersion returns the highest version any one of the store's databases records. It exceeds +// GetLatestVersion only while the databases disagree, which is what an interrupted commit, restore or +// reset leaves behind: rows above the head that a replay forward cannot delete. +func (s *EVMStateStore) HighestDBVersion() int64 { + var highest int64 + for _, db := range s.managedDBs { + if v := db.GetLatestVersion(); v > highest { + highest = v + } + } + return highest +} + func (s *EVMStateStore) SetLatestVersion(version int64) error { for _, db := range s.managedDBs { if err := db.SetLatestVersion(version); err != nil { diff --git a/sei-db/state_db/ss/snapshot/manager.go b/sei-db/state_db/ss/snapshot/manager.go index a4c6745086..b11863a838 100644 --- a/sei-db/state_db/ss/snapshot/manager.go +++ b/sei-db/state_db/ss/snapshot/manager.go @@ -452,82 +452,57 @@ func (m *Manager) PruneSnapshots(cutLine int64) error { return nil } -// RemoveSnapshotsAbove deletes every snapshot above version and leaves the current link naming the -// newest snapshot at or below it. +// RewindTo puts the snapshots under root on version and reports the version the current link ends up +// naming: the newest snapshot at or below version, or 0 when root holds none, which is a store with +// nothing to restore from. Every snapshot above version is deleted. // -// A rollback discards the history those snapshots were taken from, so leaving them lets a later -// restore resolve through state that was already rejected, and leaves the retention arithmetic reading -// a newest version that no longer exists. -func (m *Manager) RemoveSnapshotsAbove(version int64) error { - if m == nil { - return nil - } - // Publication renames a directory in and swaps current under this lock, so a removal has to hold - // it too: otherwise a snapshot published a moment earlier survives the branch it belongs to. - m.publishMu.Lock() - defer m.publishMu.Unlock() - defer m.recordRetentionMetrics() - - versions, err := m.Versions() +// It works on the directory alone, for the rollback that runs before the store and its Manager open. A +// rollback discards the history the deleted snapshots were taken from, so leaving them would let a +// later restore resolve through state that was already rejected. +func RewindTo(root string, version int64) (landed int64, err error) { + versions, err := ListSnapshotVersions(root) if err != nil { - return err - } - // current moves off the branch before the branch goes, since removeSnapshots refuses to delete - // whatever current names. - if err := m.repointCurrentAtOrBelow(versions, version); err != nil { - return err + return 0, fmt.Errorf("list the snapshots under %q: %w", root, err) } - candidates := make([]int64, 0, len(versions)) + var base int64 for _, v := range versions { - if v > version { - candidates = append(candidates, v) + if v <= version { + base = v } } - if err := m.removeSnapshots(candidates); err != nil { - return err - } - return m.requireNoneAbove(version) -} -// requireNoneAbove reports a snapshot still above version once a removal has run. -// -// removeSnapshots holds back whatever the current link and the shared floor name, so a floor above -// version leaves one standing and the removal itself reports nothing. A caller told the branch is gone -// while it is still resolvable is the failure this catches. -func (m *Manager) requireNoneAbove(version int64) error { - versions, err := m.Versions() - if err != nil { - return err + // current moves off the branch before the branch goes: an open resolves a dangling link as an + // absent snapshot rather than as a failure, so the window in the other order is a silent one. + if err := repointCurrent(root, base); err != nil { + return 0, err } + var errs []error for _, v := range versions { - if v > version { - return fmt.Errorf("%s snapshot %d is still above %d after removing the snapshots above it", - m.name, v, version) + if v <= version { + continue + } + dir := filepath.Join(root, SnapshotDirName(v)) + if err := os.RemoveAll(dir); err != nil { + errs = append(errs, fmt.Errorf("remove snapshot %q above the rollback target %d: %w", + dir, version, err)) } } - return nil + if err := errors.Join(errs...); err != nil { + return 0, err + } + return base, nil } -// repointCurrentAtOrBelow moves the current link to the newest of versions at or below version, and -// does nothing when current already names one. versions must be ascending. -func (m *Manager) repointCurrentAtOrBelow(versions []int64, version int64) error { - current, hasCurrent, err := m.currentSnapshotVersion() - if err != nil { - return err - } - if !hasCurrent || current <= version { - return nil - } - var base int64 - for _, v := range versions { - if v <= version { - base = v - } - } +// repointCurrent points the current link under root at base, and removes the link when base is 0, +// which is the store that has no snapshot left to resolve to. +func repointCurrent(root string, base int64) error { if base == 0 { - return fmt.Errorf("no %s snapshot at or below %d for the current link", m.name, version) + if err := os.Remove(filepath.Join(root, snapshotCurrentLink)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove the current snapshot link under %q: %w", root, err) + } + return nil } - return m.updateCurrentLink(SnapshotDirName(base)) + return updateCurrentLink(root, SnapshotDirName(base)) } // removeSnapshots deletes each candidate except the current snapshot and the shared floor. Every @@ -583,15 +558,19 @@ func (m *Manager) removeStaleTmpDirs() { } func (m *Manager) updateCurrentLink(name string) error { - tmpLink := filepath.Join(m.root, snapshotCurrentTmpLink) + return updateCurrentLink(m.root, name) +} + +func updateCurrentLink(root, name string) error { + tmpLink := filepath.Join(root, snapshotCurrentTmpLink) _ = os.Remove(tmpLink) if err := os.Symlink(name, tmpLink); err != nil { return fmt.Errorf("create snapshot current symlink: %w", err) } - if err := os.Rename(tmpLink, filepath.Join(m.root, snapshotCurrentLink)); err != nil { + if err := os.Rename(tmpLink, filepath.Join(root, snapshotCurrentLink)); err != nil { return fmt.Errorf("swap snapshot current symlink: %w", err) } - return syncDir(m.root) + return syncDir(root) } func (m *Manager) prune() { diff --git a/sei-db/state_db/ss/snapshot/manager_test.go b/sei-db/state_db/ss/snapshot/manager_test.go index 436eaef92e..010e137d71 100644 --- a/sei-db/state_db/ss/snapshot/manager_test.go +++ b/sei-db/state_db/ss/snapshot/manager_test.go @@ -137,34 +137,51 @@ func TestManagerRetentionKeepsTheSharedFloor(t *testing.T) { require.Equal(t, []int64{30}, versions) } -func TestRemoveSnapshotsAboveDropsTheBranch(t *testing.T) { +// A rewind drops the branch above the target and leaves current naming the newest snapshot that +// survives, which is the one the store it belongs to then opens on. +func TestRewindToDropsTheBranch(t *testing.T) { root := t.TempDir() for _, version := range []int64{10, 20, 30} { require.NoError(t, os.MkdirAll(filepath.Join(root, SnapshotDirName(version)), 0o750)) } - manager := openManager(t, root, &controlledScheduler{pending: make(chan func(), 1)}, 10, false) + require.NoError(t, updateCurrentLink(root, SnapshotDirName(30))) - require.NoError(t, manager.RemoveSnapshotsAbove(20)) + landed, err := RewindTo(root, 20) - versions, err := manager.Versions() + require.NoError(t, err) + require.Equal(t, int64(20), landed) + versions, err := ListSnapshotVersions(root) require.NoError(t, err) require.Equal(t, []int64{10, 20}, versions) + require.Equal(t, SnapshotDirName(20), readCurrentLink(t, root)) } -// Removal holds back whatever the shared floor names, so a floor above the version leaves a snapshot -// standing on the branch a rollback discarded, where a later restore can still resolve through it. -// Answering yes to a removal that did not happen is what puts it there, so the outcome is checked. -func TestRemoveSnapshotsAboveReportsASurvivor(t *testing.T) { +// A target below every snapshot leaves nothing to restore from. The whole tree goes and current with +// it, so the store opens empty for a replay to rebuild, rather than resolving to a snapshot holding +// state the rollback rejected. +func TestRewindToBelowEverySnapshotClearsTheTree(t *testing.T) { root := t.TempDir() - for _, version := range []int64{10, 20, 30} { + for _, version := range []int64{10, 20} { require.NoError(t, os.MkdirAll(filepath.Join(root, SnapshotDirName(version)), 0o750)) } - scheduler := &controlledScheduler{pending: make(chan func(), 1)} - manager := openManagerWithFloor(t, root, scheduler, 10, false, NewFloor(30)) + require.NoError(t, updateCurrentLink(root, SnapshotDirName(20))) + + landed, err := RewindTo(root, 5) - err := manager.RemoveSnapshotsAbove(20) + require.NoError(t, err) + require.Zero(t, landed) + versions, err := ListSnapshotVersions(root) + require.NoError(t, err) + require.Empty(t, versions) + _, err = os.Readlink(filepath.Join(root, snapshotCurrentLink)) + require.True(t, os.IsNotExist(err), "current must not outlive the tree it named") +} - require.ErrorContains(t, err, "still above 20") +func readCurrentLink(t *testing.T, root string) string { + t.Helper() + target, err := os.Readlink(filepath.Join(root, snapshotCurrentLink)) + require.NoError(t, err) + return filepath.Base(target) } // A hardlink probe left by a crash is reclaimed rather than accumulating, in the source directory and in From e0488eab46900fd0c2e37cf0fc269c5fcce28402 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 9 Sep 2026 11:13:24 -0500 Subject: [PATCH 16/19] fix close deadlock issue --- .../sc/flatkv/finalization_manager.go | 19 +++++++++--- .../sc/flatkv/finalization_messages.go | 8 +++++ .../sc/flatkv/lthash/block_gatherer.go | 18 ++++++++++-- .../state_db/sc/flatkv/lthash/hash_engine.go | 29 +++++++++++-------- .../sc/flatkv/lthash/hash_engine_messages.go | 8 +++++ 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/sei-db/state_db/sc/flatkv/finalization_manager.go b/sei-db/state_db/sc/flatkv/finalization_manager.go index a667448123..169b692616 100644 --- a/sei-db/state_db/sc/flatkv/finalization_manager.go +++ b/sei-db/state_db/sc/flatkv/finalization_manager.go @@ -138,16 +138,23 @@ func (fm *FinalizationManager) Flush() error { return nil } -// Close stops the manager and waits for it to finish, reporting the latched error if it failed. +// Close stops the manager once it has finalized every block offered so far, and reports 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; the WAL -// still holds them for replay to recover. +// Cancelling the manager's context stops it the other way, abandoning the blocks it has not reached for +// the WAL to replay back. // // 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() + // The request travels the same queue as the blocks, which is what makes every block offered before + // this call finalize first. A manager already stopping refuses it, and there is nothing to drain in + // that case because the abandonment is already under way. + _ = fm.enqueue(newFinalizationCloseRequest()) + fm.wg.Wait() + fm.cancel() + if err := fm.errorIfBricked(); err != nil { return fmt.Errorf("close finalization manager: %w", err) } @@ -204,6 +211,10 @@ func (fm *FinalizationManager) handle(message any) bool { // ahead of this request has already been dispatched, on this goroutine, before it is reached. close(request.doneChan) return true + case *finalizationCloseRequest: + // Reached only once every block queued ahead of it has been finalized, so stopping here leaves + // nothing behind. + return false default: fm.brick(fmt.Errorf("unknown finalization message type %T", message)) return false diff --git a/sei-db/state_db/sc/flatkv/finalization_messages.go b/sei-db/state_db/sc/flatkv/finalization_messages.go index 7450566411..568de48093 100644 --- a/sei-db/state_db/sc/flatkv/finalization_messages.go +++ b/sei-db/state_db/sc/flatkv/finalization_messages.go @@ -39,3 +39,11 @@ type finalizationFlushRequest struct { func newFinalizationFlushRequest() *finalizationFlushRequest { return &finalizationFlushRequest{doneChan: make(chan struct{})} } + +// finalizationCloseRequest asks the manager to stop once it has dealt with everything queued ahead of +// it. It carries nothing: the caller learns the manager is through by waiting on the goroutine itself. +type finalizationCloseRequest struct{} + +func newFinalizationCloseRequest() *finalizationCloseRequest { + return &finalizationCloseRequest{} +} diff --git a/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go b/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go index f20d5cb2fa..534724e978 100644 --- a/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go +++ b/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go @@ -63,10 +63,19 @@ func newBlockGatherer( // the combiner. It stops the engine on the way out, whatever the reason: a schedule waits under the // engine's context, and this goroutine is the only thing that can release it. func (g *blockGatherer) run() { + // Set only on the path that stops because every scheduled block has been gathered. + drained := false + defer g.teardown() // Cancelled before the drain rather than after it, so that a schedule parked on a full queue is - // released by the cancellation instead of being woken by the drain, which nothing follows. - defer g.cancel() + // released by the cancellation instead of being woken by the drain, which nothing follows. A drained + // stop is the exception: the combiner is still publishing the blocks this loop handed it, and a + // cancelled context makes it give up on them, so Close cancels once it is through. + defer func() { + if !drained { + g.cancel() + } + }() for { select { @@ -76,6 +85,11 @@ func (g *blockGatherer) run() { g.gather(request) case *flushRequest: g.combineJobChan <- request + case *closeRequest: + // Reached only once every block queued ahead of it has been gathered, so there is + // nothing left behind to abandon. + drained = true + return default: g.brick(fmt.Errorf("unknown engine message type %T", message)) return 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 eeeb9e8878..f2cc2bd596 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_engine.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine.go @@ -44,7 +44,8 @@ type HashEngine struct { // longer reach. ctx context.Context - // cancel stops the gatherer and the combiner. Called by Close, and by the store's own context. + // cancel stops the gatherer and the combiner where they stand. Called by Close once both are + // through, and by the store's own context. cancel context.CancelFunc // fatalErr latches the first failure. Nil until something fails. @@ -53,7 +54,8 @@ type HashEngine struct { // Construct a new hash engine. func NewHashEngine( - // Cancelling this stops the engine, exactly as Close does. + // Cancelling this stops the engine, abandoning the blocks it has not reached. Close stops it the + // other way, hashing them first. 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 @@ -121,11 +123,6 @@ func (he *HashEngine) ScheduleHash( } // 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 } @@ -152,16 +149,24 @@ func (he *HashEngine) Flush() error { return nil } -// Close stops the engine and waits for it to finish, reporting the latched error if it failed. +// Close stops the engine once it has hashed every block scheduled so far, publishing each one, and +// reports 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. +// Cancelling the engine's context stops it the other way, abandoning whatever it had not reached. func (he *HashEngine) Close() error { - he.cancel() + // The request travels the same queue as the blocks, which is what makes every block scheduled before + // this call reach the combiner first. An engine already stopping refuses it, and there is nothing to + // drain in that case because the abandonment is already under way. + _ = he.enqueue(newCloseRequest()) + he.gatherer.wg.Wait() he.combiner.wg.Wait() + + // Cancelled only once both phases are through. The combiner gives up on a publish under a cancelled + // context, so cancelling any earlier would abandon the very blocks this call is draining. + he.cancel() + if err := he.errorIfBricked(); err != nil { return fmt.Errorf("close hash engine: %w", err) } 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 index e708db644c..b2b0d21a9e 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_engine_messages.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine_messages.go @@ -80,3 +80,11 @@ type flushRequest struct { func newFlushRequest() *flushRequest { return &flushRequest{doneChan: make(chan struct{})} } + +// closeRequest asks the engine to stop once it has dealt with everything queued ahead of it. It carries +// nothing: the caller learns the engine is through by waiting on the phases themselves. +type closeRequest struct{} + +func newCloseRequest() *closeRequest { + return &closeRequest{} +} From eb47e306527b5538ba982f3e2452bf52fe902de0 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 9 Sep 2026 11:42:32 -0500 Subject: [PATCH 17/19] replace old DB wrapper in benchmarks, clean things up --- sei-db/state_db/bench/README.md | 83 ---- .../bench/bench_sc_long_running_test.go | 76 --- sei-db/state_db/bench/bench_sc_test.go | 376 --------------- sei-db/state_db/bench/bench_ss_test.go | 64 --- .../bench/cryptosim/config/basic-config.json | 8 +- .../config/historical-offload-kafka.json | 25 - .../cryptosim/config/ss-composite-config.json | 6 - .../ss-composite-pebbledb-write-only.json | 49 -- .../ss-composite-rocksdb-write-only.json | 49 -- sei-db/state_db/bench/cryptosim/cryptosim.go | 41 +- .../bench/cryptosim/cryptosim_config.go | 28 +- .../bench/cryptosim/cryptosim_config_test.go | 7 +- .../bench/cryptosim/data_generator.go | 35 +- sei-db/state_db/bench/cryptosim/database.go | 106 ++-- .../cryptosim/historical_offload_test.go | 76 --- .../state_db/bench/cryptosim/transaction.go | 24 +- .../bench/cryptosim/transaction_test.go | 64 +-- sei-db/state_db/bench/helper.go | 455 ------------------ .../bench/wrappers/combined_wrapper.go | 78 --- .../bench/wrappers/composite_wrapper.go | 65 --- .../bench/wrappers/db_implementations.go | 228 --------- sei-db/state_db/bench/wrappers/db_wrapper.go | 46 -- .../state_db/bench/wrappers/flatkv_wrapper.go | 86 ---- .../bench/wrappers/flatkv_wrapper_test.go | 58 --- .../wrappers/historical_offload_wrapper.go | 187 ------- .../historical_offload_wrapper_test.go | 80 --- .../bench/wrappers/memiavl_wrapper.go | 71 --- .../state_db/bench/wrappers/noop_wrapper.go | 62 --- .../bench/wrappers/state_store_wrapper.go | 78 --- .../wrappers/state_store_wrapper_test.go | 80 --- .../state_db/bench/wrappers/wrappers_test.go | 230 --------- sei-db/state_db/bench/writeset.go | 280 ----------- sei-db/state_db/bench/writeset_bench_test.go | 102 ---- sei-db/state_db/bench/writeset_convert.go | 262 ---------- sei-db/state_db/bench/writeset_test.go | 287 ----------- 35 files changed, 115 insertions(+), 3737 deletions(-) delete mode 100644 sei-db/state_db/bench/README.md delete mode 100644 sei-db/state_db/bench/bench_sc_long_running_test.go delete mode 100644 sei-db/state_db/bench/bench_sc_test.go delete mode 100644 sei-db/state_db/bench/bench_ss_test.go delete mode 100644 sei-db/state_db/bench/cryptosim/config/historical-offload-kafka.json delete mode 100644 sei-db/state_db/bench/cryptosim/config/ss-composite-config.json delete mode 100644 sei-db/state_db/bench/cryptosim/config/ss-composite-pebbledb-write-only.json delete mode 100644 sei-db/state_db/bench/cryptosim/config/ss-composite-rocksdb-write-only.json delete mode 100644 sei-db/state_db/bench/cryptosim/historical_offload_test.go delete mode 100644 sei-db/state_db/bench/helper.go delete mode 100644 sei-db/state_db/bench/wrappers/combined_wrapper.go delete mode 100644 sei-db/state_db/bench/wrappers/composite_wrapper.go delete mode 100644 sei-db/state_db/bench/wrappers/db_implementations.go delete mode 100644 sei-db/state_db/bench/wrappers/db_wrapper.go delete mode 100644 sei-db/state_db/bench/wrappers/flatkv_wrapper.go delete mode 100644 sei-db/state_db/bench/wrappers/flatkv_wrapper_test.go delete mode 100644 sei-db/state_db/bench/wrappers/historical_offload_wrapper.go delete mode 100644 sei-db/state_db/bench/wrappers/historical_offload_wrapper_test.go delete mode 100644 sei-db/state_db/bench/wrappers/memiavl_wrapper.go delete mode 100644 sei-db/state_db/bench/wrappers/noop_wrapper.go delete mode 100644 sei-db/state_db/bench/wrappers/state_store_wrapper.go delete mode 100644 sei-db/state_db/bench/wrappers/state_store_wrapper_test.go delete mode 100644 sei-db/state_db/bench/wrappers/wrappers_test.go delete mode 100644 sei-db/state_db/bench/writeset.go delete mode 100644 sei-db/state_db/bench/writeset_bench_test.go delete mode 100644 sei-db/state_db/bench/writeset_convert.go delete mode 100644 sei-db/state_db/bench/writeset_test.go diff --git a/sei-db/state_db/bench/README.md b/sei-db/state_db/bench/README.md deleted file mode 100644 index c446ea4129..0000000000 --- a/sei-db/state_db/bench/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# Benchmarks - -This package contains benchmarks for the state DB commit store. - -## Run benchmarks - -From the repo root: - -- Standard benchmarks: - - `go test ./sei-db/state_db/bench -run ^$ -bench . -benchmem` -- Run a single benchmark: - - `go test ./sei-db/state_db/bench -run ^$ -bench BenchmarkMemIAVLWriteWithDifferentBlockSize -benchmem` - -## Long running benchmark - -The long running benchmark is behind the `slow_bench` build tag and is intended -to run for a long time while you watch the periodic progress report. - -- Run it with a long benchtime (interrupt when done): - - `go test ./sei-db/state_db/bench -run ^$ -bench BenchmarkLongRunningWrite -benchmem -benchtime=24h -tags=slow_bench` - -Progress is printed to stdout every few seconds while the benchmark is running. - -### With snapshot pre-population - -`BenchmarkMemIAVLLongRunningWriteWithInitialState` loads a Cosmos SDK state sync -snapshot into the database before starting the timed benchmark. This lets you -measure write throughput on a realistically sized tree instead of an empty one. - -Set the `SNAPSHOT_PATH` environment variable to the directory that contains the -numbered chunk files (`0`, `1`, `2`, …). The typical on-disk layout is -`/data/snapshots///`. - -```bash -SNAPSHOT_PATH=/data/snapshots/12345678/1/ \ - go test ./sei-db/state_db/bench -run ^$ \ - -bench BenchmarkMemIAVLLongRunningWriteWithInitialState \ - -benchmem -benchtime=24h -tags=slow_bench -``` - -If `SNAPSHOT_PATH` is not set the benchmark is skipped automatically. - -## Define new scenarios - -Benchmarks are configured via `TestScenario`: - -- `Name`: scenario name used for the sub-benchmark -- `TotalKeys`: total number of keys to write across all blocks -- `NumBlocks`: number of blocks to commit -- `DuplicateRatio`: fraction of keys that are updates instead of inserts -- `Backend`: database backend (`wrappers.MemIAVL`, `wrappers.FlatKV`, - `wrappers.CompositeCosmos`, `wrappers.CompositeSplit`, `wrappers.CompositeDual`) -- `Distribution`: per-block key distribution function -- `SnapshotPath`: (optional) path to a state sync snapshot chunks directory; - when set, the snapshot is imported via the native `Committer.Importer` path - before the benchmark begins - -Example: - -```go -scenario := TestScenario{ - Name: "bursty_updates", - TotalKeys: 100_000, - NumBlocks: 10_000, - DuplicateRatio: 0.25, - Distribution: BurstyDistribution(1, 10, 5, 3), - Backend: wrappers.MemIAVL, -} -``` - -## Add a new distribution - -Define a new `KeyDistribution` in `helper.go`: - -```go -func MyDistribution(numBlocks, totalKeys, block int64) int64 { - // return the number of keys for this block - return totalKeys / numBlocks -} -``` - -Then set it on a `TestScenario` in `bench_sc_test.go` or -`bench_sc_long_running_test.go`. diff --git a/sei-db/state_db/bench/bench_sc_long_running_test.go b/sei-db/state_db/bench/bench_sc_long_running_test.go deleted file mode 100644 index d257f0be7a..0000000000 --- a/sei-db/state_db/bench/bench_sc_long_running_test.go +++ /dev/null @@ -1,76 +0,0 @@ -//go:build slow_bench - -package bench - -import ( - "os" - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" -) - -func BenchmarkMemIAVLLongRunningWrite(b *testing.B) { - scenario := TestScenario{ - Name: "long_running_write", - NumBlocks: 1_000_000_000, - TotalKeys: 1_000_000_000_000, - DuplicateRatio: 0.5, - Backend: wrappers.MemIAVL, - } - - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, true) - }) -} - -func BenchmarkFlatKVLongRunningWrite(b *testing.B) { - scenario := TestScenario{ - Name: "long_running_write", - NumBlocks: 1_000_000_000, - TotalKeys: 1_000_000_000_000, - DuplicateRatio: 0.5, - Backend: wrappers.FlatKV, - } - - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, true) - }) -} - -func BenchmarkMemIAVLLongRunningWriteWithInitialState(b *testing.B) { - snapshotPath := os.Getenv("SNAPSHOT_PATH") - if snapshotPath == "" { - b.Skip("skipping: SNAPSHOT_PATH env var not set") - } - scenario := TestScenario{ - SnapshotPath: snapshotPath, - Name: "long_running_write_with_some_initial_state", - NumBlocks: 1_000_000_000, - TotalKeys: 1_000_000_000_000, - DuplicateRatio: 0.5, - Backend: wrappers.MemIAVL, - } - - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, true) - }) -} - -func BenchmarkFlatKVLongRunningWriteWithInitialState(b *testing.B) { - snapshotPath := os.Getenv("SNAPSHOT_PATH") - if snapshotPath == "" { - b.Skip("skipping: SNAPSHOT_PATH env var not set") - } - scenario := TestScenario{ - SnapshotPath: snapshotPath, - Name: "long_running_write_with_some_initial_state", - NumBlocks: 1_000_000_000, - TotalKeys: 1_000_000_000_000, - DuplicateRatio: 0.5, - Backend: wrappers.FlatKV, - } - - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, true) - }) -} diff --git a/sei-db/state_db/bench/bench_sc_test.go b/sei-db/state_db/bench/bench_sc_test.go deleted file mode 100644 index 8e7ac9d937..0000000000 --- a/sei-db/state_db/bench/bench_sc_test.go +++ /dev/null @@ -1,376 +0,0 @@ -package bench - -import ( - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" -) - -// Using MemIAVL, Tests throughput with fixed total keys, -// varying keysPerBlock and numBlocks to find optimal block size. -func BenchmarkMemIAVLWriteWithDifferentBlockSize(b *testing.B) { - const totalKeys int64 = 100_000 - - scenarios := []TestScenario{ - { - Name: "1_key_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys, // 1 key per block - Backend: wrappers.MemIAVL, - }, - { - Name: "2_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 2, // 2 keys per block - Backend: wrappers.MemIAVL, - }, - { - Name: "10_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 10, // 10 keys per block - Backend: wrappers.MemIAVL, - }, - { - Name: "20_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 20, // 20 keys per block - Backend: wrappers.MemIAVL, - }, - { - Name: "100_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 100, // 100 keys per block - Backend: wrappers.MemIAVL, - }, - { - Name: "200_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 200, // 200 keys per block - Backend: wrappers.MemIAVL, - }, - } - - for _, scenario := range scenarios { - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, false) - }) - } -} - -// Using FlatKV, Tests throughput with fixed total keys, -// varying keysPerBlock and numBlocks to find optimal block size. -func BenchmarkFlatKVWriteWithDifferentBlockSize(b *testing.B) { - // Note: FlatKV is currently behaving more slowly than expected, and so - // the total number of keys/blocks is reduced by a factor of 1000 compared to the equivalent MemIAVL benchmarks. - const totalKeys int64 = 100_000 - - scenarios := []TestScenario{ - { - Name: "100_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 100, - Backend: wrappers.FlatKV, - }, - { - Name: "200_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 200, - Backend: wrappers.FlatKV, - }, - { - Name: "1000_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 1000, - Backend: wrappers.FlatKV, - }, - { - Name: "2000_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 2000, - Backend: wrappers.FlatKV, - }, - } - - for _, scenario := range scenarios { - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, false) - }) - } -} - -// Using composite backends, tests throughput with fixed total keys, -// varying keysPerBlock and numBlocks. Uses the same reduced scale as FlatKV. -func BenchmarkCompositeWriteWithDifferentBlockSize(b *testing.B) { - // Note: FlatKV is currently behaving more slowly than expected, and so - // the total number of keys/blocks is reduced by a factor of 1000 compared to the equivalent MemIAVL benchmarks. - const totalKeys int64 = 100_000 - - scenarios := []TestScenario{ - { - Name: "cosmos/100_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 100, - Backend: wrappers.CompositeCosmos, - }, - { - Name: "split/100_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 100, - Backend: wrappers.CompositeSplit, - }, - { - Name: "dual/100_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 100, - Backend: wrappers.CompositeDual, - }, - { - Name: "cosmos/200_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 200, - Backend: wrappers.CompositeCosmos, - }, - { - Name: "split/200_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 200, - Backend: wrappers.CompositeSplit, - }, - { - Name: "dual/200_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 200, - Backend: wrappers.CompositeDual, - }, - { - Name: "cosmos/1000_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 1000, - Backend: wrappers.CompositeCosmos, - }, - { - Name: "split/1000_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 1000, - Backend: wrappers.CompositeSplit, - }, - { - Name: "dual/1000_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 1000, - Backend: wrappers.CompositeDual, - }, - { - Name: "cosmos/2000_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 2000, - Backend: wrappers.CompositeCosmos, - }, - { - Name: "split/2000_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 2000, - Backend: wrappers.CompositeSplit, - }, - { - Name: "dual/2000_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 2000, - Backend: wrappers.CompositeDual, - }, - } - - for _, scenario := range scenarios { - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, false) - }) - } -} - -// Compares throughput across key distributions with The MemIAVL backend. -func BenchmarkMemIAVLWriteWithDifferentKeyDistributions(b *testing.B) { - const ( - totalKeys int64 = 1_000_000 - numBlocks int64 = 10_000 - ) - - scenarios := []TestScenario{ - { - Name: "even_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.MemIAVL, - }, - { - Name: "bursty_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.MemIAVL, - }, - { - Name: "normal_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.MemIAVL, - }, - { - Name: "ramp_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.MemIAVL, - }, - } - - for _, scenario := range scenarios { - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, false) - }) - } -} - -// Compares throughput across key distributions with The FlatKV backend. -func BenchmarkFlatKVWriteWithDifferentKeyDistributions(b *testing.B) { - // Note: FlatKV is currently behaving more slowly than expected, and so - // the total number of keys/blocks is reduced by a factor of 10 compared to the equivalent MemIAVL benchmarks. - const ( - totalKeys int64 = 100_000 - numBlocks int64 = 1_000 - ) - - scenarios := []TestScenario{ - { - Name: "even_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.FlatKV, - }, - { - Name: "bursty_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.FlatKV, - }, - { - Name: "normal_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.FlatKV, - }, - { - Name: "ramp_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.FlatKV, - }, - } - - for _, scenario := range scenarios { - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, false) - }) - } -} - -// Compares throughput across key distributions with the Composite backend. -func BenchmarkCompositeWriteWithDifferentKeyDistributions(b *testing.B) { - // Note: FlatKV is currently behaving more slowly than expected, and so - // the total number of keys/blocks is reduced by a factor of 10 compared to the equivalent MemIAVL benchmarks. - const ( - totalKeys int64 = 100_000 - numBlocks int64 = 1_000 - ) - - scenarios := []TestScenario{ - // Even distribution - { - Name: "cosmos/even_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeCosmos, - }, - { - Name: "split/even_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeSplit, - }, - { - Name: "dual/even_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeDual, - }, - // Bursty distribution - { - Name: "cosmos/bursty_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeCosmos, - Distribution: BurstyDistribution(1, 10, 5, 3), - }, - { - Name: "split/bursty_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeSplit, - Distribution: BurstyDistribution(1, 10, 5, 3), - }, - { - Name: "dual/bursty_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeDual, - Distribution: BurstyDistribution(1, 10, 5, 3), - }, - // Normal distribution - { - Name: "cosmos/normal_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeCosmos, - Distribution: NormalDistribution(1, 0.2), - }, - { - Name: "split/normal_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeSplit, - Distribution: NormalDistribution(1, 0.2), - }, - { - Name: "dual/normal_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeDual, - Distribution: NormalDistribution(1, 0.2), - }, - // Ramp distribution - { - Name: "cosmos/ramp_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeCosmos, - Distribution: RampDistribution(0.5, 1.5), - }, - { - Name: "split/ramp_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeSplit, - Distribution: RampDistribution(0.5, 1.5), - }, - { - Name: "dual/ramp_distribution", - TotalKeys: totalKeys, - NumBlocks: numBlocks, - Backend: wrappers.CompositeDual, - Distribution: RampDistribution(0.5, 1.5), - }, - } - - for _, scenario := range scenarios { - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, false) - }) - } -} diff --git a/sei-db/state_db/bench/bench_ss_test.go b/sei-db/state_db/bench/bench_ss_test.go deleted file mode 100644 index 434842ca79..0000000000 --- a/sei-db/state_db/bench/bench_ss_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package bench - -import ( - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" -) - -func BenchmarkSSCompositeWrite(b *testing.B) { - const totalKeys int64 = 10_000 - - scenarios := []TestScenario{ - { - Name: "100_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 100, - Backend: wrappers.SSComposite, - }, - } - - for _, scenario := range scenarios { - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, false) - }) - } -} - -func BenchmarkSSHistoricalOffloadWrite(b *testing.B) { - const totalKeys int64 = 10_000 - - scenarios := []TestScenario{ - { - Name: "100_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 100, - Backend: wrappers.SSHistoricalOffload, - }, - } - - for _, scenario := range scenarios { - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, false) - }) - } -} - -func BenchmarkCombinedCompositeDualSSCompositeWrite(b *testing.B) { - const totalKeys int64 = 10_000 - - scenarios := []TestScenario{ - { - Name: "100_keys_per_block", - TotalKeys: totalKeys, - NumBlocks: totalKeys / 100, - Backend: wrappers.CompositeDual_SSComposite, - }, - } - - for _, scenario := range scenarios { - b.Run(scenario.Name, func(b *testing.B) { - runBenchmark(b, scenario, false) - }) - } -} diff --git a/sei-db/state_db/bench/cryptosim/config/basic-config.json b/sei-db/state_db/bench/cryptosim/config/basic-config.json index c97250a157..915adf8c95 100644 --- a/sei-db/state_db/bench/cryptosim/config/basic-config.json +++ b/sei-db/state_db/bench/cryptosim/config/basic-config.json @@ -1,6 +1,5 @@ { "Comment": "Basic configuration for the cryptosim benchmark. Intended for basic correctness/sanity testing.", - "Backend": "FlatKV", "StateStoreConfig": { "Enable": true, "DBDirectory": "", @@ -10,8 +9,11 @@ "PruneIntervalSeconds": 600, "ImportNumWorkers": 1, "KeepLastVersion": true, - "UseDefaultComparer": false, - "EVMDBDirectory": "" + "UseDefaultComparer": false + }, + "CheckpointConfig": { + "TimeInterval": 600000000000, + "BlockInterval": 0 }, "CannedRandomSize": 1073741824, "ConstantThreadCount": 0, diff --git a/sei-db/state_db/bench/cryptosim/config/historical-offload-kafka.json b/sei-db/state_db/bench/cryptosim/config/historical-offload-kafka.json deleted file mode 100644 index 16e9d44f32..0000000000 --- a/sei-db/state_db/bench/cryptosim/config/historical-offload-kafka.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "Comment": "Sample cryptosim config for offchain pipeline testing. Replace HistoricalOffload.Kafka.Brokers, Topic, and Region with your environment values.", - "Backend": "SSHistoricalOffload", - "HistoricalOffload": { - "Provider": "kafka", - "Kafka": { - "Brokers": [ - "b-1.example.kafka.amazonaws.com:9098", - "b-2.example.kafka.amazonaws.com:9098" - ], - "Topic": "historical-offload", - "Region": "us-east-1", - "TLSEnabled": true, - "SASLMechanism": "aws-msk-iam" - } - }, - "ConsoleUpdateIntervalSeconds": 5, - "DataDir": "data/historical-offload-kafka", - "EnableSuspension": false, - "MetricsAddr": "", - "MaxRuntimeSeconds": 1200, - "DisableTransactionReads": true, - "LogDir": "logs/historical-offload-kafka", - "MaxTPS": 150000 -} diff --git a/sei-db/state_db/bench/cryptosim/config/ss-composite-config.json b/sei-db/state_db/bench/cryptosim/config/ss-composite-config.json deleted file mode 100644 index d0332599ea..0000000000 --- a/sei-db/state_db/bench/cryptosim/config/ss-composite-config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "Comment": "State store benchmark using composite SS with EVM sub-stores (split_write + EVM-first read).", - "Backend": "SSComposite", - "DataDir": "data", - "LogDir": "logs" -} diff --git a/sei-db/state_db/bench/cryptosim/config/ss-composite-pebbledb-write-only.json b/sei-db/state_db/bench/cryptosim/config/ss-composite-pebbledb-write-only.json deleted file mode 100644 index c353124fab..0000000000 --- a/sei-db/state_db/bench/cryptosim/config/ss-composite-pebbledb-write-only.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "Comment": "Write-only SSComposite benchmark using PebbleDB.", - "Backend": "SSComposite", - "StateStoreConfig": { - "Enable": true, - "DBDirectory": "", - "Backend": "pebbledb", - "AsyncWriteBuffer": 100, - "KeepRecent": 100000, - "PruneIntervalSeconds": 600, - "ImportNumWorkers": 1, - "KeepLastVersion": true, - "UseDefaultComparer": false, - "EVMDBDirectory": "" - }, - "CannedRandomSize": 1073741824, - "ConstantThreadCount": 0, - "ConsoleUpdateIntervalSeconds": 5, - "ConsoleUpdateIntervalTransactions": 1000000, - "DataDir": "data/pebble20", - "EnableSuspension": false, - "Erc20ContractSize": 2048, - "Erc20InteractionsPerAccount": 10, - "Erc20StorageSlotSize": 32, - "ExecutorQueueSize": 1024, - "HotAccountProbability": 0.1, - "HotErc20ContractProbability": 0.5, - "HotErc20ContractSetSize": 100, - "MetricsAddr": "", - "MinimumNumberOfColdAccounts": 1000000, - "MinimumNumberOfDormantAccounts": 1000000, - "MinimumNumberOfErc20Contracts": 10000, - "NewAccountDormancyProbability": 1.0, - "NewAccountProbability": 0.001, - "NumberOfHotAccounts": 100, - "PaddedAccountSize": 32, - "Seed": 1337, - "SetupUpdateIntervalCount": 100000, - "ThreadsPerCore": 2.0, - "TransactionsPerBlock": 1024, - "MaxRuntimeSeconds": 1200, - "TransactionMetricsSampleRate": 0.001, - "BackgroundMetricsScrapeInterval": 60, - "BlockChannelCapacity": 8, - "DisableTransactionReads": true, - "LogDir": "logs/pebble20", - "LogLevel": "info", - "MaxTPS": 150000 -} diff --git a/sei-db/state_db/bench/cryptosim/config/ss-composite-rocksdb-write-only.json b/sei-db/state_db/bench/cryptosim/config/ss-composite-rocksdb-write-only.json deleted file mode 100644 index 9339f56958..0000000000 --- a/sei-db/state_db/bench/cryptosim/config/ss-composite-rocksdb-write-only.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "Comment": "Write-only SSComposite benchmark using RocksDB.", - "Backend": "SSComposite", - "StateStoreConfig": { - "Enable": true, - "DBDirectory": "", - "Backend": "rocksdb", - "AsyncWriteBuffer": 100, - "KeepRecent": 100000, - "PruneIntervalSeconds": 600, - "ImportNumWorkers": 1, - "KeepLastVersion": true, - "UseDefaultComparer": false, - "EVMDBDirectory": "" - }, - "CannedRandomSize": 1073741824, - "ConstantThreadCount": 0, - "ConsoleUpdateIntervalSeconds": 5, - "ConsoleUpdateIntervalTransactions": 1000000, - "DataDir": "data/rocks20", - "EnableSuspension": false, - "Erc20ContractSize": 2048, - "Erc20InteractionsPerAccount": 10, - "Erc20StorageSlotSize": 32, - "ExecutorQueueSize": 1024, - "HotAccountProbability": 0.1, - "HotErc20ContractProbability": 0.5, - "HotErc20ContractSetSize": 100, - "MetricsAddr": "", - "MinimumNumberOfColdAccounts": 1000000, - "MinimumNumberOfDormantAccounts": 1000000, - "MinimumNumberOfErc20Contracts": 10000, - "NewAccountDormancyProbability": 1.0, - "NewAccountProbability": 0.001, - "NumberOfHotAccounts": 100, - "PaddedAccountSize": 32, - "Seed": 1337, - "SetupUpdateIntervalCount": 100000, - "ThreadsPerCore": 2.0, - "TransactionsPerBlock": 1024, - "MaxRuntimeSeconds": 1200, - "TransactionMetricsSampleRate": 0.001, - "BackgroundMetricsScrapeInterval": 60, - "BlockChannelCapacity": 8, - "DisableTransactionReads": true, - "LogDir": "logs/rocks20", - "LogLevel": "info", - "MaxTPS": 150000 -} diff --git a/sei-db/state_db/bench/cryptosim/cryptosim.go b/sei-db/state_db/bench/cryptosim/cryptosim.go index 9a276ea1a5..ded4374086 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim.go @@ -3,14 +3,16 @@ package cryptosim import ( "context" "fmt" + "path/filepath" "runtime" "time" + "golang.org/x/time/rate" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" "github.com/sei-protocol/sei-chain/sei-db/common/utils" - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" - "golang.org/x/time/rate" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" ) const ( @@ -133,23 +135,20 @@ func NewCryptoSim( fmt.Printf("Running cryptosim benchmark from data directory: %s\n", config.DataDir) fmt.Printf("Logs are being routed to: %s\n", config.LogDir) - var dbConfig any - switch config.Backend { - case wrappers.FlatKV: - dbConfig = config.FlatKVConfig - case wrappers.SSComposite, wrappers.CompositeDual_SSComposite: - dbConfig = config.StateStoreConfig - case wrappers.SSHistoricalOffload: - dbConfig = config.HistoricalOffload - } + config.FlatKVConfig.DataDir = config.DataDir + config.StateStoreConfig.EVMDBDirectory = filepath.Join( + config.DataDir, "state_store", "evm", config.StateStoreConfig.Backend) - db, err := wrappers.NewDBImpl(ctx, config.Backend, config.DataDir, dbConfig) + // giga.NewStateDB is the node's own entry point, and the only one that leaves the state WAL + // outside the live state DB: it opens the WAL itself and writes each block to it ahead of the + // commit. A live state DB opened directly would own its WAL and write it inline instead. + db, err := giga.NewStateDB(ctx, config.FlatKVConfig, config.StateStoreConfig, config.CheckpointConfig) if err != nil { cancel() - return nil, fmt.Errorf("failed to create database: %w", err) + return nil, fmt.Errorf("failed to open the state DB: %w", err) } - metrics := NewCryptosimMetrics(ctx, db.GetPhaseTimer(), config) + metrics := NewCryptosimMetrics(ctx, db.SC().GetPhaseTimer(), config) // Server start deferred until after DataGenerator loads DB state and sets gauges, // avoiding rate() spikes when restarting with a preserved DB. @@ -160,7 +159,7 @@ func NewCryptoSim( start := time.Now() - database, err := NewDatabase(config, db, metrics, 0) + database, err := NewDatabase(config, db, metrics) if err != nil { cancel() if closeErr := db.Close(); closeErr != nil { @@ -169,15 +168,9 @@ func NewCryptoSim( return nil, fmt.Errorf("failed to create database: %w", err) } - dataGenerator, err := NewDataGenerator(config, database, rand, metrics) - if err != nil { - cancel() - if closeErr := db.Close(); closeErr != nil { - fmt.Printf("failed to close database during error recovery: %v\n", closeErr) - } - return nil, fmt.Errorf("failed to create data generator: %w", err) - } - database.nextBlockNumber = dataGenerator.InitialNextBlockNumber() + fmt.Printf("Next block number: %s.\n", int64Commas(database.nextBlockNumber)) + + dataGenerator := NewDataGenerator(config, database, rand, metrics) threadCount := int(config.ThreadsPerCore)*runtime.NumCPU() + config.ConstantThreadCount if threadCount < 1 { threadCount = 1 diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config.go b/sei-db/state_db/bench/cryptosim/cryptosim_config.go index 286701df9a..c71afb5fd0 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/sei-protocol/sei-chain/sei-db/config" - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" flatkvConfig "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" ) @@ -100,16 +99,11 @@ type CryptoSimConfig struct { // in undefined behavior, don't change the size unless you are starting a new run from scratch. CannedRandomSize int - // The backend to use for the benchmark database. - Backend wrappers.DBType + // Configures the historical state DB. + StateStoreConfig config.StateStoreConfig - // StateStoreConfig controls SS-backed benchmark backends such as SSComposite. - // The default preserves the benchmark SS defaults: pebbledb, async buffer 100. - StateStoreConfig *config.StateStoreConfig - - // HistoricalOffload configures the transport used by the - // SSHistoricalOffload backend. - HistoricalOffload *wrappers.HistoricalOffloadConfig + // Configures the cadence the state DB checkpoints both halves of state on. + CheckpointConfig config.CheckpointConfig // This field is ignored, but allows for a comment to be added to the config file. // Something, something, why in the name of all things holy doesn't json support comments? @@ -163,7 +157,7 @@ type CryptoSimConfig struct { // If true, the log directory will be deleted on a clean shutdown. DeleteLogDirOnShutdown bool - // Configures the FlatKV database. Ignored if Backend is not "FlatKV". + // Configures the live state DB. FlatKVConfig *flatkvConfig.Config // The capacity of the channel that holds blocks awaiting execution. @@ -262,8 +256,8 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { HashLagBlocks: 32, Seed: 1337, CannedRandomSize: 1024 * 1024 * 1024, // 1GB - Backend: wrappers.FlatKV, - StateStoreConfig: wrappers.DefaultBenchStateStoreConfig(), + StateStoreConfig: config.DefaultStateStoreConfig(), + CheckpointConfig: config.DefaultCheckpointConfig(), ConsoleUpdateIntervalSeconds: 1, ConsoleUpdateIntervalTransactions: 1_000_000, SetupUpdateIntervalCount: 100_000, @@ -412,20 +406,12 @@ func (c *CryptoSimConfig) Validate() error { return fmt.Errorf("ReceiptLogFilterMaxBlockRange must be >= ReceiptLogFilterMinBlockRange (got %d < %d)", c.ReceiptLogFilterMaxBlockRange, c.ReceiptLogFilterMinBlockRange) } - if c.StateStoreConfig == nil { - return fmt.Errorf("StateStoreConfig is required") - } switch c.StateStoreConfig.Backend { case config.PebbleDBBackend, config.RocksDBBackend: default: return fmt.Errorf("StateStoreConfig.Backend must be one of %q or %q (got %q)", config.PebbleDBBackend, config.RocksDBBackend, c.StateStoreConfig.Backend) } - if c.Backend == wrappers.SSHistoricalOffload { - if err := c.HistoricalOffload.Validate(); err != nil { - return err - } - } switch strings.ToLower(c.LogLevel) { case "debug", "info", "warn", "error": default: diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config_test.go b/sei-db/state_db/bench/cryptosim/cryptosim_config_test.go index b4b8c8d2b5..ad09b24bef 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config_test.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/sei-db/config" - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" ) func TestLoadConfigFromFile_StateStoreConfigOverridePreservesBenchmarkDefaults(t *testing.T) { @@ -16,7 +15,6 @@ func TestLoadConfigFromFile_StateStoreConfigOverridePreservesBenchmarkDefaults(t configPath := filepath.Join(t.TempDir(), "cryptosim.json") err := os.WriteFile(configPath, []byte(`{ - "Backend": "SSComposite", "StateStoreConfig": { "Backend": "rocksdb" }, @@ -27,10 +25,9 @@ func TestLoadConfigFromFile_StateStoreConfigOverridePreservesBenchmarkDefaults(t cfg, err := LoadConfigFromFile(configPath) require.NoError(t, err) - require.Equal(t, wrappers.SSComposite, cfg.Backend) require.Equal(t, config.RocksDBBackend, cfg.StateStoreConfig.Backend) require.Equal(t, config.DefaultSSAsyncBuffer, cfg.StateStoreConfig.AsyncWriteBuffer) - require.True(t, cfg.StateStoreConfig.EVMSplit) + require.Equal(t, config.DefaultSSKeepRecent, cfg.StateStoreConfig.KeepRecent) } func TestLoadConfigFromFile_InvalidStateStoreBackend(t *testing.T) { @@ -74,7 +71,6 @@ func TestLoadConfigFromFile_DisableTransactionReadsOverride(t *testing.T) { configPath := filepath.Join(t.TempDir(), "cryptosim.json") err := os.WriteFile(configPath, []byte(`{ - "Backend": "NoOp", "DisableTransactionReads": true, "DataDir": "data", "LogDir": "logs" @@ -83,6 +79,5 @@ func TestLoadConfigFromFile_DisableTransactionReadsOverride(t *testing.T) { cfg, err := LoadConfigFromFile(configPath) require.NoError(t, err) - require.Equal(t, wrappers.NoOp, cfg.Backend) require.True(t, cfg.DisableTransactionReads) } diff --git a/sei-db/state_db/bench/cryptosim/data_generator.go b/sei-db/state_db/bench/cryptosim/data_generator.go index c896bbe9c3..0fa29046d4 100644 --- a/sei-db/state_db/bench/cryptosim/data_generator.go +++ b/sei-db/state_db/bench/cryptosim/data_generator.go @@ -32,10 +32,6 @@ type DataGenerator struct { // The next ERC20 contract ID to be used when creating a new ERC20 contract. nextErc20ContractID int64 - // The next block number at startup time. Not updated after initialization; - // the block builder tracks the ongoing value. - initialNextBlockNumber uint64 - // The random number generator. rand *crand.CannedRandom @@ -67,12 +63,9 @@ func NewDataGenerator( database *Database, rand *crand.CannedRandom, metrics *CryptosimMetrics, -) (*DataGenerator, error) { +) *DataGenerator { - nextAccountIDBinary, found, err := database.Get(AccountIDCounterKey()) - if err != nil { - return nil, fmt.Errorf("failed to read account counter: %w", err) - } + nextAccountIDBinary, found := database.Get(AccountIDCounterKey()) var nextAccountID int64 if found { //nolint:gosec // G115 - persisted counter value, overflow acceptable @@ -84,10 +77,7 @@ func NewDataGenerator( cold := min(int64(config.MinimumNumberOfColdAccounts), max(0, nextAccountID-1-hot)) metrics.SetTotalNumberOfAccounts(nextAccountID, hot, cold) - nextErc20ContractIDBinary, found, err := database.Get(Erc20IDCounterKey()) - if err != nil { - return nil, fmt.Errorf("failed to read ERC20 contract counter: %w", err) - } + nextErc20ContractIDBinary, found := database.Get(Erc20IDCounterKey()) var nextErc20ContractID int64 if found { //nolint:gosec // G115 - persisted counter value, overflow acceptable @@ -97,17 +87,6 @@ func NewDataGenerator( fmt.Printf("There are currently %s ERC20 contracts in the database.\n", int64Commas(nextErc20ContractID)) metrics.SetTotalNumberOfERC20Contracts(nextErc20ContractID) - nextBlockNumberBinary, found, err := database.Get(BlockNumberCounterKey()) - if err != nil { - return nil, fmt.Errorf("failed to read block number counter: %w", err) - } - var nextBlockNumber uint64 - if found { - nextBlockNumber = binary.BigEndian.Uint64(nextBlockNumberBinary) - } - - fmt.Printf("Next block number: %s.\n", int64Commas(int64(nextBlockNumber))) //nolint:gosec - feeCollectionAddress := keys.BuildEVMKey( accountKeyPrefix, rand.Address(accountPrefix, 0, keys.AddressLen), @@ -117,14 +96,13 @@ func NewDataGenerator( config: config, nextAccountID: nextAccountID, nextErc20ContractID: nextErc20ContractID, - initialNextBlockNumber: nextBlockNumber, rand: rand, feeCollectionAddress: feeCollectionAddress, database: database, highestSafeAccountIDInBlock: nextAccountID - 1, numberOfColdAccounts: int64(config.MinimumNumberOfColdAccounts), metrics: metrics, - }, nil + } } // Get the next account ID to be used when creating a new account. This is also the total number of accounts @@ -149,11 +127,6 @@ func (d *DataGenerator) NextErc20ContractID() int64 { return d.nextErc20ContractID } -// Get the next block number as it was at startup time. -func (d *DataGenerator) InitialNextBlockNumber() uint64 { - return d.initialNextBlockNumber -} - // Creates a new account and optionally writes it to the database. Returns the address of the new // account and whether it is a cold account (vs dormant). func (d *DataGenerator) CreateNewAccount( diff --git a/sei-db/state_db/bench/cryptosim/database.go b/sei-db/state_db/bench/cryptosim/database.go index fcb15248af..311b851ec2 100644 --- a/sei-db/state_db/bench/cryptosim/database.go +++ b/sei-db/state_db/bench/cryptosim/database.go @@ -4,8 +4,9 @@ import ( "encoding/binary" "fmt" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" ) // Encapsulates the database for the cryptosim benchmark. @@ -14,7 +15,11 @@ type Database struct { config *CryptoSimConfig // The database implementation to use for the benchmark. - db wrappers.DBWrapper + db gigatypes.StateDB + + // A read-only view of the most recently committed block, which every read that misses the + // current batch is served from. Replaced after each commit. + view gigatypes.StateView // The total number of transactions executed by the benchmark since it last started. transactionCount int64 @@ -22,8 +27,8 @@ type Database struct { // A count of the number of transactions in the current batch. transactionsInCurrentBlock int64 - // The next block number to be persisted. Tracked internally and incremented after each finalized block. - nextBlockNumber uint64 + // The block number the next commit lands on. Incremented after each finalized block. + nextBlockNumber int64 // The current batch of key-value pairs waiting to be committed. Represents changes we are accumulating // as part of a simulated "block". Stored as value []byte; converted to NamedChangeSet when applied to the DB. @@ -35,36 +40,36 @@ type Database struct { // The metrics for the benchmark. metrics *CryptosimMetrics - // Takes one block hash per block committed, so that the benchmark cannot outrun hashing. Nil when - // the database publishes no block hashes. + // Takes one block hash per block committed, so that the benchmark cannot outrun hashing. hashes *blockHashWaiter } // Creates a new database for the cryptosim benchmark. func NewDatabase( config *CryptoSimConfig, - db wrappers.DBWrapper, + db gigatypes.StateDB, metrics *CryptosimMetrics, - initialNextBlockNumber uint64, ) (*Database, error) { + // The view is both what reads are served from and where the starting height comes from: the + // store accepts only the block after the one it opened at. + view := db.OpenView() database := &Database{ config: config, db: db, + view: view, batch: NewSyncMap[string, []byte](), metrics: metrics, - nextBlockNumber: initialNextBlockNumber, + nextBlockNumber: view.GetBlockHeight() + 1, } // Registered here because this is before the first block is committed, and that is the only place // a listener can be sure of being handed every block's hash. waiter := newBlockHashWaiter(config.HashLagBlocks, metrics) - registered, err := db.RegisterHashListener(waiter.listen) - if err != nil { + if _, err := db.RegisterHashListener(waiter.listen); err != nil { + view.Close() return nil, fmt.Errorf("failed to register a block hash listener: %w", err) } - if registered { - database.hashes = waiter - } + database.hashes = waiter return database, nil } @@ -81,20 +86,11 @@ func (d *Database) Put(key []byte, value []byte) error { // // This method is safe to call concurrently with other calls to Put() and Get(). Is not thread // safe with FinalizeBlock(). -func (d *Database) Get(key []byte) ([]byte, bool, error) { +func (d *Database) Get(key []byte) ([]byte, bool) { if value, found := d.batch.Get(string(key)); found { - return value, true, nil - } - - value, found, err := d.db.Read(key) - if err != nil { - return nil, false, fmt.Errorf("failed to read from database: %w", err) - } - if found { - return value, true, nil + return value, true } - - return nil, false, nil + return d.view.Get(keys.EVMStoreKey, key) } // Signal that a transaction has been added to the current block. @@ -152,7 +148,7 @@ func (d *Database) FinalizeBlock( changeSets := make([]*proto.NamedChangeSet, 0, d.transactionsInCurrentBlock+3) for key, value := range d.batch.Iterator() { changeSets = append(changeSets, &proto.NamedChangeSet{ - Name: wrappers.EVMStoreName, + Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{{Key: []byte(key), Value: value}}}, }) } @@ -163,7 +159,7 @@ func (d *Database) FinalizeBlock( //nolint:gosec // G115 - nextAccountID is benchmark counter, overflow acceptable binary.BigEndian.PutUint64(nonceValue, uint64(nextAccountID)) changeSets = append(changeSets, &proto.NamedChangeSet{ - Name: wrappers.EVMStoreName, + Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ {Key: AccountIDCounterKey(), Value: nonceValue}, }}, @@ -174,49 +170,40 @@ func (d *Database) FinalizeBlock( //nolint:gosec // G115 - nextErc20ContractID is benchmark counter, overflow acceptable binary.BigEndian.PutUint64(erc20ContractIDValue, uint64(nextErc20ContractID)) changeSets = append(changeSets, &proto.NamedChangeSet{ - Name: wrappers.EVMStoreName, + Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ {Key: Erc20IDCounterKey(), Value: erc20ContractIDValue}, }}, }) // Persist the block number counter in every batch. + blockNum := d.nextBlockNumber blockNumberValue := make([]byte, 8) - binary.BigEndian.PutUint64(blockNumberValue, d.nextBlockNumber) + //nolint:gosec // G115 - blockNum is a benchmark counter, overflow acceptable + binary.BigEndian.PutUint64(blockNumberValue, uint64(blockNum)) changeSets = append(changeSets, &proto.NamedChangeSet{ - Name: wrappers.EVMStoreName, + Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ {Key: BlockNumberCounterKey(), Value: blockNumberValue}, }}, }) - d.nextBlockNumber++ - - entry := &proto.ChangelogEntry{ - Version: d.db.Version() + 1, - Changesets: changeSets, - } - err := d.db.ApplyChangeSets(entry) - if err != nil { - return fmt.Errorf("failed to apply change sets: %w", err) - } d.metrics.ReportBlockFinalized(d.transactionsInCurrentBlock) d.transactionsInCurrentBlock = 0 // One commit per block: that is the store contract, so the benchmark must not batch. d.metrics.SetMainThreadPhase("committing") - if _, err := d.db.Commit(); err != nil { - return fmt.Errorf("failed to commit: %w", err) + if err := d.db.CommitStateChanges(blockNum, changeSets); err != nil { + return fmt.Errorf("failed to commit block %d: %w", blockNum, err) } + d.nextBlockNumber++ d.metrics.ReportDBCommit() + d.reopenView() // Committing a block is not finishing it: the hash of a block committed a bounded number of // blocks ago is taken here, and waited for when hashing has fallen behind execution. - if d.hashes != nil { - if err := d.hashes.awaitBlock(); err != nil { - return fmt.Errorf("failed to obtain a block hash after committing block %d: %w", - d.db.Version(), err) - } + if err := d.hashes.awaitBlock(); err != nil { + return fmt.Errorf("failed to obtain a block hash after committing block %d: %w", blockNum, err) } d.metrics.SetMainThreadPhase("executing") @@ -224,6 +211,14 @@ func (d *Database) FinalizeBlock( return nil } +// reopenView replaces the read view with one over the block just committed. A view never observes +// writes made after it was opened, so without this every read would keep answering from the height +// the benchmark started at. +func (d *Database) reopenView() { + d.view.Close() + d.view = d.db.OpenView() +} + // Close the database and release any resources. func (d *Database) Close(nextAccountID int64, nextErc20ContractID int64) error { fmt.Printf("Committing final batch.\n") @@ -232,20 +227,17 @@ func (d *Database) Close(nextAccountID int64, nextErc20ContractID int64) error { return fmt.Errorf("failed to commit batch: %w", err) } - fmt.Printf("Closing database.\n") - err := d.db.Close() - if err != nil { - return fmt.Errorf("failed to close database: %w", err) - } - - return nil + return d.CloseWithoutFinalizing() } // Close the database and release any resources without finalizing the last batch. func (d *Database) CloseWithoutFinalizing() error { fmt.Printf("Closing database.\n") - err := d.db.Close() - if err != nil { + + // The view holds a reference into the store, which cannot release it while the view is open. + d.view.Close() + + if err := d.db.Close(); err != nil { return fmt.Errorf("failed to close database: %w", err) } diff --git a/sei-db/state_db/bench/cryptosim/historical_offload_test.go b/sei-db/state_db/bench/cryptosim/historical_offload_test.go deleted file mode 100644 index e44b5be950..0000000000 --- a/sei-db/state_db/bench/cryptosim/historical_offload_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package cryptosim - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" -) - -func TestValidateHistoricalOffloadRequiresConfigForHistoricalOffloadBackend(t *testing.T) { - cfg := DefaultCryptoSimConfig() - cfg.DataDir = t.TempDir() - cfg.LogDir = t.TempDir() - cfg.Backend = wrappers.SSHistoricalOffload - - err := cfg.Validate() - require.ErrorContains(t, err, "historical offload config is required") -} - -func TestValidateHistoricalOffloadRequiresKafkaConfig(t *testing.T) { - cfg := DefaultCryptoSimConfig() - cfg.DataDir = t.TempDir() - cfg.LogDir = t.TempDir() - cfg.Backend = wrappers.SSHistoricalOffload - cfg.HistoricalOffload = &wrappers.HistoricalOffloadConfig{ - Provider: "kafka", - } - - err := cfg.Validate() - require.Error(t, err) - require.Contains(t, err.Error(), "historical offload kafka config is required") -} - -func TestValidateHistoricalOffloadKafkaAcceptsMinimalValidConfig(t *testing.T) { - cfg := DefaultCryptoSimConfig() - cfg.DataDir = t.TempDir() - cfg.LogDir = t.TempDir() - cfg.Backend = wrappers.SSHistoricalOffload - cfg.HistoricalOffload = &wrappers.HistoricalOffloadConfig{ - Provider: "kafka", - Kafka: &wrappers.KafkaHistoricalOffloadConfig{ - Brokers: []string{"localhost:9092"}, - Topic: "historical-offload", - }, - } - - require.NoError(t, cfg.Validate()) - require.Equal(t, "cryptosim-historical-offload", cfg.HistoricalOffload.Kafka.ClientID) - require.Equal(t, "none", cfg.HistoricalOffload.Kafka.RequiredAcks) - require.Nil(t, cfg.HistoricalOffload.Kafka.Async) - require.Equal(t, 1000, cfg.HistoricalOffload.Kafka.BatchSize) - require.Equal(t, 4<<20, cfg.HistoricalOffload.Kafka.BatchBytes) -} - -func TestValidateHistoricalOffloadKafkaIAMRequiresRegion(t *testing.T) { - cfg := DefaultCryptoSimConfig() - cfg.DataDir = t.TempDir() - cfg.LogDir = t.TempDir() - cfg.Backend = wrappers.SSHistoricalOffload - cfg.HistoricalOffload = &wrappers.HistoricalOffloadConfig{ - Provider: "kafka", - Kafka: &wrappers.KafkaHistoricalOffloadConfig{ - Brokers: []string{"localhost:9098"}, - Topic: "historical-offload", - TLSEnabled: true, - SASLMechanism: "aws-msk-iam", - }, - } - - err := cfg.Validate() - require.ErrorContains(t, err, "region is required") - - cfg.HistoricalOffload.Kafka.Region = "eu-central-1" - require.NoError(t, cfg.Validate()) -} diff --git a/sei-db/state_db/bench/cryptosim/transaction.go b/sei-db/state_db/bench/cryptosim/transaction.go index 762d2586ab..04647e66db 100644 --- a/sei-db/state_db/bench/cryptosim/transaction.go +++ b/sei-db/state_db/bench/cryptosim/transaction.go @@ -103,9 +103,7 @@ func (txn *transaction) Execute( phaseTimer.SetPhase("read_erc20") // Read the simulated ERC20 contract. - if _, _, err := database.Get(txn.erc20Contract); err != nil { - return fmt.Errorf("failed to get ERC20 contract: %w", err) - } + database.Get(txn.erc20Contract) // Read the following: // - the sender's native balance / nonce / codehash @@ -120,39 +118,29 @@ func (txn *transaction) Execute( // Technically, we are just requesting to read the codehash, but internally the codehash is bundled with // the nonce and balance, so all of this data will be read from low level storage, even if it isn't being // returned to the caller. - if _, _, err := database.Get(txn.srcAccount); err != nil { - return fmt.Errorf("failed to get source account: %w", err) - } + database.Get(txn.srcAccount) phaseTimer.SetPhase("read_dst_account") // Read the receiver's native balance / nonce / codehash. - if _, _, err := database.Get(txn.dstAccount); err != nil { - return fmt.Errorf("failed to get destination account: %w", err) - } + database.Get(txn.dstAccount) phaseTimer.SetPhase("read_src_account_slot") // Read the sender's storage slot for the ERC20 contract. // We don't care if the value isn't in the DB yet, since we don't pre-populate the database with storage slots. - if _, _, err := database.Get(txn.srcAccountSlot); err != nil { - return fmt.Errorf("failed to get source account slot: %w", err) - } + database.Get(txn.srcAccountSlot) phaseTimer.SetPhase("read_dst_account_slot") // Read the receiver's storage slot for the ERC20 contract. // We don't care if the value isn't in the DB yet, since we don't pre-populate the database with storage slots. - if _, _, err := database.Get(txn.dstAccountSlot); err != nil { - return fmt.Errorf("failed to get destination account slot: %w", err) - } + database.Get(txn.dstAccountSlot) phaseTimer.SetPhase("read_fee_collection_account") // Read the fee collection account's native balance. - if _, _, err := database.Get(feeCollectionAddress); err != nil { - return fmt.Errorf("failed to get fee collection account: %w", err) - } + database.Get(feeCollectionAddress) } phaseTimer.SetPhase("update_balances") diff --git a/sei-db/state_db/bench/cryptosim/transaction_test.go b/sei-db/state_db/bench/cryptosim/transaction_test.go index 682f532442..95b67bf5b5 100644 --- a/sei-db/state_db/bench/cryptosim/transaction_test.go +++ b/sei-db/state_db/bench/cryptosim/transaction_test.go @@ -5,52 +5,41 @@ import ( "github.com/stretchr/testify/require" - commonmetrics "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/bench/wrappers" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) -type readTrackingWrapper struct { - readCalls int -} +// readTrackingView counts the reads a transaction issues. It embeds StateView without implementing +// it, so any method the transaction is not expected to call panics on the nil interface rather than +// answering with a zero value. +type readTrackingView struct { + gigatypes.StateView -func (r *readTrackingWrapper) ApplyChangeSets(_ *proto.ChangelogEntry) error { - return nil -} - -func (r *readTrackingWrapper) Read(_ []byte) ([]byte, bool, error) { - r.readCalls++ - return nil, false, nil + // Reads served, in call order. + readCalls int } -func (r *readTrackingWrapper) Commit() (int64, error) { - return 0, nil -} +func (v *readTrackingView) GetBlockHeight() int64 { return 0 } -func (r *readTrackingWrapper) Close() error { - return nil +func (v *readTrackingView) Get(_ string, _ []byte) ([]byte, bool) { + v.readCalls++ + return nil, false } -func (r *readTrackingWrapper) Version() int64 { - return 0 -} +func (v *readTrackingView) Close() {} -func (r *readTrackingWrapper) LoadLatest() error { - return nil -} +// readTrackingStateDB serves every view from one readTrackingView, so a test can count the reads +// made through it. +type readTrackingStateDB struct { + gigatypes.StateDB -func (r *readTrackingWrapper) Importer(_ int64) (scTypes.Importer, error) { - return nil, nil + view *readTrackingView } -func (r *readTrackingWrapper) GetPhaseTimer() *commonmetrics.PhaseTimer { - return nil -} +func (s *readTrackingStateDB) OpenView() gigatypes.StateView { return s.view } -func (r *readTrackingWrapper) RegisterHashListener(_ gigatypes.HashListener) (bool, error) { - return false, nil +func (s *readTrackingStateDB) RegisterHashListener(_ gigatypes.HashListener) (lthash.BlockHash, error) { + return lthash.BlockHash{}, nil } func TestTransactionExecuteSkipsReadsWhenDisabled(t *testing.T) { @@ -59,8 +48,8 @@ func TestTransactionExecuteSkipsReadsWhenDisabled(t *testing.T) { cfg := DefaultCryptoSimConfig() cfg.DisableTransactionReads = true - wrapper := &readTrackingWrapper{} - db, err := NewDatabase(cfg, wrapper, nil, 0) + stateDB := &readTrackingStateDB{view: &readTrackingView{}} + db, err := NewDatabase(cfg, stateDB, nil) require.NoError(t, err) txn := &transaction{ @@ -77,10 +66,10 @@ func TestTransactionExecuteSkipsReadsWhenDisabled(t *testing.T) { } require.NoError(t, txn.Execute(db, []byte("fee"), nil)) - require.Zero(t, wrapper.readCalls) + require.Zero(t, stateDB.view.readCalls) - _, found, err := db.Get([]byte("src")) - require.NoError(t, err) + // The write the transaction made is in the batch, so it is served without reaching the view. + _, found := db.Get([]byte("src")) require.True(t, found) } @@ -89,5 +78,4 @@ func TestDefaultCryptoSimConfigDisablesTransactionReadsByDefaultFalse(t *testing cfg := DefaultCryptoSimConfig() require.False(t, cfg.DisableTransactionReads) - require.Equal(t, wrappers.FlatKV, cfg.Backend) } diff --git a/sei-db/state_db/bench/helper.go b/sei-db/state_db/bench/helper.go deleted file mode 100644 index 4287b1a0c8..0000000000 --- a/sei-db/state_db/bench/helper.go +++ /dev/null @@ -1,455 +0,0 @@ -package bench - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/binary" - "fmt" - "io" - "math" - mrand "math/rand" - "os" - "path/filepath" - "strconv" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-cosmos/snapshots" - snapshottypes "github.com/sei-protocol/sei-chain/sei-cosmos/snapshots/types" - commonevm "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" - sctypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -const ( - // EVMStoreName simulates the EVM module store - EVMStoreName = commonevm.EVMStoreKey - - // KeySize EVM storage key: 0x03 prefix + 20-byte address + 32-byte slot = 53 bytes - KeySize = 53 - ValueSize = 32 -) - -// TestScenario bundles benchmark parameters and distribution. -type TestScenario struct { - Name string - TotalKeys int64 - NumBlocks int64 - DuplicateRatio float64 // 0.0 = all inserts, 1.0 = all updates - // The database backend to use for the benchmark. - Backend wrappers.DBType - Distribution KeyDistribution - - // SnapshotPath, when set, points to a state sync snapshot chunks directory - // (e.g. "/data/snapshots///") containing numbered - // chunk files (0, 1, 2, ...). Before the benchmark begins, the snapshot is - // imported into the database via the native Committer.Importer path as a - // preparation stage. - SnapshotPath string -} - -// KeyDistribution defines how many keys to generate per block. -type KeyDistribution func(numBlocks, totalKeys, block int64) int64 - -// EvenDistribution generates same number of keys on each block. -func EvenDistribution(numBlocks, totalKeys, _ int64) int64 { - if numBlocks <= 0 || totalKeys < numBlocks { - return 0 - } - return totalKeys / numBlocks -} - -// BurstyDistribution emits periodic bursts with optional jitter. -// Example: base=100 keys/block, burstEvery=5, burstMultiplier=3 => -// blocks 0,5,10... emit 300 keys; other blocks emit 100 keys (then +/- jitter). -func BurstyDistribution(seed int64, burstEvery, burstMultiplier, maxJitter int64) KeyDistribution { - rng := mrand.New(mrand.NewSource(seed)) - return func(numBlocks, totalKeys, block int64) int64 { - if numBlocks <= 0 { - return 0 - } - keysPerBlock := totalKeys / numBlocks - count := keysPerBlock - if burstEvery > 0 && block%burstEvery == 0 { - count *= burstMultiplier - } - if maxJitter > 0 { - count += rng.Int63n(2*maxJitter+1) - maxJitter - } - if count < 0 { - return 0 - } - return count - } -} - -// NormalDistribution samples keys per block from a normal distribution. -// Example: totalKeys=1000, numBlocks=10, stddevFactor=0.2 => -// mean=100 keys, stddev=20 keys -func NormalDistribution(seed int64, stddevFactor float64) KeyDistribution { - rng := mrand.New(mrand.NewSource(seed)) - return func(numBlocks, totalKeys, _ int64) int64 { - if numBlocks <= 0 { - return 0 - } - mean := float64(totalKeys) / float64(numBlocks) - stddev := mean * stddevFactor - if stddev <= 0 { - return int64(mean) - } - count := int64(mean + rng.NormFloat64()*stddev) - if count < 0 { - return 0 - } - return count - } -} - -// RampDistribution linearly ramps keysPerBlock by a factor over the run. -// Example: totalKeys=1000, numBlocks=10, startFactor=0.5, endFactor=1.5 => -// per-block base=100; block 0 ~50 keys, block 9 ~150 keys (linearly interpolated). -func RampDistribution(startFactor, endFactor float64) KeyDistribution { - return func(numBlocks, totalKeys, block int64) int64 { - if numBlocks <= 1 { - return int64(float64(totalKeys) * endFactor) - } - keysPerBlock := totalKeys / numBlocks - t := float64(block) / float64(numBlocks-1) - factor := startFactor + t*(endFactor-startFactor) - count := int64(float64(keysPerBlock) * factor) - if count < 0 { - return 0 - } - return count - } -} - -// ProgressReporter reports benchmark progress periodically. -type ProgressReporter struct { - totalKeys int64 - totalBlocks int64 - keysWritten atomic.Int64 - startTime time.Time - done chan struct{} - interval time.Duration -} - -// NewProgressReporter creates a new progress reporter. -func NewProgressReporter(totalKeys, totalBlocks int64, interval time.Duration) *ProgressReporter { - return &ProgressReporter{ - totalKeys: totalKeys, - totalBlocks: totalBlocks, - done: make(chan struct{}), - interval: interval, - } -} - -// Start begins periodic progress reporting in a background goroutine. -func (p *ProgressReporter) Start() { - p.startTime = time.Now() - go func() { - ticker := time.NewTicker(p.interval) - defer ticker.Stop() - for { - select { - case <-p.done: - return - case <-ticker.C: - p.report() - } - } - }() -} - -// Stop stops the progress reporter and prints final stats. -func (p *ProgressReporter) Stop() { - close(p.done) - elapsed := time.Since(p.startTime).Seconds() - keys := p.keysWritten.Load() - fmt.Printf("[Final] keys=%d/%d, keys/sec=%.0f, elapsed=%.2fs\n", - keys, p.totalKeys, float64(keys)/elapsed, elapsed) -} - -// Add records that keys were written. -func (p *ProgressReporter) Add(keys int) { - p.keysWritten.Add(int64(keys)) -} - -func (p *ProgressReporter) report() { - keys := p.keysWritten.Load() - elapsed := time.Since(p.startTime).Seconds() - if elapsed > 0 { - keysPerBlock := p.totalKeys / p.totalBlocks - if keysPerBlock > 0 { - blocks := keys / keysPerBlock - fmt.Printf("[Progress] blocks=%d/%d, keys=%d/%d, keys/sec=%.0f\n", - blocks, p.totalBlocks, keys, p.totalKeys, float64(keys)/elapsed) - return - } - fmt.Printf("[Progress] blocks=%d/%d, keys=%d/%d, keys/sec=%.0f\n", - 0, p.totalBlocks, keys, p.totalKeys, float64(keys)/elapsed) - } -} - -// startChangesetGenerator streams per-block changesets based on the scenario distribution. -func startChangesetGenerator(scenario TestScenario) <-chan *proto.NamedChangeSet { - if scenario.Distribution == nil { - scenario.Distribution = EvenDistribution - } - duplicateRatio := scenario.DuplicateRatio - if duplicateRatio < 0 { - duplicateRatio = 0 - } - if duplicateRatio > 1 { - duplicateRatio = 1 - } - rng := mrand.New(mrand.NewSource(1)) - out := make(chan *proto.NamedChangeSet) - go func() { - defer close(out) - var uniqueCounter int64 - for i := range scenario.NumBlocks { - numKeysInBlock := scenario.Distribution(scenario.NumBlocks, scenario.TotalKeys, i) - if numKeysInBlock < 0 { - numKeysInBlock = 0 - } - kvPairs := make([]*proto.KVPair, int(numKeysInBlock)) - duplicateCount := int64(float64(numKeysInBlock) * duplicateRatio) - for j := range kvPairs { - var keyIndex int64 - if int64(j) < duplicateCount && uniqueCounter > 0 { - keyIndex = rng.Int63n(uniqueCounter) - } else { - keyIndex = uniqueCounter - uniqueCounter++ - } - key := keyFromIndex(keyIndex) - val := make([]byte, ValueSize) - if _, err := rand.Read(val); err != nil { - panic(fmt.Sprintf("failed to generate random value: %v", err)) - } - kvPairs[j] = &proto.KVPair{Key: key, Value: val} - } - cs := &proto.NamedChangeSet{ - Name: EVMStoreName, - Changeset: proto.ChangeSet{Pairs: kvPairs}, - } - out <- cs - } - }() - return out -} - -func keyFromIndex(index int64) []byte { - key := make([]byte, KeySize) - key[0] = 0x03 - var input [9]byte - if index < 0 { - panic(fmt.Sprintf("negative key index: %d", index)) - } - binary.LittleEndian.PutUint64(input[1:], uint64(index)) //nolint:gosec // index validated non-negative above - sum1 := sha256.Sum256(input[:]) - input[0] = 1 //nolint:gosec - sum2 := sha256.Sum256(input[:]) - copy(key[1:], sum1[:]) - copy(key[1+len(sum1):], sum2[:len(key)-1-len(sum1)]) - return key -} - -// parseSnapshotHeight extracts the block height from a state sync snapshot -// chunks directory path. The expected layout is ///, -// so the height is the parent of the format directory. -func parseSnapshotHeight(chunksDir string) (int64, error) { - heightStr := filepath.Base(filepath.Dir(filepath.Clean(chunksDir))) - h, err := strconv.ParseInt(heightStr, 10, 64) - if err != nil { - return 0, fmt.Errorf("parse snapshot height from path %q: %w", chunksDir, err) - } - if h <= 0 || h > math.MaxUint32 { - return 0, fmt.Errorf("snapshot height %d out of range", h) - } - return h, nil -} - -// openSnapshotStream opens the numbered chunk files in chunksDir and returns a -// StreamReader that decompresses and demuxes the protobuf item stream. -func openSnapshotStream(chunksDir string) (*snapshots.StreamReader, error) { - if _, err := os.Stat(filepath.Join(chunksDir, "0")); err != nil { - return nil, fmt.Errorf("no chunk files found in %s: %w", chunksDir, err) - } - - chunks := make(chan io.ReadCloser) - go func() { - defer close(chunks) - for i := 0; ; i++ { - path := filepath.Join(chunksDir, strconv.Itoa(i)) - f, err := os.Open(filepath.Clean(path)) - if err != nil { - if os.IsNotExist(err) { - return - } - pr, pw := io.Pipe() - _ = pw.CloseWithError(fmt.Errorf("open chunk %d: %w", i, err)) - chunks <- pr - return - } - chunks <- f - } - }() - - return snapshots.NewStreamReader(chunks) -} - -// importSnapshot reads a state sync snapshot from chunksDir and feeds every -// item through the given Importer (AddModule / AddNode). This is the same -// import path used by the real state sync restore logic. -// Returns the total number of leaf keys imported. -func importSnapshot(chunksDir string, importer sctypes.Importer) error { - streamReader, err := openSnapshotStream(chunksDir) - if err != nil { - return fmt.Errorf("create stream reader: %w", err) - } - defer func() { - _ = streamReader.Close() - }() - - var ( - totalKeys int64 - startTime = time.Now() - currModule = "" - ) - - for { - var item snapshottypes.SnapshotItem - err := streamReader.ReadMsg(&item) - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("read snapshot item: %w", err) - } - - switch i := item.Item.(type) { - case *snapshottypes.SnapshotItem_Store: - currModule = i.Store.Name - if currModule == commonevm.EVMStoreKey { - if err := importer.AddModule(i.Store.Name); err != nil { - return fmt.Errorf("add module %s: %w", i.Store.Name, err) - } - fmt.Printf("[Snapshot] Importing store: %s\n", i.Store.Name) - } else { - fmt.Printf("[Snapshot] Skipping store: %s\n", i.Store.Name) - } - case *snapshottypes.SnapshotItem_IAVL: - if currModule != commonevm.EVMStoreKey { - continue - } - if i.IAVL.Height > math.MaxInt8 { - return fmt.Errorf("node height %d exceeds int8", i.IAVL.Height) - } - node := &sctypes.SnapshotNode{ - Key: i.IAVL.Key, - Value: i.IAVL.Value, - Height: int8(i.IAVL.Height), //nolint:gosec - Version: i.IAVL.Version, - } - if node.Height == 0 && node.Value == nil { - node.Value = []byte{} - } - importer.AddNode(node) - if node.Height == 0 { - totalKeys++ - if totalKeys%1_000_000 == 0 { - elapsed := time.Since(startTime).Seconds() - fmt.Printf("[Snapshot] keys=%d, keys/sec=%.0f, elapsed=%.2fs\n", - totalKeys, float64(totalKeys)/elapsed, elapsed) - } - } - default: - break - } - } - - elapsed := time.Since(startTime).Seconds() - fmt.Printf("[Snapshot] Import Done: keys=%d, keys/sec=%.0f, elapsed=%.2fs\n", - totalKeys, float64(totalKeys)/elapsed, elapsed) - - return importer.Close() -} - -// runBenchmark runs the benchmark with optional progress reporting. -// If withProgress is true, reports keys/sec every 5 seconds to stdout. -func runBenchmark(b *testing.B, scenario TestScenario, withProgress bool) { - if scenario.Distribution == nil { - scenario.Distribution = EvenDistribution - } - - b.ResetTimer() - b.ReportAllocs() - - for range b.N { - func() { - dbDir := b.TempDir() - b.StopTimer() - cs, err := wrappers.NewDBImpl(b.Context(), scenario.Backend, dbDir, nil) - require.NoError(b, err) - - // Load snapshot if available - if scenario.SnapshotPath != "" { - snapshotHeight, err := parseSnapshotHeight(scenario.SnapshotPath) - require.NoError(b, err) - importer, err := cs.Importer(snapshotHeight) - require.NoError(b, err) - err = importSnapshot(scenario.SnapshotPath, importer) - require.NoError(b, err) - err = cs.LoadLatest() - require.NoError(b, err) - } - changesetChannel := startChangesetGenerator(scenario) - - var progress *ProgressReporter - if withProgress { - progress = NewProgressReporter(scenario.TotalKeys, scenario.NumBlocks, 5*time.Second) - progress.Start() - } - - baseVersion := cs.Version() - b.StartTimer() - fmt.Printf("Opening DB with base version %d\n", baseVersion) - - for block := int64(1); block < scenario.NumBlocks; block++ { - changeset, ok := <-changesetChannel - if !ok { - break - } - entry := &proto.ChangelogEntry{ - Version: baseVersion + block, - Changesets: []*proto.NamedChangeSet{changeset}, - } - err := cs.ApplyChangeSets(entry) - require.NoError(b, err) - version, err := cs.Commit() - require.NoError(b, err) - require.Equal(b, baseVersion+block, version) - if progress != nil { - progress.Add(len(changeset.Changeset.Pairs)) - } - } - closeErr := cs.Close() // close to make sure all data got flushed - require.NoError(b, closeErr) - - b.StopTimer() - if progress != nil { - progress.Stop() - } - - elapsed := b.Elapsed().Seconds() - b.ReportMetric(float64(scenario.TotalKeys)/elapsed, "keys/sec") - b.ReportMetric(elapsed, "seconds") - }() - } -} diff --git a/sei-db/state_db/bench/wrappers/combined_wrapper.go b/sei-db/state_db/bench/wrappers/combined_wrapper.go deleted file mode 100644 index 368f96516a..0000000000 --- a/sei-db/state_db/bench/wrappers/combined_wrapper.go +++ /dev/null @@ -1,78 +0,0 @@ -package wrappers - -import ( - "sync/atomic" - - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - dbTypes "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -var _ DBWrapper = (*combinedWrapper)(nil) - -// combinedWrapper drives both a State Commit (SC) and State Store (SS) backend -// from the same changeset stream, mirroring production where SC and SS receive -// identical writes. -type combinedWrapper struct { - sc DBWrapper - ss dbTypes.StateStore - ssVersion atomic.Int64 -} - -func NewCombinedWrapper(sc DBWrapper, ss dbTypes.StateStore) DBWrapper { - w := &combinedWrapper{sc: sc, ss: ss} - w.ssVersion.Store(ss.GetLatestVersion()) - return w -} - -func (c *combinedWrapper) ApplyChangeSets(entry *proto.ChangelogEntry) error { - if err := c.sc.ApplyChangeSets(entry); err != nil { - return err - } - c.ssVersion.Store(entry.Version) - return c.ss.ApplyChangesetAsync(entry.Version, entry.Changesets) -} - -func (c *combinedWrapper) Read(key []byte) (data []byte, found bool, err error) { - return c.sc.Read(key) -} - -func (c *combinedWrapper) Commit() (int64, error) { - if _, err := c.sc.Commit(); err != nil { - return 0, err - } - return c.ssVersion.Load(), nil -} - -func (c *combinedWrapper) Close() error { - scErr := c.sc.Close() - ssErr := c.ss.Close() - if scErr != nil { - return scErr - } - return ssErr -} - -func (c *combinedWrapper) Version() int64 { - return c.ssVersion.Load() -} - -func (c *combinedWrapper) LoadLatest() error { - return c.sc.LoadLatest() -} - -func (c *combinedWrapper) Importer(version int64) (scTypes.Importer, error) { - return c.sc.Importer(version) -} - -// RegisterHashListener forwards to the SC backend. The SS backend commits the same changesets but -// computes no block hash of its own. -func (c *combinedWrapper) RegisterHashListener(listener gigatypes.HashListener) (bool, error) { - return c.sc.RegisterHashListener(listener) -} - -func (c *combinedWrapper) GetPhaseTimer() *metrics.PhaseTimer { - return nil -} diff --git a/sei-db/state_db/bench/wrappers/composite_wrapper.go b/sei-db/state_db/bench/wrappers/composite_wrapper.go deleted file mode 100644 index 8f462b06a6..0000000000 --- a/sei-db/state_db/bench/wrappers/composite_wrapper.go +++ /dev/null @@ -1,65 +0,0 @@ -package wrappers - -import ( - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/composite" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -var _ DBWrapper = (*compositeWrapper)(nil) - -// compositeWrapper wraps a composite commit store to implement the DBWrapper interface. -type compositeWrapper struct { - base *composite.CompositeCommitStore -} - -// NewCompositeWrapper creates a new compositeWrapper with a given composite commit store. -func NewCompositeWrapper(store *composite.CompositeCommitStore) DBWrapper { - return &compositeWrapper{ - base: store, - } -} - -func (c *compositeWrapper) ApplyChangeSets(entry *proto.ChangelogEntry) error { - return c.base.ApplyChangeSets(entry.Changesets) -} - -func (c *compositeWrapper) Commit() (int64, error) { - // The benchmark wrapper interface carries no height, so the next one is derived here. That is - // sound only because nothing in the benchmark path takes a block's hash before committing it. - return c.base.Commit(c.base.Version() + 1) -} - -func (c *compositeWrapper) LoadLatest() error { - return c.base.LoadLatest() -} - -func (c *compositeWrapper) Version() int64 { - return c.base.Version() -} - -func (c *compositeWrapper) Importer(version int64) (types.Importer, error) { - return c.base.Importer(version) -} - -func (c *compositeWrapper) Close() error { - return c.base.Close() -} - -func (c *compositeWrapper) Read(key []byte) (data []byte, found bool, err error) { - store := c.base.GetChildStoreByName(EVMStoreName) - data = store.Get(key) - return data, data != nil, nil -} - -// RegisterHashListener reports that this DB publishes no block hashes. The composite store consumes -// flatKV's hashes itself, in order to answer Cosmos synchronously, so it admits no second consumer. -func (c *compositeWrapper) RegisterHashListener(_ gigatypes.HashListener) (bool, error) { - return false, nil -} - -func (c *compositeWrapper) GetPhaseTimer() *metrics.PhaseTimer { - return nil -} diff --git a/sei-db/state_db/bench/wrappers/db_implementations.go b/sei-db/state_db/bench/wrappers/db_implementations.go deleted file mode 100644 index c17e2b3af2..0000000000 --- a/sei-db/state_db/bench/wrappers/db_implementations.go +++ /dev/null @@ -1,228 +0,0 @@ -package wrappers - -import ( - "context" - "fmt" - "path/filepath" - - commonevm "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "github.com/sei-protocol/sei-chain/sei-db/config" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/composite" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" - flatkvConfig "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" - sctypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" - ssComposite "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/composite" -) - -const EVMStoreName = commonevm.EVMStoreKey - -type DBType string - -const ( - NoOp DBType = "NoOp" - MemIAVL DBType = "MemIAVL" - FlatKV DBType = "FlatKV" - CompositeDual DBType = "CompositeDual" - CompositeSplit DBType = "CompositeSplit" - CompositeCosmos DBType = "CompositeCosmos" - - SSComposite DBType = "SSComposite" - SSHistoricalOffload DBType = "SSHistoricalOffload" - CompositeDual_SSComposite DBType = "CompositeDual+SSComposite" -) - -func DefaultBenchStateStoreConfig() *config.StateStoreConfig { - cfg := config.DefaultStateStoreConfig() - cfg.AsyncWriteBuffer = config.DefaultSSAsyncBuffer - cfg.EVMSplit = true - return &cfg -} - -// DefaultBenchMemIAVLConfig returns the memiavl config the benchmarks open -// with by default. Note AsyncCommitBuffer=10: Commit() returns once the WAL -// write is enqueued, not once it is durable. -func DefaultBenchMemIAVLConfig() memiavl.Config { - cfg := memiavl.DefaultConfig() - cfg.AsyncCommitBuffer = 10 - cfg.SnapshotInterval = 1000 - cfg.SnapshotMinTimeInterval = 60 - return cfg -} - -func newMemIAVLCommitStore(dbDir string, cfg *memiavl.Config) (DBWrapper, error) { - if cfg == nil { - defaultCfg := DefaultBenchMemIAVLConfig() - cfg = &defaultCfg - } - fmt.Printf("Opening memIAVL from directory %s\n", dbDir) - cs := memiavl.NewCommitStore(dbDir, *cfg) - if err := cs.Initialize([]string{EVMStoreName}); err != nil { - return nil, fmt.Errorf("memiavl Initialize: %w", err) - } - _, err := cs.LoadVersion(0, false) - if err != nil { - if closeErr := cs.Close(); closeErr != nil { - fmt.Printf("failed to close commit store during error recovery: %v\n", closeErr) - } - return nil, fmt.Errorf("failed to load version: %w", err) - } - return NewMemIAVLWrapper(cs), nil -} - -func newFlatKVCommitStore(ctx context.Context, dbDir string, config *flatkvConfig.Config) (DBWrapper, error) { - if config == nil { - config = flatkvConfig.DefaultConfig() - } - config.DataDir = dbDir - - fmt.Printf("Opening flatKV from directory %s\n", dbDir) - stateWAL, err := flatkv.OpenStateWAL(config) - if err != nil { - return nil, fmt.Errorf("failed to open FlatKV state WAL: %w", err) - } - cs, err := flatkv.NewCommitStore(ctx, config, stateWAL) - if err != nil { - _ = stateWAL.Close() - return nil, fmt.Errorf("failed to create FlatKV commit store: %w", err) - } - if err := cs.LoadLatest(); err != nil { - if closeErr := cs.Close(); closeErr != nil { - fmt.Printf("failed to close commit store during error recovery: %v\n", closeErr) - } - return nil, fmt.Errorf("failed to load version: %w", err) - } - return NewFlatKVWrapper(cs), nil -} - -func newCompositeCommitStore(ctx context.Context, dbDir string, writeMode sctypes.WriteMode) (DBWrapper, error) { - cfg := config.DefaultStateCommitConfig() - cfg.WriteMode = writeMode - cfg.MemIAVLConfig.AsyncCommitBuffer = 10 - cfg.MemIAVLConfig.SnapshotInterval = 100 - - cs, err := composite.NewCompositeCommitStore(ctx, dbDir, cfg) - if err != nil { - return nil, fmt.Errorf("failed to create composite commit store: %w", err) - } - if err := cs.CleanupCrashArtifacts(); err != nil { - return nil, fmt.Errorf("failed to cleanup crash artifacts: %w", err) - } - if err := cs.Initialize([]string{EVMStoreName}); err != nil { - return nil, fmt.Errorf("composite Initialize: %w", err) - } - - if err := cs.LoadLatest(); err != nil { - if closeErr := cs.Close(); closeErr != nil { - fmt.Printf("failed to close commit store during error recovery: %v\n", closeErr) - } - return nil, fmt.Errorf("failed to load version: %w", err) - } - - return NewCompositeWrapper(cs), nil -} - -func openSSComposite(dir string, cfg config.StateStoreConfig) (*ssComposite.CompositeStateStore, error) { - return ssComposite.NewCompositeStateStore(cfg, dir) -} - -func newSSCompositeStateStore(dbDir string, ssConfig *config.StateStoreConfig) (DBWrapper, error) { - if ssConfig == nil { - ssConfig = DefaultBenchStateStoreConfig() - } - fmt.Printf("Opening composite state store from directory %s\n", dbDir) - store, err := openSSComposite(dbDir, *ssConfig) - if err != nil { - return nil, fmt.Errorf("failed to open composite state store: %w", err) - } - return NewStateStoreWrapper(store), nil -} - -func newCombinedCompositeDualSSComposite( - ctx context.Context, - dbDir string, - ssConfig *config.StateStoreConfig, -) (DBWrapper, error) { - if ssConfig == nil { - ssConfig = DefaultBenchStateStoreConfig() - } - - fmt.Printf("Opening CompositeDual (SC) + Composite (SS) from directory %s\n", dbDir) - sc, err := newCompositeCommitStore(ctx, filepath.Join(dbDir, "sc"), sctypes.TestOnlyDualWrite) - if err != nil { - return nil, fmt.Errorf("failed to create SC store: %w", err) - } - ss, err := openSSComposite(filepath.Join(dbDir, "ss"), *ssConfig) - if err != nil { - _ = sc.Close() - return nil, fmt.Errorf("failed to create SS store: %w", err) - } - return NewCombinedWrapper(sc, ss), nil -} - -// backendConfig converts the untyped config NewDBImpl is handed into the one its backend expects. -// -// A nil config is ordinary rather than exceptional: runBenchmark passes nil for every backend, and -// each constructor supplies its own default. So nil is passed straight through, and only a config of -// the wrong type is an error. Asserting without this — dbConfig.(*T) on a nil interface — panics -// before the constructor can apply that default, which is how three bench backends came to crash at -// startup. -func backendConfig[T any](dbType DBType, dbConfig any) (*T, error) { - if dbConfig == nil { - return nil, nil - } - typed, ok := dbConfig.(*T) - if !ok { - var want T - return nil, fmt.Errorf("invalid %s config type %T, want *%T", dbType, dbConfig, want) - } - return typed, nil -} - -// NewDBImpl instantiates a new empty DBWrapper based on the given DBType. -func NewDBImpl(ctx context.Context, dbType DBType, dataDir string, dbConfig any) (DBWrapper, error) { - switch dbType { - case NoOp: - return NewNoOpWrapper(), nil - case MemIAVL: - cfg, err := backendConfig[memiavl.Config](dbType, dbConfig) - if err != nil { - return nil, err - } - return newMemIAVLCommitStore(dataDir, cfg) - case FlatKV: - cfg, err := backendConfig[flatkvConfig.Config](dbType, dbConfig) - if err != nil { - return nil, err - } - return newFlatKVCommitStore(ctx, dataDir, cfg) - case CompositeDual: - return newCompositeCommitStore(ctx, dataDir, sctypes.TestOnlyDualWrite) - case CompositeSplit: - return newCompositeCommitStore(ctx, dataDir, sctypes.EVMMigrated) - case CompositeCosmos: - return newCompositeCommitStore(ctx, dataDir, sctypes.MemiavlOnly) - case SSComposite: - cfg, err := backendConfig[config.StateStoreConfig](dbType, dbConfig) - if err != nil { - return nil, err - } - return newSSCompositeStateStore(dataDir, cfg) - case SSHistoricalOffload: - // No default: the stream needs brokers only the caller knows, so a missing config is - // reported by HistoricalOffloadConfig.Validate rather than invented here. - cfg, err := backendConfig[HistoricalOffloadConfig](dbType, dbConfig) - if err != nil { - return nil, err - } - return newSSHistoricalOffloadStateStore(ctx, dataDir, cfg) - case CompositeDual_SSComposite: - cfg, err := backendConfig[config.StateStoreConfig](dbType, dbConfig) - if err != nil { - return nil, err - } - return newCombinedCompositeDualSSComposite(ctx, dataDir, cfg) - default: - return nil, fmt.Errorf("unsupported DB type: %s", dbType) - } -} diff --git a/sei-db/state_db/bench/wrappers/db_wrapper.go b/sei-db/state_db/bench/wrappers/db_wrapper.go deleted file mode 100644 index 5efe9cf0d7..0000000000 --- a/sei-db/state_db/bench/wrappers/db_wrapper.go +++ /dev/null @@ -1,46 +0,0 @@ -package wrappers - -import ( - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -// This benchmarking utility is capable of benchmarking a DB that implements this interface. -type DBWrapper interface { - // ApplyChangeSets applies a versioned changelog entry. SC-backed wrappers buffer - // entry changesets until Commit, while SS-backed wrappers can use entry.Version - // to persist at the benchmark-assigned version immediately. - ApplyChangeSets(entry *proto.ChangelogEntry) error - - // Read reads the value for the given key. - Read(key []byte) (data []byte, found bool, err error) - - // Commit persists buffered writes and advances the version. - Commit() (int64, error) - - // Close releases any resources held by the DB. - Close() error - - // Version returns the latest committed version. - Version() int64 - - // LoadLatest opens the DB at its latest committed version. Benchmarks only ever run against the tip, so - // there is no way to ask for a historical version. - LoadLatest() error - - // Importer return an importer which load snapshot data into the database - Importer(version int64) (types.Importer, error) - - // Get the phase timer used to measure time spent in various phases of execution. Useful for metrics - // integration with external phases of execution. - // - // If the underlying DB does not support phase timers, return nil. - GetPhaseTimer() *metrics.PhaseTimer - - // RegisterHashListener subscribes listener to the hash of each block this DB commits, one per - // block in block order. It reports false when the DB publishes no block hashes, in which case - // there is nothing for a benchmark to wait on. - RegisterHashListener(listener gigatypes.HashListener) (registered bool, err error) -} diff --git a/sei-db/state_db/bench/wrappers/flatkv_wrapper.go b/sei-db/state_db/bench/wrappers/flatkv_wrapper.go deleted file mode 100644 index 97591cd146..0000000000 --- a/sei-db/state_db/bench/wrappers/flatkv_wrapper.go +++ /dev/null @@ -1,86 +0,0 @@ -package wrappers - -import ( - "fmt" - - "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -var _ DBWrapper = (*flatKVWrapper)(nil) - -// flatKVWrapper wraps a flatkv commit store to implement the DBWrapper interface. -// FlatKV persists exactly one block per Commit, so benchmarks must commit every -// block. Several -// ApplyChangeSets calls may still precede one Commit as long as they all target the -// same height; Commit() consults PendingVersion() to find that height. -type flatKVWrapper struct { - base gigatypes.LiveStateStore -} - -// NewFlatKVWrapper creates a new flatKVWrapper with a given flatkv store. -func NewFlatKVWrapper(store gigatypes.LiveStateStore) DBWrapper { - return &flatKVWrapper{ - base: store, - } -} - -func (f *flatKVWrapper) ApplyChangeSets(entry *proto.ChangelogEntry) error { - version := entry.Version - if version <= 0 { - version = f.nextVersion() - } - return f.base.ApplyChangeSets(version, entry.Changesets) -} - -func (f *flatKVWrapper) Commit() (int64, error) { - version := f.base.PendingVersion() - if version == 0 { - version = f.base.Version() + 1 - } - return f.base.Commit(version) -} - -func (f *flatKVWrapper) LoadLatest() error { - return f.base.LoadLatest() -} - -func (f *flatKVWrapper) Version() int64 { - return f.base.Version() -} - -// nextVersion computes the height for the next ApplyChangeSets call: one past the -// committed version. It deliberately ignores PendingVersion() — a pending block's -// writes may be extended at its own height, never continued at the next one. -func (f *flatKVWrapper) nextVersion() int64 { - return f.base.Version() + 1 -} - -func (f *flatKVWrapper) Importer(version int64) (types.Importer, error) { - return f.base.Importer(version) -} - -func (f *flatKVWrapper) Close() error { - return f.base.Close() -} - -func (f *flatKVWrapper) Read(key []byte) (data []byte, found bool, err error) { - val, ok := f.base.Get(keys.EVMStoreKey, key) - return val, ok, nil -} - -// RegisterHashListener subscribes listener to flatKV's block hashes. The hash the store returns is -// dropped: a benchmark waits on the blocks it is about to commit, not the one already behind it. -func (f *flatKVWrapper) RegisterHashListener(listener gigatypes.HashListener) (bool, error) { - if _, err := f.base.RegisterHashListener(listener); err != nil { - return false, fmt.Errorf("register a hash listener on flatkv: %w", err) - } - return true, nil -} - -func (f *flatKVWrapper) GetPhaseTimer() *metrics.PhaseTimer { - return f.base.GetPhaseTimer() -} diff --git a/sei-db/state_db/bench/wrappers/flatkv_wrapper_test.go b/sei-db/state_db/bench/wrappers/flatkv_wrapper_test.go deleted file mode 100644 index 2e8722eb46..0000000000 --- a/sei-db/state_db/bench/wrappers/flatkv_wrapper_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package wrappers - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/proto" -) - -func flatKVEntry(version int64, value byte) *proto.ChangelogEntry { - return &proto.ChangelogEntry{ - Version: version, - Changesets: []*proto.NamedChangeSet{{ - Name: EVMStoreName, - Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{{Key: []byte("key"), Value: []byte{value}}}}, - }}, - } -} - -// TestFlatKVWrapperCommitsOneBlockPerCommit drives the cryptosim -// Database.FinalizeBlock pattern against a real state -// WAL: each block is applied at Version()+1 and committed immediately. It runs -// several cycles because the WAL only rejects a non-contiguous block number on -// the commit after the first one. -func TestFlatKVWrapperCommitsOneBlockPerCommit(t *testing.T) { - wrapper, err := NewDBImpl(t.Context(), FlatKV, t.TempDir(), nil) - require.NoError(t, err) - defer func() { require.NoError(t, wrapper.Close()) }() - - for block := 1; block <= 5; block++ { - require.NoError(t, - wrapper.ApplyChangeSets(flatKVEntry(wrapper.Version()+1, byte(block))), "block %d", block) - committed, err := wrapper.Commit() - require.NoError(t, err, "block %d", block) - require.Equal(t, int64(block), committed) - require.Equal(t, int64(block), wrapper.Version()) - } -} - -// TestFlatKVWrapperRejectsSecondBlockBeforeCommit pins the barrier against -// batched blocks: FlatKV persists exactly one block per Commit, and the refusal -// lands on the offending ApplyChangeSets rather than on a later Commit. -func TestFlatKVWrapperRejectsSecondBlockBeforeCommit(t *testing.T) { - wrapper, err := NewDBImpl(t.Context(), FlatKV, t.TempDir(), nil) - require.NoError(t, err) - defer func() { require.NoError(t, wrapper.Close()) }() - - require.NoError(t, wrapper.ApplyChangeSets(flatKVEntry(1, 0x01))) - - err = wrapper.ApplyChangeSets(flatKVEntry(2, 0x02)) - require.ErrorContains(t, err, "flatkv: apply version 2 must be committed version 0 plus one") - - // The rejected call left the pending block intact and committable. - committed, err := wrapper.Commit() - require.NoError(t, err) - require.Equal(t, int64(1), committed) -} diff --git a/sei-db/state_db/bench/wrappers/historical_offload_wrapper.go b/sei-db/state_db/bench/wrappers/historical_offload_wrapper.go deleted file mode 100644 index ff50078c46..0000000000 --- a/sei-db/state_db/bench/wrappers/historical_offload_wrapper.go +++ /dev/null @@ -1,187 +0,0 @@ -package wrappers - -import ( - "context" - "fmt" - "io" - "strings" - "sync/atomic" - "time" - - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" - "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/offload" -) - -var _ DBWrapper = (*historicalOffloadWrapper)(nil) - -type HistoricalOffloadConfig struct { - Provider string - Kafka *KafkaHistoricalOffloadConfig -} - -type KafkaHistoricalOffloadConfig struct { - Brokers []string - Topic string - ClientID string - Region string - Async *bool - RequiredAcks string - Compression string - BatchSize int - BatchTimeoutMS int - BatchBytes int - TLSEnabled bool - SASLMechanism string -} - -type historicalOffloadWrapper struct { - stream offload.Stream - version atomic.Int64 -} - -func (c *HistoricalOffloadConfig) Validate() error { - if c == nil { - return fmt.Errorf("historical offload config is required") - } - switch strings.ToLower(c.Provider) { - case "kafka": - if c.Kafka == nil { - return fmt.Errorf("historical offload kafka config is required when provider is kafka") - } - c.Kafka.applyDefaults() - return c.Kafka.validate() - default: - return fmt.Errorf("unsupported historical offload provider %q", c.Provider) - } -} - -func (c *KafkaHistoricalOffloadConfig) applyDefaults() { - if c.ClientID == "" { - c.ClientID = "cryptosim-historical-offload" - } - if c.RequiredAcks == "" { - c.RequiredAcks = "none" - } - if c.Compression == "" { - c.Compression = "snappy" - } - if c.BatchSize == 0 { - c.BatchSize = 1000 - } - if c.BatchTimeoutMS == 0 { - c.BatchTimeoutMS = 50 - } - if c.BatchBytes == 0 { - c.BatchBytes = 4 << 20 - } -} - -func (c *KafkaHistoricalOffloadConfig) validate() error { - cfg := offload.KafkaConfig{ - Brokers: c.Brokers, - Topic: c.Topic, - ClientID: c.ClientID, - Region: c.Region, - Async: c.asyncValue(), - RequiredAcks: c.RequiredAcks, - Compression: c.Compression, - BatchSize: c.BatchSize, - BatchTimeout: time.Duration(c.BatchTimeoutMS) * time.Millisecond, - BatchBytes: c.BatchBytes, - TLSEnabled: c.TLSEnabled, - SASLMechanism: c.SASLMechanism, - } - return cfg.Validate() -} - -func (c *KafkaHistoricalOffloadConfig) asyncValue() bool { - return c.Async == nil || *c.Async -} - -func newHistoricalOffloadStream(cfg *HistoricalOffloadConfig) (offload.Stream, error) { - if err := cfg.Validate(); err != nil { - return nil, err - } - kafkaCfg := *cfg.Kafka - kafkaCfg.applyDefaults() - return offload.NewKafkaStream(offload.KafkaConfig{ - Brokers: append([]string(nil), kafkaCfg.Brokers...), - Topic: kafkaCfg.Topic, - ClientID: kafkaCfg.ClientID, - Region: kafkaCfg.Region, - Async: kafkaCfg.asyncValue(), - RequiredAcks: kafkaCfg.RequiredAcks, - Compression: kafkaCfg.Compression, - BatchSize: kafkaCfg.BatchSize, - BatchTimeout: time.Duration(kafkaCfg.BatchTimeoutMS) * time.Millisecond, - BatchBytes: kafkaCfg.BatchBytes, - TLSEnabled: kafkaCfg.TLSEnabled, - SASLMechanism: kafkaCfg.SASLMechanism, - }) -} - -func newSSHistoricalOffloadStateStore(_ context.Context, dbDir string, cfg *HistoricalOffloadConfig) (DBWrapper, error) { - fmt.Printf("Opening historical offload stream from directory %s\n", dbDir) - stream, err := newHistoricalOffloadStream(cfg) - if err != nil { - return nil, fmt.Errorf("failed to create historical offload stream: %w", err) - } - return NewHistoricalOffloadWrapper(stream), nil -} - -func NewHistoricalOffloadWrapper(stream offload.Stream) DBWrapper { - return &historicalOffloadWrapper{stream: stream} -} - -func (h *historicalOffloadWrapper) ApplyChangeSets(entry *proto.ChangelogEntry) error { - ack, err := h.stream.Publish(context.Background(), entry) - if err != nil { - return err - } - if !ack.Accepted { - return fmt.Errorf("historical offload publish was not acknowledged at version %d", entry.Version) - } - h.version.Store(entry.Version) - return nil -} - -func (h *historicalOffloadWrapper) Read(_ []byte) (data []byte, found bool, err error) { - return nil, false, nil -} - -func (h *historicalOffloadWrapper) Commit() (int64, error) { - return h.version.Load(), nil -} - -func (h *historicalOffloadWrapper) Close() error { - var streamErr error - if closer, ok := h.stream.(io.Closer); ok { - streamErr = closer.Close() - } - return streamErr -} - -func (h *historicalOffloadWrapper) Version() int64 { - return h.version.Load() -} - -func (h *historicalOffloadWrapper) LoadLatest() error { - return nil -} - -func (h *historicalOffloadWrapper) Importer(_ int64) (scTypes.Importer, error) { - return nil, fmt.Errorf("import not supported for historical offload wrapper") -} - -// RegisterHashListener reports that this DB publishes no block hashes. An offload stream computes -// none. -func (h *historicalOffloadWrapper) RegisterHashListener(_ gigatypes.HashListener) (bool, error) { - return false, nil -} - -func (h *historicalOffloadWrapper) GetPhaseTimer() *metrics.PhaseTimer { - return nil -} diff --git a/sei-db/state_db/bench/wrappers/historical_offload_wrapper_test.go b/sei-db/state_db/bench/wrappers/historical_offload_wrapper_test.go deleted file mode 100644 index d4a185dd57..0000000000 --- a/sei-db/state_db/bench/wrappers/historical_offload_wrapper_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package wrappers - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/offload" -) - -type mockOffloadStream struct { - closeCalls int - calls int - entries []*proto.ChangelogEntry -} - -func (m *mockOffloadStream) Publish(_ context.Context, entry *proto.ChangelogEntry) (offload.Ack, error) { - m.calls++ - m.entries = append(m.entries, entry) - return offload.Ack{Accepted: true}, nil -} - -func (m *mockOffloadStream) Close() error { - m.closeCalls++ - return nil -} - -func TestHistoricalOffloadWrapperPublishesWithoutLocalWrites(t *testing.T) { - stream := &mockOffloadStream{} - wrapper := NewHistoricalOffloadWrapper(stream) - - entry := &proto.ChangelogEntry{ - Version: 5, - Changesets: []*proto.NamedChangeSet{{Name: EVMStoreName}}, - } - - err := wrapper.ApplyChangeSets(entry) - require.NoError(t, err) - require.Equal(t, 1, stream.calls) - require.Len(t, stream.entries, 1) - require.Equal(t, entry.Version, stream.entries[0].Version) - require.Equal(t, entry.Changesets, stream.entries[0].Changesets) - require.Equal(t, int64(5), wrapper.Version()) - - data, found, err := wrapper.Read([]byte("ignored")) - require.NoError(t, err) - require.Nil(t, data) - require.False(t, found) - - version, err := wrapper.Commit() - require.NoError(t, err) - require.Equal(t, int64(5), version) - - require.NoError(t, wrapper.Close()) - require.Equal(t, 1, stream.closeCalls) -} - -func TestHistoricalOffloadWrapperImporterUnsupported(t *testing.T) { - wrapper := NewHistoricalOffloadWrapper(&mockOffloadStream{}) - - importer, err := wrapper.Importer(1) - require.Nil(t, importer) - require.Error(t, err) -} - -func TestHistoricalOffloadWrapperGetPhaseTimerIsNil(t *testing.T) { - wrapper := NewHistoricalOffloadWrapper(&mockOffloadStream{}) - require.Nil(t, wrapper.GetPhaseTimer()) -} - -func TestHistoricalOffloadConfigValidateRequiresKafkaConfig(t *testing.T) { - cfg := &HistoricalOffloadConfig{Provider: "kafka"} - err := cfg.Validate() - require.ErrorContains(t, err, "historical offload kafka config is required") -} - -var _ DBWrapper = NewHistoricalOffloadWrapper(&mockOffloadStream{}) -var _ offload.Stream = (*mockOffloadStream)(nil) diff --git a/sei-db/state_db/bench/wrappers/memiavl_wrapper.go b/sei-db/state_db/bench/wrappers/memiavl_wrapper.go deleted file mode 100644 index 8b43849b71..0000000000 --- a/sei-db/state_db/bench/wrappers/memiavl_wrapper.go +++ /dev/null @@ -1,71 +0,0 @@ -package wrappers - -import ( - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -var _ DBWrapper = (*memIAVLWrapper)(nil) - -// A light wrapper around a memiavl commit store to implement the DBWrapper interface. -type memIAVLWrapper struct { - base *memiavl.CommitStore -} - -// NewMemIAVLWrapper creates a new memIAVLWrapper with a given memiavl commit store. -func NewMemIAVLWrapper(commitStore *memiavl.CommitStore) DBWrapper { - return &memIAVLWrapper{ - base: commitStore, - } -} - -func (m *memIAVLWrapper) Commit() (int64, error) { - // The benchmark wrapper interface carries no height, so the next one is derived here. That is - // sound only because nothing in the benchmark path takes a block's hash before committing it. - return m.base.Commit(m.base.Version() + 1) -} - -func (m *memIAVLWrapper) LoadLatest() error { - // memiavl's Committer signature is pinned; (0, false) is its load-latest-writable path. - _, err := m.base.LoadVersion(0, false) - return err -} - -func (m *memIAVLWrapper) Version() int64 { - return m.base.Version() -} - -func (m *memIAVLWrapper) ApplyChangeSets(entry *proto.ChangelogEntry) error { - return m.base.ApplyChangeSets(entry.Changesets) -} - -func (m *memIAVLWrapper) Importer(version int64) (types.Importer, error) { - // Close DB first to release lock - if err := m.Close(); err != nil { - return nil, err - } - return m.base.Importer(version) -} - -func (m *memIAVLWrapper) Close() error { - return m.base.Close() -} - -func (m *memIAVLWrapper) Read(key []byte) (data []byte, found bool, err error) { - store := m.base.GetChildStoreByName(EVMStoreName) - data = store.Get(key) - return data, data != nil, nil -} - -// RegisterHashListener reports that this DB publishes no block hashes. memIAVL's root is a -// Cosmos-layer aggregation over its per-module hashes rather than a hash the store hands out. -func (m *memIAVLWrapper) RegisterHashListener(_ gigatypes.HashListener) (bool, error) { - return false, nil -} - -func (m *memIAVLWrapper) GetPhaseTimer() *metrics.PhaseTimer { - return nil -} diff --git a/sei-db/state_db/bench/wrappers/noop_wrapper.go b/sei-db/state_db/bench/wrappers/noop_wrapper.go deleted file mode 100644 index b01c54a591..0000000000 --- a/sei-db/state_db/bench/wrappers/noop_wrapper.go +++ /dev/null @@ -1,62 +0,0 @@ -package wrappers - -import ( - "fmt" - "sync/atomic" - - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -var _ DBWrapper = (*noOpWrapper)(nil) - -// noOpWrapper lets the benchmark measure its own overhead without DB read/write cost. -type noOpWrapper struct { - version atomic.Int64 -} - -func NewNoOpWrapper() DBWrapper { - return &noOpWrapper{} -} - -func (n *noOpWrapper) ApplyChangeSets(entry *proto.ChangelogEntry) error { - n.version.Store(entry.Version) - return nil -} - -func (n *noOpWrapper) Read(_ []byte) ([]byte, bool, error) { - return nil, false, nil -} - -func (n *noOpWrapper) Commit() (int64, error) { - return n.version.Load(), nil -} - -func (n *noOpWrapper) Close() error { - return nil -} - -func (n *noOpWrapper) Version() int64 { - return n.version.Load() -} - -// LoadLatest is a no-op: the tracked version already is this store's latest, since only ApplyChangeSets moves it. -func (n *noOpWrapper) LoadLatest() error { - return nil -} - -func (n *noOpWrapper) Importer(_ int64) (scTypes.Importer, error) { - return nil, fmt.Errorf("import not supported for no-op wrapper") -} - -// RegisterHashListener reports that this DB publishes no block hashes. A store that persists nothing -// hashes nothing. -func (n *noOpWrapper) RegisterHashListener(_ gigatypes.HashListener) (bool, error) { - return false, nil -} - -func (n *noOpWrapper) GetPhaseTimer() *metrics.PhaseTimer { - return nil -} diff --git a/sei-db/state_db/bench/wrappers/state_store_wrapper.go b/sei-db/state_db/bench/wrappers/state_store_wrapper.go deleted file mode 100644 index c6f8411bbb..0000000000 --- a/sei-db/state_db/bench/wrappers/state_store_wrapper.go +++ /dev/null @@ -1,78 +0,0 @@ -package wrappers - -import ( - "fmt" - "sync/atomic" - - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - dbTypes "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -var _ DBWrapper = (*stateStoreWrapper)(nil) - -// stateStoreWrapper adapts a versioned StateStore (SS layer) to the DBWrapper -// interface used by the cryptosim benchmark. Each ApplyChangeSets call maps to -// a single ApplyChangesetAsync at the benchmark-provided version. The SS layer -// persists on every apply, so Commit is a no-op. -type stateStoreWrapper struct { - base dbTypes.StateStore - version atomic.Int64 -} - -func NewStateStoreWrapper(store dbTypes.StateStore) DBWrapper { - w := &stateStoreWrapper{ - base: store, - } - w.version.Store(store.GetLatestVersion()) - return w -} - -func (s *stateStoreWrapper) ApplyChangeSets(entry *proto.ChangelogEntry) error { - s.version.Store(entry.Version) - return s.base.ApplyChangesetAsync(entry.Version, entry.Changesets) -} - -func (s *stateStoreWrapper) Read(key []byte) (data []byte, found bool, err error) { - version := s.version.Load() - if version == 0 { - return nil, false, nil - } - val, err := s.base.Get(EVMStoreName, version, key) - if err != nil { - return nil, false, err - } - return val, val != nil, nil -} - -func (s *stateStoreWrapper) Commit() (int64, error) { - return s.version.Load(), nil -} - -func (s *stateStoreWrapper) Close() error { - return s.base.Close() -} - -func (s *stateStoreWrapper) Version() int64 { - return s.version.Load() -} - -func (s *stateStoreWrapper) LoadLatest() error { - return nil -} - -func (s *stateStoreWrapper) Importer(_ int64) (scTypes.Importer, error) { - return nil, fmt.Errorf("import not supported for state store wrapper") -} - -// RegisterHashListener reports that this DB publishes no block hashes. The historical state store -// computes none. -func (s *stateStoreWrapper) RegisterHashListener(_ gigatypes.HashListener) (bool, error) { - return false, nil -} - -func (s *stateStoreWrapper) GetPhaseTimer() *metrics.PhaseTimer { - return nil -} diff --git a/sei-db/state_db/bench/wrappers/state_store_wrapper_test.go b/sei-db/state_db/bench/wrappers/state_store_wrapper_test.go deleted file mode 100644 index a2596131d6..0000000000 --- a/sei-db/state_db/bench/wrappers/state_store_wrapper_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package wrappers - -import ( - "bytes" - "testing" - - commonevm "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/stretchr/testify/require" -) - -func TestStateStoreWrapperApplyChangesetsAsyncPreservesHistoricalState(t *testing.T) { - dataDir := t.TempDir() - - store, err := openSSComposite(dataDir, *DefaultBenchStateStoreConfig()) - require.NoError(t, err) - - wrapper := NewStateStoreWrapper(store) - - keyV1AndV2 := commonevm.BuildEVMKey(commonevm.EVMKeyNonce, bytes.Repeat([]byte{0x11}, 20)) - keyV2Only := commonevm.BuildEVMKey(commonevm.EVMKeyCodeHash, bytes.Repeat([]byte{0x22}, 20)) - - require.NoError(t, wrapper.ApplyChangeSets(changelogEntry(1, []*proto.NamedChangeSet{ - { - Name: EVMStoreName, - Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ - {Key: keyV1AndV2, Value: []byte("value-v1")}, - }}, - }, - }))) - - version, err := wrapper.Commit() - require.NoError(t, err) - require.Equal(t, int64(1), version) - - require.NoError(t, wrapper.ApplyChangeSets(changelogEntry(2, []*proto.NamedChangeSet{ - { - Name: EVMStoreName, - Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ - {Key: keyV1AndV2, Value: []byte("value-v2")}, - {Key: keyV2Only, Value: []byte("value-v2-only")}, - }}, - }, - }))) - - version, err = wrapper.Commit() - require.NoError(t, err) - require.Equal(t, int64(2), version) - - require.NoError(t, wrapper.Close()) - - reopened, err := openSSComposite(dataDir, *DefaultBenchStateStoreConfig()) - require.NoError(t, err) - t.Cleanup(func() { - require.NoError(t, reopened.Close()) - }) - - historical, err := reopened.Get(EVMStoreName, 1, keyV1AndV2) - require.NoError(t, err) - require.Equal(t, []byte("value-v1"), historical) - - missingAtV1, err := reopened.Get(EVMStoreName, 1, keyV2Only) - require.NoError(t, err) - require.Nil(t, missingAtV1) - - latest, err := reopened.Get(EVMStoreName, 2, keyV1AndV2) - require.NoError(t, err) - require.Equal(t, []byte("value-v2"), latest) - - latestOnly, err := reopened.Get(EVMStoreName, 2, keyV2Only) - require.NoError(t, err) - require.Equal(t, []byte("value-v2-only"), latestOnly) -} - -func changelogEntry(version int64, changesets []*proto.NamedChangeSet) *proto.ChangelogEntry { - return &proto.ChangelogEntry{ - Version: version, - Changesets: changesets, - } -} diff --git a/sei-db/state_db/bench/wrappers/wrappers_test.go b/sei-db/state_db/bench/wrappers/wrappers_test.go deleted file mode 100644 index 19a9ecda59..0000000000 --- a/sei-db/state_db/bench/wrappers/wrappers_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package wrappers - -import ( - "testing" - - "github.com/stretchr/testify/require" - dbm "github.com/tendermint/tm-db" - - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - dbTypes "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" - "github.com/sei-protocol/sei-chain/sei-db/proto" - gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" - scTypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" -) - -type mockDBWrapper struct { - appliedEntries []*proto.ChangelogEntry - commitVersion int64 - applyErr error - commitErr error -} - -func (m *mockDBWrapper) ApplyChangeSets(entry *proto.ChangelogEntry) error { - m.appliedEntries = append(m.appliedEntries, entry) - return m.applyErr -} - -func (m *mockDBWrapper) Read(_ []byte) ([]byte, bool, error) { - return nil, false, nil -} - -func (m *mockDBWrapper) Commit() (int64, error) { - return m.commitVersion, m.commitErr -} - -func (m *mockDBWrapper) Close() error { - return nil -} - -func (m *mockDBWrapper) Version() int64 { - return m.commitVersion -} - -func (m *mockDBWrapper) LoadLatest() error { - return nil -} - -func (m *mockDBWrapper) Importer(_ int64) (scTypes.Importer, error) { - return nil, nil -} - -func (m *mockDBWrapper) GetPhaseTimer() *metrics.PhaseTimer { - return nil -} - -type mockStateStore struct { - latestVersion int64 - asyncVersion int64 - asyncChanges []*proto.NamedChangeSet - asyncCalls int - syncVersion int64 - syncChanges []*proto.NamedChangeSet - syncCalls int -} - -func (m *mockStateStore) Get(_ string, _ int64, _ []byte) ([]byte, error) { - return nil, nil -} - -func (m *mockStateStore) Has(_ string, _ int64, _ []byte) (bool, error) { - return false, nil -} - -func (m *mockStateStore) Iterator(_ string, _ int64, _, _ []byte) (dbm.Iterator, error) { - return nil, nil -} - -func (m *mockStateStore) ReverseIterator(_ string, _ int64, _, _ []byte) (dbm.Iterator, error) { - return nil, nil -} - -func (m *mockStateStore) RawIterate(_ string, _ func([]byte, []byte, int64) bool) (bool, error) { - return false, nil -} - -func (m *mockStateStore) GetLatestVersion() int64 { - return m.latestVersion -} - -func (m *mockStateStore) SetLatestVersion(version int64) error { - m.latestVersion = version - return nil -} - -func (m *mockStateStore) GetEarliestVersion() int64 { - return 0 -} - -func (m *mockStateStore) SetEarliestVersion(_ int64, _ bool) error { - return nil -} - -func (m *mockStateStore) ApplyChangesetSync(version int64, changesets []*proto.NamedChangeSet) error { - m.syncCalls++ - m.syncVersion = version - m.syncChanges = changesets - m.latestVersion = version - return nil -} - -func (m *mockStateStore) ApplyChangesetAsync(version int64, changesets []*proto.NamedChangeSet) error { - m.asyncCalls++ - m.asyncVersion = version - m.asyncChanges = changesets - m.latestVersion = version - return nil -} - -func (m *mockStateStore) Prune(_ int64) error { - return nil -} - -func (m *mockStateStore) Import(_ int64, _ <-chan dbTypes.SnapshotNode) error { - return nil -} - -func (m *mockStateStore) Close() error { - return nil -} - -func (m *mockDBWrapper) RegisterHashListener(_ gigatypes.HashListener) (bool, error) { - return false, nil -} - -func TestCombinedWrapperApplyChangeSetsUsesAsyncSS(t *testing.T) { - sc := &mockDBWrapper{commitVersion: 7} - ss := &mockStateStore{latestVersion: 7} - wrapper := NewCombinedWrapper(sc, ss) - - changesets := []*proto.NamedChangeSet{{Name: EVMStoreName}} - entry := &proto.ChangelogEntry{ - Version: 8, - Changesets: changesets, - } - - err := wrapper.ApplyChangeSets(entry) - require.NoError(t, err) - require.Len(t, sc.appliedEntries, 1) - require.Same(t, entry, sc.appliedEntries[0]) - require.Equal(t, 1, ss.asyncCalls) - require.Equal(t, entry.Version, ss.asyncVersion) - require.Equal(t, changesets, ss.asyncChanges) - require.Zero(t, ss.syncCalls) - require.Equal(t, entry.Version, wrapper.Version()) -} - -func TestStateStoreWrapperApplyChangeSetsUsesEntryVersion(t *testing.T) { - store := &mockStateStore{latestVersion: 11} - wrapper := NewStateStoreWrapper(store) - - changesets := []*proto.NamedChangeSet{{Name: EVMStoreName}} - entry := &proto.ChangelogEntry{ - Version: 15, - Changesets: changesets, - } - - err := wrapper.ApplyChangeSets(entry) - require.NoError(t, err) - require.Equal(t, 1, store.asyncCalls) - require.Equal(t, entry.Version, store.asyncVersion) - require.Equal(t, changesets, store.asyncChanges) - require.Zero(t, store.syncCalls) - require.Equal(t, entry.Version, wrapper.Version()) - version, err := wrapper.Commit() - require.NoError(t, err) - require.Equal(t, entry.Version, version) -} - -func TestNoOpWrapperTracksVersionWithoutReadsOrWrites(t *testing.T) { - wrapper := NewNoOpWrapper() - - entry := &proto.ChangelogEntry{ - Version: 9, - Changesets: []*proto.NamedChangeSet{{Name: EVMStoreName}}, - } - - require.NoError(t, wrapper.ApplyChangeSets(entry)) - require.Equal(t, int64(9), wrapper.Version()) - - data, found, err := wrapper.Read([]byte("key")) - require.NoError(t, err) - require.Nil(t, data) - require.False(t, found) - - version, err := wrapper.Commit() - require.NoError(t, err) - require.Equal(t, int64(9), version) -} - -// runBenchmark passes nil for every backend, so each of these opened with a panic before their -// config was made optional. -func TestNewDBImplUsesDefaultConfigWhenNil(t *testing.T) { - for _, dbType := range []DBType{MemIAVL, FlatKV, SSComposite, CompositeDual_SSComposite} { - t.Run(string(dbType), func(t *testing.T) { - wrapper, err := NewDBImpl(t.Context(), dbType, t.TempDir(), nil) - require.NoError(t, err) - require.NoError(t, wrapper.Close()) - }) - } -} - -// The offload stream needs brokers that only the caller knows, so this backend has no default to -// fall back on and must say so rather than panic. -func TestNewDBImplSSHistoricalOffloadReportsMissingConfig(t *testing.T) { - wrapper, err := NewDBImpl(t.Context(), SSHistoricalOffload, t.TempDir(), nil) - require.Error(t, err) - require.Nil(t, wrapper) - require.ErrorContains(t, err, "historical offload config is required") -} - -func TestNewDBImplRejectsInvalidConfigType(t *testing.T) { - for _, dbType := range []DBType{MemIAVL, FlatKV, SSComposite, SSHistoricalOffload, CompositeDual_SSComposite} { - t.Run(string(dbType), func(t *testing.T) { - wrapper, err := NewDBImpl(t.Context(), dbType, t.TempDir(), "invalid") - require.Error(t, err) - require.Nil(t, wrapper) - require.ErrorContains(t, err, "invalid "+string(dbType)+" config type string") - }) - } -} diff --git a/sei-db/state_db/bench/writeset.go b/sei-db/state_db/bench/writeset.go deleted file mode 100644 index ba0a53f34e..0000000000 --- a/sei-db/state_db/bench/writeset.go +++ /dev/null @@ -1,280 +0,0 @@ -package bench - -import ( - "context" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "strings" - "time" - - "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" - flatkvConfig "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" -) - -// This file implements the write-set replay adapter: it parses a captured -// write-set file (typically derived from a debug_traceCall prestateTracer -// diff) into per-block changesets and replays them through a storage-engine -// wrapper, timing ApplyChangeSets and Commit separately. -// -// v1 scope: EVM-module keys only (storage/code/nonce/codehash/raw). Bank -// (balance) changesets require the bank store layout and are deliberately -// out of scope; see the gas-repricing storage doc. - -// WriteSetEntryKind enumerates the supported key kinds in a write-set file. -const ( - WriteKindStorage = "storage" // requires address + slot - WriteKindCode = "code" // requires address - WriteKindNonce = "nonce" // requires address - WriteKindCodeHash = "codehash" // requires address - WriteKindRaw = "raw" // requires key (full store key, hex) -) - -// WriteSetEntry is one captured write. Hex fields accept an optional 0x prefix. -type WriteSetEntry struct { - // Kind is one of the WriteKind* constants. - Kind string `json:"kind"` - // Address is the 20-byte EVM address (storage/code/nonce/codehash kinds). - Address string `json:"address,omitempty"` - // Slot is the 32-byte storage slot (storage kind only). - Slot string `json:"slot,omitempty"` - // Key is the full raw store key (raw kind only). - Key string `json:"key,omitempty"` - // Value is the new value. Ignored when Delete is true. - Value string `json:"value,omitempty"` - // Delete marks a deletion instead of a write. - Delete bool `json:"delete,omitempty"` -} - -// WriteSetBlock groups the writes that commit together as one block. -type WriteSetBlock struct { - Writes []WriteSetEntry `json:"writes"` -} - -// WriteSet is the top-level write-set file format. -type WriteSet struct { - // Module is the store the writes belong to. Only "evm" is supported in v1; - // empty defaults to "evm". - Module string `json:"module,omitempty"` - Blocks []WriteSetBlock `json:"blocks"` -} - -// LoadWriteSet reads and validates a write-set file. -func LoadWriteSet(path string) (*WriteSet, error) { - data, err := os.ReadFile(path) //nolint:gosec // benchmark input path supplied by the operator - if err != nil { - return nil, fmt.Errorf("read write-set file: %w", err) - } - var ws WriteSet - if err := json.Unmarshal(data, &ws); err != nil { - return nil, fmt.Errorf("parse write-set file: %w", err) - } - if err := ws.Validate(); err != nil { - return nil, err - } - return &ws, nil -} - -// Validate checks module support and per-entry field consistency. -func (ws *WriteSet) Validate() error { - if ws.Module != "" && ws.Module != keys.EVMStoreKey { - return fmt.Errorf("unsupported module %q: v1 replay supports only %q", ws.Module, keys.EVMStoreKey) - } - if len(ws.Blocks) == 0 { - return fmt.Errorf("write set has no blocks") - } - for bi, block := range ws.Blocks { - for wi, w := range block.Writes { - if _, err := buildEntryKey(w); err != nil { - return fmt.Errorf("block %d write %d: %w", bi, wi, err) - } - if !w.Delete { - if _, err := decodeEntryValue(w); err != nil { - return fmt.Errorf("block %d write %d: %w", bi, wi, err) - } - } - } - } - return nil -} - -// TotalKeys returns the total number of writes across all blocks. -func (ws *WriteSet) TotalKeys() int { - total := 0 - for _, b := range ws.Blocks { - total += len(b.Writes) - } - return total -} - -// BlockChangesets converts one block into the NamedChangeSet slice consumed by -// DBWrapper.ApplyChangeSets. -func (ws *WriteSet) BlockChangesets(blockIdx int) ([]*proto.NamedChangeSet, error) { - block := ws.Blocks[blockIdx] - pairs := make([]*proto.KVPair, 0, len(block.Writes)) - for wi, w := range block.Writes { - key, err := buildEntryKey(w) - if err != nil { - return nil, fmt.Errorf("block %d write %d: %w", blockIdx, wi, err) - } - pair := &proto.KVPair{Key: key, Delete: w.Delete} - if !w.Delete { - value, err := decodeEntryValue(w) - if err != nil { - return nil, fmt.Errorf("block %d write %d: %w", blockIdx, wi, err) - } - pair.Value = value - } - pairs = append(pairs, pair) - } - return []*proto.NamedChangeSet{{ - Name: keys.EVMStoreKey, - Changeset: proto.ChangeSet{Pairs: pairs}, - }}, nil -} - -// buildEntryKey builds the raw store key for a write-set entry. -func buildEntryKey(w WriteSetEntry) ([]byte, error) { - switch w.Kind { - case WriteKindStorage: - addr, err := decodeHexField("address", w.Address, keys.AddressLen) - if err != nil { - return nil, err - } - slot, err := decodeHexField("slot", w.Slot, 32) - if err != nil { - return nil, err - } - return keys.BuildEVMKey(keys.EVMKeyStorage, append(addr, slot...)), nil - case WriteKindCode, WriteKindNonce, WriteKindCodeHash: - addr, err := decodeHexField("address", w.Address, keys.AddressLen) - if err != nil { - return nil, err - } - kind := map[string]keys.EVMKeyKind{ - WriteKindCode: keys.EVMKeyCode, - WriteKindNonce: keys.EVMKeyNonce, - WriteKindCodeHash: keys.EVMKeyCodeHash, - }[w.Kind] - return keys.BuildEVMKey(kind, addr), nil - case WriteKindRaw: - key, err := decodeHexField("key", w.Key, 0) - if err != nil { - return nil, err - } - if len(key) == 0 { - return nil, fmt.Errorf("raw write has empty key") - } - return key, nil - default: - return nil, fmt.Errorf("unknown write kind %q", w.Kind) - } -} - -// valueLenForKind returns the exact byte length a kind's value must have, or 0 -// when the length is unconstrained (code and raw). The fixed widths mirror the -// FlatKV apply path (vtype.ParseNonce/ParseCodeHash/ParseStorageValue), so a -// wrong-length value in a hand-authored write-set file is rejected up front by -// Validate rather than only failing later inside ApplyChangeSets on FlatKV -// (memiavl stores raw bytes and would silently accept it, breaking the -// same-write-set-across-backends premise of the benchmark). -// -// Raw entries are the escape hatch this check cannot cover: their keys are -// opaque here, so hand-authored raw entries must target key families FlatKV -// does not width-check (legacy prefixes such as 0x09 codesize). A raw key -// aliasing an optimized family (e.g. 0x0a nonce) with a wrong-width value -// passes Validate, replays on memiavl, and hard-fails on FlatKV. -func valueLenForKind(kind string) int { - switch kind { - case WriteKindNonce: - return 8 - case WriteKindStorage, WriteKindCodeHash: - return 32 - default: // WriteKindCode, WriteKindRaw: unconstrained - return 0 - } -} - -// decodeEntryValue decodes a write entry's value, enforcing the fixed width its -// kind requires (see valueLenForKind). -func decodeEntryValue(w WriteSetEntry) ([]byte, error) { - return decodeHexField("value", w.Value, valueLenForKind(w.Kind)) -} - -// decodeHexField decodes a hex field, tolerating a 0x prefix. wantLen of 0 -// disables the length check. An empty string decodes to nil. -func decodeHexField(name, value string, wantLen int) ([]byte, error) { - trimmed := strings.TrimPrefix(value, "0x") - decoded, err := hex.DecodeString(trimmed) - if err != nil { - return nil, fmt.Errorf("field %s: invalid hex %q: %w", name, value, err) - } - if wantLen > 0 && len(decoded) != wantLen { - return nil, fmt.Errorf("field %s: expected %d bytes, got %d", name, wantLen, len(decoded)) - } - return decoded, nil -} - -// OpenReplayWrapper opens a fresh DBWrapper for a replay run, supplying the -// explicit default config that the FlatKV wrapper factory requires. -// -// memiavl is opened with AsyncCommitBuffer=0 (synchronous WAL write) rather -// than the shared bench default of 10. With the async buffer, memiavl's -// Commit() returns once the WAL entry is enqueued, while FlatKV's Commit() -// waits for its WAL write — the reported commit_ns/key would compare enqueue -// latency against write latency. Neither backend fsyncs, so with a -// synchronous WAL write on both sides the durability semantics match. -func OpenReplayWrapper(ctx context.Context, backend wrappers.DBType, dbDir string) (wrappers.DBWrapper, error) { - var dbConfig any - switch backend { - case wrappers.FlatKV: - dbConfig = flatkvConfig.DefaultConfig() - case wrappers.MemIAVL: - cfg := wrappers.DefaultBenchMemIAVLConfig() - cfg.AsyncCommitBuffer = 0 - dbConfig = &cfg - } - return wrappers.NewDBImpl(ctx, backend, dbDir, dbConfig) -} - -// ReplayResult reports a replay run with apply and commit timed separately. -type ReplayResult struct { - Blocks int - Keys int - ApplyDuration time.Duration - CommitDuration time.Duration -} - -// ReplayWriteSet replays the write set through the wrapper, one block per -// version, timing ApplyChangeSets and Commit separately. The wrapper must be -// freshly opened (or snapshot-loaded); replay starts at wrapper.Version()+1. -func ReplayWriteSet(wrapper wrappers.DBWrapper, ws *WriteSet) (ReplayResult, error) { - result := ReplayResult{Blocks: len(ws.Blocks), Keys: ws.TotalKeys()} - baseVersion := wrapper.Version() - for i := range ws.Blocks { - changesets, err := ws.BlockChangesets(i) - if err != nil { - return result, err - } - entry := &proto.ChangelogEntry{ - Version: baseVersion + int64(i) + 1, - Changesets: changesets, - } - - applyStart := time.Now() - if err := wrapper.ApplyChangeSets(entry); err != nil { - return result, fmt.Errorf("apply block %d: %w", i, err) - } - result.ApplyDuration += time.Since(applyStart) - - commitStart := time.Now() - if _, err := wrapper.Commit(); err != nil { - return result, fmt.Errorf("commit block %d: %w", i, err) - } - result.CommitDuration += time.Since(commitStart) - } - return result, nil -} diff --git a/sei-db/state_db/bench/writeset_bench_test.go b/sei-db/state_db/bench/writeset_bench_test.go deleted file mode 100644 index 643fe4be5f..0000000000 --- a/sei-db/state_db/bench/writeset_bench_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package bench - -import ( - "fmt" - "os" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" -) - -// BenchmarkWriteSetReplay replays a captured write-set file against both -// storage backends, timing ApplyChangeSets and Commit separately. -// -// Inputs (environment variables): -// -// TRACE_PATH path to a prestateTracer diffMode JSON file (the raw -// {"pre","post"} result or a whole JSON-RPC response), -// converted on the fly; takes precedence over WRITESET_PATH. -// WRITESET_PATH path to a write-set JSON file (see writeset.go). Raw -// tracer output is not accepted here; use TRACE_PATH. -// SNAPSHOT_PATH optional state sync snapshot chunks directory imported -// before the timed region (same as the other benchmarks). -// -// Example: -// -// TRACE_PATH=/tmp/sstore_trace.json go test ./sei-db/state_db/bench \ -// -run '^$' -bench '^BenchmarkWriteSetReplay$' -benchtime=5x -func BenchmarkWriteSetReplay(b *testing.B) { - ws := loadBenchWriteSet(b) - - for _, backend := range []wrappers.DBType{wrappers.MemIAVL, wrappers.FlatKV} { - b.Run(string(backend), func(b *testing.B) { - // Accumulate across iterations and report once: b.ReportMetric keeps - // only the last value for a given unit, so reporting inside the loop - // would surface a single iteration instead of an average over b.N. - var totalApply, totalCommit time.Duration - var totalKeys int - for range b.N { - result := runWriteSetReplay(b, backend, ws) - totalApply += result.ApplyDuration - totalCommit += result.CommitDuration - totalKeys += result.Keys - } - if totalKeys == 0 { - return - } - keys := float64(totalKeys) - b.ReportMetric(totalApply.Seconds()/keys*1e9, "apply_ns/key") - b.ReportMetric(totalCommit.Seconds()/keys*1e9, "commit_ns/key") - }) - } -} - -func loadBenchWriteSet(b *testing.B) *WriteSet { - if tracePath := os.Getenv("TRACE_PATH"); tracePath != "" { - converted, err := ConvertPrestateDiffFile(tracePath) - require.NoError(b, err) - if converted.SkippedBalanceChanges > 0 { - b.Logf("skipped %d balance change(s): bank-module replay is out of scope", - converted.SkippedBalanceChanges) - } - return converted.WriteSet - } - if wsPath := os.Getenv("WRITESET_PATH"); wsPath != "" { - ws, err := LoadWriteSet(wsPath) - require.NoError(b, err) - return ws - } - b.Skip("set TRACE_PATH or WRITESET_PATH to run the write-set replay benchmark") - return nil -} - -func runWriteSetReplay(b *testing.B, backend wrappers.DBType, ws *WriteSet) ReplayResult { - b.StopTimer() - dbDir := b.TempDir() - wrapper, err := OpenReplayWrapper(b.Context(), backend, dbDir) - require.NoError(b, err) - defer func() { - require.NoError(b, wrapper.Close()) - }() - - if snapshotPath := os.Getenv("SNAPSHOT_PATH"); snapshotPath != "" { - snapshotHeight, err := parseSnapshotHeight(snapshotPath) - require.NoError(b, err) - importer, err := wrapper.Importer(snapshotHeight) - require.NoError(b, err) - require.NoError(b, importSnapshot(snapshotPath, importer)) - require.NoError(b, wrapper.LoadLatest()) - } - - b.StartTimer() - result, err := ReplayWriteSet(wrapper, ws) - b.StopTimer() - require.NoError(b, err) - - fmt.Printf("[Replay %s] blocks=%d keys=%d apply=%s commit=%s\n", - backend, result.Blocks, result.Keys, result.ApplyDuration, result.CommitDuration) - return result -} diff --git a/sei-db/state_db/bench/writeset_convert.go b/sei-db/state_db/bench/writeset_convert.go deleted file mode 100644 index 1323b5e730..0000000000 --- a/sei-db/state_db/bench/writeset_convert.go +++ /dev/null @@ -1,262 +0,0 @@ -package bench - -import ( - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "sort" - "strings" - - ethcrypto "github.com/ethereum/go-ethereum/crypto" -) - -// This file converts a debug_traceCall prestateTracer diffMode result -// ({"pre": {...}, "post": {...}}) into a WriteSet for replay. -// -// Mapping rules (v1): -// - storage slots present in post -> storage write with the post value -// - storage slots present in pre, not post -> storage delete (slot zeroed) -// - nonce changed -> nonce write (8-byte big-endian) -// - code changed -> code write + codehash write -// (keccak256 of the code) + codesize raw write (0x09||addr, 8-byte length), -// mirroring x/evm's deploy path -// - account present in pre, absent in post (SELFDESTRUCT) -> deletes for the -// account's nonce, code, codehash, and codesize keys, plus the storage -// deletes from the rule above. Only slots present in pre can be deleted; -// a real account wipe also removes slots the trace never touched, so -// self-destruct replays are a lower bound on the true delete volume. -// - balance changes are bank-module writes and are NOT converted; they are -// counted in SkippedBalanceChanges so callers can see what was dropped -// (a removed account's balance zeroing is counted the same way) -// -// Known v1 fidelity gap: deploying to a previously-unassociated address also -// writes the Sei<->EVM address mapping (two raw keys, 0x01||evm and 0x02||sei) -// and creates a Sei account, via x/evm's SetCode -> SetAddressMapping path. Those -// writes are NOT emitted here because a prestate trace does not reveal the prior -// association state, so we cannot tell whether the mapping write actually fired -// (the same reason balance changes are skipped). New-contract deploy replays -// therefore slightly undercount apply/commit cost; emitting them conditionally -// is left to a future revision. -// -// Addresses and slots are emitted in sorted order so conversion output is -// deterministic for a given trace. - -// codeSizeKeyPrefix mirrors x/evm/types.CodeSizeKeyPrefix. It routes to the -// legacy key family, which the sei-db keys package intentionally does not -// enumerate, so the byte is duplicated here the same way keys/evm.go -// duplicates the other prefixes. -var codeSizeKeyPrefix = []byte{0x09} - -// prestateAccount is one account entry in a prestateTracer result. -type prestateAccount struct { - Balance string `json:"balance,omitempty"` - Nonce *uint64 `json:"nonce,omitempty"` - Code string `json:"code,omitempty"` - Storage map[string]string `json:"storage,omitempty"` -} - -// prestateDiff is the diffMode payload of a prestateTracer trace. -type prestateDiff struct { - Pre map[string]prestateAccount `json:"pre"` - Post map[string]prestateAccount `json:"post"` -} - -// ConvertResult carries the converted write set plus conversion statistics. -type ConvertResult struct { - WriteSet *WriteSet - // SkippedBalanceChanges counts balance changes that were not converted - // because balances live in the bank module (out of v1 scope): accounts - // with a post-state balance, plus removed accounts whose pre-state - // balance was zeroed. - SkippedBalanceChanges int -} - -// ConvertPrestateDiffFile reads a prestateTracer diffMode JSON file (either -// the raw {"pre","post"} object or a JSON-RPC response with that object under -// "result") and converts it into a single-block WriteSet. -func ConvertPrestateDiffFile(path string) (*ConvertResult, error) { - data, err := os.ReadFile(path) //nolint:gosec // benchmark input path supplied by the operator - if err != nil { - return nil, fmt.Errorf("read trace file: %w", err) - } - return ConvertPrestateDiff(data) -} - -// ConvertPrestateDiff converts prestateTracer diffMode JSON bytes into a -// single-block WriteSet. -func ConvertPrestateDiff(data []byte) (*ConvertResult, error) { - var rpcEnvelope struct { - Result json.RawMessage `json:"result"` - } - if err := json.Unmarshal(data, &rpcEnvelope); err == nil && len(rpcEnvelope.Result) > 0 { - data = rpcEnvelope.Result - } - - var diff prestateDiff - if err := json.Unmarshal(data, &diff); err != nil { - return nil, fmt.Errorf("parse prestate diff: %w", err) - } - if diff.Post == nil { - return nil, fmt.Errorf("trace has no post state; was the tracer run with diffMode=true?") - } - - result := &ConvertResult{} - var writes []WriteSetEntry - - for _, addr := range sortedKeys(diff.Post) { - post := diff.Post[addr] - pre := diff.Pre[addr] - - writes = append(writes, convertStorage(addr, pre, post)...) - - if post.Nonce != nil { - nonce := make([]byte, 8) - binary.BigEndian.PutUint64(nonce, *post.Nonce) - writes = append(writes, WriteSetEntry{ - Kind: WriteKindNonce, - Address: addr, - Value: hex.EncodeToString(nonce), - }) - } - - if post.Code != "" && post.Code != pre.Code { - codeWrites, err := convertCode(addr, post.Code) - if err != nil { - return nil, err - } - writes = append(writes, codeWrites...) - } - - if post.Balance != "" { - result.SkippedBalanceChanges++ - } - } - - // Slots that were zeroed appear in pre but not post: emit deletes. Membership - // is tested on the normalized (padded) slot key because the write pass - // normalizes the post slot the same way; comparing raw hex could miss a match - // when pre and post encode the same slot differently (padded vs unpadded, 0x - // prefix, case), emitting a spurious delete that clobbers the write — deletes - // are appended after writes, and both engines apply last-write-wins per key. - for _, addr := range sortedKeys(diff.Pre) { - pre := diff.Pre[addr] - post, inPost := diff.Post[addr] - postSlots := make(map[string]struct{}, len(post.Storage)) - for slot := range post.Storage { - postSlots[padTo32(slot)] = struct{}{} - } - for _, slot := range sortedKeys(pre.Storage) { - if _, stillSet := postSlots[padTo32(slot)]; !stillSet { - writes = append(writes, WriteSetEntry{ - Kind: WriteKindStorage, - Address: addr, - Slot: padTo32(slot), - Delete: true, - }) - } - } - if !inPost { - removalWrites, err := convertAccountRemoval(addr, pre) - if err != nil { - return nil, err - } - writes = append(writes, removalWrites...) - if pre.Balance != "" { - result.SkippedBalanceChanges++ - } - } - } - - if len(writes) == 0 { - return nil, fmt.Errorf("trace produced no convertible writes") - } - result.WriteSet = &WriteSet{Blocks: []WriteSetBlock{{Writes: writes}}} - if err := result.WriteSet.Validate(); err != nil { - return nil, fmt.Errorf("converted write set is invalid: %w", err) - } - return result, nil -} - -// convertStorage emits writes for every slot present in the post state. -// Slots and values are padded to 32 bytes: x/evm writes fixed 32-byte values, -// and some tracers emit unpadded hex. -func convertStorage(addr string, _, post prestateAccount) []WriteSetEntry { - writes := make([]WriteSetEntry, 0, len(post.Storage)) - for _, slot := range sortedKeys(post.Storage) { - writes = append(writes, WriteSetEntry{ - Kind: WriteKindStorage, - Address: addr, - Slot: padTo32(slot), - Value: padTo32(post.Storage[slot]), - }) - } - return writes -} - -// convertCode emits the code, codehash, and codesize writes that a contract -// deployment produces. It deliberately omits the Sei<->EVM address-mapping and -// account-creation writes that SetCode also performs for a previously -// unassociated address; see the fidelity-gap note in the file header. -func convertCode(addr, codeHex string) ([]WriteSetEntry, error) { - code, err := hex.DecodeString(strings.TrimPrefix(codeHex, "0x")) - if err != nil { - return nil, fmt.Errorf("address %s: invalid code hex: %w", addr, err) - } - addrBytes, err := decodeHexField("address", addr, 20) - if err != nil { - return nil, err - } - size := make([]byte, 8) - binary.BigEndian.PutUint64(size, uint64(len(code))) - return []WriteSetEntry{ - {Kind: WriteKindCode, Address: addr, Value: hex.EncodeToString(code)}, - {Kind: WriteKindCodeHash, Address: addr, Value: hex.EncodeToString(ethcrypto.Keccak256(code))}, - {Kind: WriteKindRaw, Key: hex.EncodeToString(append(codeSizeKeyPrefix, addrBytes...)), Value: hex.EncodeToString(size)}, - }, nil -} - -// convertAccountRemoval emits the account-level deletes for an address that is -// present in pre but absent from post (the diffMode shape a SELFDESTRUCT -// produces): nonce, and — when the account had code — code, codehash, and -// codesize. Storage-slot deletes are handled by the caller's delete pass; see -// the file header for why slots absent from pre cannot be deleted. -func convertAccountRemoval(addr string, pre prestateAccount) ([]WriteSetEntry, error) { - var writes []WriteSetEntry - if pre.Nonce != nil { - writes = append(writes, WriteSetEntry{Kind: WriteKindNonce, Address: addr, Delete: true}) - } - if pre.Code != "" { - addrBytes, err := decodeHexField("address", addr, 20) - if err != nil { - return nil, err - } - writes = append(writes, - WriteSetEntry{Kind: WriteKindCode, Address: addr, Delete: true}, - WriteSetEntry{Kind: WriteKindCodeHash, Address: addr, Delete: true}, - WriteSetEntry{Kind: WriteKindRaw, Key: hex.EncodeToString(append(codeSizeKeyPrefix, addrBytes...)), Delete: true}, - ) - } - return writes, nil -} - -// padTo32 left-pads a hex slot to 32 bytes, tolerating a 0x prefix. Some -// tracers emit unpadded slot keys; store keys are always 32 bytes. -func padTo32(slot string) string { - trimmed := strings.TrimPrefix(slot, "0x") - if len(trimmed) >= 64 { - return trimmed - } - return strings.Repeat("0", 64-len(trimmed)) + trimmed -} - -// sortedKeys returns the map's keys in sorted order for deterministic output. -func sortedKeys[V any](m map[string]V) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/sei-db/state_db/bench/writeset_test.go b/sei-db/state_db/bench/writeset_test.go deleted file mode 100644 index a9cf803027..0000000000 --- a/sei-db/state_db/bench/writeset_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package bench - -import ( - "encoding/hex" - "os" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/sei-db/state_db/bench/wrappers" -) - -// prestateFixture is a real debug_traceCall prestateTracer diffMode response -// captured from pacific-1 for bytecode 0x602a60005500 -// (PUSH1 0x2a; PUSH1 0; SSTORE; STOP) with a code state override. -const prestateFixture = `{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "post": { - "0x0000000000000000000000000000000000000001": {"nonce": 1}, - "0x1000000000000000000000000000000000000001": { - "storage": { - "0x0000000000000000000000000000000000000000000000000000000000000000": - "0x000000000000000000000000000000000000000000000000000000000000002a" - } - } - }, - "pre": { - "0x0000000000000000000000000000000000000001": {"balance": "0x27147114878000"}, - "0x1000000000000000000000000000000000000001": {"balance": "0x0", "code": "0x602a60005500"} - } - } -}` - -func TestConvertPrestateDiff(t *testing.T) { - converted, err := ConvertPrestateDiff([]byte(prestateFixture)) - require.NoError(t, err) - ws := converted.WriteSet - require.Len(t, ws.Blocks, 1) - - byKind := map[string]int{} - for _, w := range ws.Blocks[0].Writes { - byKind[w.Kind]++ - } - require.Equal(t, 1, byKind[WriteKindStorage], "one SSTORE slot") - require.Equal(t, 1, byKind[WriteKindNonce], "sender nonce bump") - - changesets, err := ws.BlockChangesets(0) - require.NoError(t, err) - require.Len(t, changesets, 1) - require.Equal(t, "evm", changesets[0].Name) - - var sawStorageKey bool - for _, pair := range changesets[0].Changeset.Pairs { - if pair.Key[0] == 0x03 { - sawStorageKey = true - require.Len(t, pair.Key, 53, "storage key is 0x03||addr||slot") - require.Len(t, pair.Value, 32, "storage value padded to 32 bytes") - require.Equal(t, byte(0x2a), pair.Value[31]) - } - } - require.True(t, sawStorageKey) -} - -func TestConvertPrestateDiffEmitsDeletes(t *testing.T) { - trace := `{ - "pre": {"0x1000000000000000000000000000000000000001": {"storage": {"0x01": "0x2a"}}}, - "post": {"0x1000000000000000000000000000000000000001": {"nonce": 1}} - }` - converted, err := ConvertPrestateDiff([]byte(trace)) - require.NoError(t, err) - - var deletes int - for _, w := range converted.WriteSet.Blocks[0].Writes { - if w.Delete { - deletes++ - require.Equal(t, WriteKindStorage, w.Kind) - require.Len(t, w.Slot, 64, "slot padded to 32 bytes") - } - } - require.Equal(t, 1, deletes, "slot zeroed in post emits a delete") -} - -func TestConvertPrestateDiffSelfDestruct(t *testing.T) { - // An account present in pre but absent from post is the diffMode shape a - // SELFDESTRUCT produces: the account-level keys must be deleted too, not - // just the storage slots seen in pre. - trace := `{ - "pre": {"0x3000000000000000000000000000000000000003": { - "balance": "0x2a", "nonce": 1, "code": "0x602a60005500", - "storage": {"0x01": "0x2a"}}}, - "post": {} - }` - converted, err := ConvertPrestateDiff([]byte(trace)) - require.NoError(t, err) - require.Equal(t, 1, converted.SkippedBalanceChanges, - "removed account's balance zeroing is counted as skipped") - - deletesByKind := map[string]int{} - for _, w := range converted.WriteSet.Blocks[0].Writes { - require.True(t, w.Delete, "a removed account produces only deletes") - deletesByKind[w.Kind]++ - } - require.Equal(t, map[string]int{ - WriteKindStorage: 1, - WriteKindNonce: 1, - WriteKindCode: 1, - WriteKindCodeHash: 1, - WriteKindRaw: 1, // codesize (0x09||addr) - }, deletesByKind) - - // The delete-only write set must build valid changesets. - changesets, err := converted.WriteSet.BlockChangesets(0) - require.NoError(t, err) - for _, pair := range changesets[0].Changeset.Pairs { - require.True(t, pair.Delete) - } - - // Deleting keys that were never written must replay cleanly on both - // backends (a fresh DB has none of the removed account's keys). - for _, backend := range []wrappers.DBType{wrappers.MemIAVL, wrappers.FlatKV} { - t.Run(string(backend), func(t *testing.T) { - wrapper, err := OpenReplayWrapper(t.Context(), backend, t.TempDir()) - require.NoError(t, err) - defer func() { - require.NoError(t, wrapper.Close()) - }() - _, err = ReplayWriteSet(wrapper, converted.WriteSet) - require.NoError(t, err) - }) - } -} - -func TestConvertPrestateDiffNoSpuriousDeleteOnEncodingMismatch(t *testing.T) { - // The same slot is unpadded in pre but padded in post. The delete pass must - // normalize both before comparing, or it emits a delete that clobbers the - // write (last-write-wins), losing the updated value. - trace := `{ - "pre": {"0x1000000000000000000000000000000000000001": {"storage": {"0x01": "0x2a"}}}, - "post": {"0x1000000000000000000000000000000000000001": {"storage": { - "0x0000000000000000000000000000000000000000000000000000000000000001": "0x2b"}}} - }` - converted, err := ConvertPrestateDiff([]byte(trace)) - require.NoError(t, err) - - for _, w := range converted.WriteSet.Blocks[0].Writes { - require.False(t, w.Delete, "same slot written in post must not also be deleted") - } - - changesets, err := converted.WriteSet.BlockChangesets(0) - require.NoError(t, err) - var storageWrites int - for _, pair := range changesets[0].Changeset.Pairs { - if pair.Key[0] == 0x03 { - storageWrites++ - require.False(t, pair.Delete) - require.Equal(t, byte(0x2b), pair.Value[31], "updated value survives") - } - } - require.Equal(t, 1, storageWrites) -} - -func TestConvertPrestateDiffCodeDeployment(t *testing.T) { - trace := `{ - "pre": {"0x2000000000000000000000000000000000000002": {}}, - "post": {"0x2000000000000000000000000000000000000002": {"code": "0x602a60005500", "nonce": 1}} - }` - converted, err := ConvertPrestateDiff([]byte(trace)) - require.NoError(t, err) - - byKind := map[string]WriteSetEntry{} - for _, w := range converted.WriteSet.Blocks[0].Writes { - byKind[w.Kind] = w - } - require.Contains(t, byKind, WriteKindCode) - require.Contains(t, byKind, WriteKindCodeHash) - require.Contains(t, byKind, WriteKindRaw, "codesize write") - - codeHash, err := hex.DecodeString(byKind[WriteKindCodeHash].Value) - require.NoError(t, err) - require.Len(t, codeHash, 32) - - rawKey, err := hex.DecodeString(byKind[WriteKindRaw].Key) - require.NoError(t, err) - require.Equal(t, byte(0x09), rawKey[0], "codesize key prefix") - require.Len(t, rawKey, 21) -} - -func TestConvertPrestateDiffRequiresDiffMode(t *testing.T) { - _, err := ConvertPrestateDiff([]byte(`{"pre": {}}`)) - require.ErrorContains(t, err, "diffMode") -} - -func TestReplayWriteSetOnBothBackends(t *testing.T) { - converted, err := ConvertPrestateDiff([]byte(prestateFixture)) - require.NoError(t, err) - ws := converted.WriteSet - - for _, backend := range []wrappers.DBType{wrappers.MemIAVL, wrappers.FlatKV} { - t.Run(string(backend), func(t *testing.T) { - wrapper, err := OpenReplayWrapper(t.Context(), backend, t.TempDir()) - require.NoError(t, err) - defer func() { - require.NoError(t, wrapper.Close()) - }() - - result, err := ReplayWriteSet(wrapper, ws) - require.NoError(t, err) - require.Equal(t, 1, result.Blocks) - require.Equal(t, ws.TotalKeys(), result.Keys) - require.Positive(t, result.ApplyDuration) - require.Positive(t, result.CommitDuration) - require.Equal(t, int64(1), wrapper.Version()) - - // The SSTORE'd slot must be readable back through the store key. - changesets, err := ws.BlockChangesets(0) - require.NoError(t, err) - for _, pair := range changesets[0].Changeset.Pairs { - if pair.Key[0] == 0x03 { - value, found, err := wrapper.Read(pair.Key) - require.NoError(t, err) - require.True(t, found) - require.Equal(t, pair.Value, value) - } - } - }) - } -} - -func TestLoadWriteSetValidates(t *testing.T) { - dir := t.TempDir() - path := dir + "/ws.json" - - require.NoError(t, writeFile(path, `{"blocks": [{"writes": [ - {"kind": "storage", "address": "0x1000000000000000000000000000000000000001", - "slot": "0x0000000000000000000000000000000000000000000000000000000000000000", - "value": "0x000000000000000000000000000000000000000000000000000000000000002a"} - ]}]}`)) - ws, err := LoadWriteSet(path) - require.NoError(t, err) - require.Equal(t, 1, ws.TotalKeys()) - - require.NoError(t, writeFile(path, `{"blocks": [{"writes": [{"kind": "bogus"}]}]}`)) - _, err = LoadWriteSet(path) - require.ErrorContains(t, err, "unknown write kind") - - require.NoError(t, writeFile(path, `{"module": "bank", "blocks": [{"writes": []}]}`)) - _, err = LoadWriteSet(path) - require.ErrorContains(t, err, "unsupported module") -} - -func TestValidateRejectsWrongLengthValue(t *testing.T) { - addr := "0x1000000000000000000000000000000000000001" - - // A wrong-length value for a fixed-width kind is rejected up front, so the - // benchmark never feeds divergent data to memiavl (permissive) vs FlatKV - // (which hard-errors on bad lengths deep inside ApplyChangeSets). - for _, tc := range []struct { - name string - entry WriteSetEntry - }{ - {"short nonce", WriteSetEntry{Kind: WriteKindNonce, Address: addr, Value: "0x2a000000"}}, - {"short codehash", WriteSetEntry{Kind: WriteKindCodeHash, Address: addr, Value: "0x2a"}}, - {"short storage", WriteSetEntry{Kind: WriteKindStorage, Address: addr, - Slot: "0x" + hex.EncodeToString(make([]byte, 32)), Value: "0x2a"}}, - } { - t.Run(tc.name, func(t *testing.T) { - ws := &WriteSet{Blocks: []WriteSetBlock{{Writes: []WriteSetEntry{tc.entry}}}} - err := ws.Validate() - require.ErrorContains(t, err, "expected") - _, err = ws.BlockChangesets(0) - require.ErrorContains(t, err, "expected") - }) - } - - // Correctly-sized fixed-width values and unconstrained kinds (code/raw) pass. - ws := &WriteSet{Blocks: []WriteSetBlock{{Writes: []WriteSetEntry{ - {Kind: WriteKindNonce, Address: addr, Value: "0x" + hex.EncodeToString(make([]byte, 8))}, - {Kind: WriteKindCode, Address: addr, Value: "0x602a60005500"}, - }}}} - require.NoError(t, ws.Validate()) -} - -func writeFile(path, contents string) error { - return os.WriteFile(path, []byte(contents), 0o600) -} From d809f2536f3a6bdbafea2c63370c0e3385b2ee40 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 9 Sep 2026 14:49:56 -0500 Subject: [PATCH 18/19] wire up garbage collector --- .../bench/cryptosim/config/basic-config.json | 7 ++++- sei-db/state_db/bench/cryptosim/cryptosim.go | 23 +++++++++++++- .../bench/cryptosim/cryptosim_config.go | 14 ++++++++- sei-db/state_db/bench/cryptosim/database.go | 31 ++++++++++++++----- .../bench/cryptosim/transaction_test.go | 2 +- 5 files changed, 65 insertions(+), 12 deletions(-) diff --git a/sei-db/state_db/bench/cryptosim/config/basic-config.json b/sei-db/state_db/bench/cryptosim/config/basic-config.json index 915adf8c95..b53a5f97df 100644 --- a/sei-db/state_db/bench/cryptosim/config/basic-config.json +++ b/sei-db/state_db/bench/cryptosim/config/basic-config.json @@ -1,7 +1,7 @@ { "Comment": "Basic configuration for the cryptosim benchmark. Intended for basic correctness/sanity testing.", "StateStoreConfig": { - "Enable": true, + "Enable": false, "DBDirectory": "", "Backend": "pebbledb", "AsyncWriteBuffer": 100, @@ -15,6 +15,11 @@ "TimeInterval": 600000000000, "BlockInterval": 0 }, + "PruningConfig": { + "RollbackWindow": 1000, + "LookbackWindow": 0, + "PruneInterval": 300000000000 + }, "CannedRandomSize": 1073741824, "ConstantThreadCount": 0, "ConsoleUpdateIntervalSeconds": 1, diff --git a/sei-db/state_db/bench/cryptosim/cryptosim.go b/sei-db/state_db/bench/cryptosim/cryptosim.go index ded4374086..d981b6e636 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim.go @@ -12,6 +12,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/keys" crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" "github.com/sei-protocol/sei-chain/sei-db/common/utils" + "github.com/sei-protocol/sei-chain/sei-db/controller" "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" ) @@ -139,6 +140,11 @@ func NewCryptoSim( config.StateStoreConfig.EVMDBDirectory = filepath.Join( config.DataDir, "state_store", "evm", config.StateStoreConfig.Backend) + // Every store the state DB opens is pruned by the collector started below, so each one stands its + // own pruner down. This is the same handover bootstrap.GigaStorageManager performs for a node. + config.FlatKVConfig.ExternalPruning = true + config.StateStoreConfig.ExternalPruning = true + // giga.NewStateDB is the node's own entry point, and the only one that leaves the state WAL // outside the live state DB: it opens the WAL itself and writes each block to it ahead of the // commit. A live state DB opened directly would own its WAL and write it inline instead. @@ -148,6 +154,18 @@ func NewCryptoSim( return nil, fmt.Errorf("failed to open the state DB: %w", err) } + // Nothing the state DB opens prunes itself on this path: the state WAL, and the historical state + // DB when it is enabled, shrink only when a collector tells them to. + garbageCollector, err := controller.NewStorageGarbageCollector( + ctx, config.PruningConfig, db.PrunableStores()) + if err != nil { + cancel() + if closeErr := db.Close(); closeErr != nil { + fmt.Printf("failed to close the state DB during error recovery: %v\n", closeErr) + } + return nil, fmt.Errorf("failed to start the storage garbage collector: %w", err) + } + metrics := NewCryptosimMetrics(ctx, db.SC().GetPhaseTimer(), config) // Server start deferred until after DataGenerator loads DB state and sets gauges, // avoiding rate() spikes when restarting with a preserved DB. @@ -159,9 +177,12 @@ func NewCryptoSim( start := time.Now() - database, err := NewDatabase(config, db, metrics) + database, err := NewDatabase(config, db, garbageCollector, metrics) if err != nil { cancel() + if closeErr := garbageCollector.Close(); closeErr != nil { + fmt.Printf("failed to close the garbage collector during error recovery: %v\n", closeErr) + } if closeErr := db.Close(); closeErr != nil { fmt.Printf("failed to close database during error recovery: %v\n", closeErr) } diff --git a/sei-db/state_db/bench/cryptosim/cryptosim_config.go b/sei-db/state_db/bench/cryptosim/cryptosim_config.go index c71afb5fd0..943b82646c 100644 --- a/sei-db/state_db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/state_db/bench/cryptosim/cryptosim_config.go @@ -105,6 +105,9 @@ type CryptoSimConfig struct { // Configures the cadence the state DB checkpoints both halves of state on. CheckpointConfig config.CheckpointConfig + // Configures the prune cycle that enforces retention across the state DB's stores. + PruningConfig *config.StorageGarbageCollectorConfig + // This field is ignored, but allows for a comment to be added to the config file. // Something, something, why in the name of all things holy doesn't json support comments? Comment string @@ -237,6 +240,11 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { // Note: if you add new fields or modify default values, be sure to keep config/basic-config.json in sync. // That file should contain every available config set to its default value, as a reference. + ssConfig := config.DefaultStateStoreConfig() + // Nothing in the benchmark reads the historical state DB, so a run pays to write it only when + // the config asks for it. + ssConfig.Enable = false + cfg := &CryptoSimConfig{ NumberOfHotAccounts: 100, MinimumNumberOfColdAccounts: 1_000_000, @@ -256,8 +264,9 @@ func DefaultCryptoSimConfig() *CryptoSimConfig { HashLagBlocks: 32, Seed: 1337, CannedRandomSize: 1024 * 1024 * 1024, // 1GB - StateStoreConfig: config.DefaultStateStoreConfig(), + StateStoreConfig: ssConfig, CheckpointConfig: config.DefaultCheckpointConfig(), + PruningConfig: config.DefaultStorageGarbageCollectorConfig(), ConsoleUpdateIntervalSeconds: 1, ConsoleUpdateIntervalTransactions: 1_000_000, SetupUpdateIntervalCount: 100_000, @@ -412,6 +421,9 @@ func (c *CryptoSimConfig) Validate() error { return fmt.Errorf("StateStoreConfig.Backend must be one of %q or %q (got %q)", config.PebbleDBBackend, config.RocksDBBackend, c.StateStoreConfig.Backend) } + if err := c.PruningConfig.Validate(); err != nil { + return fmt.Errorf("PruningConfig is invalid: %w", err) + } switch strings.ToLower(c.LogLevel) { case "debug", "info", "warn", "error": default: diff --git a/sei-db/state_db/bench/cryptosim/database.go b/sei-db/state_db/bench/cryptosim/database.go index 311b851ec2..d4ce2995a7 100644 --- a/sei-db/state_db/bench/cryptosim/database.go +++ b/sei-db/state_db/bench/cryptosim/database.go @@ -2,9 +2,11 @@ package cryptosim import ( "encoding/binary" + "errors" "fmt" "github.com/sei-protocol/sei-chain/sei-db/common/keys" + "github.com/sei-protocol/sei-chain/sei-db/controller" "github.com/sei-protocol/sei-chain/sei-db/proto" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" ) @@ -17,6 +19,9 @@ type Database struct { // The database implementation to use for the benchmark. db gigatypes.StateDB + // Enforces retention across the stores the database opened. + garbageCollector *controller.StorageGarbageCollector + // A read-only view of the most recently committed block, which every read that misses the // current batch is served from. Replaced after each commit. view gigatypes.StateView @@ -48,18 +53,20 @@ type Database struct { func NewDatabase( config *CryptoSimConfig, db gigatypes.StateDB, + garbageCollector *controller.StorageGarbageCollector, metrics *CryptosimMetrics, ) (*Database, error) { // The view is both what reads are served from and where the starting height comes from: the // store accepts only the block after the one it opened at. view := db.OpenView() database := &Database{ - config: config, - db: db, - view: view, - batch: NewSyncMap[string, []byte](), - metrics: metrics, - nextBlockNumber: view.GetBlockHeight() + 1, + config: config, + db: db, + garbageCollector: garbageCollector, + view: view, + batch: NewSyncMap[string, []byte](), + metrics: metrics, + nextBlockNumber: view.GetBlockHeight() + 1, } // Registered here because this is before the first block is committed, and that is the only place @@ -234,14 +241,22 @@ func (d *Database) Close(nextAccountID int64, nextErc20ContractID int64) error { func (d *Database) CloseWithoutFinalizing() error { fmt.Printf("Closing database.\n") + var errs error + + // The collector prunes the stores closed below, so it stops before them. A failure to stop it + // does not skip those closes: every failure here is collected and reported together. + if err := d.garbageCollector.Close(); err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to close the storage garbage collector: %w", err)) + } + // The view holds a reference into the store, which cannot release it while the view is open. d.view.Close() if err := d.db.Close(); err != nil { - return fmt.Errorf("failed to close database: %w", err) + errs = errors.Join(errs, fmt.Errorf("failed to close database: %w", err)) } - return nil + return errs } // Set the function that flushes the executors. This setter is required to break a circular dependency. diff --git a/sei-db/state_db/bench/cryptosim/transaction_test.go b/sei-db/state_db/bench/cryptosim/transaction_test.go index 95b67bf5b5..047988120f 100644 --- a/sei-db/state_db/bench/cryptosim/transaction_test.go +++ b/sei-db/state_db/bench/cryptosim/transaction_test.go @@ -49,7 +49,7 @@ func TestTransactionExecuteSkipsReadsWhenDisabled(t *testing.T) { cfg.DisableTransactionReads = true stateDB := &readTrackingStateDB{view: &readTrackingView{}} - db, err := NewDatabase(cfg, stateDB, nil) + db, err := NewDatabase(cfg, stateDB, nil, nil) require.NoError(t, err) txn := &transaction{ From 2a626f533ab58a2b9115048ef562362fbd42e399 Mon Sep 17 00:00:00 2001 From: YimingZang Date: Wed, 9 Sep 2026 18:21:57 -0700 Subject: [PATCH 19/19] Fix godoc --- sei-db/bench/cryptosim/cryptosim.go | 24 ++++++++++++++-------- sei-db/bench/cryptosim/cryptosim_config.go | 2 +- sei-db/bench/cryptosim/data_generator.go | 2 -- sei-db/bench/cryptosim/database.go | 17 +++++---------- sei-db/bench/cryptosim/util.go | 5 ----- 5 files changed, 22 insertions(+), 28 deletions(-) diff --git a/sei-db/bench/cryptosim/cryptosim.go b/sei-db/bench/cryptosim/cryptosim.go index d981b6e636..08a78d3b40 100644 --- a/sei-db/bench/cryptosim/cryptosim.go +++ b/sei-db/bench/cryptosim/cryptosim.go @@ -166,6 +166,19 @@ func NewCryptoSim( return nil, fmt.Errorf("failed to start the storage garbage collector: %w", err) } + // Every construction failure past this point releases through here. The state DB holds the state + // WAL directory's exclusive lock, so a handle left open makes an in-process retry fail to open the + // WAL rather than only leaking descriptors. + releaseStorage := func() { + cancel() + if closeErr := garbageCollector.Close(); closeErr != nil { + fmt.Printf("failed to close the garbage collector during error recovery: %v\n", closeErr) + } + if closeErr := db.Close(); closeErr != nil { + fmt.Printf("failed to close the state DB during error recovery: %v\n", closeErr) + } + } + metrics := NewCryptosimMetrics(ctx, db.SC().GetPhaseTimer(), config) // Server start deferred until after DataGenerator loads DB state and sets gauges, // avoiding rate() spikes when restarting with a preserved DB. @@ -179,13 +192,7 @@ func NewCryptoSim( database, err := NewDatabase(config, db, garbageCollector, metrics) if err != nil { - cancel() - if closeErr := garbageCollector.Close(); closeErr != nil { - fmt.Printf("failed to close the garbage collector during error recovery: %v\n", closeErr) - } - if closeErr := db.Close(); closeErr != nil { - fmt.Printf("failed to close database during error recovery: %v\n", closeErr) - } + releaseStorage() return nil, fmt.Errorf("failed to create database: %w", err) } @@ -209,7 +216,7 @@ func NewCryptoSim( recieptsChan = make(chan *block, config.RecieptChannelCapacity) _, err := NewRecieptStoreSimulator(ctx, config, recieptsChan, metrics, rand.Clone(false)) if err != nil { - cancel() + releaseStorage() return nil, fmt.Errorf("failed to create receipt store simulator: %w", err) } metrics.startReceiptChannelDepthSampling(recieptsChan, config.BackgroundMetricsScrapeInterval) @@ -244,6 +251,7 @@ func NewCryptoSim( err = c.setup() if err != nil { + releaseStorage() return nil, fmt.Errorf("failed to setup benchmark: %w", err) } diff --git a/sei-db/bench/cryptosim/cryptosim_config.go b/sei-db/bench/cryptosim/cryptosim_config.go index 943b82646c..96797cdad5 100644 --- a/sei-db/bench/cryptosim/cryptosim_config.go +++ b/sei-db/bench/cryptosim/cryptosim_config.go @@ -85,7 +85,7 @@ type CryptoSimConfig struct { // How many blocks the benchmark may run ahead of block hashing. Databases hash committed blocks // asynchronously, and the benchmark takes one block's hash per block committed once it is this far // ahead — so a block's hash must arrive no later than this many blocks after it was committed, and - // the benchmark waits when it does not. A database that publishes no block hashes waits on nothing. + // the benchmark waits when it does not. HashLagBlocks int // The directory to store the benchmark data. diff --git a/sei-db/bench/cryptosim/data_generator.go b/sei-db/bench/cryptosim/data_generator.go index 0fa29046d4..3523c9b214 100644 --- a/sei-db/bench/cryptosim/data_generator.go +++ b/sei-db/bench/cryptosim/data_generator.go @@ -13,8 +13,6 @@ const ( accountIdCounterKey = "accountIdCounterKey" // Used to store the next ERC20 contract ID in the database. erc20IdCounterKey = "erc20IdCounterKey" - // Used to store the next block number in the database. - blockNumberCounterKey = "blockNumberCounterKey" // Use the code hash as a proxy. There is currently no mechanism to force FlatKV to update the account balance // field, and code hash keys will cause the account DB to get updated, which is the important part for this diff --git a/sei-db/bench/cryptosim/database.go b/sei-db/bench/cryptosim/database.go index d4ce2995a7..5400288cfa 100644 --- a/sei-db/bench/cryptosim/database.go +++ b/sei-db/bench/cryptosim/database.go @@ -183,17 +183,7 @@ func (d *Database) FinalizeBlock( }}, }) - // Persist the block number counter in every batch. blockNum := d.nextBlockNumber - blockNumberValue := make([]byte, 8) - //nolint:gosec // G115 - blockNum is a benchmark counter, overflow acceptable - binary.BigEndian.PutUint64(blockNumberValue, uint64(blockNum)) - changeSets = append(changeSets, &proto.NamedChangeSet{ - Name: keys.EVMStoreKey, - Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ - {Key: BlockNumberCounterKey(), Value: blockNumberValue}, - }}, - }) d.metrics.ReportBlockFinalized(d.transactionsInCurrentBlock) d.transactionsInCurrentBlock = 0 @@ -230,11 +220,14 @@ func (d *Database) reopenView() { func (d *Database) Close(nextAccountID int64, nextErc20ContractID int64) error { fmt.Printf("Committing final batch.\n") + // A failed final commit still has to release the stores below: they hold the state WAL directory's + // exclusive lock, which an in-process retry needs back. + var errs error if err := d.FinalizeBlock(nextAccountID, nextErc20ContractID); err != nil { - return fmt.Errorf("failed to commit batch: %w", err) + errs = errors.Join(errs, fmt.Errorf("failed to commit batch: %w", err)) } - return d.CloseWithoutFinalizing() + return errors.Join(errs, d.CloseWithoutFinalizing()) } // Close the database and release any resources without finalizing the last batch. diff --git a/sei-db/bench/cryptosim/util.go b/sei-db/bench/cryptosim/util.go index 9eab5713ba..17d3594dbb 100644 --- a/sei-db/bench/cryptosim/util.go +++ b/sei-db/bench/cryptosim/util.go @@ -28,11 +28,6 @@ func Erc20IDCounterKey() []byte { return keys.BuildEVMKey(keys.EVMKeyCode, paddedCounterKey(erc20IdCounterKey)) } -// Get the key for the block number counter in the database. -func BlockNumberCounterKey() []byte { - return keys.BuildEVMKey(keys.EVMKeyCode, paddedCounterKey(blockNumberCounterKey)) -} - // paddedCounterKey pads the string to AddressLen bytes for use with EVM key builders. func paddedCounterKey(s string) []byte { b := make([]byte, keys.AddressLen)