Skip to content
Open
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
23 changes: 12 additions & 11 deletions client/grpc/node/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}

Expand All @@ -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(),
Comment on lines -56 to -59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason why we can't use the sdkCtx.MultiStore() to achieve this functionality?

Copy link
Contributor Author

@Cordtus Cordtus Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I know -- I can change it if that seems like a better way to go.

Edit: we would have to add a ctx.EarliestHeight() to it first, which is more consistent but I guess more code as well. Which do you think is the better path?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain why it would have to be a method on ctx?

Copy link
Contributor Author

@Cordtus Cordtus Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't — my bad. I moved EarliestVersion() from MultiStore to CommitMultiStore instead.

The query context's MultiStore() is an in-memory CacheMultiStore — created in CreateQueryContext via CacheMultiStoreWithVersion(height) with no reference to the root DB or its metadata.

Placing it on CommitMultiStore means CacheMultiStore doesn't need to implement it at all and avoids a stub that just panics (like LatestVersion() currently does on CacheMultiStore, which is the bad example I was following originally). This way the query/service receives CommitMultiStore at registration.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok so if we go that route, we could just get EarliestHeight from ctx and then we don't have to pass around the cms? i'd prefer that

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
}
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, nil)
ctx := sdk.Context{}.WithMinGasPrices(sdk.NewDecCoins(sdk.NewInt64DecCoin("stake", 15)))

resp, err := svr.Config(ctx, &ConfigRequest{})
Expand Down
2 changes: 1 addition & 1 deletion runtime/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
2 changes: 1 addition & 1 deletion simapp/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
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)
}
}

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