diff --git a/CHANGELOG.md b/CHANGELOG.md index ff18644b27d2..1d9180e751ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Bug Fixes +* (grpc) [#25647](https://github.com/cosmos/cosmos-sdk/pull/25647) Return actual `earliest_store_height` in `node.Status` gRPC endpoint instead of hardcoded `0`. * (types/query) [#25665](https://github.com/cosmos/cosmos-sdk/issues/25665) Fix pagination offset when querying a collection with predicate function. * (x/staking) [#25649](https://github.com/cosmos/cosmos-sdk/pull/25649) Add missing `defer iterator.Close()` calls in `IterateDelegatorRedelegations` and `GetRedelegations` to prevent resource leaks. * (mempool) [#25563](https://github.com/cosmos/cosmos-sdk/pull/25563) Cleanup sender indices in case of tx replacement. diff --git a/client/grpc/node/service.go b/client/grpc/node/service.go index 144722a9cbfb..45fa1e9dda21 100644 --- a/client/grpc/node/service.go +++ b/client/grpc/node/service.go @@ -12,8 +12,8 @@ import ( ) // 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, earliestStoreHeightFn func() int64) { + RegisterServiceServer(server, NewQueryServer(clientCtx, cfg, earliestStoreHeightFn)) } // RegisterGRPCGatewayRoutes mounts the node gRPC service's GRPC-gateway routes @@ -25,14 +25,16 @@ func RegisterGRPCGatewayRoutes(clientConn gogogrpc.ClientConn, mux *runtime.Serv var _ ServiceServer = queryServer{} type queryServer struct { - clientCtx client.Context - cfg config.Config + clientCtx client.Context + cfg config.Config + earliestStoreHeightFn func() int64 } -func NewQueryServer(clientCtx client.Context, cfg config.Config) ServiceServer { +func NewQueryServer(clientCtx client.Context, cfg config.Config, earliestStoreHeightFn func() int64) ServiceServer { return queryServer{ - clientCtx: clientCtx, - cfg: cfg, + clientCtx: clientCtx, + cfg: cfg, + earliestStoreHeightFn: earliestStoreHeightFn, } } @@ -53,13 +55,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.earliestStoreHeightFn()), + 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..fff11f5cf5ce 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, func() int64 { return 1 }) ctx := sdk.Context{}.WithMinGasPrices(sdk.NewDecCoins(sdk.NewInt64DecCoin("stake", 15))) resp, err := svr.Config(ctx, &ConfigRequest{}) diff --git a/enterprise/poa/simapp/app.go b/enterprise/poa/simapp/app.go index d8449e8cbf65..16c7819e76ed 100644 --- a/enterprise/poa/simapp/app.go +++ b/enterprise/poa/simapp/app.go @@ -475,5 +475,8 @@ func (app *SimApp) RegisterTendermintService(clientCtx client.Context) { } func (app *SimApp) RegisterNodeService(clientCtx client.Context, cfg config.Config) { - nodeservice.RegisterNodeService(clientCtx, app.GRPCQueryRouter(), cfg) + earliestHeightFn := func() int64 { + return app.CommitMultiStore().EarliestVersion() + } + nodeservice.RegisterNodeService(clientCtx, app.GRPCQueryRouter(), cfg, earliestHeightFn) } diff --git a/runtime/app.go b/runtime/app.go index 806bf033a6ba..ab7a8ad73672 100644 --- a/runtime/app.go +++ b/runtime/app.go @@ -225,7 +225,9 @@ 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, func() int64 { + return a.CommitMultiStore().EarliestVersion() + }) } // Configurator returns the app's configurator. 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/simapp/app.go b/simapp/app.go index e556da1446ee..75d06086abb6 100644 --- a/simapp/app.go +++ b/simapp/app.go @@ -828,7 +828,9 @@ 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, func() int64 { + return app.CommitMultiStore().EarliestVersion() + }) } // GetMaccPerms returns a copy of the module account permissions 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) 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)) +}