diff --git a/blockstm/wrappers.go b/blockstm/wrappers.go index bc5b2d667216..ce594e7ea8c9 100644 --- a/blockstm/wrappers.go +++ b/blockstm/wrappers.go @@ -31,6 +31,11 @@ func (ms msWrapper) LatestVersion() int64 { panic("implement me") } +func (ms msWrapper) EarliestVersion() int64 { + // TODO implement me + panic("implement me") +} + func (ms msWrapper) getCacheWrapper(key storetypes.StoreKey) storetypes.CacheWrapper { return ms.GetStore(key) } 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/go.mod b/go.mod index cf5a1e17afbf..a0b2b54e908d 100644 --- a/go.mod +++ b/go.mod @@ -267,6 +267,8 @@ require ( // Below are the long-lived replace of the Cosmos SDK replace ( + // Use local store module for EarliestVersion implementation + cosmossdk.io/store => ./store // use cosmos fork of keyring github.com/99designs/keyring => github.com/cosmos/keyring v1.2.0 // dgrijalva/jwt-go is deprecated and doesn't receive security updates. diff --git a/runtime/app.go b/runtime/app.go index 5b3cc4705026..01e30aa810c6 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/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 0ec1ff1c8a77..69bd8376961f 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 diff --git a/simapp/go.mod b/simapp/go.mod index 1a15156d05a4..8e22754e09b1 100644 --- a/simapp/go.mod +++ b/simapp/go.mod @@ -38,6 +38,7 @@ require ( cosmossdk.io/collections v1.3.1 // indirect cosmossdk.io/errors v1.0.2 // indirect cosmossdk.io/schema v1.1.0 // indirect + cosmossdk.io/x/feegrant v0.2.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/99designs/keyring v1.2.2 // indirect @@ -264,6 +265,8 @@ require ( // Below are the long-lived replace of the SimApp replace ( + // Use local store module for EarliestVersion implementation + cosmossdk.io/store => ../store // use cosmos fork of keyring github.com/99designs/keyring => github.com/cosmos/keyring v1.2.0 // Simapp always use the latest version of the cosmos-sdk diff --git a/simapp/go.sum b/simapp/go.sum index 2b40d71cab23..090f4e7ce513 100644 --- a/simapp/go.sum +++ b/simapp/go.sum @@ -40,10 +40,10 @@ cosmossdk.io/math v1.5.3 h1:WH6tu6Z3AUCeHbeOSHg2mt9rnoiUWVWaQ2t6Gkll96U= cosmossdk.io/math v1.5.3/go.mod h1:uqcZv7vexnhMFJF+6zh9EWdm/+Ylyln34IvPnBauPCQ= cosmossdk.io/schema v1.1.0 h1:mmpuz3dzouCoyjjcMcA/xHBEmMChN+EHh8EHxHRHhzE= cosmossdk.io/schema v1.1.0/go.mod h1:Gb7pqO+tpR+jLW5qDcNOSv0KtppYs7881kfzakguhhI= -cosmossdk.io/store v1.3.0-beta.0 h1:jwJvAQkMsCY9xJHU/nz7yOo1WnNRvcI/9yLRSgZoFTk= -cosmossdk.io/store v1.3.0-beta.0/go.mod h1:CMz9JQGEA8eRcZv2pK07NgEbL4NEb9wVgzWK4tNQaPg= cosmossdk.io/tools/confix v0.1.2 h1:2hoM1oFCNisd0ltSAAZw2i4ponARPmlhuNu3yy0VwI4= cosmossdk.io/tools/confix v0.1.2/go.mod h1:7XfcbK9sC/KNgVGxgLM0BrFbVcR/+6Dg7MFfpx7duYo= +cosmossdk.io/x/feegrant v0.2.0 h1:oq3WVpoJdxko/XgWmpib63V1mYy9ZQN/1qxDajwGzJ8= +cosmossdk.io/x/feegrant v0.2.0/go.mod h1:9CutZbmhulk/Yo6tQSVD5LG8Lk40ZAQ1OX4d1CODWAE= cosmossdk.io/x/tx v0.14.0 h1:hB3O25kIcyDW/7kMTLMaO8Ripj3yqs5imceVd6c/heA= cosmossdk.io/x/tx v0.14.0/go.mod h1:Tn30rSRA1PRfdGB3Yz55W4Sn6EIutr9xtMKSHij+9PM= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= diff --git a/store/cachemulti/store.go b/store/cachemulti/store.go index 6c890bc598fd..9817f0148657 100644 --- a/store/cachemulti/store.go +++ b/store/cachemulti/store.go @@ -119,6 +119,11 @@ func (cms Store) LatestVersion() int64 { panic("cannot get latest version from branch cached multi-store") } +// EarliestVersion returns the earliest version of the store +func (cms Store) EarliestVersion() int64 { + panic("cannot get earliest version from branch cached multi-store") +} + // GetStoreType returns the type of the store. func (cms Store) GetStoreType() types.StoreType { return types.StoreTypeMulti diff --git a/store/rootmulti/store.go b/store/rootmulti/store.go index 6d6ab6ab7178..5c2b0d6bb10c 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,20 @@ 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. + 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 +1242,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 eeb146a301ea..65de3bc32e76 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 154adcef878c..e962469ca042 100644 --- a/store/types/store.go +++ b/store/types/store.go @@ -145,6 +145,9 @@ type MultiStore interface { // LatestVersion returns the latest version in the store LatestVersion() int64 + + // EarliestVersion returns the earliest version in the store + EarliestVersion() int64 } // CacheMultiStore extends MultiStore with a Write() method. diff --git a/tests/go.mod b/tests/go.mod index e114584b3e3a..80c8e243199b 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -268,6 +268,8 @@ require ( replace ( // We always want to test against the latest version of the simapp. cosmossdk.io/simapp => ../simapp + // Use local store module for EarliestVersion implementation + cosmossdk.io/store => ../store github.com/99designs/keyring => github.com/cosmos/keyring v1.2.0 // We always want to test against the latest version of the SDK. github.com/cosmos/cosmos-sdk => ../. diff --git a/tests/go.sum b/tests/go.sum index cfd3d49987cb..078381451fc6 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -40,8 +40,8 @@ cosmossdk.io/math v1.5.3 h1:WH6tu6Z3AUCeHbeOSHg2mt9rnoiUWVWaQ2t6Gkll96U= cosmossdk.io/math v1.5.3/go.mod h1:uqcZv7vexnhMFJF+6zh9EWdm/+Ylyln34IvPnBauPCQ= cosmossdk.io/schema v1.1.0 h1:mmpuz3dzouCoyjjcMcA/xHBEmMChN+EHh8EHxHRHhzE= cosmossdk.io/schema v1.1.0/go.mod h1:Gb7pqO+tpR+jLW5qDcNOSv0KtppYs7881kfzakguhhI= -cosmossdk.io/store v1.3.0-beta.0 h1:jwJvAQkMsCY9xJHU/nz7yOo1WnNRvcI/9yLRSgZoFTk= -cosmossdk.io/store v1.3.0-beta.0/go.mod h1:CMz9JQGEA8eRcZv2pK07NgEbL4NEb9wVgzWK4tNQaPg= +cosmossdk.io/x/feegrant v0.2.0 h1:oq3WVpoJdxko/XgWmpib63V1mYy9ZQN/1qxDajwGzJ8= +cosmossdk.io/x/feegrant v0.2.0/go.mod h1:9CutZbmhulk/Yo6tQSVD5LG8Lk40ZAQ1OX4d1CODWAE= cosmossdk.io/x/tx v0.14.0 h1:hB3O25kIcyDW/7kMTLMaO8Ripj3yqs5imceVd6c/heA= cosmossdk.io/x/tx v0.14.0/go.mod h1:Tn30rSRA1PRfdGB3Yz55W4Sn6EIutr9xtMKSHij+9PM= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 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)) +}