From 7369a4f50621f6e675bc39ba64ae015be8eb7e72 Mon Sep 17 00:00:00 2001 From: Cordtus Date: Mon, 26 Jan 2026 15:40:36 -0700 Subject: [PATCH 1/3] feat(store): implement EarliestVersion on CommitMultiStore Add EarliestVersion() to the CommitMultiStore interface to enable querying the earliest available state height. Placed on CommitMultiStore rather than MultiStore since it requires root DB access that CacheMultiStore does not have. - Implement in rootmulti.Store with persistent DB storage - Track earliest version after pruning in PruneStores - Add unit tests for EarliestVersion functionality Ref: #15463 --- server/mock/store.go | 4 ++ store/rootmulti/store.go | 57 ++++++++++++++++++++++++++- store/rootmulti/store_test.go | 74 +++++++++++++++++++++++++++++++++++ store/types/store.go | 3 ++ 4 files changed, 136 insertions(+), 2 deletions(-) diff --git a/server/mock/store.go b/server/mock/store.go index affa995734b1..4d48038069bc 100644 --- a/server/mock/store.go +++ b/server/mock/store.go @@ -172,6 +172,10 @@ func (ms multiStore) LatestVersion() int64 { panic("not implemented") } +func (ms multiStore) EarliestVersion() int64 { + panic("not implemented") +} + func (ms multiStore) WorkingHash() []byte { panic("not implemented") } diff --git a/store/rootmulti/store.go b/store/rootmulti/store.go index 861c59a5f18c..0ab3fe683a94 100644 --- a/store/rootmulti/store.go +++ b/store/rootmulti/store.go @@ -35,8 +35,9 @@ import ( ) const ( - latestVersionKey = "s/latest" - commitInfoKeyFmt = "s/%d" // s/ + latestVersionKey = "s/latest" + earliestVersionKey = "s/earliest" + commitInfoKeyFmt = "s/%d" // s/ ) const iavlDisablefastNodeDefault = false @@ -455,6 +456,11 @@ func (rs *Store) LatestVersion() int64 { return rs.LastCommitID().Version } +// EarliestVersion returns the earliest version in the store +func (rs *Store) EarliestVersion() int64 { + return GetEarliestVersion(rs.db) +} + // LastCommitID implements Committer/CommitStore. func (rs *Store) LastCommitID() types.CommitID { info := rs.lastCommitInfo.Load() @@ -752,6 +758,23 @@ func (rs *Store) PruneStores(pruningHeight int64) (err error) { rs.logger.Error("failed to prune store", "key", key, "err", err) } + + // Update earliest version after successful pruning. + // The new earliest available version is pruningHeight + 1. + // Only persist if newer than current earliest - this handles state sync + // scenarios and avoids issues if pruning config changes result in a + // lower pruning height than previously persisted. + newEarliest := pruningHeight + 1 + currentEarliest := GetEarliestVersion(rs.db) + if newEarliest > currentEarliest { + batch := rs.db.NewBatch() + defer batch.Close() + flushEarliestVersion(batch, newEarliest) + if err := batch.WriteSync(); err != nil { + rs.logger.Error("failed to persist earliest version", "err", err) + } + } + return nil } @@ -1222,6 +1245,36 @@ func GetLatestVersion(db dbm.DB) int64 { return latestVersion } +// GetEarliestVersion returns the earliest version stored in the database. +// Returns 1 if no earliest version has been explicitly set (unpruned chain). +func GetEarliestVersion(db dbm.DB) int64 { + bz, err := db.Get([]byte(earliestVersionKey)) + if err != nil { + panic(err) + } else if bz == nil { + return 1 // default to 1 for unpruned chains + } + + var earliestVersion int64 + + if err := gogotypes.StdInt64Unmarshal(&earliestVersion, bz); err != nil { + panic(err) + } + + return earliestVersion +} + +func flushEarliestVersion(batch dbm.Batch, version int64) { + bz, err := gogotypes.StdInt64Marshal(version) + if err != nil { + panic(err) + } + + if err := batch.Set([]byte(earliestVersionKey), bz); err != nil { + panic(err) + } +} + // commitStores commits each store and returns a new commitInfo. func commitStores(version int64, storeMap map[types.StoreKey]types.CommitStore, removalMap map[types.StoreKey]bool) *types.CommitInfo { storeInfos := make([]types.StoreInfo, 0, len(storeMap)) diff --git a/store/rootmulti/store_test.go b/store/rootmulti/store_test.go index 9437af4439e0..781cd536bf83 100644 --- a/store/rootmulti/store_test.go +++ b/store/rootmulti/store_test.go @@ -1169,3 +1169,77 @@ func TestCommitStores(t *testing.T) { }) } } + +func TestEarliestVersion(t *testing.T) { + db := dbm.NewMemDB() + ms := newMultiStoreWithMounts(db, pruningtypes.NewPruningOptions(pruningtypes.PruningNothing)) + require.NoError(t, ms.LoadLatestVersion()) + + // Initially, earliest version should be 1 (default for unpruned chains) + require.Equal(t, int64(1), ms.EarliestVersion()) + + // Commit some versions + for i := 0; i < 5; i++ { + ms.Commit() + } + + // Earliest version should still be 1 + require.Equal(t, int64(1), ms.EarliestVersion()) + require.Equal(t, int64(5), ms.LatestVersion()) +} + +func TestEarliestVersionWithPruning(t *testing.T) { + db := dbm.NewMemDB() + // keepRecent=2, interval=1 means prune aggressively + ms := newMultiStoreWithMounts(db, pruningtypes.NewCustomPruningOptions(2, 1)) + require.NoError(t, ms.LoadLatestVersion()) + + // Initially, earliest version should be 1 + require.Equal(t, int64(1), ms.EarliestVersion()) + + // Commit enough versions to trigger pruning + for i := 0; i < 10; i++ { + ms.Commit() + } + + // Wait for async pruning to complete and check earliest version is updated + checkEarliest := func() bool { + return ms.EarliestVersion() > 1 + } + require.Eventually(t, checkEarliest, 1*time.Second, 10*time.Millisecond, + "expected earliest version to be updated after pruning") + + // Earliest version should now be greater than 1 (pruned heights + 1) + earliest := ms.EarliestVersion() + require.Greater(t, earliest, int64(1), "earliest version should be updated after pruning") + + // Latest should still be 10 + require.Equal(t, int64(10), ms.LatestVersion()) +} + +func TestEarliestVersionPersistence(t *testing.T) { + db := dbm.NewMemDB() + ms := newMultiStoreWithMounts(db, pruningtypes.NewCustomPruningOptions(2, 1)) + require.NoError(t, ms.LoadLatestVersion()) + + // Commit and prune + for i := 0; i < 10; i++ { + ms.Commit() + } + + // Wait for pruning + checkEarliest := func() bool { + return ms.EarliestVersion() > 1 + } + require.Eventually(t, checkEarliest, 1*time.Second, 10*time.Millisecond) + + earliestBeforeRestart := ms.EarliestVersion() + + // "Restart" by creating new store with same db + ms2 := newMultiStoreWithMounts(db, pruningtypes.NewCustomPruningOptions(2, 1)) + require.NoError(t, ms2.LoadLatestVersion()) + + // Earliest version should be persisted and restored + require.Equal(t, earliestBeforeRestart, ms2.EarliestVersion(), + "earliest version should persist across restarts") +} diff --git a/store/types/store.go b/store/types/store.go index 0e51bd988b3a..a381b8f03f1d 100644 --- a/store/types/store.go +++ b/store/types/store.go @@ -159,6 +159,9 @@ type CommitMultiStore interface { MultiStore snapshottypes.Snapshotter + // EarliestVersion returns the earliest version in the store + EarliestVersion() int64 + // Mount a store of type using the given db. // If db == nil, the new store will use the CommitMultiStore db. MountStoreWithDB(key StoreKey, typ StoreType, db dbm.DB) From 0df5ce93e78bb89794942ec0ec1a6bacfd1ad886 Mon Sep 17 00:00:00 2001 From: Cordtus Date: Mon, 26 Jan 2026 15:40:42 -0700 Subject: [PATCH 2/3] feat(node): expose EarliestStoreHeight in Status gRPC endpoint Pass CommitMultiStore to the node query service to populate EarliestStoreHeight in the Status response. --- client/grpc/node/service.go | 23 ++++++++++++----------- client/grpc/node/service_test.go | 2 +- runtime/app.go | 2 +- simapp/app.go | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/client/grpc/node/service.go b/client/grpc/node/service.go index 144722a9cbfb..3cc040dbc092 100644 --- a/client/grpc/node/service.go +++ b/client/grpc/node/service.go @@ -6,14 +6,16 @@ import ( gogogrpc "github.com/cosmos/gogoproto/grpc" "github.com/grpc-ecosystem/grpc-gateway/runtime" + storetypes "cosmossdk.io/store/types" + "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/server/config" sdk "github.com/cosmos/cosmos-sdk/types" ) // RegisterNodeService registers the node gRPC service on the provided gRPC router. -func RegisterNodeService(clientCtx client.Context, server gogogrpc.Server, cfg config.Config) { - RegisterServiceServer(server, NewQueryServer(clientCtx, cfg)) +func RegisterNodeService(clientCtx client.Context, server gogogrpc.Server, cfg config.Config, cms storetypes.CommitMultiStore) { + RegisterServiceServer(server, NewQueryServer(clientCtx, cfg, cms)) } // RegisterGRPCGatewayRoutes mounts the node gRPC service's GRPC-gateway routes @@ -27,12 +29,14 @@ var _ ServiceServer = queryServer{} type queryServer struct { clientCtx client.Context cfg config.Config + cms storetypes.CommitMultiStore } -func NewQueryServer(clientCtx client.Context, cfg config.Config) ServiceServer { +func NewQueryServer(clientCtx client.Context, cfg config.Config, cms storetypes.CommitMultiStore) ServiceServer { return queryServer{ clientCtx: clientCtx, cfg: cfg, + cms: cms, } } @@ -53,13 +57,10 @@ func (s queryServer) Status(ctx context.Context, _ *StatusRequest) (*StatusRespo blockTime := sdkCtx.BlockTime() return &StatusResponse{ - // TODO: Get earliest version from store. - // - // Ref: ... - // EarliestStoreHeight: sdkCtx.MultiStore(), - Height: uint64(sdkCtx.BlockHeight()), - Timestamp: &blockTime, - AppHash: sdkCtx.BlockHeader().AppHash, - ValidatorHash: sdkCtx.BlockHeader().NextValidatorsHash, + EarliestStoreHeight: uint64(s.cms.EarliestVersion()), + Height: uint64(sdkCtx.BlockHeight()), + Timestamp: &blockTime, + AppHash: sdkCtx.BlockHeader().AppHash, + ValidatorHash: sdkCtx.BlockHeader().NextValidatorsHash, }, nil } diff --git a/client/grpc/node/service_test.go b/client/grpc/node/service_test.go index fc9ddbb5101e..2f3c4d4b9efa 100644 --- a/client/grpc/node/service_test.go +++ b/client/grpc/node/service_test.go @@ -15,7 +15,7 @@ func TestServiceServer_Config(t *testing.T) { defaultCfg.PruningKeepRecent = "2000" defaultCfg.PruningInterval = "10" defaultCfg.HaltHeight = 100 - svr := NewQueryServer(client.Context{}, *defaultCfg) + svr := NewQueryServer(client.Context{}, *defaultCfg, nil) ctx := sdk.Context{}.WithMinGasPrices(sdk.NewDecCoins(sdk.NewInt64DecCoin("stake", 15))) resp, err := svr.Config(ctx, &ConfigRequest{}) diff --git a/runtime/app.go b/runtime/app.go index 806bf033a6ba..9044b59572c5 100644 --- a/runtime/app.go +++ b/runtime/app.go @@ -225,7 +225,7 @@ func (a *App) RegisterTendermintService(clientCtx client.Context) { // RegisterNodeService registers the node gRPC service on the app gRPC router. func (a *App) RegisterNodeService(clientCtx client.Context, cfg config.Config) { - nodeservice.RegisterNodeService(clientCtx, a.GRPCQueryRouter(), cfg) + nodeservice.RegisterNodeService(clientCtx, a.GRPCQueryRouter(), cfg, a.CommitMultiStore()) } // Configurator returns the app's configurator. diff --git a/simapp/app.go b/simapp/app.go index e556da1446ee..e77879dba2e7 100644 --- a/simapp/app.go +++ b/simapp/app.go @@ -828,7 +828,7 @@ func (app *SimApp) RegisterTendermintService(clientCtx client.Context) { } func (app *SimApp) RegisterNodeService(clientCtx client.Context, cfg config.Config) { - nodeservice.RegisterNodeService(clientCtx, app.GRPCQueryRouter(), cfg) + nodeservice.RegisterNodeService(clientCtx, app.GRPCQueryRouter(), cfg, app.CommitMultiStore()) } // GetMaccPerms returns a copy of the module account permissions From 609ddf6eb81ae47443764a7fcb2e10815be6211c Mon Sep 17 00:00:00 2001 From: Cordtus Date: Mon, 26 Jan 2026 15:40:47 -0700 Subject: [PATCH 3/3] test(systemtests): add gRPC systemtest for node Status endpoint --- tests/systemtests/node_service_test.go | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/systemtests/node_service_test.go diff --git a/tests/systemtests/node_service_test.go b/tests/systemtests/node_service_test.go new file mode 100644 index 000000000000..434d03c00448 --- /dev/null +++ b/tests/systemtests/node_service_test.go @@ -0,0 +1,113 @@ +//go:build system_test + +package systemtests + +import ( + "context" + "fmt" + "path/filepath" + "strconv" + "testing" + + "github.com/creachadair/tomledit" + "github.com/creachadair/tomledit/parser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "cosmossdk.io/systemtests" + + "github.com/cosmos/cosmos-sdk/client/grpc/node" +) + +// TestNodeStatusGRPC tests the Status gRPC endpoint to verify earliest_store_height. +func TestNodeStatusGRPC(t *testing.T) { + sut := systemtests.Sut + sut.ResetChain(t) + sut.StartChain(t) + sut.AwaitNBlocks(t, 3) + + grpcAddr := fmt.Sprintf("localhost:%d", 9090) + conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + queryClient := node.NewServiceClient(conn) + + t.Run("returns valid store heights", func(t *testing.T) { + resp, err := queryClient.Status(context.Background(), &node.StatusRequest{}) + require.NoError(t, err) + t.Logf("Status response: earliest_store_height=%d, height=%d", resp.EarliestStoreHeight, resp.Height) + + assert.GreaterOrEqual(t, resp.EarliestStoreHeight, uint64(1)) + assert.GreaterOrEqual(t, resp.Height, uint64(1)) + assert.GreaterOrEqual(t, resp.Height, resp.EarliestStoreHeight) + }) + + t.Run("earliest stable on unpruned chain", func(t *testing.T) { + resp1, err := queryClient.Status(context.Background(), &node.StatusRequest{}) + require.NoError(t, err) + initial := resp1.EarliestStoreHeight + + sut.AwaitNBlocks(t, 2) + + resp2, err := queryClient.Status(context.Background(), &node.StatusRequest{}) + require.NoError(t, err) + assert.Equal(t, initial, resp2.EarliestStoreHeight) + }) +} + +// TestNodeStatusWithStatePruning tests earliest_store_height increases with state pruning. +func TestNodeStatusWithStatePruning(t *testing.T) { + const pruningKeepRecent = 5 + const pruningInterval = 10 + + sut := systemtests.Sut + sut.ResetChain(t) + + // Configure state pruning + for i := 0; i < sut.NodesCount(); i++ { + appTomlPath := filepath.Join(sut.NodeDir(i), "config", "app.toml") + systemtests.EditToml(appTomlPath, func(doc *tomledit.Document) { + setNodeString(doc, "custom", "pruning") + setNodeString(doc, strconv.Itoa(pruningKeepRecent), "pruning-keep-recent") + setNodeString(doc, strconv.Itoa(pruningInterval), "pruning-interval") + }) + } + + sut.StartChain(t) + + grpcAddr := fmt.Sprintf("localhost:%d", 9090) + conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + queryClient := node.NewServiceClient(conn) + + resp, err := queryClient.Status(context.Background(), &node.StatusRequest{}) + require.NoError(t, err) + initialEarliest := resp.EarliestStoreHeight + t.Logf("Initial: earliest_store_height=%d, height=%d", initialEarliest, resp.Height) + + // Wait for pruning to occur + blocksToWait := pruningInterval + pruningKeepRecent + 5 + t.Logf("Waiting %d blocks for state pruning...", blocksToWait) + sut.AwaitNBlocks(t, int64(blocksToWait)) + + resp, err = queryClient.Status(context.Background(), &node.StatusRequest{}) + require.NoError(t, err) + t.Logf("After %d blocks: earliest_store_height=%d, height=%d", blocksToWait, resp.EarliestStoreHeight, resp.Height) + + assert.Greater(t, resp.EarliestStoreHeight, initialEarliest, + "earliest_store_height should increase after pruning") + assert.GreaterOrEqual(t, resp.Height, resp.EarliestStoreHeight) +} + +func setNodeString(doc *tomledit.Document, val string, xpath ...string) { + e := doc.First(xpath...) + if e == nil { + panic(fmt.Sprintf("not found: %v", xpath)) + } + e.Value = parser.MustValue(fmt.Sprintf("%q", val)) +}