diff --git a/tm2/adr/pr5979_bptree_bounded_version_discovery.md b/tm2/adr/pr5979_bptree_bounded_version_discovery.md new file mode 100644 index 00000000000..22058cf58f7 --- /dev/null +++ b/tm2/adr/pr5979_bptree_bounded_version_discovery.md @@ -0,0 +1,58 @@ +# Bound bptree version discovery to two seeks + +## Context + +`nodeDB.discoverVersions` finds the first and latest retained versions before a +tree loads. It scanned every root key (`PrefixRoot‖version-BE`) to take the min +and max. `MutableTree.Load` calls it, and the rootmulti immutable store opens a +tree per ABCI query at a height, so every custom query reran the full scan. The +cost is linear in the retained-version count, and the gno.land default prune +strategy keeps 705,600 versions, so the scan grows without bound as the chain +runs and sits on the RPC path under the mutex the query handler shares with +block production. + +## Decision + +Replace the scan with two backend seeks. Root keys are `PrefixRoot‖version-BE`, +so key order is version order: the first forward key is the smallest version and +the first reverse key is the largest. `edgeRootVersion` opens a forward or +reverse iterator over `[PrefixRoot, PrefixRoot+1)` and returns the first 9-byte +key's version, skipping any non-9-byte key at the edge so a stray key cannot +stop discovery. `discoverVersions` calls it once per direction. + +`rootDBKey` is the only writer under `PrefixRoot` and always emits 9 bytes, so +the seek result is identical to the old scan's min/max, at O(log n) per edge +instead of O(retained versions). + +## Alternatives considered + +- **Cache first/latest on the nodeDB and skip discovery on immutable opens.** + Larger surface, needs invalidation on prune and commit, and the seek is cheap + enough that caching earns little. +- **One `db.Has` probe per version in `[first, latest]`.** Still linear in the + retained range. + +## Consequences + +- Immutable query-height opens no longer scan all retained roots; discovery is + two seeks regardless of retention depth. +- Behaviour is unchanged for every reachable input: the discovered first/latest + match the prior scan, including after pruning opens a gap at the low end + (`TestDiscoverVersionsSeeksEdgesAfterPruneGap`). The one divergence is a root + at version 0, which the old scan's `first == 0` sentinel could not tell from + "unset" and so reported as the second-smallest version; the seek reports 0. + That case is unreachable (`WorkingVersion` never emits 0 and + `SetInitialVersion(0)` falls through to version 1), and where it would apply + the seek is the more faithful answer. +- `AvailableVersions` still scans, by design: it must return the full list. + +## Out of scope + +The same immutable query-height open can also write live state: when the fast +index stamp is behind the loaded version, `Load` runs `ensureFastIndex`, which +rebuilds and writes through the raw DB even though the open is nominally +read-only. It is latent (a current stamp makes the rebuild a no-op) and does not +affect the app hash (the fast index is outside the Merkle commitment), but a +correct fix must gate the immutable view's fast-read on the stamp so skipping +the rebuild cannot serve a stale value. That is a change to the fast-index +read-trust contract and is left as a separate follow-up. diff --git a/tm2/pkg/bptree/discover_versions_test.go b/tm2/pkg/bptree/discover_versions_test.go new file mode 100644 index 00000000000..e99a6d3237a --- /dev/null +++ b/tm2/pkg/bptree/discover_versions_test.go @@ -0,0 +1,38 @@ +package bptree + +import ( + "testing" + + "github.com/gnolang/gno/tm2/pkg/db/memdb" + "github.com/stretchr/testify/require" +) + +// discoverVersions seeks the first and last root keys instead of scanning every +// retained root. After pruning opens a gap at the low end, the discovered edges +// must still be the smallest and largest surviving versions, matching what a +// full scan would find. +func TestDiscoverVersionsSeeksEdgesAfterPruneGap(t *testing.T) { + t.Parallel() + + db := memdb.NewMemDB() + tree := NewMutableTreeWithDB(db, 1000, NewNopLogger()) + for i := range 5 { + _, err := tree.Set([]byte{byte('a' + i)}, []byte{byte(i)}) + require.NoError(t, err) + _, _, err = tree.SaveVersion() + require.NoError(t, err) + } + // Versions 1..5 exist; drop 1 and 2 so the surviving floor is 3. + require.NoError(t, tree.PruneVersionsTo(2)) + + // A fresh tree over the same DB rediscovers versions during Load. + fresh := NewMutableTreeWithDB(db, 1000, NewNopLogger()) + _, err := fresh.Load() + require.NoError(t, err) + + require.Equal(t, int64(3), fresh.ndb.getFirstVersion(), + "first must be the smallest surviving version") + require.Equal(t, int64(5), fresh.ndb.getLatestVersion(), + "latest must be the largest surviving version") + require.Equal(t, []int{3, 4, 5}, fresh.AvailableVersions()) +} diff --git a/tm2/pkg/bptree/nodedb.go b/tm2/pkg/bptree/nodedb.go index aa467ff18ce..ae5a9564b2f 100644 --- a/tm2/pkg/bptree/nodedb.go +++ b/tm2/pkg/bptree/nodedb.go @@ -468,44 +468,59 @@ func (ndb *nodeDB) AvailableVersions() []int { return versions } -// discoverVersions scans the DB for root references to find -// the first and latest versions. Called during Load. +// discoverVersions sets the oldest and newest saved versions. func (ndb *nodeDB) discoverVersions() error { + first, err := ndb.edgeRootVersion(false) + if err != nil { + return err + } + latest, err := ndb.edgeRootVersion(true) + if err != nil { + return err + } + + ndb.mtx.Lock() + ndb.firstVersion = first + ndb.latestVersion = latest + ndb.mtx.Unlock() + return nil +} + +// edgeRootVersion returns the oldest (reverse=false) or newest (reverse=true) +// saved version, or 0 if none. Root keys are 'R' followed by the version in +// big-endian, so sorting them sorts the versions and each end is one seek: +// +// R 00 00 00 00 00 00 00 03 <- oldest, reverse=false returns 3 +// R 00 00 00 00 00 00 00 04 +// R 00 00 00 00 00 00 00 05 <- newest, reverse=true returns 5 +func (ndb *nodeDB) edgeRootVersion(reverse bool) (int64, error) { prefix := []byte{PrefixRoot} end := make([]byte, len(prefix)) copy(end, prefix) end[0]++ - itr, err := ndb.db.Iterator(prefix, end) + var ( + itr dbm.Iterator + err error + ) + if reverse { + itr, err = ndb.db.ReverseIterator(prefix, end) + } else { + itr, err = ndb.db.Iterator(prefix, end) + } if err != nil { - return err + return 0, err } defer itr.Close() - first := int64(0) - latest := int64(0) for ; itr.Valid(); itr.Next() { key := itr.Key() if len(key) != 9 { // prefix(1) + version(8) continue } - v := int64(binary.BigEndian.Uint64(key[1:])) - if first == 0 || v < first { - first = v - } - if v > latest { - latest = v - } - } - if err := itr.Error(); err != nil { - return err + return int64(binary.BigEndian.Uint64(key[1:])), itr.Error() } - - ndb.mtx.Lock() - ndb.firstVersion = first - ndb.latestVersion = latest - ndb.mtx.Unlock() - return nil + return 0, itr.Error() } // --- Version readers ---