From 11795066def517c8ecdade435a4c4de3309deea9 Mon Sep 17 00:00:00 2001 From: Cody Littley <56973212+cody-littley@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:02:08 +0000 Subject: [PATCH 1/7] FlatKV: background read/fold for account data (#4155) ## Describe your changes and provide context Move account read/fold workflow off of the execution thread. --------- Co-authored-by: Cody Littley --- sei-db/db_engine/view/batch_update_test.go | 207 ++++++ sei-db/db_engine/view/differential_test.go | 54 +- sei-db/db_engine/view/pending_value.go | 90 +++ sei-db/db_engine/view/read_cache.go | 3 +- sei-db/db_engine/view/shard.go | 623 ++++++++++++++++-- sei-db/db_engine/view/shard_manager.go | 7 + sei-db/db_engine/view/shutdown_test.go | 83 +++ sei-db/db_engine/view/test_helpers_test.go | 9 +- sei-db/db_engine/view/view_manager.go | 26 +- sei-db/db_engine/view/view_manager_impl.go | 93 ++- sei-db/state_db/sc/flatkv/metrics.go | 48 +- sei-db/state_db/sc/flatkv/store_apply.go | 174 ++--- .../state_db/sc/flatkv/store_replay_test.go | 2 +- sei-db/state_db/sc/flatkv/store_test.go | 27 +- 14 files changed, 1273 insertions(+), 173 deletions(-) create mode 100644 sei-db/db_engine/view/batch_update_test.go create mode 100644 sei-db/db_engine/view/pending_value.go diff --git a/sei-db/db_engine/view/batch_update_test.go b/sei-db/db_engine/view/batch_update_test.go new file mode 100644 index 0000000000..87745c128c --- /dev/null +++ b/sei-db/db_engine/view/batch_update_test.go @@ -0,0 +1,207 @@ +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, err := held[i].GetDiff() + require.NoError(t, err) + 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, err := sealed.GetDiff() + require.NoError(t, err) + 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 7098578f14..046e13fd9a 100644 --- a/sei-db/db_engine/view/differential_test.go +++ b/sei-db/db_engine/view/differential_test.go @@ -43,6 +43,7 @@ const ( opSet = iota opDelete opBatch + opUpdate opView ) @@ -91,6 +92,13 @@ 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() @@ -193,13 +201,57 @@ func pickOp(rng *testutil.TestRandom) int { return opSet case r < 60: return opDelete - case r < 80: + case r < 70: return opBatch + case r < 80: + return opUpdate 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 new file mode 100644 index 0000000000..66224003ac --- /dev/null +++ b/sei-db/db_engine/view/pending_value.go @@ -0,0 +1,90 @@ +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/read_cache.go b/sei-db/db_engine/view/read_cache.go index 24c6862c3f..58268ef868 100644 --- a/sei-db/db_engine/view/read_cache.go +++ b/sei-db/db_engine/view/read_cache.go @@ -20,7 +20,8 @@ import ( // Capitalized methods are the surface the shard calls; readCache is unexported, so they are not exports. // // Method postfixes state the lock contract: RLocked and WLocked require the caller to hold the read or -// write lock, Unlocked requires the caller to hold neither, and a bare name has no lock dependency. +// write lock, and Unlocked requires the caller to hold neither. A bare name touches no guarded state, +// or is external surface whose caller has no access to the lock. type readCache struct { // Cancelled when the manager shuts down; interrupts blocked waits on in-flight reads. ctx context.Context diff --git a/sei-db/db_engine/view/shard.go b/sei-db/db_engine/view/shard.go index eb9e09d5a0..f4bb6e59d2 100644 --- a/sei-db/db_engine/view/shard.go +++ b/sei-db/db_engine/view/shard.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "sync" "github.com/sei-protocol/sei-chain/sei-db/common/structures" @@ -24,8 +25,12 @@ import ( // - The database crashed. Database failures are fatal and are never recovered from, so every shard // goes out of service, not just the one that saw the failure. // +// Capitalized methods are the surface the ViewManager calls; shard is unexported, so they are not +// exports. +// // Method postfixes state the lock contract: RLocked and WLocked require the caller to hold the read or -// write lock, Unlocked requires the caller to hold neither, and a bare name has no lock dependency. +// write lock, and Unlocked requires the caller to hold neither. A bare name touches no guarded state, +// or is external surface whose caller has no access to the lock. type shard struct { // A lock to protect the shard's data. Also used by the read cache (see the cache field). lock sync.RWMutex @@ -59,6 +64,22 @@ type shard struct { // leaked iterator, since reading one after the database has closed is undefined behaviour (see // 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 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) } // A single value at a specific version. @@ -69,6 +90,22 @@ 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. @@ -87,6 +124,9 @@ 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 { @@ -100,6 +140,11 @@ 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 @@ -109,6 +154,11 @@ func NewShard( versionDiffs: versionDiffs, 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, + + reportFoldFailure: reportFoldFailure, } s.cache = NewReadCache(ctx, config, db, readPool, &s.lock, maxSize, shutdownError, reportReadFailure) return s, nil @@ -125,8 +175,19 @@ func (s *shard) Get( // overhead to do so with little benefit. updateLru bool, ) ([]byte, bool, error) { - if value, found, done, err := s.attemptFastGetUnlocked(key, version, updateLru); done { - return value, found, err + 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 + } + if done { + if err != nil { + return nil, false, fmt.Errorf("get at version %d: %w", version, err) + } + return value, found, nil } // Not resolvable without mutating: classify against the DB read-cache under the write lock, @@ -138,19 +199,26 @@ func (s *shard) Get( // not just those that would have reached the DB. if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { s.lock.Unlock() - return nil, false, err + return nil, false, fmt.Errorf("get key %x: %w", key, err) } if err := s.validateVersionRLocked(version); err != nil { s.lock.Unlock() - return nil, false, err + return nil, false, fmt.Errorf("get key %x: %w", key, err) } // First, check to see if we have this value in the versioned data map. - if value, found := s.lookupVersionedRLocked(key, version); found { + 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 value, value != nil, nil + return entry.value, entry.value != nil, nil } outcome := s.cache.LookupWLocked(key, updateLru) @@ -162,32 +230,38 @@ 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, done bool, err error) { +) (value []byte, found bool, pending *pendingValue, done bool, err error) { s.lock.RLock() defer s.lock.RUnlock() if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { - return nil, false, true, err + return nil, false, nil, true, fmt.Errorf("key %x: %w", key, err) } if err := s.validateVersionRLocked(version); err != nil { - return nil, false, true, err + return nil, false, nil, true, fmt.Errorf("key %x: %w", key, err) } - if value, found := s.lookupVersionedRLocked(key, version); found { + if entry, found := s.lookupVersionedRLocked(key, version); found { + if entry.pending != nil { + return nil, false, entry.pending, false, nil + } s.metrics.reportCacheHits(1) - return value, value != nil, true, nil + return entry.value, entry.value != nil, nil, true, nil } value, found, ok := s.cache.AttemptFastLookupRLocked(key, updateLru) if !ok { - return nil, false, false, nil + return nil, false, nil, false, nil } s.metrics.reportCacheHits(1) - return value, found, true, nil + return value, found, nil, true, nil } // validateVersionRLocked checks that the given version is within the valid range. @@ -201,28 +275,32 @@ func (s *shard) validateVersionRLocked(version uint64) error { return nil } -// lookupVersionedRLocked checks versioned data for a key at the given version. -// Returns (value, true) if found in versioned data, (nil, false) if the read cache should be -// consulted. -func (s *shard) lookupVersionedRLocked(key []byte, version uint64) ([]byte, bool) { +// 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. deque, ok := s.versionedData[string(key)] if !ok { - return nil, false + return versionedValue{}, false } if version == s.oldestVersion { next := deque.PeekFront() if next.version == version { - return next.value, true + return next, true } - return nil, false + return versionedValue{}, false } for i := deque.Len() - 1; i >= 0; i-- { next := deque.Get(i) if next.version <= version { - return next.value, true + return next, true } } - return nil, false + return versionedValue{}, false } // BatchGet reads the given keys at the given version, returning a map (keyed by string(key)) of the @@ -231,19 +309,23 @@ func (s *shard) lookupVersionedRLocked(key []byte, version uint64) ([]byte, bool func (s *shard) BatchGet(keys [][]byte, version uint64) (map[string][]byte, error) { results := make(map[string][]byte, len(keys)) - unresolved, hits, err := s.attemptFastBatchGetUnlocked(keys, results, version) + unresolved, staged, hits, err := s.attemptFastBatchGetUnlocked(keys, results, version) if err != nil { - return nil, err + return nil, fmt.Errorf("batch get of %d keys at version %d: %w", len(keys), version, err) } var pending []pendingRead if len(unresolved) > 0 { var remainingHits int64 - pending, remainingHits, err = s.batchGetRemainingUnlocked(keys, unresolved, results, version) + var remainingStaged []*pendingValue + pending, remainingStaged, remainingHits, err = + s.batchGetRemainingUnlocked(keys, unresolved, results, version) if err != nil { - return nil, err + 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 { @@ -252,37 +334,60 @@ func (s *shard) BatchGet(keys [][]byte, version uint64) (map[string][]byte, erro if err := s.cache.ResolveBatchUnlocked(pending, results); err != nil { // DB errors are fatal; fail the whole batch. - return nil, err + 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, hits int64, err error) { +) (unresolved []int, staged []*pendingValue, 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, 0, err + return nil, nil, 0, fmt.Errorf("resolve what is already in memory: %w", err) } if err := s.validateVersionRLocked(version); err != nil { - return nil, 0, err + return nil, nil, 0, fmt.Errorf("resolve what is already in memory: %w", err) } for i, key := range keys { keyStr := string(key) - if value, found := s.lookupVersionedRLocked(key, version); found { + 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 value != nil { - results[keyStr] = value + if entry.value != nil { + results[keyStr] = entry.value } hits++ continue @@ -300,7 +405,7 @@ func (s *shard) attemptFastBatchGetUnlocked( } unresolved = append(unresolved, i) } - return unresolved, hits, nil + return unresolved, staged, hits, nil } // batchGetRemainingUnlocked classifies the keys at the given positions in keys, which are those the @@ -310,28 +415,33 @@ func (s *shard) batchGetRemainingUnlocked( indices []int, results map[string][]byte, version uint64, -) (pending []pendingRead, hits int64, err error) { +) (pending []pendingRead, staged []*pendingValue, 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, 0, err + return nil, nil, 0, fmt.Errorf("classify the remaining keys: %w", err) } if err := s.validateVersionRLocked(version); err != nil { - return nil, 0, err + return nil, 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. + // in between and another reader may have scheduled or completed any of these keys — or staged a + // fold for one. for _, i := range indices { key := keys[i] keyStr := string(key) - if value, found := s.lookupVersionedRLocked(key, version); found { - if value != nil { - results[keyStr] = value + 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 } hits++ continue @@ -352,7 +462,7 @@ func (s *shard) batchGetRemainingUnlocked( needsSchedule: outcome.needsSchedule, }) } - return pending, hits, nil + return pending, staged, hits, nil } // GetSizeInfo returns the current cache size (bytes) and entry count under the read lock. @@ -392,7 +502,7 @@ func (s *shard) Set(key []byte, value []byte) error { defer s.lock.Unlock() if err := s.cache.ErrIfOutOfServiceRLocked(); err != nil { - return err + return fmt.Errorf("set key %x: %w", key, err) } s.setWLocked(key, value) return nil @@ -424,7 +534,7 @@ func (s *shard) BatchSet(entries []*proto.KVPair) error { // 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 err + return fmt.Errorf("batch set of %d keys: %w", len(entries), err) } for i := range entries { if entries[i].Delete { @@ -437,6 +547,359 @@ func (s *shard) BatchSet(entries []*proto.KVPair) 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.NewDeque[versionedValue]() + // 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) @@ -460,7 +923,11 @@ func (s *shard) Commit() (uint64, error) { s.lock.Unlock() - return newVersion, err + if err != nil { + return newVersion, fmt.Errorf("maintain the read cache after sealing version %d: %w", + newVersion, err) + } + return newVersion, nil } // Get the diffs for a range of versions [firstVersion, lastVersion). The returned data should not be mutated @@ -477,9 +944,19 @@ func (s *shard) GetDiffsForVersions( firstVersion, lastVersion) } + // Awaited before the lock is taken, not after: a sealed version's diff keeps being written to + // while its folds resolve, so it is frozen only once its latch has opened. This is the single + // place the diff consumers — hashing, flushing, and the retirement that follows a flush — reach + // a version's values, which is why the wait belongs here rather than at each of them. + for version := firstVersion; version < lastVersion; version++ { + if err := s.awaitVersionFoldsUnlocked(version); err != nil { + return nil, fmt.Errorf("await version %d before reading its diff: %w", version, err) + } + } + // A read lock suffices, and it matters: sort jobs for different versions call this concurrently. - // Nothing here mutates the shard, and the maps handed back are frozen — only versionDiffs at the - // current version is ever written to, so a version stops changing the moment it is no longer current. + // Nothing here mutates the shard, and the maps handed back are frozen — a version whose latch has + // opened gains no further writes. s.lock.RLock() defer s.lock.RUnlock() @@ -506,13 +983,38 @@ func (s *shard) GetDiffsForVersions( // 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, err + return nil, nil, fmt.Errorf("read the current overrides: %w", err) } out := make([]kvPair, 0, len(s.versionedData)) @@ -526,12 +1028,20 @@ func (s *shard) MaterializeCurrentOverrides(lowerBound []byte, upperBound []byte if upperBound != nil && key >= string(upperBound) { continue } + newest := deque.PeekBack() + if newest.pending != nil { + staged = append(staged, newest.pending) + continue + } out = append(out, kvPair{ key: []byte(key), - value: deque.PeekBack().value, + value: newest.value, }) } - return out, nil + if len(staged) > 0 { + return nil, staged, nil + } + return out, nil, nil } // Drop versions, pushing their data down into the read cache. The first version to drop must be @@ -560,6 +1070,17 @@ 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) + } + } + // Combine the data from all versions being dropped. var combinedData map[string][]byte if firstVersion == lastVersion-1 { diff --git a/sei-db/db_engine/view/shard_manager.go b/sei-db/db_engine/view/shard_manager.go index 4ffb6b55f8..c55126b3d9 100644 --- a/sei-db/db_engine/view/shard_manager.go +++ b/sei-db/db_engine/view/shard_manager.go @@ -44,3 +44,10 @@ func (s *shardManager) Shard(addr []byte) uint64 { return x & s.mask } + +// ShardString is Shard for a key already held as a string. maphash.String is defined as +// Bytes(seed, []byte(addr)), and Shard's pooled Hash computes the same seeded sum, so a key lands in +// the same shard whichever form it arrives in. +func (s *shardManager) ShardString(addr string) uint64 { + return maphash.String(s.seed, addr) & s.mask +} diff --git a/sei-db/db_engine/view/shutdown_test.go b/sei-db/db_engine/view/shutdown_test.go index 564d0478ef..e5facad1e3 100644 --- a/sei-db/db_engine/view/shutdown_test.go +++ b/sei-db/db_engine/view/shutdown_test.go @@ -245,3 +245,86 @@ 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() + manager, err := NewViewManager(newTestConfig(1, 4096), db, readPool, miscPool) + 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() + 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 541a764fea..047ba3c5a8 100644 --- a/sei-db/db_engine/view/test_helpers_test.go +++ b/sei-db/db_engine/view/test_helpers_test.go @@ -42,6 +42,9 @@ 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). @@ -62,6 +65,9 @@ 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 } @@ -313,9 +319,10 @@ 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 - // without help, so reporting is a no-op here. + // or fold without help, so both reports are no-ops 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 dd0f595ae1..b25e18178e 100644 --- a/sei-db/db_engine/view/view_manager.go +++ b/sei-db/db_engine/view/view_manager.go @@ -14,6 +14,15 @@ 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. @@ -71,12 +80,25 @@ type ViewManager interface { // Iterator). BatchSet(updates []*proto.KVPair) 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, or the construction of an Iterator. Reads of + // 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. // @@ -98,7 +120,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. Each shard's overrides are copied under that + // Constructing an iterator must NOT race a BatchSet or a BatchUpdate. 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 b2124413d7..66894c8912 100644 --- a/sei-db/db_engine/view/view_manager_impl.go +++ b/sei-db/db_engine/view/view_manager_impl.go @@ -203,8 +203,8 @@ func NewViewManager( // cancellation — see the Close contract on ViewManager. 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) + shards[i], err = NewShard(childCtx, config, db, readPool, sizePerShard, + c.shutdownError, c.reportReadFailure, c.reportFoldFailure) if err != nil { cancel() return nil, fmt.Errorf("failed to create shard: %w", err) @@ -271,6 +271,71 @@ func (c *viewManager) BatchSet(updates []*proto.KVPair) error { return nil } +func (c *viewManager) BatchUpdate(keys []string, updater BatchUpdater) error { + work := c.partitionIndicesByShard(keys) + version := c.currentVersion + + // Staging is synchronous, and it is all this call does on the caller's thread: one lock hold per + // shard that reads no database and folds nothing. It is what makes the keys read as their new + // values before any fold has run. + staged := make([][]stagedFold, len(c.shards)) + for shardIndex := range work { + if len(work[shardIndex]) == 0 { + continue + } + folds, err := c.shards[shardIndex].StageUpdates(keys, work[shardIndex], version) + if err != nil { + // The shards that already staged are holding values nothing will ever fold, and a reader + // would park on one forever. Fail them before reporting. + c.abandonStaged(staged, version, err) + return fmt.Errorf("failed to stage update in shard: %w", err) + } + staged[shardIndex] = folds + } + + // 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) + } +} + +// partitionIndicesByShard groups the positions of keys by the shard each key belongs to, so each +// shard is visited once. The returned slice is indexed by shard, and a shard no key landed in holds +// an empty bucket. +// +// Buckets start out sized for an even spread, which is what the seeded hash produces; a bucket that +// lands above its share still grows on demand. +func (c *viewManager) partitionIndicesByShard(keys []string) [][]int { + work := make([][]int, len(c.shards)) + perShard := len(keys)/len(c.shards) + 1 + for index, key := range keys { + shardIndex := c.shardManager.ShardString(key) + if work[shardIndex] == nil { + work[shardIndex] = make([]int, 0, perShard) + } + work[shardIndex] = append(work[shardIndex], index) + } + return work +} + func (c *viewManager) BatchGet(keys [][]byte) (map[string][]byte, error) { return c.BatchGetAtVersion(keys, c.currentVersion) } @@ -795,6 +860,15 @@ 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. @@ -1131,6 +1205,19 @@ 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 @@ -1138,6 +1225,8 @@ 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/sc/flatkv/metrics.go b/sei-db/state_db/sc/flatkv/metrics.go index 7a83003c91..a02fb23530 100644 --- a/sei-db/state_db/sc/flatkv/metrics.go +++ b/sei-db/state_db/sc/flatkv/metrics.go @@ -16,26 +16,26 @@ var ( flatkvMeter = otel.Meter(flatkvMeterName) otelMetrics = struct { - OpenLatency metric.Float64Histogram - ApplyChangesetsLatency metric.Float64Histogram - CommitLatency metric.Float64Histogram - CommitBatchLatency metric.Float64Histogram - BatchReadOldValuesLatency metric.Float64Histogram - NumKVPairs metric.Int64Counter - PendingWrites metric.Int64Gauge - CurrentVersion metric.Int64Gauge - CatchupLatency metric.Float64Histogram - CatchupReplayNumBlocks metric.Int64Counter - SnapshotWriteLatency metric.Float64Histogram - SnapshotQueue *commonmetrics.QueueMeter - SnapshotPruneLatency metric.Float64Histogram - SnapshotPruneAttempts metric.Int64Counter - CurrentSnapshotHeight metric.Int64Gauge - RollbackLatency metric.Float64Histogram - ImportLatency metric.Float64Histogram - ImportKVPairs metric.Int64Counter - ImportWorkerFlushLatency metric.Float64Histogram - FlushLatency metric.Float64Histogram + OpenLatency metric.Float64Histogram + ApplyChangesetsLatency metric.Float64Histogram + CommitLatency metric.Float64Histogram + CommitBatchLatency metric.Float64Histogram + AccountUpdateLatency metric.Float64Histogram + NumKVPairs metric.Int64Counter + PendingWrites metric.Int64Gauge + CurrentVersion metric.Int64Gauge + CatchupLatency metric.Float64Histogram + CatchupReplayNumBlocks metric.Int64Counter + SnapshotWriteLatency metric.Float64Histogram + SnapshotQueue *commonmetrics.QueueMeter + SnapshotPruneLatency metric.Float64Histogram + SnapshotPruneAttempts metric.Int64Counter + CurrentSnapshotHeight metric.Int64Gauge + RollbackLatency metric.Float64Histogram + ImportLatency metric.Float64Histogram + ImportKVPairs metric.Int64Counter + ImportWorkerFlushLatency metric.Float64Histogram + FlushLatency metric.Float64Histogram }{ OpenLatency: must(flatkvMeter.Float64Histogram( "flatkv_open_latency", @@ -61,9 +61,11 @@ var ( metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(commonmetrics.LatencyBuckets...), )), - BatchReadOldValuesLatency: must(flatkvMeter.Float64Histogram( - "flatkv_batch_read_old_values_latency", - metric.WithDescription("Time taken to batch read old FlatKV values"), + 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...), )), diff --git a/sei-db/state_db/sc/flatkv/store_apply.go b/sei-db/state_db/sc/flatkv/store_apply.go index 73d4841830..e1aeba42a7 100644 --- a/sei-db/state_db/sc/flatkv/store_apply.go +++ b/sei-db/state_db/sc/flatkv/store_apply.go @@ -87,7 +87,7 @@ func (s *CommitStore) applyChangeSets( logger.Debug("FlatKV ApplyChangeSets complete", "version", version, "changesets", len(changeSets), - "writes", len(prepared.accounts)+len(prepared.storage)+len(prepared.code)+len(prepared.misc), + "writes", prepared.accountCount()+len(prepared.storage)+len(prepared.code)+len(prepared.misc), "elapsed", obs.elapsed()) return nil } @@ -96,7 +96,7 @@ func (s *CommitStore) applyChangeSets( // ApplyChangeSets call. Nothing here reaches a store until every kind has validated — see // writeToStores. type preparedWrites struct { - accounts map[string]*vtype.AccountData + accounts *accountUpdater storage map[string]*vtype.StorageData code map[string]*vtype.CodeData misc map[string]*vtype.MiscData @@ -109,29 +109,20 @@ func (s *CommitStore) prepareWrites( ) (preparedWrites, error) { var out preparedWrites - // A nonce, codehash or balance change carries only its own field, so it has to be merged onto the - // account as it stands right now — a live read, since anything an earlier call at this height wrote - // counts. - s.phaseTimer.SetPhase("apply_change_sets_read_accounts") - readStart := time.Now() - accountOld, err := s.readAccountsForMerge(changesByType) - otelMetrics.BatchReadOldValuesLatency.Record(s.ctx, secondsSince(readStart), - metric.WithAttributes(successAttr(err))) - if err != nil { - return out, err - } - s.phaseTimer.SetPhase("apply_change_sets_gather_values") - accountUpdates, err := mergeAccountUpdates( + // Only the changeset's own field values are parsed here. Folding them onto the rows those + // accounts already hold is left to the account store, which does it off this thread; see + // accountUpdater. + accountWrites, err := newAccountUpdater( changesByType[keys.EVMKeyNonce], changesByType[keys.EVMKeyCodeHash], changesByType[keys.EVMKeyBalance], + blockHeight, ) if err != nil { - return out, fmt.Errorf("failed to gather account updates: %w", err) + return out, fmt.Errorf("prepare account writes for block %d: %w", blockHeight, err) } - newAccounts := deriveNewAccountValues(accountUpdates, accountOld, blockHeight) storageWrites, err := toStorageValues(changesByType[keys.EVMKeyStorage], blockHeight) if err != nil { @@ -148,44 +139,89 @@ func (s *CommitStore) prepareWrites( return out, fmt.Errorf("failed to parse misc changes: %w", err) } - out.accounts = newAccounts + out.accounts = accountWrites out.storage = storageWrites out.code = codeWrites out.misc = miscWrites return out, nil } -// readAccountsForMerge reads the accounts that this batch's nonce, codehash and balance changes touch, -// so those partial updates can be merged onto whole accounts. Keys come from all three kinds, since any -// one of them can name an account the others do not. -func (s *CommitStore) readAccountsForMerge( - changesByType map[keys.EVMKeyKind]map[string][]byte, -) (map[string]*vtype.AccountData, error) { - accountKinds := []keys.EVMKeyKind{keys.EVMKeyNonce, keys.EVMKeyCodeHash, keys.EVMKeyBalance} +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 map[string][]byte, + codeHashChanges map[string][]byte, + balanceChanges map[string][]byte, + 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 + } - size := 0 - for _, kind := range accountKinds { - size += len(changesByType[kind]) + physKeys := make([]string, 0, len(pending)) + for key := range pending { + physKeys = append(physKeys, key) } - touched := make(map[string]struct{}, size) - for _, kind := range accountKinds { - for key := range changesByType[kind] { - touched[key] = struct{}{} + 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) + if err != nil { + return nil, fmt.Errorf("failed to deserialize accountDB old value: %w", err) } + stored = parsed } - if len(touched) == 0 { + + // Merge copies rather than writing through, so the value handed back does not alias the row the + // store still holds for earlier versions. + merged := u.pending[key].Merge(stored, u.blockHeight) + if merged.IsDelete() { return nil, nil } + return merged.Serialize(), nil +} - physKeys := make([][]byte, 0, len(touched)) - for key := range touched { - physKeys = append(physKeys, []byte(key)) - } - raw, err := s.accountStore.BatchGet(physKeys) - if err != nil { - return nil, fmt.Errorf("read accounts to merge onto: %w", err) +// 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 deserializeOldAccounts(raw) + return len(p.accounts.keys) } // writeToStores writes one successful ApplyChangeSets batch into the four data stores and records the @@ -207,11 +243,15 @@ func (s *CommitStore) writeToStores( // 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 alreadyHave[accountDBDir] < version { - if err := serializeAndPut(s.accountStore, prepared.accounts); err != nil { + if alreadyHave[accountDBDir] < version && prepared.accounts != nil { + start := time.Now() + err := s.accountStore.BatchUpdate(prepared.accounts.keys, prepared.accounts) + 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(prepared.accounts)) + addKVPairs(s.ctx, accountDBDir, len(prepared.accounts.keys)) } if alreadyHave[storageDBDir] < version { if err := serializeAndPut(s.storageStore, prepared.storage); err != nil { @@ -259,33 +299,15 @@ func serializeAndPut[T vtype.VType](store view.ViewManager, values map[string]T) return nil } -// deserializeOldAccounts parses the account database's old values into AccountData. A partial update — -// a nonce without a codehash, say — has to be merged onto the account that is already there, which -// needs the old value in structured form rather than as bytes. -// -// raw is keyed by physical key, and a key that had no prior value maps to nil; those are dropped -// rather than deserialized, so the result holds only accounts that already existed. -func deserializeOldAccounts(raw map[string][]byte) (map[string]*vtype.AccountData, error) { - old := make(map[string]*vtype.AccountData, len(raw)) - for key, b := range raw { - if b == nil { - continue - } - v, err := vtype.DeserializeAccountData(b) - if err != nil { - return nil, fmt.Errorf("failed to deserialize accountDB old value: %w", err) - } - old[key] = v - } - return old, 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). func moduleOfKey(physicalKey []byte) (string, error) { module, _, err := ktype.StripModulePrefix(physicalKey) - return module, err + if err != nil { + return "", fmt.Errorf("strip the module prefix from key %x: %w", physicalKey, err) + } + return module, nil } // classifyAndPrefix splits changeSets into per-EVMKeyKind maps whose keys are @@ -491,23 +513,3 @@ func mergeAccountUpdates( } return updates, nil } - -// Combine the pending account writes with prior values to determine the new account values. -// -// We need to take this step because accounts are split into multiple fields, and it's possible to overwrite just a -// single field (thus requiring us to copy the unmodified fields from the prior value). -func deriveNewAccountValues( - pendingWrites map[string]*vtype.PendingAccountWrite, - oldValues map[string]*vtype.AccountData, - blockHeight int64, -) map[string]*vtype.AccountData { - result := make(map[string]*vtype.AccountData, len(pendingWrites)) - - for addrStr, pendingWrite := range pendingWrites { - oldValue := oldValues[addrStr] - - newValue := pendingWrite.Merge(oldValue, blockHeight) - result[addrStr] = newValue - } - return result -} 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 7b7393331f..af72028c14 100644 --- a/sei-db/state_db/sc/flatkv/store_replay_test.go +++ b/sei-db/state_db/sc/flatkv/store_replay_test.go @@ -411,7 +411,7 @@ 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: deriveNewAccountValues folds a nonce-only or +// 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 diff --git a/sei-db/state_db/sc/flatkv/store_test.go b/sei-db/state_db/sc/flatkv/store_test.go index a2d89fc85a..0f85348596 100644 --- a/sei-db/state_db/sc/flatkv/store_test.go +++ b/sei-db/state_db/sc/flatkv/store_test.go @@ -1,6 +1,7 @@ package flatkv import ( + "fmt" "os" "path/filepath" "testing" @@ -1596,15 +1597,31 @@ func TestCrashRecoveryCorruptedAccountValueInDB(t *testing.T) { defer s2.Close() require.NoError(t, s2.LoadLatest()) - // Applying a partial nonce update reads the old account back to merge onto it, and must reject the - // corrupted row instead of merging onto garbage. + // A partial nonce update has to be folded onto the account already stored, which the account store + // does off this thread. Applying the block therefore succeeds: the corrupted row has not been read + // yet, and nothing waits for it to be. cs2 := &proto.NamedChangeSet{ Name: "evm", Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{noncePair(addr, 99)}}, } - err = s2.ApplyChangeSets(s2.Version()+1, []*proto.NamedChangeSet{cs2}) - require.Error(t, err, "should fail on corrupted AccountValue") - require.Contains(t, err.Error(), "unsupported serialization version") + require.NoError(t, s2.ApplyChangeSets(s2.Version()+1, []*proto.NamedChangeSet{cs2}), + "the fold is scheduled, not performed, so applying the block cannot meet the corruption") + + // The read is what meets it. It waits for the fold rather than racing it, so this is not timing + // dependent: the account either folds before the read arrives or the read waits for it, and both + // end at the same failure. Reads report a corrupted row by panicking (see CommitStore.Get). + nonceKey := keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]) + cause := func() (recovered string) { + defer func() { + if r := recover(); r != nil { + recovered = fmt.Sprint(r) + } + }() + s2.Get("evm", nonceKey) + return "" + }() + require.Contains(t, cause, "unsupported serialization version", + "reading the folded account must report the corrupted row it was folded onto") } func TestCrashRecoveryCrashAfterWALBeforeDBCommit(t *testing.T) { From f0cd46cd0065c1aa499a1f24f5feeba8b34b0077 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 15 Sep 2026 12:24:40 +0000 Subject: [PATCH 2/7] feat(evmonly): add eth_getBalance RPC (#4140) ## Describe your changes and provide context - expose `eth_getBalance` from the EVM-only JSON-RPC server - read balances from the current committed EVM state - support `latest`, `safe`, `finalized`, and `pending`, while rejecting block heights and hashes until historical state is wired - add unit, JSON-RPC registration, and Docker integration coverage - document the endpoint and its supported block selectors in the Autobahn README ## Testing performed to validate your change - `go test -race -count=1 ./giga/evmonly/rpc/...` - `go test -count=1 ./sei-tendermint/internal/rpc/core/...` - `make autobahn-evmonly-integration-test` (four local Docker validators, 4,000 finalized transfers, post-transfer balance checks on every validator) - `golangci-lint run ./giga/evmonly/rpc/... ./sei-tendermint/internal/rpc/core/...` - `golangci-lint fmt --diff` --- giga/evmonly/rpc/balance.go | 38 ++++++++++ giga/evmonly/rpc/balance_test.go | 80 +++++++++++++++++++++ giga/evmonly/rpc/server.go | 9 ++- giga/evmonly/rpc/server_test.go | 6 ++ integration_test/autobahn/README.md | 30 ++++++-- integration_test/autobahn/autobahn_test.go | 19 +++++ sei-tendermint/internal/rpc/core/mempool.go | 6 ++ 7 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 giga/evmonly/rpc/balance.go create mode 100644 giga/evmonly/rpc/balance_test.go diff --git a/giga/evmonly/rpc/balance.go b/giga/evmonly/rpc/balance.go new file mode 100644 index 0000000000..79237d972e --- /dev/null +++ b/giga/evmonly/rpc/balance.go @@ -0,0 +1,38 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethrpc "github.com/ethereum/go-ethereum/rpc" +) + +var errHistoricalStateUnsupported = errors.New("historical state is not supported by EVM-only RPC") + +type balanceAPI struct { + backend Backend +} + +// GetBalance returns the address balance from the current committed EVM state. +func (api *balanceAPI) GetBalance(_ context.Context, address common.Address, block ethrpc.BlockNumberOrHash) (*hexutil.Big, error) { + if err := requireCurrentState(block); err != nil { + return nil, err + } + balance := api.backend.EvmBalance(address) + return (*hexutil.Big)(balance.ToBig()), nil +} + +func requireCurrentState(block ethrpc.BlockNumberOrHash) error { + number, ok := block.Number() + if !ok { + return errHistoricalStateUnsupported + } + switch number { + case ethrpc.LatestBlockNumber, ethrpc.SafeBlockNumber, ethrpc.FinalizedBlockNumber, ethrpc.PendingBlockNumber: + return nil + default: + return errHistoricalStateUnsupported + } +} diff --git a/giga/evmonly/rpc/balance_test.go b/giga/evmonly/rpc/balance_test.go new file mode 100644 index 0000000000..304ac5266e --- /dev/null +++ b/giga/evmonly/rpc/balance_test.go @@ -0,0 +1,80 @@ +package rpc + +import ( + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +func TestGetBalance(t *testing.T) { + address := common.HexToAddress("0x1000000000000000000000000000000000000001") + want := uint256.NewInt(123456789) + backend := &testBackend{ + balance: func(got common.Address) uint256.Int { + require.Equal(t, address, got) + return *want + }, + } + api := &balanceAPI{backend: backend} + + for _, tag := range []ethrpc.BlockNumber{ + ethrpc.LatestBlockNumber, + ethrpc.SafeBlockNumber, + ethrpc.FinalizedBlockNumber, + ethrpc.PendingBlockNumber, + } { + got, err := api.GetBalance(t.Context(), address, ethrpc.BlockNumberOrHashWithNumber(tag)) + require.NoError(t, err) + require.Equal(t, want.ToBig(), got.ToInt()) + } +} + +func TestGetBalanceRejectsHistoricalState(t *testing.T) { + backend := &testBackend{ + balance: func(common.Address) uint256.Int { + t.Fatal("historical request read the current balance") + return uint256.Int{} + }, + } + api := &balanceAPI{backend: backend} + address := common.Address{1} + + for _, block := range []ethrpc.BlockNumberOrHash{ + ethrpc.BlockNumberOrHashWithNumber(ethrpc.EarliestBlockNumber), + ethrpc.BlockNumberOrHashWithNumber(7), + ethrpc.BlockNumberOrHashWithHash(common.Hash{2}, true), + {}, + } { + got, err := api.GetBalance(t.Context(), address, block) + require.ErrorIs(t, err, errHistoricalStateUnsupported) + require.Nil(t, got) + } +} + +func TestHandlerServesGetBalance(t *testing.T) { + address := common.HexToAddress("0x2000000000000000000000000000000000000002") + backend := &testBackend{ + balance: func(common.Address) uint256.Int { + return *uint256.NewInt(42) + }, + } + handler, err := newHandler(backend, evmonly.NewMemoryReceiptStore()) + require.NoError(t, err) + t.Cleanup(handler.Stop) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := ethrpc.DialHTTP(server.URL) + require.NoError(t, err) + t.Cleanup(client.Close) + + var got hexutil.Big + require.NoError(t, client.CallContext(t.Context(), &got, "eth_getBalance", address, "latest")) + require.Equal(t, "0x2a", got.String()) +} diff --git a/giga/evmonly/rpc/server.go b/giga/evmonly/rpc/server.go index e19863a414..079292d872 100644 --- a/giga/evmonly/rpc/server.go +++ b/giga/evmonly/rpc/server.go @@ -13,6 +13,7 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" ethtypes "github.com/ethereum/go-ethereum/core/types" ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" @@ -29,11 +30,12 @@ const ( var logger = seilog.NewLogger("giga", "evmonly", "rpc") -// Backend submits transactions, reads finalized blocks, and returns the RPC -// client for an Autobahn shard owner. +// Backend submits transactions, reads committed EVM state and finalized +// blocks, and returns the RPC client for an Autobahn shard owner. type Backend interface { BroadcastTx(context.Context, *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) Block(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) + EvmBalance(common.Address) uint256.Int EvmProxy(common.Address) utils.Option[*ethrpc.Client] } @@ -117,6 +119,9 @@ func newHandler(backend Backend, receiptStore receipt.ReceiptStore) (*ethrpc.Ser if err := rpcServer.RegisterName("eth", &receiptAPI{backend: backend, store: receiptStore}); err != nil { return nil, fmt.Errorf("register EVM-only receipt RPC: %w", err) } + if err := rpcServer.RegisterName("eth", &balanceAPI{backend: backend}); err != nil { + return nil, fmt.Errorf("register EVM-only balance RPC: %w", err) + } return rpcServer, nil } diff --git a/giga/evmonly/rpc/server_test.go b/giga/evmonly/rpc/server_test.go index 192f37692d..811fb635ad 100644 --- a/giga/evmonly/rpc/server_test.go +++ b/giga/evmonly/rpc/server_test.go @@ -11,6 +11,7 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/giga/evmonly" @@ -21,6 +22,7 @@ import ( type testBackend struct { broadcast func(context.Context, *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) block func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) + balance func(common.Address) uint256.Int proxy utils.Option[*ethrpc.Client] } @@ -32,6 +34,10 @@ func (b *testBackend) Block(ctx context.Context, req *coretypes.RequestBlockInfo return b.block(ctx, req) } +func (b *testBackend) EvmBalance(address common.Address) uint256.Int { + return b.balance(address) +} + func (b *testBackend) EvmProxy(common.Address) utils.Option[*ethrpc.Client] { return b.proxy } diff --git a/integration_test/autobahn/README.md b/integration_test/autobahn/README.md index 647890dc77..db139f77be 100644 --- a/integration_test/autobahn/README.md +++ b/integration_test/autobahn/README.md @@ -13,8 +13,8 @@ passed to `deploy`, pass the same name to `list`, `forward`, and `teardown`. Both targets require Go 1.25.6 and `make`. Local deployment also requires a running Docker engine with Docker Compose v2. AWS deployment requires the AWS CLI, `git`, and `ssh`, plus credentials allowed to manage EC2 instances, -security groups, and key pairs. The inspection and receipt examples also use -`jq` and Foundry's `cast`. +security groups, and key pairs. The inspection, balance, and receipt examples +also use `jq` and Foundry's `cast`. Build the manager once: @@ -297,11 +297,27 @@ tail -f build/generated/logs/seid-0.log The public EVM JSON-RPC surface intentionally contains only: - `eth_sendRawTransaction`, used by `sei-load` and `cast publish`; -- `eth_getTransactionReceipt`, for finalized receipts. +- `eth_getTransactionReceipt`, for finalized receipts; +- `eth_getBalance`, for the current committed EVM balance. All other `eth_*` methods currently return JSON-RPC method-not-found. A lookup for a pending or unknown hash returns `null`. +### Fetch balances with `cast` + +`eth_getBalance` accepts `latest`, `safe`, `finalized`, and `pending`; all four +read the current committed state because Sei has instant finality. Explicit +block numbers and hashes return an error because historical EVM-only state is +not wired yet. + +Every previously unseen address starts with the test-only `2^200` wei balance: + +```sh +cast balance \ + --rpc-url http://127.0.0.1:8545 \ + 0x000000000000000000000000000000000000dEaD +``` + ### Fetch receipts with `cast` `cast receipt` works for a known finalized transaction hash. Contract @@ -350,10 +366,10 @@ correct. The remaining `cast` gaps are RPC gaps, not receipt-decoding gaps. There is no `eth_getTransactionByHash` or block API to discover a `sei-load` transfer hash, and `sei-load` does not currently print every submitted hash. There are also no -chain ID, balance, nonce, fee-estimation, gas-estimation, call, log, or -WebSocket subscription methods. Commands that depend on those queries cannot -operate normally; raw transactions must provide chain ID, nonce, gas limit, -and gas price offline as in the example above. +chain ID, nonce, fee-estimation, gas-estimation, call, log, or WebSocket +subscription methods. Commands that depend on those queries cannot operate +normally; raw transactions must provide chain ID, nonce, gas limit, and gas +price offline as in the example above. ## Tear down diff --git a/integration_test/autobahn/autobahn_test.go b/integration_test/autobahn/autobahn_test.go index 2d990db080..15ab5c55b3 100644 --- a/integration_test/autobahn/autobahn_test.go +++ b/integration_test/autobahn/autobahn_test.go @@ -714,12 +714,31 @@ func testEVMOnlyLoad(t *testing.T) { lastHeight, included := waitForEVMOnlyTxs(t, ctx, listRunningNodes(t), len(block.Txs)) assertEVMOnlyReceipts(t, ctx, clients, block.Txs) + assertEVMOnlyBalances(t, ctx, clients, block.Txs) elapsed := time.Since(started) t.Logf("Autobahn finalized %d raw EVM transfers through %d validators in %s (%.0f tx/s)", included, clusterSize, elapsed.Round(time.Millisecond), float64(included)/elapsed.Seconds()) t.Logf("all validators executed through at least height %d", lastHeight) } +func assertEVMOnlyBalances(t *testing.T, ctx context.Context, clients []*ethrpc.Client, txs [][]byte) { + t.Helper() + want := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 200), big.NewInt(1)) + for nodeIndex, client := range clients { + tx := new(ethtypes.Transaction) + if err := tx.UnmarshalBinary(txs[nodeIndex]); err != nil { + t.Fatalf("decode EVM-only transaction %d: %v", nodeIndex, err) + } + var got hexutil.Big + if err := client.CallContext(ctx, &got, "eth_getBalance", tx.To(), "latest"); err != nil { + t.Fatalf("read EVM-only balance %s from node %d: %v", tx.To(), nodeIndex, err) + } + if got.ToInt().Cmp(want) != 0 { + t.Fatalf("node %d returned balance %s for %s, want %s", nodeIndex, got.ToInt(), tx.To(), want) + } + } +} + func assertEVMOnlyReceipts(t *testing.T, ctx context.Context, clients []*ethrpc.Client, txs [][]byte) { t.Helper() for nodeIndex, client := range clients { diff --git a/sei-tendermint/internal/rpc/core/mempool.go b/sei-tendermint/internal/rpc/core/mempool.go index a11a802ef1..d70aa64faf 100644 --- a/sei-tendermint/internal/rpc/core/mempool.go +++ b/sei-tendermint/internal/rpc/core/mempool.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum/go-ethereum/common" ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/state/indexer" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -26,6 +27,11 @@ func (env *Environment) EvmProxy(sender common.Address) utils.Option[*ethrpc.Cli return utils.None[*ethrpc.Client]() } +// EvmBalance returns the address balance from the current committed EVM state. +func (env *Environment) EvmBalance(address common.Address) uint256.Int { + return env.App.EvmBalance(address, nil) +} + func (env *Environment) EvmTxByHash(hash common.Hash) (types.Tx, bool) { if giga, ok := env.gigaRouter().Get(); ok { if v, ok := giga.Mempool().Get(); ok { From c45517d71484e70972fa2ef499454c69cb8edaf7 Mon Sep 17 00:00:00 2001 From: Yiming Zang <50607998+yzang2019@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:46:56 +0000 Subject: [PATCH 3/7] Make Receipt and SS write fully async (#4159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes and provide context Both the receipt write and the EVM state store (SS) write were doing slow synchronous work on the block commit path. This makes both of them fully async. ### Receipt store `SetReceipts` used to write the receipt bodies, the `eth_getLogs` index and the version marker inline, and the index commit alone was ~74% of the call. It now hands the block to a background writer and returns. Measured at 2,000 receipts per block, the commit path went from **4.0 ms to 11 µs**. The work still costs the same; it just happens on the writer, where it overlaps with execution instead of serializing against it. - `receipt-store.async-write-buffer` (default 100) bounds how many blocks the store may fall behind. A full queue blocks the caller — that is the back-pressure. - Setting it to `<= 0` keeps writes synchronous, which is the escape hatch if strict read-after-write is wanted. - `LatestVersion()` only advances once a write has actually been applied, so it never advertises a receipt that is not yet readable. It is the watermark a reader follows. ### EVM state store `enqueue_ss` looked async but was dominated by a **synchronous changelog WAL write sitting in front of the queue**. That is also why its queue depth always read 0: queue depth only reveals a slow consumer, and here the producer was the slow side. Under giga that changelog is written every block and never read — crash recovery replays giga's own state WAL via `catchUpTo`, and rollback rewinds SS from its snapshots against that same WAL. So giga now opens SS with `DisableInternalWAL` and the commit-path write is gone. The composite (non-giga) path is untouched and keeps its changelog, which it does need: `ss/composite` rollback replays it to reach versions above a snapshot. ### Interface cleanup `SetLatestVersion` / `SetEarliestVersion` are no longer on the `ReceiptStore` interface. No production code called them — the write path carries the markers, and every external caller was test or benchmark scaffolding. cryptosim's redundant `SetLatestVersion` after each block is deleted for the same reason. ### Bug fixed along the way Draining the pebble async writer on close was nested inside the changelog check: ```go if db.streamHandler != nil { close(db.pendingChanges) db.asyncWriteWG.Wait() ... } ``` With the changelog off, that drain would never run, silently dropping queued blocks on every clean shutdown. The drain is now unconditional, behind a `sync.Once` so `Close` stays idempotent. ### Dashboard `receipt_write_queue_depth` now covers the whole receipt write. The old "ReceiptDB Queue Depth" panel tracked only litt's table queue, which is ~7% of the call, which is why it read 0 while `write_receipts` was a large share of the execution loop. ## Testing performed to validate your change - `sei-db/ledger_db/...`, `sei-db/state_db/...`, `sei-db/bootstrap`, `sei-db/config`, `sei-db/db_engine/pebbledb/...`, `giga/evmonly/...`, `evmrpc/...` and `x/evm/keeper` all pass. - The receipt package passes three repeats under `-race`. - `make dblint` reports 0 issues; `go vet ./...` is clean. New tests: - `TestLittIdxSynchronousWriteBuffer` — with the buffer off, a block is queryable the moment `SetReceipts` returns. - `TestLittIdxWriteBufferBoundsLag` — the buffer is the back-pressure point; the store cannot trail further than it allows. - `TestOpenSSKeepsNoChangelogOfItsOwn` — pins the absence of the SS changelog under giga rather than trusting the config. Verified non-vacuous by re-enabling the flag and watching it fail. Tests that previously relied on read-after-write now wait on `LatestVersion` instead. Worth noting for reviewers: that watermark is necessary but not sufficient as a "my write landed" signal — the bodies land just before the version marker commits, and a block written in parts advances the marker on its first part. The `littidx` helper waits on both. --- .../dashboards/gigasim-dashboard.json | 376 +++++++----------- evmrpc/setup_test.go | 14 +- evmrpc/simulate_test.go | 3 +- evmrpc/tests/utils.go | 6 +- .../cryptosim/reciept_store_simulator.go | 4 - sei-db/bench/gigasim/block_generator.go | 28 +- sei-db/bench/gigasim/gigasim.go | 11 +- sei-db/bench/gigasim/gigasim_metrics.go | 12 - sei-db/bench/gigasim/receipt.go | 159 +++++--- sei-db/bench/gigasim/receipt_bloom_test.go | 96 +++++ sei-db/bench/gigasim/receipt_test.go | 26 ++ sei-db/bench/gigasim/receipt_writer.go | 33 +- sei-db/bootstrap/recovery_test.go | 6 +- sei-db/config/giga_config.go | 1 + sei-db/config/receipt_config.go | 17 +- sei-db/config/ss_config.go | 8 + sei-db/db_engine/pebbledb/mvcc/db.go | 48 ++- .../receipt/litt_ctx_internal_test.go | 6 + .../ledger_db/receipt/litt_receipt_store.go | 142 ++++++- .../litt_write_failure_internal_test.go | 180 +++++++++ sei-db/ledger_db/receipt/littidx_test.go | 74 ++++ .../receipt/offline_internal_test.go | 1 + .../receipt/receipt_bench_read_test.go | 5 + sei-db/ledger_db/receipt/receipt_store.go | 26 +- .../ledger_db/receipt/receipt_store_test.go | 6 +- sei-db/ledger_db/receipt/test_helpers_test.go | 12 + sei-db/state_db/giga/state_db.go | 12 +- sei-db/state_db/giga/state_db_replay_test.go | 51 +++ 28 files changed, 980 insertions(+), 383 deletions(-) create mode 100644 sei-db/bench/gigasim/receipt_bloom_test.go create mode 100644 sei-db/ledger_db/receipt/litt_write_failure_internal_test.go diff --git a/docker/monitornode/dashboards/gigasim-dashboard.json b/docker/monitornode/dashboards/gigasim-dashboard.json index b3f01c2688..badc93bd3e 100644 --- a/docker/monitornode/dashboards/gigasim-dashboard.json +++ b/docker/monitornode/dashboards/gigasim-dashboard.json @@ -313,8 +313,8 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "litt_control_queue_depth{table=\"receipts\"}", - "legendFormat": "control loop", + "expr": "receipt_write_queue_depth", + "legendFormat": "receipt write (whole write)", "range": true }, "version": "v0" @@ -334,24 +334,45 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "litt_flush_queue_depth{table=\"receipts\"}", - "legendFormat": "flush loop", + "expr": "litt_control_queue_depth{table=\"receipts\"}", + "legendFormat": "litt control loop", "range": true }, "version": "v0" }, "refId": "B" } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "PBFA97CFB590B2093" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "litt_flush_queue_depth{table=\"receipts\"}", + "legendFormat": "litt flush loop", + "range": true + }, + "version": "v0" + }, + "refId": "C" + } } ], "queryOptions": {}, "transformations": [] } }, - "description": "LittDB's write path for receipt bodies. Receipt writes block on the control loop when it fills.", + "description": "Blocks waiting for the receipt writer. SetReceipts queues the whole write — bodies, log index and version marker — and returns, so this depth is the depth of the receipt write itself. The litt series are the table queues downstream of it.", "id": 23, "links": [], - "title": "ReceiptDB Queue Depth", + "title": "Receipt Write Queue Depth", "vizConfig": { "group": "timeseries", "kind": "VizConfig", @@ -9050,91 +9071,6 @@ } } }, - "panel-104": { - "kind": "Panel", - "spec": { - "data": { - "kind": "QueryGroup", - "spec": { - "queries": [ - { - "kind": "PanelQuery", - "spec": { - "hidden": false, - "query": { - "datasource": { - "name": "PBFA97CFB590B2093" - }, - "group": "prometheus", - "kind": "DataQuery", - "spec": { - "editorMode": "code", - "expr": "rate(gigasim_receipt_write_phase_duration_seconds_total[$__rate_interval])", - "legendFormat": "{{phase}}", - "range": true - }, - "version": "v0" - }, - "refId": "A" - } - } - ], - "queryOptions": {}, - "transformations": [] - } - }, - "description": "What the main execution loop's write_receipts phase is made of: encoding the receipts, which the benchmark does itself, against handing them to the receipt store.", - "id": 104, - "links": [], - "title": "└ Write Receipts — encode vs store", - "vizConfig": { - "group": "piechart", - "kind": "VizConfig", - "spec": { - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "#73BF69", - "mode": "palette-classic" - }, - "custom": { - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - } - }, - "unit": "percentunit" - }, - "overrides": [] - }, - "options": { - "legend": { - "displayMode": "list", - "placement": "bottom", - "showLegend": true, - "values": [] - }, - "pieType": "pie", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "sort": "desc", - "tooltip": { - "hideZeros": false, - "mode": "single", - "sort": "none" - } - } - }, - "version": "13.2.1" - } - } - }, "panel-106": { "kind": "Panel", "spec": { @@ -9803,7 +9739,7 @@ } } }, - "panel-124": { + "panel-125": { "kind": "Panel", "spec": { "data": { @@ -9822,7 +9758,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(gigasim_receipt_write_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", + "expr": "rate(giga_state_commit_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", "legendFormat": "{{phase}}", "range": true }, @@ -9836,10 +9772,10 @@ "transformations": [] } }, - "description": "What the main execution loop's write_receipts phase is made of: encoding the receipts, which the benchmark does itself, against handing them to the receipt store. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", - "id": 124, + "description": "What the main execution loop's commit window is made of, split across the state WAL, the state commit store and the EVM state store. These three are the second series on the top-level pie. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", + "id": 125, "links": [], - "title": "└ Write Receipts — encode vs store (per block)", + "title": "└ State Commit — by store (per block)", "vizConfig": { "group": "timeseries", "kind": "VizConfig", @@ -9914,7 +9850,7 @@ } } }, - "panel-125": { + "panel-126": { "kind": "Panel", "spec": { "data": { @@ -9933,7 +9869,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(giga_state_commit_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", + "expr": "rate(seidb_main_thread_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", "legendFormat": "{{phase}}", "range": true }, @@ -9947,10 +9883,10 @@ "transformations": [] } }, - "description": "What the main execution loop's commit window is made of, split across the state WAL, the state commit store and the EVM state store. These three are the second series on the top-level pie. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", - "id": 125, + "description": "What the state commit store's commit_sc phase is made of. Covers the commits themselves and not the gaps between them, so it sums to its parent. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", + "id": 126, "links": [], - "title": "└ State Commit — by store (per block)", + "title": " └ SC — inside the FlatKV commit (per block)", "vizConfig": { "group": "timeseries", "kind": "VizConfig", @@ -10025,7 +9961,7 @@ } } }, - "panel-126": { + "panel-127": { "kind": "Panel", "spec": { "data": { @@ -10044,7 +9980,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(seidb_main_thread_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", + "expr": "rate(ss_evm_commit_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", "legendFormat": "{{phase}}", "range": true }, @@ -10058,10 +9994,10 @@ "transformations": [] } }, - "description": "What the state commit store's commit_sc phase is made of. Covers the commits themselves and not the gaps between them, so it sums to its parent. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", - "id": 126, + "description": "What the state commit's enqueue_ss phase is made of. The name is misleading: almost all of it is applying the changesets, not queueing them. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", + "id": 127, "links": [], - "title": " └ SC — inside the FlatKV commit (per block)", + "title": " └ SS — inside the EVM state store commit (per block)", "vizConfig": { "group": "timeseries", "kind": "VizConfig", @@ -10136,7 +10072,7 @@ } } }, - "panel-127": { + "panel-129": { "kind": "Panel", "spec": { "data": { @@ -10155,7 +10091,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(ss_evm_commit_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", + "expr": "rate(gigasim_block_producing_loop_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", "legendFormat": "{{phase}}", "range": true }, @@ -10169,10 +10105,10 @@ "transformations": [] } }, - "description": "What the state commit's enqueue_ss phase is made of. The name is misleading: almost all of it is applying the changesets, not queueing them. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", - "id": 127, + "description": "Every phase the goroutine that builds blocks and writes them to the block store passes through, as a share of its wall clock. Fully accounted, the blocked hand-off to the execution loop included, so these total 100%. A large wait_for_execution means execution is the limit, not block production. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", + "id": 129, "links": [], - "title": " └ SS — inside the EVM state store commit (per block)", + "title": "Main Block Producing Loop — Time Spent (per block)", "vizConfig": { "group": "timeseries", "kind": "VizConfig", @@ -10247,7 +10183,7 @@ } } }, - "panel-129": { + "panel-130": { "kind": "Panel", "spec": { "data": { @@ -10266,7 +10202,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(gigasim_block_producing_loop_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", + "expr": "rate(gigasim_blockstore_write_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", "legendFormat": "{{phase}}", "range": true }, @@ -10280,10 +10216,10 @@ "transformations": [] } }, - "description": "Every phase the goroutine that builds blocks and writes them to the block store passes through, as a share of its wall clock. Fully accounted, the blocked hand-off to the execution loop included, so these total 100%. A large wait_for_execution means execution is the limit, not block production. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", - "id": 129, + "description": "What the block producing loop's write_block phase is made of: the block, the QC and the AppQC covering it, and the periodic flush behind them. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", + "id": 130, "links": [], - "title": "Main Block Producing Loop — Time Spent (per block)", + "title": "└ BlockStore Write — by record (per block)", "vizConfig": { "group": "timeseries", "kind": "VizConfig", @@ -10358,7 +10294,7 @@ } } }, - "panel-130": { + "panel-131": { "kind": "Panel", "spec": { "data": { @@ -10377,7 +10313,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(gigasim_blockstore_write_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", + "expr": "rate(receipt_store_write_phase_duration_seconds_total[$__rate_interval])", "legendFormat": "{{phase}}", "range": true }, @@ -10391,77 +10327,51 @@ "transformations": [] } }, - "description": "What the block producing loop's write_block phase is made of: the block, the QC and the AppQC covering it, and the periodic flush behind them. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", - "id": 130, + "description": "What the execution loop's write_receipts phase is made of. The receipt bodies go to LittDB asynchronously (litt_put), but the same call also maintains the pebble index that serves eth_getLogs, and that is committed inline: stage_tag_keys builds one key per log address plus one per topic, and commit_index writes them. The index scales with logs and topics rather than with receipts, so it dominates the write.", + "id": 131, "links": [], - "title": "└ BlockStore Write — by record (per block)", + "title": " └ Receipt Store Write — litt vs log index", "vizConfig": { - "group": "timeseries", + "group": "piechart", "kind": "VizConfig", "spec": { "fieldConfig": { "defaults": { "color": { + "fixedColor": "#73BF69", "mode": "palette-classic" }, "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 100, - "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "showValues": false, - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" } }, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - } - ] - }, - "unit": "s" + "unit": "percentunit" }, "overrides": [] }, "options": { "legend": { - "calcs": [], "displayMode": "list", "placement": "bottom", - "showLegend": true + "showLegend": true, + "values": [] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false }, + "sort": "desc", "tooltip": { "hideZeros": false, - "mode": "multi", - "sort": "desc" + "mode": "single", + "sort": "none" } } }, @@ -10469,7 +10379,7 @@ } } }, - "panel-131": { + "panel-132": { "kind": "Panel", "spec": { "data": { @@ -10488,7 +10398,7 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(receipt_store_write_phase_duration_seconds_total[$__rate_interval])", + "expr": "rate(receipt_store_write_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", "legendFormat": "{{phase}}", "range": true }, @@ -10502,51 +10412,77 @@ "transformations": [] } }, - "description": "What the execution loop's write_receipts phase is made of. The receipt bodies go to LittDB asynchronously (litt_put), but the same call also maintains the pebble index that serves eth_getLogs, and that is committed inline: stage_tag_keys builds one key per log address plus one per topic, and commit_index writes them. The index scales with logs and topics rather than with receipts, so it dominates the write.", - "id": 131, + "description": "What the execution loop's write_receipts phase is made of. The receipt bodies go to LittDB asynchronously (litt_put), but the same call also maintains the pebble index that serves eth_getLogs, and that is committed inline: stage_tag_keys builds one key per log address plus one per topic, and commit_index writes them. The index scales with logs and topics rather than with receipts, so it dominates the write. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", + "id": 132, "links": [], - "title": " └ Receipt Store Write — litt vs log index", + "title": " └ Receipt Store Write — litt vs log index (per block)", "vizConfig": { - "group": "piechart", + "group": "timeseries", "kind": "VizConfig", "spec": { "fieldConfig": { "defaults": { "color": { - "fixedColor": "#73BF69", "mode": "palette-classic" }, "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 100, + "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" } }, - "unit": "percentunit" + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" }, "overrides": [] }, "options": { "legend": { + "calcs": [], "displayMode": "list", "placement": "bottom", - "showLegend": true, - "values": [] - }, - "pieType": "pie", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false + "showLegend": true }, - "sort": "desc", "tooltip": { "hideZeros": false, - "mode": "single", - "sort": "none" + "mode": "multi", + "sort": "desc" } } }, @@ -10554,7 +10490,7 @@ } } }, - "panel-132": { + "panel-133": { "kind": "Panel", "spec": { "data": { @@ -10573,8 +10509,8 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "rate(receipt_store_write_phase_duration_seconds_total[$__rate_interval])\n/ on() group_left()\nrate(gigasim_blocks_processed_total[$__rate_interval])", - "legendFormat": "{{phase}}", + "expr": "flatkv_snapshot_write_latency_seconds_count", + "legendFormat": "{{success}}", "range": true }, "version": "v0" @@ -10587,10 +10523,10 @@ "transformations": [] } }, - "description": "What the execution loop's write_receipts phase is made of. The receipt bodies go to LittDB asynchronously (litt_put), but the same call also maintains the pebble index that serves eth_getLogs, and that is committed inline: stage_tag_keys builds one key per log address plus one per topic, and commit_index writes them. The index scales with logs and topics rather than with receipts, so it dominates the write. Divided by the block rate, so each band is the time one block spends there rather than a share, which is what shows a phase getting slower while its share holds steady.", - "id": 132, + "description": "Snapshots FlatKV has written since start, counted by the write that produced each one. A series under success=false is a checkpoint that failed: the writer reports it and carries on, so nothing else marks it.", + "id": 133, "links": [], - "title": " └ Receipt Store Write — litt vs log index (per block)", + "title": "Checkpoints Created", "vizConfig": { "group": "timeseries", "kind": "VizConfig", @@ -10609,7 +10545,7 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 100, + "fillOpacity": 15, "gradientMode": "none", "hideFrom": { "legend": false, @@ -10628,7 +10564,7 @@ "spanNulls": false, "stacking": { "group": "A", - "mode": "normal" + "mode": "none" }, "thresholdsStyle": { "mode": "off" @@ -10643,7 +10579,7 @@ } ] }, - "unit": "s" + "unit": "short" }, "overrides": [] }, @@ -10665,7 +10601,7 @@ } } }, - "panel-133": { + "panel-134": { "kind": "Panel", "spec": { "data": { @@ -10684,8 +10620,8 @@ "kind": "DataQuery", "spec": { "editorMode": "code", - "expr": "flatkv_snapshot_write_latency_seconds_count", - "legendFormat": "{{success}}", + "expr": "sum(rate(pebble_pending_changes_queue_blocked_seconds_total{db=~\".*state_store.*\"}[$__rate_interval])) / sum(rate(gigasim_blocks_processed_total[$__rate_interval]))", + "legendFormat": "blocked per block", "range": true }, "version": "v0" @@ -10698,10 +10634,10 @@ "transformations": [] } }, - "description": "Snapshots FlatKV has written since start, counted by the write that produced each one. A series under success=false is a checkpoint that failed: the writer reports it and carries on, so nothing else marks it.", - "id": 133, + "description": "Seconds the commit path spent waiting for room on the EVM state store's apply queue, per block. Depth is sampled and can miss a queue that fills and drains between samples; this counter integrates every wait. When it accounts for most of enqueue_ss, the store is applying slower than blocks arrive, so the commit path is waiting on the queue rather than on work of its own — and no further pipelining helps, because the bottleneck is downstream of it.", + "id": 134, "links": [], - "title": "Checkpoints Created", + "title": "SS Commit Queue — Blocked Time per Block", "vizConfig": { "group": "timeseries", "kind": "VizConfig", @@ -10745,6 +10681,7 @@ "mode": "off" } }, + "min": 0, "thresholds": { "mode": "absolute", "steps": [ @@ -10754,7 +10691,7 @@ } ] }, - "unit": "short" + "unit": "s" }, "overrides": [] }, @@ -11024,32 +10961,6 @@ "y": 54 } }, - { - "kind": "GridLayoutItem", - "spec": { - "element": { - "kind": "ElementReference", - "name": "panel-104" - }, - "height": 9, - "width": 8, - "x": 0, - "y": 63 - } - }, - { - "kind": "GridLayoutItem", - "spec": { - "element": { - "kind": "ElementReference", - "name": "panel-124" - }, - "height": 9, - "width": 16, - "x": 8, - "y": 63 - } - }, { "kind": "GridLayoutItem", "spec": { @@ -11167,6 +11078,19 @@ "x": 16, "y": 8 } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-134" + }, + "height": 8, + "width": 8, + "x": 0, + "y": 16 + } } ] } diff --git a/evmrpc/setup_test.go b/evmrpc/setup_test.go index 28dc5a279c..bc7a4261d6 100644 --- a/evmrpc/setup_test.go +++ b/evmrpc/setup_test.go @@ -31,6 +31,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/hd" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/bytes" tmutils "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -62,6 +63,12 @@ const MockHeight103 = 103 const MockHeight101 = 101 const MockHeight100 = 100 +// pinReceiptVersions widens a store's queryable window to [1, latest]. These tests seed receipts +// by other means, so nothing has advanced the markers a read is gated on. +func pinReceiptVersions(store receipt.ReceiptStore, latest int64) error { + return receipt.PinVersions(store, 1, latest) +} + // LatestCtxUpgradeName makes the test ctx look like a real chain that has // applied a post-v5.8.0 upgrade. The default Ctx has empty // ClosestUpgradeName and semver.Compare("", "v5.8.0") returns -1 (treated @@ -660,11 +667,9 @@ func init() { } testApp.Commit(context.Background()) if store := EVMKeeper.ReceiptStore(); store != nil { - latest := int64(math.MaxInt64) - if err := store.SetLatestVersion(latest); err != nil { + if err := pinReceiptVersions(store, math.MaxInt64); err != nil { panic(err) } - _ = store.SetEarliestVersion(1) } ctxProvider := func(height int64) sdk.Context { if height == MockHeight2 { @@ -1263,10 +1268,9 @@ func setupLogs() { EVMKeeper.SetEvmOnlyBlockBloom(Ctx, []ethtypes.Bloom{bloom4, bloomTx1}) if store := EVMKeeper.ReceiptStore(); store != nil { - if err := store.SetLatestVersion(MockHeight103); err != nil { + if err := pinReceiptVersions(store, MockHeight103); err != nil { panic(err) } - _ = store.SetEarliestVersion(1) } } diff --git a/evmrpc/simulate_test.go b/evmrpc/simulate_test.go index a89bffbce3..c42e722b76 100644 --- a/evmrpc/simulate_test.go +++ b/evmrpc/simulate_test.go @@ -47,8 +47,7 @@ import ( func primeReceiptStore(t *testing.T, store receipt.ReceiptStore, latest int64) { t.Helper() - require.NoError(t, store.SetLatestVersion(latest)) - require.NoError(t, store.SetEarliestVersion(1)) + require.NoError(t, pinReceiptVersions(store, latest)) } // bcAlwaysFailClient fails every Block call (header resolution uses a single block fetch). diff --git a/evmrpc/tests/utils.go b/evmrpc/tests/utils.go index a367663ea5..54592ac5d9 100644 --- a/evmrpc/tests/utils.go +++ b/evmrpc/tests/utils.go @@ -20,6 +20,7 @@ import ( evmrpcconfig "github.com/sei-protocol/sei-chain/evmrpc/config" "github.com/sei-protocol/sei-chain/sei-cosmos/client" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" testkeeper "github.com/sei-protocol/sei-chain/testutil/keeper" @@ -187,11 +188,10 @@ func setupTestServer( } pinStateStoreLatestVersion(a, ctxProvider) if store := a.EvmKeeper.ReceiptStore(); store != nil { - latest := int64(math.MaxInt64) - if err := store.SetLatestVersion(latest); err != nil { + // These tests seed receipts by other means and would otherwise read against an unset window. + if err := receipt.PinVersions(store, 1, math.MaxInt64); err != nil { panic(err) } - _ = store.SetEarliestVersion(1) } return TestServer{EVMServer: s, port: port, mockClient: mockClient, app: a, ctxProvider: ctxProvider} } diff --git a/sei-db/bench/cryptosim/reciept_store_simulator.go b/sei-db/bench/cryptosim/reciept_store_simulator.go index 3a0c5aa117..b1bfdc8d7e 100644 --- a/sei-db/bench/cryptosim/reciept_store_simulator.go +++ b/sei-db/bench/cryptosim/reciept_store_simulator.go @@ -251,10 +251,6 @@ func (r *RecieptStoreSimulator) processBlock(blk *block) { for _, entry := range ringEntries { r.txRing.Push(entry.txHash, blockNumber, entry.contractAddress) } - - if err := r.store.SetLatestVersion(int64(blockNumber)); err != nil { //nolint:gosec - fmt.Printf("failed to update latest version for block %d: %v\n", blockNumber, err) - } } // startReceiptReaders launches dedicated goroutines for receipt-by-hash lookups. diff --git a/sei-db/bench/gigasim/block_generator.go b/sei-db/bench/gigasim/block_generator.go index a073b32ac3..597a151b85 100644 --- a/sei-db/bench/gigasim/block_generator.go +++ b/sei-db/bench/gigasim/block_generator.go @@ -3,13 +3,11 @@ package gigasim import ( "context" "fmt" - "hash" - "golang.org/x/crypto/sha3" "golang.org/x/time/rate" "github.com/sei-protocol/sei-chain/sei-db/common/metrics" - evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" ) // simulatedBlock is one block's worth of work: the transactions the execution phase runs, the payload @@ -22,8 +20,13 @@ type simulatedBlock struct { // executor pool, so a block's transaction count is also its degree of parallelism. transactions []*transaction - // The receipts written to the receipt store, empty when receipts are disabled. - receipts []*evmtypes.Receipt + // The receipts written to the receipt store, in the form it takes them, empty when receipts are + // disabled. They are marshaled here because execution does not change them and its loop paces + // the run. + receiptRecords []receipt.ReceiptRecord + + // What those records marshaled to, which the run reports as bytes written. + receiptBytes int64 // The transaction bytes the block store persists. These stand in for encoded transactions, which // the block store holds as opaque bytes. @@ -77,7 +80,7 @@ type blockGenerator struct { // The keccak hasher every receipt's bloom is built with, held here because only this goroutine // builds receipts. - bloomHasher hash.Hash + receiptCache *receiptCache // This goroutine's share of a block's critical path: building it and storing it. lifecycle *metrics.PhaseTimer @@ -113,7 +116,7 @@ func newBlockGenerator( blocks: blocks, rateLimiter: rateLimiter, blocksChan: make(chan *simulatedBlock, config.MaxPendingExecutionQueueSize), - bloomHasher: sha3.NewLegacyKeccak256(), + receiptCache: newReceiptCache(), lifecycle: gigasimMetrics.NewBlockProducingTimer(), blockStoreWrite: blockStoreWrite, metrics: gigasimMetrics, @@ -201,8 +204,8 @@ func (g *blockGenerator) buildBlock() (*simulatedBlock, error) { } var receipts *receiptBuffer if g.config.EnableReceiptStore { - receipts = newReceiptBuffer(count, g.bloomHasher) - block.receipts = receipts.receipts + receipts = newReceiptBuffer(count, g.receiptCache) + block.receiptRecords = receipts.records } for i := range count { @@ -214,9 +217,14 @@ func (g *blockGenerator) buildBlock() (*simulatedBlock, error) { block.payload[i] = g.accounts.Rand().Bytes(g.config.BytesPerTransaction) if receipts != nil { - receipts.build(i, g.accounts.Rand(), txn, number) + if err := receipts.build(i, g.accounts.Rand(), txn, number); err != nil { + return nil, err + } } } + if receipts != nil { + block.receiptBytes = receipts.encodedBytes + } // Accounts minted for this block become legal read targets once it is complete. g.accounts.ReportEndOfBlock() diff --git a/sei-db/bench/gigasim/gigasim.go b/sei-db/bench/gigasim/gigasim.go index 0cc2796c70..c931977ba2 100644 --- a/sei-db/bench/gigasim/gigasim.go +++ b/sei-db/bench/gigasim/gigasim.go @@ -18,7 +18,7 @@ import ( crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" "github.com/sei-protocol/sei-chain/sei-db/common/utils" dbconfig "github.com/sei-protocol/sei-chain/sei-db/config" - evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" ) // GigaSim runs the benchmark, driving generated blocks through the block store, the state DB and the @@ -401,7 +401,7 @@ func (g *GigaSim) finalizeSetupBlock() error { if err := g.blocks.writeBlock(number, payload); err != nil { return err } - if err := g.persistExecutionResults(number, nil, g.accounts.Counters()); err != nil { + if err := g.persistExecutionResults(number, nil, 0, g.accounts.Counters()); err != nil { return err } g.accounts.ReportEndOfBlock() @@ -472,7 +472,7 @@ func (g *GigaSim) halt() { func (g *GigaSim) executeAndRecord(block *simulatedBlock) error { g.executeBlock(block) - if err := g.persistExecutionResults(block.number, block.receipts, block.counters); err != nil { + if err := g.persistExecutionResults(block.number, block.receiptRecords, block.receiptBytes, block.counters); err != nil { return err } @@ -514,12 +514,13 @@ func (g *GigaSim) executeBlock(block *simulatedBlock) { // the reverse leaves committed state whose receipts were dropped. func (g *GigaSim) persistExecutionResults( number int64, - receipts []*evmtypes.Receipt, + records []receipt.ReceiptRecord, + receiptBytes int64, counters identifierCounters, ) error { if g.receipts != nil { g.lifecycle.SetPhase("write_receipts") - if err := g.receipts.writeBlock(number, receipts); err != nil { + if err := g.receipts.writeBlock(number, records, receiptBytes); err != nil { return err } } diff --git a/sei-db/bench/gigasim/gigasim_metrics.go b/sei-db/bench/gigasim/gigasim_metrics.go index c35985c29a..20c6675a17 100644 --- a/sei-db/bench/gigasim/gigasim_metrics.go +++ b/sei-db/bench/gigasim/gigasim_metrics.go @@ -52,7 +52,6 @@ type GigasimMetrics struct { executionLoopPhases *metrics.PhaseTimerFactory blockProducingPhases *metrics.PhaseTimerFactory blockStoreWritePhases *metrics.PhaseTimerFactory - receiptWritePhases *metrics.PhaseTimerFactory pendingExecutionQueue *metrics.QueueMeter } @@ -160,7 +159,6 @@ func NewGigasimMetrics() *GigasimMetrics { executionLoopPhases: metrics.NewPhaseTimerFactory(meter, "gigasim_execution_loop").RecordLatencies(), blockProducingPhases: metrics.NewPhaseTimerFactory(meter, "gigasim_block_producing_loop").RecordLatencies(), blockStoreWritePhases: metrics.NewPhaseTimerFactory(meter, "gigasim_blockstore_write"), - receiptWritePhases: metrics.NewPhaseTimerFactory(meter, "gigasim_receipt_write"), pendingExecutionQueue: metrics.NewQueueMeter(meter, "gigasim_pending_execution"), } } @@ -197,16 +195,6 @@ func (m *GigasimMetrics) NewBlockStoreWriteTimer() *metrics.PhaseTimer { return m.blockStoreWritePhases.Build() } -// NewReceiptWriteTimer returns the timer breaking a receipt write into encoding the receipts and -// handing them to the store. It subdivides the execution loop's write_receipts phase rather than -// adding to it. -func (m *GigasimMetrics) NewReceiptWriteTimer() *metrics.PhaseTimer { - if m == nil || m.receiptWritePhases == nil { - return nil - } - return m.receiptWritePhases.Build() -} - // NewTransactionPhaseTimer returns a phase timer for one executor. Each executor needs its own: a // timer tracks a single thread's current phase. func (m *GigasimMetrics) NewTransactionPhaseTimer() *metrics.PhaseTimer { diff --git a/sei-db/bench/gigasim/receipt.go b/sei-db/bench/gigasim/receipt.go index 1dd6e75d3c..b3824558af 100644 --- a/sei-db/bench/gigasim/receipt.go +++ b/sei-db/bench/gigasim/receipt.go @@ -3,11 +3,13 @@ package gigasim import ( "encoding/binary" "encoding/hex" - "hash" + "fmt" + "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/sei-protocol/sei-chain/sei-db/common/keys" crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) @@ -64,10 +66,80 @@ func writeSyntheticTxHash(dst []byte, rand *crand.CannedRandom, blockNumber int6 // topicsPerTransferLog is the Transfer event signature plus its two indexed address topics. const topicsPerTransferLog = 3 +// bloomBits are the three bit positions a value contributes to a log bloom. +type bloomBits [3]uint + +// bloomBitsFor derives the three bits a value sets in a log bloom by mixing its bytes rather than +// hashing them, which no filter could match against. Nothing here reads a log back, so what is kept +// is what reaches the store: the same bits per value, the same spread, and the same bits on a +// rerun of the seed. +func bloomBitsFor(value []byte) bloomBits { + // FNV-1a, for a spread across the bloom's positions that costs a multiply per byte. + const ( + fnvOffset uint64 = 14695981039346656037 + fnvPrime uint64 = 1099511628211 + ) + mixed := fnvOffset + for _, b := range value { + mixed ^= uint64(b) + mixed *= fnvPrime + } + var bits bloomBits + for i := range bits { + bits[i] = uint(mixed & 2047) + mixed >>= 11 + } + return bits +} + +// receiptCache holds what a receipt repeats rather than derives anew: the event signature's bloom +// bits, and the values that follow from a contract address. It is not safe for concurrent use. +type receiptCache struct { + signature bloomBits + contracts map[[keys.AddressLen]byte]contractFields +} + +// contractFields are the per-contract values a receipt repeats and none of its transactions change. +type contractFields struct { + bits bloomBits + hex string +} + +// newReceiptCache returns a cache with the constant inputs already resolved. +func newReceiptCache() *receiptCache { + return &receiptCache{ + signature: bloomBitsFor(erc20TransferEventSignatureBytes[:]), + contracts: make(map[[keys.AddressLen]byte]contractFields), + } +} + +// contract returns an ERC20 contract's bloom bits and hex address, resolving one it has not seen. +func (c *receiptCache) contract(address []byte) contractFields { + var key [keys.AddressLen]byte + copy(key[:], address) + if fields, ok := c.contracts[key]; ok { + return fields + } + fields := contractFields{bits: bloomBitsFor(address), hex: bytesToHex(address)} + c.contracts[key] = fields + return fields +} + +// setBits marks bits in a bloom. +func setBits(bloom *ethtypes.Bloom, bits bloomBits) { + for _, bit := range bits { + bloom[ethtypes.BloomByteLength-1-bit/8] |= byte(1 << (bit % 8)) + } +} + // receiptBuffer holds one block's receipts in a fixed number of allocations: every array a receipt // points into is carved out of a slice the buffer owns. type receiptBuffer struct { - receipts []*evmtypes.Receipt + // The records the store is handed, marshaled as each receipt is built. + records []receipt.ReceiptRecord + + // The total size of what those records marshaled to. + encodedBytes int64 storage []evmtypes.Receipt logs []evmtypes.Log @@ -76,30 +148,29 @@ type receiptBuffer struct { blooms []ethtypes.Bloom data []byte - // The bloom hasher, which belongs to the generator rather than to any one block: it is reset - // before each use, and building one per receipt costs more than the hashing does. - hasher hash.Hash + // What the generator has already resolved about the contract pool, kept across blocks. + cache *receiptCache } // newReceiptBuffer allocates the backing storage for one block of receipts. -func newReceiptBuffer(count int, hasher hash.Hash) *receiptBuffer { +func newReceiptBuffer(count int, cache *receiptCache) *receiptBuffer { return &receiptBuffer{ - receipts: make([]*evmtypes.Receipt, count), - storage: make([]evmtypes.Receipt, count), - logs: make([]evmtypes.Log, count), - logRefs: make([]*evmtypes.Log, count), - topics: make([]string, count*topicsPerTransferLog), - blooms: make([]ethtypes.Bloom, count), - data: make([]byte, count*hashLen), - hasher: hasher, + records: make([]receipt.ReceiptRecord, count), + storage: make([]evmtypes.Receipt, count), + logs: make([]evmtypes.Log, count), + logRefs: make([]*evmtypes.Log, count), + topics: make([]string, count*topicsPerTransferLog), + blooms: make([]ethtypes.Bloom, count), + data: make([]byte, count*hashLen), + cache: cache, } } // build fills in the receipt an ERC20 transfer would leave behind: one Transfer log with two indexed // address topics, and a bloom covering them. The values are synthetic, since the receipt store is // measured on the volume and shape of what it stores rather than on the arithmetic behind it. -func (b *receiptBuffer) build(index int, rand *crand.CannedRandom, txn *transaction, blockNumber int64) { - contractAddress := addressFromKey(txn.erc20Contract) +func (b *receiptBuffer) build(index int, rand *crand.CannedRandom, txn *transaction, blockNumber int64) error { + contract := b.cache.contract(addressFromKey(txn.erc20Contract)) senderTopic := indexedAddressTopic(addressFromKey(txn.srcAccount)) receiverTopic := indexedAddressTopic(addressFromKey(txn.dstAccount)) @@ -112,11 +183,9 @@ func (b *receiptBuffer) build(index int, rand *crand.CannedRandom, txn *transact effectiveGasPrice := receiptGasPriceBase + rand.Int64Range(0, receiptGasPriceSpan) transferAmount := receiptTransferBase + rand.Int64Range(0, receiptTransferSpan) - contractAddressHex := bytesToHex(contractAddress) - bloom := &b.blooms[index] *bloom = ethtypes.Bloom{} - b.addTransferLogToBloom(bloom, contractAddress, senderTopic[:], receiverTopic[:]) + b.addTransferLogToBloom(bloom, contract.bits, senderTopic[:], receiverTopic[:]) topics := b.topics[index*topicsPerTransferLog : (index+1)*topicsPerTransferLog] topics[0] = erc20TransferEventSignatureHex @@ -130,7 +199,7 @@ func (b *receiptBuffer) build(index int, rand *crand.CannedRandom, txn *transact log := &b.logs[index] b.logRefs[index] = log *log = evmtypes.Log{ - Address: contractAddressHex, + Address: contract.hex, Topics: topics, Data: amount, Index: 0, @@ -139,13 +208,12 @@ func (b *receiptBuffer) build(index int, rand *crand.CannedRandom, txn *transact var txHash [hashLen]byte writeSyntheticTxHash(txHash[:], rand, blockNumber, index) - receipt := &b.storage[index] - b.receipts[index] = receipt + built := &b.storage[index] //nolint:gosec // G115 - benchmark values are bounded well below the conversion limits - *receipt = evmtypes.Receipt{ + *built = evmtypes.Receipt{ TxType: txType, CumulativeGasUsed: uint64(gasUsed + int64(index)*previousGas), - ContractAddress: contractAddressHex, + ContractAddress: contract.hex, TxHashHex: bytesToHex(txHash[:]), GasUsed: uint64(gasUsed), EffectiveGasPrice: uint64(effectiveGasPrice), @@ -153,38 +221,37 @@ func (b *receiptBuffer) build(index int, rand *crand.CannedRandom, txn *transact TransactionIndex: uint32(index), Status: uint32(ethtypes.ReceiptStatusSuccessful), From: bytesToHex(addressFromKey(txn.srcAccount)), - To: contractAddressHex, + To: contract.hex, Logs: b.logRefs[index : index+1], LogsBloom: bloom[:], } + + // Marshaled here rather than on the execution loop, which is what paces the run. The receipt is + // final once built, so nothing downstream changes what this encodes. + encoded, err := built.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal the receipt for transaction %d of block %d: %w", + index, blockNumber, err) + } + b.encodedBytes += int64(len(encoded)) + b.records[index] = receipt.ReceiptRecord{ + TxHash: common.BytesToHash(txHash[:]), + Receipt: built, + ReceiptBytes: encoded, + } + return nil } // addTransferLogToBloom sets the bits a Transfer log contributes: the emitting contract, the event // signature and both indexed topics. func (b *receiptBuffer) addTransferLogToBloom( bloom *ethtypes.Bloom, - contractAddress, senderTopic, receiverTopic []byte, + contractBits bloomBits, senderTopic, receiverTopic []byte, ) { - var digest [hashLen]byte - for _, value := range [4][]byte{ - contractAddress, - erc20TransferEventSignatureBytes[:], - senderTopic, - receiverTopic, - } { - addToBloom(b.hasher, &digest, bloom, value) - } -} - -// addToBloom sets the three bits a value contributes to a bloom filter. -func addToBloom(hasher hash.Hash, digest *[hashLen]byte, bloom *ethtypes.Bloom, value []byte) { - hasher.Reset() - _, _ = hasher.Write(value) - sum := hasher.Sum(digest[:0]) - for i := 0; i < 6; i += 2 { - bit := (uint(sum[i])<<8)&2047 + uint(sum[i+1]) - bloom[ethtypes.BloomByteLength-1-bit/8] |= byte(1 << (bit % 8)) - } + setBits(bloom, contractBits) + setBits(bloom, b.cache.signature) + setBits(bloom, bloomBitsFor(senderTopic)) + setBits(bloom, bloomBitsFor(receiverTopic)) } // addressFromKey takes the address out of an EVM key, which carries it after a one-byte prefix. A diff --git a/sei-db/bench/gigasim/receipt_bloom_test.go b/sei-db/bench/gigasim/receipt_bloom_test.go new file mode 100644 index 0000000000..19d6d5c8e1 --- /dev/null +++ b/sei-db/bench/gigasim/receipt_bloom_test.go @@ -0,0 +1,96 @@ +package gigasim + +import ( + "encoding/binary" + "math/bits" + "testing" + + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/sha3" +) + +// keccakBloomBits is how a real log bloom picks its bits, which these tests compare against. +func keccakBloomBits(value []byte) bloomBits { + hasher := sha3.NewLegacyKeccak256() + _, _ = hasher.Write(value) + sum := hasher.Sum(nil) + var picked bloomBits + for i := 0; i < 6; i += 2 { + picked[i/2] = (uint(sum[i])<<8)&2047 + uint(sum[i+1]) + } + return picked +} + +// bloomTestValue is a distinct value per seed. The seed is written in rather than folded into every +// byte, which wraps at 256 and would yield far fewer values than asked for. +func bloomTestValue(seed int) []byte { + value := make([]byte, hashLen) + binary.BigEndian.PutUint64(value, uint64(seed)) //nolint:gosec // seeds are small and non-negative + for i := 8; i < len(value); i++ { + value[i] = byte(i * 7) + } + return value +} + +// TestBloomBitsForFillsABloomLikeKeccakDoes pins the property the store is measured on: the bits +// differ from a real bloom's, but a corpus has to set as many of them as keccak would. +func TestBloomBitsForFillsABloomLikeKeccakDoes(t *testing.T) { + const values = 2048 + var mixedSet, keccakSet int + for seed := range values { + value := bloomTestValue(seed) + + var mixed, keccak ethtypes.Bloom + setBits(&mixed, bloomBitsFor(value)) + setBits(&keccak, keccakBloomBits(value)) + + mixedSet += countBloomBits(mixed) + keccakSet += countBloomBits(keccak) + } + // Three bits per value either way, less whatever collides; the collision rates have to agree. + require.InDelta(t, keccakSet, mixedSet, float64(keccakSet)*0.01, + "a mixed bloom must fill to the same density as a keccak one, or the corpus compresses differently") +} + +// TestBloomBitsForIsDeterministic pins that a rerun of the same seed produces the same corpus, which +// is what lets two runs be compared. +func TestBloomBitsForIsDeterministic(t *testing.T) { + for seed := range 64 { + value := bloomTestValue(seed) + require.Equal(t, bloomBitsFor(value), bloomBitsFor(value)) + } +} + +// TestBloomBitsForSeparatesValues pins that blooms do not collapse onto a few bit patterns, which +// would compress better than real ones and flatter the store. +func TestBloomBitsForSeparatesValues(t *testing.T) { + const values = 4096 + seen := make(map[bloomBits]struct{}, values) + for seed := range values { + seen[bloomBitsFor(bloomTestValue(seed))] = struct{}{} + } + require.Greater(t, len(seen), values*99/100, "distinct values must land on distinct bits") +} + +// TestReceiptCacheReturnsWhatItCached pins that a contract resolved once reads back the same. +func TestReceiptCacheReturnsWhatItCached(t *testing.T) { + cache := newReceiptCache() + address := make([]byte, keys.AddressLen) + for i := range address { + address[i] = byte(i) + } + first := cache.contract(address) + require.Equal(t, bytesToHex(address), first.hex) + require.Equal(t, bloomBitsFor(address), first.bits) + require.Equal(t, first, cache.contract(address), "the cached value must match the resolved one") +} + +func countBloomBits(bloom ethtypes.Bloom) int { + total := 0 + for _, b := range bloom { + total += bits.OnesCount8(b) + } + return total +} diff --git a/sei-db/bench/gigasim/receipt_test.go b/sei-db/bench/gigasim/receipt_test.go index 5e89ee5b22..d8c9c11fff 100644 --- a/sei-db/bench/gigasim/receipt_test.go +++ b/sei-db/bench/gigasim/receipt_test.go @@ -3,8 +3,10 @@ package gigasim import ( "testing" + "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" crand "github.com/sei-protocol/sei-chain/sei-db/common/rand" ) @@ -34,6 +36,30 @@ func TestSyntheticTxHashesAreUniqueAcrossPositions(t *testing.T) { } } +// TestBuiltRecordKeysOnItsOwnReceiptHash pins a record's key to the hash inside the receipt it +// carries. The store keys on TxHash, so a record keyed on anything else would hide its receipt. +func TestBuiltRecordKeysOnItsOwnReceiptHash(t *testing.T) { + t.Parallel() + + const count = 8 + buffer := newReceiptBuffer(count, newReceiptCache()) + rand := crand.NewCannedRandom(1<<20, 1337) + txn := &transaction{ + erc20Contract: make([]byte, 1+keys.AddressLen+hashLen), + srcAccount: make([]byte, 1+keys.AddressLen+hashLen), + dstAccount: make([]byte, 1+keys.AddressLen+hashLen), + } + + for index := range count { + require.NoError(t, buffer.build(index, rand, txn, 3)) + + record := buffer.records[index] + require.Equal(t, common.HexToHash(record.Receipt.TxHashHex), record.TxHash, + "the record's key must be the hash its own receipt reports") + require.NotEmpty(t, record.ReceiptBytes, "a record reaches the store already marshaled") + } +} + // A hash is recomputable from its position alone, which is what lets a run's transaction hashes be // derived rather than stored. func TestSyntheticTxHashDependsOnlyOnItsPosition(t *testing.T) { diff --git a/sei-db/bench/gigasim/receipt_writer.go b/sei-db/bench/gigasim/receipt_writer.go index 6f65709e0d..5ace22f9a9 100644 --- a/sei-db/bench/gigasim/receipt_writer.go +++ b/sei-db/bench/gigasim/receipt_writer.go @@ -3,12 +3,9 @@ package gigasim import ( "fmt" - "github.com/ethereum/go-ethereum/common" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" - "github.com/sei-protocol/sei-chain/sei-db/common/metrics" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" - evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) // receiptWriter persists a block's receipts through the production write path. It exists only when @@ -16,10 +13,6 @@ import ( type receiptWriter struct { store receipt.ReceiptStore - // Splits this writer's work into encoding the receipts and handing them to the store, subdividing - // the execution loop's write_receipts phase. Only that loop writes receipts, so one timer serves it. - phases *metrics.PhaseTimer - metrics *GigasimMetrics } @@ -27,7 +20,6 @@ type receiptWriter struct { func newReceiptWriter(store receipt.ReceiptStore, gigasimMetrics *GigasimMetrics) *receiptWriter { return &receiptWriter{ store: store, - phases: gigasimMetrics.NewReceiptWriteTimer(), metrics: gigasimMetrics, } } @@ -38,30 +30,7 @@ func newReceiptWriter(store receipt.ReceiptStore, gigasimMetrics *GigasimMetrics // skipping the call would leave the receipt head behind the ledger and the state for every setup // block — heights recovery takes the minimum of, so a run interrupted during setup over an existing // directory would roll state back to a height the ledger has passed and then refuse to reopen. -func (w *receiptWriter) writeBlock(number int64, receipts []*evmtypes.Receipt) error { - // Closes the phase in flight, so the gap until the next block's receipts is charged to neither. - defer w.phases.Reset() - - w.phases.SetPhase("encode") - var encodedBytes int64 - records := make([]receipt.ReceiptRecord, 0, len(receipts)) - for _, rcpt := range receipts { - // The store accepts pre-marshaled bytes, and marshaling here keeps the cost of producing them - // attributed to the benchmark rather than to the store. - encoded, err := rcpt.Marshal() - if err != nil { - return fmt.Errorf("failed to marshal the receipt for transaction %d of block %d: %w", - rcpt.TransactionIndex, number, err) - } - encodedBytes += int64(len(encoded)) - records = append(records, receipt.ReceiptRecord{ - TxHash: common.HexToHash(rcpt.TxHashHex), - Receipt: rcpt, - ReceiptBytes: encoded, - }) - } - - w.phases.SetPhase("store_write") +func (w *receiptWriter) writeBlock(number int64, records []receipt.ReceiptRecord, encodedBytes int64) error { if err := w.store.SetReceipts(sdk.NewContext(nil, tmproto.Header{Height: number}, false), records); err != nil { return fmt.Errorf("failed to write the receipts for block %d: %w", number, err) } diff --git a/sei-db/bootstrap/recovery_test.go b/sei-db/bootstrap/recovery_test.go index 4c10cc72e5..293b4b92d8 100644 --- a/sei-db/bootstrap/recovery_test.go +++ b/sei-db/bootstrap/recovery_test.go @@ -235,7 +235,11 @@ func TestRecoverStoresAtAZeroTargetLeavesReceiptsAlone(t *testing.T) { func TestFindTargetRecoveryHeightIsZeroWithoutABlockLedger(t *testing.T) { manager, _ := openManager(t, nil) commitBlocks(t, manager, 3) - require.NoError(t, manager.ReceiptDB().SetLatestVersion(3)) + // The version marker rides SetReceipts, so it is off the store's interface; this test stamps a + // head without bodies on purpose. + pinner, ok := manager.ReceiptDB().(receipt.VersionPinner) + require.True(t, ok) + require.NoError(t, pinner.SetLatestVersion(3)) // findTargetRecoveryHeight reads the state and receipt directories offline, so both stores have // to be closed for it. closeStateDB(t, manager) diff --git a/sei-db/config/giga_config.go b/sei-db/config/giga_config.go index dca0f6d107..b97a6bc3bf 100644 --- a/sei-db/config/giga_config.go +++ b/sei-db/config/giga_config.go @@ -44,6 +44,7 @@ func DefaultGigaStorageConfig(homePath string) (*GigaStorageConfig, error) { ssConfig := DefaultStateStoreConfig() ssConfig.EVMDBDirectory = utils.GetEVMStateStorePath(homePath, ssConfig.Backend) ssConfig.ExternalPruning = true + ssConfig.DisableInternalWAL = true receiptConfig := DefaultReceiptStoreConfig() receiptConfig.Backend = gigaReceiptBackend diff --git a/sei-db/config/receipt_config.go b/sei-db/config/receipt_config.go index f8ae9df29b..b0a7cf1d04 100644 --- a/sei-db/config/receipt_config.go +++ b/sei-db/config/receipt_config.go @@ -28,6 +28,10 @@ const ( // littidx eth_getLogs (see ReceiptStoreConfig.LogFilterParallelism). const DefaultReceiptLogFilterParallelism = 16 +// DefaultReceiptAsyncWriteBuffer is the default queue depth for receipt writes. It is small because +// the depth is also how far an unclean exit sets recovery back. +const DefaultReceiptAsyncWriteBuffer = 10 + // ReceiptStoreConfig defines configuration for the receipt store database. type ReceiptStoreConfig struct { // Enable reports whether the receipt store is opened. A node with it off keeps no receipt @@ -46,10 +50,15 @@ type ReceiptStoreConfig struct { // defaults to pebbledb Backend string `mapstructure:"rs-backend"` - // AsyncWriteBuffer defines the async queue length for commits to be applied to receipt store - // Applies only to the pebbledb backend. + // AsyncWriteBuffer defines the async queue length for commits to be applied to receipt store. + // It bounds how many blocks the store may fall behind the chain before a write blocks. + // + // Raising it costs more than memory. The queue is not on disk, so an unclean exit loses it and + // the store comes back that far behind, dragging recovery of every other store down with it; + // the EVM RPC head also trails by the queue's depth. Size it for the burst the writer absorbs. + // // Set <= 0 for synchronous writes. - // defaults to 100 + // defaults to 10 AsyncWriteBuffer int `mapstructure:"async-write-buffer"` // KeepRecent defines the number of versions to keep in receipt store. @@ -98,7 +107,7 @@ func DefaultReceiptStoreConfig() ReceiptStoreConfig { return ReceiptStoreConfig{ Enable: true, Backend: "pebbledb", - AsyncWriteBuffer: DefaultSSAsyncBuffer, + AsyncWriteBuffer: DefaultReceiptAsyncWriteBuffer, KeepRecent: 0, PruneIntervalSeconds: DefaultSSPruneInterval, LogFilterParallelism: DefaultReceiptLogFilterParallelism, diff --git a/sei-db/config/ss_config.go b/sei-db/config/ss_config.go index 0e371816f8..c7ab6cae64 100644 --- a/sei-db/config/ss_config.go +++ b/sei-db/config/ss_config.go @@ -38,6 +38,14 @@ type StateStoreConfig struct { // defaults to 100 AsyncWriteBuffer int `mapstructure:"async-write-buffer"` + // DisableInternalWAL stops the backend from keeping a changelog WAL of its own, so a commit is + // not held up by a log write. It is for an owner that already logs every block and replays that + // log into this store, as giga's StateDB does with its state WAL. + // + // Like ExternalPruning it is set by the code wiring the store into its owner rather than read + // from app.toml. Rollback through ss/composite replays the changelog, so that path must keep it. + DisableInternalWAL bool `mapstructure:"-"` + // KeepRecent defines the number of versions to keep in state store (shared by Cosmos and EVM). // Setting it to 0 means keep everything. // Default to keep the last 100,000 blocks diff --git a/sei-db/db_engine/pebbledb/mvcc/db.go b/sei-db/db_engine/pebbledb/mvcc/db.go index 7e079dc465..7d0bc44ec5 100644 --- a/sei-db/db_engine/pebbledb/mvcc/db.go +++ b/sei-db/db_engine/pebbledb/mvcc/db.go @@ -103,6 +103,9 @@ type Database struct { // Pending changes to be written to the DB pendingChanges chan VersionedChangesets + // Guards the one close of pendingChanges, so Close stays idempotent. + drainOnce sync.Once + // Reports pendingChanges from the writer's side: how full it was when a write needed room, and how // long writes waited when it had none. pendingChangesQueue *seidbmetrics.QueueMeter @@ -149,7 +152,7 @@ func newPebbleOptions(config config.StateStoreConfig, cache *pebble.Cache) *pebb FormatMajorVersion: pebble.FormatVirtualSSTables, L0CompactionThreshold: 2, L0StopWritesThreshold: 1000, - LBaseMaxBytes: 64 << 20, // 64 MB + LBaseMaxBytes: 64 << 20, // 64 MiB MemTableSize: 64 << 20, MemTableStopWritesThreshold: 4, // Let Pebble run several compactions in parallel so it can keep up with @@ -239,23 +242,27 @@ func OpenDB(dataDir string, config config.StateStoreConfig) (types.StateStore, e _ = db.Close() return nil, errors.New("KeepRecent must be non-negative") } - walKeepRecent := changelogKeepRecent(config) - // Snapshot rollback replays the changelog forward from the oldest retained - // snapshot, so count-based pruning must not cut inside that span. The - // snapshot manager prunes this changelog by snapshot version after every - // retention pass and is what actually holds it down; the count below is the - // ceiling for the states that pass does not cover — external snapshot - // pruning, and the stretch before enough snapshots exist to prune. Raising - // the ceiling is what a rollback window costs on disk: roughly one snapshot - // interval of changelog per retained snapshot. - streamHandler, err := wal.NewChangelogWAL(utils.GetChangelogPath(dataDir), wal.Config{ - KeepRecent: walKeepRecent, - PruneInterval: time.Duration(config.PruneIntervalSeconds) * time.Second, - }) - if err != nil { - return nil, err + // An owner that logs every block replays it into this store, leaving the changelog here written + // and never read. + if !config.DisableInternalWAL { + walKeepRecent := changelogKeepRecent(config) + // Snapshot rollback replays the changelog forward from the oldest retained + // snapshot, so count-based pruning must not cut inside that span. The + // snapshot manager prunes this changelog by snapshot version after every + // retention pass and is what actually holds it down; the count below is the + // ceiling for the states that pass does not cover — external snapshot + // pruning, and the stretch before enough snapshots exist to prune. Raising + // the ceiling is what a rollback window costs on disk: roughly one snapshot + // interval of changelog per retained snapshot. + streamHandler, err := wal.NewChangelogWAL(utils.GetChangelogPath(dataDir), wal.Config{ + KeepRecent: walKeepRecent, + PruneInterval: time.Duration(config.PruneIntervalSeconds) * time.Second, + }) + if err != nil { + return nil, err + } + database.streamHandler = streamHandler } - database.streamHandler = streamHandler database.asyncWriteWG.Add(1) go database.writeAsyncInBackground() @@ -395,12 +402,15 @@ func (db *Database) Close() error { db.metricsCancel() } - if db.streamHandler != nil { + // Owed whether or not a changelog is kept, the queued blocks being only in memory. The channel + // is left in place so a send after close still panics rather than blocking on a nil one. + db.drainOnce.Do(func() { // First, stop accepting new pending changes and drain the worker close(db.pendingChanges) // Wait for the async writes to finish db.asyncWriteWG.Wait() - // Now close the WAL stream + }) + if db.streamHandler != nil { _ = db.streamHandler.Close() db.streamHandler = nil } diff --git a/sei-db/ledger_db/receipt/litt_ctx_internal_test.go b/sei-db/ledger_db/receipt/litt_ctx_internal_test.go index 1f10a1341c..6e4cdbd467 100644 --- a/sei-db/ledger_db/receipt/litt_ctx_internal_test.go +++ b/sei-db/ledger_db/receipt/litt_ctx_internal_test.go @@ -77,6 +77,7 @@ func TestBlockLogsReturnsCanceledContextBeforeScanning(t *testing.T) { topic := common.HexToHash("0xdef1") txHash, rcpt := littCtxTestReceipt(1, 0, addr, topic, 1) require.NoError(t, s.SetReceipts(newTestCtxAtHeight(1), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}})) + requireReceiptVersion(t, s, 1) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -95,6 +96,7 @@ func TestCandidateBlockLogsReturnsCanceledContextBeforeTx(t *testing.T) { topic := common.HexToHash("0xdef2") txHash, rcpt := littCtxTestReceipt(2, 0, addr, topic, 1) require.NoError(t, s.SetReceipts(newTestCtxAtHeight(2), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}})) + requireReceiptVersion(t, s, 2) candidates, err := s.blockTagCandidates(2, criteriaTagGroups(filters.FilterCriteria{})) require.NoError(t, err) @@ -121,6 +123,7 @@ func TestCandidateBlockLogsCancelsMidLoopOverLogs(t *testing.T) { topic := common.HexToHash("0xdef3") txHash, rcpt := littCtxTestReceipt(3, 0, addr, topic, 5) require.NoError(t, s.SetReceipts(newTestCtxAtHeight(3), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}})) + requireReceiptVersion(t, s, 3) candidates, err := s.blockTagCandidates(3, criteriaTagGroups(filters.FilterCriteria{})) require.NoError(t, err) @@ -147,6 +150,7 @@ func TestCandidateBlockLogsTripsBudgetMidLoopOverLogs(t *testing.T) { topic := common.HexToHash("0xdef4") txHash, rcpt := littCtxTestReceipt(4, 0, addr, topic, 2) require.NoError(t, s.SetReceipts(newTestCtxAtHeight(4), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}})) + requireReceiptVersion(t, s, 4) candidates, err := s.blockTagCandidates(4, criteriaTagGroups(filters.FilterCriteria{})) require.NoError(t, err) @@ -173,6 +177,7 @@ func TestFilterLogsByTagsPreCanceledContextReturnsEmptyFast(t *testing.T) { for block := uint64(1); block <= 5; block++ { txHash, rcpt := littCtxTestReceipt(block, 0, addr, topic, 1) require.NoError(t, s.SetReceipts(newTestCtxAtHeight(block), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}})) + requireReceiptVersion(t, s, int64(block)) } ctx, cancel := context.WithCancel(context.Background()) @@ -196,6 +201,7 @@ func TestFilterLogsThreadsSDKContext(t *testing.T) { topic := common.HexToHash("0xdef6") txHash, rcpt := littCtxTestReceipt(6, 0, addr, topic, 1) require.NoError(t, s.SetReceipts(newTestCtxAtHeight(6), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}})) + requireReceiptVersion(t, s, 6) crit := filters.FilterCriteria{Addresses: []common.Address{addr}} diff --git a/sei-db/ledger_db/receipt/litt_receipt_store.go b/sei-db/ledger_db/receipt/litt_receipt_store.go index a645587369..9155b47e58 100644 --- a/sei-db/ledger_db/receipt/litt_receipt_store.go +++ b/sei-db/ledger_db/receipt/litt_receipt_store.go @@ -62,6 +62,9 @@ import ( // - unset: the background pruner below keeps the last KeepRecent blocks. // - set: the StorageGarbageCollector prunes through the gc.PrunableStore // implementation in litt_receipt_gc.go, and startPruning stands down. +// +// Writes are applied in the background, so a receipt is not necessarily readable when SetReceipts +// returns. LatestVersion is the watermark of what has been applied; Close waits for the queue. type littReceiptStore struct { values litt.DB receipts litt.Table @@ -79,12 +82,33 @@ type littReceiptStore struct { backgroundWg sync.WaitGroup closeOnce sync.Once - // Breaks a write into its stages. The receipt bodies go to litt asynchronously while the log index - // is committed inline, so which of the two a slow write is in is not otherwise visible. Only the - // commit path writes, so one timer serves the store. + // Breaks a write into its stages. Only the writer goroutine records, so one timer serves the store. writePhases *seidbmetrics.PhaseTimer + + // Receipt writes waiting to be applied, and the meter for time spent waiting on a full queue. A + // whole write is queued, so the depth is the receipt write's own. Nil means writes apply inline. + writes chan receiptWrite + + // Orders admitting a write against shutting the writer down, so none is accepted into a queue + // that will not be drained. queueWrite holds it shared; Close takes it exclusively. + admission sync.RWMutex + closing bool + + writeQueue *seidbmetrics.QueueMeter + writeErr atomic.Pointer[error] + stopSampling context.CancelFunc +} + +// receiptWrite is one block's receipts, waiting to be applied. +type receiptWrite struct { + height int64 + receipts []ReceiptRecord } +// writeQueueSampleIntervalSeconds is how often the write queue's depth is read. Sampling on a timer +// rather than at each send keeps the reading unbiased by the send rate. +const writeQueueSampleIntervalSeconds = 1 + var _ ReceiptStore = (*littReceiptStore)(nil) var ( @@ -213,7 +237,19 @@ func newLittReceiptStore(cfg dbconfig.ReceiptStoreConfig, storeKey sdk.StoreKey) return nil, fmt.Errorf("failed to open receipt log index: %w", err) } s.index = index - s.writePhases = seidbmetrics.NewPhaseTimer(otel.Meter("seidb_receipt"), "receipt_store_write") + + receiptMeter := otel.Meter("seidb_receipt") + s.writePhases = seidbmetrics.NewPhaseTimer(receiptMeter, "receipt_store_write") + if cfg.AsyncWriteBuffer > 0 { + s.writes = make(chan receiptWrite, cfg.AsyncWriteBuffer) + s.writeQueue = seidbmetrics.NewQueueMeter(receiptMeter, "receipt_write") + s.startWriter() + + samplingCtx, stopSampling := context.WithCancel(context.Background()) + s.stopSampling = stopSampling + s.writeQueue.SampleDepth(samplingCtx, writeQueueSampleIntervalSeconds, + func() int { return len(s.writes) }) + } s.latestVersion.Store(s.readMeta(receiptLatestVersionKey)) s.earliestVersion.Store(s.readMeta(receiptEarliestVersionKey)) @@ -301,17 +337,47 @@ func (s *littReceiptStore) belowRetentionFloor(blockNumber uint64) bool { return earliest > 0 && blockNumber < uint64(earliest) //nolint:gosec // earliest is non-negative } +// SetReceipts hands the block's receipts to the writer, blocking only when the queue is full, or +// applies them inline when AsyncWriteBuffer is off. Once a queued write has failed it takes no +// further block and returns that failure. func (s *littReceiptStore) SetReceipts(ctx sdk.Context, receipts []ReceiptRecord) error { + if s.writes == nil { + return s.applyReceipts(ctx.BlockHeight(), receipts) + } + if err := s.writeFailure(); err != nil { + return err + } + return s.queueWrite(receiptWrite{height: ctx.BlockHeight(), receipts: receipts}) +} + +// ErrStoreClosed is returned by a write the store can no longer apply, the writer having stopped. +var ErrStoreClosed = errors.New("receipt store is closed") + +// queueWrite hands a write to the writer, waiting for room when the queue is full and refusing once +// the store is closing. +func (s *littReceiptStore) queueWrite(write receiptWrite) error { + // Held across the send, not merely to read the flag: Close takes it exclusively before stopping + // the writer, so a write admitted here always reaches a writer that is still running. + s.admission.RLock() + defer s.admission.RUnlock() + if s.closing { + return ErrStoreClosed + } + seidbmetrics.Send(s.writeQueue, s.writes, write) + return nil +} + +// applyReceipts writes a block's receipt bodies, log index and version marker. The bodies go to +// litt first, so an indexed block always has its values written. +func (s *littReceiptStore) applyReceipts(height int64, receipts []ReceiptRecord) error { blockNumbers, receiptsByBlock := groupReceiptRecordsByBlock(receipts) if len(blockNumbers) == 0 { - return s.SetLatestVersion(ctx.BlockHeight()) + return s.SetLatestVersion(height) } // Closes the stage in flight, so the gap until the next write is charged to neither. defer s.writePhases.Reset() - // Receipt values go to litt first; the index batch (tag keys + version - // meta) commits after, so an indexed block always has its values written. batch := s.index.NewBatch() defer func() { _ = batch.Close() }() @@ -421,6 +487,53 @@ func (s *littReceiptStore) FilterLogs(ctx sdk.Context, fromBlock, toBlock uint64 return s.filterLogsByTags(reqCtx, fromBlock, toBlock, crit, budget) } +// startWriter applies queued receipt writes in the order they were enqueued, until the store closes. +// It drains what it holds before returning, so a clean shutdown applies them all and an unclean exit +// loses the queue. +func (s *littReceiptStore) startWriter() { + s.backgroundWg.Add(1) + go func() { + defer s.backgroundWg.Done() + for { + select { + case write := <-s.writes: + s.applyWrite(write) + case <-s.stopBackground: + for { + select { + case write := <-s.writes: + s.applyWrite(write) + default: + return + } + } + } + } + }() +} + +// applyWrite performs one queued write, keeping the first failure for its callers to collect. +// Nothing is applied after a failure: a later block carries its own version marker and would publish +// a head above one whose receipts were never written. +func (s *littReceiptStore) applyWrite(write receiptWrite) { + if s.writeFailure() != nil { + return + } + if err := s.applyReceipts(write.height, write.receipts); err != nil { + logger.Error("failed to write receipts", "height", write.height, "err", err) + s.writeErr.CompareAndSwap(nil, &err) + } +} + +// writeFailure returns the first failure a queued write hit. It latches, so every later caller sees +// it rather than the first to ask consuming it. +func (s *littReceiptStore) writeFailure() error { + if err := s.writeErr.Load(); err != nil { + return *err + } + return nil +} + // startFlusher bounds litt durability lag to littFlushInterval from a // background goroutine so block commit never waits on an fsync. func (s *littReceiptStore) startFlusher() { @@ -445,10 +558,23 @@ func (s *littReceiptStore) startFlusher() { func (s *littReceiptStore) Close() error { var err error s.closeOnce.Do(func() { + // Exclusive and before the writer stops, so it takes effect only once the writes already + // admitted have been handed over. + s.admission.Lock() + s.closing = true + s.admission.Unlock() + + if s.stopSampling != nil { + s.stopSampling() + } close(s.stopBackground) + // The writer drains what it holds before returning, so this is where queued writes land. s.backgroundWg.Wait() + err = s.writeFailure() // litt's Close flushes, so the last sub-interval of writes is durable. - err = s.values.Close() + if valuesErr := s.values.Close(); err == nil { + err = valuesErr + } if indexErr := s.index.Close(); err == nil { err = indexErr } diff --git a/sei-db/ledger_db/receipt/litt_write_failure_internal_test.go b/sei-db/ledger_db/receipt/litt_write_failure_internal_test.go new file mode 100644 index 0000000000..e53242cd06 --- /dev/null +++ b/sei-db/ledger_db/receipt/litt_write_failure_internal_test.go @@ -0,0 +1,180 @@ +package receipt + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + dbtypes "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" + "github.com/stretchr/testify/require" +) + +var errIndexCommit = errors.New("injected index commit failure") + +// failingIndex is the store's log index with its batch commits made to fail on demand. Holding a +// commit open is what lets a test queue a block behind the one that is failing. +type failingIndex struct { + dbtypes.KeyValueDB + failing atomic.Bool + entered chan struct{} // closed once a failing commit has been reached + enteredOnce sync.Once // more than one commit may fail, and entered closes for the first + release chan struct{} // closed to let that commit return its error +} + +func (f *failingIndex) NewBatch() dbtypes.Batch { + return &failingBatch{Batch: f.KeyValueDB.NewBatch(), index: f} +} + +type failingBatch struct { + dbtypes.Batch + index *failingIndex +} + +func (b *failingBatch) Commit(opts dbtypes.WriteOptions) error { + if b.index.failing.Load() { + b.index.enteredOnce.Do(func() { close(b.index.entered) }) + <-b.index.release + return errIndexCommit + } + return b.Batch.Commit(opts) +} + +// TestWriteFailureHoldsTheHeadAgainstAQueuedBlock covers what a failed write owes the blocks queued +// behind it: applying one would publish a head above the block that never landed. The follower is +// queued while the failing commit is held, since SetReceipts refuses blocks once the failure shows. +func TestWriteFailureHoldsTheHeadAgainstAQueuedBlock(t *testing.T) { + s, closeStore := setupLittCtxStore(t) + defer closeStore() + + addr := common.HexToAddress("0xfa11") + topic := common.HexToHash("0xfa12") + + index := &failingIndex{ + KeyValueDB: s.index, + entered: make(chan struct{}), + release: make(chan struct{}), + } + s.index = index + + // Block 1 lands, so there is a real head for the failure to hold. + writeOneReceipt(t, s, 1, addr, topic) + requireReceiptVersion(t, s, 1) + + // Block 2 reaches its commit and stops there, still holding the writer. + index.failing.Store(true) + writeOneReceipt(t, s, 2, addr, topic) + <-index.entered + + // Block 3 would commit cleanly and carry a marker naming it the head. Queued now, while block 2 + // is mid-commit, it is past the refusal in SetReceipts and only the writer can hold it back. + index.failing.Store(false) + writeOneReceipt(t, s, 3, addr, topic) + + close(index.release) + + // Close drains, so the writer has decided about block 3 by the time this returns. + require.ErrorIs(t, s.Close(), errIndexCommit) + require.Equal(t, int64(1), s.LatestVersion(), + "the head must not move past a block whose receipts were never written") +} + +// TestWriteFailureLatches covers the failure reaching every later caller rather than only the first +// to ask. +func TestWriteFailureLatches(t *testing.T) { + s, closeStore := setupLittCtxStore(t) + defer closeStore() + + addr := common.HexToAddress("0xfa21") + topic := common.HexToHash("0xfa22") + + index := &failingIndex{ + KeyValueDB: s.index, + entered: make(chan struct{}), + release: make(chan struct{}), + } + s.index = index + close(index.release) + + index.failing.Store(true) + writeOneReceipt(t, s, 1, addr, topic) + require.Eventually(t, func() bool { return s.writeFailure() != nil }, 5*time.Second, time.Millisecond) + + txHash, rcpt := littCtxTestReceipt(2, 0, addr, topic, 1) + require.ErrorIs(t, s.SetReceipts(newTestCtxAtHeight(2), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}}), + errIndexCommit, "a commit after a failed write must be refused rather than queued") + require.ErrorIs(t, s.writeFailure(), errIndexCommit, "reading the failure must not consume it") + require.ErrorIs(t, s.Close(), errIndexCommit, "Close must report it too") +} + +// TestWriteAfterCloseIsRefused covers a commit arriving after shutdown, which the writer is no +// longer there to apply. +func TestWriteAfterCloseIsRefused(t *testing.T) { + s, _ := setupLittCtxStore(t) + require.NoError(t, s.Close()) + + txHash, rcpt := littCtxTestReceipt(1, 0, common.HexToAddress("0xfa31"), common.HexToHash("0xfa32"), 1) + err := s.SetReceipts(newTestCtxAtHeight(1), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}}) + require.ErrorIs(t, err, ErrStoreClosed) +} + +// TestWriteAfterCloseIsRefusedWithAFullQueue is the same refusal with no room left to send into, +// which would otherwise block forever. +func TestWriteAfterCloseIsRefusedWithAFullQueue(t *testing.T) { + s, _ := setupLittCtxStore(t) + require.NoError(t, s.Close()) + + // Leftovers with no writer behind them: the send has nowhere to go and nobody to take it. + for len(s.writes) < cap(s.writes) { + s.writes <- receiptWrite{height: 1} + } + + txHash, rcpt := littCtxTestReceipt(1, 0, common.HexToAddress("0xfa41"), common.HexToHash("0xfa42"), 1) + done := make(chan error, 1) + go func() { + done <- s.SetReceipts(newTestCtxAtHeight(1), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}}) + }() + select { + case err := <-done: + require.ErrorIs(t, err, ErrStoreClosed) + case <-time.After(5 * time.Second): + t.Fatal("a write into a full queue on a closed store never returned") + } +} + +// TestWriteRacingCloseIsEitherAppliedOrRefused covers a write admitted while Close is running, +// which the two tests above cannot reach. A write reporting success must have been applied. +func TestWriteRacingCloseIsEitherAppliedOrRefused(t *testing.T) { + for attempt := range 50 { + s, _ := setupLittCtxStore(t) + + addr := common.HexToAddress("0xfa51") + topic := common.HexToHash("0xfa52") + txHash, rcpt := littCtxTestReceipt(1, 0, addr, topic, 1) + + started := make(chan struct{}) + result := make(chan error, 1) + go func() { + close(started) + result <- s.SetReceipts(newTestCtxAtHeight(1), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}}) + }() + <-started + require.NoError(t, s.Close()) + + if err := <-result; err != nil { + require.ErrorIs(t, err, ErrStoreClosed, "attempt %d", attempt) + continue + } + // Accepted, so the writer must have applied it before Close let the writer go. + require.Equal(t, int64(1), s.LatestVersion(), + "attempt %d: a write that reported success must have been applied", attempt) + } +} + +func writeOneReceipt(t *testing.T, s *littReceiptStore, block uint64, addr common.Address, topic common.Hash) { + t.Helper() + txHash, rcpt := littCtxTestReceipt(block, 0, addr, topic, 1) + require.NoError(t, s.SetReceipts(newTestCtxAtHeight(block), []ReceiptRecord{{TxHash: txHash, Receipt: rcpt}})) +} diff --git a/sei-db/ledger_db/receipt/littidx_test.go b/sei-db/ledger_db/receipt/littidx_test.go index d4f5fa23ad..3d123159fb 100644 --- a/sei-db/ledger_db/receipt/littidx_test.go +++ b/sei-db/ledger_db/receipt/littidx_test.go @@ -2,9 +2,12 @@ package receipt_test import ( "fmt" + "slices" "testing" + "time" "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/eth/filters" storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" "github.com/sei-protocol/sei-chain/sei-cosmos/testutil" @@ -15,6 +18,62 @@ import ( "github.com/stretchr/testify/require" ) +// TestLittIdxSynchronousWriteBuffer pins the AsyncWriteBuffer <= 0 case: the write is applied on the +// caller, so the block is queryable the moment SetReceipts returns. +func TestLittIdxSynchronousWriteBuffer(t *testing.T) { + storeKey := storetypes.NewKVStoreKey("evm") + tkey := storetypes.NewTransientStoreKey("evm_transient") + ctx := testutil.DefaultContext(storeKey, tkey).WithBlockHeight(1) + cfg := dbconfig.DefaultReceiptStoreConfig() + cfg.Backend = "littidx" + cfg.DBDirectory = t.TempDir() + cfg.KeepRecent = 0 + cfg.AsyncWriteBuffer = 0 + + store, err := receipt.NewReceiptStore(cfg, storeKey) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + + addr := common.HexToAddress("0xabc") + record := litReceipt(1, 0, addr, common.HexToHash("0xdead")) + require.NoError(t, store.SetReceipts(ctx, []receipt.ReceiptRecord{record})) + + require.Equal(t, int64(1), store.LatestVersion()) + got, err := store.GetReceipt(ctx, record.TxHash) + require.NoError(t, err) + require.Equal(t, record.Receipt.TxHashHex, got.TxHashHex) +} + +// TestLittIdxWriteBufferBoundsLag pins that the buffer is the back-pressure point: with room for one +// block, a writer cannot get further than the buffer ahead of what has been applied. +func TestLittIdxWriteBufferBoundsLag(t *testing.T) { + storeKey := storetypes.NewKVStoreKey("evm") + tkey := storetypes.NewTransientStoreKey("evm_transient") + ctx := testutil.DefaultContext(storeKey, tkey).WithBlockHeight(1) + cfg := dbconfig.DefaultReceiptStoreConfig() + cfg.Backend = "littidx" + cfg.DBDirectory = t.TempDir() + cfg.KeepRecent = 0 + cfg.AsyncWriteBuffer = 1 + + store, err := receipt.NewReceiptStore(cfg, storeKey) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + + addr := common.HexToAddress("0xabc") + const blocks = 8 + for block := uint64(1); block <= blocks; block++ { + record := litReceipt(block, 0, addr, common.HexToHash("0xdead")) + require.NoError(t, store.SetReceipts(ctx.WithBlockHeight(int64(block)), //nolint:gosec // small test heights + []receipt.ReceiptRecord{record})) + // One queued block plus the one in flight is as far as the store may trail. + require.GreaterOrEqual(t, store.LatestVersion(), int64(block)-2) //nolint:gosec // small test heights + } + + require.Eventually(t, func() bool { return store.LatestVersion() == blocks }, + 5*time.Second, time.Millisecond) +} + func setupLittIdx(t *testing.T, dir string) (receipt.ReceiptStore, sdk.Context) { t.Helper() return setupLittIdxPar(t, dir, dbconfig.DefaultReceiptLogFilterParallelism) @@ -64,6 +123,21 @@ func litReceipt(block uint64, txIndex uint32, addr common.Address, topics ...com func writeLitBlock(t *testing.T, store receipt.ReceiptStore, ctx sdk.Context, block uint64, records ...receipt.ReceiptRecord) { t.Helper() require.NoError(t, store.SetReceipts(ctx.WithBlockHeight(int64(block)), records)) //nolint:gosec // small test heights + if len(records) == 0 { + return + } + // A write puts its bodies in litt before it commits its log index, so a readable receipt does not + // mean a queryable one. LatestVersion does not close that gap either: a block written in parts + // does not advance it past the first part. Waiting for the last record's log covers both stages. + last := records[len(records)-1].TxHash + require.Eventually(t, func() bool { + //nolint:gosec // small test heights + logs, err := store.FilterLogs(ctx, block, block, filters.FilterCriteria{}, nil) + if err != nil { + return false + } + return slices.ContainsFunc(logs, func(l *ethtypes.Log) bool { return l.TxHash == last }) + }, 5*time.Second, time.Millisecond) } func TestLittIdxReadWrite(t *testing.T) { diff --git a/sei-db/ledger_db/receipt/offline_internal_test.go b/sei-db/ledger_db/receipt/offline_internal_test.go index ac030667ca..2fd51092e9 100644 --- a/sei-db/ledger_db/receipt/offline_internal_test.go +++ b/sei-db/ledger_db/receipt/offline_internal_test.go @@ -36,6 +36,7 @@ func writeLittIdxReceipts(t *testing.T, dir string, blocks uint64) { []common.Hash{topic})} //nolint:gosec // small test heights require.NoError(t, store.SetReceipts(ctx.WithBlockHeight(int64(block)), []ReceiptRecord{record})) + requireReceiptVersion(t, store, int64(block)) //nolint:gosec // small test heights } require.NoError(t, store.Close()) } diff --git a/sei-db/ledger_db/receipt/receipt_bench_read_test.go b/sei-db/ledger_db/receipt/receipt_bench_read_test.go index 0605370167..4d9e002011 100644 --- a/sei-db/ledger_db/receipt/receipt_bench_read_test.go +++ b/sei-db/ledger_db/receipt/receipt_bench_read_test.go @@ -6,6 +6,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -271,6 +272,10 @@ func setupReadBenchmark(b *testing.B, backend string, blocks, receiptsPerBlock, if err := store.SetReceipts(ctx.WithBlockHeight(int64(blockNumber)), batch); err != nil { b.Fatalf("failed to write block %d: %v", blockNumber, err) } + // Seeding outruns the writer, so wait for the block to be published before the next one. + for store.LatestVersion() < int64(blockNumber) { //nolint:gosec // small test heights + time.Sleep(time.Millisecond) + } seed += uint64(receiptsPerBlock) if (block+1)%logInterval == 0 { diff --git a/sei-db/ledger_db/receipt/receipt_store.go b/sei-db/ledger_db/receipt/receipt_store.go index eea5219070..2a713c9881 100644 --- a/sei-db/ledger_db/receipt/receipt_store.go +++ b/sei-db/ledger_db/receipt/receipt_store.go @@ -51,12 +51,14 @@ func NewTooManyLogBytesError(maxBytes int64) error { type ReceiptStore interface { controller.PrunableStore + // LatestVersion is the highest block whose receipts are queryable. A write may land after + // SetReceipts returns, so a reader follows this rather than the height it last wrote. LatestVersion() int64 EarliestVersion() int64 - SetLatestVersion(version int64) error - SetEarliestVersion(version int64) error GetReceipt(ctx sdk.Context, txHash common.Hash) (*types.Receipt, error) GetReceiptFromStore(ctx sdk.Context, txHash common.Hash) (*types.Receipt, error) + // SetReceipts writes the block's receipts, carrying the version markers with them. An + // implementation may apply the write in the background; LatestVersion reports when it lands. SetReceipts(ctx sdk.Context, receipts []ReceiptRecord) error // FilterLogs queries logs across a range of blocks. // For single-block queries, set fromBlock == toBlock. @@ -68,6 +70,26 @@ type ReceiptStore interface { Close() error } +// VersionPinner is implemented by receipt stores whose version markers can be written directly. It +// is for a caller that put receipts in place by other means and has to state the window they cover. +type VersionPinner interface { + SetLatestVersion(version int64) error + SetEarliestVersion(version int64) error +} + +// PinVersions widens store's queryable window to [earliest, latest], reporting a store that cannot +// be pinned rather than leaving the window unset. +func PinVersions(store ReceiptStore, earliest, latest int64) error { + pinner, ok := store.(VersionPinner) + if !ok { + return fmt.Errorf("receipt store %T cannot pin versions", store) + } + if err := pinner.SetLatestVersion(latest); err != nil { + return err + } + return pinner.SetEarliestVersion(earliest) +} + type ReceiptRecord struct { TxHash common.Hash Receipt *types.Receipt diff --git a/sei-db/ledger_db/receipt/receipt_store_test.go b/sei-db/ledger_db/receipt/receipt_store_test.go index 28461dbed8..7d51966d53 100644 --- a/sei-db/ledger_db/receipt/receipt_store_test.go +++ b/sei-db/ledger_db/receipt/receipt_store_test.go @@ -93,6 +93,7 @@ func TestSetReceiptsAndGet(t *testing.T) { {TxHash: txHash}, }) require.NoError(t, err) + require.Eventually(t, func() bool { return store.LatestVersion() >= 1 }, 5*time.Second, time.Millisecond) got, err := store.GetReceipt(ctx, txHash) require.NoError(t, err) @@ -106,9 +107,10 @@ func TestSetReceiptsAndGet(t *testing.T) { require.Error(t, err) require.GreaterOrEqual(t, store.LatestVersion(), int64(1)) - require.NoError(t, store.SetLatestVersion(10)) + + // The version markers ride SetReceipts, so they are off the store's interface. + require.NoError(t, receipt.PinVersions(store, 1, 10)) require.Equal(t, int64(10), store.LatestVersion()) - require.NoError(t, store.SetEarliestVersion(1)) require.Equal(t, int64(1), store.EarliestVersion()) } diff --git a/sei-db/ledger_db/receipt/test_helpers_test.go b/sei-db/ledger_db/receipt/test_helpers_test.go index bbb8df60c8..09c1c89e18 100644 --- a/sei-db/ledger_db/receipt/test_helpers_test.go +++ b/sei-db/ledger_db/receipt/test_helpers_test.go @@ -1,13 +1,25 @@ package receipt import ( + "testing" + "time" + "github.com/ethereum/go-ethereum/common" storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" "github.com/sei-protocol/sei-chain/sei-cosmos/testutil" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/x/evm/types" + "github.com/stretchr/testify/require" ) +// requireReceiptVersion waits for the store to publish height. A write may be applied after +// SetReceipts returns, and LatestVersion is how a reader learns that it landed. +func requireReceiptVersion(t *testing.T, store ReceiptStore, height int64) { + t.Helper() + require.Eventually(t, func() bool { return store.LatestVersion() >= height }, + 5*time.Second, time.Millisecond) +} + func newTestContext() (sdk.Context, storetypes.StoreKey) { storeKey := storetypes.NewKVStoreKey("evm") tkey := storetypes.NewTransientStoreKey("evm_transient") diff --git a/sei-db/state_db/giga/state_db.go b/sei-db/state_db/giga/state_db.go index 8d7c224e35..036eb4e7d7 100644 --- a/sei-db/state_db/giga/state_db.go +++ b/sei-db/state_db/giga/state_db.go @@ -77,7 +77,7 @@ func NewStateDB( ) (db *StateDB, retErr error) { s := &StateDB{ flatkvCfg: flatkvCfg, - ssCfg: ssCfg, + ssCfg: stateStoreConfigFor(ssCfg), commitPhases: metrics.NewPhaseTimerFactory(otel.Meter(gigaMeterName), commitPhaseTimerName). RecordLatencies().Build(), } @@ -108,6 +108,14 @@ func NewStateDB( return s, nil } +// stateStoreConfigFor is the config a StateDB opens SS with. It is settled here rather than at each +// open because the rollback path opens the same databases through DiscardStateAbove. The changelog +// is off: this StateDB's own state WAL is what catchUpTo replays into SS. +func stateStoreConfigFor(cfg config.StateStoreConfig) config.StateStoreConfig { + cfg.DisableInternalWAL = true + return cfg +} + // NewStateDBWithRollback rolls SC, SS and the state WAL back to target and then opens them, so the // returned StateDB commits target+1. It cuts the WAL's tail to target and puts whichever of SC and SS // sits above target on its newest snapshot at or below it, all while the stores are closed, then opens @@ -131,7 +139,7 @@ func NewStateDBWithRollback( } // rewindTo only moves files, so it needs no store open, only where they live. - offline := &StateDB{flatkvCfg: flatkvCfg, ssCfg: ssCfg} + offline := &StateDB{flatkvCfg: flatkvCfg, ssCfg: stateStoreConfigFor(ssCfg)} if err := offline.rewindTo(target); err != nil { return nil, err } 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 f1861346c9..cfd41c9e8c 100644 --- a/sei-db/state_db/giga/state_db_replay_test.go +++ b/sei-db/state_db/giga/state_db_replay_test.go @@ -13,6 +13,57 @@ import ( "github.com/stretchr/testify/require" ) +// SS keeps no changelog of its own under giga, the state WAL being what catchUpTo replays into it. +// The absence is pinned here rather than left to the config, since recovery rests on it. +func TestGigaOpensSSWithoutAChangelog(t *testing.T) { + newStateDB := func(t *testing.T) *StateDB { + t.Helper() + ssCfg := config.DefaultStateStoreConfig() + ssCfg.Enable = true + ssCfg.EVMDBDirectory = filepath.Join(t.TempDir(), "ss") + return &StateDB{ + flatkvCfg: flatkvconfig.DefaultTestConfig(t), + // As the constructors settle it, which is what makes both paths below agree. + ssCfg: stateStoreConfigFor(ssCfg), + } + } + + t.Run("opened to commit", func(t *testing.T) { + s := newStateDB(t) + require.NoError(t, s.openSS()) + t.Cleanup(func() { _ = s.ss.Close() }) + requireNoSSChangelog(t, s.ssCfg.EVMDBDirectory) + }) + + // The rollback path opens the same databases through DiscardStateAbove rather than openSS, so a + // config settled per-open would miss it. StoredVersions opens nothing when the directory is + // absent, so the store has to exist first. + t.Run("opened to roll back", func(t *testing.T) { + s := newStateDB(t) + require.NoError(t, s.openSS()) + require.NoError(t, s.ss.Close()) + + require.NoError(t, s.discardStateAbove(storedWALRange{first: 1, last: 9}, 7)) + requireNoSSChangelog(t, s.ssCfg.EVMDBDirectory) + }) +} + +// TestStateStoreConfigForDisablesTheInternalWAL pins what the constructors apply, every path that +// opens SS reading the config they settled rather than disabling the log for itself. +func TestStateStoreConfigForDisablesTheInternalWAL(t *testing.T) { + handedIn := config.DefaultStateStoreConfig() + require.False(t, handedIn.DisableInternalWAL, "a caller is not expected to have set it") + require.True(t, stateStoreConfigFor(handedIn).DisableInternalWAL) +} + +func requireNoSSChangelog(t *testing.T, evmDBDirectory string) { + t.Helper() + changelog := utils.GetChangelogPath(evmDBDirectory) + _, err := os.Stat(changelog) + require.True(t, os.IsNotExist(err), + "SS must keep no changelog under giga; found one at %s", changelog) +} + // A node that keeps no EVM state store never reaches it, so nothing probes a store it does not have. // The directory is one an earlier run with SS on could have left, and the WAL reaches block 1, so a // rollback that read it would come back with a rewind to run. From 0a477e8588a80e01c721f340ea95ff2c9057770f Mon Sep 17 00:00:00 2001 From: masih Date: Tue, 15 Sep 2026 14:30:35 +0000 Subject: [PATCH 4/7] Keep lane parent hash across pruning in ProduceLocalBlock --- .../internal/autobahn/avail/inner.go | 32 +++++++++++++++++-- .../internal/autobahn/avail/inner_test.go | 4 +-- .../internal/autobahn/avail/state.go | 6 +--- .../internal/autobahn/avail/state_test.go | 29 +++++++++++++---- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 7e819e3ff2..d078251389 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -10,6 +10,32 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// blockQueue is a lane's queue of LaneProposals which additionally remembers +// the hash of the last block pushed, so that the parent hash of the next +// block is known even after the queue has been pruned. +type blockQueue struct { + queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] + lastHash utils.Option[types.BlockHeaderHash] +} + +func newBlockQueue() *blockQueue { + return &blockQueue{queue: *newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]()} +} + +func (q *blockQueue) pushBack(p *types.Signed[*types.LaneProposal]) { + q.queue.pushBack(p) + q.lastHash = utils.Some(p.Msg().Block().Header().Hash()) +} + +// parentHash returns the hash the next block of the lane should point to: +// the zero hash if no block has been pushed to the queue since construction. +func (q *blockQueue) parentHash() types.BlockHeaderHash { + if h, ok := q.lastHash.Get(); ok { + return h + } + return types.BlockHeaderHash{} +} + // inner holds roads and per-LaneID block/vote maps. type inner struct { persistedCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] // latest persisted CommitQC @@ -25,7 +51,7 @@ type inner struct { // When it lags applied, epochForVote falls back to this committee for // departing-lane voters. anchorEpoch utils.Option[*types.Epoch] - blocks map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] + blocks map[types.LaneID]*blockQueue votes map[types.LaneID]*queue[types.BlockNumber, *blockVotes] // nextBlockToPersist tracks per-lane how far block persistence has progressed. // RecvBatch only yields blocks below this cursor for voting. @@ -61,7 +87,7 @@ func newInner(ep *types.Epoch, first types.RoadIndex) *inner { persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep}), roads: roads, - blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, + blocks: map[types.LaneID]*blockQueue{}, votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, } @@ -192,7 +218,7 @@ func (i *inner) addLane(lane types.LaneID) bool { if _, ok := i.blocks[lane]; ok { return false } - i.blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() + i.blocks[lane] = newBlockQueue() i.votes[lane] = newQueue[types.BlockNumber, *blockVotes]() i.nextBlockToPersist[lane] = 0 return true diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index a55c3d4b37..af113f8132 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -218,7 +218,7 @@ func TestAddLane_ReportsNewLaneForEachMembershipPeriod(t *testing.T) { a := types.GenSecretKey(rng) i := &inner{ - blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, + blocks: map[types.LaneID]*blockQueue{}, votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, } @@ -249,7 +249,7 @@ func TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied(t *testing.T persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep0}), roads: newQueue[types.RoadIndex, *road](), - blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, + blocks: map[types.LaneID]*blockQueue{}, votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 102c3c611d..7f3b8f41e7 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -662,11 +662,7 @@ func (s *State) ProduceLocalBlock(lane types.LaneID, n types.BlockNumber, payloa if q.next != n { return nil, fmt.Errorf("unexpected block number: got %v, want %v", n, q.next) } - var parent types.BlockHeaderHash - if q.first < q.next { - parent = q.q[q.next-1].Msg().Block().Header().Hash() - } - result = types.Sign(s.key, types.NewLaneProposal(types.NewBlock(lane, q.next, parent, payload))) + result = types.Sign(s.key, types.NewLaneProposal(types.NewBlock(lane, q.next, q.parentHash(), payload))) q.pushBack(result) ctrl.Updated() } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 34c4156b48..7ee340be82 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -25,12 +25,7 @@ func pushPeerLaneBlock(state *State, key types.SecretKey, payload *types.Payload if !ok { return nil, ErrLaneClosed } - n := q.next - var parent types.BlockHeaderHash - if q.first < q.next { - parent = q.q[q.next-1].Msg().Block().Header().Hash() - } - b = types.Sign(key, types.NewLaneProposal(types.NewBlock(lane, n, parent, payload))) + b = types.Sign(key, types.NewLaneProposal(types.NewBlock(lane, q.next, q.parentHash(), payload))) q.pushBack(b) ctrl.Updated() } @@ -560,6 +555,28 @@ func TestPushBlockRejectsBadParentHash(t *testing.T) { require.Equal(t, types.BlockNumber(1), state.NextBlock(lane)) } +func TestProduceLocalBlock_ParentHashSurvivesPrune(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 3) + + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.Some(t.TempDir()))) + + lane := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("lane") + first, err := state.ProduceLocalBlock(lane, state.NextBlock(lane), types.GenPayload(rng)) + require.NoError(t, err) + + // Drop the produced block from the lane queue, as eviction does once the + // block has been certified. + for inner := range state.inner.Lock() { + inner.blocks[lane].prune(1) + } + + second, err := state.ProduceLocalBlock(lane, state.NextBlock(lane), types.GenPayload(rng)) + require.NoError(t, err) + require.Equal(t, first.Msg().Block().Header().Hash(), second.Msg().Block().Header().ParentHash()) +} + func TestPushBlockRejectsWrongSigner(t *testing.T) { ctx := t.Context() rng := utils.TestRng() From 72f4c0afd66b577178f351d86f9374e8549812c7 Mon Sep 17 00:00:00 2001 From: masih Date: Tue, 15 Sep 2026 15:28:42 +0000 Subject: [PATCH 5/7] Recover lane parent hash from the WAL after restart --- .../internal/autobahn/avail/inner.go | 9 ++- .../internal/autobahn/avail/state_test.go | 67 +++++++++++++++++++ .../autobahn/consensus/persist/blocks.go | 12 +++- .../autobahn/consensus/persist/blocks_test.go | 38 +++++++++++ 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index d078251389..ca170ac713 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -12,7 +12,9 @@ import ( // blockQueue is a lane's queue of LaneProposals which additionally remembers // the hash of the last block pushed, so that the parent hash of the next -// block is known even after the queue has been pruned. +// block is known even after the queue has been pruned. On restart the hash +// is recovered from the last block on disk, which the lane WAL retains past +// the anchor for this purpose. type blockQueue struct { queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] lastHash utils.Option[types.BlockHeaderHash] @@ -28,7 +30,7 @@ func (q *blockQueue) pushBack(p *types.Signed[*types.LaneProposal]) { } // parentHash returns the hash the next block of the lane should point to: -// the zero hash if no block has been pushed to the queue since construction. +// the zero hash if no block of the lane is known. func (q *blockQueue) parentHash() types.BlockHeaderHash { if h, ok := q.lastHash.Get(); ok { return h @@ -111,6 +113,9 @@ func (i *inner) restoreBlocks(blocks map[types.LaneID][]persist.LoadedBlock) err return fmt.Errorf("lane %s: loaded %d blocks exceeds capacity %d", lane, len(bs), BlocksPerLane) } if b.Number < q.next { + if b.Number == q.next-1 { + q.lastHash = utils.Some(b.Proposal.Msg().Block().Header().Hash()) + } continue } if b.Number != q.next { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 7ee340be82..c29a5fa256 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -577,6 +577,73 @@ func TestProduceLocalBlock_ParentHashSurvivesPrune(t *testing.T) { require.Equal(t, first.Msg().Block().Header().Hash(), second.Msg().Block().Header().ParentHash()) } +// TestProduceLocalBlock_ParentHashSurvivesRestart certifies the only block of +// the local lane, restarts from disk with an Anchor that already covers it, and +// checks that the next block still points to it. +func TestProduceLocalBlock_ParentHashSurvivesRestart(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 3) + ep := registry.MustEpoch(0) + lane := ep.Committee().Lane(keys[0].Public()).OrPanic("lane") + dir := t.TempDir() + ds := newTestDataState(&data.Config{Registry: registry}) + + var state1 *State + var first *types.Signed[*types.LaneProposal] + require.NoError(t, scope.Run(t.Context(), func(ctx context.Context, s scope.Scope) error { + s.SpawnBgNamed("data.Run", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) + state, err := NewState(keys[0], ds, utils.Some(dir)) + if err != nil { + return err + } + state1 = state + s.SpawnBgNamed("avail.Run", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) + + first, err = state.ProduceLocalBlock(lane, state.NextBlock(lane), types.GenPayload(rng)) + if err != nil { + return fmt.Errorf("ProduceLocalBlock: %w", err) + } + for _, vote := range makeLaneVotes(keys, first.Msg().Block().Header()) { + if err := state.PushVote(ctx, vote); err != nil { + return fmt.Errorf("PushVote: %w", err) + } + } + laneQCs, err := state.WaitForLaneQCs(ctx, ep, utils.None[*types.CommitQC]()) + if err != nil { + return fmt.Errorf("WaitForLaneQCs: %w", err) + } + qc := types.BuildCommitQC(ep, keys, utils.None[*types.CommitQC](), laneQCs) + if err := state.PushCommitQC(ctx, qc); err != nil { + return fmt.Errorf("PushCommitQC: %w", err) + } + appHash := types.GenAppHash(rng) + appProposal := types.NewAppProposal(qc.Proposal(), appHash) + if err := ds.PushAppHash(ctx, appProposal.GlobalRange().Next-1, appHash, nil); err != nil { + return fmt.Errorf("PushAppHash: %w", err) + } + for _, vote := range makeAppVotes(keys, appProposal) { + if err := state.PushAppVote(ctx, vote); err != nil { + return fmt.Errorf("PushAppVote: %w", err) + } + } + // Data's Anchor covering the block is what makes the restart prune the + // lane past it. + _, err = ds.Anchor().Wait(ctx, func(a utils.Option[data.Anchor]) bool { + got, ok := a.Get() + return ok && got.CommitQC.Index() == qc.Index() + }) + return err + })) + require.NoError(t, state1.Close()) + + state2, err := NewState(keys[0], ds, utils.Some(dir)) + require.NoError(t, err) + require.Equal(t, first.Msg().Block().Header().Next(), state2.NextBlock(lane)) + second, err := state2.ProduceLocalBlock(lane, state2.NextBlock(lane), types.GenPayload(rng)) + require.NoError(t, err) + require.Equal(t, first.Msg().Block().Header().Hash(), second.Msg().Block().Header().ParentHash()) +} + func TestPushBlockRejectsWrongSigner(t *testing.T) { ctx := t.Context() rng := utils.TestRng() diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks.go index 23b894113c..b048dd227a 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks.go @@ -68,13 +68,19 @@ func (s *laneWALState) flush(lane types.LaneID) error { } // truncateForAnchor prunes the WAL so that `first` becomes the oldest retained block number, and moves -// the cursor up when the anchor has advanced past every block the lane holds. +// the cursor up when the anchor has advanced past every block the lane holds. The last persisted block +// is always retained: it is the parent of the next block the lane produces, and restoration needs its +// hash even once the anchor has moved past it. // // Pruning is lazy: blocks below `first` may remain on disk until the file holding them falls entirely // below the threshold. Caller must hold the per-lane lock. func (s *laneWALState) truncateForAnchor(lane types.LaneID, first types.BlockNumber) error { - if err := s.wal.PruneBefore(uint64(first)); err != nil { - return fmt.Errorf("prune lane %s WAL before block %d: %w", lane, first, err) + keep := first + if s.nextBlockNum > 0 { + keep = min(keep, s.nextBlockNum-1) + } + if err := s.wal.PruneBefore(uint64(keep)); err != nil { + return fmt.Errorf("prune lane %s WAL before block %d: %w", lane, keep, err) } if first > s.nextBlockNum { // The anchor moved past every block persisted for this lane, so the next block to persist is diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go index 299afc1956..ebd7748849 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go @@ -493,6 +493,44 @@ func TestPruneReclaimsSealedFiles(t *testing.T) { require.Equal(t, types.BlockNumber(total), s2.nextBlockNum) } +// TestPrunePastAllKeepsLastBlock verifies that truncateForAnchor retains the last persisted block when +// the anchor has moved past every block of the lane, so a reopened lane still knows its tip. +func TestPrunePastAllKeepsLastBlock(t *testing.T) { + rng := utils.TestRng() + key := types.GenSecretKey(rng) + lane := types.LaneID{Validator: key.Public(), Joined: 0} + dir := t.TempDir() + + const total = 40 + const fileSize = 512 + + w, err := openWAL(dir, blocksWALName, types.SignedLaneProposalConv, fileSize, blocksWALMetrics) + require.NoError(t, err) + s := &laneWALState{wal: w} + var last *types.Signed[*types.LaneProposal] + for i := range types.BlockNumber(total) { + last = testSignedProposal(rng, key, i) + require.NoError(t, s.persistBlock(last)) + } + require.NoError(t, s.flush(lane)) + require.NoError(t, s.truncateForAnchor(lane, total)) + require.NoError(t, s.wal.Close()) + + w2, err := openWAL(dir, blocksWALName, types.SignedLaneProposalConv, fileSize, blocksWALMetrics) + require.NoError(t, err) + s2 := &laneWALState{wal: w2} + loaded, err := s2.loadAll(lane) + require.NoError(t, err) + require.NoError(t, s2.wal.Close()) + + require.True(t, len(loaded) > 0, "the last block must survive pruning") + require.True(t, loaded[0].Number > 0, "pruning should have reclaimed the oldest blocks") + got := loaded[len(loaded)-1] + require.Equal(t, types.BlockNumber(total-1), got.Number) + require.Equal(t, last.Msg().Block().Header().Hash(), got.Proposal.Msg().Block().Header().Hash()) + require.Equal(t, types.BlockNumber(total), s2.nextBlockNum) +} + func TestPersistBlockConcurrentDistinctLanes(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() From e4ec60b7a461f4f3bb253c839024edbcaa5b1c47 Mon Sep 17 00:00:00 2001 From: masih Date: Tue, 15 Sep 2026 21:18:36 +0000 Subject: [PATCH 6/7] Drop balance.go superseded by state.go from giga-1 --- giga/evmonly/rpc/balance.go | 38 --------------- giga/evmonly/rpc/balance_test.go | 80 -------------------------------- 2 files changed, 118 deletions(-) delete mode 100644 giga/evmonly/rpc/balance.go delete mode 100644 giga/evmonly/rpc/balance_test.go diff --git a/giga/evmonly/rpc/balance.go b/giga/evmonly/rpc/balance.go deleted file mode 100644 index 79237d972e..0000000000 --- a/giga/evmonly/rpc/balance.go +++ /dev/null @@ -1,38 +0,0 @@ -package rpc - -import ( - "context" - "errors" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" - ethrpc "github.com/ethereum/go-ethereum/rpc" -) - -var errHistoricalStateUnsupported = errors.New("historical state is not supported by EVM-only RPC") - -type balanceAPI struct { - backend Backend -} - -// GetBalance returns the address balance from the current committed EVM state. -func (api *balanceAPI) GetBalance(_ context.Context, address common.Address, block ethrpc.BlockNumberOrHash) (*hexutil.Big, error) { - if err := requireCurrentState(block); err != nil { - return nil, err - } - balance := api.backend.EvmBalance(address) - return (*hexutil.Big)(balance.ToBig()), nil -} - -func requireCurrentState(block ethrpc.BlockNumberOrHash) error { - number, ok := block.Number() - if !ok { - return errHistoricalStateUnsupported - } - switch number { - case ethrpc.LatestBlockNumber, ethrpc.SafeBlockNumber, ethrpc.FinalizedBlockNumber, ethrpc.PendingBlockNumber: - return nil - default: - return errHistoricalStateUnsupported - } -} diff --git a/giga/evmonly/rpc/balance_test.go b/giga/evmonly/rpc/balance_test.go deleted file mode 100644 index 304ac5266e..0000000000 --- a/giga/evmonly/rpc/balance_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package rpc - -import ( - "net/http/httptest" - "testing" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" - ethrpc "github.com/ethereum/go-ethereum/rpc" - "github.com/holiman/uint256" - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-chain/giga/evmonly" -) - -func TestGetBalance(t *testing.T) { - address := common.HexToAddress("0x1000000000000000000000000000000000000001") - want := uint256.NewInt(123456789) - backend := &testBackend{ - balance: func(got common.Address) uint256.Int { - require.Equal(t, address, got) - return *want - }, - } - api := &balanceAPI{backend: backend} - - for _, tag := range []ethrpc.BlockNumber{ - ethrpc.LatestBlockNumber, - ethrpc.SafeBlockNumber, - ethrpc.FinalizedBlockNumber, - ethrpc.PendingBlockNumber, - } { - got, err := api.GetBalance(t.Context(), address, ethrpc.BlockNumberOrHashWithNumber(tag)) - require.NoError(t, err) - require.Equal(t, want.ToBig(), got.ToInt()) - } -} - -func TestGetBalanceRejectsHistoricalState(t *testing.T) { - backend := &testBackend{ - balance: func(common.Address) uint256.Int { - t.Fatal("historical request read the current balance") - return uint256.Int{} - }, - } - api := &balanceAPI{backend: backend} - address := common.Address{1} - - for _, block := range []ethrpc.BlockNumberOrHash{ - ethrpc.BlockNumberOrHashWithNumber(ethrpc.EarliestBlockNumber), - ethrpc.BlockNumberOrHashWithNumber(7), - ethrpc.BlockNumberOrHashWithHash(common.Hash{2}, true), - {}, - } { - got, err := api.GetBalance(t.Context(), address, block) - require.ErrorIs(t, err, errHistoricalStateUnsupported) - require.Nil(t, got) - } -} - -func TestHandlerServesGetBalance(t *testing.T) { - address := common.HexToAddress("0x2000000000000000000000000000000000000002") - backend := &testBackend{ - balance: func(common.Address) uint256.Int { - return *uint256.NewInt(42) - }, - } - handler, err := newHandler(backend, evmonly.NewMemoryReceiptStore()) - require.NoError(t, err) - t.Cleanup(handler.Stop) - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - client, err := ethrpc.DialHTTP(server.URL) - require.NoError(t, err) - t.Cleanup(client.Close) - - var got hexutil.Big - require.NoError(t, client.CallContext(t.Context(), &got, "eth_getBalance", address, "latest")) - require.Equal(t, "0x2a", got.String()) -} From 05be6a2390a83dd856130194163fd050c52989a1 Mon Sep 17 00:00:00 2001 From: masih Date: Wed, 16 Sep 2026 07:57:34 +0000 Subject: [PATCH 7/7] Track lane tip by block number and carry it across empty lane ranges The remembered parent hash of a lane is now stored together with the number of the block it belongs to, and is only used when that block is the immediate predecessor of the next one to produce. An anchor whose lane range covers every block the queue held seeds the tip as well. A lane omitted from a tipcut now keeps the previous CommitQC's last hash in its empty LaneRange instead of dropping to the zero hash, and proposal verification requires it. --- sei-tendermint/autobahn/types/proposal.go | 68 +++++++++++-------- .../autobahn/types/proposal_test.go | 43 ++++++++++++ .../internal/autobahn/avail/inner.go | 44 +++++++++--- .../internal/autobahn/avail/state_test.go | 23 +++++++ 4 files changed, 141 insertions(+), 37 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 600d19cd46..373bc1640a 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -15,7 +15,9 @@ import ( // MaxLaneRangeInProposal is the maximum number of blocks a proposal may advance a lane by. const MaxLaneRangeInProposal = 10 -// LaneRange represents a range [first,next) of blocks of a lane. +// LaneRange represents a range [first,next) of blocks of a lane, together with +// the hash of block next-1, so that the tip of the lane is known even when the +// range is empty. type LaneRange struct { utils.ReadOnly lane LaneID @@ -24,7 +26,8 @@ type LaneRange struct { lastHash BlockHeaderHash } -// NewLaneRange constructs a LaneRange. +// NewLaneRange constructs a LaneRange ending at h, or an empty range at first +// with a zero hash if h is None. func NewLaneRange(lane LaneID, first BlockNumber, h utils.Option[*BlockHeader]) *LaneRange { if h, ok := h.Get(); ok { return &LaneRange{lane: lane, first: first, next: h.BlockNumber() + 1, lastHash: h.Hash()} @@ -32,6 +35,12 @@ func NewLaneRange(lane LaneID, first BlockNumber, h utils.Option[*BlockHeader]) return &LaneRange{lane: lane, first: first, next: first, lastHash: BlockHeaderHash{}} } +// NewEmptyLaneRange constructs the empty range following prev, which keeps +// prev's last hash. +func NewEmptyLaneRange(prev *LaneRange) *LaneRange { + return &LaneRange{lane: prev.lane, first: prev.next, next: prev.next, lastHash: prev.lastHash} +} + // Lane of this block range. func (m *LaneRange) Lane() LaneID { return m.lane } @@ -44,8 +53,7 @@ func (m *LaneRange) Next() BlockNumber { return m.next } // Len returns the number of blocks in the range. func (m *LaneRange) Len() uint64 { return uint64(m.next - m.first) } -// LastHash is the hash of the last block of the range. -// Returns a zero hash for an empty range. +// LastHash is the hash of block Next()-1, or the zero hash if Next() is 0. func (m *LaneRange) LastHash() BlockHeaderHash { return m.lastHash } // Verify verifies the LaneRange against the committee. @@ -56,8 +64,8 @@ func (m *LaneRange) Verify(c *Committee) error { if m.first > m.next { return fmt.Errorf("invalid range [%v,%v)", m.first, m.next) } - if m.first == m.next && m.lastHash != (BlockHeaderHash{}) { - return errors.New("non-zero hash for an empty range") + if m.next == 0 && m.lastHash != (BlockHeaderHash{}) { + return errors.New("non-zero hash for a lane without blocks") } return nil } @@ -337,18 +345,18 @@ func buildProposal( ) (*Proposal, error) { var laneRanges []*LaneRange for lane := range committee.Lanes().All() { - first := LaneRangeOpt(viewSpec.CommitQC, lane).Next() + prev := LaneRangeOpt(viewSpec.CommitQC, lane) if lQC, ok := laneQCs[lane]; ok { if lQC.Header().Lane() != lane { return nil, fmt.Errorf("laneQC %v for lane %v", lQC.Header().Lane(), lane) } - laneRange := NewLaneRange(lane, first, utils.Some(lQC.Header())) + laneRange := NewLaneRange(lane, prev.Next(), utils.Some(lQC.Header())) if got := laneRange.Len(); got > MaxLaneRangeInProposal { return nil, fmt.Errorf("laneRange[%v].Len() = %d, want <= %d", lane, got, MaxLaneRangeInProposal) } laneRanges = append(laneRanges, laneRange) } else { - laneRanges = append(laneRanges, NewLaneRange(lane, first, utils.None[*BlockHeader]())) + laneRanges = append(laneRanges, NewEmptyLaneRange(prev)) } } // Normalize the creation timestamp. @@ -459,28 +467,34 @@ func (m *FullProposal) Verify(vs ViewSpec) error { for lane := range c.Lanes().All() { r := proposal.LaneRange(lane) // Verify that range matches previous commitQC. - if got, want := r.First(), LaneRangeOpt(vs.CommitQC, r.Lane()).Next(); got != want { + prev := LaneRangeOpt(vs.CommitQC, r.Lane()) + if got, want := r.First(), prev.Next(); got != want { return fmt.Errorf("laneRange[%v].First() = %v, want %v", r.Lane(), got, want) } - // Verify that the necessary laneQC is present and valid. - if r.First() < r.Next() { - qc, ok := m.LaneQC(r.Lane()) - if !ok { - return fmt.Errorf("missing qc for %q", r.Lane()) - } - if got, want := qc.Header().BlockNumber(), r.Next()-1; got != want { - return fmt.Errorf("qc[%v].BlockNumber() = %v, want %v", r.Lane(), got, want) + // An empty range carries the previous tip forward. + if r.Len() == 0 { + if got, want := r.LastHash(), prev.LastHash(); got != want { + return fmt.Errorf("laneRange[%v].LastHash() = %v, want %v", r.Lane(), got, want) } - if got, want := qc.Header().Hash(), r.LastHash(); got != want { - return fmt.Errorf("qc[%v].Header().Hash() = %v, want %v", r.Lane(), got, want) - } - s.Spawn(func() error { - if err := qc.Verify(c); err != nil { - return fmt.Errorf("qc[%v]: %w", r.Lane(), err) - } - return nil - }) + continue + } + // Verify that the necessary laneQC is present and valid. + qc, ok := m.LaneQC(r.Lane()) + if !ok { + return fmt.Errorf("missing qc for %q", r.Lane()) } + if got, want := qc.Header().BlockNumber(), r.Next()-1; got != want { + return fmt.Errorf("qc[%v].BlockNumber() = %v, want %v", r.Lane(), got, want) + } + if got, want := qc.Header().Hash(), r.LastHash(); got != want { + return fmt.Errorf("qc[%v].Header().Hash() = %v, want %v", r.Lane(), got, want) + } + s.Spawn(func() error { + if err := qc.Verify(c); err != nil { + return fmt.Errorf("qc[%v]: %w", r.Lane(), err) + } + return nil + }) } return nil }) diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index f30ae747b7..f0aca5c4dc 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -392,6 +392,49 @@ func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { require.NoError(t, shortFP.Verify(vs)) } +// TestProposalEmptyRangeKeepsLastHash checks that a lane omitted from a tipcut +// keeps the last hash of the previous CommitQC, and that dropping it is rejected. +func TestProposalEmptyRangeKeepsLastHash(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep := genFreshEpoch(rng, committee) + lane0 := committee.Lanes().At(0) + lane1 := committee.Lanes().At(1) + + // Road 0 commits a block on lane0. + prev := BuildCommitQC(ep, keys, utils.None[*CommitQC](), map[LaneID]*LaneQC{ + lane0: makeLaneQC(rng, committee, keys, lane0, 0, BlockHeaderHash{}), + }) + want := prev.LaneRange(lane0).LastHash() + require.NotEqual(t, BlockHeaderHash{}, want) + + // Road 1 commits a block on lane1 only. + vs := ViewSpec{ConsensusSpec: ConsensusSpec{CommitQC: utils.Some(prev), Epoch: ep}} + proposerKey := leaderKey(committee, keys, vs.View()) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), map[LaneID]*LaneQC{ + lane1: makeLaneQC(rng, committee, keys, lane1, 0, BlockHeaderHash{}), + })) + require.NoError(t, fp.Verify(vs)) + lr := fp.Proposal().Msg().LaneRange(lane0) + require.Equal(t, uint64(0), lr.Len()) + require.Equal(t, want, lr.LastHash()) + + // Zeroing the carried hash is rejected. + origP := fp.Proposal().Msg() + var tamperedRanges []*LaneRange + for _, r := range origP.laneRanges { + if r.Lane() == lane0 { + r = NewLaneRange(lane0, r.First(), utils.None[*BlockHeader]()) + } + tamperedRanges = append(tamperedRanges, r) + } + tamperedFP := &FullProposal{ + proposal: Sign(proposerKey, newProposal(origP.view, origP.timestamp, tamperedRanges, origP.GlobalRange().First)), + laneQCs: fp.laneQCs, + } + require.Error(t, tamperedFP.Verify(vs)) +} + func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index ca170ac713..ae9719b86f 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -10,14 +10,21 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// laneTip identifies the last known block of a lane. +type laneTip struct { + number types.BlockNumber + hash types.BlockHeaderHash +} + // blockQueue is a lane's queue of LaneProposals which additionally remembers -// the hash of the last block pushed, so that the parent hash of the next -// block is known even after the queue has been pruned. On restart the hash -// is recovered from the last block on disk, which the lane WAL retains past -// the anchor for this purpose. +// the lane's tip, so that the parent hash of the next block is known even +// after the queue has been pruned. The tip is taken from the last block +// pushed, from the anchor's lane range when it prunes past the queue, and on +// restart from the last block on disk, which the lane WAL retains past the +// anchor for this purpose. type blockQueue struct { queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] - lastHash utils.Option[types.BlockHeaderHash] + tip utils.Option[laneTip] } func newBlockQueue() *blockQueue { @@ -26,14 +33,31 @@ func newBlockQueue() *blockQueue { func (q *blockQueue) pushBack(p *types.Signed[*types.LaneProposal]) { q.queue.pushBack(p) - q.lastHash = utils.Some(p.Msg().Block().Header().Hash()) + q.setTip(p.Msg().Block().Header()) +} + +func (q *blockQueue) setTip(h *types.BlockHeader) { + q.tip = utils.Some(laneTip{number: h.BlockNumber(), hash: h.Hash()}) +} + +// pruneTo advances the queue to the anchor's lane range, adopting its last +// block as the tip when the range covers every block the queue held. +func (q *blockQueue) pruneTo(lr *types.LaneRange) { + q.prune(lr.Next()) + if lr.Next() == 0 { + return + } + if t, ok := q.tip.Get(); ok && t.number+1 >= lr.Next() { + return + } + q.tip = utils.Some(laneTip{number: lr.Next() - 1, hash: lr.LastHash()}) } // parentHash returns the hash the next block of the lane should point to: // the zero hash if no block of the lane is known. func (q *blockQueue) parentHash() types.BlockHeaderHash { - if h, ok := q.lastHash.Get(); ok { - return h + if t, ok := q.tip.Get(); ok && t.number+1 == q.next { + return t.hash } return types.BlockHeaderHash{} } @@ -114,7 +138,7 @@ func (i *inner) restoreBlocks(blocks map[types.LaneID][]persist.LoadedBlock) err } if b.Number < q.next { if b.Number == q.next-1 { - q.lastHash = utils.Some(b.Proposal.Msg().Block().Header().Hash()) + q.setTip(b.Proposal.Msg().Block().Header()) } continue } @@ -278,7 +302,7 @@ func (i *inner) prune(anchor data.Anchor) int { lr := anchor.CommitQC.LaneRange(lane) bq := i.blocks[lane] vq.prune(lr.Next()) - bq.prune(lr.Next()) + bq.pruneTo(lr) if i.nextBlockToPersist[lane] < lr.Next() { i.nextBlockToPersist[lane] = lr.Next() } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index c29a5fa256..e67296b2af 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -577,6 +577,29 @@ func TestProduceLocalBlock_ParentHashSurvivesPrune(t *testing.T) { require.Equal(t, first.Msg().Block().Header().Hash(), second.Msg().Block().Header().ParentHash()) } +// TestProduceLocalBlock_ParentHashFromAnchor checks that a lane whose blocks +// were never held locally takes its parent hash from the anchor's lane range, +// including one that merely carries the hash forward from an earlier tipcut. +func TestProduceLocalBlock_ParentHashFromAnchor(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 3) + + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + lane := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("lane") + prevHeader := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + lr := types.NewEmptyLaneRange(types.NewLaneRange(lane, 0, utils.Some(prevHeader))) + for inner := range state.inner.Lock() { + inner.blocks[lane].pruneTo(lr) + } + + require.Equal(t, prevHeader.Next(), state.NextBlock(lane)) + next, err := state.ProduceLocalBlock(lane, state.NextBlock(lane), types.GenPayload(rng)) + require.NoError(t, err) + require.Equal(t, prevHeader.Hash(), next.Msg().Block().Header().ParentHash()) +} + // TestProduceLocalBlock_ParentHashSurvivesRestart certifies the only block of // the local lane, restarts from disk with an Anchor that already covers it, and // checks that the next block still points to it.