From 045e94d8bca5ad5884fd4dd6fcfd242f8eb8dfa5 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 26 Oct 2026 09:49:57 -0500 Subject: [PATCH 1/6] preparation work for flatKV mutation conversion --- sei-db/bootstrap/recovery_test.go | 3 +- sei-db/state_db/giga/state_db.go | 3 +- sei-db/state_db/giga/state_db_replay.go | 3 +- .../state_db/giga/types/live_state_store.go | 21 ++-- sei-db/state_db/sc/composite/store.go | 45 +++++---- .../sc/composite/store_migration_test.go | 2 + .../changeset_mutation_agreement_test.go} | 53 +++------- .../flatkv/changeset_mutation_conversions.go} | 28 +++--- .../changeset_mutation_conversions_test.go} | 34 +++---- .../sc/flatkv/empty_value_replay_test.go | 14 +-- .../sc/flatkv/lthash_correctness_test.go | 50 +++++----- sei-db/state_db/sc/flatkv/store.go | 16 ++- sei-db/state_db/sc/flatkv/store_apply.go | 70 ++++++------- sei-db/state_db/sc/flatkv/store_iteration.go | 15 ++- .../sc/flatkv/store_iteration_test.go | 11 ++- sei-db/state_db/sc/flatkv/store_read.go | 17 +--- sei-db/state_db/sc/flatkv/store_read_test.go | 84 +++++++--------- sei-db/state_db/sc/flatkv/store_replay.go | 18 +++- sei-db/state_db/sc/flatkv/store_test.go | 37 +++---- sei-db/state_db/sc/flatkv/store_write.go | 32 +++--- sei-db/state_db/sc/flatkv/store_write_test.go | 98 ++++++++----------- sei-db/state_db/sc/flatkv/test_verify.go | 9 +- sei-db/state_db/sc/flatkv/testutil_test.go | 9 ++ .../sc/flatkv/wal_format_recovery_test.go | 2 +- 24 files changed, 303 insertions(+), 371 deletions(-) rename sei-db/state_db/{giga/types/mutation/mutation_flatkv_agreement_test.go => sc/flatkv/changeset_mutation_agreement_test.go} (69%) rename sei-db/state_db/{giga/types/mutation/mutation_conversions.go => sc/flatkv/changeset_mutation_conversions.go} (91%) rename sei-db/state_db/{giga/types/mutation/mutation_conversions_test.go => sc/flatkv/changeset_mutation_conversions_test.go} (93%) diff --git a/sei-db/bootstrap/recovery_test.go b/sei-db/bootstrap/recovery_test.go index 49b6c6c96c..1066e715ad 100644 --- a/sei-db/bootstrap/recovery_test.go +++ b/sei-db/bootstrap/recovery_test.go @@ -18,7 +18,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" "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/giga/types/mutation" "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" @@ -96,7 +95,7 @@ func writeReceipts(t *testing.T, manager *GigaStorageManager, through uint64) { func writeWALOnly(t *testing.T, wal statewal.StateWAL, block uint64, changesets []*proto.NamedChangeSet) { t.Helper() //nolint:gosec // small test heights - mutations, err := mutation.MutationsFromChangeSets(changesets, block, emptyStateView{height: int64(block) - 1}) + mutations, err := flatkv.MutationsFromChangeSets(changesets, block, emptyStateView{height: int64(block) - 1}) require.NoError(t, err) require.NoError(t, wal.Write(block, mutations)) require.NoError(t, wal.Flush()) diff --git a/sei-db/state_db/giga/state_db.go b/sei-db/state_db/giga/state_db.go index 8994603b7b..319e95d8fb 100644 --- a/sei-db/state_db/giga/state_db.go +++ b/sei-db/state_db/giga/state_db.go @@ -17,7 +17,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb/undo" "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/giga/types/mutation" "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" @@ -445,7 +444,7 @@ func (s *StateDB) CommitStateChangesWithPrior( s.commitPhases.SetPhase("convert_mutations") // SC has not taken this block yet, so its view is of the block before. prior := s.sc.OpenView() - mutations, err := mutation.MutationsFromChangeSets(changeset, uint64(blockNum), prior) + mutations, err := flatkv.MutationsFromChangeSets(changeset, uint64(blockNum), prior) prior.Close() if err != nil { return fmt.Errorf("convert block %d to mutations: %w", blockNum, err) diff --git a/sei-db/state_db/giga/state_db_replay.go b/sei-db/state_db/giga/state_db_replay.go index d3abd84c1d..15477535fc 100644 --- a/sei-db/state_db/giga/state_db_replay.go +++ b/sei-db/state_db/giga/state_db_replay.go @@ -7,7 +7,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb/undo" "github.com/sei-protocol/sei-chain/sei-db/proto" - "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/evm" ) @@ -265,7 +264,7 @@ func (s *StateDB) replay( break } block, mutations := it.Entry() - changesets, err := mutation.MutationsToChangeSets(mutations) + changesets, err := flatkv.MutationsToChangeSets(mutations) if err != nil { return fmt.Errorf("convert block %d: %w", block, err) } 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 69ca603239..38d7dc8ccd 100644 --- a/sei-db/state_db/giga/types/live_state_store.go +++ b/sei-db/state_db/giga/types/live_state_store.go @@ -53,13 +53,11 @@ type LiveStateStore interface { // there is none. It does not modify the store. OldestSnapshotAbove(version int64) (snapshot int64, ok bool, err error) - // ApplyChangeSets buffers changesets at the given version, to be - // persisted by the next Commit. + // ApplyChangeSets buffers changesets as part of the block at version, which the next Commit writes. Reads do + // not observe buffered changesets. // - // Exactly one block may be buffered per Commit: repeated calls at the - // same version are allowed, any other version is rejected (see - // PendingVersion), and Commit must be called with that version. - // Batching several blocks into one Commit is not supported. + // Exactly one block may be buffered per Commit: repeated calls at the same version are allowed, any other + // version is rejected (see PendingVersion), and Commit must be called with that version. ApplyChangeSets(version int64, cs []*proto.NamedChangeSet) error // Commit persists buffered writes at the given version (block height). @@ -70,8 +68,10 @@ type LiveStateStore interface { // CommitStateChanges is a Giga-only helper that applies changesets and commits // them at blockNum in one call. + // // Callers must use either ApplyChangeSets+Commit or CommitStateChanges // exclusively, never mixing the two on the same store. + // // ApplyChangeSets+Commit is expected to be deprecated once all callers // move to CommitStateChanges. CommitStateChanges(blockNum int64, changeset []*proto.NamedChangeSet) error @@ -86,7 +86,7 @@ type LiveStateStore interface { // initialVersion <= 0. SetInitialVersion(initialVersion int64) error - // Get returns the value for a key within the given module. + // Get returns the committed value for a key within the given module. // For EVM keys (moduleName == "evm"), the key is a memiavl EVM key // routed to account/storage/code/misc DBs internally. // For non-EVM modules, the key is read from misc storage with the module prefix. @@ -116,10 +116,9 @@ type LiveStateStore interface { // Create an iterator over a range of keys in a given store. // - // The returned iterator is a stable snapshot taken at construction (pending - // writes are cloned and the Pebble view is pinned): it may be used - // concurrently with, and outlive, subsequent ApplyChangeSets/Commit calls - // without observing their effects. The caller must Close it when done; an + // The returned iterator is a stable snapshot of committed state taken at + // construction: it may be used concurrently with, and outlive, subsequent + // ApplyChangeSets/Commit calls without observing their effects. The caller must Close it when done; an // open iterator pins Pebble resources and holds back compaction, so close // promptly rather than relying on it being safe to keep open. Iterator( diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index 7fc5f9eb06..ae82fc0185 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -1022,7 +1022,7 @@ func (cs *CompositeCommitStore) MayHoldStore(name string) bool { // building. // // The height comes from the caller rather than from a backend. Taking a block's hash seals it on -// flatkv — see latticeHash — so by the time this runs flatkv may already sit at version, and a +// flatkv — see WorkingCommitInfo — 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) { @@ -1370,6 +1370,26 @@ func (cs *CompositeCommitStore) WorkingCommitInfo(version int64) *proto.CommitIn } } + // Cosmos asks for a block's hash before it calls Commit, and flatKV hashes only committed blocks, so + // the commit happens here; the Commit that follows finds the block already committed and does nothing. + // It precedes shouldAppendLatticeHash, which reads the migration boundary the block may have written. + // + // Committing 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 + // writer stamps it at the committed height plus one, which is a valid stamp for the next block, so it + // silently becomes part of that block instead. + // + // 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. + if flatKV := cs.loadFlatKV(); flatKV != nil { + if err := flatKV.CommitPendingBlock(); err != nil { + // Panics for the same reason a failed lattice hash does below. + panic(fmt.Sprintf("composite: failed to commit flatkv block %d before hashing: %v", + version, err)) + } + } + if cs.shouldAppendLatticeHash() { hash, err := cs.latticeHash(version) if err != nil { @@ -1384,31 +1404,14 @@ func (cs *CompositeCommitStore) WorkingCommitInfo(version int64) *proto.CommitIn 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. 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 -// 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 -// 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. -// -// 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. +// latticeHash returns flatKV's lattice hash for the block at version, waiting for flatKV to finish hashing it. +// flatKV must already have committed every write of the block. It returns nil when no flatKV backend is +// configured. func (cs *CompositeCommitStore) latticeHash(version int64) ([]byte, error) { flatKV := cs.loadFlatKV() if flatKV == nil { return nil, nil } - // A block that has not been committed has no hash, so asking for one is asking for the commit. - if err := 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. 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 0c5e2e7a25..76b7e62797 100644 --- a/sei-db/state_db/sc/composite/store_migration_test.go +++ b/sei-db/state_db/sc/composite/store_migration_test.go @@ -339,6 +339,8 @@ func TestComposite_MigrateEVM_SecondNonEmptyFlushDoesNotAdvanceMigration(t *test {Key: evmStorageTestKey(0x04), Value: evmStorageTestValue(0x44)}, }}}, })) + _, err = cs.Commit(cs.Version() + 1) + require.NoError(t, err) boundaryBytes, ok := cs.loadFlatKV().Get(migration.MigrationStore, []byte(migration.MigrationBoundaryKey)) require.True(t, ok) diff --git a/sei-db/state_db/giga/types/mutation/mutation_flatkv_agreement_test.go b/sei-db/state_db/sc/flatkv/changeset_mutation_agreement_test.go similarity index 69% rename from sei-db/state_db/giga/types/mutation/mutation_flatkv_agreement_test.go rename to sei-db/state_db/sc/flatkv/changeset_mutation_agreement_test.go index a59e37872b..5edde87a7c 100644 --- a/sei-db/state_db/giga/types/mutation/mutation_flatkv_agreement_test.go +++ b/sei-db/state_db/sc/flatkv/changeset_mutation_agreement_test.go @@ -1,23 +1,18 @@ -package mutation_test +package flatkv import ( "bytes" "context" - "encoding/binary" "testing" "github.com/stretchr/testify/require" "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/giga/types/mutation" - "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/config" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) -// This test lives in an external package so that flatKV can import the mutation package without a cycle. - // TestMutationsAgreeWithFlatKV converts each block's changesets into mutations against a flatKV store's committed // state, then requires that every mutation's new value is the row flatKV stores for its key, and that a second // store fed the mutations converted back into changesets reaches the same lattice hash. @@ -25,16 +20,16 @@ func TestMutationsAgreeWithFlatKV(t *testing.T) { source := newFlatKVStore(t) replica := newFlatKVStore(t) - for i, block := range agreementBlocks() { + for i, block := range changesetAgreementBlocks() { height := uint64(i + 1) prior := source.OpenView() - mutations, err := mutation.MutationsFromChangeSets(block, height, prior) + mutations, err := MutationsFromChangeSets(block, height, prior) prior.Close() require.NoError(t, err, "block %d", height) commitBlock(t, source, height, block) - converted, err := mutation.MutationsToChangeSets(mutations) + converted, err := MutationsToChangeSets(mutations) require.NoError(t, err, "block %d", height) commitBlock(t, replica, height, converted) @@ -50,9 +45,9 @@ func TestMutationsAgreeWithFlatKV(t *testing.T) { } } -// agreementBlocks returns a sequence of blocks, each a list of changesets, that writes and deletes every kind of row -// and changes account fields one at a time. -func agreementBlocks() [][]*proto.NamedChangeSet { +// changesetAgreementBlocks returns a sequence of blocks, each a list of changesets, that writes and deletes every +// kind of row and changes account fields one at a time. +func changesetAgreementBlocks() [][]*proto.NamedChangeSet { addressA := bytes.Repeat([]byte{0xa1}, keys.AddressLen) addressB := bytes.Repeat([]byte{0xb2}, keys.AddressLen) slot1 := append(bytes.Clone(addressA), bytes.Repeat([]byte{0x01}, 32)...) @@ -62,7 +57,7 @@ func agreementBlocks() [][]*proto.NamedChangeSet { return [][]*proto.NamedChangeSet{ { evmChangeSet( - set(keys.BuildEVMKey(keys.EVMKeyNonce, addressA), nonce(1)), + set(keys.BuildEVMKey(keys.EVMKeyNonce, addressA), nonceBytes(1)), set(keys.BuildEVMKey(keys.EVMKeyCodeHash, addressA), filled(32, 0xc0)), set(keys.BuildEVMKey(keys.EVMKeyBalance, addressA), filled(32, 0x0a)), set(keys.BuildEVMKey(keys.EVMKeyBalance, addressB), filled(32, 0x0b)), @@ -75,7 +70,7 @@ func agreementBlocks() [][]*proto.NamedChangeSet { }, { evmChangeSet( - set(keys.BuildEVMKey(keys.EVMKeyNonce, addressA), nonce(2)), + set(keys.BuildEVMKey(keys.EVMKeyNonce, addressA), nonceBytes(2)), del(keys.BuildEVMKey(keys.EVMKeyBalance, addressB)), set(keys.BuildEVMKey(keys.EVMKeyStorage, slot1), filled(32, 0x52)), set(keys.BuildEVMKey(keys.EVMKeyStorage, slot2), filled(32, 0x61)), @@ -97,11 +92,11 @@ func agreementBlocks() [][]*proto.NamedChangeSet { } // newFlatKVStore opens an empty flatKV store with no state WAL. -func newFlatKVStore(t *testing.T) *flatkv.CommitStore { +func newFlatKVStore(t *testing.T) *CommitStore { t.Helper() // Not t.Context(): it is cancelled before cleanups run, and a store closed after its context is cancelled // cannot drain its in-flight blocks. - store, err := flatkv.NewCommitStore(context.Background(), flatkvconfig.DefaultTestConfig(t), nil) + store, err := NewCommitStore(context.Background(), config.DefaultTestConfig(t), nil) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, store.Close()) }) require.NoError(t, store.LoadLatest()) @@ -109,7 +104,7 @@ func newFlatKVStore(t *testing.T) *flatkv.CommitStore { } // commitBlock applies changeSets to store as block height and commits it. -func commitBlock(t *testing.T, store *flatkv.CommitStore, height uint64, changeSets []*proto.NamedChangeSet) { +func commitBlock(t *testing.T, store *CommitStore, height uint64, changeSets []*proto.NamedChangeSet) { t.Helper() require.NoError(t, store.ApplyChangeSets(int64(height), changeSets)) //nolint:gosec // test heights are small _, err := store.Commit(int64(height)) //nolint:gosec // test heights are small @@ -117,7 +112,7 @@ func commitBlock(t *testing.T, store *flatkv.CommitStore, height uint64, changeS } // latestHash returns the global lattice hash of the most recently committed block of store. -func latestHash(t *testing.T, store *flatkv.CommitStore) [32]byte { +func latestHash(t *testing.T, store *CommitStore) [32]byte { t.Helper() require.NoError(t, store.FlushHashes()) hash, err := store.RegisterHashListener(func(context.Context, int64, *lthash.BlockHash) error { return nil }) @@ -125,26 +120,6 @@ func latestHash(t *testing.T, store *flatkv.CommitStore) [32]byte { return hash.Global.Checksum() } -func evmChangeSet(pairs ...*proto.KVPair) *proto.NamedChangeSet { - return moduleChangeSet(keys.EVMStoreKey, pairs...) -} - -func moduleChangeSet(moduleName string, pairs ...*proto.KVPair) *proto.NamedChangeSet { - return &proto.NamedChangeSet{Name: moduleName, Changeset: proto.ChangeSet{Pairs: pairs}} -} - -func set(key []byte, value []byte) *proto.KVPair { - return &proto.KVPair{Key: key, Value: value} -} - -func del(key []byte) *proto.KVPair { - return &proto.KVPair{Key: key, Delete: true} -} - -func nonce(value uint64) []byte { - return binary.BigEndian.AppendUint64(nil, value) -} - func filled(length int, value byte) []byte { return bytes.Repeat([]byte{value}, length) } diff --git a/sei-db/state_db/giga/types/mutation/mutation_conversions.go b/sei-db/state_db/sc/flatkv/changeset_mutation_conversions.go similarity index 91% rename from sei-db/state_db/giga/types/mutation/mutation_conversions.go rename to sei-db/state_db/sc/flatkv/changeset_mutation_conversions.go index 7fa4a413df..d6271ffcf8 100644 --- a/sei-db/state_db/giga/types/mutation/mutation_conversions.go +++ b/sei-db/state_db/sc/flatkv/changeset_mutation_conversions.go @@ -1,4 +1,4 @@ -package mutation +package flatkv import ( "encoding/binary" @@ -7,6 +7,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/keys" "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/giga/types/mutation" "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/vtype" ) @@ -25,7 +26,7 @@ func MutationsFromChangeSets( blockHeight uint64, // The state as of the block before blockHeight. Supplies each mutation's previous value. priorState gigatypes.StateView, -) ([]Mutation, error) { +) ([]mutation.Mutation, error) { priorHeight := priorState.GetBlockHeight() //nolint:gosec // a non-negative height converts exactly if priorHeight < 0 || uint64(priorHeight)+1 != blockHeight { @@ -88,7 +89,7 @@ func MutationsFromChangeSets( } priorValues := priorState.BatchGetRaw(physicalKeys) - mutations := make([]Mutation, len(physicalKeys)) + mutations := make([]mutation.Mutation, len(physicalKeys)) for i, key := range physicalKeys { previousValue := priorValues[string(key)] var newValue []byte @@ -101,20 +102,19 @@ func MutationsFromChangeSets( } else { newValue = nonAccountNewValues[string(key)] } - mutations[i] = Mutation{Key: key, PreviousValue: previousValue, NewValue: newValue} + mutations[i] = mutation.Mutation{Key: key, PreviousValue: previousValue, NewValue: newValue} } return mutations, nil } -// ToChangeSet converts the mutation into a changeset that writes its new value, with one entry per account field for -// an account row. The previous value is not carried over. Neither the mutation nor the returned changeset may be -// modified. -func (m *Mutation) ToChangeSet() (*proto.NamedChangeSet, error) { +// MutationToChangeSet returns a changeset that writes m's new value, with one entry per account field for an account +// row. The previous value is not carried over. Neither m nor the returned changeset may be modified. +func MutationToChangeSet(m *mutation.Mutation) (*proto.NamedChangeSet, error) { moduleName, key, err := ktype.StripModulePrefix(m.Key) if err != nil { return nil, fmt.Errorf("failed to parse key %x: %w", m.Key, err) } - pairs, err := m.changeSetPairs(moduleName, key) + pairs, err := changeSetPairs(m, moduleName, key) if err != nil { return nil, fmt.Errorf("failed to convert key %x: %w", m.Key, err) } @@ -123,11 +123,11 @@ func (m *Mutation) ToChangeSet() (*proto.NamedChangeSet, error) { // MutationsToChangeSets converts mutations into one changeset per module that they write. Previous values are not // carried over. Neither the mutations nor the returned changesets may be modified. -func MutationsToChangeSets(mutations []Mutation) ([]*proto.NamedChangeSet, error) { +func MutationsToChangeSets(mutations []mutation.Mutation) ([]*proto.NamedChangeSet, error) { var changeSets []*proto.NamedChangeSet changeSetByModule := make(map[string]*proto.NamedChangeSet) for i := range mutations { - changeSet, err := mutations[i].ToChangeSet() + changeSet, err := MutationToChangeSet(&mutations[i]) if err != nil { return nil, fmt.Errorf("failed to convert mutation %d: %w", i, err) } @@ -205,8 +205,10 @@ func rowValue( } } -// changeSetPairs returns the changeset pairs that write the mutation's new value. -func (m *Mutation) changeSetPairs( +// changeSetPairs returns the changeset pairs that write m's new value. +func changeSetPairs( + // The mutation to convert. + m *mutation.Mutation, // The module the mutation's key belongs to. moduleName string, // The mutation's key with the module prefix removed. diff --git a/sei-db/state_db/giga/types/mutation/mutation_conversions_test.go b/sei-db/state_db/sc/flatkv/changeset_mutation_conversions_test.go similarity index 93% rename from sei-db/state_db/giga/types/mutation/mutation_conversions_test.go rename to sei-db/state_db/sc/flatkv/changeset_mutation_conversions_test.go index b8e30d2e2c..723a178b23 100644 --- a/sei-db/state_db/giga/types/mutation/mutation_conversions_test.go +++ b/sei-db/state_db/sc/flatkv/changeset_mutation_conversions_test.go @@ -1,8 +1,7 @@ -package mutation +package flatkv import ( "bytes" - "encoding/binary" "testing" "github.com/stretchr/testify/require" @@ -10,6 +9,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/keys" "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/giga/types/mutation" "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/vtype" ) @@ -75,10 +75,6 @@ var ( testMiscPhysicalKey = ktype.BuildPhysicalKey(keys.EVMStoreKey, testMiscKey) ) -func nonceBytes(nonce uint64) []byte { - return binary.BigEndian.AppendUint64(nil, nonce) -} - func evmChangeSet(pairs ...*proto.KVPair) *proto.NamedChangeSet { return moduleChangeSet(keys.EVMStoreKey, pairs...) } @@ -151,7 +147,7 @@ func TestMutationsFromChangeSetsBuildsEachKind(t *testing.T) { mutations, err := MutationsFromChangeSets(changeSets, 5, stateAt(4, nil)) require.NoError(t, err) - expected := []Mutation{ + expected := []mutation.Mutation{ {Key: testStoragePhysicalKey, NewValue: storageRow(t, 5, storageValue)}, {Key: testCodePhysicalKey, NewValue: vtype.SerializeCode(5, code)}, {Key: testMiscPhysicalKey, NewValue: vtype.SerializeMisc(5, []byte("evm misc"))}, @@ -172,7 +168,7 @@ func TestMutationsFromChangeSetsMergesAccountOntoPriorRow(t *testing.T) { mutations, err := MutationsFromChangeSets(changeSets, 5, prior) require.NoError(t, err) - expected := []Mutation{{ + expected := []mutation.Mutation{{ Key: testAccountPhysicalKey, PreviousValue: priorRow, NewValue: accountRow(t, 5, 8, testCodeHash, testBalance), @@ -204,7 +200,7 @@ func TestMutationsFromChangeSetsDeletes(t *testing.T) { mutations, err := MutationsFromChangeSets(changeSets, 5, prior) require.NoError(t, err) - expected := []Mutation{ + expected := []mutation.Mutation{ {Key: testStoragePhysicalKey}, {Key: ktype.BuildPhysicalEVMKey(keys.EVMKeyStorage, append(bytes.Clone(testAddress), make([]byte, 32)...))}, @@ -233,7 +229,7 @@ func TestMutationsFromChangeSetsKeepsLastWrite(t *testing.T) { require.NoError(t, err) evmMiscKey := func(key []byte) []byte { return ktype.BuildPhysicalKey(keys.EVMStoreKey, key) } - expected := []Mutation{ + expected := []mutation.Mutation{ {Key: evmMiscKey(keyA), NewValue: vtype.SerializeMisc(5, []byte("last"))}, {Key: evmMiscKey(keyB), NewValue: vtype.SerializeMisc(5, []byte("other"))}, } @@ -297,7 +293,7 @@ func TestMutationsFromChangeSetsRejectsInvalidInput(t *testing.T) { func TestAccountMutationToChangeSet(t *testing.T) { row := accountRow(t, 5, 0, make([]byte, vtype.CodeHashLen), testBalance) - changeSet, err := (&Mutation{Key: testAccountPhysicalKey, NewValue: row}).ToChangeSet() + changeSet, err := MutationToChangeSet(&mutation.Mutation{Key: testAccountPhysicalKey, NewValue: row}) require.NoError(t, err) expected := evmChangeSet( set(testNonceKey, nonceBytes(0)), @@ -306,7 +302,7 @@ func TestAccountMutationToChangeSet(t *testing.T) { ) requireChangeSetsMatch(t, []*proto.NamedChangeSet{expected}, []*proto.NamedChangeSet{changeSet}) - changeSet, err = (&Mutation{Key: testAccountPhysicalKey, PreviousValue: row}).ToChangeSet() + changeSet, err = MutationToChangeSet(&mutation.Mutation{Key: testAccountPhysicalKey, PreviousValue: row}) require.NoError(t, err) expected = evmChangeSet(del(testNonceKey), del(testCodeHashKey), del(testBalanceKey)) requireChangeSetsMatch(t, []*proto.NamedChangeSet{expected}, []*proto.NamedChangeSet{changeSet}) @@ -315,23 +311,23 @@ func TestAccountMutationToChangeSet(t *testing.T) { func TestMutationToChangeSetRejectsInvalidInput(t *testing.T) { tests := []struct { name string - mutation Mutation + mutation mutation.Mutation }{ - {name: "key without module prefix", mutation: Mutation{Key: []byte("no-prefix")}}, - {name: "empty EVM key", mutation: Mutation{Key: []byte("evm/")}}, + {name: "key without module prefix", mutation: mutation.Mutation{Key: []byte("no-prefix")}}, + {name: "empty EVM key", mutation: mutation.Mutation{Key: []byte("evm/")}}, { name: "account field key", - mutation: Mutation{Key: ktype.BuildPhysicalKey(keys.EVMStoreKey, testBalanceKey)}, + mutation: mutation.Mutation{Key: ktype.BuildPhysicalKey(keys.EVMStoreKey, testBalanceKey)}, }, { name: "corrupt row", - mutation: Mutation{Key: testCodePhysicalKey, NewValue: []byte{0xff}}, + mutation: mutation.Mutation{Key: testCodePhysicalKey, NewValue: []byte{0xff}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := tt.mutation.ToChangeSet() + _, err := MutationToChangeSet(&tt.mutation) require.Error(t, err) }) } @@ -340,7 +336,7 @@ func TestMutationToChangeSetRejectsInvalidInput(t *testing.T) { func TestMutationsToChangeSetsGroupsByModule(t *testing.T) { keyA := []byte{0x01, 0x0a} keyC := []byte{0x01, 0x0c} - mutations := []Mutation{ + mutations := []mutation.Mutation{ { Key: ktype.BuildPhysicalKey(keys.EVMStoreKey, keyA), NewValue: vtype.SerializeMisc(5, []byte("a")), diff --git a/sei-db/state_db/sc/flatkv/empty_value_replay_test.go b/sei-db/state_db/sc/flatkv/empty_value_replay_test.go index 06a1d90c22..501e017dfb 100644 --- a/sei-db/state_db/sc/flatkv/empty_value_replay_test.go +++ b/sei-db/state_db/sc/flatkv/empty_value_replay_test.go @@ -75,7 +75,7 @@ func TestEmptyValueSurvivesWALReplay(t *testing.T) { "empty-value keys must not be dropped on replay") } -func TestEmptyValueVisibleBeforeCommit(t *testing.T) { +func TestEmptyValueVisibleAfterCommit(t *testing.T) { cfg := config.DefaultTestConfig(t) s := setupTestStoreWithConfig(t, cfg) defer func() { require.NoError(t, s.Close()) }() @@ -85,6 +85,7 @@ func TestEmptyValueVisibleBeforeCommit(t *testing.T) { Key: key, Value: []byte{}, }))) + commitBuffered(t, s) value, found := s.Get("bank", key) require.True(t, found) @@ -96,19 +97,10 @@ func TestEmptyValueVisibleBeforeCommit(t *testing.T) { require.NoError(t, err) require.True(t, iter.Valid()) require.Equal(t, key, iter.Key()) - require.NotNil(t, iter.Value(), "pending iterator should preserve empty value presence") + require.NotNil(t, iter.Value(), "iterator should preserve empty value presence") require.Empty(t, iter.Value()) iter.Next() require.False(t, iter.Valid()) require.NoError(t, iter.Error()) require.NoError(t, iter.Close()) - - _, err = s.Commit(s.Version() + 1) - require.NoError(t, err) - - value, found = s.Get("bank", key) - require.True(t, found) - require.NotNil(t, value) - require.Empty(t, value) - require.True(t, s.Has("bank", key)) } 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 25610e78bf..bdea111707 100644 --- a/sei-db/state_db/sc/flatkv/lthash_correctness_test.go +++ b/sei-db/state_db/sc/flatkv/lthash_correctness_test.go @@ -939,9 +939,9 @@ func TestLtHashAccountPartialDeletePreservesRow(t *testing.T) { require.Equal(t, &zeroHash, ad.GetCodeHash(), "codehash should be zero after delete") } -// TestAccountPendingReadPartialDelete verifies that the isDelete guard in +// TestAccountReadPartialDelete verifies that the isDelete guard in // Get() only fires when all fields are zero, not on partial deletes. -func TestAccountPendingReadPartialDelete(t *testing.T) { +func TestAccountReadPartialDelete(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -949,34 +949,33 @@ func TestAccountPendingReadPartialDelete(t *testing.T) { nonceKey := keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]) chKey := keys.BuildEVMKey(keys.EVMKeyCodeHash, addr[:]) - // Apply 1: write nonce + codehash (not committed yet) + // Apply 1: write nonce + codehash require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ namedCS(noncePair(addr, 42), codeHashPair(addr, codeHashN(0xDD))), })) - // Apply 2: delete only codehash (not committed yet) + // Apply 2, same block: delete only codehash require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ namedCS(codeHashDeletePair(addr)), })) + commitBuffered(t, s) - // Pending reads before commit nonceVal, found := s.Get(keys.EVMStoreKey, nonceKey) - require.True(t, found, "nonce should be readable from pending writes") + require.True(t, found, "nonce should be readable") require.Equal(t, nonceBytes(42), nonceVal) chVal, found := s.Get(keys.EVMStoreKey, chKey) - require.False(t, found, "codehash should be not-found after pending delete") + require.False(t, found, "codehash should be not-found after its delete") require.Nil(t, chVal) paw := stagedRow(t, s.accountStore, accountPhysKey(addr), vtype.DeserializeAccountData) - require.NotNil(t, paw, "the staged account row must still be present") + require.NotNil(t, paw, "the account row must still be present") require.False(t, paw.IsDelete(), "row should NOT be marked for deletion (partial delete)") } -// TestAccountRowDeleteGetBeforeCommit verifies the core behavioral change: -// after deleting all account fields within a block, Get() returns (nil, false) -// for both nonce and codehash BEFORE commit. -func TestAccountRowDeleteGetBeforeCommit(t *testing.T) { +// TestAccountRowDeleteGet verifies that after every account field is deleted, Get() and Has() report the +// nonce and codehash as not found. +func TestAccountRowDeleteGet(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -984,44 +983,43 @@ func TestAccountRowDeleteGetBeforeCommit(t *testing.T) { nonceKey := keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]) chKey := keys.BuildEVMKey(keys.EVMKeyCodeHash, addr[:]) - // Write nonce + codehash (not committed yet) + // Write nonce + codehash require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ namedCS(noncePair(addr, 10), codeHashPair(addr, codeHashN(0xEE))), })) + commitBuffered(t, s) - // Verify both fields are readable before commit nonceVal, found := s.Get(keys.EVMStoreKey, nonceKey) - require.True(t, found, "nonce should be readable from pending writes") + require.True(t, found, "nonce should be readable") require.Equal(t, nonceBytes(10), nonceVal) chVal, found := s.Get(keys.EVMStoreKey, chKey) - require.True(t, found, "codehash should be readable from pending writes") + require.True(t, found, "codehash should be readable") expected := codeHashN(0xEE) require.Equal(t, expected[:], chVal) - // Delete both fields (still before commit) + // Delete both fields require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ namedCS(nonceDeletePair(addr), codeHashDeletePair(addr)), })) + commitBuffered(t, s) - // Verify both fields return not-found BEFORE commit (the core semantic change) nonceVal, found = s.Get(keys.EVMStoreKey, nonceKey) - require.False(t, found, "nonce should not be found after pending full-delete") + require.False(t, found, "nonce should not be found after full-delete") require.Nil(t, nonceVal) chVal, found = s.Get(keys.EVMStoreKey, chKey) - require.False(t, found, "codehash should not be found after pending full-delete") + require.False(t, found, "codehash should not be found after full-delete") require.Nil(t, chVal) hasNonce := s.Has(keys.EVMStoreKey, nonceKey) - require.False(t, hasNonce, "Has(nonce) should be false after pending full-delete") + require.False(t, hasNonce, "Has(nonce) should be false after full-delete") hasCodeHash := s.Has(keys.EVMStoreKey, chKey) - require.False(t, hasCodeHash, "Has(codehash) should be false after pending full-delete") + require.False(t, hasCodeHash, "Has(codehash) should be false after full-delete") - // A full delete stages a deletion rather than a zeroed row. The tombstone itself is no longer - // inspectable — BatchSet turns an IsDelete row into a store-level delete, and reading a key - // deleted in the current version reports absent rather than handing back the tombstone — so assert - // the observable consequence instead. + // A full delete writes a deletion rather than a zeroed row. The tombstone itself is not inspectable — + // BatchSet turns an IsDelete row into a store-level delete, and reading a deleted key reports absent + // rather than handing back the tombstone — so assert the observable consequence instead. paw := stagedRow(t, s.accountStore, accountPhysKey(addr), vtype.DeserializeAccountData) require.Nil(t, paw, "a fully deleted account row must read back as absent") } diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index 38591961c2..f1c63a6988 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -113,9 +113,8 @@ type CommitStore struct { // 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 - // that same block staged, with no separate overlay to consult. + // The four data stores below mediate every read and write of their databases. Commit stages a block's + // writes inside each store and seals them under mu, so a reader never observes a partly staged block. // // They are constructed as the last step of open, after any replay or rollback has run, and are nil // until then — the bootstrap and import paths deliberately write raw pebble before they exist. @@ -146,12 +145,11 @@ type CommitStore struct { // lifecycle is decoupled from the DB open/close cycle, so closeDBsOnly does not touch it. wal statewal.StateWAL - // Changes to feed into the WAL at the next commit. + // pendingChangeSets is the changesets ApplyChangeSets has buffered for the block the next Commit writes, in + // arrival order. pendingChangeSets []*proto.NamedChangeSet - // pendingBlockHeight is the version stamped by the current buffered ApplyChangeSets. 0 means no - // pending apply. It records the staged height; it does not constrain it — neither ApplyChangeSets - // nor Commit validates its version against this field. + // pendingBlockHeight is the height of the buffered block, or 0 when nothing is buffered. pendingBlockHeight int64 // Writes snapshots off the execution thread. Built by openStores once the view managers exist and @@ -169,8 +167,8 @@ type CommitStore struct { phaseTimer *metrics.PhaseTimer - // classifyBucketSizes is the length of each kind's bucket in the previous ApplyChangeSets call, used to size - // the next call's buckets. Zero for a kind that call did not touch. + // classifyBucketSizes is the length of each kind's bucket in the previous staged block, used to size the next + // block's buckets. Zero for a kind that block did not touch. classifyBucketSizes [keys.EVMKeyKindCount]int // readOnly marks stores opened via LoadVersionReadOnly. diff --git a/sei-db/state_db/sc/flatkv/store_apply.go b/sei-db/state_db/sc/flatkv/store_apply.go index 86c7e71e04..b138c25161 100644 --- a/sei-db/state_db/sc/flatkv/store_apply.go +++ b/sei-db/state_db/sc/flatkv/store_apply.go @@ -16,43 +16,28 @@ import ( "go.opentelemetry.io/otel/metric" ) -// classifyBucketHeadroom is the factor applied to a kind's bucket length in the previous ApplyChangeSets call to -// size that bucket in the next. +// classifyBucketHeadroom is the factor applied to a kind's bucket length in the previous staged block to size that +// bucket in the next. const classifyBucketHeadroom = 2 // classifyUnitSize is the most changeset pairs one worker classifies. const classifyUnitSize = 1024 -// ApplyChangeSets writes one block's changes into the four data stores. Non-EVM modules go to miscDB -// under "/". Each value records version as the height it was last modified at; the same version -// must be passed to the subsequent Commit, which is what folds the block into the LtHash. +// ApplyChangeSets buffers changeSets as part of the block at version, which the next Commit (or +// CommitPendingBlock) writes. Nothing reaches the stores before then, so reads do not observe buffered +// writes. func (s *CommitStore) ApplyChangeSets(version int64, changeSets []*proto.NamedChangeSet) error { - // The read-only refusal belongs here rather than in applyChangeSets, which a read-only store reaches - // legitimately: building a view at a past height replays the primary's WAL through the same apply path. + // The read-only refusal belongs here rather than in stageChangeSets, which a read-only store reaches + // legitimately: building a view at a past height replays the primary's WAL through the same staging path. // Commit and outOfBandSnapshot place their refusals at the same boundary, and readOnly is fixed for a store's // lifetime, so reading it outside the lock is safe. if s.readOnly { return errReadOnly } - return s.applyChangeSets(version, changeSets, nil) -} -// applyChangeSets is ApplyChangeSets with the replay skip list. alreadyHave is nil outside a startup -// replay, which means every store needs every block. -func (s *CommitStore) applyChangeSets( - version int64, - changeSets []*proto.NamedChangeSet, - alreadyHave map[string]int64, -) (err error) { - // Hold the write lock for the whole body: it both reads old values out of the stores and writes - // this block's values into them, and Get and iterator construction read them under a read lock. s.mu.Lock() defer s.mu.Unlock() - obs := s.observeOp("ApplyChangeSets", otelMetrics.ApplyChangesetsLatency, - "changesets", len(changeSets)) - defer obs.done(&err, nil) - // Blocks are contiguous and the first block is 1, so writes always land at committedVersion+1. See the // Commit contract: a store whose history starts higher is seeded by SetInitialVersion. // An empty batch for a block that is already committed is accepted and does nothing. @@ -75,8 +60,25 @@ func (s *CommitStore) applyChangeSets( } // A single block's writes may arrive across several ApplyChangeSets calls at the same height (e.g. a // ModuleRouter fanning one block's changesets out to multiple routes that all target flatKV). The check - // above already restricts those to committedVersion+1, which is the only height pending writes can be - // stamped at, so same-height repeats are accepted and no other height can reach here. + // above already restricts those to committedVersion+1, so same-height repeats are accepted and no other + // height can reach here. + s.pendingChangeSets = append(s.pendingChangeSets, changeSets...) + s.pendingBlockHeight = version + return nil +} + +// stageChangeSets writes one block's changesets into the four data stores, where the block's seal picks them up. +// Non-EVM modules go to miscDB under "/". Each value records version as the height it was last modified +// at. The caller holds mu, or has the store to itself. +func (s *CommitStore) stageChangeSets( + version int64, + changeSets []*proto.NamedChangeSet, + // The replay skip list. Nil outside a startup replay, which means every store needs every block. + alreadyHave map[string]int64, +) (err error) { + obs := s.observeOp("ApplyChangeSets", otelMetrics.ApplyChangesetsLatency, + "changesets", len(changeSets)) + defer obs.done(&err, nil) s.phaseTimer.SetPhase("apply_change_sets_prepare") changesByType, err := classifyAndPrefix(changeSets, s.classifyBucketSizes, s.miscPool) @@ -91,12 +93,12 @@ func (s *CommitStore) applyChangeSets( return fmt.Errorf("prepare writes: %w", err) } - if err := s.writeToStores(prepared, changeSets, version, alreadyHave); err != nil { + if err := s.writeToStores(prepared, version, alreadyHave); err != nil { return fmt.Errorf("write to stores: %w", err) } s.phaseTimer.SetPhase("apply_change_done") - logger.Debug("FlatKV ApplyChangeSets complete", + logger.Debug("FlatKV block staged", "version", version, "changesets", len(changeSets), "writes", prepared.accountCount()+len(prepared.storage)+len(prepared.code)+len(prepared.misc), @@ -104,9 +106,8 @@ func (s *CommitStore) applyChangeSets( return nil } -// preparedWrites holds the fully-validated per-database values and LtHash pairs for one -// ApplyChangeSets call. Nothing here reaches a store until every kind has validated — see -// writeToStores. +// preparedWrites holds the fully-validated per-database values for one staged block. Nothing here reaches a store +// until every kind has validated — see writeToStores. type preparedWrites struct { accounts *accountUpdater storage []view.Write @@ -275,7 +276,6 @@ func (p preparedWrites) accountCount() int { // writeToStores writes one block's prepared values into the four data stores. func (s *CommitStore) writeToStores( prepared preparedWrites, - changeSets []*proto.NamedChangeSet, version int64, alreadyHave map[string]int64, ) error { @@ -308,13 +308,7 @@ func (s *CommitStore) writeToStores( }) wg.Wait() - if err := errors.Join(accountErr, storageErr, codeErr, miscErr); err != nil { - return err - } - - s.pendingChangeSets = append(s.pendingChangeSets, changeSets...) - s.pendingBlockHeight = version - return nil + return errors.Join(accountErr, storageErr, codeErr, miscErr) } // writeStore writes one database's values, and is a no-op for a store that already holds this block. @@ -388,7 +382,7 @@ type classifiedChange struct { value []byte } -// classifiedChanges holds one ApplyChangeSets call's pairs bucketed by EVM key kind, each bucket in the order the +// classifiedChanges holds one staged block's pairs bucketed by EVM key kind, each bucket in the order the // pairs arrived. A key written more than once appears once per write, and the last of them is its new value. type classifiedChanges [keys.EVMKeyKindCount][]classifiedChange diff --git a/sei-db/state_db/sc/flatkv/store_iteration.go b/sei-db/state_db/sc/flatkv/store_iteration.go index 284b4ebd07..5d848ef3c0 100644 --- a/sei-db/state_db/sc/flatkv/store_iteration.go +++ b/sei-db/state_db/sc/flatkv/store_iteration.go @@ -20,10 +20,10 @@ import ( // order. Within each DB, keys are in key order. Per-DB _meta/* keys are skipped by the stores // themselves. // -// Requires that no block be staged: see the check below. +// Refuses while ApplyChangeSets has a block buffered. // // It walks the stores rather than the databases, so it needs no flush gate: a store iterator already -// merges the rows a block has staged and the rows the flusher has not yet written over what is on disk. +// merges the rows the flusher has not yet written over what is on disk. // Scanning the databases directly would instead return whatever the flusher happened to have finished. // // The returned iterator is a fixed view of the instant it was built, so it may be held while later blocks commit and @@ -37,12 +37,11 @@ func (s *CommitStore) RawGlobalIterator() (dbm.Iterator, error) { return nil, errors.New("flatkv: RawGlobalIterator on a closed store") } - // Refuse while a block is staged. A store iterator sees staged rows, and every caller here is - // exporting or auditing committed state — emitting a row from a block that has not committed would be - // wrong, and silently including it would be worse. + // Refuse while a block is buffered. A block is buffered only mid-block, and every caller here exports or + // audits committed state between blocks. if s.pendingBlockHeight != 0 { return nil, fmt.Errorf( - "flatkv: RawGlobalIterator requires no staged block; block %d is staged and uncommitted", + "flatkv: RawGlobalIterator requires no buffered block; block %d is buffered and uncommitted", s.pendingBlockHeight) } @@ -75,8 +74,8 @@ func (s *CommitStore) Iterator(store string, start []byte, end []byte, ascending } // Read lock for the construction span: buildEvmIterator/buildMiscDBLane - // copy the pending-writes maps and pin the Pebble reader here, so the - // returned iterator may safely outlive a concurrent ApplyChangeSets/Commit. + // build their store iterators and pin the Pebble reader here, so the + // returned iterator may safely outlive a concurrent Commit. s.mu.RLock() defer s.mu.RUnlock() diff --git a/sei-db/state_db/sc/flatkv/store_iteration_test.go b/sei-db/state_db/sc/flatkv/store_iteration_test.go index 9e4f50dfab..07d5834089 100644 --- a/sei-db/state_db/sc/flatkv/store_iteration_test.go +++ b/sei-db/state_db/sc/flatkv/store_iteration_test.go @@ -115,7 +115,7 @@ func TestEvmIterator(t *testing.T) { }) } - t.Run("overlap keys use pending values", func(t *testing.T) { + t.Run("overlap keys use the later block's values", func(t *testing.T) { require.NotEmpty(t, fixture.OverlapSamples) iter, err := fixture.Store.Iterator(keys.EVMStoreKey, nil, nil, true) @@ -165,6 +165,7 @@ func TestMiscIteratorNonEVM(t *testing.T) { &proto.KVPair{Key: []byte("alpha"), Value: []byte("A2")}, ), })) + commitBuffered(t, s) iter, err := s.Iterator("bank", nil, nil, true) require.NoError(t, err) @@ -453,7 +454,7 @@ func TestEvmIteratorDifferential(t *testing.T) { rng := rand.New(rand.NewSource(seed ^ 0x5deece66d)) //nolint:gosec // deterministic test data only - // Pool of boundary-relevant keys: every committed/pending key, plus each + // Pool of boundary-relevant keys: every fixture key, plus each // type prefix and its prefix-end. var pool [][]byte for _, e := range fixture.Sorted { @@ -629,6 +630,8 @@ func buildEvmIteratorFixture(t *testing.T, seed int64) *evmIteratorFixture { require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{namedCS(batch1...)})) commitAndCheck(t, s) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{namedCS(batch2...)})) + // Committed without waiting for the flush, so iterators merge batch2's unflushed rows over batch1's. + commitBuffered(t, s) return &evmIteratorFixture{ Seed: seed, @@ -685,8 +688,8 @@ func (g *evmIteratorGenerator) addMalformedStoragePrefixMiscKey() { // addMalformedCodePrefixMiscKey writes a 0x07-prefixed key whose length does // not match a well-formed code key (1 + 20), so it routes to miscDB while -// physically living in the code keyspace (evm/0x07...). It is pending-only -// (batch2) so the misc and code lanes interleave over pending writes too. +// physically living in the code keyspace (evm/0x07...). It is written only in +// batch2, so the misc and code lanes interleave over unflushed rows too. func (g *evmIteratorGenerator) addMalformedCodePrefixMiscKey() { key := append([]byte{0x07}, bytes.Repeat([]byte{0x5a}, ktype.AddressLen-1)...) val := []byte{0x56, 0x78} diff --git a/sei-db/state_db/sc/flatkv/store_read.go b/sei-db/state_db/sc/flatkv/store_read.go index 7de01893be..58643dcdbf 100644 --- a/sei-db/state_db/sc/flatkv/store_read.go +++ b/sei-db/state_db/sc/flatkv/store_read.go @@ -32,9 +32,9 @@ func (s *CommitStore) OpenView() gigatypes.StateView { // Panics on I/O errors or unsupported key types. func (s *CommitStore) Get(moduleName string, key []byte) ([]byte, bool) { // Read lock: the internal getters (getAccountData, getStorageData, - // getCodeData, getMiscData) read the pending-writes maps, which - // ApplyChangeSets/Commit mutate under the write lock. Has delegates to Get - // and must not take its own lock (RWMutex read locks are not reentrant). + // getCodeData, getMiscData) read the stores, which Commit stages a block + // into under the write lock. Has delegates to Get and must not take its + // own lock (RWMutex read locks are not reentrant). s.mu.RLock() defer s.mu.RUnlock() @@ -92,7 +92,7 @@ func (s *CommitStore) Get(moduleName string, key []byte) ([]byte, bool) { // If not found, returns (-1, false, nil). func (s *CommitStore) GetBlockHeightModified(moduleName string, key []byte) (int64, bool, error) { // Read lock: the internal getters (getStorageData, getAccountData, - // getCodeData) read the pending-writes maps mutated under the write lock. + // getCodeData) read the stores, which Commit stages a block into under the write lock. s.mu.RLock() defer s.mu.RUnlock() @@ -148,17 +148,10 @@ func (s *CommitStore) Has(moduleName string, key []byte) bool { // Internal Getters // ============================================================================= // -// Each of these reads through its store, which already merges the values staged by the block currently -// being applied over the on-disk data. A key absent from both, and a key that same block deleted -// earlier, both come back as the zero value; every caller below collapses those two cases anyway. +// Each of these reads the committed block through its store. An absent key comes back as the zero value. // parseRow deserializes a row read out of a store, or returns the zero value of T when the row was // not there. -// -// A key that the block currently being applied has already deleted reads as absent rather than as a -// tombstone, so callers need not distinguish "never existed" from "deleted by the block in progress" — -// both yield the zero value, which every FlatKV read path already treats the same way as a value whose -// IsDelete reports true. func parseRow[T vtype.VType](raw []byte, found bool, parse func([]byte) (T, error)) (T, error) { var zero T if !found { diff --git a/sei-db/state_db/sc/flatkv/store_read_test.go b/sei-db/state_db/sc/flatkv/store_read_test.go index f19fd71261..0f0a27c5a8 100644 --- a/sei-db/state_db/sc/flatkv/store_read_test.go +++ b/sei-db/state_db/sc/flatkv/store_read_test.go @@ -22,7 +22,8 @@ import ( // Get, Has, and Pending Writes // ============================================================================= -func TestStoreGetPendingWrites(t *testing.T) { +// TestStoreGetBufferedWrite verifies that a write ApplyChangeSets buffered is not readable until Commit. +func TestStoreGetBufferedWrite(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -35,25 +36,22 @@ func TestStoreGetPendingWrites(t *testing.T) { _, found := s.Get(keys.EVMStoreKey, key) require.False(t, found) - // Apply changeset (adds to pending writes) + // Apply changeset (buffered until commit) cs := makeChangeSet(key, value, false) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) - // Should be readable from pending writes - got, found := s.Get(keys.EVMStoreKey, key) - require.True(t, found) - require.Equal(t, value, got) + _, found = s.Get(keys.EVMStoreKey, key) + require.False(t, found, "a buffered write must not be readable before commit") - // Commit commitAndCheck(t, s) - // Should still be readable after commit - got, found = s.Get(keys.EVMStoreKey, key) + got, found := s.Get(keys.EVMStoreKey, key) require.True(t, found) require.Equal(t, value, got) } -func TestStoreGetPendingDelete(t *testing.T) { +// TestStoreGetBufferedDelete verifies that a delete ApplyChangeSets buffered takes effect only at Commit. +func TestStoreGetBufferedDelete(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -70,18 +68,15 @@ func TestStoreGetPendingDelete(t *testing.T) { _, found := s.Get(keys.EVMStoreKey, key) require.True(t, found) - // Apply delete (pending) + // Apply delete (buffered until commit) cs2 := makeChangeSet(key, nil, true) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs2})) - // Should not be found (pending delete) _, found = s.Get(keys.EVMStoreKey, key) - require.False(t, found) + require.True(t, found, "a buffered delete must not take effect before commit") - // Commit delete commitAndCheck(t, s) - // Still should not be found _, found = s.Get(keys.EVMStoreKey, key) require.False(t, found) } @@ -132,7 +127,8 @@ func TestStoreHas(t *testing.T) { // Misc Key Get Tests // ============================================================================= -func TestStoreGetMiscPendingWrites(t *testing.T) { +// TestStoreGetMiscBufferedWrite verifies that a buffered misc write is not readable until Commit. +func TestStoreGetMiscBufferedWrite(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -147,19 +143,17 @@ func TestStoreGetMiscPendingWrites(t *testing.T) { cs := makeChangeSet(miscKey, []byte{0x00, 0x40}, false) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) - // Should be readable from pending writes - got, found := s.Get(keys.EVMStoreKey, miscKey) - require.True(t, found) - require.Equal(t, []byte{0x00, 0x40}, got) + _, found = s.Get(keys.EVMStoreKey, miscKey) + require.False(t, found, "a buffered write must not be readable before commit") - // Commit and still readable commitAndCheck(t, s) - got, found = s.Get(keys.EVMStoreKey, miscKey) + got, found := s.Get(keys.EVMStoreKey, miscKey) require.True(t, found) require.Equal(t, []byte{0x00, 0x40}, got) } -func TestStoreGetMiscPendingDelete(t *testing.T) { +// TestStoreGetMiscBufferedDelete verifies that a buffered misc delete takes effect only at Commit. +func TestStoreGetMiscBufferedDelete(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -174,13 +168,12 @@ func TestStoreGetMiscPendingDelete(t *testing.T) { _, found := s.Get(keys.EVMStoreKey, miscKey) require.True(t, found) - // Apply delete (pending) + // Apply delete (buffered until commit) cs2 := makeChangeSet(miscKey, nil, true) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs2})) - // Should not be found (pending delete) _, found = s.Get(keys.EVMStoreKey, miscKey) - require.False(t, found) + require.True(t, found, "a buffered delete must not take effect before commit") // Commit delete commitAndCheck(t, s) @@ -356,27 +349,25 @@ func TestGetCodeFromCommittedDB(t *testing.T) { bytecode := []byte{0x60, 0x80, 0x52} codeKey := keys.BuildEVMKey(keys.EVMKeyCode, addr[:]) - // Pending code write is visible before commit + // A buffered code write is not visible before commit require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ namedCS(codePair(addr, bytecode)), })) - got, found := s.Get(keys.EVMStoreKey, codeKey) - require.True(t, found, "pending code write should be visible") - require.Equal(t, bytecode, got) + _, found := s.Get(keys.EVMStoreKey, codeKey) + require.False(t, found, "buffered code write should not be visible") commitAndCheck(t, s) - // Still visible after commit - got, found = s.Get(keys.EVMStoreKey, codeKey) + got, found := s.Get(keys.EVMStoreKey, codeKey) require.True(t, found) require.Equal(t, bytecode, got) - // Pending code delete hides it before commit + // A buffered code delete does not hide it before commit require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ namedCS(codeDeletePair(addr)), })) _, found = s.Get(keys.EVMStoreKey, codeKey) - require.False(t, found, "pending code delete should hide the entry") + require.True(t, found, "buffered code delete should not hide the entry") commitAndCheck(t, s) _, found = s.Get(keys.EVMStoreKey, codeKey) @@ -429,10 +420,9 @@ func TestGetUnknownKeyTypes(t *testing.T) { // R-6 ~ R-8: Account Delete Semantics (isDelete interaction) // ============================================================================= -// TestGetAccountAfterFullDeletePending verifies that Get returns (nil, false) -// for both nonce and codehash when all account fields are zeroed (pending). -// The isDelete guard in store_read.go:39-41 hides the zeroed row entirely. -func TestGetAccountAfterFullDeletePending(t *testing.T) { +// TestGetAccountAfterFullDeleteUnflushed verifies that Get returns (nil, false) for both nonce and codehash +// once all account fields are deleted, while the deleting block has not yet been flushed to disk. +func TestGetAccountAfterFullDeleteUnflushed(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -448,6 +438,7 @@ func TestGetAccountAfterFullDeletePending(t *testing.T) { require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ namedCS(nonceDeletePair(addr), codeHashDeletePair(addr)), })) + commitBuffered(t, s) _, nonceFound := s.Get(keys.EVMStoreKey, nonceKey) require.False(t, nonceFound, "nonce should not be found after full delete (isDelete=true)") @@ -744,12 +735,10 @@ func TestIteratorDoesNotSeePendingWrites(t *testing.T) { namedCS(storagePair(addr, slot, []byte{0xAA})), })) - // Before commit: the raw scan is refused outright rather than quietly omitting the staged row. It - // iterates the stores, which see staged rows, so the guarantee that an export never contains an - // uncommitted row is enforced as a precondition. + // Before commit: the raw scan is refused outright while a block is buffered. _, err := s.RawGlobalIterator() - require.Error(t, err, "a raw scan must be refused while a block is staged") - require.Contains(t, err.Error(), "staged and uncommitted") + require.Error(t, err, "a raw scan must be refused while a block is buffered") + require.Contains(t, err.Error(), "buffered and uncommitted") commitAndCheck(t, s) @@ -776,12 +765,12 @@ func TestIteratorDoesNotSeePendingDeletes(t *testing.T) { })) commitAndCheck(t, s) - // Apply pending delete for middle key + // Apply a buffered delete for middle key require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ namedCS(storageDeletePair(addr, slotN(0x02))), })) - // The scan is refused while the delete is staged, so it can never report a half-applied block. + // The scan is refused while the delete is buffered, so it can never report a half-applied block. commitAndCheck(t, s) // After commit: only 2 remain @@ -888,7 +877,7 @@ func TestHasForAllKeyTypes(t *testing.T) { require.True(t, found) } -func TestHasOnPendingDeletes(t *testing.T) { +func TestHasOnUnflushedDeletes(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -904,8 +893,9 @@ func TestHasOnPendingDeletes(t *testing.T) { delCS := makeChangeSet(key, nil, true) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{delCS})) + commitBuffered(t, s) found = s.Has(keys.EVMStoreKey, key) - require.False(t, found, "Has should return false for pending-deleted key") + require.False(t, found, "Has should return false for a deleted key") } func TestHasOnReadOnlyStore(t *testing.T) { diff --git a/sei-db/state_db/sc/flatkv/store_replay.go b/sei-db/state_db/sc/flatkv/store_replay.go index ced6f734fe..3ed838502b 100644 --- a/sei-db/state_db/sc/flatkv/store_replay.go +++ b/sei-db/state_db/sc/flatkv/store_replay.go @@ -209,7 +209,7 @@ func replayBlocks( break } block, mutations := it.Entry() - changesets, cErr := mutation.MutationsToChangeSets(mutations) + changesets, cErr := MutationsToChangeSets(mutations) if cErr != nil { return 0, fmt.Errorf("convert block %d: %w", block, cErr) } @@ -236,13 +236,21 @@ func (s *CommitStore) applyAndCommit( alreadyHave map[string]int64, ) error { // Replay re-reads blocks the recorded version already covers, so committedVersion may be ahead of the - // block being applied. Rewind it for the duration: ApplyChangeSets and Commit both require the - // block to be exactly committedVersion+1, and the stores that already hold this block are skipped - // individually via alreadyHave rather than by refusing the whole block. + // block being applied. Rewind it for the duration: replay requires the block to be exactly + // committedVersion+1, and the stores that already hold this block are skipped individually via + // alreadyHave rather than by refusing the whole block. if len(alreadyHave) > 0 && version <= s.committedVersion { s.committedVersion = version - 1 } - if err := s.applyChangeSets(version, changesets, alreadyHave); err != nil { + if version != s.committedVersion+1 { + return fmt.Errorf("flatkv: replay version %d must be committed version %d plus one", + version, s.committedVersion) + } + // Staged under mu, as Commit stages, which is what keeps a concurrent read from interleaving with the writes. + s.mu.Lock() + err := s.stageChangeSets(version, changesets, alreadyHave) + s.mu.Unlock() + if err != nil { return fmt.Errorf("apply v%d: %w", version, err) } if err := s.sealBlock(version, alreadyHave); err != nil { diff --git a/sei-db/state_db/sc/flatkv/store_test.go b/sei-db/state_db/sc/flatkv/store_test.go index 2eb7d50c4a..b35b617aa3 100644 --- a/sei-db/state_db/sc/flatkv/store_test.go +++ b/sei-db/state_db/sc/flatkv/store_test.go @@ -13,7 +13,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb" "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/types/mutation" "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" ) @@ -184,17 +183,14 @@ func TestStoreApplyAndCommit(t *testing.T) { cs := makeChangeSet(key, value, false) - // Apply but not commit - should be readable from pending writes + // Apply but not commit - buffered, so not yet readable require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) - got, found := s.Get(keys.EVMStoreKey, key) - require.True(t, found, "should be readable from pending writes") - require.Equal(t, value, got) + _, found := s.Get(keys.EVMStoreKey, key) + require.False(t, found, "a buffered write must not be readable before commit") - // Commit commitAndCheck(t, s) - // Still should be readable after commit - got, found = s.Get(keys.EVMStoreKey, key) + got, found := s.Get(keys.EVMStoreKey, key) require.True(t, found) require.Equal(t, value, got) } @@ -255,12 +251,11 @@ func TestStoreEmptyChangesets(t *testing.T) { require.Equal(t, int64(1), s.Version()) } -// TestStoreApplyRejectsEmptyModuleName guards against a non-EVM changeset with -// an empty Name. An empty module folds into the physical key as "/"+key, -// whose per-module meta key ("_meta/x:/hash") ParseModuleLtHashKey rejects on -// reload — silently accepting it here would make the store permanently -// unopenable rather than failing fast at Apply time. -func TestStoreApplyRejectsEmptyModuleName(t *testing.T) { +// TestStoreCommitRejectsEmptyModuleName guards against a non-EVM changeset with an empty Name. An empty module +// folds into the physical key as "/"+key, whose per-module meta key ("_meta/x:/hash") ParseModuleLtHashKey +// rejects on reload, so accepting it would make the store permanently unopenable. The commit fails before the +// WAL records the block. +func TestStoreCommitRejectsEmptyModuleName(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -269,8 +264,10 @@ func TestStoreApplyRejectsEmptyModuleName(t *testing.T) { Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{{Key: []byte("k"), Value: []byte("v")}}}, } - err := s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs}) + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + _, err := s.Commit(s.Version() + 1) require.ErrorContains(t, err, "empty module name") + require.Equal(t, int64(0), s.Version(), "a rejected block must not commit") } func TestStoreClearsPendingAfterCommit(t *testing.T) { @@ -284,13 +281,9 @@ func TestStoreClearsPendingAfterCommit(t *testing.T) { cs := makeChangeSet(key, padLeft32(0xCC), false) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) - // The staged row is readable before the commit, and the changeset is queued for the WAL. There is - // no staged-row count to assert on any more: the rows live in the store's current version, which - // deliberately does not expose a size. - staged, found := s.Get(keys.EVMStoreKey, key) - require.True(t, found, "the staged row must be readable before commit") - require.Equal(t, padLeft32(0xCC), staged) + // The changeset is buffered for the next commit. require.Len(t, s.pendingChangeSets, 1) + require.Equal(t, s.Version()+1, s.PendingVersion()) commitAndCheck(t, s) @@ -1652,7 +1645,7 @@ func TestCrashRecoveryCrashAfterWALBeforeDBCommit(t *testing.T) { // Write v2 to the WAL manually (like Commit step 1) without committing to the DBs. prior := s.OpenView() - mutations, err := mutation.MutationsFromChangeSets(s.pendingChangeSets, 2, prior) + mutations, err := MutationsFromChangeSets(s.pendingChangeSets, 2, prior) prior.Close() require.NoError(t, err) require.NoError(t, s.wal.Write(2, mutations)) diff --git a/sei-db/state_db/sc/flatkv/store_write.go b/sei-db/state_db/sc/flatkv/store_write.go index 29a9f53ac8..5d56e55c66 100644 --- a/sei-db/state_db/sc/flatkv/store_write.go +++ b/sei-db/state_db/sc/flatkv/store_write.go @@ -9,7 +9,6 @@ import ( "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/giga/types/mutation" "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" @@ -25,12 +24,9 @@ func (s *CommitStore) CommitStateChanges(blockNum int64, changeset []*proto.Name return nil } -// Commit persists buffered writes at the given version (block height). One Commit persists exactly one -// block; version must equal the height the pending writes were stamped with. Consecutive commits must also -// be contiguous: the state WAL rejects a version that skips a height, though the first block written to an -// empty WAL may be any height. -// Protocol: WAL → seal on every store, each one's LocalMeta finalized into the same batch as its diff -// → asynchronous flush. On crash, catchup replays WAL to recover incomplete commits. +// Commit commits the block ApplyChangeSets buffered at version, or an empty block if nothing is buffered, and +// returns version. version must be the committed version plus one; committing the committed version again does +// nothing. func (s *CommitStore) Commit(version int64) (committed int64, err error) { start := time.Now() @@ -80,16 +76,22 @@ func (s *CommitStore) Commit(version int64) (committed int64, err error) { version, s.committedVersion+1, s.committedVersion) } - // Step 1: Write the WAL (source of truth) before the DBs, so crash recovery via catchup stays valid. + // Step 1: Stage the block ApplyChangeSets buffered into the stores. Staging validates every changeset before + // it writes anything, so a malformed block fails here, before the WAL records it. + if err := s.stageChangeSets(version, s.pendingChangeSets, nil); err != nil { + return version, fmt.Errorf("stage block %d: %w", version, err) + } + + // Step 2: Write the WAL (source of truth) before the seal, so crash recovery via catchup stays valid. // Write records this block's mutations and Flush makes the record durable. An empty block (no // ApplyChangeSets) writes an empty but contiguous record. Skipped entirely when the WAL is nil — the // outer context then owns the WAL pipeline. if s.wal != nil { s.phaseTimer.SetPhase("commit_convert_mutations") - // The last sealed block is the one before this one, since this block is not sealed until Step 2. + // The last sealed block is the one before this one, since this block is not sealed until Step 3. height := uint64(version) //nolint:gosec // version > committed >= 0 prior := s.OpenView() - mutations, err := mutation.MutationsFromChangeSets(s.pendingChangeSets, height, prior) + mutations, err := MutationsFromChangeSets(s.pendingChangeSets, height, prior) prior.Close() if err != nil { return version, fmt.Errorf("convert block %d to mutations: %w", version, err) @@ -104,22 +106,22 @@ func (s *CommitStore) Commit(version int64) (committed int64, err error) { } } - // Step 2: Seal the block on every store, hash it, and carry each database's metadata down with its - // diff. The stores flush to Pebble asynchronously from here; the WAL (Step 1) remains the source of + // Step 3: Seal the block on every store, hash it, and carry each database's metadata down with its + // diff. The stores flush to Pebble asynchronously from here; the WAL (Step 2) remains the source of // truth for anything that has not landed yet, so a restart self-heals via catchup. if err := s.sealBlock(version, nil); err != nil { return version, fmt.Errorf("seal block: %w", err) } - // Step 3: Update in-memory committed state, only once every store accepted the seal. The block's + // Step 4: 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 handed to the // registered listeners once it is. s.committedVersion = version - // Step 4: Clear per-block bookkeeping + // Step 5: Clear per-block bookkeeping s.clearPendingBlock() - // Step 5: Offer the block to the snapshot writer, which decides whether it becomes a snapshot and, + // Step 6: Offer the block to the snapshot writer, which decides whether it becomes a snapshot and, // if so, writes it on its own goroutine. Periodic snapshots are what keep the WAL bounded and // restarts fast. if s.snapshotWriter != nil { 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 6ebb00e6d4..a0a491afc9 100644 --- a/sei-db/state_db/sc/flatkv/store_write_test.go +++ b/sei-db/state_db/sc/flatkv/store_write_test.go @@ -353,15 +353,10 @@ func TestAccountValueStorage(t *testing.T) { } require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) - - // AccountValue structure: one row per address containing both nonce and codehash. There is no - // staged-row count to assert on any more, so assert the row itself is there. - requireStaged(t, s.accountStore, accountPhysKey(addr), "expected one staged AccountValue row") - - // Commit commitAndCheck(t, s) - // Verify AccountValue is stored in accountDB with physical key + // AccountValue structure: one row per address containing both nonce and codehash, stored in accountDB + // under the account's physical key. stored, err := s.rawDBFor(accountDBDir).Get(accountPhysKey(addr)) require.NoError(t, err) require.NotNil(t, stored) @@ -402,10 +397,6 @@ func TestStoreWriteMiscKeys(t *testing.T) { cs := makeChangeSet(codeSizeKey, codeSizeValue, false) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) - - // Should be staged in the misc store - requireStaged(t, s.miscStore, ktype.BuildPhysicalKey(keys.EVMStoreKey, codeSizeKey)) - commitAndCheck(t, s) // Verify miscDB LocalMeta is updated. Read it back: the finalizer writes it, so the store's @@ -862,6 +853,7 @@ func TestAccountFieldsMergeIntoOneRow(t *testing.T) { }, } require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + commitBuffered(t, s) accountWrite := stagedRow(t, s.accountStore, accountPhysKey(addr), vtype.DeserializeAccountData) require.NotNil(t, accountWrite) @@ -969,9 +961,10 @@ func TestLtHashAccountFieldMerge(t *testing.T) { }, } require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + commitBuffered(t, s) - // Both changeset entries merge into one AccountValue: the single staged row carries the nonce and - // the codehash together. + // Both changeset entries merge into one AccountValue: the single row carries the nonce and the + // codehash together. accountWrite := stagedRow(t, s.accountStore, accountPhysKey(addr), vtype.DeserializeAccountData) require.NotNil(t, accountWrite) require.Equal(t, uint64(10), accountWrite.GetNonce()) @@ -1218,7 +1211,7 @@ func TestSubDBEntryCount(t *testing.T) { } // ============================================================================= -// ApplyChangeSets Input Validation Error Paths +// Changeset Input Validation Error Paths (reported by Commit) // ============================================================================= func TestApplyChangeSetsInvalidNonceLength(t *testing.T) { @@ -1237,7 +1230,8 @@ func TestApplyChangeSetsInvalidNonceLength(t *testing.T) { }, }, } - err := s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs}) + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + _, err := s.Commit(s.Version() + 1) require.Error(t, err) require.Contains(t, err.Error(), "invalid nonce value length") } @@ -1258,7 +1252,8 @@ func TestApplyChangeSetsInvalidCodehashLength(t *testing.T) { }, }, } - err := s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs}) + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + _, err := s.Commit(s.Version() + 1) require.Error(t, err) require.Contains(t, err.Error(), "invalid codehash value length") } @@ -1672,13 +1667,10 @@ func TestApplyChangeSetsNonEVMModuleRoutesToMisc(t *testing.T) { require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) require.Len(t, s.pendingChangeSets, 1) - // Physical key in the misc store should be module-prefixed: "bank/some-bank-key" - physKey := string(ktype.BuildPhysicalKey("bank", []byte("some-bank-key"))) - requireStaged(t, s.miscStore, []byte(physKey), - "misc store should contain module-prefixed key %q", physKey) - - // Persist and verify round-trip via raw miscDB lookup + // Persist and verify round-trip via raw miscDB lookup. The physical key in the misc store should be + // module-prefixed: "bank/some-bank-key" commitAndCheck(t, s) + physKey := string(ktype.BuildPhysicalKey("bank", []byte("some-bank-key"))) require.NotEqual(t, hashBefore, rootHash(s), "misc-routed key changes hash") raw, err := s.rawDBFor(miscDBDir).Get([]byte(physKey)) require.NoError(t, err) @@ -1707,11 +1699,12 @@ func TestApplyChangeSetsMixedEVMAndNonEVM(t *testing.T) { } require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{evmCS, bankCS})) + commitBuffered(t, s) // EVM storage write should exist. requireStaged(t, s.storageStore, ktype.BuildPhysicalEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, slot))) - // The EVM value should be readable via pending writes. + // The EVM value should be readable. val, found := s.Get(keys.EVMStoreKey, storageKey) require.True(t, found) require.Equal(t, padLeft32(0x42), val) @@ -1790,15 +1783,18 @@ func TestApplyChangeSetsInvalidAddressLength(t *testing.T) { } // Routed to EVMKeyMisc (not Nonce), so no address validation error. require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + commitBuffered(t, s) requireStaged(t, s.miscStore, ktype.BuildPhysicalKey(keys.EVMStoreKey, truncatedNonceKey), "malformed nonce key should be treated as misc") } -func TestApplyChangeSetsErrorRecoveryPartialState(t *testing.T) { +// TestCommitParseErrorLeavesNoPartialState verifies that a block whose changesets fail to parse stages none of +// its rows and leaves the lattice state unchanged. +func TestCommitParseErrorLeavesNoPartialState(t *testing.T) { s := setupTestStore(t) defer s.Close() - // Seed non-trivial per-module state so a failed Apply is checked against + // Seed non-trivial per-module state so a failed Commit is checked against // real buckets, not empty maps that would trivially equal a fresh clone. seedAddr := addrN(0xAA) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{ @@ -1822,28 +1818,23 @@ func TestApplyChangeSetsErrorRecoveryPartialState(t *testing.T) { }}, } - err := s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs}) + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + version := s.Version() + _, err := s.Commit(version + 1) require.Error(t, err) require.Contains(t, err.Error(), "invalid nonce value length") - // A failed Apply must stage nothing and leave the working lattice state untouched, so a later - // Commit cannot seal orphaned rows against a stale AppHash. The valid storage pair that preceded - // the invalid one is the one that would leak. + // A failed Commit must stage nothing and leave the lattice state untouched. The valid storage pair that + // preceded the invalid one is the one that would leak. requireNotStaged(t, s.storageStore, ktype.BuildPhysicalEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, slot))) requireNotStaged(t, s.accountStore, accountPhysKey(addr)) - require.Empty(t, s.pendingChangeSets) - require.Equal(t, int64(0), s.pendingBlockHeight) + require.Equal(t, version, s.Version()) requireWorkingHashesUnchanged(t, s, before) - - validCS := makeChangeSet(storageKey, padLeft32(0xBB), false) - require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{validCS})) } -// TestApplyChangeSetsKeepsPendingCleanOnLaterParseError covers the case where -// an earlier DB kind would previously have been maps.Copy'd into the pending -// overlay before a later process*Changes failure — leaving rows that Commit -// could persist while working LtHash still reflected the pre-failure state. -func TestApplyChangeSetsKeepsPendingCleanOnLaterParseError(t *testing.T) { +// TestCommitKeepsStoresCleanOnLaterParseError verifies that when one DB kind of a block fails to parse, no +// other kind's rows reach a store and the lattice state is unchanged. +func TestCommitKeepsStoresCleanOnLaterParseError(t *testing.T) { s := setupTestStore(t) defer s.Close() @@ -1867,27 +1858,16 @@ func TestApplyChangeSetsKeepsPendingCleanOnLaterParseError(t *testing.T) { }}, } - err := s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs}) + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + version := s.Version() + _, err := s.Commit(version + 1) require.Error(t, err) require.Contains(t, err.Error(), "failed to parse storage changes") requireNotStaged(t, s.accountStore, accountPhysKey(addr), "account rows must not stage before storage validation finishes") - require.Empty(t, s.pendingChangeSets) - require.Equal(t, int64(0), s.pendingBlockHeight) + require.Equal(t, version, s.Version()) requireWorkingHashesUnchanged(t, s, before) - - // A subsequent Commit must not invent on-disk state for the failed apply. - // A successful apply stages every row, so absence from - // pending is not enough — read the keys back and check committedLtHash - // (the AppHash input) stayed put. - _, err = s.Commit(s.Version() + 1) - require.NoError(t, err) - 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") } // A hash failure is no longer a commit failure: hashing happens after the block is committed, so the @@ -1983,7 +1963,9 @@ func TestApplyChangeSetsEVMKeyEmptySkipped(t *testing.T) { {Key: []byte{}, Value: []byte{0xAA}}, }}, } - require.Error(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) + _, err := s.Commit(s.Version() + 1) + require.Error(t, err) } func TestApplyChangeSetsNonPrefixedKeyGoesToMisc(t *testing.T) { @@ -2103,13 +2085,13 @@ func TestRejectedCommitLeavesStoreIntact(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "committing bad version") require.Equal(t, int64(0), s.Version(), "rejected commit must not advance version") - requireStaged(t, s.storageStore, - ktype.BuildPhysicalEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, slotN(0x01))), - "rejected commit must leave the staged row intact") + require.Equal(t, int64(1), s.PendingVersion(), "rejected commit must leave the buffered block intact") v, err := s.Commit(1) require.NoError(t, err) require.Equal(t, int64(1), v) + _, found := s.Get(keys.EVMStoreKey, key) + require.True(t, found, "the buffered row must be committed by the correct commit") } // TestApplyChangeSetsAllowsSameHeightRepeatsOnly pins the one-block-per-commit diff --git a/sei-db/state_db/sc/flatkv/test_verify.go b/sei-db/state_db/sc/flatkv/test_verify.go index 1b25c18c02..f0bf40c097 100644 --- a/sei-db/state_db/sc/flatkv/test_verify.go +++ b/sei-db/state_db/sc/flatkv/test_verify.go @@ -12,8 +12,7 @@ import ( // VerifyLtHash scans all four data stores and checks the recomputed state against the store's maintained // metadata. Beyond the global root it validates the per-DB, per-module decomposition — per-module hashes // and per-module key/byte totals — catching drift in that bookkeeping even when the global root matches. -// A store with a staged block is rejected: the scan sees that block's rows and the maintained hashes do -// not. +// A store with a buffered, uncommitted block is rejected. // // Buffers one store's worth of KVs in memory at a time and is not cancellable. // Intended for tests and offline maintenance / migration checks; not suitable @@ -27,12 +26,10 @@ func VerifyLtHash(s gigatypes.LiveStateStore) error { } func verifyLtHashInternal(cs *CommitStore) error { - // The scan walks the stores, which merge a staged block's rows, while the hashes it is compared - // against do not account for that block until it is sealed. Refuse rather than report a healthy - // store as corrupt. + // A block is buffered only mid-block, and verification belongs between blocks. if cs.pendingBlockHeight != 0 { return fmt.Errorf( - "VerifyLtHash: store has uncommitted writes: block %d is staged; "+ + "VerifyLtHash: store has uncommitted writes: block %d is buffered; "+ "commit or reopen readonly before verifying", cs.pendingBlockHeight, ) diff --git a/sei-db/state_db/sc/flatkv/testutil_test.go b/sei-db/state_db/sc/flatkv/testutil_test.go index 4e2b69f806..8ea1c60110 100644 --- a/sei-db/state_db/sc/flatkv/testutil_test.go +++ b/sei-db/state_db/sc/flatkv/testutil_test.go @@ -195,6 +195,15 @@ func commitAndCheck(t *testing.T, s *CommitStore) int64 { return v } +// commitBuffered commits the block ApplyChangeSets buffered without waiting for it to reach disk, so later reads +// go through the stores' unflushed versions. +func commitBuffered(t *testing.T, s *CommitStore) int64 { + t.Helper() + v, err := s.Commit(s.Version() + 1) + require.NoError(t, err) + return v +} + // 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 diff --git a/sei-db/state_db/sc/flatkv/wal_format_recovery_test.go b/sei-db/state_db/sc/flatkv/wal_format_recovery_test.go index cdae470a96..a662bfa1a7 100644 --- a/sei-db/state_db/sc/flatkv/wal_format_recovery_test.go +++ b/sei-db/state_db/sc/flatkv/wal_format_recovery_test.go @@ -182,7 +182,7 @@ func runReference(t *testing.T, blocks [][]*proto.NamedChangeSet) referenceRun { height := int64(i + 1) prior := store.OpenView() //nolint:gosec // height > 0 - mutations, err := mutation.MutationsFromChangeSets(block, uint64(height), prior) + mutations, err := MutationsFromChangeSets(block, uint64(height), prior) prior.Close() require.NoError(t, err) run.blockMutations[i] = mutations From 564e19d5d71142b00a903a38e7b39ff52ef23e4c Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 26 Oct 2026 09:56:14 -0500 Subject: [PATCH 2/6] defensive check --- app/upgrade_exit_flush_test.go | 6 +++-- .../rootmulti/hash_consistency_test.go | 25 +++++++++++++++++++ sei-cosmos/storev2/rootmulti/store.go | 17 +++++++++---- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/app/upgrade_exit_flush_test.go b/app/upgrade_exit_flush_test.go index d549efe5c1..da788670fe 100644 --- a/app/upgrade_exit_flush_test.go +++ b/app/upgrade_exit_flush_test.go @@ -82,12 +82,14 @@ func TestUpgradeNeededPanicFlushesMemIAVLChangelog(t *testing.T) { ) require.False(t, testApp.UpgradeKeeper.HasHandler(planName)) - _, err := finalizeEmptyBlock(testApp, 1) - require.NoError(t, err) + // Scheduled into the deliver state block 1 reuses from InitChain, so the write reaches the store before the + // block's working hash is taken, as a governance proposal executed in the block would. require.NoError(t, testApp.UpgradeKeeper.ScheduleUpgrade(testApp.GetContextForDeliverTx(nil), upgradetypes.Plan{ Name: planName, Height: planHeight, })) + _, err := finalizeEmptyBlock(testApp, 1) + require.NoError(t, err) _, err = testApp.Commit(context.Background()) require.NoError(t, err) for height := int64(2); height <= commitBlocks; height++ { diff --git a/sei-cosmos/storev2/rootmulti/hash_consistency_test.go b/sei-cosmos/storev2/rootmulti/hash_consistency_test.go index 1785e82a63..416fa0445d 100644 --- a/sei-cosmos/storev2/rootmulti/hash_consistency_test.go +++ b/sei-cosmos/storev2/rootmulti/hash_consistency_test.go @@ -180,3 +180,28 @@ func TestCommitAndHistoricalQueryWithDoubleFlush(t *testing.T) { scStore.Close() } } + +// TestWriteAfterWorkingHashPanics verifies that a write reaching the commit store after the block's working hash +// was taken crashes rather than being silently moved into another block. +func TestWriteAfterWorkingHashPanics(t *testing.T) { + scConfig := seidbconfig.DefaultStateCommitConfig() + scConfig.MemIAVLConfig.AsyncCommitBuffer = 0 + store := NewStore(t.TempDir(), scConfig, seidbconfig.StateStoreConfig{}, nil) + defer func() { _ = store.Close() }() + + bankKey := types.NewKVStoreKey("bank") + store.MountStoreWithDB(bankKey, types.StoreTypeIAVL, nil) + require.NoError(t, store.LoadLatestVersion()) + + cms := store.CacheMultiStore() + cms.GetKVStore(bankKey).Set([]byte("supply"), []byte("1000")) + cms.Write() + _, err := store.GetWorkingHash() + require.NoError(t, err) + + cms.GetKVStore(bankKey).Set([]byte("supply"), []byte("2000")) + cms.Write() + require.PanicsWithValue(t, + "rootmulti: stores [bank] were written at height 1 after the block's working hash was taken", + func() { _, _ = store.GetWorkingHash() }) +} diff --git a/sei-cosmos/storev2/rootmulti/store.go b/sei-cosmos/storev2/rootmulti/store.go index 6599348f83..c0da359ac8 100644 --- a/sei-cosmos/storev2/rootmulti/store.go +++ b/sei-cosmos/storev2/rootmulti/store.go @@ -299,11 +299,18 @@ func (rs *Store) flush() error { // never existed. return nil } - // A later run that does carry writes falls through, and nothing detects it: flatkv is already sealed - // at currentVersion, so its writer stamps the batch currentVersion+1 and the writes silently land in - // the next block, while the state store and memIAVL still take them at currentVersion. That nothing - // writes to the multistore after the first working hash — notably in the preCommitHandler — is a - // convention, not an enforced invariant. + if len(changeSets) > 0 && rs.flushedVersion == currentVersion { + // The block's first flush is the one its working hash was taken after, so these writes would be + // missing from the hash already handed to consensus, and flatkv, already committed at + // currentVersion, would silently place them in the next block. Nothing on mainnet writes here. + names := make([]string, len(changeSets)) + for i, cs := range changeSets { + names[i] = cs.Name + } + panic(fmt.Sprintf( + "rootmulti: stores %v were written at height %d after the block's working hash was taken", + names, currentVersion)) + } rs.flushedVersion = currentVersion // Capture the (sorted) aggregate changeset for hash logging once per block. nil is normalized to an From 0bfa309ee5b3a051634e5a03aa247aa5cb10d09b Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Mon, 26 Oct 2026 11:43:40 -0500 Subject: [PATCH 3/6] fix broken tests --- app/test_helpers.go | 40 ++++++++--- evmrpc/simulate_test.go | 11 +-- evmrpc/tests/utils.go | 24 ++++--- sei-cosmos/x/bank/app_test.go | 68 +++++++++---------- sei-cosmos/x/bank/handler_test.go | 2 +- sei-cosmos/x/slashing/app_test.go | 7 +- sei-cosmos/x/staking/app_test.go | 14 +++- .../keeper/snapshotter_integration_test.go | 22 ++++-- 8 files changed, 117 insertions(+), 71 deletions(-) diff --git a/app/test_helpers.go b/app/test_helpers.go index 34768b0d01..51bc4b5663 100644 --- a/app/test_helpers.go +++ b/app/test_helpers.go @@ -775,11 +775,23 @@ func SetupWithGenesisAccounts(t *testing.T, genAccs []authtypes.GenesisAccount, ) _, _ = app.Commit(context.Background()) - _, _ = app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{Header: &tmproto.Header{ChainID: "sei-test", Height: app.LastBlockHeight() + 1}}) return app } +// CommitBlockWithWrites makes write's state changes in the next block, before its working hash is taken, and +// commits that block. +func CommitBlockWithWrites(t *testing.T, app *App, write func(ctx sdk.Context)) { + header := tmproto.Header{ChainID: app.ChainID, Height: app.LastBlockHeight() + 1} + ctx, writeCache := app.NewUncachedContext(false, header).CacheContext() + write(ctx) + writeCache() + _, err := app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{Header: &header}) + require.NoError(t, err) + _, err = app.Commit(context.Background()) + require.NoError(t, err) +} + type GenerateAccountStrategy func(int) []sdk.AccAddress // createRandomAccounts is a strategy used by addTestAddrs() in order to generated addresses in random order. @@ -976,12 +988,13 @@ func SignCheckDeliver( require.Error(t, err) require.Nil(t, res) } - _, err = app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{Header: &tmproto.Header{ChainID: header.ChainID, Height: header.Height}}) + blockRes, err := app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{ + Txs: [][]byte{txBytes}, + Header: &tmproto.Header{ChainID: header.ChainID, Height: header.Height}, + }) require.NoError(t, err) - gInfo, res, deliverErr := app.Deliver(txCfg.TxEncoder(), tx) - if deliverErr == nil && res == nil { - deliverErr = fmt.Errorf("deliver tx returned no result") - } + require.Len(t, blockRes.TxResults, 1) + gInfo, res, deliverErr := txResultOutcome(blockRes.TxResults[0]) if expPass { require.NoError(t, deliverErr) @@ -991,14 +1004,25 @@ func SignCheckDeliver( require.Nil(t, res) } - _, err = app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{Header: &tmproto.Header{ChainID: header.ChainID, Height: header.Height}}) - require.NoError(t, err) _, err = app.Commit(context.Background()) require.NoError(t, err) return gInfo, res, deliverErr } +// txResultOutcome returns a block's tx result in the form BaseApp.Deliver() reports it: a nil result and the +// registered error for a failed tx. +func txResultOutcome(txRes *abci.ExecTxResult) (sdk.GasInfo, *sdk.Result, error) { + gInfo := sdk.GasInfo{ + GasWanted: uint64(txRes.GasWanted), //nolint:gosec // gas is never negative + GasUsed: uint64(txRes.GasUsed), //nolint:gosec // gas is never negative + } + if txRes.Code != 0 { + return gInfo, nil, errors.ABCIError(txRes.Codespace, txRes.Code, txRes.Log) + } + return gInfo, &sdk.Result{Data: txRes.Data, Log: txRes.Log, Events: txRes.Events}, nil +} + func GenSequenceOfTxs(txGen client.TxConfig, msgs []sdk.Msg, accNums []uint64, initSeqNums []uint64, numToGenerate int, priv ...cryptotypes.PrivKey) ([]sdk.Tx, error) { txs := make([]sdk.Tx, numToGenerate) var err error diff --git a/evmrpc/simulate_test.go b/evmrpc/simulate_test.go index 0ecb8db67f..32b4c71bed 100644 --- a/evmrpc/simulate_test.go +++ b/evmrpc/simulate_test.go @@ -1133,11 +1133,9 @@ func TestStateAtBlockRetracesLockedCoinsLookupFromRecordedUpgradeHeights(t *test return genesisTime.Add(time.Duration(height) * time.Second) } for height := int64(1); height <= latestHeight; height++ { - _, err := testApp.FinalizeBlock(t.Context(), &abci.RequestFinalizeBlock{ - Header: &tenderminttypes.Header{ChainID: testApp.ChainID, Height: height, Time: blockTime(height)}, - }) - require.NoError(t, err) - blockCtx := testApp.GetContextForDeliverTx(nil) + header := tenderminttypes.Header{ChainID: testApp.ChainID, Height: height, Time: blockTime(height)} + // Writes made on the commit store before FinalizeBlock land in this height's working hash. + blockCtx, writeBlock := testApp.NewUncachedContext(false, header).CacheContext() switch height { case 1: testApp.AccountKeeper.SetAccount(blockCtx, testApp.AccountKeeper.NewAccountWithAddress(blockCtx, holder)) @@ -1146,6 +1144,9 @@ func TestStateAtBlockRetracesLockedCoinsLookupFromRecordedUpgradeHeights(t *test case v68UpgradeHeight: testApp.UpgradeKeeper.SetDone(blockCtx, "v6.8") } + writeBlock() + _, err := testApp.FinalizeBlock(t.Context(), &abci.RequestFinalizeBlock{Header: &header}) + require.NoError(t, err) _, err = testApp.Commit(t.Context()) require.NoError(t, err) } diff --git a/evmrpc/tests/utils.go b/evmrpc/tests/utils.go index a121084935..0de1e93912 100644 --- a/evmrpc/tests/utils.go +++ b/evmrpc/tests/utils.go @@ -123,22 +123,24 @@ func initializeApp( ) (*app.App, *abci.ResponseFinalizeBlock) { a := app.Setup(t, false, true, chainID == "pacific-1") a.ChainID = chainID + header := tmproto.Header{ + ChainID: chainID, + Height: 1, + Time: time.Now(), + } + // The initializers write into the genesis block InitChain left open, before FinalizeBlock takes its hash. + ctx := a.GetContextForDeliverTx(nil).WithBlockHeader(header).WithBlockHeight(header.Height) + for _, i := range initializer { + i(ctx, a) + } res, err := a.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{ - Txs: [][]byte{}, - Hash: mockHash(1, 0), - Header: &tmproto.Header{ - ChainID: chainID, - Height: 1, - Time: time.Now(), - }, + Txs: [][]byte{}, + Hash: mockHash(1, 0), + Header: &header, }) if err != nil { panic(err) } - ctx := a.GetContextForDeliverTx(nil) - for _, i := range initializer { - i(ctx, a) - } _, _ = a.Commit(context.Background()) return a, res } diff --git a/sei-cosmos/x/bank/app_test.go b/sei-cosmos/x/bank/app_test.go index 137b2103c8..a2ec318f38 100644 --- a/sei-cosmos/x/bank/app_test.go +++ b/sei-cosmos/x/bank/app_test.go @@ -1,7 +1,6 @@ package bank_test import ( - "context" "testing" "github.com/sei-protocol/sei-chain/app" @@ -84,14 +83,12 @@ func TestSendNotEnoughBalance(t *testing.T) { genAccs := []authtypes.GenesisAccount{acc} a := app.SetupWithGenesisAccounts(t, genAccs) - ctx := a.BaseApp.NewContext(false, tmproto.Header{}) + app.CommitBlockWithWrites(t, a, func(ctx sdk.Context) { + require.NoError(t, apptesting.FundAccount( + a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 67)))) + }) - require.NoError(t, apptesting.FundAccount(a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 67)))) - - _, err := a.Commit(context.Background()) - require.NoError(t, err) - - res1 := a.AccountKeeper.GetAccount(ctx, addr1) + res1 := a.AccountKeeper.GetAccount(a.NewContext(true, tmproto.Header{}), addr1) require.NotNil(t, res1) require.Equal(t, acc, res1.(*authtypes.BaseAccount)) @@ -101,7 +98,10 @@ func TestSendNotEnoughBalance(t *testing.T) { sendMsg := types.NewMsgSend(addr1, addr2, sdk.Coins{sdk.NewInt64Coin("foocoin", 100)}) header := tmproto.Header{ChainID: a.ChainID, Height: a.LastBlockHeight() + 1} txGen := app.MakeEncodingConfig().TxConfig - _, _, err = app.SignCheckDeliver(t, txGen, a.BaseApp, header, []sdk.Msg{sendMsg}, []uint64{origAccNum}, []uint64{origSeq}, false, false, priv1) + _, _, err := app.SignCheckDeliver( + t, txGen, a.BaseApp, header, []sdk.Msg{sendMsg}, []uint64{origAccNum}, []uint64{origSeq}, + false, false, priv1, + ) require.Error(t, err) app.CheckBalance(t, a, addr1, sdk.Coins{sdk.NewInt64Coin("foocoin", 67)}) @@ -120,13 +120,12 @@ func TestMsgMultiSendWithAccounts(t *testing.T) { genAccs := []authtypes.GenesisAccount{acc} a := app.SetupWithGenesisAccounts(t, genAccs) - ctx := a.BaseApp.NewContext(false, tmproto.Header{}) - - require.NoError(t, apptesting.FundAccount(a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 67)))) + app.CommitBlockWithWrites(t, a, func(ctx sdk.Context) { + require.NoError(t, apptesting.FundAccount( + a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 67)))) + }) - a.Commit(context.Background()) - - res1 := a.AccountKeeper.GetAccount(ctx, addr1) + res1 := a.AccountKeeper.GetAccount(a.NewContext(true, tmproto.Header{}), addr1) require.NotNil(t, res1) require.Equal(t, acc, res1.(*authtypes.BaseAccount)) @@ -181,13 +180,12 @@ func TestMsgMultiSendMultipleOut(t *testing.T) { genAccs := []authtypes.GenesisAccount{acc1, acc2} a := app.SetupWithGenesisAccounts(t, genAccs) - ctx := a.BaseApp.NewContext(false, tmproto.Header{}) - - require.NoError(t, apptesting.FundAccount(a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) - - require.NoError(t, apptesting.FundAccount(a.BankKeeper, ctx, addr2, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) - - a.Commit(context.Background()) + app.CommitBlockWithWrites(t, a, func(ctx sdk.Context) { + require.NoError(t, apptesting.FundAccount( + a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) + require.NoError(t, apptesting.FundAccount( + a.BankKeeper, ctx, addr2, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) + }) testCases := []appTestCase{ { @@ -230,15 +228,14 @@ func TestMsgMultiSendMultipleInOut(t *testing.T) { genAccs := []authtypes.GenesisAccount{acc1, acc2, acc4} a := app.SetupWithGenesisAccounts(t, genAccs) - ctx := a.BaseApp.NewContext(false, tmproto.Header{}) - - require.NoError(t, apptesting.FundAccount(a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) - - require.NoError(t, apptesting.FundAccount(a.BankKeeper, ctx, addr2, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) - - require.NoError(t, apptesting.FundAccount(a.BankKeeper, ctx, addr4, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) - - a.Commit(context.Background()) + app.CommitBlockWithWrites(t, a, func(ctx sdk.Context) { + require.NoError(t, apptesting.FundAccount( + a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) + require.NoError(t, apptesting.FundAccount( + a.BankKeeper, ctx, addr2, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) + require.NoError(t, apptesting.FundAccount( + a.BankKeeper, ctx, addr4, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) + }) testCases := []appTestCase{ { @@ -277,11 +274,10 @@ func TestMsgMultiSendDependent(t *testing.T) { genAccs := []authtypes.GenesisAccount{acc1, acc2} a := app.SetupWithGenesisAccounts(t, genAccs) - ctx := a.BaseApp.NewContext(false, tmproto.Header{}) - - require.NoError(t, apptesting.FundAccount(a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) - - a.Commit(context.Background()) + app.CommitBlockWithWrites(t, a, func(ctx sdk.Context) { + require.NoError(t, apptesting.FundAccount( + a.BankKeeper, ctx, addr1, sdk.NewCoins(sdk.NewInt64Coin("foocoin", 42)))) + }) testCases := []appTestCase{ { diff --git a/sei-cosmos/x/bank/handler_test.go b/sei-cosmos/x/bank/handler_test.go index 54f5352995..a2c5b6ef1c 100644 --- a/sei-cosmos/x/bank/handler_test.go +++ b/sei-cosmos/x/bank/handler_test.go @@ -66,7 +66,7 @@ func TestSendToModuleAccount(t *testing.T) { } app := seiapp.SetupWithGenesisAccounts(t, accs, balances...) - ctx := app.BaseApp.NewContext(false, tmproto.Header{}) + ctx, _ := app.NewUncachedContext(false, tmproto.Header{}).CacheContext() app.BankKeeper = bankkeeper.NewBaseKeeper( app.AppCodec(), app.GetKey(types.StoreKey), app.AccountKeeper, app.GetSubspace(types.ModuleName), map[string]bool{ diff --git a/sei-cosmos/x/slashing/app_test.go b/sei-cosmos/x/slashing/app_test.go index 88394df98d..6f83f4d34a 100644 --- a/sei-cosmos/x/slashing/app_test.go +++ b/sei-cosmos/x/slashing/app_test.go @@ -75,7 +75,12 @@ func TestSlashingMsgs(t *testing.T) { require.NoError(t, err) seiapp.CheckBalance(t, app, addr1, sdk.Coins{genCoin.Sub(bondCoin)}) - app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{Header: &tmproto.Header{ChainID: app.ChainID, Height: app.LastBlockHeight() + 1}}) + _, err = app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{ + Header: &tmproto.Header{ChainID: app.ChainID, Height: app.LastBlockHeight() + 1}, + }) + require.NoError(t, err) + _, err = app.Commit(context.Background()) + require.NoError(t, err) validator := checkValidator(t, app, addr1, true) require.Equal(t, sdk.ValAddress(addr1).String(), validator.OperatorAddress) diff --git a/sei-cosmos/x/staking/app_test.go b/sei-cosmos/x/staking/app_test.go index 177dbd6167..1371f477b9 100644 --- a/sei-cosmos/x/staking/app_test.go +++ b/sei-cosmos/x/staking/app_test.go @@ -78,7 +78,12 @@ func TestStakingMsgs(t *testing.T) { seiapp.CheckBalance(t, app, addr1, sdk.Coins{genCoin.Sub(bondCoin)}) header = tmproto.Header{ChainID: app.ChainID, Height: app.LastBlockHeight() + 1} - app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{Header: &tmproto.Header{ChainID: app.ChainID, Height: app.LastBlockHeight() + 1}}) + _, err = app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{ + Header: &tmproto.Header{ChainID: app.ChainID, Height: app.LastBlockHeight() + 1}, + }) + require.NoError(t, err) + _, err = app.Commit(context.Background()) + require.NoError(t, err) validator := checkValidator(t, app, sdk.ValAddress(addr1), true) require.Equal(t, sdk.ValAddress(addr1).String(), validator.OperatorAddress) @@ -86,7 +91,12 @@ func TestStakingMsgs(t *testing.T) { require.True(sdk.IntEq(t, bondTokens, validator.BondedTokens())) header = tmproto.Header{ChainID: app.ChainID, Height: app.LastBlockHeight() + 1} - app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{Header: &tmproto.Header{ChainID: app.ChainID, Height: app.LastBlockHeight() + 1}}) + _, err = app.FinalizeBlock(context.Background(), &abci.RequestFinalizeBlock{ + Header: &tmproto.Header{ChainID: app.ChainID, Height: app.LastBlockHeight() + 1}, + }) + require.NoError(t, err) + _, err = app.Commit(context.Background()) + require.NoError(t, err) // edit the validator description = types.NewDescription("bar_moniker", "", "", "", "") diff --git a/sei-wasmd/x/wasm/keeper/snapshotter_integration_test.go b/sei-wasmd/x/wasm/keeper/snapshotter_integration_test.go index 0e8d2514aa..b799c410f4 100644 --- a/sei-wasmd/x/wasm/keeper/snapshotter_integration_test.go +++ b/sei-wasmd/x/wasm/keeper/snapshotter_integration_test.go @@ -16,6 +16,7 @@ import ( sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" authtypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/types" banktypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/bank/types" + abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types" "github.com/stretchr/testify/require" @@ -43,13 +44,16 @@ func TestSnapshotter(t *testing.T) { // setup source app srcWasmApp, genesisAddr := newWasmExampleApp(t) - // store wasm codes on chain (use cached deliver-state context so - // writes are visible to subsequent reads within the same block) - ctx := srcWasmApp.NewContext(false, tmproto.Header{ - ChainID: "foo", + // close the block setup left open, then store wasm codes in the next block before its + // hash is taken + _, err := srcWasmApp.Commit(context.Background()) + require.NoError(t, err) + header := tmproto.Header{ + ChainID: srcWasmApp.ChainID, Height: srcWasmApp.LastBlockHeight() + 1, Time: time.Now(), - }) + } + ctx, writeCodes := srcWasmApp.NewUncachedContext(false, header).CacheContext() wasmKeeper := app.NewTestSupport(t, srcWasmApp).WasmKeeper() contractKeeper := keeper.NewDefaultPermissionKeeper(&wasmKeeper) @@ -63,9 +67,13 @@ func TestSnapshotter(t *testing.T) { hash := sha256.Sum256(wasmCode) srcCodeIDToChecksum[codeID] = hash[:] } + writeCodes() // create snapshot - srcWasmApp.SetDeliverStateToCommit() - srcWasmApp.Commit(context.Background()) + _, err = srcWasmApp.FinalizeBlock( + context.Background(), &abci.RequestFinalizeBlock{Header: &header}) + require.NoError(t, err) + _, err = srcWasmApp.Commit(context.Background()) + require.NoError(t, err) snapshotHeight := uint64(srcWasmApp.LastBlockHeight()) snapshot, err := srcWasmApp.SnapshotManager().Create(snapshotHeight) require.NoError(t, err) From d2c3ff804c11084757234a15d65797d9da581e44 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Tue, 27 Oct 2026 14:09:48 -0500 Subject: [PATCH 4/6] fixed bug --- sei-db/state_db/sc/composite/store.go | 56 +++++-------------- .../sc/composite/store_migration_test.go | 36 ++++++++++++ 2 files changed, 51 insertions(+), 41 deletions(-) diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index ae82fc0185..0d339b110d 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -1252,14 +1252,8 @@ func (cs *CompositeCommitStore) shouldAppendLatticeHash() bool { return false } -// migrationStarted reports whether the migration metadata visible through -// the given flatkv handle shows the EVM migration progressed past -// MigrationNotStarted: the boundary record decodes to any non-NotStarted -// status, or the version key exists (which is what survives a completion -// block — the manager atomically deletes the boundary and writes the -// version key). This is the moment flatkv joins the AppHash. Works -// against both live stores (latest + pending writes) and read-only clones -// (metadata as-of their loaded version). +// migrationStarted reports whether flatKV's committed migration metadata shows the EVM migration past +// MigrationNotStarted. func migrationStarted(flatKV gigatypes.LiveStateStore) (bool, error) { if boundaryBytes, ok := flatKV.Get( migration.MigrationStore, []byte(migration.MigrationBoundaryKey), @@ -1298,29 +1292,8 @@ func (cs *CompositeCommitStore) appendEvmLatticeHash(ci *proto.CommitInfo, evmHa } } -// shouldIncludeMemiavlInfos reports whether memiavl's per-store infos -// belong in the commit info (and therefore the AppHash). Memiavl -// participates until MigrateCosmos completes, i.e. until the persisted -// migration version reaches Version2_FlatKVOnly; from that commit on, a -// store with memiavl still open must produce the same commit info as a -// configured flatkv_only store (memIAVL == nil), or the two configurations -// fork. -// -// The gate is keyed on persisted migration metadata, not on -// currentWriteMode: write-mode derivation auto-advances on restart -// (version 2 on disk derives FlatKVOnly) while live peers stay in -// MigrateCosmos until their transition trigger fires, so a mode-keyed -// answer would differ between a live and a restarted node at the same -// height. The metadata read sees pending (uncommitted) writes, so the -// block that completes the migration already excludes memiavl from its -// own working hash — deterministically on every node, since migration -// advances are part of block execution. -// -// The per-call metadata read is scoped to the only states that can observe -// version 2: MigrateCosmos (the migration that produces it) and FlatKVOnly -// (which the SetWriteMode gate only admits once version 2 is persisted). -// Every earlier mode includes memiavl unconditionally, and the result is -// one-way latched once exclusion is observed (mirroring latticeAppendLatched). +// shouldIncludeMemiavlInfos reports whether memiavl's per-store infos belong in the commit info: true until +// flatKV's committed migration metadata shows MigrateCosmos complete. func (cs *CompositeCommitStore) shouldIncludeMemiavlInfos() bool { if cs.memIAVL == nil { return false @@ -1343,6 +1316,7 @@ func (cs *CompositeCommitStore) shouldIncludeMemiavlInfos() bool { cs.memiavlHashExcluded.Store(true) return false } + // Keyed on persisted metadata rather than the write mode, which a restarted node derives ahead of a live one. complete, err := migration.IsModeComplete(flatKV, types.MigrateCosmos) if err != nil { // Consensus-critical: if the migration version cannot be read we @@ -1361,18 +1335,9 @@ func (cs *CompositeCommitStore) shouldIncludeMemiavlInfos() bool { // WorkingCommitInfo returns the working commit info func (cs *CompositeCommitStore) WorkingCommitInfo(version int64) *proto.CommitInfo { - var ci *proto.CommitInfo - if cs.shouldIncludeMemiavlInfos() { - ci = cs.memIAVL.WorkingCommitInfo(version) - } else { - ci = &proto.CommitInfo{ - Version: version, - } - } - // Cosmos asks for a block's hash before it calls Commit, and flatKV hashes only committed blocks, so // the commit happens here; the Commit that follows finds the block already committed and does nothing. - // It precedes shouldAppendLatticeHash, which reads the migration boundary the block may have written. + // It precedes both commit-info gates below, which read only committed migration metadata. // // Committing 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 @@ -1390,6 +1355,15 @@ func (cs *CompositeCommitStore) WorkingCommitInfo(version int64) *proto.CommitIn } } + var ci *proto.CommitInfo + if cs.shouldIncludeMemiavlInfos() { + ci = cs.memIAVL.WorkingCommitInfo(version) + } else { + ci = &proto.CommitInfo{ + Version: version, + } + } + if cs.shouldAppendLatticeHash() { hash, err := cs.latticeHash(version) if err != nil { 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 76b7e62797..8b879f7c12 100644 --- a/sei-db/state_db/sc/composite/store_migration_test.go +++ b/sei-db/state_db/sc/composite/store_migration_test.go @@ -1258,6 +1258,42 @@ func TestComposite_MigrateCosmos_RollbackAcrossCompletionBoundary(t *testing.T) "post-restart commit info across the MigrateCosmos-completion boundary must re-include memiavl") } +// TestComposite_MigrateCosmos_CompletionBlockWorkingInfoMatchesCommitted checks that every MigrateCosmos block, +// the one completing the migration included, is stored with the commit info its working hash reported. +func TestComposite_MigrateCosmos_CompletionBlockWorkingInfoMatchesCommitted(t *testing.T) { + dir := t.TempDir() + workload := newMigrationWorkload(0xC0DE) + const batch = 8 + + cs := openAutoStore(t, dir, batch) + defer func() { _ = cs.Close() }() + walkToMigrateCosmos(t, cs, workload) + require.Equal(t, types.MigrateCosmos, cs.loadWriteMode()) + + var working *proto.CommitInfo + for done := false; !done; { + require.Less(t, cs.Version(), int64(800), "migration did not reach version %d", + migration.Version2_FlatKVOnly) + next := cs.Version() + 1 + require.NoError(t, cs.ApplyChangeSets(workload.generateBlock(0, 2, 1, 1, 1))) + working = cloneCommitInfo(cs.WorkingCommitInfo(next)) + _, err := cs.Commit(next) + require.NoError(t, err) + requireCommitInfoEqual(t, working, cs.LastCommitInfo(), + fmt.Sprintf("block %d: stored commit info must match the working commit info", next)) + + done, err = migration.IsAtVersion(flatKVReaderFor(cs), migration.Version2_FlatKVOnly) + require.NoError(t, err) + } + + names := make([]string, 0, len(working.StoreInfos)) + for _, si := range working.StoreInfos { + names = append(names, si.Name) + } + require.Equal(t, []string{"evm_lattice"}, names, + "the completion block's working commit info must already exclude memiavl") +} + // TestComposite_Auto_RollbackAcrossMigrateCosmosCompletionAfterRestart rolls an Auto store that was reopened // after MigrateCosmos completed, so its mode was derived as FlatKVOnly, back below completion. The // rolled-back store must report the canonical commit info and recommit the rolled-back blocks exactly, From fd9d4ce65479504e1f4a154e226c96c374c848df Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 28 Oct 2026 09:37:01 -0500 Subject: [PATCH 5/6] FlatKV uses mutations --- giga/evmonly/flatkv_changeset_test.go | 6 +- sei-db/bootstrap/recovery_test.go | 6 +- sei-db/db_engine/view/batch_update_test.go | 205 ------ sei-db/db_engine/view/differential_test.go | 54 +- sei-db/db_engine/view/pending_value.go | 90 --- sei-db/db_engine/view/shard.go | 547 +-------------- sei-db/db_engine/view/shutdown_test.go | 85 --- sei-db/db_engine/view/test_helpers_test.go | 9 +- sei-db/db_engine/view/view_manager.go | 32 +- sei-db/db_engine/view/view_manager_impl.go | 86 +-- sei-db/state_db/giga/state_db.go | 2 +- sei-db/state_db/giga/state_db_replay.go | 18 +- sei-db/state_db/giga/state_db_replay_test.go | 4 +- sei-db/state_db/giga/state_db_test.go | 12 + .../state_db/giga/types/live_state_store.go | 15 +- .../composite/random_test_framework_test.go | 2 +- sei-db/state_db/sc/composite/store_test.go | 3 +- .../sc/flatkv/audit/snapshot_audit.go | 11 +- .../sc/flatkv/audit/snapshot_audit_test.go | 3 +- .../changeset_mutation_agreement_test.go | 32 + .../flatkv/changeset_mutation_conversions.go | 26 +- .../state_db/sc/flatkv/import_export_test.go | 4 +- .../state_db/sc/flatkv/import_translator.go | 141 +--- sei-db/state_db/sc/flatkv/importer.go | 11 +- sei-db/state_db/sc/flatkv/key_arena.go | 32 - .../sc/flatkv/local_meta_decoder_test.go | 3 +- .../state_db/sc/flatkv/lthash/backend_test.go | 5 +- .../sc/flatkv/lthash/block_gatherer.go | 211 ------ .../sc/flatkv/lthash/hash_combiner.go | 15 +- .../state_db/sc/flatkv/lthash/hash_engine.go | 102 ++- .../sc/flatkv/lthash/hash_engine_config.go | 21 +- .../sc/flatkv/lthash/hash_engine_messages.go | 56 +- .../sc/flatkv/lthash/hash_engine_test.go | 263 ++----- .../state_db/sc/flatkv/lthash/hash_types.go | 12 +- .../state_db/sc/flatkv/lthash/leaf_hasher.go | 39 +- .../state_db/sc/flatkv/lthash/lthash_test.go | 93 +-- .../state_db/sc/flatkv/lthash/stats_test.go | 25 +- .../sc/flatkv/lthash_agreement_test.go | 5 +- .../sc/flatkv/lthash_correctness_test.go | 9 +- sei-db/state_db/sc/flatkv/metrics.go | 9 - .../state_db/sc/flatkv/perdb_lthash_test.go | 76 +-- .../sc/flatkv/permodule_lthash_test.go | 9 +- sei-db/state_db/sc/flatkv/snapshot_test.go | 14 +- .../sc/flatkv/state_view_bench_test.go | 2 +- .../state_db/sc/flatkv/state_view_raw_test.go | 2 +- sei-db/state_db/sc/flatkv/state_view_test.go | 22 +- sei-db/state_db/sc/flatkv/store.go | 9 +- sei-db/state_db/sc/flatkv/store_apply.go | 644 ++---------------- .../sc/flatkv/store_apply_bench_test.go | 54 -- .../sc/flatkv/store_init_repair_test.go | 50 +- sei-db/state_db/sc/flatkv/store_meta_test.go | 9 +- sei-db/state_db/sc/flatkv/store_read.go | 2 +- sei-db/state_db/sc/flatkv/store_replay.go | 15 +- .../state_db/sc/flatkv/store_replay_test.go | 122 ++-- sei-db/state_db/sc/flatkv/store_test.go | 45 +- .../sc/flatkv/store_version_probe_test.go | 30 +- sei-db/state_db/sc/flatkv/store_write.go | 135 ++-- sei-db/state_db/sc/flatkv/store_write_test.go | 6 +- sei-db/state_db/sc/flatkv/test_verify.go | 9 +- sei-db/state_db/sc/flatkv/testutil_test.go | 32 + .../migration_test_framework_test.go | 2 +- sei-db/state_db/ss/composite/store.go | 4 +- sei-db/state_db/ss/composite/store_test.go | 4 +- sei-db/state_db/statewal/state_wal.go | 9 +- sei-db/state_db/statewal/state_wal_impl.go | 3 +- .../state_db/statewal/state_wal_iterator.go | 14 + .../statewal/state_wal_iterator_test.go | 47 ++ .../tools/cmd/seidb/operations/dump_flatkv.go | 11 +- .../cmd/seidb/operations/dump_flatkv_test.go | 9 +- .../evmlogical/evm_logical_digest.go | 6 +- .../operations/import_flatkv_from_memiavl.go | 5 +- 71 files changed, 843 insertions(+), 2862 deletions(-) delete mode 100644 sei-db/db_engine/view/batch_update_test.go delete mode 100644 sei-db/db_engine/view/pending_value.go delete mode 100644 sei-db/state_db/sc/flatkv/key_arena.go delete mode 100644 sei-db/state_db/sc/flatkv/lthash/block_gatherer.go delete mode 100644 sei-db/state_db/sc/flatkv/store_apply_bench_test.go diff --git a/giga/evmonly/flatkv_changeset_test.go b/giga/evmonly/flatkv_changeset_test.go index 33c5610ddf..dee9382e3d 100644 --- a/giga/evmonly/flatkv_changeset_test.go +++ b/giga/evmonly/flatkv_changeset_test.go @@ -32,7 +32,11 @@ func TestFlatKVChangeSetEncoderPersistsExecutorState(t *testing.T) { }, }) require.NoError(t, err) - require.NoError(t, store.CommitStateChanges(1, changes)) + prior := store.OpenView() + mutations, err := flatkv.MutationsFromChangeSets(changes, 1, prior) + prior.Close() + require.NoError(t, err) + require.NoError(t, store.CommitStateChanges(1, mutations)) view := store.OpenView() defer view.Close() diff --git a/sei-db/bootstrap/recovery_test.go b/sei-db/bootstrap/recovery_test.go index 1066e715ad..9ac475d4f3 100644 --- a/sei-db/bootstrap/recovery_test.go +++ b/sei-db/bootstrap/recovery_test.go @@ -484,7 +484,11 @@ func TestRecoverReplaysForwardFromTheStoreOwnHeight(t *testing.T) { } // 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))) + prior := manager.SC().OpenView() + marked, err := flatkv.MutationsFromChangeSets(evmBlock(108, 108), 8, prior) + prior.Close() + require.NoError(t, err) + require.NoError(t, manager.SC().CommitStateChanges(8, marked)) writeWALOnly(t, manager.StateWAL(), 8, evmBlock(8, 8)) writeWALOnly(t, manager.StateWAL(), 9, evmBlock(9, 9)) writeWALOnly(t, manager.StateWAL(), 10, evmBlock(10, 10)) diff --git a/sei-db/db_engine/view/batch_update_test.go b/sei-db/db_engine/view/batch_update_test.go deleted file mode 100644 index 28bdf32f6b..0000000000 --- a/sei-db/db_engine/view/batch_update_test.go +++ /dev/null @@ -1,205 +0,0 @@ -package view - -import ( - "fmt" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// markUpdater folds a value by appending mark to whatever the key already held, so a result says -// which value it was folded onto rather than merely that a fold happened. A nil prior folds from -// "", which distinguishes a key the store held nothing for from one holding an empty value. -type markUpdater struct { - mark byte -} - -func (u markUpdater) NewValueFor(_ string, priorValue []byte) ([]byte, error) { - if priorValue == nil { - return append([]byte(""), u.mark), nil - } - return append(append([]byte{}, priorValue...), u.mark), nil -} - -// parkedUpdater holds every fold until it is released, which is what lets a test tell staging apart -// from folding. -type parkedUpdater struct { - started chan struct{} - release chan struct{} - once sync.Once -} - -func newParkedUpdater() *parkedUpdater { - return &parkedUpdater{started: make(chan struct{}), release: make(chan struct{})} -} - -func (u *parkedUpdater) NewValueFor(_ string, priorValue []byte) ([]byte, error) { - u.once.Do(func() { close(u.started) }) - <-u.release - return append(append([]byte{}, priorValue...), '+'), nil -} - -// failingUpdater fails every fold, standing in for a corrupted stored value. -type failingUpdater struct{} - -func (failingUpdater) NewValueFor(_ string, _ []byte) ([]byte, error) { - return nil, fmt.Errorf("fold refused this value") -} - -// BatchUpdate must return without folding anything. The fold here parks until released, so a -// BatchUpdate that performed it on the calling thread could never return and this test would hang -// rather than pass — reaching the assertions at all is the proof. -func TestBatchUpdateReturnsBeforeFolding(t *testing.T) { - manager, _ := newTestManager(t, map[string][]byte{"k": []byte("old")}, 4, 1<<20) - updater := newParkedUpdater() - - require.NoError(t, manager.BatchUpdate([]string{"k"}, updater)) - - // A read of a staged key must wait for its value rather than miss the write or serve the value it - // replaced. Observing "did not return" needs a deadline; the fold is parked, so any wait fails - // the same way. - type readResult struct { - value []byte - found bool - err error - } - reads := make(chan readResult, 1) - go func() { - value, found, err := manager.Get([]byte("k"), true) - reads <- readResult{value: value, found: found, err: err} - }() - - <-updater.started - select { - case got := <-reads: - t.Fatalf("a read of a staged key returned %q before its value was available", got.value) - case <-time.After(50 * time.Millisecond): - } - - close(updater.release) - got := <-reads - require.NoError(t, got.err) - require.True(t, got.found) - require.Equal(t, []byte("old+"), got.value, "the read must serve the folded value") -} - -// A staged key has to read as its new value on every read surface, not just the single-key one. -func TestBatchUpdateStagedValueIsVisibleToEveryReadPath(t *testing.T) { - seed := map[string][]byte{"a": []byte("1"), "b": []byte("2")} - manager, _ := newTestManager(t, seed, 4, 1<<20) - - require.NoError(t, manager.BatchUpdate([]string{"a", "b"}, markUpdater{mark: '+'})) - - value, found, err := manager.Get([]byte("a"), true) - require.NoError(t, err) - require.True(t, found) - require.Equal(t, []byte("1+"), value) - - batch, err := manager.BatchGet([][]byte{[]byte("a"), []byte("b")}) - require.NoError(t, err) - require.Equal(t, []byte("1+"), batch["a"]) - require.Equal(t, []byte("2+"), batch["b"]) - - it, err := manager.Iterator(nil) - require.NoError(t, err) - defer func() { require.NoError(t, it.Close()) }() - seen := map[string][]byte{} - for ; it.Valid(); it.Next() { - seen[string(it.Key())] = append([]byte{}, it.Value()...) - } - require.NoError(t, it.Error()) - require.Equal(t, []byte("1+"), seen["a"], "an iterator must not copy an unfolded value") - require.Equal(t, []byte("2+"), seen["b"]) -} - -// Two folds staged for one key within a single version must apply in the order they were staged: the -// second folds onto the first's result, not onto the value both of them started from. -func TestBatchUpdateChainsFoldsWithinAVersion(t *testing.T) { - manager, _ := newTestManager(t, map[string][]byte{"k": []byte("a")}, 4, 1<<20) - - require.NoError(t, manager.BatchUpdate([]string{"k"}, markUpdater{mark: '1'})) - require.NoError(t, manager.BatchUpdate([]string{"k"}, markUpdater{mark: '2'})) - - value, found, err := manager.Get([]byte("k"), true) - require.NoError(t, err) - require.True(t, found) - require.Equal(t, []byte("a12"), value) -} - -// The same key folded in consecutive versions must chain the same way, with each version's fold -// applying to the previous version's result. -func TestBatchUpdateChainsFoldsAcrossVersions(t *testing.T) { - manager, _ := newTestManager(t, map[string][]byte{"k": []byte("a")}, 4, 1<<20) - - var held []View - defer func() { - for _, v := range held { - require.NoError(t, v.Release()) - } - }() - - for _, mark := range []byte{'1', '2', '3'} { - require.NoError(t, manager.BatchUpdate([]string{"k"}, markUpdater{mark: mark})) - sealed, err := manager.Commit() - require.NoError(t, err) - require.NoError(t, sealed.Finalize(hashWrites(testHash))) - held = append(held, sealed) - } - - value, found, err := manager.Get([]byte("k"), true) - require.NoError(t, err) - require.True(t, found) - require.Equal(t, []byte("a123"), value) - - // Each sealed version's diff must carry that version's own folded value, since the diff is what - // hashing and flushing read. - for i, want := range [][]byte{[]byte("a1"), []byte("a12"), []byte("a123")} { - diff := collectDiff(t, held[i]) - require.Equal(t, want, diff["k"], "version %d's diff", i+1) - } -} - -// A key whose fold deletes it must read as absent and reach the diff as a nil value, which is how a -// delete is carried to the flush. -func TestBatchUpdateFoldCanDelete(t *testing.T) { - manager, _ := newTestManager(t, map[string][]byte{"k": []byte("doomed")}, 4, 1<<20) - - require.NoError(t, manager.BatchUpdate([]string{"k"}, deletingUpdater{})) - - _, found, err := manager.Get([]byte("k"), true) - require.NoError(t, err) - require.False(t, found, "a key whose fold returned nil must read as absent") - - sealed, err := manager.Commit() - require.NoError(t, err) - require.NoError(t, sealed.Finalize(hashWrites(testHash))) - defer func() { require.NoError(t, sealed.Release()) }() - - diff := collectDiff(t, sealed) - value, present := diff["k"] - require.True(t, present, "a delete must appear in the diff") - require.Nil(t, value, "a delete is carried as a nil value") -} - -type deletingUpdater struct{} - -func (deletingUpdater) NewValueFor(_ string, _ []byte) ([]byte, error) { return nil, nil } - -// A fold that fails has to be reported to everything that would otherwise read its value as good: a -// reader, and the diff that hashing and flushing consume. It must also brick the manager, because a -// version missing one of its writes can never be hashed correctly. -func TestBatchUpdateFoldFailureReachesObservers(t *testing.T) { - manager, _ := newTestManager(t, map[string][]byte{"k": []byte("old")}, 4, 1<<20) - - require.NoError(t, manager.BatchUpdate([]string{"k"}, failingUpdater{})) - - _, _, err := manager.Get([]byte("k"), true) - require.Error(t, err, "a reader must not be handed the value a failed fold never produced") - require.Contains(t, err.Error(), "fold refused this value") - - // The diff consumers have to fail too rather than hash a version that is missing this key. - _, err = manager.Commit() - require.Error(t, err, "a failed fold must brick the manager") -} diff --git a/sei-db/db_engine/view/differential_test.go b/sei-db/db_engine/view/differential_test.go index 35166210be..262ad68f32 100644 --- a/sei-db/db_engine/view/differential_test.go +++ b/sei-db/db_engine/view/differential_test.go @@ -42,7 +42,6 @@ const ( opSet = iota opDelete opBatch - opUpdate opView ) @@ -91,13 +90,6 @@ func runDifferential(t *testing.T, shardCount, maxSize uint64, seedDB bool, seed muts := randMuts(rng, keys) require.NoError(t, manager.BatchSet(muts)) model.BatchSet(muts) - case opUpdate: - updated := randUpdateKeys(rng, keys) - require.NoError(t, manager.BatchUpdate(updated, foldUpdater{})) - for _, k := range updated { - prior, _ := model.GetLive([]byte(k)) - model.Set([]byte(k), foldedValue(prior)) - } case opView: if len(opens) >= maxOpen { releaseOldest() @@ -198,57 +190,13 @@ func pickOp(rng *testutil.TestRandom) int { return opSet case r < 60: return opDelete - case r < 70: - return opBatch case r < 80: - return opUpdate + return opBatch default: return opView } } -// randUpdateKeys picks the keys for one BatchUpdate, deduplicated because the contract forbids a -// repeated key. -func randUpdateKeys(rng *testutil.TestRandom, keys [][]byte) []string { - n := rng.IntRange(1, 9) - seen := make(map[string]struct{}, n) - picked := make([]string, 0, n) - for i := 0; i < n; i++ { - k := string(pick(rng, keys)) - if _, ok := seen[k]; ok { - continue - } - seen[k] = struct{}{} - picked = append(picked, k) - } - return picked -} - -// foldUpdater folds through foldedValue, so the oracle can reproduce the same writes from its own -// state rather than carrying a second copy of the manager's logic. -type foldUpdater struct{} - -var _ BatchUpdater = foldUpdater{} - -func (foldUpdater) NewValueFor(_ string, priorValue []byte) ([]byte, error) { - return foldedValue(priorValue), nil -} - -// foldedValue is a pure function of the value a key already held. A key holding nothing gets one, a -// value that has grown past the cap is deleted, and anything else is extended — between them the -// create, modify and delete outcomes a fold can have. Bounded so a long run cannot grow values -// without limit. -func foldedValue(priorValue []byte) []byte { - if priorValue == nil { - return []byte("folded") - } - if len(priorValue) >= 12 { - return nil - } - // Copied rather than appended in place: priorValue aliases the manager's own stored value. - return append(append([]byte{}, priorValue...), '+') -} - func genKeys(rng *testutil.TestRandom, n int) [][]byte { keys := make([][]byte, n) keys[0] = []byte{} // include the empty key as an edge case diff --git a/sei-db/db_engine/view/pending_value.go b/sei-db/db_engine/view/pending_value.go deleted file mode 100644 index 66224003ac..0000000000 --- a/sei-db/db_engine/view/pending_value.go +++ /dev/null @@ -1,90 +0,0 @@ -package view - -import ( - "context" - "fmt" -) - -// pendingValue is a staged value whose bytes are not yet known: the fold of one batch's changes onto -// the value its key already held. -type pendingValue struct { - // The key whose value this fold produces. - key string - - // Closed once value and err are final. - done chan struct{} - - // What the fold produced, nil for a delete. Illegal to read before the done chan is closed. - value []byte - - // The failure that stopped the fold, if it failed. Illegal to read before the done chan is closed. - err error -} - -// newPendingValue returns an unresolved staged value for the given key. -func newPendingValue(key string) *pendingValue { - return &pendingValue{key: key, done: make(chan struct{})} -} - -// inject records what the fold produced and releases every observer. Called exactly once. -func (p *pendingValue) inject(value []byte, err error) { - p.value = value - p.err = err - // Closing publishes both fields and wakes every observer at once, so none of them has to pass the - // value to the next, and a second inject panics here rather than queueing a second answer. - close(p.done) -} - -// await blocks until this value is resolved and reports what it resolved to. A nil value means the -// key was deleted. Must be called with no shard lock held, since the fold needs that lock to publish. -// -// ctx is cancelled when the manager shuts down, and shutdownError then names the cause. -func (p *pendingValue) await(ctx context.Context, shutdownError func() error) ([]byte, error) { - // Not threading.InterruptiblePull: it reports a closed channel as an error, and a close is how a - // resolved value is published here. - select { - case <-p.done: - if p.err != nil { - return nil, fmt.Errorf("staged value failed to resolve: %w", p.err) - } - return p.value, nil - case <-ctx.Done(): - return nil, fmt.Errorf("view manager shut down while awaiting a staged value: %w", shutdownError()) - } -} - -// stagedFold is one fold's two halves: where it gets the value it folds onto, and where it puts the -// result. The key is on result. -type stagedFold struct { - // Where the prior value comes from. - prior priorValueSource - - // The handle every observer of this key waits on, and where the fold injects its result. - result *pendingValue -} - -// Which of the three places a staged fold's prior value comes from. -type priorValueLocation int - -const ( - // The shard's versioned data holds it; priorValueSource.value is it. - priorValueInVersionedData priorValueLocation = 1 - // An earlier staged fold on the same key has yet to produce it; priorValueSource.pending is - // that fold. - priorValueInEarlierFold priorValueLocation = 2 - // Nothing the shard holds has it, so it comes from the read cache. - priorValueInReadCache priorValueLocation = 3 -) - -// priorValueSource is where a staged fold gets the prior value it folds onto. -type priorValueSource struct { - // Which of the three places the prior value comes from. - location priorValueLocation - - // The prior value. Meaningful exactly while location is priorValueInVersionedData, where a nil - // value is a tombstone rather than an absence. - value []byte - - // The earlier fold to await. Non-nil exactly while location is priorValueInEarlierFold. - pending *pendingValue -} diff --git a/sei-db/db_engine/view/shard.go b/sei-db/db_engine/view/shard.go index cedf98d2c9..4131cf4037 100644 --- a/sei-db/db_engine/view/shard.go +++ b/sei-db/db_engine/view/shard.go @@ -39,7 +39,7 @@ type shard struct { versionedData map[string] /* key */ *structures.Deque[versionedValue] /* values at various versions */ // The values set in each version not yet materialized: the current version, and any sealed version - // whose folds are still resolving. A key set more than once in a version keeps only its last value. + // whose diff has not been ordered yet. A key set more than once in a version keeps only its last value. // Reading one key at a version through versionedData is far cheaper than scanning this map for it. versionDiffs map[uint64] /* version */ map[string] /* key */ []byte /* value */ @@ -69,22 +69,12 @@ type shard struct { // ViewManager.Close). openIterators uint64 - // versionLatches holds a latch for each version that still has staged folds outstanding; a - // version absent from the map has none. Guarded by the shard lock. See versionLatch. - versionLatches map[uint64]*versionLatch - - // ctx is cancelled when the manager shuts down. Awaits on a staged value observe it, because a - // fold interrupted by shutdown never resolves. + // ctx is cancelled when the manager shuts down, releasing a wait for a sealed version's diff. ctx context.Context // shutdownError names the cause once ctx is cancelled. shutdownError func() error - // reportFoldFailure bricks the manager. A fold that cannot complete leaves a version unhashable, - // so it has to stop the whole manager rather than only this shard — the same response the read - // cache gives a failed database read. - reportFoldFailure func(error) - // initialVersionsPerKey sizes the value list a key gets when first written. See // ViewManagerConfig.InitialVersionsPerKey. initialVersionsPerKey int @@ -92,9 +82,9 @@ type shard struct { // Write is one key's change: a value to store, or a deletion. // -// A nil Value is a deletion. That is the manager's tombstone convention throughout — BatchUpdater -// returns nil from NewValueFor to delete, and the version diff maps hold nil for a deleted key — so a -// caller with a genuinely empty value passes a non-nil, zero-length slice. +// A nil Value is a deletion. That is the manager's tombstone convention throughout — the version diff +// maps hold nil for a deleted key — so a caller with a genuinely empty value passes a non-nil, +// zero-length slice. // // Key may be carved from a shared buffer; see setWLocked for what the manager retains. type Write struct { @@ -120,22 +110,6 @@ type versionedValue struct { // as block height, this is just a version number that monotonically increases over the lifetime // of a view manager instance. version uint64 - // pending is non-nil while this value's bytes are still being folded. Every path that reads value - // must check it first. - pending *pendingValue -} - -// versionLatch counts a version's unresolved staged values and lets an observer wait for the last of -// them. A latch that has opened stays open, and one left incomplete by a failure carries that failure. -type versionLatch struct { - // How many of this version's staged values have not resolved. Guarded by the shard lock. - count int - - // Closed when count reaches zero. - done chan struct{} - - // The fold failure that left this version incomplete, if one did. - err error } // Creates a new Shard. @@ -154,9 +128,6 @@ func NewShard( shutdownError func() error, // Reports a failed DB read to the manager, which bricks and stops serving reads. reportReadFailure func(error), - // Reports a fold that could not produce its value to the manager, which bricks. Distinct from - // reportReadFailure so the latched error names the failure that actually happened. - reportFoldFailure func(error), ) (*shard, error) { if maxSize == 0 { @@ -170,11 +141,6 @@ func NewShard( // failure mode this reporting exists to prevent. return nil, fmt.Errorf("reportReadFailure must be non-nil") } - if reportFoldFailure == nil { - // A fold failure leaves a version's diff incomplete. A shard that cannot report one would let - // that version be hashed as though it were whole. - return nil, fmt.Errorf("reportFoldFailure must be non-nil") - } versionDiffs := make(map[uint64]map[string][]byte) versionDiffs[1] = make(map[string][]byte) // versions start at 1 @@ -185,13 +151,10 @@ func NewShard( sealedDiffs: make(map[uint64]*sealedDiff), currentVersion: 1, // important: versions start at 1, not 0, to allow (version - 1) without underflow oldestVersion: 1, - versionLatches: make(map[uint64]*versionLatch), ctx: ctx, shutdownError: shutdownError, initialVersionsPerKey: int(config.InitialVersionsPerKey), //nolint:gosec // validated non-zero - - reportFoldFailure: reportFoldFailure, } s.cache = NewReadCache(ctx, config, db, readPool, &s.lock, maxSize, shutdownError, reportReadFailure) return s, nil @@ -208,14 +171,7 @@ func (s *shard) Get( // overhead to do so with little benefit. updateLru bool, ) ([]byte, bool, error) { - value, found, pending, done, err := s.attemptFastGetUnlocked(key, version, updateLru) - if pending != nil { - value, err := pending.await(s.ctx, s.shutdownError) - if err != nil { - return nil, false, fmt.Errorf("get key %x at version %d: %w", key, version, err) - } - return value, value != nil, nil - } + value, found, done, err := s.attemptFastGetUnlocked(key, version, updateLru) if done { if err != nil { return nil, false, fmt.Errorf("get at version %d: %w", version, err) @@ -243,13 +199,6 @@ func (s *shard) Get( // First, check to see if we have this value in the versioned data map. if entry, found := s.lookupVersionedRLocked(key, version); found { s.lock.Unlock() - if entry.pending != nil { - value, err := entry.pending.await(s.ctx, s.shutdownError) - if err != nil { - return nil, false, fmt.Errorf("get key %x at version %d: %w", key, version, err) - } - return value, value != nil, nil - } s.metrics.reportCacheHits(1) return entry.value, entry.value != nil, nil } @@ -263,38 +212,32 @@ func (s *shard) Get( // attemptFastGetUnlocked attempts a read while holding only the read lock, reporting done when it // succeeded. A non-nil err always comes with done. A read it could not resolve without mutating is // left to the caller to redo under the write lock. -// -// A key holding an unresolved fold is reported as pending, for the caller to await once it has -// released the lock. func (s *shard) attemptFastGetUnlocked( key []byte, version uint64, updateLru bool, -) (value []byte, found bool, pending *pendingValue, done bool, err error) { +) (value []byte, found bool, done bool, err error) { s.lock.RLock() defer s.lock.RUnlock() if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { - return nil, false, nil, true, fmt.Errorf("key %x: %w", key, err) + return nil, false, true, fmt.Errorf("key %x: %w", key, err) } if err := s.validateVersionRLocked(version); err != nil { - return nil, false, nil, true, fmt.Errorf("key %x: %w", key, err) + return nil, false, true, fmt.Errorf("key %x: %w", key, err) } if entry, found := s.lookupVersionedRLocked(key, version); found { - if entry.pending != nil { - return nil, false, entry.pending, false, nil - } s.metrics.reportCacheHits(1) - return entry.value, entry.value != nil, nil, true, nil + return entry.value, entry.value != nil, true, nil } value, found, ok := s.cache.AttemptFastLookupRLocked(key, updateLru) if !ok { - return nil, false, nil, false, nil + return nil, false, false, nil } s.metrics.reportCacheHits(1) - return value, found, nil, true, nil + return value, found, true, nil } // validateVersionRLocked checks that the given version is within the valid range. @@ -310,9 +253,6 @@ func (s *shard) validateVersionRLocked(version uint64) error { // lookupVersionedRLocked checks versioned data for a key at the given version. Reports the entry and // true when versioned data holds one, or false when the read cache should be consulted instead. -// -// The entry may be an unresolved fold, so every caller has to check its pending field before reading -// its value. Resolving one requires releasing this lock first; see pendingValue.await. func (s *shard) lookupVersionedRLocked(key []byte, version uint64) (versionedValue, bool) { // Converted inline rather than by the caller: the compiler elides the conversion only where it // indexes a map directly, and every single-key read pays an allocation for it otherwise. @@ -342,7 +282,7 @@ func (s *shard) lookupVersionedRLocked(key []byte, version uint64) (versionedVal func (s *shard) BatchGet(keys [][]byte, version uint64) (map[string][]byte, error) { results := make(map[string][]byte, len(keys)) - unresolved, staged, hits, err := s.attemptFastBatchGetUnlocked(keys, results, version) + unresolved, hits, err := s.attemptFastBatchGetUnlocked(keys, results, version) if err != nil { return nil, fmt.Errorf("batch get of %d keys at version %d: %w", len(keys), version, err) } @@ -350,15 +290,12 @@ func (s *shard) BatchGet(keys [][]byte, version uint64) (map[string][]byte, erro var pending []pendingRead if len(unresolved) > 0 { var remainingHits int64 - var remainingStaged []*pendingValue - pending, remainingStaged, remainingHits, err = - s.batchGetRemainingUnlocked(keys, unresolved, results, version) + pending, remainingHits, err = s.batchGetRemainingUnlocked(keys, unresolved, results, version) if err != nil { return nil, fmt.Errorf("batch get of %d unresolved keys at version %d: %w", len(unresolved), version, err) } hits += remainingHits - staged = append(staged, remainingStaged...) } if hits > 0 { @@ -369,55 +306,32 @@ func (s *shard) BatchGet(keys [][]byte, version uint64) (map[string][]byte, erro // DB errors are fatal; fail the whole batch. return nil, fmt.Errorf("complete %d database reads for a batch get: %w", len(pending), err) } - // Awaited after the DB reads and outside every lock, for the reason given on pendingValue.await. - if err := s.awaitStagedReadsUnlocked(staged, results); err != nil { - return nil, fmt.Errorf("batch get at version %d: %w", version, err) - } return results, nil } -// awaitStagedReadsUnlocked completes the staged folds a batch read ran into, writing each resolved value into -// results. A deleted key resolves to nil and is left out, as it would be on any other read path. -func (s *shard) awaitStagedReadsUnlocked(staged []*pendingValue, results map[string][]byte) error { - for _, pending := range staged { - value, err := pending.await(s.ctx, s.shutdownError) - if err != nil { - return fmt.Errorf("await staged value for key %x: %w", pending.key, err) - } - if value != nil { - results[pending.key] = value - } - } - return nil -} - // attemptFastBatchGetUnlocked resolves the keys it can while holding the read lock, writing found // values into results and returning the positions in keys of those it could not resolve. func (s *shard) attemptFastBatchGetUnlocked( keys [][]byte, results map[string][]byte, version uint64, -) (unresolved []int, staged []*pendingValue, hits int64, err error) { +) (unresolved []int, hits int64, err error) { s.lock.RLock() defer s.lock.RUnlock() // Checked ahead of the versioned data so that a shard taken out of service refuses every read, // not just those that would have reached the DB. if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { - return nil, nil, 0, fmt.Errorf("resolve what is already in memory: %w", err) + return nil, 0, fmt.Errorf("resolve what is already in memory: %w", err) } if err := s.validateVersionRLocked(version); err != nil { - return nil, nil, 0, fmt.Errorf("resolve what is already in memory: %w", err) + return nil, 0, fmt.Errorf("resolve what is already in memory: %w", err) } for i, key := range keys { keyStr := string(key) if entry, found := s.lookupVersionedRLocked(key, version); found { - if entry.pending != nil { - staged = append(staged, entry.pending) - continue - } // found includes tombstones (nil value); only non-nil values are real hits to return. if entry.value != nil { results[keyStr] = entry.value @@ -438,7 +352,7 @@ func (s *shard) attemptFastBatchGetUnlocked( } unresolved = append(unresolved, i) } - return unresolved, staged, hits, nil + return unresolved, hits, nil } // batchGetRemainingUnlocked classifies the keys at the given positions in keys, which are those the @@ -448,31 +362,26 @@ func (s *shard) batchGetRemainingUnlocked( indices []int, results map[string][]byte, version uint64, -) (pending []pendingRead, staged []*pendingValue, hits int64, err error) { +) (pending []pendingRead, hits int64, err error) { pending = make([]pendingRead, 0, len(indices)) s.lock.Lock() defer s.lock.Unlock() if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { - return nil, nil, 0, fmt.Errorf("classify the remaining keys: %w", err) + return nil, 0, fmt.Errorf("classify the remaining keys: %w", err) } if err := s.validateVersionRLocked(version); err != nil { - return nil, nil, 0, fmt.Errorf("classify the remaining keys: %w", err) + return nil, 0, fmt.Errorf("classify the remaining keys: %w", err) } // Redone from scratch rather than carried over from the fast pass, because the lock was released - // in between and another reader may have scheduled or completed any of these keys — or staged a - // fold for one. + // in between and another reader may have scheduled or completed any of these keys. for _, i := range indices { key := keys[i] keyStr := string(key) if entry, found := s.lookupVersionedRLocked(key, version); found { - if entry.pending != nil { - staged = append(staged, entry.pending) - continue - } if entry.value != nil { results[keyStr] = entry.value } @@ -495,7 +404,7 @@ func (s *shard) batchGetRemainingUnlocked( needsSchedule: outcome.needsSchedule, }) } - return pending, staged, hits, nil + return pending, hits, nil } // GetSizeInfo returns the current cache size (bytes) and entry count under the read lock. @@ -583,359 +492,6 @@ func (s *shard) batchSetAt(writes []Write, indices []int) error { return nil } -// StageUpdates reserves a slot at the current version for each key named by indices, each holding an -// unresolved fold, and reports where each of those folds gets the value it folds onto. Folds staged -// for one key apply in the order they were staged. -func (s *shard) StageUpdates( - keys []string, - indices []int, - version uint64, -) ([]stagedFold, error) { - s.lock.Lock() - defer s.lock.Unlock() - - // Checked once for the whole batch rather than per key: it cannot change while we hold the lock. - if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { - return nil, fmt.Errorf("stage %d values at version %d: %w", len(indices), version, err) - } - if version != s.currentVersion { - return nil, fmt.Errorf("staging at version %d, but the current version is %d", - version, s.currentVersion) - } - - folds := make([]stagedFold, len(indices)) - for n, index := range indices { - key := keys[index] - folds[n] = stagedFold{ - prior: s.capturePriorValueWLocked(key), - result: newPendingValue(key), - } - s.stagePendingValueWLocked(key, version, folds[n].result) - } - return folds, nil -} - -// capturePriorValueWLocked reports what a fold staged now for key would be folding on top of: the newest -// value the shard holds, resolved or not, or neither when the shard holds none and it has to come -// from the read cache or the database. -func (s *shard) capturePriorValueWLocked(key string) priorValueSource { - deque, ok := s.versionedData[key] - if !ok || deque.IsEmpty() { - return priorValueSource{location: priorValueInReadCache} - } - // The newest entry is the right one to fold onto, whether it belongs to an earlier version or to - // an earlier write within this one. It can never belong to a later version: every write lands at - // the current version, and StageUpdates refuses any other, so nothing is ever appended above it. - newest := deque.PeekBack() - if newest.pending != nil { - return priorValueSource{location: priorValueInEarlierFold, pending: newest.pending} - } - return priorValueSource{location: priorValueInVersionedData, value: newest.value} -} - -// stagePendingValueWLocked puts an unresolved fold into the versioned data at the given version, -// replacing any entry this version already had for the key. -func (s *shard) stagePendingValueWLocked(key string, version uint64, pending *pendingValue) { - entry := versionedValue{version: version, pending: pending} - - deque, ok := s.versionedData[key] - if !ok { - deque = structures.NewDequeWithCapacity[versionedValue](s.initialVersionsPerKey) - // Cloned because this map entry outlives the batch that created it, and Go leaves a map's - // original key in place on reassignment. The copy is per key new to this shard, not per write. - s.versionedData[strings.Clone(key)] = deque - } - if deque.IsEmpty() || deque.PeekBack().version < version { - deque.PushBack(entry) - } else { - deque.PopBack() - deque.PushBack(entry) - } - - s.markFoldStagedWLocked(version) -} - -// markFoldStagedWLocked records one more of a version's folds as outstanding, creating the version's -// latch if this is its first. -func (s *shard) markFoldStagedWLocked(version uint64) { - latch, ok := s.versionLatches[version] - if !ok { - latch = &versionLatch{done: make(chan struct{})} - s.versionLatches[version] = latch - } - latch.count++ -} - -// markFoldResolvedWLocked records one of a version's folds as no longer outstanding, opening the -// version's latch when it was the last. A version left incomplete by a failure keeps its latch. -func (s *shard) markFoldResolvedWLocked(version uint64) { - latch, ok := s.versionLatches[version] - if !ok { - return - } - latch.count-- - if latch.count > 0 { - return - } - close(latch.done) - if latch.err == nil { - delete(s.versionLatches, version) - } -} - -// awaitVersionFoldsUnlocked blocks until every fold staged in the given version has resolved, reporting -// the failure that stopped one if any did. Must be called with no lock held. -func (s *shard) awaitVersionFoldsUnlocked(version uint64) error { - s.lock.RLock() - latch, outstanding := s.versionLatches[version] - s.lock.RUnlock() - if !outstanding { - return nil - } - - select { - case <-latch.done: - case <-s.ctx.Done(): - return fmt.Errorf("view manager shut down while awaiting version %d: %w", - version, s.shutdownError()) - } - - s.lock.RLock() - defer s.lock.RUnlock() - if latch.err != nil { - return fmt.Errorf("version %d holds a value that failed to resolve: %w", version, latch.err) - } - return nil -} - -// AwaitOutstandingFolds blocks until every fold this shard has staged has resolved, whatever it -// resolved to. -// -// The wait is not interruptible: it exists to keep a shutdown from overtaking a fold, and a manager -// that has already failed has already cancelled the context an interruptible wait would observe. -// Every fold resolves its version's latch whether it produced a value or failed, so the wait -// terminates either way. -func (s *shard) AwaitOutstandingFolds() { - s.lock.RLock() - latches := make([]*versionLatch, 0, len(s.versionLatches)) - for _, latch := range s.versionLatches { - latches = append(latches, latch) - } - s.lock.RUnlock() - - for _, latch := range latches { - <-latch.done - } -} - -// FoldStagedValues folds every value a batch staged and records what each produced. Either every fold -// in the batch is recorded or none is. -func (s *shard) FoldStagedValues(folds []stagedFold, updater BatchUpdater, version uint64) { - priorValues, err := s.resolvePriorValuesUnlocked(folds) - if err != nil { - s.FailStagedFolds(folds, version, err) - return - } - - newValues := make([][]byte, len(folds)) - for n := range folds { - newValues[n], err = updater.NewValueFor(folds[n].result.key, priorValues[n]) - if err != nil { - s.FailStagedFolds(folds, version, - fmt.Errorf("fold key %x at version %d: %w", folds[n].result.key, version, err)) - return - } - } - - s.recordFoldsUnlocked(folds, newValues, version) -} - -// resolvePriorValuesUnlocked produces the value each staged fold applies on top of. -func (s *shard) resolvePriorValuesUnlocked(folds []stagedFold) ([][]byte, error) { - values := make([][]byte, len(folds)) - var needRead []int - - for n := range folds { - switch folds[n].prior.location { - case priorValueInVersionedData: - values[n] = folds[n].prior.value - case priorValueInEarlierFold: - // Awaited outside the lock, which is why this runs here rather than during staging. - value, err := folds[n].prior.pending.await(s.ctx, s.shutdownError) - if err != nil { - return nil, fmt.Errorf("await the earlier fold of key %x: %w", folds[n].result.key, err) - } - values[n] = value - case priorValueInReadCache: - needRead = append(needRead, n) - default: - // The zero value lands here, which is the point: a priorValueSource built with its - // location left unset would otherwise be served as though its value had been read. - panic(fmt.Sprintf("unexpected prior value location: %#v", folds[n].prior.location)) - } - } - if len(needRead) == 0 { - return values, nil - } - - // Read through the cache rather than the versioned lookup: versioned data already holds this - // batch's own unresolved entry for these keys, so a versioned lookup would find each fold waiting - // on itself. - results := make(map[string][]byte, len(needRead)) - unresolved, err := s.priorValuesFromCacheUnlocked(folds, needRead, results) - if err != nil { - return nil, fmt.Errorf("read %d prior values from the cache: %w", len(needRead), err) - } - if len(unresolved) > 0 { - pending, err := s.schedulePriorValueReadsUnlocked(folds, unresolved, results) - if err != nil { - return nil, fmt.Errorf("schedule %d prior value reads: %w", len(unresolved), err) - } - if err := s.cache.ResolveBatchUnlocked(pending, results); err != nil { - return nil, fmt.Errorf("complete %d prior value reads: %w", len(pending), err) - } - } - - for _, n := range needRead { - values[n] = results[folds[n].result.key] - } - return values, nil -} - -// priorValuesFromCacheUnlocked resolves the prior values the cache already holds, returning the -// positions it could not. -func (s *shard) priorValuesFromCacheUnlocked( - folds []stagedFold, - positions []int, - results map[string][]byte, -) ([]int, error) { - s.lock.RLock() - defer s.lock.RUnlock() - - if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { - return nil, fmt.Errorf("look up %d prior values: %w", len(positions), err) - } - - var unresolved []int - for _, n := range positions { - key := folds[n].result.key - value, found, ok := s.cache.AttemptFastLookupRLocked([]byte(key), false) - if !ok { - unresolved = append(unresolved, n) - continue - } - if found { - results[key] = value - } - } - return unresolved, nil -} - -// schedulePriorValueReadsUnlocked classifies the prior values the cache could not resolve, scheduling -// database reads as needed. The reads themselves are completed by the caller, outside the lock. -func (s *shard) schedulePriorValueReadsUnlocked( - folds []stagedFold, - positions []int, - results map[string][]byte, -) ([]pendingRead, error) { - pending := make([]pendingRead, 0, len(positions)) - - s.lock.Lock() - defer s.lock.Unlock() - - if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { - return nil, fmt.Errorf("classify %d prior value reads: %w", len(positions), err) - } - - for _, n := range positions { - key := folds[n].result.key - outcome := s.cache.LookupWLocked([]byte(key), false) - if outcome.immediate { - if outcome.found { - results[key] = outcome.value - } - continue - } - pending = append(pending, pendingRead{ - key: key, - entry: outcome.entry, - valueChan: outcome.valueChan, - needsSchedule: outcome.needsSchedule, - }) - } - return pending, nil -} - -// recordFoldsUnlocked stores what every fold in a batch produced: into the versioned entry staged for it, -// into the version's diff, and into the handle its observers are waiting on. -func (s *shard) recordFoldsUnlocked(folds []stagedFold, newValues [][]byte, version uint64) { - s.lock.Lock() - defer s.lock.Unlock() - - for n := range folds { - key := folds[n].result.key - if s.fillStagedValueWLocked(key, version, folds[n].result, newValues[n]) { - s.versionDiffs[version][key] = newValues[n] - } - s.markFoldResolvedWLocked(version) - // Released under the lock deliberately. A woken observer reads the versioned data, so it has - // to queue behind this hold anyway, and releasing here leaves no window in which the entry is - // filled but its observers are still parked. - folds[n].result.inject(newValues[n], nil) - } -} - -// fillStagedValueWLocked replaces a staged entry with the value its fold produced, reporting whether -// that value is still the one the key holds at this version. A value that is not must be kept out of -// the version's diff. -func (s *shard) fillStagedValueWLocked( - key string, - version uint64, - pending *pendingValue, - value []byte, -) bool { - deque, ok := s.versionedData[key] - if !ok { - // Retirement is the only thing that removes a key, and it refuses a version whose folds are - // still outstanding. Reaching here means that invariant broke, and carrying on would lose - // the write silently. - panic(fmt.Sprintf("no versioned data for staged key %x at version %d", key, version)) - } - - for i := deque.Len() - 1; i >= 0; i-- { - entry := deque.Get(i) - if entry.pending == pending { - deque.Set(i, versionedValue{value: value, version: version}) - return true - } - if entry.version < version { - break - } - } - // The entry is gone, so a later fold or plain write at this version took its place and won. At most - // one entry per version exists, so finding none means this fold's value has been superseded. - return false -} - -// FailStagedFolds records a failed fold on every value a batch staged and takes the shard out of -// service. The version keeps its latch, carrying the failure. -func (s *shard) FailStagedFolds(folds []stagedFold, version uint64, err error) { - s.lock.Lock() - if latch, ok := s.versionLatches[version]; ok && latch.err == nil { - latch.err = err - } - s.cache.TakeOutOfServiceWLocked(err) - for n := range folds { - s.markFoldResolvedWLocked(version) - folds[n].result.inject(nil, err) - } - s.lock.Unlock() - - // Reported after the lock is released: bricking takes the manager's versionLock and then every - // shard's, this one included. - s.reportFoldFailure(err) -} - // Delete deletes the value for the given key. func (s *shard) Delete(key []byte) error { return s.Set(key, nil) @@ -988,12 +544,6 @@ func (s *shard) Commit() (uint64, error) { // This is the single place a sealed diff is produced: the flush, hashing, and retirement all read the // result rather than the map. func (s *shard) MaterializeSortedDiff(version uint64) error { - // A sealed version keeps being written to while its folds resolve, so it can be materialized only - // once its latch has opened. - if err := s.awaitVersionFoldsUnlocked(version); err != nil { - return fmt.Errorf("await version %d before materializing its diff: %w", version, err) - } - handle, diff, err := s.claimDiffToMaterialize(version) if err != nil { return err @@ -1006,7 +556,7 @@ func (s *shard) MaterializeSortedDiff(version uint64) error { } // Ordered outside the lock. The map is private to this call once claimed — it has left - // versionDiffs and the latch guarantees no writer remains — so the only work the lock covers is + // versionDiffs, and writes land only at the current version — so the only work the lock covers is // publishing the result. entries := make([]Write, 0, len(diff)) for key, value := range diff { @@ -1080,38 +630,13 @@ func (s *shard) SortedDiff(version uint64) ([]Write, error) { // Because the target is always the current version, each key resolves to the back of its deque — // no version scan is needed, unlike lookupVersionedRLocked, which serves reads at older versions. func (s *shard) MaterializeCurrentOverrides(lowerBound []byte, upperBound []byte) ([]kvPair, error) { - // Retried rather than resolved in place, because a fold cannot complete while this lock is held. - // One retry is the normal case: iterator construction must not race a batch write, so no further - // values are staged while the first pass's folds are awaited. - for { - pairs, staged, err := s.materializeAttemptUnlocked(lowerBound, upperBound) - if err != nil { - return nil, fmt.Errorf("materialize the current overrides: %w", err) - } - if len(staged) == 0 { - return pairs, nil - } - for _, pending := range staged { - if _, err := pending.await(s.ctx, s.shutdownError); err != nil { - return nil, fmt.Errorf("await a staged value before materializing: %w", err) - } - } - } -} - -// materializeAttemptUnlocked copies the in-memory overrides in range, or reports the folds that have -// to resolve before they can be copied. A non-empty staged result means pairs is incomplete. -func (s *shard) materializeAttemptUnlocked( - lowerBound []byte, - upperBound []byte, -) (pairs []kvPair, staged []*pendingValue, err error) { s.lock.RLock() defer s.lock.RUnlock() // Same reason the read paths check it: a shard taken out of service cannot vouch for its data, // and an iterator is just a bulk read. if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { - return nil, nil, fmt.Errorf("read the current overrides: %w", err) + return nil, fmt.Errorf("materialize the current overrides: read the current overrides: %w", err) } out := make([]kvPair, 0, len(s.versionedData)) @@ -1126,19 +651,12 @@ func (s *shard) materializeAttemptUnlocked( continue } newest := deque.PeekBack() - if newest.pending != nil { - staged = append(staged, newest.pending) - continue - } out = append(out, kvPair{ key: []byte(key), value: newest.value, }) } - if len(staged) > 0 { - return nil, staged, nil - } - return out, nil, nil + return out, nil } // Drop versions, pushing their data down into the read cache. The first version to drop must be @@ -1157,7 +675,7 @@ func (s *shard) DropVersions( // Retirement reads the diffs, so it materializes the versions it is about to drop rather than // trusting that a flush already did. An already materialized version makes this a no-op, which is - // the production case; it cannot happen under the lock below, because materializing waits for folds. + // the production case. for version := firstVersion; version < lastVersion; version++ { if err := s.MaterializeSortedDiff(version); err != nil { return fmt.Errorf("materialize version %d before retiring it: %w", version, err) @@ -1176,17 +694,6 @@ func (s *shard) DropVersions( lastVersion, s.currentVersion) } - // Retirement is driven off the version diffs, so a version with folds outstanding would retire - // without the keys those folds have yet to write: their versioned entries would never be dropped - // and their values would never reach the cache. Retirement only ever follows a flush, which waits - // for the same latch, so this reports a broken lifecycle rather than a race to be waited out. - for version := firstVersion; version < lastVersion; version++ { - if latch, outstanding := s.versionLatches[version]; outstanding { - return fmt.Errorf("version %d still has %d unresolved value(s) and cannot be retired", - version, latch.count) - } - } - // Gather the diffs oldest version first. The cache insert below replays them in that order, so a // key written in several retiring versions ends up holding the newest of those values — which is // what combining them into one map used to do. diff --git a/sei-db/db_engine/view/shutdown_test.go b/sei-db/db_engine/view/shutdown_test.go index 5211756d58..727c1b9c10 100644 --- a/sei-db/db_engine/view/shutdown_test.go +++ b/sei-db/db_engine/view/shutdown_test.go @@ -255,88 +255,3 @@ func TestCloseLeavesNoManagerGoroutines(t *testing.T) { 2*time.Second, 10*time.Millisecond, "manager goroutines leaked across create/use/close cycles") } - -// Close must not return while a fold staged by BatchUpdate is still reading its prior value: that -// read goes through the database Close is about to release. -func TestCloseAwaitsFoldReadingItsPriorValue(t *testing.T) { - db := newTestDB(map[string][]byte{"k": []byte("old")}) - manager := newTestManagerWithDB(t, db, 1, 4096) - - // Baseline past the construction-time initial-hash read, then gate all further DB reads. - base := db.getCalls.Load() - db.getGate = make(chan struct{}) - - // Release the gated read exactly once, and unconditionally on test failure, before the - // pool-draining cleanup registered at construction (t.Cleanup runs LIFO). - releaseGate := sync.OnceFunc(func() { close(db.getGate) }) - t.Cleanup(releaseGate) - - require.NoError(t, manager.BatchUpdate([]string{"k"}, markUpdater{mark: '+'})) - require.Eventually(t, func() bool { return db.getCalls.Load() > base }, - 2*time.Second, time.Millisecond, "the fold never reached the DB for its prior value") - - closeDone := make(chan error, 1) - go func() { closeDone <- manager.Close() }() - - select { - case <-closeDone: - t.Fatal("Close returned while a fold was still reading its prior value") - case <-time.After(100 * time.Millisecond): - } - - releaseGate() - select { - case err := <-closeDone: - require.NoError(t, err) - case <-time.After(2 * time.Second): - t.Fatal("Close did not return after the fold's read was released") - } - - require.Zero(t, db.getsAfterClose.Load(), "a fold read the database after it was closed") -} - -// The production teardown order — manager, then pools, then DB (see CommitStore.Close) — must hold -// with a fold in flight. The fold here has yet to schedule the read of one of its keys, so a Close -// that returned before it resolved would leave it submitting that read to a closed pool. -func TestCloseAwaitsFoldBeforeItSchedulesItsRead(t *testing.T) { - db := newTestDB(map[string][]byte{"a": []byte("old"), "b": []byte("old")}) - readPool := threading.NewAdHocPool() - miscPool := threading.NewAdHocPool() - sortPool := threading.NewAdHocPool() - manager, err := NewViewManager(newTestConfig(1, 4096), db, readPool, miscPool, sortPool) - require.NoError(t, err) - - // The first batch parks mid-fold, holding the value the second batch folds onto. - parked := newParkedUpdater() - require.NoError(t, manager.BatchUpdate([]string{"a"}, parked)) - <-parked.started - - // The second batch takes "a" from the parked fold and "b" from the database. Prior values are - // awaited before any read is scheduled, so this fold parks with "b" unread. - require.NoError(t, manager.BatchUpdate([]string{"a", "b"}, markUpdater{mark: '+'})) - - closeDone := make(chan error, 1) - go func() { closeDone <- manager.Close() }() - - select { - case <-closeDone: - close(parked.release) - t.Fatal("Close returned while two folds were still outstanding") - case <-time.After(100 * time.Millisecond): - } - - close(parked.release) - select { - case err := <-closeDone: - require.NoError(t, err) - case <-time.After(2 * time.Second): - t.Fatal("Close did not return after the parked fold was released") - } - - // Closing the pools is what would panic on a fold that outlived Close. - readPool.Close() - miscPool.Close() - sortPool.Close() - require.NoError(t, db.Close()) - require.Zero(t, db.getsAfterClose.Load(), "a fold read the database after it was closed") -} diff --git a/sei-db/db_engine/view/test_helpers_test.go b/sei-db/db_engine/view/test_helpers_test.go index e0634e0e88..399590ded2 100644 --- a/sei-db/db_engine/view/test_helpers_test.go +++ b/sei-db/db_engine/view/test_helpers_test.go @@ -42,9 +42,6 @@ type testDB struct { commitBlock chan struct{} getGate chan struct{} closed atomic.Bool - // Incremented when a Get reaches the store after Close. Lets tests assert that nothing read the - // database once it was released. - getsAfterClose atomic.Int64 // Batch lifecycle counters: batchesCreated increments in NewBatch, batchesClosed on a // batch's first Close. Lets tests assert every created batch is released (types.Batch // requires Close even after a successful Commit). @@ -68,9 +65,6 @@ func (d *testDB) Get(key []byte) ([]byte, error) { if d.getGate != nil { <-d.getGate } - if d.closed.Load() { - d.getsAfterClose.Add(1) - } if d.getErr != nil { return nil, d.getErr } @@ -338,10 +332,9 @@ func newTestShard(t *testing.T, maxSize uint64, db *testDB) *shard { config := DefaultTestViewManagerConfig() config.EstimatedOverheadPerEntry = 0 // A standalone shard has no manager to brick, and it takes itself out of service on a failed read - // or fold without help, so both reports are no-ops here. + // without help, so the report is a no-op here. s, err := NewShard(context.Background(), config, db, threading.NewAdHocPool(), maxSize, func() error { return ErrViewManagerClosed }, - func(error) {}, func(error) {}) require.NoError(t, err) return s diff --git a/sei-db/db_engine/view/view_manager.go b/sei-db/db_engine/view/view_manager.go index 7d69021f10..e51a9e3dab 100644 --- a/sei-db/db_engine/view/view_manager.go +++ b/sei-db/db_engine/view/view_manager.go @@ -14,15 +14,6 @@ import ( // closed normally rather than failed. Detect it with errors.Is. var ErrViewManagerClosed = errors.New("view manager closed") -// BatchUpdater produces the value to write for each of a batch's keys, from the value that key -// currently holds. One BatchUpdater serves every key in a BatchUpdate call. -type BatchUpdater interface { - // NewValueFor returns the value to write for key, or nil to delete it. priorValue is the value - // key currently holds, or nil if it holds none. Called concurrently, after BatchUpdate has - // returned, and must neither retain nor mutate priorValue. - NewValueFor(key string, priorValue []byte) ([]byte, error) -} - // ViewManager provides a read-through cache and efficient point-in-time views on top of a basic // key-value database. It also coordinates writes to the database, since efficient views require // careful staging of inserts. @@ -80,27 +71,14 @@ type ViewManager interface { // iterators created earlier (see Iterator). BatchSet(writes []Write) error - // BatchUpdate stages a value for every key in keys, to be produced later by handing that key's - // prior value to updater. Where BatchSet takes the values, this takes a function of the values - // already stored. - // - // It returns as soon as the keys are staged, before any prior value has been read and before any - // value has been produced. From that moment the keys read as their new values: a read of one - // blocks until its value is available. A failure to produce a value is reported to whatever - // reads, hashes or flushes that key, and bricks the manager. - // - // keys must not repeat. Not visible to iterators created earlier (see Iterator). - BatchUpdate(keys []string, updater BatchUpdater) error - // Commit seals the current version as an immutable, point-in-time View and advances the // manager to a fresh mutable version. The returned View is safe to read for as long as the // caller holds a reservation on it; see View for the full lifecycle contract. // - // Commit must not be called concurrently with operations on the current (mutable) - // version — Get, BatchGet, Set, Delete, BatchSet, BatchUpdate, or the construction of an - // Iterator. Reads of - // sealed views may proceed concurrently with it, and so may reads through an already-constructed - // Iterator: an iterator is fixed at its creation instant, so a seal cannot disturb it. + // Commit must not be called concurrently with operations on the current (mutable) version: Get, + // BatchGet, Set, Delete, BatchSet, or the construction of an Iterator. Reads of sealed views may + // proceed concurrently with it, and so may reads through an already-constructed Iterator: an + // iterator is fixed at its creation instant, so a seal cannot disturb it. // // Commit may block for backpressure when the underlying DB cannot keep up with flushing // (see ViewManagerConfig.MaxUnflushedVersions). The manager imposes no bound on unfinalized @@ -120,7 +98,7 @@ type ViewManager interface { // Equally, it will never show them — a caller that wants later writes needs a new iterator. // Holding one is therefore safe from another thread, and does not block writes. // - // Constructing an iterator must NOT race a BatchSet or a BatchUpdate. Each shard's overrides are copied under that + // Constructing an iterator must NOT race a BatchSet. Each shard's overrides are copied under that // shard's own lock, so a batch spanning two shards during construction can leave the iterator // holding part of it — a state belonging to no single instant, reported without an error. Serialize // construction against BatchSet. Set and Delete each touch a single shard and so are seen either diff --git a/sei-db/db_engine/view/view_manager_impl.go b/sei-db/db_engine/view/view_manager_impl.go index e2fe9c7bbd..a9cf8a8d3d 100644 --- a/sei-db/db_engine/view/view_manager_impl.go +++ b/sei-db/db_engine/view/view_manager_impl.go @@ -167,8 +167,7 @@ func NewViewManager( miscPool threading.Pool, // A work pool for ordering each sealed version's writes by key. // - // Must not be readPool or miscPool: a sort job blocks awaiting the folds that resolve on those - // pools. Its queue must be large enough never to fill in practice, because Commit submits to it, + // Its queue must be large enough never to fill in practice, because Commit submits to it, // and backpressure on commits belongs to MaxUnflushedVersions alone. The count of outstanding jobs // is not bounded by that setting either, because a version held by an outside reservation stops // being counted as unflushed (see scanForFlushEligibilityLocked) while its successors keep being @@ -218,7 +217,7 @@ func NewViewManager( shards := make([]*shard, config.ShardCount) for i := uint64(0); i < config.ShardCount; i++ { shards[i], err = NewShard(childCtx, config, db, readPool, sizePerShard, - c.shutdownError, c.reportReadFailure, c.reportFoldFailure) + c.shutdownError, c.reportReadFailure) if err != nil { cancel() return nil, fmt.Errorf("failed to create shard: %w", err) @@ -279,61 +278,6 @@ func (c *viewManager) BatchSet(writes []Write) error { return nil } -func (c *viewManager) BatchUpdate(keys []string, updater BatchUpdater) error { - work := c.bucketIndicesByShard(len(keys), func(i int) string { return keys[i] }) - version := c.currentVersion - - // Shards partition the keys, so they stage disjoint sets and run concurrently. - staged := make([][]stagedFold, len(c.shards)) - errs := make([]error, len(c.shards)) - var wg sync.WaitGroup - for shardIndex := range work { - if len(work[shardIndex]) == 0 { - continue - } - wg.Add(1) - c.miscPool.Submit(func() { - defer wg.Done() - staged[shardIndex], errs[shardIndex] = - c.shards[shardIndex].StageUpdates(keys, work[shardIndex], version) - }) - } - wg.Wait() - - for _, err := range errs { - if err != nil { - // The shards that did stage are holding values nothing will ever fold, and a reader would - // park on one forever. Fail them before reporting. A shard whose own staging failed staged - // nothing, so its empty entry is skipped. - c.abandonStaged(staged, version, err) - return fmt.Errorf("failed to stage update in shard: %w", err) - } - } - - // Folding happens here, off this thread, and nothing waits for it: whatever reads, hashes or - // flushes one of these keys is what waits. - for shardIndex, folds := range staged { - if len(folds) == 0 { - continue - } - shard := c.shards[shardIndex] - c.miscPool.Submit(func() { - shard.FoldStagedValues(folds, updater, version) - }) - } - return nil -} - -// abandonStaged fails every fold staged so far, for a BatchUpdate that could not finish staging. -func (c *viewManager) abandonStaged(staged [][]stagedFold, version uint64, err error) { - for shardIndex, folds := range staged { - if len(folds) == 0 { - continue - } - c.shards[shardIndex].FailStagedFolds(folds, version, err) - } -} - // bucketIndicesByShard decides which shard each key of a batch belongs to. // // byShard is indexed by shard, and each entry lists the batch indices of that shard's keys: @@ -546,7 +490,7 @@ func (c *viewManager) sealShardsLocked() error { var wg sync.WaitGroup wg.Add(len(c.shards)) // One task per shard. Sealing a shard is almost entirely the wait to take its lock from the - // readers and folds holding it, and those waits are independent, so overlapping them costs one + // readers holding it, and those waits are independent, so overlapping them costs one // wait rather than eight. Holding every shard lock at once is safe because no shard lock holder // reaches back for the versionLock (see the cache field on shard). for i, shard := range c.shards { @@ -948,15 +892,6 @@ func (c *viewManager) reportReadFailure(err error) { c.brick(fmt.Errorf("failed to read from the underlying database: %w", err)) } -// reportFoldFailure handles a fold that could not produce its value by bricking the manager. The -// latched error names the fold rather than the read that may have fed it. -// -// Must be called without the shard lock held: it acquires versionLock, and the established order is -// versionLock before any shard lock. -func (c *viewManager) reportFoldFailure(err error) { - c.brick(fmt.Errorf("failed to fold a staged value: %w", err)) -} - // brickLocked latches the fatal error, cancels the manager context, wakes backpressure waiters, and // takes every shard out of service, so callers observe the failure immediately rather than waiting // for Close. @@ -1286,19 +1221,6 @@ func (c *viewManager) Close() error { return c.closeErr } -// awaitOutstandingFolds blocks until every fold staged by BatchUpdate has resolved in every shard. -// -// A fold is the manager's only background work that no caller waits for: it runs on a pool, reads -// through to the database the manager owns, and submits that read to a pool the manager's owner -// closes once Close returns. Close calls this before cancelling, so that a fold in flight resolves -// against an open database instead of abandoning a read that would then race db.Close, and outside -// versionLock, which a failing fold takes to brick the manager. -func (c *viewManager) awaitOutstandingFolds() { - for _, s := range c.shards { - s.AwaitOutstandingFolds() - } -} - func (c *viewManager) closeInternal() error { // Tell the lifecycle runner to exit, then wait for it to report offline. The send is // buffered, so it does not block when the runner has already exited (manager failure), and @@ -1306,8 +1228,6 @@ func (c *viewManager) closeInternal() error { c.lifecycleExit <- struct{}{} <-c.lifecycleExited - c.awaitOutstandingFolds() - // Release everyone blocked on the manager's future: AwaitFlush, backpressured // View callers, and reads still awaiting results. The cancel happens under versionLock // because backpressure waiters re-check the context under that lock before parking on the diff --git a/sei-db/state_db/giga/state_db.go b/sei-db/state_db/giga/state_db.go index 319e95d8fb..fa3a67e72e 100644 --- a/sei-db/state_db/giga/state_db.go +++ b/sei-db/state_db/giga/state_db.go @@ -457,7 +457,7 @@ func (s *StateDB) CommitStateChangesWithPrior( } s.commitPhases.SetPhase("commit_sc") - if err := s.sc.CommitStateChanges(blockNum, changeset); err != nil { + if err := s.sc.CommitStateChanges(blockNum, mutations); 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 diff --git a/sei-db/state_db/giga/state_db_replay.go b/sei-db/state_db/giga/state_db_replay.go index 15477535fc..192aef60f1 100644 --- a/sei-db/state_db/giga/state_db_replay.go +++ b/sei-db/state_db/giga/state_db_replay.go @@ -6,7 +6,7 @@ import ( "time" "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb/undo" - "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "github.com/sei-protocol/sei-chain/sei-db/state_db/ss/evm" ) @@ -169,15 +169,19 @@ func (s *StateDB) catchUpTo(ctx context.Context, target int64) error { } s.logReplayPlan(scFrom, ssFrom, ssReplays, target) - if err := s.replay(ctx, from, target, func(block int64, changesets []*proto.NamedChangeSet) error { + if err := s.replay(ctx, from, target, func(block int64, mutations []mutation.Mutation) 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 { + if err := s.sc.CommitStateChanges(block, mutations); err != nil { return err } } if ssReplays && block > ssFrom { + changesets, err := flatkv.MutationsToChangeSets(mutations) + if err != nil { + return fmt.Errorf("convert block %d to changesets: %w", block, err) + } return s.ss.ApplyReplayedBlock(block, changesets) } return nil @@ -227,7 +231,7 @@ func replayPlanFields(store string, from int64, to int64) []any { func (s *StateDB) replay( ctx context.Context, from, target int64, - apply func(int64, []*proto.NamedChangeSet) error, + apply func(int64, []mutation.Mutation) error, ) error { stored, first, last, err := s.wal.GetStoredRange() if err != nil { @@ -264,14 +268,10 @@ func (s *StateDB) replay( break } block, mutations := it.Entry() - changesets, err := flatkv.MutationsToChangeSets(mutations) - if err != nil { - return fmt.Errorf("convert block %d: %w", block, err) - } if err := ctx.Err(); err != nil { return fmt.Errorf("replay stopped at block %d of %d: %w", block, end, err) } - if err := apply(int64(block), changesets); err != nil { //nolint:gosec // block <= end + if err := apply(int64(block), mutations); err != nil { //nolint:gosec // block <= end return fmt.Errorf("replay block %d: %w", block, err) } progress.observe(int64(block)) //nolint:gosec // block <= end diff --git a/sei-db/state_db/giga/state_db_replay_test.go b/sei-db/state_db/giga/state_db_replay_test.go index cfd41c9e8c..52725aa281 100644 --- a/sei-db/state_db/giga/state_db_replay_test.go +++ b/sei-db/state_db/giga/state_db_replay_test.go @@ -161,7 +161,7 @@ func TestCatchUpRefusesAWALMissingTheBlocksAStoreNeeds(t *testing.T) { func TestMatchHeightExcusesAStoreLeftToFillForward(t *testing.T) { _, _, sc := newTestStateDB(t) for block := int64(1); block <= 4; block++ { - require.NoError(t, sc.CommitStateChanges(block, changeset("k", "v"))) + commitToLiveStateDB(t, sc, block, changeset("k", "v")) } s := &StateDB{wal: &gapWAL{first: 3, last: 4}, sc: sc, ss: &evm.EVMStateStore{}} @@ -173,7 +173,7 @@ func TestMatchHeightExcusesAStoreLeftToFillForward(t *testing.T) { func TestMatchHeightDoesNotExcuseAnEmptyStoreTheWALCanRebuild(t *testing.T) { _, _, sc := newTestStateDB(t) for block := int64(1); block <= 4; block++ { - require.NoError(t, sc.CommitStateChanges(block, changeset("k", "v"))) + commitToLiveStateDB(t, sc, block, changeset("k", "v")) } s := &StateDB{wal: &gapWAL{first: 1, last: 4}, sc: sc, ss: &evm.EVMStateStore{}} diff --git a/sei-db/state_db/giga/state_db_test.go b/sei-db/state_db/giga/state_db_test.go index 2ddc24f60f..14bc945c26 100644 --- a/sei-db/state_db/giga/state_db_test.go +++ b/sei-db/state_db/giga/state_db_test.go @@ -76,6 +76,18 @@ func changeset(key string, value string) []*proto.NamedChangeSet { }} } +// commitToLiveStateDB commits cs to the live state DB as block, converted to mutations as the state DB converts +// a block it commits. +func commitToLiveStateDB(t *testing.T, liveStateDB *flatkv.CommitStore, block int64, cs []*proto.NamedChangeSet) { + t.Helper() + prior := liveStateDB.OpenView() + //nolint:gosec // test heights are small + mutations, err := flatkv.MutationsFromChangeSets(cs, uint64(block), prior) + prior.Close() + require.NoError(t, err) + require.NoError(t, liveStateDB.CommitStateChanges(block, mutations)) +} + // Fanning a block out to every layer is the whole job. A layer silently missed here is a layer that // falls a block further behind the others with every block committed. func TestCommitStateChangesReachesWALAndLiveStateDB(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 38d7dc8ccd..b205c7d651 100644 --- a/sei-db/state_db/giga/types/live_state_store.go +++ b/sei-db/state_db/giga/types/live_state_store.go @@ -8,6 +8,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/types/mutation" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" sctypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ) @@ -66,15 +67,13 @@ type LiveStateStore interface { // (see PendingVersion). Commit(version int64) (int64, error) - // CommitStateChanges is a Giga-only helper that applies changesets and commits - // them at blockNum in one call. + // CommitStateChanges writes and commits the block at blockNum, which must be the committed version plus one. + // mutations holds one entry per physical key the block writes, and each PreviousValue must be that key's row + // as of blockNum-1: the lattice hash is computed from it without being checked against the store. // - // Callers must use either ApplyChangeSets+Commit or CommitStateChanges - // exclusively, never mixing the two on the same store. - // - // ApplyChangeSets+Commit is expected to be deprecated once all callers - // move to CommitStateChanges. - CommitStateChanges(blockNum int64, changeset []*proto.NamedChangeSet) error + // Callers must use either ApplyChangeSets+Commit or CommitStateChanges exclusively, never mixing the two on + // the same store. + CommitStateChanges(blockNum int64, mutations []mutation.Mutation) error // OpenView returns a read-only StateView of the current block. The caller must Close it when done. OpenView() StateView 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 9909ba7ea1..5875a6d2b5 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 @@ -259,7 +259,7 @@ func randomCodeValue(rng *testutil.TestRandom) []byte { // randomLegacyValue returns a variable-length value for cosmos / EVM-legacy // keys. Roughly 10% of the time it returns an explicit empty value to exercise -// flatkv's nonNilValue empty-vs-nil WAL round-trip (and memiavl's empty-value +// flatkv's empty-vs-nil WAL round-trip (and memiavl's empty-value // leaf): such keys are present but hold a zero-length value, and the restart / // state-sync / migration phases verify they survive replay. Empty values are // only ever generated for legacy-style keys -- a fixed-length nonce / code diff --git a/sei-db/state_db/sc/composite/store_test.go b/sei-db/state_db/sc/composite/store_test.go index 3cf8b78cbd..632adb9738 100644 --- a/sei-db/state_db/sc/composite/store_test.go +++ b/sei-db/state_db/sc/composite/store_test.go @@ -17,6 +17,7 @@ import ( dbm "github.com/tendermint/tm-db" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" "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/flatkv/lthash" @@ -40,7 +41,7 @@ func (f *failingEVMStore) ApplyChangeSets(int64, []*proto.NamedChangeSet) error return nil } func (f *failingEVMStore) Commit(int64) (int64, error) { return 0, nil } -func (f *failingEVMStore) CommitStateChanges(int64, []*proto.NamedChangeSet) error { +func (f *failingEVMStore) CommitStateChanges(int64, []mutation.Mutation) error { return nil } func (f *failingEVMStore) OpenView() gigatypes.StateView { return nil } diff --git a/sei-db/state_db/sc/flatkv/audit/snapshot_audit.go b/sei-db/state_db/sc/flatkv/audit/snapshot_audit.go index 8b66d20242..95e3253cc5 100644 --- a/sei-db/state_db/sc/flatkv/audit/snapshot_audit.go +++ b/sei-db/state_db/sc/flatkv/audit/snapshot_audit.go @@ -18,6 +18,7 @@ import ( "github.com/cockroachdb/pebble/v2" "golang.org/x/time/rate" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" "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/flatkv/lthash" @@ -68,7 +69,7 @@ type auditBatch struct { db string // rows are the rows, each with its key and value copied out of the iterator. - rows []lthash.KeyMutation + rows []mutation.Mutation } // auditedDatabase is what the reader learned about one database besides its rows. @@ -326,7 +327,7 @@ func (a *snapshotAuditor) readDatabase( if err := a.throttle(ctx, len(key)+len(value)); err != nil { return nil, err } - batch.rows = append(batch.rows, lthash.KeyMutation{Key: bytes.Clone(key), Value: bytes.Clone(value)}) + batch.rows = append(batch.rows, mutation.Mutation{Key: bytes.Clone(key), NewValue: bytes.Clone(value)}) a.rows++ a.bytes += uint64(len(key)) + uint64(len(value)) if len(batch.rows) == int(a.config.BatchSize) { @@ -367,7 +368,7 @@ func openPebbleReadOnly(dbDir string) (*pebble.DB, error) { // newBatch returns an empty batch for the named database. func (a *snapshotAuditor) newBatch(db string) *auditBatch { - return &auditBatch{db: db, rows: make([]lthash.KeyMutation, 0, a.config.BatchSize)} + return &auditBatch{db: db, rows: make([]mutation.Mutation, 0, a.config.BatchSize)} } // throttle waits until the read budget allows n more bytes. It returns at once when the read rate is uncapped. @@ -453,11 +454,11 @@ func hashAuditBatch(batch *auditBatch, modules map[lthash.ModuleKey]*lthash.Modu info.Hash.MixIn(lthash.ComputeLtHash(nil, rows)) for _, row := range rows { // The membership rule the hash applies: a row with an empty key or value is not a member. - if len(row.Key) == 0 || len(row.Value) == 0 { + if len(row.Key) == 0 || len(row.NewValue) == 0 { continue } info.KeyCount++ - info.Bytes += int64(len(row.Key)) + int64(len(row.Value)) + info.Bytes += int64(len(row.Key)) + int64(len(row.NewValue)) } } return nil diff --git a/sei-db/state_db/sc/flatkv/audit/snapshot_audit_test.go b/sei-db/state_db/sc/flatkv/audit/snapshot_audit_test.go index 7292f9ae6e..3a4fe3cf4f 100644 --- a/sei-db/state_db/sc/flatkv/audit/snapshot_audit_test.go +++ b/sei-db/state_db/sc/flatkv/audit/snapshot_audit_test.go @@ -15,6 +15,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb" "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/types/mutation" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" "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" @@ -248,7 +249,7 @@ func TestAuditSnapshotReportsWrongModuleStats(t *testing.T) { func TestAuditSnapshotReportsARecordedModuleWithNoRows(t *testing.T) { snapshot := newAuditTestSnapshot(t) db := openAuditTestSnapshotDB(t, snapshot, miscDB) - ghost := lthash.ComputeLtHash(nil, []lthash.KeyMutation{{Key: []byte("ghost/k"), Value: []byte("v")}}) + ghost := lthash.ComputeLtHash(nil, []mutation.Mutation{{Key: []byte("ghost/k"), NewValue: []byte("v")}}) require.NoError(t, db.Set(ktype.ModuleLtHashKey("ghost"), ghost.Marshal(), types.WriteOptions{Sync: true})) require.NoError(t, db.Close()) diff --git a/sei-db/state_db/sc/flatkv/changeset_mutation_agreement_test.go b/sei-db/state_db/sc/flatkv/changeset_mutation_agreement_test.go index 5edde87a7c..7e0c866c56 100644 --- a/sei-db/state_db/sc/flatkv/changeset_mutation_agreement_test.go +++ b/sei-db/state_db/sc/flatkv/changeset_mutation_agreement_test.go @@ -45,6 +45,38 @@ func TestMutationsAgreeWithFlatKV(t *testing.T) { } } +// TestCommitStateChangesAgreesWithApplyChangeSets commits each block to one store as changesets through +// ApplyChangeSets and Commit, and to another as mutations through CommitStateChanges, and requires both stores to +// hold the same rows and reach the same lattice hash. +func TestCommitStateChangesAgreesWithApplyChangeSets(t *testing.T) { + viaChangeSets := newFlatKVStore(t) + viaMutations := newFlatKVStore(t) + + for i, block := range changesetAgreementBlocks() { + height := uint64(i + 1) + commitBlock(t, viaChangeSets, height, block) + //nolint:gosec // test heights are small + require.NoError(t, commitChangeSets(viaMutations, int64(height), block), "block %d", height) + + require.Equal(t, latestHash(t, viaChangeSets), latestHash(t, viaMutations), "block %d", height) + require.Equal(t, rawRows(t, viaChangeSets), rawRows(t, viaMutations), "block %d", height) + } +} + +// rawRows returns every committed row of store, keyed by physical key. +func rawRows(t *testing.T, store *CommitStore) map[string][]byte { + t.Helper() + it, err := store.RawGlobalIterator() + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + rows := make(map[string][]byte) + for ; it.Valid(); it.Next() { + rows[string(it.Key())] = bytes.Clone(it.Value()) + } + require.NoError(t, it.Error()) + return rows +} + // changesetAgreementBlocks returns a sequence of blocks, each a list of changesets, that writes and deletes every // kind of row and changes account fields one at a time. func changesetAgreementBlocks() [][]*proto.NamedChangeSet { diff --git a/sei-db/state_db/sc/flatkv/changeset_mutation_conversions.go b/sei-db/state_db/sc/flatkv/changeset_mutation_conversions.go index d6271ffcf8..acdcd737ba 100644 --- a/sei-db/state_db/sc/flatkv/changeset_mutation_conversions.go +++ b/sei-db/state_db/sc/flatkv/changeset_mutation_conversions.go @@ -52,17 +52,11 @@ func MutationsFromChangeSets( continue } for _, pair := range changeSet.Changeset.Pairs { - kind, strippedKey, err := ktype.ParseLogicalKey(changeSet.Name, pair.Key) + kind, key, err := changeSetEntryKey(changeSet.Name, pair.Key) if err != nil { return nil, fmt.Errorf("failed to parse module %q key %x: %w", changeSet.Name, pair.Key, err) } - var key []byte - if kind == keys.EVMKeyMisc { - key = ktype.BuildPhysicalKey(changeSet.Name, pair.Key) - } else { - key = ktype.BuildPhysicalEVMKey(kind, strippedKey) - } if isAccountKind(kind) { fields, ok := accountFieldWrites[string(key)] if !ok { @@ -142,6 +136,24 @@ func MutationsToChangeSets(mutations []mutation.Mutation) ([]*proto.NamedChangeS return changeSets, nil } +// changeSetEntryKey returns the kind of row a changeset entry writes and the physical key of that row. Every account +// field of one account maps to the same account row key. +func changeSetEntryKey( + // The module the changeset belongs to. + moduleName string, + // The entry's key within that module. + key []byte, +) (keys.EVMKeyKind, []byte, error) { + kind, strippedKey, err := ktype.ParseLogicalKey(moduleName, key) + if err != nil { + return keys.EVMKeyEmpty, nil, fmt.Errorf("parse logical key: %w", err) + } + if kind == keys.EVMKeyMisc { + return kind, ktype.BuildPhysicalKey(moduleName, key), nil + } + return kind, ktype.BuildPhysicalEVMKey(kind, strippedKey), nil +} + // isAccountKind reports whether kind is one of the account fields merged into a single account row. func isAccountKind(kind keys.EVMKeyKind) bool { return kind == keys.EVMKeyNonce || kind == keys.EVMKeyCodeHash || kind == keys.EVMKeyBalance 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 945bc61762..e01a4cfeed 100644 --- a/sei-db/state_db/sc/flatkv/import_export_test.go +++ b/sei-db/state_db/sc/flatkv/import_export_test.go @@ -879,7 +879,7 @@ func TestExporterCorruptAccountValueInDB(t *testing.T) { // // Invariants verified: // 1. ApplyChangeSets routes non-EVM modules to miscDB under a -// "/" physical-key prefix via classifyAndPrefix. +// "/" physical-key prefix via MutationsFromChangeSets. // 2. The Exporter emits raw physical keys carrying that prefix. // 3. The Importer re-routes via routePhysicalKey, sending non-EVM keys // back to miscDB with the prefix intact. @@ -925,7 +925,7 @@ func TestExporterImporterNonEVMMiscRoundTrip(t *testing.T) { } // Block 1: mixed changeset — non-EVM and EVM in the same commit — so - // classifyAndPrefix has to route both kinds in one pass. + // MutationsFromChangeSets has to route both kinds in one pass. require.NoError(t, src.ApplyChangeSets(src.Version()+1, []*proto.NamedChangeSet{ {Name: "bank", Changeset: proto.ChangeSet{Pairs: bankPairs}}, {Name: "staking", Changeset: proto.ChangeSet{Pairs: stakingPairs}}, diff --git a/sei-db/state_db/sc/flatkv/import_translator.go b/sei-db/state_db/sc/flatkv/import_translator.go index 4d0386e633..ac035368b5 100644 --- a/sei-db/state_db/sc/flatkv/import_translator.go +++ b/sei-db/state_db/sc/flatkv/import_translator.go @@ -3,10 +3,7 @@ package flatkv import ( "errors" "fmt" - "strings" - "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "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/vtype" ) @@ -29,10 +26,8 @@ type PhysicalKVPair struct { // ImportTranslator converts raw EVM/non-EVM changesets into physically-encoded // pairs ready for FlatKV bulk import. // -// It applies the same translation logic that CommitStore.ApplyChangeSets uses -// (classifyAndPrefix + toStorageValues + toCodeValues + -// toMiscValues + mergeAccountUpdates), but assumes the import target -// is empty so it does not merge with prior DB values. +// It produces the same rows a committed block would, but assumes the import +// target is empty so it does not merge with prior DB values. // // Storage / code / misc / non-EVM pairs are emitted directly from each // Translate call. Account-related entries (nonce, codehash, balance) are buffered @@ -44,8 +39,10 @@ type PhysicalKVPair struct { // // ImportTranslator is not safe for concurrent use. type ImportTranslator struct { - blockHeight int64 - pendingAccts map[string]vtype.PendingAccountWrite + blockHeight int64 + + // The account fields written so far to each account row, keyed by string(physical key). + pendingAccts map[string]*vtype.PendingAccountWrite } // NewImportTranslator creates a translator that stamps blockHeight onto every @@ -54,7 +51,7 @@ type ImportTranslator struct { func NewImportTranslator(blockHeight int64) *ImportTranslator { return &ImportTranslator{ blockHeight: blockHeight, - pendingAccts: make(map[string]vtype.PendingAccountWrite), + pendingAccts: make(map[string]*vtype.PendingAccountWrite), } } @@ -71,81 +68,37 @@ func (t *ImportTranslator) Translate(cs *proto.NamedChangeSet) ([]PhysicalKVPair return nil, nil } - // Drop deletes up front: import targets an empty store, so deleting a - // non-existent key is a no-op. This also keeps mergeAccountUpdates - // from interpreting nil values as "set field to zero". - filteredPairs := make([]*proto.KVPair, 0, len(cs.Changeset.Pairs)) - for _, p := range cs.Changeset.Pairs { - if p == nil || p.Delete { + var out []PhysicalKVPair + for _, pair := range cs.Changeset.Pairs { + if pair == nil || pair.Delete { continue } - filteredPairs = append(filteredPairs, p) - } - if len(filteredPairs) == 0 { - return nil, nil - } - filteredCS := &proto.NamedChangeSet{ - Name: cs.Name, - Changeset: proto.ChangeSet{Pairs: filteredPairs}, - } - - changesByType, err := classifyAndPrefix([]*proto.NamedChangeSet{filteredCS}, [keys.EVMKeyKindCount]int{}, nil) - if err != nil { - return nil, err - } - - out := make([]PhysicalKVPair, 0, len(filteredPairs)) - - storageChanges, err := toStorageValues(changesByType[keys.EVMKeyStorage], t.blockHeight) - if err != nil { - return nil, fmt.Errorf("failed to process storage changes: %w", err) - } - out = appendWrites(out, storageChanges) - - codeChanges, err := toCodeValues(changesByType[keys.EVMKeyCode], t.blockHeight) - if err != nil { - return nil, fmt.Errorf("failed to process code changes: %w", err) - } - out = appendWrites(out, codeChanges) - - miscChanges, err := toMiscValues(changesByType[keys.EVMKeyMisc], t.blockHeight) - if err != nil { - return nil, fmt.Errorf("failed to process misc changes: %w", err) - } - out = appendWrites(out, miscChanges) - - // Accumulate nonce + codeHash + balance entries from this batch into the - // translator-level pending account map, so that several Translate calls - // fold their updates for one address together. - batchAccts, err := mergeAccountUpdates( - changesByType[keys.EVMKeyNonce], - changesByType[keys.EVMKeyCodeHash], - changesByType[keys.EVMKeyBalance], - ) - if err != nil { - return nil, fmt.Errorf("failed to merge account changes: %w", err) - } - for arenaAddr, batchUpdate := range batchAccts { - // arenaAddr is carved from a key arena and this map outlives the call, so storing it as a key - // would pin the arena for the rest of the import. Every map write stores its key. - addr := strings.Clone(arenaAddr) - existing, ok := t.pendingAccts[addr] - if !ok { - t.pendingAccts[addr] = batchUpdate - continue + kind, key, err := changeSetEntryKey(cs.Name, pair.Key) + if err != nil { + return nil, fmt.Errorf("failed to parse module %q key %x: %w", cs.Name, pair.Key, err) } - if batchUpdate.IsNonceSet() { - existing.SetNonce(batchUpdate.GetNonce()) + if isAccountKind(kind) { + fields, ok := t.pendingAccts[string(key)] + if !ok { + fields = vtype.NewPendingAccountWrite() + t.pendingAccts[string(key)] = fields + } + if err := fields.SetField(kind, pair.Value, false); err != nil { + return nil, fmt.Errorf("failed to set account field of key %x: %w", key, err) + } + continue } - if batchUpdate.IsCodeHashSet() { - existing.SetCodeHash(batchUpdate.GetCodeHash()) + //nolint:gosec // block heights are non-negative + value, err := rowValue(kind, pair, uint64(t.blockHeight)) + if err != nil { + return nil, fmt.Errorf("failed to encode module %q key %x: %w", cs.Name, pair.Key, err) } - if batchUpdate.IsBalanceSet() { - existing.SetBalance(batchUpdate.GetBalance()) + if value == nil { + // The write empties the row, which an empty import target does not have. + continue } - t.pendingAccts[addr] = existing + out = append(out, PhysicalKVPair{Key: key, Value: value}) } - return out, nil } @@ -156,34 +109,14 @@ func (t *ImportTranslator) Translate(cs *proto.NamedChangeSet) ([]PhysicalKVPair // Call once after all Translate calls. Translate must not be called after // Finalize. func (t *ImportTranslator) Finalize() []PhysicalKVPair { - merged := make(map[string]*vtype.AccountData, len(t.pendingAccts)) - for addr, pending := range t.pendingAccts { - merged[addr] = pending.Merge(nil, t.blockHeight) - } - t.pendingAccts = nil - return appendNonDeletes(make([]PhysicalKVPair, 0, len(merged)), merged) -} - -// appendWrites appends every write that is not a deletion to out. A nil Value is the store's -// tombstone, and an import target starts empty, so there is nothing for one to delete. -func appendWrites(out []PhysicalKVPair, writes []view.Write) []PhysicalKVPair { - for _, w := range writes { - if w.Value == nil { + out := make([]PhysicalKVPair, 0, len(t.pendingAccts)) + for key, fields := range t.pendingAccts { + account := fields.Merge(nil, t.blockHeight) + if account.IsDelete() { continue } - out = append(out, PhysicalKVPair{Key: []byte(w.Key), Value: w.Value}) - } - return out -} - -// appendNonDeletes serializes every non-delete entry in m and appends the resulting -// (physical_key, serialized_value) pair to out. -func appendNonDeletes[T vtype.VType](out []PhysicalKVPair, m map[string]T) []PhysicalKVPair { - for k, v := range m { - if v.IsDelete() { - continue - } - out = append(out, PhysicalKVPair{Key: []byte(k), Value: v.Serialize()}) + out = append(out, PhysicalKVPair{Key: []byte(key), Value: account.Serialize()}) } + t.pendingAccts = nil return out } diff --git a/sei-db/state_db/sc/flatkv/importer.go b/sei-db/state_db/sc/flatkv/importer.go index 51a9555898..d579f20bc5 100644 --- a/sei-db/state_db/sc/flatkv/importer.go +++ b/sei-db/state_db/sc/flatkv/importer.go @@ -11,6 +11,7 @@ import ( "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/giga/types/mutation" "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" "go.opentelemetry.io/otel/metric" @@ -46,7 +47,7 @@ type dbWorker struct { db seidbtypes.KeyValueDB ch chan rawKVPair batch seidbtypes.Batch - ltMutations []lthash.KeyMutation + ltMutations []mutation.Mutation ltHash *lthash.LtHash // moduleLtHash tracks the per-module decomposition of ltHash, keyed by the // "/" physical-key prefix. Its homomorphic sum equals ltHash. @@ -83,7 +84,7 @@ func newDBWorker( db: db, ch: make(chan rawKVPair, workerChanSize), batch: db.NewBatch(), - ltMutations: make([]lthash.KeyMutation, 0, importBatchSize), + ltMutations: make([]mutation.Mutation, 0, importBatchSize), ltHash: ltHash, moduleLtHash: moduleLtHash, moduleStats: moduleStats, @@ -111,9 +112,9 @@ 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.ltMutations = append(w.ltMutations, lthash.KeyMutation{ - Key: kv.Key, - Value: kv.Value, + w.ltMutations = append(w.ltMutations, mutation.Mutation{ + Key: kv.Key, + NewValue: kv.Value, }) if len(w.ltMutations) >= importBatchSize { if err := w.flush(); err != nil { diff --git a/sei-db/state_db/sc/flatkv/key_arena.go b/sei-db/state_db/sc/flatkv/key_arena.go deleted file mode 100644 index 3fab6c0886..0000000000 --- a/sei-db/state_db/sc/flatkv/key_arena.go +++ /dev/null @@ -1,32 +0,0 @@ -package flatkv - -import ( - "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt/util" -) - -// keyArena hands out immutable strings carved from one buffer of fixed capacity. -// -// A string from here keeps the whole buffer alive, so anything that retains one past the version that wrote it must -// copy it. -type keyArena struct { - // The bytes handed out so far. Its capacity is the arena's size, and it never grows past it. - buf []byte -} - -// newKeyArena returns an arena that holds capacity bytes of keys. -func newKeyArena(capacity int) keyArena { - return keyArena{buf: make([]byte, 0, capacity)} -} - -// intern copies key into the arena and returns it as a string. A key that does not fit in the arena's remaining -// capacity gets its own allocation. -func (a *keyArena) intern(key []byte) string { - if len(key) > cap(a.buf)-len(a.buf) { - return string(key) - } - - // The append stays within capacity, so it never moves the bytes earlier strings alias. - start := len(a.buf) - a.buf = append(a.buf, key...) - return util.UnsafeBytesToString(a.buf[start:]) -} diff --git a/sei-db/state_db/sc/flatkv/local_meta_decoder_test.go b/sei-db/state_db/sc/flatkv/local_meta_decoder_test.go index da784258c0..f6b12bbd27 100644 --- a/sei-db/state_db/sc/flatkv/local_meta_decoder_test.go +++ b/sei-db/state_db/sc/flatkv/local_meta_decoder_test.go @@ -6,13 +6,14 @@ import ( "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" "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" ) func TestLocalMetaDecoder(t *testing.T) { t.Run("FullRecord", func(t *testing.T) { - root := lthash.ComputeLtHash(nil, []lthash.KeyMutation{{Key: []byte("evm/k"), Value: []byte("v")}}) + root := lthash.ComputeLtHash(nil, []mutation.Mutation{{Key: []byte("evm/k"), NewValue: []byte("v")}}) stats := map[string]lthash.ModuleStats{"evm": {KeyCount: 1, Bytes: 6}} pairs, err := encodeLocalMeta(42, root, map[string]*lthash.LtHash{"evm": root}, stats) require.NoError(t, err) diff --git a/sei-db/state_db/sc/flatkv/lthash/backend_test.go b/sei-db/state_db/sc/flatkv/lthash/backend_test.go index 4a7a81e20c..ea2e56262a 100644 --- a/sei-db/state_db/sc/flatkv/lthash/backend_test.go +++ b/sei-db/state_db/sc/flatkv/lthash/backend_test.go @@ -6,6 +6,7 @@ import ( "math/rand" "testing" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" "github.com/zeebo/blake3" ) @@ -156,7 +157,7 @@ func BenchmarkHashKV(b *testing.B) { // backend, switching the active backend for each sub-benchmark. func BenchmarkHashChunk(b *testing.B) { rng := rand.New(rand.NewSource(4)) - mutations := make([]KeyMutation, 1000) + mutations := make([]mutation.Mutation, 1000) for i := range mutations { key := make([]byte, 40) last := make([]byte, 76) @@ -164,7 +165,7 @@ func BenchmarkHashChunk(b *testing.B) { rng.Read(key) rng.Read(last) rng.Read(value) - mutations[i] = KeyMutation{Key: key, LastValue: last, Value: value} + mutations[i] = mutation.Mutation{Key: key, PreviousValue: last, NewValue: value} } saved := active defer func() { active = saved }() diff --git a/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go b/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go deleted file mode 100644 index 539b19f290..0000000000 --- a/sei-db/state_db/sc/flatkv/lthash/block_gatherer.go +++ /dev/null @@ -1,211 +0,0 @@ -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 - - // 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) - - // 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, - // 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 { - g := &blockGatherer{ - hasher: hasher, - scheduledBlockChan: make(chan any, cfg.ScheduleQueueSize), - combineJobChan: make(chan any, cfg.CombineQueueSize), - ctx: ctx, - cancel: cancel, - 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. 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 { - case message := <-g.scheduledBlockChan: - switch request := message.(type) { - case *hashRequest: - if !g.gather(request) { - return - } - case *flushRequest: - if !g.forward(request) { - return - } - default: - g.brick(fmt.Errorf("unknown engine message type %T", message)) - return - } - case <-g.ctx.Done(): - return - } - } -} - -// Drain the queue without hashing it and stop the combiner. -func (g *blockGatherer) teardown() { - defer close(g.combineJobChan) - g.discardQueued() -} - -// discardQueued empties the queue without hashing it, releasing each block's reservation. It runs when -// the gatherer stops, and from the engine's enqueue() for a message that reaches the queue after that. -func (g *blockGatherer) discardQueued() { - 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, reporting whether the gatherer may carry on. -func (g *blockGatherer) gather(request *hashRequest) bool { - 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) - } - - return g.forward(&gatheredBlock{ - blockNumber: request.blockNumber, - hashes: hashes, - err: err, - }) -} - -// forward hands one message to the combiner -func (g *blockGatherer) forward(message any) bool { - select { - case g.combineJobChan <- message: - return true - case <-g.ctx.Done(): - return false - } -} - -// 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) { - // One pass over the view's writes. The key is copied because a mutation outlives the walk, while - // the value is the view's own and stays valid until the view retires, which is after hashing. - var mutations []KeyMutation - err := current.ForEachDiff(func(key string, value []byte) error { - if strings.HasPrefix(key, ktype.MetaKeyPrefix) { - return nil - } - mutations = append(mutations, KeyMutation{ - Key: []byte(key), - Value: value, - Delete: value == nil, - }) - return nil - }) - if err != nil { - return DatabaseMutations{}, fmt.Errorf("%s read diff: %w", current.Name(), err) - } - if len(mutations) == 0 { - return DatabaseMutations{DBName: current.Name()}, nil - } - if previous == nil { - return DatabaseMutations{DBName: current.Name(), Mutations: mutations}, nil - } - - // Aliases the keys already held by the mutations rather than copying them again. - changedKeys := make([][]byte, 0, len(mutations)) - for i := range mutations { - changedKeys = append(changedKeys, mutations[i].Key) - } - old, err := previous.BatchGet(changedKeys) - if err != nil { - return DatabaseMutations{}, fmt.Errorf("%s read previous values: %w", current.Name(), err) - } - for i := range mutations { - mutations[i].LastValue = old[string(mutations[i].Key)] - } - return DatabaseMutations{DBName: current.Name(), Mutations: mutations}, nil -} diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_combiner.go b/sei-db/state_db/sc/flatkv/lthash/hash_combiner.go index d47a5323e8..c19fa9c04e 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_combiner.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_combiner.go @@ -15,7 +15,7 @@ type hashCombiner struct { // has been combined. combined *BlockHash - // Gathered blocks arrive here, in block order. + // Submitted blocks arrive here, in block order. combineJobChan <-chan any // When a block is fully hashed, the result is put onto this channel. @@ -36,7 +36,7 @@ func newHashCombiner( dbNames []string, // The hash state the first block is summed onto. runningHash *BlockHash, - // Gathered blocks arrive here, in block order. + // Submitted 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, @@ -57,19 +57,16 @@ func newHashCombiner( return c } -// run combines blocks until the gatherer stops sending them. +// run combines blocks until the engine stops. func (c *hashCombiner) run() { defer close(c.blockHashChan) publishing := true for { select { - case job, open := <-c.combineJobChan: - if !open { - return - } + case job := <-c.combineJobChan: switch job := job.(type) { - case *gatheredBlock: + case *submittedBlock: if publishing { publishing = c.combineBlock(job) } @@ -86,7 +83,7 @@ func (c *hashCombiner) run() { // 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 { +func (c *hashCombiner) combineBlock(job *submittedBlock) 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. 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 3a162c4653..51c1fcaf84 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_engine.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine.go @@ -2,30 +2,23 @@ package lthash import ( "context" - "errors" "fmt" "sync/atomic" "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: +The HashEngine hashes using two pipelined phases: --- Phase 1: Gather -- +-- Phase 1: Hash -- -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. +For each changed key-value pair in a block, we hash both the previous value and the new value. Both arrive +with the block, as its mutations. This phase fans out to a large work pool, since individual leaf hashes can +be computed independently. --- 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 -- +-- Phase 2: 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. @@ -34,8 +27,11 @@ is done one block at a time, since block N's hash is a function of block N-1's h // 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 + // hasher fans each block's leaf hashing out across the pool. + hasher *leafHasher + + // Blocks whose leaf hashing has been submitted, and flush requests, in the order they were scheduled. + combineJobChan chan any // combiner sums each block's leaf hashes onto the block before it, and owns the running state. combiner *hashCombiner @@ -44,8 +40,7 @@ type HashEngine struct { // longer reach. ctx context.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 stops the combiner where it stands. Called by Close, and by the store's own context. cancel context.CancelFunc // fatalErr latches the first failure. Nil until something fails. @@ -85,39 +80,35 @@ func NewHashEngine( } ctx, cancel := context.WithCancel(parent) - he := &HashEngine{ctx: ctx, cancel: cancel} - 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) + he := &HashEngine{ + hasher: newLeafHasher(pool, moduleParser, cfg.ChunkSize), + combineJobChan: make(chan any, cfg.CombineQueueSize), + ctx: ctx, + cancel: cancel, + } + he.combiner = newHashCombiner(dbNames, seed, he.combineJobChan, ctx, cfg.HashChanSize, he.brick) return he, nil } // Schedule a block to be hashed. -// -// 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, + // The height being hashed. + blockNumber int64, + // Everything the block changed, grouped by database. The engine reads it until the block's hash is published, + // so it must not be modified before then. + mutations []DatabaseMutations, ) error { - if current == nil || previous == nil { - return fmt.Errorf("schedule hash: current and previous views are both required") + if err := he.requireRunning(); err != nil { + return fmt.Errorf("schedule hash for block %d: %w", blockNumber, err) } - request := &hashRequest{ - blockNumber: current.BlockHeight(), - current: current, - previous: previous, + hashes, err := he.hasher.submit(mutations) + if err != nil { + // Reported on the stream as this block's hash, like any other failure to hash it. + err = fmt.Errorf("submit block %d: %w", blockNumber, err) } - 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()) + job := &submittedBlock{blockNumber: blockNumber, hashes: hashes, err: err} + if err := he.enqueue(job); err != nil { + return fmt.Errorf("schedule hash for block %d: %w", blockNumber, err) } return nil } @@ -152,7 +143,6 @@ func (he *HashEngine) Flush() error { // Close stops the engine where it stands, abandoning the blocks it has not hashed. func (he *HashEngine) Close() error { he.cancel() - he.gatherer.wg.Wait() he.combiner.wg.Wait() if err := he.errorIfBricked(); err != nil { @@ -161,24 +151,28 @@ func (he *HashEngine) Close() error { return nil } -// 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 { +// requireRunning reports why the engine cannot take more work, or nil if it can. +func (he *HashEngine) requireRunning() error { if err := he.errorIfBricked(); err != nil { return fmt.Errorf("hash engine failed: %w", err) } + if err := he.ctx.Err(); err != nil { + return fmt.Errorf("hash engine is stopping: %w", err) + } + return nil +} + +// enqueue puts a message on the combiner's queue, blocking while it is full. +func (he *HashEngine) enqueue(message any) error { + if err := he.requireRunning(); err != nil { + return err + } select { - case he.gatherer.scheduledBlockChan <- message: + case he.combineJobChan <- message: + return nil case <-he.ctx.Done(): return fmt.Errorf("hash engine is stopping: %w", he.ctx.Err()) } - if he.ctx.Err() != nil { - // The engine stopped around this send, so the gatherer's exit drain may already have run and - // nothing else will take the message off the queue. - he.gatherer.discardQueued() - } - return nil } // brick latches err as the engine's fatal error and stops it. 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 index 7328b00ae4..65d9842f25 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_engine_config.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_engine_config.go @@ -2,15 +2,10 @@ package lthash import "fmt" -// Config configures a HashEngine. The three queue sizes bound how far hashing may fall behind. +// Config configures a HashEngine. The two 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 is how many scheduled blocks may be waiting for the combiner before ScheduleHash() + // blocks. Each costs the memory of its changed values. CombineQueueSize uint32 // HashChanSize is the depth of the channel AwaitHash() reads from. Headroom for a consumer that reads @@ -26,18 +21,14 @@ type Config struct { // DefaultConfig returns the default HashEngine configuration. func DefaultConfig() *Config { return &Config{ - ScheduleQueueSize: 64, - CombineQueueSize: 8, - HashChanSize: 1024, - ChunkSize: 128, + 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") } 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 b52b22266a..da0ff3c738 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 @@ -1,66 +1,18 @@ 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 gives up 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 +// submittedBlock is one block whose leaf hashing has been submitted, waiting to be folded onto the // running hash. -type gatheredBlock struct { +type submittedBlock 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 is set when this block's chunks could not be produced at all, in which case hashes is zero and + // the combiner fails the block rather than reading results. err error } 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 05165d72b8..ff1e412690 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 @@ -12,14 +12,11 @@ import ( "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" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" ) -// 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. +// These tests drive the engine over synthetic blocks, 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" @@ -33,103 +30,49 @@ var engineDBNames = []string{engineAccountName, engineCodeName, engineStorageNam // 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 ForEachDiff 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) ForEachDiff(visit func(key string, value []byte) error) error { - if v.getDiffErr != nil { - return v.getDiffErr - } - // Sorted, because a real view walks each shard's run in key order and a test that depended on map - // order would be depending on something the production walk never produces. - for _, key := range slices.Sorted(maps.Keys(v.diff)) { - if err := visit(key, v.diff[key]); err != nil { - return err - } - } - return 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) Abandon() {} - -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 +// blockMutations builds one block's mutations: diff is what the block wrote, a nil value being a deletion, and +// prior is what those keys held before it. The whole diff goes to the account database; the rest are untouched, +// as most blocks leave most databases alone. +func blockMutations(diff map[string][]byte, prior map[string][]byte) []DatabaseMutations { + mutations := make([]DatabaseMutations, 0, len(engineDBNames)) 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{} + dbMutations := DatabaseMutations{DBName: dbName} if dbName == engineAccountName { - blockDiff = diff + for _, key := range slices.Sorted(maps.Keys(diff)) { + dbMutations.Mutations = append(dbMutations.Mutations, mutation.Mutation{ + Key: []byte(key), + NewValue: diff[key], + PreviousValue: prior[key], + }) + } } - 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) + mutations = append(mutations, dbMutations) } - 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 + return mutations } // 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 { +func newTestEngine(t *testing.T, fold uint32, hashes uint32) *HashEngine { + t.Helper() + return newTestEngineWithModuleOf(t, fold, hashes, engineModuleOf) +} + +// newTestEngineWithModuleOf is newTestEngine with the given module parser. +func newTestEngineWithModuleOf( + t *testing.T, + fold uint32, + hashes uint32, + moduleOf ModuleParser, +) *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)) + engine, err := NewHashEngine(t.Context(), cfg, pool, engineDBNames, moduleOf, NewBlockHash(engineDBNames)) require.NoError(t, err) return engine } @@ -169,16 +112,14 @@ func TestHashEngineAgreesWithSynchronousCompute(t *testing.T) { 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)) + engine := newTestEngine(t, 4, 4) + require.NoError(t, engine.ScheduleHash(1, blockMutations(diff, prior))) 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) + mutations := blockMutations(diff, prior) want, err := compute(pool, engineDBNames, engineModuleOf, mutations, DefaultConfig().ChunkSize) require.NoError(t, err) @@ -187,25 +128,15 @@ func TestHashEngineAgreesWithSynchronousCompute(t *testing.T) { 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. +// the combine queue holds without reading any of them, so 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) + engine := newTestEngine(t, 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)) + require.NoError(t, engine.ScheduleHash(height, blockMutations(blockDiff(height, 120), nil))) } for height := int64(1); height <= blocks; height++ { @@ -219,28 +150,11 @@ func TestHashEngineStreamsOneHashPerBlockInOrder(t *testing.T) { 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)) + engine := newTestEngine(t, 4, 4) + require.NoError(t, engine.ScheduleHash(1, blockMutations(blockDiff(1, 40), nil))) fromEmpty := (<-engine.AwaitHash()).Global.Checksum() require.NoError(t, engine.Close()) @@ -249,8 +163,7 @@ func TestHashEngineStartsFromItsSeed(t *testing.T) { 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)) + require.NoError(t, seeded.ScheduleHash(2, blockMutations(blockDiff(2, 40), nil))) fromSeed := (<-seeded.AwaitHash()).Global.Checksum() require.NoError(t, seeded.Close()) @@ -260,9 +173,8 @@ func TestHashEngineStartsFromItsSeed(t *testing.T) { // 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)) + engine := newTestEngine(t, 4, 4) + require.NoError(t, engine.ScheduleHash(1, blockMutations(blockDiff(1, 40), nil))) seed := <-engine.AwaitHash() require.NoError(t, seed.Error) require.NoError(t, engine.Close()) @@ -272,12 +184,11 @@ func seedWithOneBlock(t *testing.T) *BlockHash { // 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) + engine := newTestEngine(t, 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.ScheduleHash(height, blockMutations(blockDiff(height, 80), nil))) } require.NoError(t, engine.Flush()) @@ -292,37 +203,23 @@ func TestHashEngineFlushWaitsForScheduledBlocks(t *testing.T) { } } -// 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) { +// Close abandons whatever it has not reached rather than finishing it, and still returns. +func TestHashEngineCloseAbandons(t *testing.T) { const blocks = 5 - engine := newTestEngine(t, blocks, blocks, blocks) + engine := newTestEngine(t, 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.ScheduleHash(height, blockMutations(blockDiff(height, 30), nil))) } 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() { } } -// 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. +// A schedule parked on a full queue has to be released once the engine stops. func TestScheduleHashIsReleasedWhenTheEngineStops(t *testing.T) { pool := threading.NewFixedPool("lthash-schedule-shutdown-test", 4, 64) t.Cleanup(pool.Close) @@ -330,7 +227,6 @@ func TestScheduleHashIsReleasedWhenTheEngineStops(t *testing.T) { // 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()) @@ -342,21 +238,15 @@ func TestScheduleHashIsReleasedWhenTheEngineStops(t *testing.T) { // 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) + pending := make([][]DatabaseMutations, 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}) + pending = append(pending, blockMutations(blockDiff(height, 4), nil)) } results := make(chan error, blocks) go func() { - for _, block := range pending { - results <- engine.ScheduleHash(block.current, block.previous) + for i, block := range pending { + results <- engine.ScheduleHash(int64(i+1), block) } }() @@ -381,59 +271,39 @@ func TestScheduleHashIsReleasedWhenTheEngineStops(t *testing.T) { 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) - } } +// failingModuleOf fails every key, so any block with a mutation fails to hash. +func failingModuleOf([]byte) (string, error) { return "", errors.New("injected module failure") } + // 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) + engine := newTestEngineWithModuleOf(t, 4, 4, failingModuleOf) - 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.NoError(t, engine.ScheduleHash(1, blockMutations(blockDiff(1, 10), nil))) got := <-engine.AwaitHash() require.Error(t, got.Error) - require.ErrorContains(t, got.Error, "injected diff failure") + require.ErrorContains(t, got.Error, "injected module failure") require.Equal(t, int64(1), got.BlockNumber) - require.ErrorContains(t, engine.Close(), "injected diff failure") + require.ErrorContains(t, engine.Close(), "injected module 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) + engine := newTestEngineWithModuleOf(t, 4, 4, failingModuleOf) - 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.NoError(t, engine.ScheduleHash(1, blockMutations(blockDiff(1, 10), nil))) require.Error(t, (<-engine.AwaitHash()).Error) - next, nextPrev, nextViews := blockViews(t, 2, blockDiff(2, 10), nil) - err := engine.ScheduleHash(next, nextPrev) + err := engine.ScheduleHash(2, blockMutations(blockDiff(2, 10), nil)) 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") + require.ErrorContains(t, engine.Close(), "injected module failure") } // A flush waits to be told the engine has dealt with everything queued ahead of it, which a stopping @@ -451,8 +321,7 @@ func TestFlushReturnsOnceTheEngineIsStopped(t *testing.T) { // hashing to the pool, and Close is what waits for them to stop. t.Cleanup(func() { require.NoError(t, engine.Close()) }) - current, previous, _ := blockViews(t, 1, blockDiff(1, 4), nil) - require.NoError(t, engine.ScheduleHash(current, previous)) + require.NoError(t, engine.ScheduleHash(1, blockMutations(blockDiff(1, 4), nil))) cancel() diff --git a/sei-db/state_db/sc/flatkv/lthash/hash_types.go b/sei-db/state_db/sc/flatkv/lthash/hash_types.go index 6ee983da5d..593a708aed 100644 --- a/sei-db/state_db/sc/flatkv/lthash/hash_types.go +++ b/sei-db/state_db/sc/flatkv/lthash/hash_types.go @@ -1,20 +1,14 @@ package lthash +import "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" + // 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 + Mutations []mutation.Mutation } // ModuleParser extracts the owning module name from a physical key. Injected by diff --git a/sei-db/state_db/sc/flatkv/lthash/leaf_hasher.go b/sei-db/state_db/sc/flatkv/lthash/leaf_hasher.go index e629776bbd..98cd3aa117 100644 --- a/sei-db/state_db/sc/flatkv/lthash/leaf_hasher.go +++ b/sei-db/state_db/sc/flatkv/lthash/leaf_hasher.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/sei-protocol/sei-chain/sei-db/common/threading" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" ) // The hash phase: turning a block's changed key-value pairs into one homomorphic delta per (database, @@ -81,7 +82,7 @@ func ComputeModuleHashInfos( // a single (database, module) bucket. type lthashTask struct { key ModuleKey - mutations []KeyMutation + mutations []mutation.Mutation } // buildTasks buckets each database's mutations by module and splits every bucket into fixed-size @@ -114,7 +115,7 @@ func buildTasks(moduleOf ModuleParser, mutations []DatabaseMutations, chunkSize } // ComputeLtHash applies mutations to prev and returns the result. A nil prev starts from zero. -func ComputeLtHash(prev *LtHash, mutations []KeyMutation) *LtHash { +func ComputeLtHash(prev *LtHash, mutations []mutation.Mutation) *LtHash { result := New() if prev != nil { result = prev.Clone() @@ -125,40 +126,40 @@ func ComputeLtHash(prev *LtHash, mutations []KeyMutation) *LtHash { // 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). +// defines it: a prior value exists iff PreviousValue is non-empty (an unmix), and a +// new value exists iff NewValue 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 { +func hashChunk(mutations []mutation.Mutation) *ModuleHashInfo { d := &ModuleHashInfo{Hash: New()} - for _, mutation := range mutations { + for _, m := 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 + hadOld := len(m.Key) > 0 && len(m.PreviousValue) > 0 + hasNew := len(m.Key) > 0 && len(m.NewValue) > 0 if hadOld { - h := hash(serializeKV(mutation.Key, mutation.LastValue)) + h := hash(serializeKV(m.Key, m.PreviousValue)) d.Hash.MixOut(h) putLtHashToPool(h) } if hasNew { - h := hash(serializeKV(mutation.Key, mutation.Value)) + h := hash(serializeKV(m.Key, m.NewValue)) d.Hash.MixIn(h) putLtHashToPool(h) } switch { case !hadOld && hasNew: d.KeyCount++ - d.Bytes += int64(len(mutation.Key)) + int64(len(mutation.Value)) + d.Bytes += int64(len(m.Key)) + int64(len(m.NewValue)) case hadOld && hasNew: - d.Bytes += int64(len(mutation.Value)) - int64(len(mutation.LastValue)) + d.Bytes += int64(len(m.NewValue)) - int64(len(m.PreviousValue)) case hadOld && !hasNew: d.KeyCount-- - d.Bytes -= int64(len(mutation.Key)) + int64(len(mutation.LastValue)) + d.Bytes -= int64(len(m.Key)) + int64(len(m.PreviousValue)) } } return d @@ -211,16 +212,16 @@ func hashChunks(pool threading.Pool, tasks []lthashTask) map[ModuleKey]*ModuleHa // physical key via moduleOf. Used to decompose a per-DB root into additive // per-module hashes without changing the root. func BucketByModule( - mutations []KeyMutation, + mutations []mutation.Mutation, moduleOf ModuleParser, -) (map[string][]KeyMutation, error) { - byModule := make(map[string][]KeyMutation) - for _, mutation := range mutations { - module, err := moduleOf(mutation.Key) +) (map[string][]mutation.Mutation, error) { + byModule := make(map[string][]mutation.Mutation) + for _, m := range mutations { + module, err := moduleOf(m.Key) if err != nil { return nil, err } - byModule[module] = append(byModule[module], mutation) + byModule[module] = append(byModule[module], m) } 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 f1bde0b18c..13bb432d6f 100644 --- a/sei-db/state_db/sc/flatkv/lthash/lthash_test.go +++ b/sei-db/state_db/sc/flatkv/lthash/lthash_test.go @@ -8,6 +8,8 @@ import ( "math/rand" "testing" "time" + + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" ) func TestLtHashBasic(t *testing.T) { @@ -17,8 +19,8 @@ func TestLtHashBasic(t *testing.T) { } // Test via ComputeLtHash - lth1 := ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("key"), Value: []byte("value")}, + lth1 := ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("key"), NewValue: []byte("value")}, }) if lth1.IsZero() { t.Error("ComputeLtHash should not return zero for non-empty data") @@ -43,8 +45,8 @@ func TestLtHashBasic(t *testing.T) { } func TestLtHashDeterminism(t *testing.T) { - mutations := []KeyMutation{ - {Key: []byte("key"), Value: []byte("test data for determinism")}, + mutations := []mutation.Mutation{ + {Key: []byte("key"), NewValue: []byte("test data for determinism")}, } lth1 := ComputeLtHash(nil, mutations) @@ -61,11 +63,11 @@ func TestLtHashDeterminism(t *testing.T) { func TestHashKVNoCollision(t *testing.T) { // Verify length-prefixing prevents key||value concatenation collisions - lth1 := ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("a"), Value: []byte("bc")}, + lth1 := ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("a"), NewValue: []byte("bc")}, }) - lth2 := ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("ab"), Value: []byte("c")}, + lth2 := ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("ab"), NewValue: []byte("c")}, }) if lth1.Checksum() == lth2.Checksum() { @@ -80,34 +82,34 @@ func TestComputeLtHash(t *testing.T) { t.Error("Empty changeset should produce zero") } // Insert - result = ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("key1"), Value: []byte("value1")}, + result = ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("key1"), NewValue: []byte("value1")}, }) if result.IsZero() { t.Error("Insert should produce non-zero result") } // Insert then delete should cancel out - result1 := ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("key1"), Value: []byte("value1")}, + result1 := ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("key1"), NewValue: []byte("value1")}, }) - result2 := ComputeLtHash(result1, []KeyMutation{ - {Key: []byte("key1"), LastValue: []byte("value1"), Delete: true}, + result2 := ComputeLtHash(result1, []mutation.Mutation{ + {Key: []byte("key1"), PreviousValue: []byte("value1")}, }) if !result2.IsZero() { t.Error("Insert then delete should cancel out to zero") } // Update: old value replaced with new value - initial := ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("key1"), Value: []byte("value1")}, + initial := ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("key1"), NewValue: []byte("value1")}, }) - updated := ComputeLtHash(initial, []KeyMutation{ - {Key: []byte("key1"), Value: []byte("value2"), LastValue: []byte("value1")}, + updated := ComputeLtHash(initial, []mutation.Mutation{ + {Key: []byte("key1"), NewValue: []byte("value2"), PreviousValue: []byte("value1")}, }) // updated should equal direct insert of value2 - direct := ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("key1"), Value: []byte("value2")}, + direct := ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("key1"), NewValue: []byte("value2")}, }) if updated.Checksum() != direct.Checksum() { t.Error("Update should produce same result as direct insert") @@ -115,11 +117,11 @@ func TestComputeLtHash(t *testing.T) { } func TestComputeLtHashLarge(t *testing.T) { - mutations := make([]KeyMutation, 500) + mutations := make([]mutation.Mutation, 500) for i := range mutations { - mutations[i] = KeyMutation{ - Key: []byte{byte(i >> 8), byte(i)}, - Value: []byte{byte(i), byte(i >> 8)}, + mutations[i] = mutation.Mutation{ + Key: []byte{byte(i >> 8), byte(i)}, + NewValue: []byte{byte(i), byte(i >> 8)}, } } @@ -130,8 +132,8 @@ func TestComputeLtHashLarge(t *testing.T) { } func TestUnmarshal(t *testing.T) { - original := ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("key"), Value: []byte("test data")}, + original := ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("key"), NewValue: []byte("test data")}, }) rawBytes := original.Marshal() @@ -151,8 +153,8 @@ func TestUnmarshal(t *testing.T) { } func TestChecksumHex(t *testing.T) { - lth := ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("key"), Value: []byte("hello")}, + lth := ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("key"), NewValue: []byte("hello")}, }) checksum := lth.Checksum() hexStr := hex.EncodeToString(checksum[:]) @@ -162,8 +164,8 @@ func TestChecksumHex(t *testing.T) { } func TestReset(t *testing.T) { - lth := ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("key"), Value: []byte("data")}, + lth := ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("key"), NewValue: []byte("data")}, }) if lth.IsZero() { t.Error("Should not be zero after ComputeLtHash") @@ -176,15 +178,15 @@ func TestReset(t *testing.T) { func TestEmptyKeyOrValue(t *testing.T) { // Empty key or value should be skipped - result := ComputeLtHash(nil, []KeyMutation{ - {Key: nil, Value: []byte("value")}, + result := ComputeLtHash(nil, []mutation.Mutation{ + {Key: nil, NewValue: []byte("value")}, }) if !result.IsZero() { t.Error("Empty key should be skipped") } - result = ComputeLtHash(nil, []KeyMutation{ - {Key: []byte("key"), Value: nil}, + result = ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("key"), NewValue: nil}, }) if !result.IsZero() { t.Error("Empty value should be skipped") @@ -196,13 +198,13 @@ func TestEmptyKeyOrValue(t *testing.T) { func TestParallelConsistency(t *testing.T) { // Create enough pairs to trigger parallel path (> 100) count := 500 - mutations := make([]KeyMutation, count) + mutations := make([]mutation.Mutation, count) for i := 0; i < count; i++ { key := fmt.Sprintf("key-%d", i) val := fmt.Sprintf("val-%d", i) - mutations[i] = KeyMutation{ - Key: []byte(key), - Value: []byte(val), + mutations[i] = mutation.Mutation{ + Key: []byte(key), + NewValue: []byte(val), } } @@ -232,9 +234,9 @@ func TestParallelConsistency(t *testing.T) { // Commutativity: A + B = B + A // Associativity: (A + B) + C = A + (B + C) func TestHomomorphicProperties(t *testing.T) { - kv1 := []KeyMutation{{Key: []byte("k1"), Value: []byte("v1")}} - kv2 := []KeyMutation{{Key: []byte("k2"), Value: []byte("v2")}} - kv3 := []KeyMutation{{Key: []byte("k3"), Value: []byte("v3")}} + kv1 := []mutation.Mutation{{Key: []byte("k1"), NewValue: []byte("v1")}} + kv2 := []mutation.Mutation{{Key: []byte("k2"), NewValue: []byte("v2")}} + kv3 := []mutation.Mutation{{Key: []byte("k3"), NewValue: []byte("v3")}} h1 := ComputeLtHash(nil, kv1) h2 := ComputeLtHash(nil, kv2) @@ -280,17 +282,16 @@ func TestFuzz(t *testing.T) { binary.LittleEndian.PutUint64(val, rng.Uint64()) // Randomly insert or delete - op := KeyMutation{Key: key} + op := mutation.Mutation{Key: key} if rng.Intn(2) == 0 { // Insert - op.Value = val + op.NewValue = val } else { // Delete (requires we "know" the old value, but here we just test MixOut stability) - op.LastValue = val - op.Delete = true + op.PreviousValue = val } - next := ComputeLtHash(base, []KeyMutation{op}) + next := ComputeLtHash(base, []mutation.Mutation{op}) base = next } 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 7a56472e10..a79d143237 100644 --- a/sei-db/state_db/sc/flatkv/lthash/stats_test.go +++ b/sei-db/state_db/sc/flatkv/lthash/stats_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/sei-db/common/threading" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" ) func TestModuleStatsMarshalRoundTrip(t *testing.T) { @@ -48,44 +49,48 @@ func TestFoldChunkStats(t *testing.T) { tests := []struct { name string - pair KeyMutation + pair mutation.Mutation wantKeys int64 wantByte int64 }{ { name: "add", - pair: KeyMutation{Key: key, Value: []byte("newvalue")}, + pair: mutation.Mutation{Key: key, NewValue: []byte("newvalue")}, wantKeys: 1, wantByte: int64(len(key)) + int64(len("newvalue")), }, { - name: "update grows", - pair: KeyMutation{Key: key, Value: []byte("longer-value"), LastValue: []byte("short")}, + name: "update grows", + pair: mutation.Mutation{ + Key: key, + NewValue: []byte("longer-value"), + PreviousValue: []byte("short"), + }, wantKeys: 0, wantByte: int64(len("longer-value")) - int64(len("short")), }, { name: "update shrinks", - pair: KeyMutation{Key: key, Value: []byte("v"), LastValue: []byte("wasbigger")}, + pair: mutation.Mutation{Key: key, NewValue: []byte("v"), PreviousValue: []byte("wasbigger")}, wantKeys: 0, wantByte: int64(len("v")) - int64(len("wasbigger")), }, { name: "delete", - pair: KeyMutation{Key: key, LastValue: []byte("oldvalue"), Delete: true}, + pair: mutation.Mutation{Key: key, PreviousValue: []byte("oldvalue")}, wantKeys: -1, wantByte: -(int64(len(key)) + int64(len("oldvalue"))), }, { name: "delete absent is no-op", - pair: KeyMutation{Key: key, Delete: true}, + pair: mutation.Mutation{Key: key}, wantKeys: 0, wantByte: 0, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - d := hashChunk([]KeyMutation{tc.pair}) + d := hashChunk([]mutation.Mutation{tc.pair}) require.Equal(t, tc.wantKeys, d.KeyCount) require.Equal(t, tc.wantByte, d.Bytes) }) @@ -102,12 +107,12 @@ func TestComputeModuleHashInfosStatsParallel(t *testing.T) { defer pool.Close() cfg := DefaultConfig() n := int(cfg.ChunkSize)*3 + 7 // spans several chunks, not a chunk multiple - mutations := make([]KeyMutation, n) + mutations := make([]mutation.Mutation, n) var wantKeys, wantBytes int64 for i := range mutations { key := []byte(fmt.Sprintf("m/key-%05d", i)) val := []byte(fmt.Sprintf("value-%d", i)) - mutations[i] = KeyMutation{Key: key, Value: val} + mutations[i] = mutation.Mutation{Key: key, NewValue: val} wantKeys++ wantBytes += int64(len(key)) + int64(len(val)) } 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 a6c2c79d27..9c3b620638 100644 --- a/sei-db/state_db/sc/flatkv/lthash_agreement_test.go +++ b/sei-db/state_db/sc/flatkv/lthash_agreement_test.go @@ -12,6 +12,7 @@ import ( "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/giga/types/mutation" "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" @@ -416,9 +417,9 @@ func (m *stateModel) expect() *expectedState { } for _, dir := range dataDBDirs { byKey := byDB[dir] - pairs := make([]lthash.KeyMutation, 0, len(byKey)) + pairs := make([]mutation.Mutation, 0, len(byKey)) for physKey, value := range byKey { - pairs = append(pairs, lthash.KeyMutation{Key: []byte(physKey), Value: value}) + pairs = append(pairs, mutation.Mutation{Key: []byte(physKey), NewValue: value}) out.rows[physKey] = value } root := lthash.ComputeLtHash(nil, pairs) 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 bdea111707..0cdc152141 100644 --- a/sei-db/state_db/sc/flatkv/lthash_correctness_test.go +++ b/sei-db/state_db/sc/flatkv/lthash_correctness_test.go @@ -13,6 +13,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/keys" "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/types/mutation" "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" @@ -29,7 +30,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.KeyMutation + var pairs []mutation.Mutation scanDB := func(db types.KeyValueDB) { iter, err := db.NewIter(&types.IterOptions{}) @@ -41,9 +42,9 @@ func fullScanLtHash(t *testing.T, s *CommitStore) *lthash.LtHash { } key := bytes.Clone(iter.Key()) value := bytes.Clone(iter.Value()) - pairs = append(pairs, lthash.KeyMutation{ - Key: key, - Value: value, + pairs = append(pairs, mutation.Mutation{ + Key: key, + NewValue: value, }) } require.NoError(t, iter.Error()) diff --git a/sei-db/state_db/sc/flatkv/metrics.go b/sei-db/state_db/sc/flatkv/metrics.go index a02fb23530..cc2a306a49 100644 --- a/sei-db/state_db/sc/flatkv/metrics.go +++ b/sei-db/state_db/sc/flatkv/metrics.go @@ -20,7 +20,6 @@ var ( ApplyChangesetsLatency metric.Float64Histogram CommitLatency metric.Float64Histogram CommitBatchLatency metric.Float64Histogram - AccountUpdateLatency metric.Float64Histogram NumKVPairs metric.Int64Counter PendingWrites metric.Int64Gauge CurrentVersion metric.Int64Gauge @@ -61,14 +60,6 @@ var ( metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(commonmetrics.LatencyBuckets...), )), - AccountUpdateLatency: must(flatkvMeter.Float64Histogram( - "flatkv_account_update_latency", - metric.WithDescription( - "Time taken to stage one block's account changes with the account store, which folds "+ - "them onto the rows they modify on its own threads"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(commonmetrics.LatencyBuckets...), - )), NumKVPairs: must(flatkvMeter.Int64Counter( "flatkv_num_kv_pairs", metric.WithDescription("Number of key-value pairs applied to FlatKV"), 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 04b342523c..357d9b62f5 100644 --- a/sei-db/state_db/sc/flatkv/perdb_lthash_test.go +++ b/sei-db/state_db/sc/flatkv/perdb_lthash_test.go @@ -8,9 +8,9 @@ import ( "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb" "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/types/mutation" "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" @@ -26,14 +26,14 @@ func testFullScanDBLtHash(t *testing.T, db types.KeyValueDB) *lthash.LtHash { require.NoError(t, err) defer iter.Close() - var pairs []lthash.KeyMutation + var pairs []mutation.Mutation for ; iter.Valid(); iter.Next() { if ktype.IsMetaKey(iter.Key()) { continue } - pairs = append(pairs, lthash.KeyMutation{ - Key: bytes.Clone(iter.Key()), - Value: bytes.Clone(iter.Value()), + pairs = append(pairs, mutation.Mutation{ + Key: bytes.Clone(iter.Key()), + NewValue: bytes.Clone(iter.Value()), }) } require.NoError(t, iter.Error()) @@ -92,26 +92,20 @@ func commitMixedState(t *testing.T, s *CommitStore, round byte) { require.NoError(t, err) } -// Test: crash recovery where one data DB's version record is behind the others. -// -// A data DB commits its version in the same batch as its data, so a torn commit -// leaves the DBs at different versions. The store then opens at the lowest and -// replays from there — into DBs that already hold those blocks. This pins that -// the replay is idempotent: re-applying a block to a DB that already has it is a -// no-op for the LtHash, because the old value read back is the new value. +// TestPerDBLtHashSkewRecovery pins crash recovery where one data DB is a block behind the others: the store opens +// at the lowest height, replays the missing block into that DB alone, and lands on the root it had before. func TestPerDBLtHashSkewRecovery(t *testing.T) { - dir := t.TempDir() - dbDir := filepath.Join(dir, flatkvRootDir) - cfg := config.DefaultTestConfig(t) - cfg.DataDir = dbDir + cfg.DataDir = filepath.Join(t.TempDir(), flatkvRootDir) s1, err := newCommitStoreWithWAL(t.Context(), cfg) require.NoError(t, err) - err = s1.LoadLatest() - require.NoError(t, err) - + require.NoError(t, s1.LoadLatest()) commitMixedState(t, s1, 1) + s1 = reopenStore(t, s1, cfg) + accountAt1 := copyDataDB(t, cfg, accountDBDir) + + require.NoError(t, s1.LoadLatest()) commitMixedState(t, s1, 2) verifyPerDBLtHash(t, s1) wantRoot := bytes.Clone(rootHash(s1)) @@ -119,25 +113,16 @@ func TestPerDBLtHashSkewRecovery(t *testing.T) { for _, dbDir := range dataDBDirs { 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 - // torn commit leaves behind and what the next open actually reads: tampering with the snapshot - // instead has no effect, because SNAPSHOT_BASE still matches and the working dir is reused. - rewindVersionRecords(t, s1, 1, accountDBDir) require.NoError(t, s1.Close()) - // Reopen -- catchup should replay version 2 from WAL - cfg2 := config.DefaultTestConfig(t) - cfg2.DataDir = dbDir + restoreDataDB(t, cfg, accountDBDir, accountAt1) - s2, err := newCommitStoreWithWAL(t.Context(), cfg2) - require.NoError(t, err) - err = s2.LoadLatest() + s2, err := newCommitStoreWithWAL(t.Context(), cfg) require.NoError(t, err) + require.NoError(t, s2.LoadLatest()) defer s2.Close() - require.Equal(t, wantRoot, rootHash(s2), - "replaying an already-applied block must reproduce the same global root") + require.Equal(t, wantRoot, rootHash(s2), "replaying the lagging DB must reproduce the same global root") for _, dbDir := range dataDBDirs { require.Equal(t, wantPerDB[dbDir], s2.maintainedHashes().PerDB[dbDir].Checksum(), "%s per-DB root must be bit-identical after replay", dbDir) @@ -615,8 +600,8 @@ func TestPerDBLtHashSumInvariantAcrossAllOperations(t *testing.T) { // apply each block only to the stores missing it, or the ones that already have it fold that block // into their LtHash twice. // -// The skew is forged by rewinding one data database's recorded height on disk while the store is -// closed, which is what a lost flush of that database looks like on the next open. +// The skew is built by putting back a copy of the storage database taken at block 1, which is what a lost +// flush of that database looks like on the next open. func TestPerDBLtHashLevelsUpStoresAtDifferentHeights(t *testing.T) { dir := t.TempDir() dbDir := filepath.Join(dir, flatkvRootDir) @@ -629,6 +614,12 @@ func TestPerDBLtHashLevelsUpStoresAtDifferentHeights(t *testing.T) { require.NoError(t, s1.LoadLatest()) commitMixedState(t, s1, 1) + s1 = reopenStore(t, s1, cfg) + require.Equal(t, resolveConfig(cfg).StorageDBConfig.DataDir, filepath.Join(dbDir, workingDirName, storageDBDir), + "the skew must target the directory the store opens, or this test proves nothing") + storageAt1 := copyDataDB(t, cfg, storageDBDir) + + require.NoError(t, s1.LoadLatest()) commitMixedState(t, s1, 2) commitMixedState(t, s1, 3) verifyPerDBLtHash(t, s1) @@ -639,20 +630,9 @@ func TestPerDBLtHashLevelsUpStoresAtDifferentHeights(t *testing.T) { 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 - // are at {storage: 1, others: 3}, so the store derives version 1 and replays 2 and 3 into databases - // that already hold them. - // The working directory, not the snapshot — see TestPerDBLtHashSkewRecovery. - storageCfg := pebbledb.DefaultConfig() - storageCfg.DataDir = filepath.Join(dbDir, workingDirName, storageDBDir) - resolved := resolveConfig(cfg) - require.Equal(t, resolved.StorageDBConfig.DataDir, storageCfg.DataDir, - "the forged skew must target the directory the store opens, or this test proves nothing") - storageCfg.EnableMetrics = false - db, err := pebbledb.Open(t.Context(), &storageCfg) - require.NoError(t, err) - require.NoError(t, db.Set(ktype.MetaVersionKey, versionToBytes(1), types.WriteOptions{Sync: true})) - require.NoError(t, db.Close()) + // On reopen the stores are at {storage: 1, others: 3}, so the store derives version 1 and replays 2 + // and 3 into the storage database alone. + restoreDataDB(t, cfg, storageDBDir, storageAt1) s2, err := newCommitStoreWithWAL(t.Context(), cfg) require.NoError(t, err) 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 482eba9279..dc97d5520f 100644 --- a/sei-db/state_db/sc/flatkv/permodule_lthash_test.go +++ b/sei-db/state_db/sc/flatkv/permodule_lthash_test.go @@ -10,6 +10,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/keys" "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/types/mutation" "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" @@ -34,16 +35,16 @@ func fullScanModuleLtHash(t *testing.T, db types.KeyValueDB) map[string]*lthash. require.NoError(t, err) defer iter.Close() - byModule := make(map[string][]lthash.KeyMutation) + byModule := make(map[string][]mutation.Mutation) 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.KeyMutation{ - Key: bytes.Clone(iter.Key()), - Value: bytes.Clone(iter.Value()), + byModule[module] = append(byModule[module], mutation.Mutation{ + Key: bytes.Clone(iter.Key()), + NewValue: bytes.Clone(iter.Value()), }) } require.NoError(t, iter.Error()) diff --git a/sei-db/state_db/sc/flatkv/snapshot_test.go b/sei-db/state_db/sc/flatkv/snapshot_test.go index aad5d925db..8cdc92143c 100644 --- a/sei-db/state_db/sc/flatkv/snapshot_test.go +++ b/sei-db/state_db/sc/flatkv/snapshot_test.go @@ -2043,6 +2043,9 @@ func TestAccountRowDeleteSurvivesWALReplay(t *testing.T) { require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs1})) _, err = s.Commit(s.Version() + 1) // v1 require.NoError(t, err) + s = reopenStore(t, s, cfg) + accountAt1 := copyDataDB(t, cfg, accountDBDir) + require.NoError(t, s.LoadLatest()) cs2 := &proto.NamedChangeSet{ Name: "evm", @@ -2057,15 +2060,8 @@ func TestAccountRowDeleteSurvivesWALReplay(t *testing.T) { hashAtV2 := rootHash(s) require.NoError(t, s.Close()) - // Simulate a torn commit: rewind accountDB's version record to v1 so catchup must replay v2 - metaCfg := pebbledb.DefaultTestConfig(t) - metaCfg.DataDir = filepath.Join(dbDir, "working", accountDBDir) - mdb, err := pebbledb.Open(context.Background(), &metaCfg) - require.NoError(t, err) - versionBuf := make([]byte, 8) - versionBuf[7] = 1 // version = 1 - require.NoError(t, mdb.Set(ktype.MetaVersionKey, versionBuf, types.WriteOptions{Sync: true})) - require.NoError(t, mdb.Close()) + // Put back accountDB as it was at v1, so catchup must replay v2 into it. + restoreDataDB(t, cfg, accountDBDir, accountAt1) s2, err := newCommitStoreWithWAL(context.Background(), cfg) require.NoError(t, err) diff --git a/sei-db/state_db/sc/flatkv/state_view_bench_test.go b/sei-db/state_db/sc/flatkv/state_view_bench_test.go index f611b884c4..2263de46a9 100644 --- a/sei-db/state_db/sc/flatkv/state_view_bench_test.go +++ b/sei-db/state_db/sc/flatkv/state_view_bench_test.go @@ -32,7 +32,7 @@ func benchView(b *testing.B) (gigatypes.StateView, gigatypes.Address) { Key: keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]), Value: padLeft32(0x05), }) - require.NoError(b, s.CommitStateChanges(1, []*proto.NamedChangeSet{cs})) + require.NoError(b, commitChangeSets(s, 1, []*proto.NamedChangeSet{cs})) v := s.OpenView() b.Cleanup(v.Close) return v, gigaAddr(addr) diff --git a/sei-db/state_db/sc/flatkv/state_view_raw_test.go b/sei-db/state_db/sc/flatkv/state_view_raw_test.go index b5b4b73294..8e432d57a8 100644 --- a/sei-db/state_db/sc/flatkv/state_view_raw_test.go +++ b/sei-db/state_db/sc/flatkv/state_view_raw_test.go @@ -29,7 +29,7 @@ func commitRowInEveryStore(t *testing.T, s *CommitStore) []rawRow { evmMisc := evmMiscKey(0x05) bankKey := []byte{0x06} - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{ + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{ namedCS( noncePair(addr, 7), balancePair(addr, balance), 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 7d5ca59110..0e57f3eaf4 100644 --- a/sei-db/state_db/sc/flatkv/state_view_test.go +++ b/sei-db/state_db/sc/flatkv/state_view_test.go @@ -9,6 +9,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/keys" "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/giga/types/mutation" "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/vtype" ) @@ -21,7 +22,7 @@ func gigaAddr(addr ktype.Address) gigatypes.Address { // commitNonce writes addr's nonce as a whole block through the Giga write entry point. func commitNonce(t *testing.T, s *CommitStore, version int64, addr ktype.Address, nonce uint64) { t.Helper() - require.NoError(t, s.CommitStateChanges(version, []*proto.NamedChangeSet{namedCS(noncePair(addr, nonce))})) + require.NoError(t, commitChangeSets(s, version, []*proto.NamedChangeSet{namedCS(noncePair(addr, nonce))})) } // CommitStateChanges is the Giga write entry point, and its whole job is that a block committed @@ -44,7 +45,10 @@ func TestCommitStateChangesRejectsNonContiguousVersion(t *testing.T) { s := setupTestStore(t) defer func() { require.NoError(t, s.Close()) }() - err := s.CommitStateChanges(5, []*proto.NamedChangeSet{namedCS(noncePair(addrN(1), 7))}) + addr := addrN(1) + key := ktype.BuildPhysicalEVMKey(keys.EVMKeyNonce, addr[:]) + row := vtype.NewAccountData().SetNonce(7).Serialize() + err := s.CommitStateChanges(5, []mutation.Mutation{{Key: key, NewValue: row}}) require.Error(t, err) require.Equal(t, int64(0), s.Version(), "a refused block must not advance the store") } @@ -158,7 +162,7 @@ func TestStateViewEVMAccessors(t *testing.T) { bytecode := []byte{0x60, 0x80, 0x60, 0x40} codeHash := codeHashN(0xAB) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{namedCS( + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{namedCS( noncePair(contract, 3), codeHashPair(contract, codeHash), codePair(contract, bytecode), @@ -211,7 +215,7 @@ func TestStateViewReadAccountMatchesFieldGetters(t *testing.T) { contract, funded, missing := addrN(1), addrN(2), addrN(3) codeHash := codeHashN(0xAB) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{namedCS( + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{namedCS( noncePair(contract, 3), codeHashPair(contract, codeHash), codePair(contract, []byte{0x60, 0x80}), @@ -250,7 +254,7 @@ func TestStateViewGetBalance(t *testing.T) { absent := addrN(3) balance := balanceN(42) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{ + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{ namedCS(balancePair(funded, balance), noncePair(nonceOnly, 7)), })) @@ -269,7 +273,7 @@ func TestStateViewBalanceCreatesAccount(t *testing.T) { defer func() { require.NoError(t, s.Close()) }() addr := addrN(1) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{namedCS(balancePair(addr, balanceN(9)))})) + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{namedCS(balancePair(addr, balanceN(9)))})) stateView := s.OpenView() defer stateView.Close() @@ -284,8 +288,8 @@ func TestStateViewBalanceDeletionRemovesAccount(t *testing.T) { defer func() { require.NoError(t, s.Close()) }() addr := addrN(1) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{namedCS(balancePair(addr, balanceN(9)))})) - require.NoError(t, s.CommitStateChanges(2, []*proto.NamedChangeSet{namedCS(balanceDeletePair(addr))})) + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{namedCS(balancePair(addr, balanceN(9)))})) + require.NoError(t, commitChangeSets(s, 2, []*proto.NamedChangeSet{namedCS(balanceDeletePair(addr))})) stateView := s.OpenView() defer stateView.Close() @@ -309,7 +313,7 @@ func TestStateViewGetReturnsValues(t *testing.T) { codeHash := codeHashN(0xAB) balance := balanceN(0x77) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{ + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{ namedCS( noncePair(addr, 5), codeHashPair(addr, codeHash), diff --git a/sei-db/state_db/sc/flatkv/store.go b/sei-db/state_db/sc/flatkv/store.go index f1c63a6988..6ce489a4dc 100644 --- a/sei-db/state_db/sc/flatkv/store.go +++ b/sei-db/state_db/sc/flatkv/store.go @@ -167,10 +167,6 @@ type CommitStore struct { phaseTimer *metrics.PhaseTimer - // classifyBucketSizes is the length of each kind's bucket in the previous staged block, used to size the next - // block's buckets. Zero for a kind that block did not touch. - classifyBucketSizes [keys.EVMKeyKindCount]int - // readOnly marks stores opened via LoadVersionReadOnly. readOnly bool @@ -216,9 +212,8 @@ func routePhysicalKey(physicalKey []byte) (string, error) { // An empty module name would fold into moduleLtHash[""]/moduleStats[""] // and later persist as the per-module meta key "_meta/x:/hash", which // ParseModuleLtHashKey rejects on the very next reopen — permanently - // bricking the store (root != sum(modules) forever). Reject it here, - // at the state-sync import dispatch boundary, mirroring the - // classifyAndPrefix guard on the live-commit path (store_apply.go). + // bricking the store (root != sum(modules) forever). Every committed + // and imported key is routed here, so this is where it is rejected. return "", fmt.Errorf("flatkv: empty module name in physical key %q", physicalKey) } if moduleName != keys.EVMStoreKey { diff --git a/sei-db/state_db/sc/flatkv/store_apply.go b/sei-db/state_db/sc/flatkv/store_apply.go index b138c25161..62c969053e 100644 --- a/sei-db/state_db/sc/flatkv/store_apply.go +++ b/sei-db/state_db/sc/flatkv/store_apply.go @@ -5,32 +5,18 @@ import ( "errors" "fmt" "sync" - "time" - "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "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/giga/types/mutation" "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/vtype" - "go.opentelemetry.io/otel/metric" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) -// classifyBucketHeadroom is the factor applied to a kind's bucket length in the previous staged block to size that -// bucket in the next. -const classifyBucketHeadroom = 2 - -// classifyUnitSize is the most changeset pairs one worker classifies. -const classifyUnitSize = 1024 - // ApplyChangeSets buffers changeSets as part of the block at version, which the next Commit (or // CommitPendingBlock) writes. Nothing reaches the stores before then, so reads do not observe buffered // writes. func (s *CommitStore) ApplyChangeSets(version int64, changeSets []*proto.NamedChangeSet) error { - // The read-only refusal belongs here rather than in stageChangeSets, which a read-only store reaches - // legitimately: building a view at a past height replays the primary's WAL through the same staging path. - // Commit and outOfBandSnapshot place their refusals at the same boundary, and readOnly is fixed for a store's - // lifetime, so reading it outside the lock is safe. if s.readOnly { return errReadOnly } @@ -67,270 +53,106 @@ func (s *CommitStore) ApplyChangeSets(version int64, changeSets []*proto.NamedCh return nil } -// stageChangeSets writes one block's changesets into the four data stores, where the block's seal picks them up. -// Non-EVM modules go to miscDB under "/". Each value records version as the height it was last modified -// at. The caller holds mu, or has the store to itself. -func (s *CommitStore) stageChangeSets( +// stageMutations writes one block's mutations into the four data stores, where the block's seal picks them up, and +// returns them grouped by database for hashing, in dataDBDirs order. A database that already holds the block is +// neither written nor given any mutations to hash. The caller holds mu, or has the store to itself. +func (s *CommitStore) stageMutations( version int64, - changeSets []*proto.NamedChangeSet, + mutations []mutation.Mutation, // The replay skip list. Nil outside a startup replay, which means every store needs every block. alreadyHave map[string]int64, -) (err error) { - obs := s.observeOp("ApplyChangeSets", otelMetrics.ApplyChangesetsLatency, - "changesets", len(changeSets)) +) (staged []lthash.DatabaseMutations, err error) { + obs := s.observeOp("StageMutations", otelMetrics.ApplyChangesetsLatency, "mutations", len(mutations)) defer obs.done(&err, nil) - s.phaseTimer.SetPhase("apply_change_sets_prepare") - changesByType, err := classifyAndPrefix(changeSets, s.classifyBucketSizes, s.miscPool) + s.phaseTimer.SetPhase("stage_mutations_route") + routed, err := routeMutations(mutations, version, alreadyHave) if err != nil { - return fmt.Errorf("classify changesets: %w", err) + return nil, fmt.Errorf("route mutations: %w", err) } - s.classifyBucketSizes = changesByType.bucketSizes() - // Parse, gather, and sort. Nothing is written until all of it has validated, so a parse failure - // part way through cannot leave some of the block's values in a store. - prepared, err := s.prepareWrites(changesByType, version) - if err != nil { - return fmt.Errorf("prepare writes: %w", err) + + s.phaseTimer.SetPhase("stage_mutations_write") + if err := s.writeToStores(routed); err != nil { + return nil, fmt.Errorf("write to stores: %w", err) } - if err := s.writeToStores(prepared, version, alreadyHave); err != nil { - return fmt.Errorf("write to stores: %w", err) + staged = make([]lthash.DatabaseMutations, len(dataDBDirs)) + for i, dir := range dataDBDirs { + staged[i] = lthash.DatabaseMutations{DBName: dir, Mutations: routed[i]} } - s.phaseTimer.SetPhase("apply_change_done") logger.Debug("FlatKV block staged", "version", version, - "changesets", len(changeSets), - "writes", prepared.accountCount()+len(prepared.storage)+len(prepared.code)+len(prepared.misc), + "mutations", len(mutations), "elapsed", obs.elapsed()) - return nil + return staged, nil } -// preparedWrites holds the fully-validated per-database values for one staged block. Nothing here reaches a store -// until every kind has validated — see writeToStores. -type preparedWrites struct { - accounts *accountUpdater - storage []view.Write - code []view.Write - misc []view.Write -} - -// prepareWrites applies EVM value semantics and returns the values to write, per database. -func (s *CommitStore) prepareWrites( - changesByType classifiedChanges, - blockHeight int64, -) (preparedWrites, error) { - s.phaseTimer.SetPhase("apply_change_sets_gather_values") - - var wg sync.WaitGroup - wg.Add(4) - - var accountWrites *accountUpdater - var accountErr error - s.miscPool.Submit(func() { - defer wg.Done() - writes, err := newAccountUpdater( - changesByType[keys.EVMKeyNonce], - changesByType[keys.EVMKeyCodeHash], - changesByType[keys.EVMKeyBalance], - blockHeight, - ) - if err != nil { - accountErr = fmt.Errorf("prepare account writes for block %d: %w", blockHeight, err) - return - } - accountWrites = writes - }) - - var storageWrites []view.Write - var storageErr error - s.miscPool.Submit(func() { - defer wg.Done() - writes, err := toStorageValues(changesByType[keys.EVMKeyStorage], blockHeight) - if err != nil { - storageErr = fmt.Errorf("failed to parse storage changes: %w", err) - return - } - storageWrites = writes - }) - - var codeWrites []view.Write - var codeErr error - s.miscPool.Submit(func() { - defer wg.Done() - writes, err := toCodeValues(changesByType[keys.EVMKeyCode], blockHeight) - if err != nil { - codeErr = fmt.Errorf("failed to parse code changes: %w", err) - return - } - codeWrites = writes - }) - - var miscWrites []view.Write - var miscErr error - s.miscPool.Submit(func() { - defer wg.Done() - writes, err := toMiscValues(changesByType[keys.EVMKeyMisc], blockHeight) - if err != nil { - miscErr = fmt.Errorf("failed to parse misc changes: %w", err) - return - } - miscWrites = writes - }) - - // Every kind has to validate before any of them is returned: writeToStores stages rows, so a parse - // failure discovered after it ran would leave part of a block behind. - wg.Wait() - if err := errors.Join(accountErr, storageErr, codeErr, miscErr); err != nil { - return preparedWrites{}, err - } - - return preparedWrites{ - accounts: accountWrites, - storage: storageWrites, - code: codeWrites, - misc: miscWrites, - }, nil -} - -var _ view.BatchUpdater = (*accountUpdater)(nil) - -// accountUpdater folds one block's per-field account changes onto the rows those accounts already -// hold. -// -// An account is stored as one row but written a field at a time, so a change carrying only a nonce or -// only a code hash has to be applied on top of the row as it stands. The account store does that fold -// on its own threads, after the write has been staged, so no part of it runs on the thread applying -// the block. -type accountUpdater struct { - // pending is the fields this block set, keyed by physical key. Parsed up front, so a fold can - // never fail on a malformed change. - pending map[string]vtype.PendingAccountWrite - - // keys names every account the block touched, in the form BatchUpdate takes them. - keys []string - - // blockHeight is stamped on every row written, whether or not any field value changed, because - // GetBlockHeightModified reports it. - blockHeight int64 -} - -// newAccountUpdater parses one batch's per-field account changes into the fields to set on each -// account. Reports nil when the batch touches no account. -// -// Parsing here rather than during the fold is what keeps a malformed changeset from being discovered -// halfway through writing the block: by the time the folds run, the block has already been accepted. -func newAccountUpdater( - nonceChanges []classifiedChange, - codeHashChanges []classifiedChange, - balanceChanges []classifiedChange, - blockHeight int64, -) (*accountUpdater, error) { - pending, err := mergeAccountUpdates(nonceChanges, codeHashChanges, balanceChanges) - if err != nil { - return nil, fmt.Errorf("failed to gather account updates: %w", err) - } - if len(pending) == 0 { - return nil, nil - } - - physKeys := make([]string, 0, len(pending)) - for key := range pending { - physKeys = append(physKeys, key) +// routeMutations splits mutations by the database each key belongs to, indexed in dataDBDirs order. Every key is +// routed before anything is returned, so a malformed key fails the block before any store is written. +func routeMutations( + mutations []mutation.Mutation, + version int64, + // The replay skip list. A database listed at or above version gets no mutations. + alreadyHave map[string]int64, +) ([][]mutation.Mutation, error) { + routed := make([][]mutation.Mutation, len(dataDBDirs)) + dbIndex := make(map[string]int, len(dataDBDirs)) + for i, dir := range dataDBDirs { + dbIndex[dir] = i } - return &accountUpdater{pending: pending, keys: physKeys, blockHeight: blockHeight}, nil -} -// NewValueFor folds this block's changes to one account onto the row it already holds. An account the -// store does not hold starts from zero, and a row left with no balance, nonce or code hash is deleted. -func (u *accountUpdater) NewValueFor(key string, priorValue []byte) ([]byte, error) { - var stored *vtype.AccountData - if priorValue != nil { - parsed, err := vtype.DeserializeAccountData(priorValue) + for n := range mutations { + m := &mutations[n] + dir, err := routePhysicalKey(m.Key) if err != nil { - return nil, fmt.Errorf("failed to deserialize accountDB old value: %w", err) + return nil, fmt.Errorf("route key %x: %w", m.Key, err) + } + if alreadyHave[dir] >= version { + // A store already holds the block only when a startup replay is catching the stores up to each + // other, where its hash already includes the block and writing it again would count it twice. + // + // TODO: currently, WAL replay may replay blocks already in some stores. In the future when WAL + // replay is external, we may be able to simplify this code since we will be able to assume that + // all stores start at the same block. + continue } - stored = parsed + i := dbIndex[dir] + routed[i] = append(routed[i], *m) } - - // Copied out of the map so the pointer-receiver methods have something addressable to work on. - pending := u.pending[key] - // Merge copies rather than writing through, so the value handed back does not alias the row the - // store still holds for earlier versions. - merged := pending.Merge(stored, u.blockHeight) - if merged.IsDelete() { - return nil, nil - } - return merged.Serialize(), nil + return routed, nil } -// accountCount reports how many accounts the block writes, treating a block that touches none as zero -// rather than requiring the caller to nil-check. -func (p preparedWrites) accountCount() int { - if p.accounts == nil { - return 0 - } - return len(p.accounts.keys) -} - -// writeToStores writes one block's prepared values into the four data stores. -func (s *CommitStore) writeToStores( - prepared preparedWrites, - version int64, - alreadyHave map[string]int64, -) error { - s.phaseTimer.SetPhase("apply_change_write_to_stores") - +// writeToStores writes each database's share of one block, indexed in dataDBDirs order. +func (s *CommitStore) writeToStores(routed [][]mutation.Mutation) error { // The four databases are independent view managers with independent locks, so their writes run // concurrently rather than one store's fan-out waiting on the last. + errs := make([]error, len(dataDBDirs)) var wg sync.WaitGroup - wg.Add(4) - - var accountErr error - s.miscPool.Submit(func() { - defer wg.Done() - accountErr = s.writeAccountStore(prepared.accounts, version, alreadyHave) - }) - var storageErr error - s.miscPool.Submit(func() { - defer wg.Done() - storageErr = writeStore(s.ctx, s.storageStore, storageDBDir, prepared.storage, version, alreadyHave) - }) - var codeErr error - s.miscPool.Submit(func() { - defer wg.Done() - codeErr = writeStore(s.ctx, s.codeStore, codeDBDir, prepared.code, version, alreadyHave) - }) - var miscErr error - s.miscPool.Submit(func() { - defer wg.Done() - miscErr = writeStore(s.ctx, s.miscStore, miscDBDir, prepared.misc, version, alreadyHave) - }) - + wg.Add(len(dataDBDirs)) + for i, dir := range dataDBDirs { + s.miscPool.Submit(func() { + defer wg.Done() + errs[i] = writeStore(s.ctx, s.viewManagerFor(dir), dir, routed[i]) + }) + } wg.Wait() - return errors.Join(accountErr, storageErr, codeErr, miscErr) + return errors.Join(errs...) } -// writeStore writes one database's values, and is a no-op for a store that already holds this block. +// writeStore writes the new value of each mutation into one database. func writeStore( ctx context.Context, store view.ViewManager, dbDir string, - writes []view.Write, - version int64, - alreadyHave map[string]int64, + mutations []mutation.Mutation, ) error { - if alreadyHave[dbDir] >= version { - // A store already holds the block only when a startup replay is catching the stores up to each - // other, where its hash already includes the block and writing it again would count it twice. - // - // TODO: currently, WAL replay may replay blocks already in some stores. In the future when WAL - // replay is external, we may be able to simplify this code since we will be able to assume that - // all stores start at the same block. + if len(mutations) == 0 { return nil } - if len(writes) == 0 { - return nil + writes := make([]view.Write, len(mutations)) + for i := range mutations { + writes[i] = view.Write{Key: string(mutations[i].Key), Value: mutations[i].NewValue} } if err := store.BatchSet(writes); err != nil { return fmt.Errorf("write %s values: %w", dbDir, err) @@ -339,28 +161,6 @@ func writeStore( return nil } -// writeAccountStore writes the block's accounts, each folded onto the row its key already holds. A batch -// touching no account, and a store that already holds this block, are both no-ops. -func (s *CommitStore) writeAccountStore( - updater *accountUpdater, - version int64, - alreadyHave map[string]int64, -) error { - if alreadyHave[accountDBDir] >= version || updater == nil { - // The store already holding the block is the replay case described in writeStore(). - return nil - } - start := time.Now() - err := s.accountStore.BatchUpdate(updater.keys, updater) - otelMetrics.AccountUpdateLatency.Record(s.ctx, secondsSince(start), - metric.WithAttributes(successAttr(err))) - if err != nil { - return fmt.Errorf("write %s values: %w", accountDBDir, err) - } - addKVPairs(s.ctx, accountDBDir, len(updater.keys)) - return nil -} - // moduleOfKey extracts the owning module from a physical key. Injected into the // lthash HashCalculator so it can bucket pairs by module without importing ktype // (ktype already imports lthash). @@ -371,315 +171,3 @@ func moduleOfKey(physicalKey []byte) (string, error) { } return module, nil } - -// classifiedChange is one changeset pair with its physical key already built. -type classifiedChange struct { - // key is the physical key: "module/" + the module's encoded key. It is carved from a keyArena, so anything that - // keeps it past the version that writes it must copy it. - key string - - // value is the key's new raw bytes. A nil value means the key was deleted. - value []byte -} - -// classifiedChanges holds one staged block's pairs bucketed by EVM key kind, each bucket in the order the -// pairs arrived. A key written more than once appears once per write, and the last of them is its new value. -type classifiedChanges [keys.EVMKeyKindCount][]classifiedChange - -// bucketSizes returns the number of pairs in each kind's bucket. -func (c *classifiedChanges) bucketSizes() [keys.EVMKeyKindCount]int { - var sizes [keys.EVMKeyKindCount]int - for kind, bucket := range c { - sizes[kind] = len(bucket) - } - return sizes -} - -// classifyAndPrefix splits changeSets into per-EVMKeyKind buckets whose keys are already in physical format -// ("module/" + prefix_encoded_key). Non-EVM modules go to the EVMKeyMisc bucket with a "/" prefix. -// -// sizeHints gives each kind's bucket length in an earlier call, and a kind with no hint grows on demand. The pairs -// are classified on pool when there is more than one unit of them, and on the calling goroutine when pool is nil. -func classifyAndPrefix( - changeSets []*proto.NamedChangeSet, - sizeHints [keys.EVMKeyKindCount]int, - pool threading.Pool, -) (classifiedChanges, error) { - // Repeated keys are kept rather than resolved here. Every consumer already resolves them in arrival order: - // the view manager keeps a key's last write in a version, and mergeAccountUpdates folds each account into - // one entry. Import input has unique keys. - units := planClassifyUnits(changeSets) - if pool == nil || len(units) < 2 { - return classifyUnitsSerially(units, sizeHints) - } - return classifyUnitsInParallel(units, sizeHints, pool) -} - -// classifyUnit is a contiguous run of one changeset's pairs. -type classifyUnit struct { - // moduleName is the name of the changeset the pairs belong to. - moduleName string - - // pairs is the run itself, at most classifyUnitSize long. - pairs []*proto.KVPair -} - -// planClassifyUnits divides changeSets into units of at most classifyUnitSize pairs, in block order. A unit never -// spans two changesets. -func planClassifyUnits(changeSets []*proto.NamedChangeSet) []classifyUnit { - var units []classifyUnit - for _, cs := range changeSets { - if cs == nil { - continue - } - pairs := cs.Changeset.Pairs - for len(pairs) > 0 { - count := min(len(pairs), classifyUnitSize) - units = append(units, classifyUnit{moduleName: cs.Name, pairs: pairs[:count]}) - pairs = pairs[count:] - } - } - return units -} - -// classifyUnitsSerially classifies every unit on the calling goroutine, into one set of buckets. -func classifyUnitsSerially( - units []classifyUnit, - sizeHints [keys.EVMKeyKindCount]int, -) (classifiedChanges, error) { - result := newClassifiedChanges(sizeHints) - for _, unit := range units { - if err := classifyUnitPairs(unit, &result); err != nil { - return classifiedChanges{}, err - } - } - return result, nil -} - -// classifyUnitsInParallel classifies each unit on pool into its own set of buckets, then concatenates them in unit -// order. -func classifyUnitsInParallel( - units []classifyUnit, - sizeHints [keys.EVMKeyKindCount]int, - pool threading.Pool, -) (classifiedChanges, error) { - // Each unit is sized for its share of the call rather than all of it. A unit that receives an uneven share of - // some kind grows that bucket on demand. - var unitHints [keys.EVMKeyKindCount]int - for kind, hint := range sizeHints { - unitHints[kind] = hint / len(units) - } - - parts := make([]classifiedChanges, len(units)) - errs := make([]error, len(units)) - - var wg sync.WaitGroup - wg.Add(len(units)) - for i := range units { - pool.Submit(func() { - defer wg.Done() - parts[i] = newClassifiedChanges(unitHints) - errs[i] = classifyUnitPairs(units[i], &parts[i]) - }) - } - // The calling goroutine blocks rather than classifying a unit of its own. The runtime queues the last worker - // woken to run next on this goroutine's own CPU, so a unit classified here would delay that worker until it - // finished, running the two units back to back. - wg.Wait() - - for _, err := range errs { - if err != nil { - return classifiedChanges{}, err - } - } - return mergeClassified(parts), nil -} - -// newClassifiedChanges returns empty buckets, each with room for classifyBucketHeadroom times its hint. -func newClassifiedChanges(sizeHints [keys.EVMKeyKindCount]int) classifiedChanges { - var result classifiedChanges - for kind, hint := range sizeHints { - if hint > 0 { - result[kind] = make([]classifiedChange, 0, classifyBucketHeadroom*hint) - } - } - return result -} - -// classifyUnitPairs appends each of unit's pairs, with its physical key built, to the bucket for its kind. -func classifyUnitPairs(unit classifyUnit, into *classifiedChanges) error { - keyBuf := make([]byte, 0, physKeyBufLen) - arena := newKeyArena(unitKeyFootprint(unit)) - for _, pair := range unit.pairs { - kind, strippedKey, err := ktype.ParseLogicalKey(unit.moduleName, pair.Key) - if err != nil { - return fmt.Errorf("flatkv: %w", err) - } - if kind == keys.EVMKeyMisc { - keyBuf = ktype.WritePhysicalKeyToBuffer(keyBuf, unit.moduleName, pair.Key) - } else { - keyBuf = ktype.WritePhysicalEVMKeyToBuffer(keyBuf, kind, strippedKey) - } - into[kind] = append(into[kind], newClassifiedChange(arena.intern(keyBuf), pair)) - } - return nil -} - -// unitKeyFootprint returns an upper bound on the total length of unit's physical keys. Each is at most -// len(moduleName)+1+len(pair.Key) bytes: a misc key is exactly "/" + key, and an EVM key replaces a key -// prefix of at least one byte with "evm/" and a single prefix byte. -func unitKeyFootprint(unit classifyUnit) int { - footprint := len(unit.pairs) * (len(unit.moduleName) + 1) - for _, pair := range unit.pairs { - footprint += len(pair.Key) - } - return footprint -} - -// mergeClassified concatenates each part's buckets in part order. -func mergeClassified(parts []classifiedChanges) classifiedChanges { - var totals [keys.EVMKeyKindCount]int - for i := range parts { - for kind, bucket := range parts[i] { - totals[kind] += len(bucket) - } - } - - var result classifiedChanges - for kind, total := range totals { - if total > 0 { - result[kind] = make([]classifiedChange, 0, total) - } - } - for i := range parts { - for kind, bucket := range parts[i] { - result[kind] = append(result[kind], bucket...) - } - } - return result -} - -// newClassifiedChange pairs a physical key with a changeset pair's new value, recording a deleted pair as a nil -// value. -func newClassifiedChange(physicalKey string, pair *proto.KVPair) classifiedChange { - if pair.Delete { - return classifiedChange{key: physicalKey} - } - return classifiedChange{key: physicalKey, value: nonNilValue(pair.Value)} -} - -// nonNilValue normalizes a non-delete changeset value so the downstream -// "nil value == deletion" convention in the to*Values helpers stays correct. -// -// A changeset pair is a deletion iff its Delete flag is set; an empty -// (zero-length) value with Delete=false is a legitimate "set this key to an -// empty value" write. Protobuf cannot distinguish an empty []byte{} from nil, -// so after a WAL round-trip (catchup, read-only clone, snapshot export, -// state-sync restore) such a write arrives as Value=nil. Without this -// normalization the to*Values helpers would treat the nil value as a -// deletion and drop the key on replay, diverging the per-DB LtHash — and thus -// the evm_lattice store hash and the consensus AppHash — from the live chain -// that stored the key. True deletes carry Delete=true and are recorded as nil -// by the caller before reaching this helper. -func nonNilValue(v []byte) []byte { - if v == nil { - return []byte{} - } - return v -} - -// toStorageValues turns raw storage changes into the writes the storage store takes, stamped with -// blockHeight, one write per change and in the same order. A nil change is a deletion — as is a value -// of all zeros, which is the same thing for storage. -func toStorageValues( - rawChanges []classifiedChange, - blockHeight int64, -) ([]view.Write, error) { - writes := make([]view.Write, 0, len(rawChanges)) - - for _, change := range rawChanges { - if change.value == nil { - writes = append(writes, view.Write{Key: change.key}) - continue - } - value, err := vtype.SerializeStorage(blockHeight, change.value) - if err != nil { - return nil, fmt.Errorf("failed to parse storage value: %w", err) - } - writes = append(writes, view.Write{Key: change.key, Value: value}) - } - - return writes, nil -} - -// toCodeValues turns raw code changes into the writes the code store takes, stamped with -// blockHeight, one write per change and in the same order. A nil change is a deletion — as is empty -// bytecode, which is the same thing for code. -func toCodeValues( - rawChanges []classifiedChange, - blockHeight int64, -) ([]view.Write, error) { - writes := make([]view.Write, 0, len(rawChanges)) - - for _, change := range rawChanges { - if len(change.value) == 0 { - writes = append(writes, view.Write{Key: change.key}) - continue - } - value := vtype.SerializeCode(blockHeight, change.value) - writes = append(writes, view.Write{Key: change.key, Value: value}) - } - return writes, nil -} - -// toMiscValues turns raw misc changes into the writes the misc store takes, stamped with -// blockHeight, one write per change and in the same order. Only a nil change is a deletion: an empty -// value is a write a Cosmos module may legitimately make. See nonNilValue. -func toMiscValues( - rawChanges []classifiedChange, - blockHeight int64, -) ([]view.Write, error) { - writes := make([]view.Write, 0, len(rawChanges)) - - for _, change := range rawChanges { - if change.value == nil { - writes = append(writes, view.Write{Key: change.key}) - continue - } - value := vtype.SerializeMisc(blockHeight, change.value) - writes = append(writes, view.Write{Key: change.key, Value: value}) - } - return writes, nil -} - -// mergeAccountUpdates folds per-field account changes into a single update per account. Where a field of one -// account changes more than once, the last change wins. -func mergeAccountUpdates( - nonceChanges []classifiedChange, - codeHashChanges []classifiedChange, - balanceChanges []classifiedChange, -) (map[string]vtype.PendingAccountWrite, error) { - - updates := make(map[string]vtype.PendingAccountWrite, - len(nonceChanges)+len(codeHashChanges)+len(balanceChanges)) - - fields := []struct { - kind keys.EVMKeyKind - changes []classifiedChange - }{ - {keys.EVMKeyNonce, nonceChanges}, - {keys.EVMKeyCodeHash, codeHashChanges}, - {keys.EVMKeyBalance, balanceChanges}, - } - for _, field := range fields { - for _, change := range field.changes { - pending := updates[change.key] - // A nil value is a deletion; see nonNilValue. - if err := pending.SetField(field.kind, change.value, change.value == nil); err != nil { - return nil, fmt.Errorf("failed to set account field: %w", err) - } - updates[change.key] = pending - } - } - return updates, nil -} diff --git a/sei-db/state_db/sc/flatkv/store_apply_bench_test.go b/sei-db/state_db/sc/flatkv/store_apply_bench_test.go deleted file mode 100644 index 730407b2dc..0000000000 --- a/sei-db/state_db/sc/flatkv/store_apply_bench_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package flatkv - -import ( - "encoding/binary" - "runtime" - "testing" - - "github.com/sei-protocol/sei-chain/sei-db/common/keys" - "github.com/sei-protocol/sei-chain/sei-db/common/threading" - "github.com/sei-protocol/sei-chain/sei-db/proto" -) - -func BenchmarkClassifyAndPrefix(b *testing.B) { - evmPairs := make([]*proto.KVPair, 2000) - for i := range evmPairs { - key := make([]byte, keys.InternalKeyLen(keys.EVMKeyStorage)) - binary.BigEndian.PutUint32(key[len(key)-4:], uint32(i)) - evmPairs[i] = &proto.KVPair{ - Key: keys.BuildEVMKey(keys.EVMKeyStorage, key), - Value: []byte{1}, - } - } - - miscPairs := make([]*proto.KVPair, 500) - for i := range miscPairs { - key := make([]byte, 8) - binary.BigEndian.PutUint32(key, uint32(i)) - miscPairs[i] = &proto.KVPair{ - Key: key, - Value: []byte{1}, - } - } - - changeSets := []*proto.NamedChangeSet{ - {Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: evmPairs}}, - {Name: "bank", Changeset: proto.ChangeSet{Pairs: miscPairs}}, - } - - pool := threading.NewElasticPool("bench-classify", runtime.NumCPU()) - b.Cleanup(pool.Close) - - // Sized from the previous iteration, as ApplyChangeSets sizes each call from the one before it. - var sizeHints [keys.EVMKeyKindCount]int - - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - classified, err := classifyAndPrefix(changeSets, sizeHints, pool) - if err != nil { - b.Fatal(err) - } - sizeHints = classified.bucketSizes() - } -} 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 a6997dc3e2..aaa1209211 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 @@ -64,7 +64,9 @@ func TestRebuildTriggersOnlyAboveTheReachableVersion(t *testing.T) { 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)})})) + require.NoError(t, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } reachable, err := latestVersion(cfg.DataDir, s.wal) @@ -100,7 +102,9 @@ func TestRebuildIfTornTriggersOnDisagreementNotOnTheSnapshot(t *testing.T) { 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)})})) + require.NoError(t, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } snapVersion, err := currentSnapshotVersion(cfg.DataDir) @@ -155,7 +159,7 @@ func TestTornSeedIsDiscardedAndCanBeReseeded(t *testing.T) { // The caller re-seeds, and the version it asks for need not be the one that was interrupted. require.NoError(t, reopened.SetInitialVersion(200)) require.Equal(t, int64(199), reopened.Version()) - require.NoError(t, reopened.CommitStateChanges(200, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte("v"))})) + require.NoError(t, commitChangeSets(reopened, 200, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte("v"))})) require.Equal(t, int64(200), reopened.Version()) } @@ -249,30 +253,6 @@ func TestCompletedImportSurvivesReopen(t *testing.T) { requireAllDataDBsAt(t, reopened, 99) } -// TestTornCommitStillRecoversFromWAL guards the ordinary case against the new classification: a DB -// whose version record was rewound, with its root intact, is brought forward by replay and the store -// opens aligned. Nothing is discarded and nothing refuses. -func TestTornCommitStillRecoversFromWAL(t *testing.T) { - cfg := config.DefaultTestConfig(t) - cfg.DataDir = filepath.Join(t.TempDir(), flatkvRootDir) - - s, err := newCommitStoreWithWAL(t.Context(), cfg) - require.NoError(t, err) - require.NoError(t, s.LoadLatest()) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte("v"))})) - requireAllDataDBsAt(t, s, 1) - - rewindVersionRecords(t, s, 0, miscDBDir) - - reopened := reopenStore(t, s, cfg) - defer reopened.Close() - - require.NoError(t, reopened.LoadLatest(), "the WAL still holds the block, so replay reconciles it") - require.Equal(t, int64(1), reopened.Version()) - requireAllDataDBsAt(t, reopened, 1) - require.NoError(t, VerifyLtHash(reopened)) -} - // TestLostVersionRecordWithMetadataRefuses pins the read-time half of the co-presence invariant. A DB // that lost its version and root records but kept its per-module records cannot be reported as // never-written: the maintained hashes are updated by unmixing the old value and mixing the new, so @@ -285,7 +265,7 @@ func TestLostVersionRecordWithMetadataRefuses(t *testing.T) { s, err := newCommitStoreWithWAL(t.Context(), cfg) require.NoError(t, err) require.NoError(t, s.LoadLatest()) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte("v"))})) + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte("v"))})) // The per-module records under _meta/x:bank/ survive, which is what makes this distinguishable // from a DB that was never written. @@ -310,10 +290,10 @@ func TestIdentityRootsAtNonZeroVersionOpen(t *testing.T) { require.NoError(t, err) require.NoError(t, s.LoadLatest()) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{ + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{ makeChangeSet(evmStorageKey(addrN(0x01), slotN(0x01)), padLeft32(0xAA), false), })) - require.NoError(t, s.CommitStateChanges(2, []*proto.NamedChangeSet{ + require.NoError(t, commitChangeSets(s, 2, []*proto.NamedChangeSet{ makeChangeSet(evmStorageKey(addrN(0x01), slotN(0x01)), nil, true), })) require.True(t, s.maintainedHashes().Global.IsZero(), "fixture precondition: the store root is the identity") @@ -437,12 +417,12 @@ func TestDataDBAheadOfWALTouchedByReplayIsRebuilt(t *testing.T) { require.NoError(t, err) require.NoError(t, s.LoadLatest()) - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{bankPair([]byte("a"), []byte{1})})) - require.NoError(t, s.CommitStateChanges(2, []*proto.NamedChangeSet{bankPair([]byte("a"), []byte{2})})) + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{bankPair([]byte("a"), []byte{1})})) + require.NoError(t, commitChangeSets(s, 2, []*proto.NamedChangeSet{bankPair([]byte("a"), []byte{2})})) wantRoot := bytes.Clone(rootHash(s)) // Block 3 writes a different key, so replaying block 2 cannot overwrite it. - require.NoError(t, s.CommitStateChanges(3, []*proto.NamedChangeSet{bankPair([]byte("b"), []byte{3})})) + require.NoError(t, commitChangeSets(s, 3, []*proto.NamedChangeSet{bankPair([]byte("b"), []byte{3})})) // Drop block 3 from the WAL, leaving misc holding its rows and recording version 3. require.NoError(t, s.wal.Close()) @@ -482,7 +462,9 @@ func TestTwoDataDBsAheadAtDifferentVersions(t *testing.T) { require.NoError(t, err) require.NoError(t, s.LoadLatest()) for i := int64(1); i <= 2; i++ { - require.NoError(t, s.CommitStateChanges(i, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte{byte(i)})})) + require.NoError(t, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } rewindVersionRecords(t, s, 5, miscDBDir) 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 a32cf2b437..98d64412af 100644 --- a/sei-db/state_db/sc/flatkv/store_meta_test.go +++ b/sei-db/state_db/sc/flatkv/store_meta_test.go @@ -11,6 +11,7 @@ import ( "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/types/mutation" "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" @@ -62,13 +63,13 @@ 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.KeyMutation{ - {Key: []byte("k"), Value: []byte("v")}, + nonZero := lthash.ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("k"), NewValue: []byte("v")}, }) require.False(t, nonZero.IsZero(), "precondition: crafted root must be non-identity") - other := lthash.ComputeLtHash(nil, []lthash.KeyMutation{ - {Key: []byte("other"), Value: []byte("w")}, + other := lthash.ComputeLtHash(nil, []mutation.Mutation{ + {Key: []byte("other"), NewValue: []byte("w")}, }) require.False(t, other.IsZero()) require.False(t, nonZero.Equal(other), "precondition: distinct hashes") diff --git a/sei-db/state_db/sc/flatkv/store_read.go b/sei-db/state_db/sc/flatkv/store_read.go index 58643dcdbf..0cf72562b2 100644 --- a/sei-db/state_db/sc/flatkv/store_read.go +++ b/sei-db/state_db/sc/flatkv/store_read.go @@ -165,7 +165,7 @@ func parseRow[T vtype.VType](raw []byte, found bool, parse func([]byte) (T, erro // hash or a balance. The second return reports whether that field is set. // // A zero code hash and a zero balance both report false. A deletion is stored by zeroing the field -// rather than by removing anything (see mergeAccountUpdates), so answering "present" for a zero would +// rather than by removing anything (see PendingAccountWrite.SetField()), so answering "present" for a zero would // hand back a key the block deleted. The nonce is the exception: it answers for every row that exists. func accountFieldValue(kind keys.EVMKeyKind, account *vtype.AccountData) ([]byte, bool) { switch kind { diff --git a/sei-db/state_db/sc/flatkv/store_replay.go b/sei-db/state_db/sc/flatkv/store_replay.go index 3ed838502b..d7129bfb50 100644 --- a/sei-db/state_db/sc/flatkv/store_replay.go +++ b/sei-db/state_db/sc/flatkv/store_replay.go @@ -3,7 +3,6 @@ package flatkv import ( "fmt" - "github.com/sei-protocol/sei-chain/sei-db/proto" "github.com/sei-protocol/sei-chain/sei-db/seiwal" "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" "github.com/sei-protocol/sei-chain/sei-db/state_db/statewal" @@ -209,12 +208,8 @@ func replayBlocks( break } block, mutations := it.Entry() - changesets, cErr := MutationsToChangeSets(mutations) - if cErr != nil { - return 0, fmt.Errorf("convert block %d: %w", block, cErr) - } //nolint:gosec // block <= end - if err := dest.applyAndCommit(int64(block), changesets, alreadyHave); err != nil { + if err := dest.applyAndCommit(int64(block), mutations, alreadyHave); err != nil { return 0, fmt.Errorf("replay block %d: %w", block, err) } replayed++ @@ -226,13 +221,13 @@ func replayBlocks( return replayed, nil } -// applyAndCommit replays a single block into the store: it applies the changesets, seals the block on +// applyAndCommit replays a single block into the store: it stages the mutations, 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 // double-append. func (s *CommitStore) applyAndCommit( version int64, - changesets []*proto.NamedChangeSet, + mutations []mutation.Mutation, alreadyHave map[string]int64, ) error { // Replay re-reads blocks the recorded version already covers, so committedVersion may be ahead of the @@ -248,12 +243,12 @@ func (s *CommitStore) applyAndCommit( } // Staged under mu, as Commit stages, which is what keeps a concurrent read from interleaving with the writes. s.mu.Lock() - err := s.stageChangeSets(version, changesets, alreadyHave) + staged, err := s.stageMutations(version, mutations, alreadyHave) s.mu.Unlock() if err != nil { return fmt.Errorf("apply v%d: %w", version, err) } - if err := s.sealBlock(version, alreadyHave); err != nil { + if err := s.sealBlock(version, staged, alreadyHave); err != nil { return fmt.Errorf("commit v%d: %w", version, err) } s.committedVersion = version 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 986c5cf469..f5aa35779e 100644 --- a/sei-db/state_db/sc/flatkv/store_replay_test.go +++ b/sei-db/state_db/sc/flatkv/store_replay_test.go @@ -32,62 +32,6 @@ func TestCatchupNoOpWhenWALBehindCommittedVersion(t *testing.T) { require.Equal(t, int64(3), s.committedVersion) } -// TestCatchupReplaysAlreadyAppliedBlockOnSeededStore pins replay over a store whose history begins -// mid-chain. The WAL's first block is the seeded height, so replay must start there rather than treat -// blocks 1..seed-1 as missing, and re-applying a block the DBs already hold must land on the same root -// it had before. -// -// The lagging watermark is written to disk rather than poked into memory: the store derives its -// version from the per-DB records, so an in-memory-only value is a state no crash can produce. -func TestCatchupReplaysAlreadyAppliedBlockOnSeededStore(t *testing.T) { - dir := t.TempDir() - 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()) - - addr := ktype.Address{0xAB} - slot := ktype.Slot{0xCD} - key := keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, slot)) - balanceKey := keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]) - balanceVal := balanceN(0x6D) - cs := namedCS( - &proto.KVPair{Key: key, Value: padLeft32(0x11)}, - &proto.KVPair{Key: balanceKey, Value: balanceVal[:]}, - ) - - // History legally begins at 10, so this is a lagging watermark rather than a store that skipped - // blocks 1-9. - require.NoError(t, s.SetInitialVersion(10)) - require.NoError(t, s.CommitStateChanges(10, []*proto.NamedChangeSet{cs})) - require.Equal(t, int64(10), s.Version()) - hashAfterCommit := append([]byte(nil), rootHash(s)...) - - rewindVersionRecords(t, s, 9) - require.NoError(t, s.Close()) - - reopened, err := newCommitStoreWithWAL(t.Context(), cfg) - require.NoError(t, err) - defer reopened.Close() - - require.NoError(t, reopened.LoadLatest()) - require.Equal(t, int64(10), reopened.Version()) - require.Equal(t, hashAfterCommit, rootHash(reopened)) - - for _, replayed := range [][]byte{key, balanceKey} { - height, found, err := reopened.GetBlockHeightModified(keys.EVMStoreKey, replayed) - require.NoError(t, err) - require.True(t, found) - require.Equal(t, int64(10), height) - } - - got, found := reopened.Get(keys.EVMStoreKey, balanceKey) - require.True(t, found, "balance should survive WAL replay") - require.Equal(t, balanceVal[:], got) -} - // gappedWALStore returns a store whose WAL holds exactly one block, at firstBlock, with nothing before it. // Committing block 10 as the very first commit is legitimate on its own (a chain whose initial_height is // above 1, or a store materialized mid-chain); callers create the gap by moving committedVersion. @@ -105,7 +49,7 @@ func gappedWALStore(t *testing.T, firstBlock int64) *CommitStore { key := keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(ktype.Address{0xAB}, ktype.Slot{0xCD})) cs := makeChangeSet(key, padLeft32(0x11), false) - require.NoError(t, s.CommitStateChanges(firstBlock, []*proto.NamedChangeSet{cs})) + require.NoError(t, commitChangeSets(s, firstBlock, []*proto.NamedChangeSet{cs})) return s } @@ -148,7 +92,7 @@ func TestLoadVersionSurfacesCatchupGap(t *testing.T) { require.NoError(t, s.SetInitialVersion(10)) key := keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(ktype.Address{0xAB}, ktype.Slot{0xCD})) cs := makeChangeSet(key, padLeft32(0x11), false) - require.NoError(t, s.CommitStateChanges(10, []*proto.NamedChangeSet{cs})) + require.NoError(t, commitChangeSets(s, 10, []*proto.NamedChangeSet{cs})) // Rewind the persisted watermark so the reopened store needs blocks 6-9, which this WAL never held. rewindVersionRecords(t, s, 5) @@ -178,7 +122,7 @@ func TestReadOnlyServesSeededMidChainStore(t *testing.T) { key := keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(ktype.Address{0xAB}, ktype.Slot{0xCD})) for _, v := range []int64{10, 11, 12} { cs := makeChangeSet(key, padLeft32(byte(v)), false) - require.NoError(t, s.CommitStateChanges(v, []*proto.NamedChangeSet{cs})) + require.NoError(t, commitChangeSets(s, v, []*proto.NamedChangeSet{cs})) } // The clone opens at the seeded snapshot (version 9) and replays 10-12 from the WAL. @@ -204,7 +148,9 @@ func TestReadOnlySurfacesReplayGap(t *testing.T) { key := keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(ktype.Address{0xAB}, ktype.Slot{0xCD})) commit := func(v int64, val byte) { - require.NoError(t, s.CommitStateChanges(v, []*proto.NamedChangeSet{makeChangeSet(key, padLeft32(val), false)})) + require.NoError(t, commitChangeSets(s, v, []*proto.NamedChangeSet{ + makeChangeSet(key, padLeft32(val), false), + })) } for v := int64(1); v <= 4; v++ { commit(v, byte(v)) @@ -437,12 +383,15 @@ func TestReplaySkipDoesNotRewindRecordedHeight(t *testing.T) { require.NoError(t, s.sealBaseline()) addr, slot := addrN(3), slotN(3) - block3 := []*proto.NamedChangeSet{namedCS( + prior := s.OpenView() + block3, err := MutationsFromChangeSets([]*proto.NamedChangeSet{namedCS( noncePair(addr, 3), codeHashPair(addr, codeHashN(3)), codePair(addr, []byte{0x60, 0x80, 3}), storagePair(addr, slot, []byte{3, 0xAA}), - )} + )}, 3, prior) + prior.Close() + require.NoError(t, err) require.NoError(t, s.applyAndCommit(3, block3, alreadyHave)) requireFlushedToDisk(t, s) @@ -456,14 +405,9 @@ func TestReplaySkipDoesNotRewindRecordedHeight(t *testing.T) { } } -// TestReplayConvergesOnPartialAccountFieldWrites pins the one case where replaying -// a block into a DB that already holds it is not obviously a no-op. An account row -// is a merge, not an overwrite: accountUpdater folds a nonce-only or -// codehash-only update onto whatever is currently on disk. Replaying a range where -// different blocks touch different fields therefore rebuilds the row field by field -// through intermediate values that were never on-chain. It converges because the -// last block to write each field writes it last — and the LtHash must land on the -// same value either way, since that value feeds the AppHash. +// TestReplayConvergesOnPartialAccountFieldWrites pins replaying blocks that each write only some fields +// of an account row into a database left behind the others. The database must land on the same row and +// root as the store that committed those blocks, since that root feeds the AppHash. func TestReplayConvergesOnPartialAccountFieldWrites(t *testing.T) { dir := t.TempDir() dbDir := filepath.Join(dir, flatkvRootDir) @@ -476,18 +420,21 @@ func TestReplayConvergesOnPartialAccountFieldWrites(t *testing.T) { addr := ktype.Address{0xAB} // Block 1 sets the nonce, block 2 is unrelated, block 3 sets only the codehash. - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{{ + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{{ Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{noncePair(addr, 7)}}, }})) - require.NoError(t, s.CommitStateChanges(2, []*proto.NamedChangeSet{{ + s = reopenStore(t, s, cfg) + accountAt1 := copyDataDB(t, cfg, accountDBDir) + require.NoError(t, s.LoadLatest()) + require.NoError(t, commitChangeSets(s, 2, []*proto.NamedChangeSet{{ Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ {Key: keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, ktype.Slot{0x01})), Value: padLeft32(0x22)}, }}, }})) - require.NoError(t, s.CommitStateChanges(3, []*proto.NamedChangeSet{{ + require.NoError(t, commitChangeSets(s, 3, []*proto.NamedChangeSet{{ Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{codePair(addr, []byte{0x60, 0x0A})}}, }})) @@ -497,13 +444,9 @@ func TestReplayConvergesOnPartialAccountFieldWrites(t *testing.T) { require.True(t, found) require.NoError(t, s.Close()) - // Rewind accountDB alone to block 1, leaving its rows at block 3. Replay of - // blocks 2 and 3 now runs against an account row that already holds both fields. - s2, err := newCommitStoreWithWAL(t.Context(), cfg) - require.NoError(t, err) - require.NoError(t, s2.LoadLatest()) - rewindVersionRecords(t, s2, 1, accountDBDir) - require.NoError(t, s2.Close()) + // Put back accountDB as it was at block 1. Replay of blocks 2 and 3 then rebuilds its account row + // from the nonce alone. + restoreDataDB(t, cfg, accountDBDir, accountAt1) s3, err := newCommitStoreWithWAL(t.Context(), cfg) require.NoError(t, err) @@ -536,17 +479,28 @@ func TestReplayDrainsHashStreamPastItsDepth(t *testing.T) { require.NoError(t, err) require.NoError(t, s.LoadLatest()) + // Every database is put back as it was at block 4, so replay has many more blocks to re-apply than the + // stream below can hold. + const lagged = 4 const blocks = 24 + var copies map[string]string for i := byte(1); i <= blocks; i++ { commitStorageEntry(t, s, ktype.Address{i}, ktype.Slot{i}, []byte{i}) + if i == lagged { + s = reopenStore(t, s, cfg) + copies = make(map[string]string, len(dataDBDirs)) + for _, dbName := range dataDBDirs { + copies[dbName] = copyDataDB(t, cfg, dbName) + } + require.NoError(t, s.LoadLatest()) + } } 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()) + for dbName, copied := range copies { + restoreDataDB(t, cfg, dbName, copied) + } replayCfg := config.DefaultTestConfig(t) replayCfg.DataDir = cfg.DataDir diff --git a/sei-db/state_db/sc/flatkv/store_test.go b/sei-db/state_db/sc/flatkv/store_test.go index b35b617aa3..b67740aa30 100644 --- a/sei-db/state_db/sc/flatkv/store_test.go +++ b/sei-db/state_db/sc/flatkv/store_test.go @@ -1263,7 +1263,10 @@ func TestCrashRecoverySkewedPerDBVersions(t *testing.T) { err = s.LoadLatest() require.NoError(t, err) + // accountDB is put back as it was at block 4 while the others reach block 6, which is what a crash + // between the databases' flushes leaves behind. addr := addrN(0x01) + var accountAt4 string for i := 1; i <= 6; i++ { cs := &proto.NamedChangeSet{ Name: "evm", @@ -1274,24 +1277,15 @@ func TestCrashRecoverySkewedPerDBVersions(t *testing.T) { require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) _, err := s.Commit(s.Version() + 1) require.NoError(t, err) + if i == 4 { + s = reopenStore(t, s, cfg) + accountAt4 = copyDataDB(t, cfg, accountDBDir) + require.NoError(t, s.LoadLatest()) + } } require.Equal(t, int64(6), s.Version()) - - // Save the correct per-DB LtHash for accountDB before skewing version. - 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() - 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() - require.NoError(t, s.Close()) + restoreDataDB(t, cfg, accountDBDir, accountAt4) // Reopen: loadGlobalMetadata detects version skew and catchup replays. s2, err := newCommitStoreWithWAL(t.Context(), cfg) @@ -1327,28 +1321,23 @@ func TestCrashRecoveryGlobalMetadataAheadOfDataDBs(t *testing.T) { err = s.LoadLatest() require.NoError(t, err) + // Simulate crash: storageDB only flushed v3 while the others reached v5. addr := addrN(0x02) + var storageAt3 string for i := 1; i <= 5; i++ { key := keys.BuildEVMKey(keys.EVMKeyStorage, ktype.StorageKey(addr, slotN(byte(i)))) cs := makeChangeSet(key, padLeft32(byte(i*11)), false) require.NoError(t, s.ApplyChangeSets(s.Version()+1, []*proto.NamedChangeSet{cs})) _, err := s.Commit(s.Version() + 1) require.NoError(t, err) + if i == 3 { + s = reopenStore(t, s, cfg) + storageAt3 = copyDataDB(t, cfg, storageDBDir) + require.NoError(t, s.LoadLatest()) + } } - - // Save the correct storageDB per-DB LtHash before skewing. - savedStorageLtHash := s.maintainedHashes().PerDB[storageDBDir].Clone() - - // Simulate crash: storageDB only flushed v3 (version watermark behind). - batch := s.rawDBFor(storageDBDir).NewBatch() - 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() - require.NoError(t, s.Close()) + restoreDataDB(t, cfg, storageDBDir, storageAt3) s2, err := newCommitStoreWithWAL(t.Context(), cfg) require.NoError(t, err) 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 8011da87d3..81a8879814 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 @@ -63,7 +63,9 @@ func TestGetLatestVersionFreshStore(t *testing.T) { func TestGetLatestVersionAfterCommits(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, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } require.Equal(t, int64(3), requireProbeMatchesOpen(t, s, cfg)) } @@ -76,7 +78,9 @@ func TestGetLatestVersionAfterCommits(t *testing.T) { func TestGetLatestVersionWatermarksBehindWAL(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, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } rewindVersionRecords(t, s, 2) @@ -114,11 +118,15 @@ func TestGetLatestVersionTornSeed(t *testing.T) { func TestGetLatestVersionSnapshotBehindWAL(t *testing.T) { s, cfg := newProbeStore(t) for i := int64(1); i <= 2; i++ { - require.NoError(t, s.CommitStateChanges(i, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte{byte(i)})})) + require.NoError(t, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } require.NoError(t, s.outOfBandSnapshot()) for i := int64(3); i <= 5; i++ { - require.NoError(t, s.CommitStateChanges(i, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte{byte(i)})})) + require.NoError(t, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } snapVersion, err := currentSnapshotVersion(cfg.DataDir) @@ -132,7 +140,9 @@ func TestGetLatestVersionSnapshotBehindWAL(t *testing.T) { func TestGetLatestVersionWALWipedAfterSnapshot(t *testing.T) { s, cfg := newProbeStore(t) for i := int64(1); i <= 2; i++ { - require.NoError(t, s.CommitStateChanges(i, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte{byte(i)})})) + require.NoError(t, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } require.NoError(t, s.outOfBandSnapshot()) resetWALForTest(t, s) @@ -147,7 +157,7 @@ func TestCommitStoreGetLatestVersionUsesMemoryWhileOpen(t *testing.T) { s, _ := newProbeStore(t) defer func() { require.NoError(t, s.Close()) }() - require.NoError(t, s.CommitStateChanges(1, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte("v"))})) + require.NoError(t, commitChangeSets(s, 1, []*proto.NamedChangeSet{bankPair([]byte("k"), []byte("v"))})) got, err := s.GetLatestVersion() require.NoError(t, err) require.Equal(t, int64(1), got) @@ -184,7 +194,9 @@ func TestStoredVersionsPartiallyCreatedWorkingCopyIsZero(t *testing.T) { 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, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } require.NoError(t, s.Close()) require.NoError(t, statewal.PruneAfter(StateWALConfig(cfg.DataDir), 1)) @@ -206,7 +218,9 @@ func TestStoredVersionsIgnoreTheWALTail(t *testing.T) { 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)})})) + require.NoError(t, commitChangeSets(s, i, []*proto.NamedChangeSet{ + bankPair([]byte("k"), []byte{byte(i)}), + })) } rewindVersionRecords(t, s, 2, accountDBDir) require.NoError(t, s.Close()) diff --git a/sei-db/state_db/sc/flatkv/store_write.go b/sei-db/state_db/sc/flatkv/store_write.go index 5d56e55c66..8d6c87effc 100644 --- a/sei-db/state_db/sc/flatkv/store_write.go +++ b/sei-db/state_db/sc/flatkv/store_write.go @@ -9,16 +9,17 @@ import ( "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/giga/types/mutation" "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" ) -func (s *CommitStore) CommitStateChanges(blockNum int64, changeset []*proto.NamedChangeSet) error { - if err := s.ApplyChangeSets(blockNum, changeset); err != nil { - return fmt.Errorf("CommitStateChanges: apply version %d: %w", blockNum, err) - } - if _, err := s.Commit(blockNum); err != nil { +// CommitStateChanges writes the block's mutations and commits them at blockNum. +func (s *CommitStore) CommitStateChanges(blockNum int64, mutations []mutation.Mutation) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.commitMutations(blockNum, mutations); err != nil { return fmt.Errorf("CommitStateChanges: commit version %d: %w", blockNum, err) } return nil @@ -27,9 +28,7 @@ func (s *CommitStore) CommitStateChanges(blockNum int64, changeset []*proto.Name // Commit commits the block ApplyChangeSets buffered at version, or an empty block if nothing is buffered, and // returns version. version must be the committed version plus one; committing the committed version again does // nothing. -func (s *CommitStore) Commit(version int64) (committed int64, err error) { - start := time.Now() - +func (s *CommitStore) Commit(version int64) (int64, error) { // TODO(concurrency): This takes a single coarse write lock for the whole // commit, so it also blocks readers/iterator construction during the WAL // fsync and the periodic auto-snapshot. Views are unaffected, since @@ -49,68 +48,81 @@ func (s *CommitStore) Commit(version int64) (committed int64, err error) { return version, nil } - // Row counts are no longer available here: the staged rows live inside the stores' current - // version, which does not expose a size. The changeset count is the closest stand-in and is what - // a failure investigation actually starts from. - pendingChangeSets := len(s.pendingChangeSets) + mutations, err := s.pendingMutations(version) + if err != nil { + return version, err + } + if err := s.commitMutations(version, mutations); err != nil { + return version, err + } + return version, nil +} + +// pendingMutations converts the changesets ApplyChangeSets buffered into the block's mutations at version. The +// caller holds mu. +func (s *CommitStore) pendingMutations(version int64) ([]mutation.Mutation, error) { + // Ahead of commitMutations' own check, because the conversion reads the state of the block before version. + if err := s.requireCommittable(version); err != nil { + return nil, err + } + s.phaseTimer.SetPhase("commit_convert_mutations") + // The last sealed block is the one before this one, which commitMutations has yet to seal. + prior := s.OpenView() + defer prior.Close() + height := uint64(version) //nolint:gosec // version > committed >= 0 + mutations, err := MutationsFromChangeSets(s.pendingChangeSets, height, prior) + if err != nil { + return nil, fmt.Errorf("convert block %d to mutations: %w", version, err) + } + return mutations, nil +} + +// commitMutations writes and commits one block's mutations at version, which must be the committed version plus +// one. The caller holds mu. +func (s *CommitStore) commitMutations(version int64, mutations []mutation.Mutation) (err error) { + start := time.Now() defer func() { otelMetrics.CommitLatency.Record(s.ctx, secondsSince(start), metric.WithAttributes(successAttr(err))) if err != nil && !errors.Is(err, errReadOnly) { logger.Error("FlatKV Commit failed", "version", version, - "pendingChangeSets", pendingChangeSets, + "mutations", len(mutations), "elapsed", time.Since(start), "err", err) } }() - if s.readOnly { - return 0, errReadOnly - } - // Blocks are contiguous and the first block is 1, so the next commit is always committedVersion+1. On a - // fresh store that means block 1; a store whose history starts higher gets there via SetInitialVersion, - // which seeds committedVersion to one below its first block. - if version != s.committedVersion+1 { - return 0, fmt.Errorf("flatkv: committing bad version: got %d, want %d (current %d)", - version, s.committedVersion+1, s.committedVersion) + if err := s.requireCommittable(version); err != nil { + return err } - // Step 1: Stage the block ApplyChangeSets buffered into the stores. Staging validates every changeset before - // it writes anything, so a malformed block fails here, before the WAL records it. - if err := s.stageChangeSets(version, s.pendingChangeSets, nil); err != nil { - return version, fmt.Errorf("stage block %d: %w", version, err) + // Step 1: Stage the block into the stores. Every key is routed before anything is written, so a malformed + // block fails here, before the WAL records it. + staged, err := s.stageMutations(version, mutations, nil) + if err != nil { + return fmt.Errorf("stage block %d: %w", version, err) } // Step 2: Write the WAL (source of truth) before the seal, so crash recovery via catchup stays valid. - // Write records this block's mutations and Flush makes the record durable. An empty block (no - // ApplyChangeSets) writes an empty but contiguous record. Skipped entirely when the WAL is nil — the - // outer context then owns the WAL pipeline. + // Write records this block's mutations and Flush makes the record durable. An empty block writes an empty + // but contiguous record. Skipped entirely when the WAL is nil — the outer context then owns the WAL pipeline. if s.wal != nil { - s.phaseTimer.SetPhase("commit_convert_mutations") - // The last sealed block is the one before this one, since this block is not sealed until Step 3. - height := uint64(version) //nolint:gosec // version > committed >= 0 - prior := s.OpenView() - mutations, err := MutationsFromChangeSets(s.pendingChangeSets, height, prior) - prior.Close() - if err != nil { - return version, fmt.Errorf("convert block %d to mutations: %w", version, err) - } - s.phaseTimer.SetPhase("commit_write_wal") - if err := s.wal.Write(height, mutations); err != nil { - return version, fmt.Errorf("WAL write: %w", err) + //nolint:gosec // version > committed >= 0 + if err := s.wal.Write(uint64(version), mutations); err != nil { + return fmt.Errorf("WAL write: %w", err) } if err := s.wal.Flush(); err != nil { - return version, fmt.Errorf("WAL flush: %w", err) + return fmt.Errorf("WAL flush: %w", err) } } // Step 3: Seal the block on every store, hash it, and carry each database's metadata down with its // diff. The stores flush to Pebble asynchronously from here; the WAL (Step 2) remains the source of // truth for anything that has not landed yet, so a restart self-heals via catchup. - if err := s.sealBlock(version, nil); err != nil { - return version, fmt.Errorf("seal block: %w", err) + if err := s.sealBlock(version, staged, nil); err != nil { + return fmt.Errorf("seal block: %w", err) } // Step 4: Update in-memory committed state, only once every store accepted the seal. The block's @@ -127,7 +139,7 @@ func (s *CommitStore) Commit(version int64) (committed int64, err error) { if s.snapshotWriter != nil { s.phaseTimer.SetPhase("commit_offer_snapshot") if err := s.offerToSnapshotWriter(); err != nil { - return version, fmt.Errorf("auto snapshot at version %d: %w", version, err) + return fmt.Errorf("auto snapshot at version %d: %w", version, err) } } @@ -140,7 +152,22 @@ func (s *CommitStore) Commit(version int64) (committed int64, err error) { // themselves and not the gaps between them. s.phaseTimer.Reset() otelMetrics.CurrentVersion.Record(s.ctx, version) - return version, nil + return nil +} + +// requireCommittable returns an error unless this store accepts a commit at version. The caller holds mu. +func (s *CommitStore) requireCommittable(version int64) error { + if s.readOnly { + return errReadOnly + } + // Blocks are contiguous and the first block is 1, so the next commit is always committedVersion+1. On a + // fresh store that means block 1; a store whose history starts higher gets there via SetInitialVersion, + // which seeds committedVersion to one below its first block. + if version != s.committedVersion+1 { + return fmt.Errorf("flatkv: committing bad version: got %d, want %d (current %d)", + version, s.committedVersion+1, s.committedVersion) + } + return nil } // FlushSnapshots blocks until no snapshot is being written. It is a synchronization point for callers @@ -162,17 +189,17 @@ func (s *CommitStore) clearPendingBlock() { // sealBlock marks the block as closed for new writes and hands it to the hashing pipeline. func (s *CommitStore) sealBlock( version int64, + // The block's mutations grouped by database, as stageMutations returned them. + staged []lthash.DatabaseMutations, // 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, ) (err error) { var blockView *sview.StoreView - var previous *sview.StoreView defer func() { if err != nil { - // Error is fatal; the reservations are given up rather than released. + // Error is fatal; the reservation is given up rather than released. blockView.Abandon() - previous.Abandon() } }() @@ -183,11 +210,6 @@ func (s *CommitStore) sealBlock( return err } - previous, err = s.lastSealed.Get() - if err != nil { - return fmt.Errorf("read previous block's view: %w", err) - } - if err := s.lastSealed.Set(blockView); err != nil { return fmt.Errorf("install block %d: %w", version, err) } @@ -198,13 +220,10 @@ func (s *CommitStore) sealBlock( } s.phaseTimer.SetPhase("commit_schedule_hash") - if err := s.hashEngine.ScheduleHash(blockView, previous); err != nil { + if err := s.hashEngine.ScheduleHash(version, staged); err != nil { return err } - 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) } 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 a0a491afc9..3458c3e6a3 100644 --- a/sei-db/state_db/sc/flatkv/store_write_test.go +++ b/sei-db/state_db/sc/flatkv/store_write_test.go @@ -1854,7 +1854,7 @@ func TestCommitKeepsStoresCleanOnLaterParseError(t *testing.T) { Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ {Key: keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]), Value: nonceBytes(7)}, - {Key: storageKey, Value: []byte{0x01}}, // not 32 bytes — fails toStorageValues + {Key: storageKey, Value: []byte{0x01}}, // not 32 bytes — fails conversion to mutations }}, } @@ -1862,7 +1862,7 @@ func TestCommitKeepsStoresCleanOnLaterParseError(t *testing.T) { version := s.Version() _, err := s.Commit(version + 1) require.Error(t, err) - require.Contains(t, err.Error(), "failed to parse storage changes") + require.Contains(t, err.Error(), "invalid storage value") requireNotStaged(t, s.accountStore, accountPhysKey(addr), "account rows must not stage before storage validation finishes") @@ -2146,7 +2146,7 @@ func TestCommitStateChangesStampsRowBlockHeight(t *testing.T) { // Start at block 10 the legal way: seed the store so its history begins there. require.NoError(t, s.SetInitialVersion(10)) - require.NoError(t, s.CommitStateChanges(10, []*proto.NamedChangeSet{cs})) + require.NoError(t, commitChangeSets(s, 10, []*proto.NamedChangeSet{cs})) require.Equal(t, int64(10), s.Version()) height, found, err := s.GetBlockHeightModified(keys.EVMStoreKey, key) diff --git a/sei-db/state_db/sc/flatkv/test_verify.go b/sei-db/state_db/sc/flatkv/test_verify.go index f0bf40c097..a18c2dc044 100644 --- a/sei-db/state_db/sc/flatkv/test_verify.go +++ b/sei-db/state_db/sc/flatkv/test_verify.go @@ -6,6 +6,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) @@ -95,7 +96,7 @@ func scanStoreByModule( } defer func() { _ = iter.Close() }() - byModule := make(map[string][]lthash.KeyMutation) + byModule := make(map[string][]mutation.Mutation) stats := make(map[string]lthash.ModuleStats) for ; iter.Valid(); iter.Next() { // Match foldChunk / serializeKV: empty key or empty value is not a @@ -108,9 +109,9 @@ func scanStoreByModule( if err != nil { return nil, nil, fmt.Errorf("route key %x: %w", iter.Key(), err) } - byModule[module] = append(byModule[module], lthash.KeyMutation{ - Key: bytes.Clone(iter.Key()), - Value: bytes.Clone(iter.Value()), + byModule[module] = append(byModule[module], mutation.Mutation{ + Key: bytes.Clone(iter.Key()), + NewValue: bytes.Clone(iter.Value()), }) st := stats[module] st.KeyCount++ diff --git a/sei-db/state_db/sc/flatkv/testutil_test.go b/sei-db/state_db/sc/flatkv/testutil_test.go index 8ea1c60110..0c97519514 100644 --- a/sei-db/state_db/sc/flatkv/testutil_test.go +++ b/sei-db/state_db/sc/flatkv/testutil_test.go @@ -4,6 +4,7 @@ import ( "encoding/binary" "fmt" "maps" + "os" "path/filepath" "testing" @@ -195,6 +196,37 @@ func commitAndCheck(t *testing.T, s *CommitStore) int64 { return v } +// copyDataDB copies the named data DB's working directory and returns the copy. The store must be closed. +func copyDataDB(t *testing.T, cfg *config.Config, dbName string) string { + t.Helper() + copied := filepath.Join(t.TempDir(), dbName) + require.NoError(t, os.CopyFS(copied, os.DirFS(filepath.Join(cfg.DataDir, workingDirName, dbName)))) + return copied +} + +// restoreDataDB replaces the named data DB's working directory with copied, leaving that DB at the block it held +// when copied while the others keep theirs: what a crash between two DBs' flushes leaves behind. The store must +// be closed. +func restoreDataDB(t *testing.T, cfg *config.Config, dbName string, copied string) { + t.Helper() + working := filepath.Join(cfg.DataDir, workingDirName, dbName) + require.NoError(t, os.RemoveAll(working)) + require.NoError(t, os.CopyFS(working, os.DirFS(copied))) +} + +// commitChangeSets commits changeSets as block version through CommitStateChanges, converting them to mutations +// against the store's current state. +func commitChangeSets(s *CommitStore, version int64, changeSets []*proto.NamedChangeSet) error { + prior := s.OpenView() + defer prior.Close() + //nolint:gosec // test heights are small + mutations, err := MutationsFromChangeSets(changeSets, uint64(version), prior) + if err != nil { + return fmt.Errorf("convert block %d to mutations: %w", version, err) + } + return s.CommitStateChanges(version, mutations) +} + // commitBuffered commits the block ApplyChangeSets buffered without waiting for it to reach disk, so later reads // go through the stores' unflushed versions. func commitBuffered(t *testing.T, s *CommitStore) int64 { 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 5c61b5e836..07f13725c3 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 @@ -487,7 +487,7 @@ func randomTestBytes(rng *testutil.TestRandom, n int) []byte { // randomEVMKVPair returns a random but structurally valid EVM key-value pair // for use with the "evm" store. It selects uniformly among the four EVM key // kinds, constructing keys and values that satisfy the length requirements -// enforced by flatKV's classifyAndPrefix / parse routines: +// enforced by flatKV's changeset parsing: // // - Nonce: key = 0x0a + addr(20 B), value = 8 B // - CodeHash: key = 0x08 + addr(20 B), value = 32 B diff --git a/sei-db/state_db/ss/composite/store.go b/sei-db/state_db/ss/composite/store.go index f3ef8560a7..a5e4b707cf 100644 --- a/sei-db/state_db/ss/composite/store.go +++ b/sei-db/state_db/ss/composite/store.go @@ -491,7 +491,7 @@ func convertFlatKVNodes(node types.SnapshotNode) ([]types.SnapshotNode, error) { return nil, fmt.Errorf("convertFlatKVNodes failed: %w", err) } if moduleName == "" { - // Same guard as classifyAndPrefix and routePhysicalKey: an empty + // Same guard as ParseLogicalKey and routePhysicalKey: an empty // module would produce a StoreKey the SS importer silently drops. return nil, fmt.Errorf("flatkv: empty module name in physical key %q", node.Key) } @@ -509,7 +509,7 @@ func convertFlatKVNodes(node types.SnapshotNode) ([]types.SnapshotNode, error) { // with an EVM prefix byte and satisfy its length check, and would then be // deserialized as the wrong vtype (observed on production-scale data). // Default them to the misc kind — their values are always MiscData. This - // mirrors the write side (classifyAndPrefix) and the read routing + // mirrors the write side (ParseLogicalKey) and the read routing // (routePhysicalKey), which both gate EVM parsing on the module name. kind, strippedKey := keys.EVMKeyMisc, innerKey if moduleName == evm.EVMStoreKey { diff --git a/sei-db/state_db/ss/composite/store_test.go b/sei-db/state_db/ss/composite/store_test.go index cf01503711..ad1c7a5db3 100644 --- a/sei-db/state_db/ss/composite/store_test.go +++ b/sei-db/state_db/ss/composite/store_test.go @@ -1280,7 +1280,7 @@ func TestImport_FlatKVLegacyKeysPreserveModule(t *testing.T) { // starts with an EVM prefix byte and matches that kind's length check must // still be treated as legacy MiscData, not deserialized as an EVM vtype. // EVM kind parsing is only valid under the "evm" module, mirroring the write -// side (classifyAndPrefix) and read routing (routePhysicalKey). +// side (ParseLogicalKey) and read routing (routePhysicalKey). func TestImport_LegacyKeyMimickingEVMShapeStaysLegacy(t *testing.T) { addr := make([]byte, 20) addr[0] = 0x11 @@ -1342,7 +1342,7 @@ func TestImport_LegacyKeyMimickingEVMShapeStaysLegacy(t *testing.T) { } // TestImport_FlatKVEmptyModuleKeyRejected pins the empty-module guard shared -// with classifyAndPrefix and routePhysicalKey: a physical key of the form +// with ParseLogicalKey and routePhysicalKey: a physical key of the form // "/" must fail the import instead of producing a StoreKey "" node that // the SS importer would silently drop. func TestImport_FlatKVEmptyModuleKeyRejected(t *testing.T) { diff --git a/sei-db/state_db/statewal/state_wal.go b/sei-db/state_db/statewal/state_wal.go index 8d6d8d0169..908b14c986 100644 --- a/sei-db/state_db/statewal/state_wal.go +++ b/sei-db/state_db/statewal/state_wal.go @@ -14,7 +14,7 @@ import ( // A StateWAL is not safe for concurrent use. Callers must serialize their calls to a single instance; // in particular Write maintains write-ordering state that is not internally locked. // -// The mutations returned by the iterator are owned by the WAL and must not be modified. +// The mutations returned by the iterator own their bytes, which may be retained and must not be modified. type StateWAL interface { controllertypes.PrunableStore @@ -23,8 +23,8 @@ type StateWAL interface { // This method only schedules the write, it does not block until the write is complete. It does block while the // WAL is behind by more than Config.RequestBufferSize blocks. // - // Write takes ownership of mutations. Once the call begins, neither mutations nor any byte slice reachable - // through it may be read or modified. + // Write does not retain mutations, but it retains the byte slices reachable through it, which must never be + // modified. // // Blocks must be written in contiguous ascending order, one Write per block. The first block written to // an empty WAL may be any number; every block after it must be exactly one greater than the last, so @@ -120,7 +120,8 @@ type HashListener func( hash [32]byte, ) error -// An iterator over the blocks of a StateWAL. Entry() returns (blockNumber, mutations). +// An iterator over the blocks of a StateWAL. Entry() returns (blockNumber, mutations). The mutations own their +// bytes, so they may be retained past the iterator's next position, but must not be modified. type StateWALIterator interface { seiwal.Iterator[[]mutation.Mutation] diff --git a/sei-db/state_db/statewal/state_wal_impl.go b/sei-db/state_db/statewal/state_wal_impl.go index 85a758d8c1..08fcf6b2da 100644 --- a/sei-db/state_db/statewal/state_wal_impl.go +++ b/sei-db/state_db/statewal/state_wal_impl.go @@ -209,7 +209,8 @@ func (w *stateWALImpl) Write(blockNumber uint64, mutations []mutation.Mutation) return fmt.Errorf("write rejected: %w", err) } - if !w.send(writeRequest{blockNumber: blockNumber, mutations: mutations}) { + // The goroutine sorts the block in place, so it is given its own slice and the caller keeps theirs. + if !w.send(writeRequest{blockNumber: blockNumber, mutations: slices.Clone(mutations)}) { return fmt.Errorf("failed to schedule block %d: %w", blockNumber, w.stoppedErr()) } w.lastBlock.Store(blockNumber) diff --git a/sei-db/state_db/statewal/state_wal_iterator.go b/sei-db/state_db/statewal/state_wal_iterator.go index 4fdd893695..b08a78027c 100644 --- a/sei-db/state_db/statewal/state_wal_iterator.go +++ b/sei-db/state_db/statewal/state_wal_iterator.go @@ -1,6 +1,7 @@ package statewal import ( + "bytes" "fmt" "github.com/sei-protocol/sei-chain/sei-db/seiwal" @@ -38,12 +39,25 @@ func (it *stateWALIterator) Next() (bool, error) { if err != nil { return false, fmt.Errorf("failed to read block %d: %w", blockNumber, err) } + cloneMutationBytes(mutations) it.blockNumber = blockNumber it.serialized = serialized it.mutations = mutations return true, nil } +// cloneMutationBytes replaces every byte slice in mutations with a copy of its own, preserving nil. +// +// Decoded mutations point into the buffer holding the whole WAL file. A caller that retained one value would keep +// that entire buffer alive, so each value gets its own allocation instead. +func cloneMutationBytes(mutations []mutation.Mutation) { + for i := range mutations { + mutations[i].Key = bytes.Clone(mutations[i].Key) + mutations[i].PreviousValue = bytes.Clone(mutations[i].PreviousValue) + mutations[i].NewValue = bytes.Clone(mutations[i].NewValue) + } +} + // Entry returns the block at the iterator's current position and its mutations. func (it *stateWALIterator) Entry() (uint64, []mutation.Mutation) { return it.blockNumber, it.mutations diff --git a/sei-db/state_db/statewal/state_wal_iterator_test.go b/sei-db/state_db/statewal/state_wal_iterator_test.go index 55a0bdb50f..5f65f1dc6f 100644 --- a/sei-db/state_db/statewal/state_wal_iterator_test.go +++ b/sei-db/state_db/statewal/state_wal_iterator_test.go @@ -1,6 +1,7 @@ package statewal import ( + "slices" "testing" "github.com/stretchr/testify/require" @@ -74,6 +75,52 @@ func TestIteratorYieldsBlockContents(t *testing.T) { require.False(t, ok) } +// TestWriteLeavesCallerSliceInPlace verifies that Write sorts a block without reordering the slice the caller +// passed, which the caller may still be reading. +func TestWriteLeavesCallerSliceInPlace(t *testing.T) { + w := openWAL(t, testConfig(t.TempDir())) + defer func() { require.NoError(t, w.Close()) }() + + unsorted := []mutation.Mutation{ + makeMutation("bank", []byte("c"), []byte("3")), + makeMutation("bank", []byte("a"), []byte("1")), + makeMutation("bank", []byte("b"), []byte("2")), + } + callerOrder := slices.Clone(unsorted) + require.NoError(t, w.Write(1, unsorted)) + require.NoError(t, w.Flush()) + require.Equal(t, callerOrder, unsorted) + + it, err := w.Iterator(1, 1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + _, stored := it.Entry() + require.Equal(t, []mutation.Mutation{callerOrder[1], callerOrder[2], callerOrder[0]}, stored, + "the WAL still stores the block sorted by key") +} + +// TestCloneMutationBytesDetachesFromBuffer verifies that cloned mutations share no memory with the buffer they +// were decoded from, and that a nil value stays nil. +func TestCloneMutationBytesDetachesFromBuffer(t *testing.T) { + buffer := []byte("keyprevnew") + mutations := []mutation.Mutation{ + {Key: buffer[0:3], PreviousValue: buffer[3:7], NewValue: buffer[7:10]}, + {Key: buffer[0:3]}, + } + + cloneMutationBytes(mutations) + clear(buffer) + + require.Equal(t, mutation.Mutation{Key: []byte("key"), PreviousValue: []byte("prev"), NewValue: []byte("new")}, + mutations[0]) + require.Equal(t, []byte("key"), mutations[1].Key) + require.Nil(t, mutations[1].PreviousValue) + require.Nil(t, mutations[1].NewValue) +} + // TestIteratorDoesNotSeePostConstructionBlocks confirms the snapshot contract at the wrapper level: an // iterator yields only blocks that were complete when it was created. func TestIteratorDoesNotSeePostConstructionBlocks(t *testing.T) { diff --git a/sei-db/tools/cmd/seidb/operations/dump_flatkv.go b/sei-db/tools/cmd/seidb/operations/dump_flatkv.go index bee23bad03..fdba80d18b 100644 --- a/sei-db/tools/cmd/seidb/operations/dump_flatkv.go +++ b/sei-db/tools/cmd/seidb/operations/dump_flatkv.go @@ -10,6 +10,7 @@ import ( "slices" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types/mutation" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" "github.com/sei-protocol/sei-chain/sei-db/tools/utils" "github.com/spf13/cobra" @@ -340,23 +341,23 @@ const lthashBatchCap = 8192 // committed LtHash. type bucketLtHasher struct { acc *lthash.LtHash - batch []lthash.KeyMutation + batch []mutation.Mutation count uint64 } func newBucketLtHasher() *bucketLtHasher { return &bucketLtHasher{ acc: lthash.New(), - batch: make([]lthash.KeyMutation, 0, lthashBatchCap), + batch: make([]mutation.Mutation, 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.KeyMutation{ - Key: bytes.Clone(key), - Value: bytes.Clone(val), + h.batch = append(h.batch, mutation.Mutation{ + Key: bytes.Clone(key), + NewValue: bytes.Clone(val), }) h.count++ if len(h.batch) >= lthashBatchCap { 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 98d742267c..5cbb387dab 100644 --- a/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go +++ b/sei-db/tools/cmd/seidb/operations/dump_flatkv_test.go @@ -9,6 +9,7 @@ import ( "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/giga/types/mutation" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" "github.com/stretchr/testify/require" ) @@ -136,12 +137,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.KeyMutation, 0, n) + all := make([]mutation.Mutation, 0, n) hashers := map[string]*bucketLtHasher{ FlatKVBucketAccount: newBucketLtHasher(), FlatKVBucketStorage: newBucketLtHasher(), } - bucketPairs := map[string][]lthash.KeyMutation{} + bucketPairs := map[string][]mutation.Mutation{} for i := 0; i < n; i++ { bucket := FlatKVBucketAccount @@ -151,8 +152,8 @@ 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.KeyMutation{Key: key, Value: val}) - all = append(all, lthash.KeyMutation{Key: key, Value: val}) + bucketPairs[bucket] = append(bucketPairs[bucket], mutation.Mutation{Key: key, NewValue: val}) + all = append(all, mutation.Mutation{Key: key, NewValue: val}) } total := lthash.New() diff --git a/sei-db/tools/cmd/seidb/operations/evmlogical/evm_logical_digest.go b/sei-db/tools/cmd/seidb/operations/evmlogical/evm_logical_digest.go index fe7cd4ab76..b794a63aa2 100644 --- a/sei-db/tools/cmd/seidb/operations/evmlogical/evm_logical_digest.go +++ b/sei-db/tools/cmd/seidb/operations/evmlogical/evm_logical_digest.go @@ -84,7 +84,7 @@ const defaultInspectListLimit = 1000 // - memIAVL semantic mode (default): raw EVM leaves are independently decoded // into the same logical account / code / storage / misc buckets. // - memIAVL translator mode: each EVM leaf is fed through -// flatkv.ImportTranslator, which applies the same classifyAndPrefix + +// flatkv.ImportTranslator, which applies the same key routing and // account-merge logic FlatKV uses. // // The per-bucket accumulator is an XOR of sha256(len(key)||key||len(val)||val), @@ -1873,8 +1873,8 @@ func runMemiavlSemanticDigest(ctx digestPrintContext, modeLabel, totalLabel stri } // runMemiavlTranslatorDigest digests memiavl EVM leaves by routing every leaf -// through flatkv.ImportTranslator (the exact classifyAndPrefix + merge path -// CommitStore.ApplyChangeSets uses) and then through the shared consume, making +// through flatkv.ImportTranslator (the same changeset conversion a FlatKV +// commit uses) and then through the shared consume, making // the memiavl and flatkv digests byte-identical by construction. Translate // streams storage/code/misc out immediately and only buffers account fragments // until Finalize, so RSS stays bounded. 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 45e8db685d..0f43142a7e 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 @@ -21,10 +21,7 @@ import ( ) // translatorBatchSize bounds how many memiavl key/value pairs we hand to a -// single flatkv.ImportTranslator.Translate call. Batching amortizes the -// per-call classifyAndPrefix map allocations across many keys without -// growing ImportTranslator's account-buffer memory beyond what an unbatched -// stream would already need. +// single flatkv.ImportTranslator.Translate call. // // Distinct from flatkv.importBatchSize, which is the per-DB-worker flush // threshold (in already-translated physical pairs); the two constants tune From be4db01e5fcdd60233e86bde7592cf4883165d76 Mon Sep 17 00:00:00 2001 From: Cody Littley Date: Wed, 28 Oct 2026 11:45:36 -0500 Subject: [PATCH 6/6] make suggested change --- sei-db/state_db/giga/types/live_state_store.go | 3 ++- sei-db/state_db/sc/flatkv/store_apply.go | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) 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 b205c7d651..d5f0c764df 100644 --- a/sei-db/state_db/giga/types/live_state_store.go +++ b/sei-db/state_db/giga/types/live_state_store.go @@ -69,7 +69,8 @@ type LiveStateStore interface { // CommitStateChanges writes and commits the block at blockNum, which must be the committed version plus one. // mutations holds one entry per physical key the block writes, and each PreviousValue must be that key's row - // as of blockNum-1: the lattice hash is computed from it without being checked against the store. + // as of blockNum-1: the lattice hash is computed from it without being checked against the store. mutations + // must not be modified after the call. // // Callers must use either ApplyChangeSets+Commit or CommitStateChanges exclusively, never mixing the two on // the same store. diff --git a/sei-db/state_db/sc/flatkv/store_apply.go b/sei-db/state_db/sc/flatkv/store_apply.go index 62c969053e..8d0df19bed 100644 --- a/sei-db/state_db/sc/flatkv/store_apply.go +++ b/sei-db/state_db/sc/flatkv/store_apply.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sync" + "unsafe" "github.com/sei-protocol/sei-chain/sei-db/db_engine/view" "github.com/sei-protocol/sei-chain/sei-db/proto" @@ -152,7 +153,12 @@ func writeStore( } writes := make([]view.Write, len(mutations)) for i := range mutations { - writes[i] = view.Write{Key: string(mutations[i].Key), Value: mutations[i].NewValue} + key := mutations[i].Key + // Not copied: mutation keys are never modified, and the view copies any key it keeps past retirement. + writes[i] = view.Write{ + Key: unsafe.String(unsafe.SliceData(key), len(key)), //nolint:gosec // see above + Value: mutations[i].NewValue, + } } if err := store.BatchSet(writes); err != nil { return fmt.Errorf("write %s values: %w", dbDir, err)