Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 14 additions & 15 deletions client/grpc/node/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}

Expand All @@ -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(),
Comment thread
technicallyty marked this conversation as resolved.
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
}
2 changes: 1 addition & 1 deletion client/grpc/node/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
5 changes: 4 additions & 1 deletion enterprise/poa/simapp/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
4 changes: 3 additions & 1 deletion runtime/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions server/mock/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
4 changes: 3 additions & 1 deletion simapp/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 55 additions & 2 deletions store/rootmulti/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ import (
)

const (
latestVersionKey = "s/latest"
commitInfoKeyFmt = "s/%d" // s/<version>
latestVersionKey = "s/latest"
earliestVersionKey = "s/earliest"
commitInfoKeyFmt = "s/%d" // s/<version>
)

const iavlDisablefastNodeDefault = false
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
Comment thread
Cordtus marked this conversation as resolved.

return nil
}

Expand Down Expand Up @@ -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))
Expand Down
74 changes: 74 additions & 0 deletions store/rootmulti/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
3 changes: 3 additions & 0 deletions store/types/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
113 changes: 113 additions & 0 deletions tests/systemtests/node_service_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading