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
25 changes: 24 additions & 1 deletion sei-cosmos/server/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"fmt"
"math"
"runtime"
"strings"

Expand All @@ -12,6 +13,7 @@ import (
"github.com/sei-protocol/sei-chain/sei-db/config"
"github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl"
tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config"
"github.com/spf13/cast"
"github.com/spf13/viper"
)

Expand Down Expand Up @@ -65,6 +67,10 @@ type BaseConfig struct {
// Note: Commitment of state will be attempted on the corresponding block.
HaltHeight uint64 `mapstructure:"halt-height"`

// FreezeHeight contains a non-zero block height at which the node stops
// before executing the block while continuing to serve RPC.
FreezeHeight uint64 `mapstructure:"freeze-height"`

// HaltTime contains a non-zero minimum block time (in Unix seconds) at which
// a node will gracefully halt and shutdown that can be used to assist
// upgrades and testing.
Expand Down Expand Up @@ -258,6 +264,7 @@ func DefaultConfig() *Config {
PruningKeepRecent: "0",
PruningKeepEvery: "0",
PruningInterval: "0",
FreezeHeight: 0,
MinRetainBlocks: 0,
IndexEvents: nil,
CompactionInterval: 0,
Expand Down Expand Up @@ -314,6 +321,10 @@ func GetConfig(v *viper.Viper) (Config, error) {
if !ok {
return Config{}, fmt.Errorf("failed to parse global-labels config")
}
freezeHeight, err := cast.ToUint64E(v.Get("freeze-height"))
if err != nil {
return Config{}, fmt.Errorf("invalid freeze-height: %w", err)
}

globalLabels := make([][]string, 0, len(globalLabelsRaw))
for idx, glr := range globalLabelsRaw {
Expand Down Expand Up @@ -347,6 +358,7 @@ func GetConfig(v *viper.Viper) (Config, error) {
PruningKeepRecent: v.GetString("pruning-keep-recent"),
PruningInterval: v.GetString("pruning-interval"),
HaltHeight: v.GetUint64("halt-height"),
FreezeHeight: freezeHeight,
HaltTime: v.GetUint64("halt-time"),
IndexEvents: v.GetStringSlice("index-events"),
MinRetainBlocks: v.GetUint64("min-retain-blocks"),
Expand Down Expand Up @@ -427,7 +439,7 @@ func GetConfig(v *viper.Viper) (Config, error) {
}, nil
}

// ValidateBasic returns an error if min-gas-prices field is empty in BaseConfig. Otherwise, it returns nil.
// ValidateBasic validates the server configuration.
func (c Config) ValidateBasic(tendermintConfig *tmcfg.Config) error {
if c.MinGasPrices == "" {
return sdkerrors.ErrAppConfig.Wrap("set min gas price in app.toml or flag or env variable")
Expand All @@ -437,6 +449,17 @@ func (c Config) ValidateBasic(tendermintConfig *tmcfg.Config) error {
"cannot enable state sync snapshots with '%s' pruning setting", storetypes.PruningOptionEverything,
)
}
return c.ValidateFreeze()
}

// ValidateFreeze validates the configuration that controls freeze mode.
func (c Config) ValidateFreeze() error {
if c.FreezeHeight > math.MaxInt64 {
return sdkerrors.ErrAppConfig.Wrapf("freeze-height must not exceed %d", int64(math.MaxInt64))
}
if c.FreezeHeight > 0 && (c.HaltHeight > 0 || c.HaltTime > 0) {
return sdkerrors.ErrAppConfig.Wrap("freeze-height cannot be combined with halt-height or halt-time")
}

return nil
}
9 changes: 9 additions & 0 deletions sei-cosmos/server/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ func TestValidateBasic(t *testing.T) {
}
}

func TestGetConfigRejectsNegativeFreezeHeight(t *testing.T) {
v := viper.New()
v.Set("telemetry.global-labels", []interface{}{})
v.Set("freeze-height", -1)

_, err := GetConfig(v)
require.Error(t, err)
}

func TestGetMinGasPrices(t *testing.T) {
tests := []struct {
name string
Expand Down
39 changes: 39 additions & 0 deletions sei-cosmos/server/config/freeze_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package config

import (
"math"
"testing"

"github.com/stretchr/testify/require"
)

func TestValidateFreezeBackport(t *testing.T) {
tests := []struct {
name string
freezeHeight uint64
haltHeight uint64
haltTime uint64
wantErr string
}{
{name: "disabled"},
{name: "enabled", freezeHeight: 100},
{name: "height overflow", freezeHeight: uint64(math.MaxInt64) + 1, wantErr: "freeze-height must not exceed"},
{name: "halt height", freezeHeight: 100, haltHeight: 100, wantErr: "cannot be combined"},
{name: "halt time", freezeHeight: 100, haltTime: 100, wantErr: "cannot be combined"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := DefaultConfig()
cfg.FreezeHeight = tt.freezeHeight
cfg.HaltHeight = tt.haltHeight
cfg.HaltTime = tt.haltTime
err := cfg.ValidateFreeze()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tt.wantErr)
})
}
}
4 changes: 4 additions & 0 deletions sei-cosmos/server/config/toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ occ-enabled = {{ .BaseConfig.OccEnabled }}
# Note: Commitment of state will be attempted on the corresponding block.
halt-height = {{ .BaseConfig.HaltHeight }}

# FreezeHeight contains a non-zero block height at which the node stops before
# executing the block while continuing to serve RPC.
freeze-height = {{ .BaseConfig.FreezeHeight }}

# HaltTime contains a non-zero minimum block time (in Unix seconds) at which
# a node will gracefully halt and shutdown that can be used to assist upgrades
# and testing.
Expand Down
14 changes: 12 additions & 2 deletions sei-cosmos/server/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const (
flagCPUProfile = "cpu-profile"
FlagMinGasPrices = "minimum-gas-prices"
FlagHaltHeight = "halt-height"
FlagFreezeHeight = "freeze-height"
FlagHaltTime = "halt-time"
FlagInterBlockCache = "inter-block-cache"
FlagUnsafeSkipUpgrades = "unsafe-skip-upgrades"
Expand Down Expand Up @@ -102,6 +103,8 @@ the ABCI Commit phase, the node will check if the current block height is greate
the halt-height or if the current block time is greater than or equal to the halt-time. If so, the
node will attempt to gracefully shutdown and the block will not be committed. In addition, the node
will not be able to commit subsequent blocks.
The '--freeze-height' flag instead keeps the process and RPC servers running while preventing block
sync and consensus from executing the block at the configured height or advancing beyond it.
For profiling and benchmarking purposes, CPU profiling can be enabled via the '--cpu-profile' flag
which accepts a path for the resulting pprof file.
The node may be started in a 'query only' mode where only the gRPC and JSON HTTP
Expand Down Expand Up @@ -211,6 +214,7 @@ func addStartNodeFlags(cmd *cobra.Command, defaultNodeHome string) {
cmd.Flags().String(FlagMinGasPrices, "", "Minimum gas prices to accept for transactions; Any fee in a tx must meet this minimum (e.g. 0.01photino;0.0001stake)")
cmd.Flags().IntSlice(FlagUnsafeSkipUpgrades, []int{}, "Skip a set of upgrade heights to continue the old binary")
cmd.Flags().Uint64(FlagHaltHeight, 0, "Block height at which to gracefully halt the chain and shutdown the node")
cmd.Flags().Uint64(FlagFreezeHeight, 0, "Block height to stop before executing while continuing to serve RPC")
cmd.Flags().Uint64(FlagHaltTime, 0, "Minimum block time (in Unix seconds) at which to gracefully halt the chain and shutdown the node")
cmd.Flags().Bool(FlagInterBlockCache, true, "Enable inter-block caching")
cmd.Flags().String(flagCPUProfile, "", "Enable CPU profiling and write to the provided file")
Expand Down Expand Up @@ -295,6 +299,13 @@ func startInProcess(
if err != nil {
return err
}
if err := config.ValidateFreeze(); err != nil {
return err
}
gRPCOnly := ctx.Viper.GetBool(flagGRPCOnly)
if gRPCOnly && config.FreezeHeight > 0 {
return errors.New("freeze-height cannot be used with grpc-only mode")
}

if err := config.ValidateBasic(ctx.Config); err != nil {
logger.Error("WARNING: The minimum-gas-prices config in app.toml is set to the empty string. " +
Expand All @@ -309,8 +320,6 @@ func startInProcess(
}
}()

gRPCOnly := ctx.Viper.GetBool(flagGRPCOnly)

var restartMtx sync.Mutex
restartCh := make(chan struct{})
restartEvent := func() {
Expand Down Expand Up @@ -351,6 +360,7 @@ func startInProcess(
tracerProviderOptions,
nodeMetricsProvider,
tmtypes.DefaultConsensusPolicy(),
node.WithFreezeHeight(config.FreezeHeight),
)
if err != nil {
return fmt.Errorf("error creating node: %w", err)
Expand Down
37 changes: 37 additions & 0 deletions sei-tendermint/internal/blocksync/reactor.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ type SyncerConfig struct {
EventBus *eventbus.EventBus
RestartEvent func()
SelfRemediationConfig *config.SelfRemediationConfig
FreezeHeight uint64
}

// Reactor owns the blocksync channel and always-on query serving path, while
Expand Down Expand Up @@ -156,6 +157,7 @@ type syncController struct {
blocksBehindThreshold uint64
blocksBehindCheckInterval time.Duration
restartCooldownSeconds uint64
freezeHeight uint64

// blocksyncReady fires when the active sync routines should begin processing
// work, either during OnStart or later via SwitchToBlockSync.
Expand Down Expand Up @@ -189,6 +191,7 @@ func NewReactor(
blocksBehindThreshold: cfg.SelfRemediationConfig.BlocksBehindThreshold,
blocksBehindCheckInterval: time.Duration(cfg.SelfRemediationConfig.BlocksBehindCheckIntervalSeconds) * time.Second, //nolint:gosec // validated in config.ValidateBasic against MaxInt64
restartCooldownSeconds: cfg.SelfRemediationConfig.RestartCooldownSeconds,
freezeHeight: cfg.FreezeHeight,
blocksyncReady: utils.NewAtomicSend(utils.None[blocksyncResult]()),
startInBlockSync: cfg.BlockSync,
}
Expand Down Expand Up @@ -378,6 +381,9 @@ func (s *syncController) run(ctx context.Context) error {
if r, ok := s.consReactor.Get(); ok {
logger.Info("switching to consensus reactor", "height", handoff.height, "blocks_synced", handoff.blocksSynced, "state_synced", handoff.stateSynced, "max_peer_height", handoff.maxPeerHeight)
r.SwitchToConsensus(handoff.state, handoff.blocksSynced > 0 || handoff.stateSynced)
if s.shouldFreeze(handoff.state) {
return nil
}
s.autoRestartIfBehind(ctx, pool)
}
return nil
Expand Down Expand Up @@ -465,6 +471,10 @@ func (s *syncController) requestRoutine(ctx context.Context, pool *BlockPool) er
//
// NOTE: Don't sleep in the FOR_LOOP or otherwise slow it down!
func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initialState sm.State, stateSynced bool) (consensusHandoff, error) {
if handoff, frozen := s.frozenHandoff(pool, initialState, 0, stateSynced); frozen {
return handoff, nil
}

var (
trySyncTicker = time.NewTicker(trySyncIntervalMS * time.Millisecond)
switchToConsensusTicker = time.NewTicker(switchToConsensusIntervalSeconds * time.Second)
Expand Down Expand Up @@ -581,6 +591,9 @@ func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initi

s.metrics.RecordConsMetrics(first)
blocksSynced++
if handoff, frozen := s.frozenHandoff(pool, state, blocksSynced, stateSynced); frozen {
return handoff, nil
}

if blocksSynced%100 == 0 {
lastRate = 0.9*lastRate + 0.1*(100/time.Since(lastHundred).Seconds())
Expand All @@ -596,6 +609,26 @@ func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initi
}
}

func (s *syncController) frozenHandoff(pool *BlockPool, state sm.State, blocksSynced uint64, stateSynced bool) (consensusHandoff, bool) {
if !s.shouldFreeze(state) {
return consensusHandoff{}, false
}
height, _, _ := pool.GetStatus()
logger.Info("Block sync stopped before configured freeze height", "last_block_height", state.LastBlockHeight, "freeze_height", s.freezeHeight)
return consensusHandoff{
state: state,
blocksSynced: blocksSynced,
stateSynced: stateSynced,
height: height,
maxPeerHeight: pool.MaxPeerHeight(),
}, true
}

func (s *syncController) shouldFreeze(state sm.State) bool {
height := startHeightForState(state)
return s.freezeHeight > 0 && height >= 0 && uint64(height) >= s.freezeHeight //nolint:gosec // negative heights are rejected first.
}

// autoRestartIfBehind will check if the node is behind the max peer height by
// a certain threshold. If it is, the node will attempt to restart itself.
// TODO(gprusak): this should be a sub task of the consensus reactor instead.
Expand All @@ -612,6 +645,10 @@ func (s *syncController) autoRestartIfBehind(ctx context.Context, pool *BlockPoo
select {
case <-time.After(s.blocksBehindCheckInterval):
selfHeight := s.store.Height()
if s.freezeHeight > 0 && selfHeight >= 0 && uint64(selfHeight) >= s.freezeHeight-1 { //nolint:gosec // negative heights are rejected first.
logger.Info("Auto remediation stopped at configured freeze height", "selfHeight", selfHeight, "freeze_height", s.freezeHeight)
return
}
maxPeerHeight := pool.MaxPeerHeight()
threshold := int64(s.blocksBehindThreshold) //nolint:gosec // validated in config.ValidateBasic against MaxInt64
behindHeight := maxPeerHeight - selfHeight
Expand Down
47 changes: 47 additions & 0 deletions sei-tendermint/internal/blocksync/reactor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"runtime"
"strings"
"testing"
"testing/synctest"
"time"

"github.com/sei-protocol/sei-chain/sei-tendermint/internal/mempool"
Expand All @@ -27,6 +28,7 @@ import (
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/store"
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/test/factory"
"github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils"
utilsrequire "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require"
pb "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/blocksync"
"github.com/sei-protocol/sei-chain/sei-tendermint/types"
)
Expand Down Expand Up @@ -618,6 +620,30 @@ func TestPoolRoutine_RetriesAfterValidationFailure(t *testing.T) {
}
}

func TestAutoRestartStopsAtFreezeBoundary(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const freezeHeight = uint64(101)
mockBlockStore := new(MockBlockStore)
mockBlockStore.On("Height").Return(int64(freezeHeight - 1))

blockPool := &BlockPool{
height: int64(freezeHeight),
maxPeerHeight: int64(freezeHeight + 100),
}
restart := utils.NewAtomicSend(false)
syncer := &syncController{
store: mockBlockStore,
blocksBehindThreshold: 1,
blocksBehindCheckInterval: time.Hour,
freezeHeight: freezeHeight,
restartEvent: func() { restart.Store(true) },
}

syncer.autoRestartIfBehind(t.Context(), blockPool)
utilsrequire.False(t, restart.Load())
})
}

func TestQueryResponder_ServesBlockRequestsWhenBlockSyncDisabled(t *testing.T) {
ctx := t.Context()

Expand Down Expand Up @@ -667,6 +693,27 @@ func TestQueryResponder_ServesBlockRequestsWhenBlockSyncDisabled(t *testing.T) {
t.Fatal("did not receive block response")
}

func TestPoolRoutineHandsOffAtFreezeHeight(t *testing.T) {
const freezeHeight = int64(10)
pool := NewBlockPool(freezeHeight, nil)
syncer := &syncController{freezeHeight: uint64(freezeHeight)} //nolint:gosec // the test height is positive.
state := sm.State{InitialHeight: 1, LastBlockHeight: freezeHeight - 1}

handoff, err := syncer.poolRoutine(t.Context(), pool, state, false)
if err != nil {
t.Fatalf("poolRoutine: %v", err)
}
if handoff.state.LastBlockHeight != freezeHeight-1 {
t.Fatalf("handoff state height = %d, want %d", handoff.state.LastBlockHeight, freezeHeight-1)
}
if handoff.height != freezeHeight {
t.Fatalf("handoff pool height = %d, want %d", handoff.height, freezeHeight)
}
if handoff.blocksSynced != 0 {
t.Fatalf("handoff blocks synced = %d, want 0", handoff.blocksSynced)
}
}

func TestQueryResponder_ServesStatusRequestsWhenBlockSyncDisabled(t *testing.T) {
ctx := t.Context()

Expand Down
Loading
Loading