Skip to content
58 changes: 58 additions & 0 deletions tm2/adr/pr5979_bptree_bounded_version_discovery.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions tm2/pkg/bptree/discover_versions_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
59 changes: 37 additions & 22 deletions tm2/pkg/bptree/nodedb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down