diff --git a/app/app.go b/app/app.go index 3225851286..a660f3f28d 100644 --- a/app/app.go +++ b/app/app.go @@ -41,6 +41,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" servertypes "github.com/sei-protocol/sei-chain/sei-cosmos/server/types" storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" + storev2_rootmulti "github.com/sei-protocol/sei-chain/sei-cosmos/storev2/rootmulti" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" genesistypes "github.com/sei-protocol/sei-chain/sei-cosmos/types/genesis" @@ -719,6 +720,15 @@ func New( panic(fmt.Sprintf("failed to open trace db: %s", dbErr)) } app.EvmKeeper.SetTraceDB(traceDB) + + if app.evmRPCConfig.TraceBakeUseSnapshot { + if rs, ok := app.CommitMultiStore().(*storev2_rootmulti.Store); ok { + app.EvmKeeper.SetTraceSnapshotStore(evmkeeper.NewTraceSnapshotStore(app.evmRPCConfig.TraceBakeSnapshotWindow)) + app.EvmKeeper.SetTraceSnapshotCapture(rs.SnapshotSCStore) + } else { + logger.Info("trace_bake_use_snapshot set but commit multistore is not storev2 rootmulti; falling back to SS-pebble") + } + } } app.adminConfig, err = admin.ReadConfig(appOpts) if err != nil { @@ -1085,6 +1095,9 @@ func (app *App) HandleClose() error { errs = append(errs, fmt.Errorf("failed to close trace db: %w", err)) } } + if ts := app.EvmKeeper.TraceSnapshotStore(); ts != nil { + ts.Close() + } // Close receipt store if app.receiptStore != nil { @@ -2446,6 +2459,49 @@ func (app *App) RPCContextProvider(i int64) sdk.Context { return ctx.WithIsEVM(true).WithTraceMode(true).WithIsCheckTx(false) } +// SnapshotAwareRPCContextProvider builds SDK contexts from in-memory memiavl +// snapshots; falls back to RPCContextProvider on miss or unsupported backend. +func (app *App) SnapshotAwareRPCContextProvider() evmrpc.TraceContextProvider { + store := app.EvmKeeper.TraceSnapshotStore() + if store == nil { + return evmrpc.TraceContextProvider(func(i int64) (sdk.Context, func()) { + return app.RPCContextProvider(i), func() {} + }) + } + rs, ok := app.CommitMultiStore().(*storev2_rootmulti.Store) + if !ok { + return evmrpc.TraceContextProvider(func(i int64) (sdk.Context, func()) { + return app.RPCContextProvider(i), func() {} + }) + } + return evmrpc.TraceContextProvider(func(i int64) (sdk.Context, func()) { + if i <= 0 { + return app.RPCContextProvider(i), func() {} + } + snap, release := store.Lease(i) + if snap == nil { + return app.RPCContextProvider(i), func() {} + } + cms, err := rs.CacheMultiStoreFromCommitter(snap) + if err != nil { + release() + return app.RPCContextProvider(i), func() {} + } + checkCtx := app.GetCheckCtx() + closestUpgrade, upgradeHeight := app.UpgradeKeeper.GetClosestUpgrade(checkCtx, i) + if closestUpgrade == "" && upgradeHeight == 0 { + closestUpgrade = LatestUpgrade + } + ctx := sdk.NewContext(cms, checkCtx.BlockHeader(), true). + WithMinGasPrices(checkCtx.MinGasPrices()). + WithBlockHeight(i). + WithClosestUpgradeName(closestUpgrade). + WithIsEVM(true).WithTraceMode(true).WithIsCheckTx(false) + ctx = ctx.WithConsensusParams(app.GetConsensusParams(ctx)) + return ctx, release + }) +} + // RegisterTendermintService implements the Application.RegisterTendermintService method. func (app *App) RegisterTendermintService(clientCtx client.Context) { tmservice.RegisterTendermintService(app.GRPCQueryRouter(), clientCtx, app.interfaceRegistry) @@ -2460,8 +2516,10 @@ func (app *App) RegisterTendermintService(clientCtx client.Context) { return app.legacyEncodingConfig.TxConfig } + rpcCtxProvider := app.RPCContextProvider + traceCtxProvider := app.SnapshotAwareRPCContextProvider() if app.evmRPCConfig.HTTPEnabled { - evmHTTPServer, err := evmrpc.NewEVMHTTPServer(app.evmRPCConfig, clientCtx.Client, &app.EvmKeeper, app.BeginBlockKeepers, app.BaseApp, app.TracerAnteHandler, app.RPCContextProvider, txConfigProvider, DefaultNodeHome, app.GetStateStore(), nil) + evmHTTPServer, err := evmrpc.NewEVMHTTPServer(app.evmRPCConfig, clientCtx.Client, &app.EvmKeeper, app.BeginBlockKeepers, app.BaseApp, app.TracerAnteHandler, rpcCtxProvider, txConfigProvider, DefaultNodeHome, app.GetStateStore(), nil, traceCtxProvider) if err != nil { panic(err) } @@ -2474,7 +2532,7 @@ func (app *App) RegisterTendermintService(clientCtx client.Context) { } if app.evmRPCConfig.WSEnabled { - evmWSServer, err := evmrpc.NewEVMWebSocketServer(app.evmRPCConfig, clientCtx.Client, &app.EvmKeeper, app.BeginBlockKeepers, app.BaseApp, app.TracerAnteHandler, app.RPCContextProvider, txConfigProvider, DefaultNodeHome, app.GetStateStore()) + evmWSServer, err := evmrpc.NewEVMWebSocketServer(app.evmRPCConfig, clientCtx.Client, &app.EvmKeeper, app.BeginBlockKeepers, app.BaseApp, app.TracerAnteHandler, rpcCtxProvider, txConfigProvider, DefaultNodeHome, app.GetStateStore()) if err != nil { panic(err) } diff --git a/app/app_test.go b/app/app_test.go index 794d9e31f0..279f9bf694 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -27,6 +27,7 @@ import ( cosmosed25519 "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/keys/ed25519" "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/keys/secp256k1" cryptotypes "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/types" + storev2_rootmulti "github.com/sei-protocol/sei-chain/sei-cosmos/storev2/rootmulti" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-cosmos/types/tx/signing" xauthsigning "github.com/sei-protocol/sei-chain/sei-cosmos/x/auth/signing" @@ -37,6 +38,7 @@ import ( tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types" testkeeper "github.com/sei-protocol/sei-chain/testutil/keeper" "github.com/sei-protocol/sei-chain/x/evm/config" + evmkeeper "github.com/sei-protocol/sei-chain/x/evm/keeper" evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" "github.com/sei-protocol/sei-chain/x/evm/types/ethtx" oracletypes "github.com/sei-protocol/sei-chain/x/oracle/types" @@ -1048,6 +1050,40 @@ func TestRPCContextProviderPopulatesConsensusParams(t *testing.T) { }) } +func TestSnapshotAwareRPCContextProviderPopulatesConsensusParams(t *testing.T) { + valPub := cosmosed25519.GenPrivKey().PubKey() + accAddr := sdk.AccAddress(valPub.Address()) + genAcc := authtypes.NewBaseAccount(accAddr, nil, 0, 0) + balance := banktypes.Balance{ + Address: accAddr.String(), + Coins: sdk.NewCoins(sdk.NewCoin(sdk.DefaultBondDenom, sdk.DefaultPowerReduction)), + } + tmPub, err := cryptocodec.ToTmPubKeyInterface(valPub) + require.NoError(t, err) + valSet := tmtypes.NewValidatorSet([]*tmtypes.Validator{tmtypes.NewValidator(tmPub, 1)}) + + testApp := app.SetupWithGenesisValSet(t, valSet, []authtypes.GenesisAccount{genAcc}, balance) + rs, ok := testApp.CommitMultiStore().(*storev2_rootmulti.Store) + require.True(t, ok) + snap := rs.SnapshotSCStore() + require.NotNil(t, snap) + testApp.EvmKeeper.SetTraceSnapshotStore(evmkeeper.NewTraceSnapshotStore(8)) + testApp.EvmKeeper.TraceSnapshotStore().Put(snap.Version(), snap) + + ctx, release := testApp.SnapshotAwareRPCContextProvider()(snap.Version()) + defer release() + + cp := ctx.ConsensusParams() + require.NotNil(t, cp, "ConsensusParams must be populated on the snapshot RPC ctx") + require.NotNil(t, cp.Block, "Block params must be populated") + require.Equal(t, app.DefaultConsensusParams.Block.MaxGas, cp.Block.MaxGas) + require.Equal(t, app.DefaultConsensusParams.Block.MaxBytes, cp.Block.MaxBytes) + + rpcCtx := testApp.RPCContextProvider(snap.Version()) + require.Equal(t, rpcCtx.ChainID(), ctx.ChainID()) + require.Equal(t, rpcCtx.BlockTime(), ctx.BlockTime()) +} + func finalizeToBlockProcessReq(req *abci.RequestFinalizeBlock) *app.BlockProcessRequest { var height int64 var blockTime time.Time diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index da4aa34c76..23d104c709 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -150,6 +150,12 @@ type Config struct { TraceBakeQueueSize int `mapstructure:"trace_bake_queue_size"` // in-flight height queue (default 4096) TraceBakeTracers []string `mapstructure:"trace_bake_tracers"` // tracers to bake (default ["callTracer"]) TraceBakeWindowBlocks int64 `mapstructure:"trace_bake_window_blocks"` // rolling prune window; 0 disables + + // TraceBakeUseSnapshot captures an in-memory memiavl snapshot at + // EndBlock and uses it as the state backend for the baker, bypassing + // SS-pebble. Requires CosmosOnly write mode; falls back transparently. + TraceBakeUseSnapshot bool `mapstructure:"trace_bake_use_snapshot"` + TraceBakeSnapshotWindow int64 `mapstructure:"trace_bake_snapshot_window"` // recent snapshots to keep (default 64) } var DefaultConfig = Config{ @@ -187,11 +193,13 @@ var DefaultConfig = Config{ "sei_getEVMAddress", "sei_getCosmosTx", }, - TraceBakeEnabled: false, - TraceBakeWorkers: 1, - TraceBakeQueueSize: 4096, - TraceBakeTracers: []string{"callTracer"}, - TraceBakeWindowBlocks: 0, + TraceBakeEnabled: false, + TraceBakeWorkers: 1, + TraceBakeQueueSize: 4096, + TraceBakeTracers: []string{"callTracer"}, + TraceBakeWindowBlocks: 0, + TraceBakeUseSnapshot: false, + TraceBakeSnapshotWindow: 64, } const ( @@ -230,6 +238,8 @@ const ( flagTraceBakeQueueSize = "evm.trace_bake_queue_size" flagTraceBakeTracers = "evm.trace_bake_tracers" flagTraceBakeWindowBlocks = "evm.trace_bake_window_blocks" + flagTraceBakeUseSnapshot = "evm.trace_bake_use_snapshot" + flagTraceBakeSnapshotWindow = "evm.trace_bake_snapshot_window" ) func ReadConfig(opts servertypes.AppOptions) (Config, error) { @@ -410,6 +420,16 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } + if v := opts.Get(flagTraceBakeUseSnapshot); v != nil { + if cfg.TraceBakeUseSnapshot, err = cast.ToBoolE(v); err != nil { + return cfg, err + } + } + if v := opts.Get(flagTraceBakeSnapshotWindow); v != nil { + if cfg.TraceBakeSnapshotWindow, err = cast.ToInt64E(v); err != nil { + return cfg, err + } + } return cfg, nil } @@ -585,4 +605,17 @@ trace_bake_tracers = [{{- range $i, $t := .EVM.TraceBakeTracers }}{{- if $i }}, # Rolling cache window: prune blocks older than (latest - this). # 0 disables pruning (cache grows forever). trace_bake_window_blocks = {{ .EVM.TraceBakeWindowBlocks }} + +# TraceBakeUseSnapshot, when true, uses in-memory memiavl snapshots as the +# state backend for trace baking when the store backend supports snapshots. +# Watch these metrics when enabling on a high-throughput node: +# - memiavl_mem_node_total_size / memiavl_num_of_mem_node: rise if held +# snapshots are pinning too many COW nodes; lower the window or drop the +# memiavl snapshot interval. +# - trace baker dropped/baked counters: dropped > 0 or baked lagging chain +# tip means the baker is falling behind. +trace_bake_use_snapshot = {{ .EVM.TraceBakeUseSnapshot }} + +# Number of recent memiavl snapshots to retain for trace baking. +trace_bake_snapshot_window = {{ .EVM.TraceBakeSnapshotWindow }} ` diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 9c9dbc5dee..baac9655f2 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -139,7 +139,9 @@ func (o *opts) Get(k string) interface{} { k == "evm.trace_bake_workers" || k == "evm.trace_bake_queue_size" || k == "evm.trace_bake_tracers" || - k == "evm.trace_bake_window_blocks" { + k == "evm.trace_bake_window_blocks" || + k == "evm.trace_bake_use_snapshot" || + k == "evm.trace_bake_snapshot_window" { return nil } panic("unknown key") diff --git a/evmrpc/server.go b/evmrpc/server.go index 1b5e534ed2..a130bb8031 100644 --- a/evmrpc/server.go +++ b/evmrpc/server.go @@ -45,6 +45,7 @@ func NewEVMHTTPServer( homeDir string, stateStore types.StateStore, isPanicOrSyntheticTxFunc func(ctx context.Context, hash common.Hash) (bool, error), // used in *ExcludeTraceFail endpoints + traceCtxProviders ...TraceContextProvider, ) (EVMServer, error) { // Initialize global worker pool with configuration (metrics are embedded in pool) @@ -89,8 +90,13 @@ func NewEVMHTTPServer( sendAPI := NewSendAPI(tmClient, txConfigProvider, &SendConfig{slow: config.Slow}, k, beginBlockKeepers, ctxProvider, homeDir, simulateConfig, app, antehandler, ConnectionTypeHTTP, globalBlockCache, cacheCreationMutex, watermarks) ctx := ctxProvider(LatestCtxHeight) + traceCtxProvider := defaultTraceContextProvider(ctxProvider) + if len(traceCtxProviders) > 0 && traceCtxProviders[0] != nil { + traceCtxProvider = traceCtxProviders[0] + } txAPI := NewTransactionAPI(tmClient, k, ctxProvider, txConfigProvider, homeDir, ConnectionTypeHTTP, watermarks, globalBlockCache, cacheCreationMutex) debugAPI := NewDebugAPI(tmClient, k, beginBlockKeepers, ctxProvider, txConfigProvider, simulateConfig, app, antehandler, ConnectionTypeHTTP, config, globalBlockCache, cacheCreationMutex, watermarks) + debugAPI.backend.SetTraceContextProvider(traceCtxProvider) if config.TraceBakeEnabled { StartTraceBakerForDebugAPI(debugAPI, TraceBakerConfig{ Workers: config.TraceBakeWorkers, @@ -109,6 +115,7 @@ func NewEVMHTTPServer( seiTxAPI := NewSeiTransactionAPI(tmClient, k, ctxProvider, txConfigProvider, homeDir, ConnectionTypeHTTP, isPanicOrSyntheticTxFunc, watermarks, globalBlockCache, cacheCreationMutex) seiDebugAPI := NewSeiDebugAPI(tmClient, k, beginBlockKeepers, ctxProvider, txConfigProvider, simulateConfig, app, antehandler, ConnectionTypeHTTP, config, globalBlockCache, cacheCreationMutex, watermarks) + seiDebugAPI.backend.SetTraceContextProvider(traceCtxProvider) // DB semaphore aligned with worker count dbReadSemaphore := make(chan struct{}, workerCount) diff --git a/evmrpc/simulate.go b/evmrpc/simulate.go index 42b60efdd6..e3b85d9b97 100644 --- a/evmrpc/simulate.go +++ b/evmrpc/simulate.go @@ -228,6 +228,7 @@ var _ tracers.Backend = (*Backend)(nil) type Backend struct { *eth.EthAPIBackend ctxProvider func(int64) sdk.Context + traceCtxProvider TraceContextProvider txConfigProvider func(int64) client.TxConfig keeper *keeper.Keeper tmClient rpcclient.Client @@ -240,6 +241,8 @@ type Backend struct { watermarks *WatermarkManager } +type TraceContextProvider func(int64) (sdk.Context, func()) + func NewBackend( ctxProvider func(int64) sdk.Context, keeper *keeper.Keeper, @@ -255,6 +258,7 @@ func NewBackend( ) *Backend { return &Backend{ ctxProvider: ctxProvider, + traceCtxProvider: defaultTraceContextProvider(ctxProvider), keeper: keeper, beginBlockKeepers: beginBlockKeepers, txConfigProvider: txConfigProvider, @@ -268,6 +272,18 @@ func NewBackend( } } +func defaultTraceContextProvider(ctxProvider func(int64) sdk.Context) TraceContextProvider { + return func(height int64) (sdk.Context, func()) { + return ctxProvider(height), func() {} + } +} + +func (b *Backend) SetTraceContextProvider(provider TraceContextProvider) { + if provider != nil { + b.traceCtxProvider = provider + } +} + func (b *Backend) StateAndHeaderByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (vm.StateDB, *ethtypes.Header, error) { tmBlock, isLatestBlock, err := b.getBlockByNumberOrHash(ctx, blockNrOrHash) if err != nil { @@ -461,10 +477,16 @@ func (b *Backend) HeaderByNumber(ctx context.Context, bn rpc.BlockNumber) (*etht func (b *Backend) StateAtTransaction(ctx context.Context, block *ethtypes.Block, txIndex int, reexec uint64) (*ethtypes.Transaction, vm.BlockContext, vm.StateDB, tracers.StateReleaseFunc, error) { emptyRelease := func() {} - stateDB, txs, err := b.ReplayTransactionTillIndex(ctx, block, txIndex-1) + stateDB, txs, release, err := b.replayTransactionTillIndex(ctx, block, txIndex-1, b.traceCtxProvider) if err != nil { return nil, vm.BlockContext{}, nil, emptyRelease, err } + success := false + defer func() { + if !success { + release() + } + }() blockContext, err := b.keeper.GetVMBlockContext(stateDB.(*state.DBImpl).Ctx(), b.keeper.GetGasPool()) if err != nil { return nil, vm.BlockContext{}, nil, emptyRelease, err @@ -489,23 +511,37 @@ func (b *Backend) StateAtTransaction(ctx context.Context, block *ethtypes.Block, evmMsg = msg } ethTx, _ := evmMsg.AsTransaction() - return ethTx, *blockContext, stateDB, emptyRelease, nil + success = true + return ethTx, *blockContext, stateDB, release, nil } func (b *Backend) ReplayTransactionTillIndex(ctx context.Context, block *ethtypes.Block, txIndex int) (vm.StateDB, tmtypes.Txs, error) { + stateDB, txs, _, err := b.replayTransactionTillIndex(ctx, block, txIndex, defaultTraceContextProvider(b.ctxProvider)) + return stateDB, txs, err +} + +func (b *Backend) replayTransactionTillIndex(ctx context.Context, block *ethtypes.Block, txIndex int, ctxProvider TraceContextProvider) (vm.StateDB, tmtypes.Txs, tracers.StateReleaseFunc, error) { + emptyRelease := func() {} // Short circuit if it's genesis block. if block.Number().Int64() == 0 { - return nil, nil, errors.New("no transaction in genesis") + return nil, nil, emptyRelease, errors.New("no transaction in genesis") } - sdkCtx, tmBlock, err := b.initializeBlock(ctx, block) + sdkCtx, tmBlock, release, err := b.initializeBlock(ctx, block, ctxProvider) if err != nil { - return nil, nil, err + return nil, nil, emptyRelease, err } + success := false + defer func() { + if !success { + release() + } + }() if txIndex > len(tmBlock.Block.Txs)-1 { - return nil, nil, errors.New("did not find transaction") + return nil, nil, emptyRelease, errors.New("did not find transaction") } if txIndex < 0 { - return state.NewDBImpl(sdkCtx.WithIsEVM(true), b.keeper, true), tmBlock.Block.Txs, nil + success = true + return state.NewDBImpl(sdkCtx.WithIsEVM(true), b.keeper, true), tmBlock.Block.Txs, release, nil } for idx, tx := range tmBlock.Block.Txs { if idx > txIndex { @@ -520,42 +556,49 @@ func (b *Backend) ReplayTransactionTillIndex(ctx context.Context, block *ethtype } _ = b.app.DeliverTx(sdkCtx, abci.RequestDeliverTxV2{Tx: tx}, sdkTx, sha256.Sum256(tx)) } - return state.NewDBImpl(sdkCtx.WithIsEVM(true), b.keeper, true), tmBlock.Block.Txs, nil + success = true + return state.NewDBImpl(sdkCtx.WithIsEVM(true), b.keeper, true), tmBlock.Block.Txs, release, nil } func (b *Backend) StateAtBlock(ctx context.Context, block *ethtypes.Block, reexec uint64, base vm.StateDB, readOnly bool, preferDisk bool) (vm.StateDB, tracers.StateReleaseFunc, error) { emptyRelease := func() {} - sdkCtx, _, err := b.initializeBlock(ctx, block) + sdkCtx, _, release, err := b.initializeBlock(ctx, block, b.traceCtxProvider) if err != nil { return nil, emptyRelease, err } statedb := state.NewDBImpl(sdkCtx, b.keeper, true) - return statedb, emptyRelease, nil + return statedb, release, nil } -func (b *Backend) initializeBlock(ctx context.Context, block *ethtypes.Block) (sdk.Context, *coretypes.ResultBlock, error) { +func (b *Backend) initializeBlock(ctx context.Context, block *ethtypes.Block, ctxProvider TraceContextProvider) (sdk.Context, *coretypes.ResultBlock, tracers.StateReleaseFunc, error) { + emptyRelease := func() {} // get the parent block using block.parentHash prevBlockHeight := block.Number().Int64() - 1 blockNumber := block.Number().Int64() tmBlock, err := blockByNumberRespectingWatermarks(ctx, b.tmClient, b.watermarks, &blockNumber, 1) if err != nil { - return sdk.Context{}, nil, fmt.Errorf("cannot find block %d from tendermint", blockNumber) + return sdk.Context{}, nil, emptyRelease, fmt.Errorf("cannot find block %d from tendermint", blockNumber) } res, err := b.tmClient.Validators(ctx, &prevBlockHeight, nil, nil) // todo: load all if err != nil { - return sdk.Context{}, nil, fmt.Errorf("failed to load validators for block %d from tendermint", prevBlockHeight) + return sdk.Context{}, nil, emptyRelease, fmt.Errorf("failed to load validators for block %d from tendermint", prevBlockHeight) } TraceTendermintIfApplicable(ctx, "Validators", []string{stringifyInt64Ptr(&prevBlockHeight)}, res) reqBeginBlock := tmBlock.Block.ToReqBeginBlock(res.Validators) reqBeginBlock.Simulate = true - sdkCtx := b.ctxProvider(prevBlockHeight).WithBlockHeight(blockNumber).WithBlockTime(tmBlock.Block.Time) + baseCtx, baseRelease := ctxProvider(prevBlockHeight) + sdkCtx := baseCtx.WithBlockHeight(blockNumber).WithBlockTime(tmBlock.Block.Time) legacyabci.BeginBlock(sdkCtx, blockNumber, reqBeginBlock.LastCommitInfo.Votes, tmBlock.Block.Evidence.ToABCI(), b.beginBlockKeepers) + nextCtx, nextRelease := ctxProvider(sdkCtx.BlockHeight()) sdkCtx = sdkCtx.WithNextMs( - b.ctxProvider(sdkCtx.BlockHeight()).MultiStore(), + nextCtx.MultiStore(), []string{"oracle", "oracle_mem"}, ) - return sdkCtx, tmBlock, nil + return sdkCtx, tmBlock, func() { + nextRelease() + baseRelease() + }, nil } func (b *Backend) GetEVM(_ context.Context, msg *core.Message, stateDB vm.StateDB, h *ethtypes.Header, vmConfig *vm.Config, blockCtx *vm.BlockContext) *vm.EVM { diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index 3710209a1e..fe15818ead 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -288,10 +288,8 @@ func (api *DebugAPI) tryBlockTraceCacheByHash(ctx context.Context, hash common.H return blockTraceCacheGet(cache, height, txHashesOf(block.Transactions()), config) } -// tryExcludeFailBlockTraceCacheByNumber serves the *ExcludeTraceFail variants: -// parses the cached per-block row and drops entries with non-empty Error. -// Per-tx rows aren't usable here — they store only Result, so Error info is -// lost; if the per-block row is missing we miss-and-fall-through to live. +// tryExcludeFailBlockTraceCacheByNumber reads the per-block JSON row, parses it, +// and drops entries with Error set. Per-tx rows are skipped — they omit Error. func (api *DebugAPI) tryExcludeFailBlockTraceCacheByNumber(ctx context.Context, number rpc.BlockNumber, config *tracers.TraceConfig) ([]*tracers.TxTraceResult, bool) { cache := api.keeper.TraceDB() name := bakeableTracerName(config) diff --git a/sei-cosmos/storev2/rootmulti/store.go b/sei-cosmos/storev2/rootmulti/store.go index a0ec63e825..d3442c2549 100644 --- a/sei-cosmos/storev2/rootmulti/store.go +++ b/sei-cosmos/storev2/rootmulti/store.go @@ -57,6 +57,8 @@ type Store struct { histProofSem chan struct{} histProofLimiter *rate.Limiter + + snapshotSCStoreWarnOnce sync.Once } type VersionedChangesets struct { @@ -336,6 +338,48 @@ func (rs *Store) CacheMultiStoreForExport(version int64) (types.CacheMultiStore, return cacheMs, nil } +// SnapshotSCStore returns an O(1) SC snapshot, or nil when flatkv is engaged. +func (rs *Store) SnapshotSCStore() sctypes.Committer { + rs.mtx.RLock() + defer rs.mtx.RUnlock() + if rs.scStore == nil { + rs.snapshotSCStoreWarnOnce.Do(func() { + logger.Info("SC snapshot unavailable; trace baker snapshot path will fall back to disk-backed state") + }) + return nil + } + snap := rs.scStore.Copy() + if snap == nil { + rs.snapshotSCStoreWarnOnce.Do(func() { + logger.Info("SC snapshot unavailable; trace baker snapshot path will fall back to disk-backed state") + }) + } + return snap +} + +// CacheMultiStoreFromCommitter builds a CacheMultiStore backed by snap for +// IAVL stores; non-IAVL stores use their live counterparts. +func (rs *Store) CacheMultiStoreFromCommitter(snap sctypes.Committer) (types.CacheMultiStore, error) { + if snap == nil { + return nil, fmt.Errorf("snap is nil") + } + rs.mtx.RLock() + defer rs.mtx.RUnlock() + stores := make(map[types.StoreKey]types.CacheWrapper) + for k, store := range rs.ckvStores { + if store.GetStoreType() != types.StoreTypeIAVL { + stores[k] = store + continue + } + tree := snap.GetChildStoreByName(k.Name()) + if tree == nil { + return nil, fmt.Errorf("snapshot missing child store %q", k.Name()) + } + stores[k] = commitment.NewStore(tree) + } + return cachemulti.NewStore(nil, stores, rs.storeKeys, nil, nil, nil), nil +} + // GetStore Implements interface MultiStore func (rs *Store) GetStore(key types.StoreKey) types.Store { return rs.ckvStores[key] diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index 672748a998..c8c675fa26 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -341,6 +341,34 @@ func (cs *CompositeCommitStore) GetChildStoreByName(name string) types.CommitKVS return cs.cosmosCommitter.GetChildStoreByName(name) } +// Copy returns an in-memory snapshot, or nil when flatkv is engaged +// (no in-memory primitive; a partial snapshot would miss EVM state). +func (cs *CompositeCommitStore) Copy() types.Committer { + if cs == nil || cs.cosmosCommitter == nil || cs.flatkvCommitter != nil { + return nil + } + cosmosCopy, ok := cs.cosmosCommitter.Copy().(*memiavl.CommitStore) + if !ok || cosmosCopy == nil { + return nil + } + return &CompositeCommitStore{ + cosmosCommitter: cosmosCopy, + homeDir: cs.homeDir, + config: cs.config, + } +} + +// ReleaseSnapshotRefs releases refs held by a copied in-memory snapshot without +// closing DB-level resources shared with the live store. +func (cs *CompositeCommitStore) ReleaseSnapshotRefs() error { + if cs == nil || cs.cosmosCommitter == nil { + return nil + } + err := cs.cosmosCommitter.ReleaseSnapshotRefs() + cs.cosmosCommitter = nil + return err +} + // Rollback rolls back to the specified version func (cs *CompositeCommitStore) Rollback(targetVersion int64) error { if err := cs.cosmosCommitter.Rollback(targetVersion); err != nil { diff --git a/sei-db/state_db/sc/memiavl/db.go b/sei-db/state_db/sc/memiavl/db.go index 0161257802..b99bb58ec4 100644 --- a/sei-db/state_db/sc/memiavl/db.go +++ b/sei-db/state_db/sc/memiavl/db.go @@ -745,6 +745,15 @@ func (db *DB) copy() *DB { } } +func (db *DB) ReleaseSnapshotRefs() error { + if db == nil || db.MultiTree == nil { + return nil + } + db.mtx.Lock() + defer db.mtx.Unlock() + return db.MultiTree.Close() +} + // RewriteSnapshot writes the current version of memiavl into a snapshot, and update the `current` symlink. func (db *DB) RewriteSnapshot(ctx context.Context) error { db.mtx.Lock() @@ -921,6 +930,13 @@ func (db *DB) rewriteSnapshotBackground() error { cloned := db.copy() go func() { defer close(ch) + // Release per-tree snapshot refs; don't call cloned.Close() which + // would also tear down the live db's writer pool and stream handler. + defer func() { + if err := cloned.MultiTree.Close(); err != nil { + logger.Error("failed to release cloned snapshot refs after rewrite", "err", err) + } + }() startTime := time.Now() logger.Info("start rewriting snapshot", "version", cloned.Version()) rewriteStart := time.Now() diff --git a/sei-db/state_db/sc/memiavl/snapshot.go b/sei-db/state_db/sc/memiavl/snapshot.go index 871c3f9632..72794a802e 100644 --- a/sei-db/state_db/sc/memiavl/snapshot.go +++ b/sei-db/state_db/sc/memiavl/snapshot.go @@ -116,8 +116,8 @@ func (w *rateLimitedWriter) Write(p []byte) (n int, err error) { return written, nil } -// Snapshot manage the lifecycle of mmap-ed files for the snapshot, -// it must out live the objects that derived from it. +// Snapshot manages mmap-ed files for a single tree snapshot. +// Refcounted: Tree.Copy() Acquires; Close unmaps only on the final release. type Snapshot struct { nodesMap *MmapFile leavesMap *MmapFile @@ -136,11 +136,27 @@ type Snapshot struct { // nil means empty snapshot root *PersistedNode + + refCount atomic.Int32 // starts at 1; Close unmaps when it hits 0 } func NewEmptySnapshot(version uint32) *Snapshot { - return &Snapshot{ - version: version, + s := &Snapshot{version: version} + s.refCount.Store(1) + return s +} + +// Acquire increments the refcount; pair with one Close. Panics on a +// snapshot whose refcount is already 0 — that's a programming error. +func (snapshot *Snapshot) Acquire() { + for { + cur := snapshot.refCount.Load() + if cur <= 0 { + panic("memiavl: Acquire on closed Snapshot") + } + if snapshot.refCount.CompareAndSwap(cur, cur+1) { + return + } } } @@ -243,6 +259,7 @@ func OpenSnapshot(snapshotDir string, opts Options) (*Snapshot, error) { nodesLayout: nodesData, leavesLayout: leavesData, } + snapshot.refCount.Store(1) if nodesLen > 0 { snapshot.root = &PersistedNode{ @@ -267,8 +284,26 @@ func OpenSnapshot(snapshotDir string, opts Options) (*Snapshot, error) { return snapshot, nil } -// Close closes the file and mmap handles, clears the buffers. +// Close decrements the refcount; the mmap handles are unmapped only on +// the final Close. func (snapshot *Snapshot) Close() error { + for { + cur := snapshot.refCount.Load() + if cur <= 0 { + err := fmt.Errorf("memiavl: Close on closed Snapshot") + logger.Error("snapshot over-close", "version", snapshot.version, "ref_count", cur) + return err + } + if cur > 1 { + if snapshot.refCount.CompareAndSwap(cur, cur-1) { + return nil + } + continue + } + if snapshot.refCount.CompareAndSwap(1, 0) { + break + } + } var errs []error if snapshot.nodesMap != nil { @@ -281,8 +316,15 @@ func (snapshot *Snapshot) Close() error { errs = append(errs, snapshot.kvsMap.Close()) } - // reset to an empty tree - *snapshot = *NewEmptySnapshot(snapshot.version) + snapshot.nodesMap = nil + snapshot.leavesMap = nil + snapshot.kvsMap = nil + snapshot.nodes = nil + snapshot.leaves = nil + snapshot.kvs = nil + snapshot.nodesLayout = Nodes{} + snapshot.leavesLayout = Leaves{} + snapshot.root = nil return errors.Join(errs...) } diff --git a/sei-db/state_db/sc/memiavl/snapshot_refcount_test.go b/sei-db/state_db/sc/memiavl/snapshot_refcount_test.go new file mode 100644 index 0000000000..0d4ac9b8a0 --- /dev/null +++ b/sei-db/state_db/sc/memiavl/snapshot_refcount_test.go @@ -0,0 +1,172 @@ +package memiavl + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/stretchr/testify/require" +) + +// Regression: a Copy()'d tree must remain readable across a memiavl +// snapshot rewrite + reload. Before refcounting *Snapshot, the rewrite +// path called snapshot.Close() (munmap) while a held trace-baker copy +// was still pointing into it — crashing reads in cmpbody. +func TestTreeCopyOutlivesSnapshotRewrite(t *testing.T) { + db, err := OpenDB(0, Options{ + Config: Config{SnapshotKeepRecent: 0}, + Dir: t.TempDir(), + CreateIfMissing: true, + InitialStores: []string{"test"}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + cs := []*proto.NamedChangeSet{{ + Name: "test", + Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ + {Key: []byte("hello"), Value: []byte("world")}, + {Key: []byte("hello1"), Value: []byte("world1")}, + }}, + }} + require.NoError(t, db.ApplyChangeSets(cs)) + _, err = db.Commit() + require.NoError(t, err) + + held := db.Copy() + defer func() { _ = held.ReleaseSnapshotRefs() }() + + require.NoError(t, db.RewriteSnapshot(context.Background())) + require.NoError(t, db.Reload()) + + cs2 := []*proto.NamedChangeSet{{ + Name: "test", + Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ + {Key: []byte("hello"), Value: []byte("OVERWRITTEN")}, + }}, + }} + require.NoError(t, db.ApplyChangeSets(cs2)) + _, err = db.Commit() + require.NoError(t, err) + + tree := held.TreeByName("test") + require.NotNil(t, tree) + require.Equal(t, "world", string(tree.Get([]byte("hello")))) + require.Equal(t, "world1", string(tree.Get([]byte("hello1")))) +} + +func TestMultipleCopiesIndependentLifecycle(t *testing.T) { + db, err := OpenDB(0, Options{ + Config: Config{SnapshotKeepRecent: 0}, + Dir: t.TempDir(), + CreateIfMissing: true, + InitialStores: []string{"test"}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + require.NoError(t, db.ApplyChangeSets([]*proto.NamedChangeSet{{ + Name: "test", + Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ + {Key: []byte("k"), Value: []byte("v")}, + }}, + }})) + _, err = db.Commit() + require.NoError(t, err) + + copyA := db.Copy() + copyB := db.Copy() + + require.NoError(t, db.RewriteSnapshot(context.Background())) + require.NoError(t, db.Reload()) + + require.NoError(t, copyA.ReleaseSnapshotRefs()) + + tree := copyB.TreeByName("test") + require.NotNil(t, tree) + require.Equal(t, "v", string(tree.Get([]byte("k")))) + + require.NoError(t, copyB.ReleaseSnapshotRefs()) +} + +func TestTreeCopyConcurrentRewriteReload(t *testing.T) { + db, err := OpenDB(0, Options{ + Config: Config{SnapshotKeepRecent: 0}, + Dir: t.TempDir(), + CreateIfMissing: true, + InitialStores: []string{"test"}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + var pairs []*proto.KVPair + for i := 0; i < 32; i++ { + pairs = append(pairs, &proto.KVPair{ + Key: []byte(fmt.Sprintf("key-%02d", i)), + Value: []byte(fmt.Sprintf("value-%02d", i)), + }) + } + require.NoError(t, db.ApplyChangeSets([]*proto.NamedChangeSet{{ + Name: "test", + Changeset: proto.ChangeSet{Pairs: pairs}, + }})) + _, err = db.Commit() + require.NoError(t, err) + + errCh := make(chan error, 32) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 40; j++ { + held := db.Copy() + tree := held.TreeByName("test") + if tree == nil { + errCh <- fmt.Errorf("missing copied tree") + _ = held.ReleaseSnapshotRefs() + return + } + time.Sleep(time.Millisecond) + if got := tree.Get([]byte("key-00")); len(got) == 0 { + errCh <- fmt.Errorf("missing copied value") + _ = held.ReleaseSnapshotRefs() + return + } + if err := held.ReleaseSnapshotRefs(); err != nil { + errCh <- err + return + } + } + }() + } + + for i := 0; i < 10; i++ { + require.NoError(t, db.RewriteSnapshot(context.Background())) + require.NoError(t, db.Reload()) + require.NoError(t, db.ApplyChangeSets([]*proto.NamedChangeSet{{ + Name: "test", + Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{ + {Key: []byte(fmt.Sprintf("round-%02d", i)), Value: []byte("ok")}, + }}, + }})) + _, err = db.Commit() + require.NoError(t, err) + } + + wg.Wait() + close(errCh) + for err := range errCh { + require.NoError(t, err) + } +} + +func TestSnapshotDoubleCloseReturnsError(t *testing.T) { + snapshot := NewEmptySnapshot(1) + require.NoError(t, snapshot.Close()) + require.Error(t, snapshot.Close()) + require.Panics(t, snapshot.Acquire) +} diff --git a/sei-db/state_db/sc/memiavl/store.go b/sei-db/state_db/sc/memiavl/store.go index 9737299359..43201c67f1 100644 --- a/sei-db/state_db/sc/memiavl/store.go +++ b/sei-db/state_db/sc/memiavl/store.go @@ -96,6 +96,29 @@ func (cs *CommitStore) LoadVersion(targetVersion int64, readOnly bool) (types.Co return cs, nil } +// Copy returns an O(1) memiavl snapshot; COW nodes are shared with the live store. +func (cs *CommitStore) Copy() types.Committer { + if cs == nil || cs.db == nil { + return nil + } + return &CommitStore{ + db: cs.db.Copy(), + opts: cs.opts, + homeDir: cs.homeDir, + } +} + +// ReleaseSnapshotRefs releases refs held by a copied in-memory snapshot without +// closing DB-level resources shared with the live store. +func (cs *CommitStore) ReleaseSnapshotRefs() error { + if cs == nil || cs.db == nil { + return nil + } + err := cs.db.ReleaseSnapshotRefs() + cs.db = nil + return err +} + func (cs *CommitStore) Commit() (int64, error) { return cs.db.Commit() } diff --git a/sei-db/state_db/sc/memiavl/tree.go b/sei-db/state_db/sc/memiavl/tree.go index 0234457611..b315ba0ae7 100644 --- a/sei-db/state_db/sc/memiavl/tree.go +++ b/sei-db/state_db/sc/memiavl/tree.go @@ -97,8 +97,9 @@ func (t *Tree) SetInitialVersion(initialVersion int64) error { return nil } -// Copy returns a snapshot of the tree which won't be modified by further modifications on the main tree, -// the returned new tree can be accessed concurrently with the main tree. +// Copy returns a concurrent-safe snapshot. Acquires the underlying *Snapshot +// so background rewrites can't unmap it while the copy is live; callers must +// call Close on the returned tree to release the ref. func (t *Tree) Copy() *Tree { t.mtx.RLock() defer t.mtx.RUnlock() @@ -108,6 +109,9 @@ func (t *Tree) Copy() *Tree { } newTree := *t newTree.mtx = &sync.RWMutex{} + if newTree.snapshot != nil { + newTree.snapshot.Acquire() + } return &newTree } diff --git a/sei-db/state_db/sc/types/types.go b/sei-db/state_db/sc/types/types.go index 81b0d18e4f..9bf63f6113 100644 --- a/sei-db/state_db/sc/types/types.go +++ b/sei-db/state_db/sc/types/types.go @@ -123,6 +123,12 @@ type Committer interface { // state with the Committer and must not be used after Close. GetChildStoreByName(name string) CommitKVStore + // Copy returns an in-memory snapshot of the current committer state. + // O(1) for memiavl. Returns nil when the backend can't produce one + // (e.g. flatkv) — callers should treat nil as "snapshot unavailable" + // and fall back to the disk-backed path. + Copy() Committer + // Importer returns an Importer that ingests state at the given version, // typically used to restore from a state-sync snapshot. The caller owns // the returned Importer and must Close it when finished. diff --git a/x/evm/keeper/abci.go b/x/evm/keeper/abci.go index 672b3f85fe..60e950f83b 100644 --- a/x/evm/keeper/abci.go +++ b/x/evm/keeper/abci.go @@ -60,9 +60,16 @@ func (k *Keeper) BeginBlock(ctx sdk.Context) { func (k *Keeper) EndBlock(ctx sdk.Context, height int64, blockGasUsed int64) { defer telemetry.ModuleMeasureSince(types.ModuleName, time.Now(), telemetry.MetricKeyEndBlocker) - // Bake height-1: at EndBlock(N) the indexer's safe latest is N-1, so - // N-1 is the most recent block guaranteed to be queryable. + // Bake height-1: at EndBlock(N) the indexer's safe latest is N-1. When + // the snapshot store is wired, also Put a memiavl snapshot keyed by + // its committed version (= N-1, since Commit fires after EndBlock); + // the baker tracing block H looks up snapshot[H-1]. if !ctx.IsTracing() && height > 1 { + if k.traceSnapshotStore != nil && k.traceSnapshotCapture != nil { + if snap := k.traceSnapshotCapture(); snap != nil { + k.traceSnapshotStore.Put(snap.Version(), snap) + } + } k.traceDB.Enqueue(height - 1) } // TODO: remove after all TxHashes have been removed diff --git a/x/evm/keeper/keeper.go b/x/evm/keeper/keeper.go index e49323e264..85d22dc992 100644 --- a/x/evm/keeper/keeper.go +++ b/x/evm/keeper/keeper.go @@ -31,6 +31,7 @@ import ( stakingkeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/staking/keeper" upgradekeeper "github.com/sei-protocol/sei-chain/sei-cosmos/x/upgrade/keeper" receipt "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + sctypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" ibctransferkeeper "github.com/sei-protocol/sei-chain/sei-ibc-go/modules/apps/transfer/keeper" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types" @@ -97,6 +98,12 @@ type Keeper struct { // traceDB, when non-nil, serves cached debug_trace results and // forwards EndBlock heights to the registered baker. nil-safe. traceDB *TraceDB + + // traceSnapshotStore + traceSnapshotCapture, when set, capture an O(1) + // memiavl snapshot of the SC tree at EndBlock so the baker replays + // against in-memory state instead of SS-pebble. nil-safe. + traceSnapshotStore *TraceSnapshotStore + traceSnapshotCapture func() sctypes.Committer } type AddressNoncePair struct { @@ -164,6 +171,12 @@ func NewKeeper( func (k *Keeper) SetTraceDB(c *TraceDB) { k.traceDB = c } func (k *Keeper) TraceDB() *TraceDB { return k.traceDB } +func (k *Keeper) SetTraceSnapshotStore(s *TraceSnapshotStore) { k.traceSnapshotStore = s } +func (k *Keeper) TraceSnapshotStore() *TraceSnapshotStore { return k.traceSnapshotStore } +func (k *Keeper) SetTraceSnapshotCapture(f func() sctypes.Committer) { + k.traceSnapshotCapture = f +} + func (k *Keeper) SetCustomPrecompiles(cp map[common.Address]putils.VersionedPrecompiles, latestUpgrade string) { k.customPrecompiles = cp k.latestUpgrade = latestUpgrade diff --git a/x/evm/keeper/trace_snapshot.go b/x/evm/keeper/trace_snapshot.go new file mode 100644 index 0000000000..b3678b97b9 --- /dev/null +++ b/x/evm/keeper/trace_snapshot.go @@ -0,0 +1,129 @@ +package keeper + +import ( + "sync" + + sctypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" +) + +// TraceSnapshotStore holds bounded in-memory SC snapshots keyed by block height. +// Each entry is a Committer.Copy() sharing COW nodes with the live memiavl tree. +// +// Operational signals to watch when this store is in use: +// - memiavl gauges MemNodeTotalSize / NumOfMemNode: rise if held snapshots +// pin too many COW nodes (bigger window or higher TPS amplifies this). +// - trace baker counters (BakedCount / DroppedCount / FailedCount): if +// DroppedCount climbs or BakedCount lags the chain tip, the baker is +// falling behind and trace cache hit rate will drop. +type TraceSnapshotStore struct { + mu sync.Mutex + snapshots map[int64]sctypes.Committer + window int64 + latest int64 +} + +type snapshotRefReleaser interface { + ReleaseSnapshotRefs() error +} + +func NewTraceSnapshotStore(window int64) *TraceSnapshotStore { + if window <= 0 { + window = 64 + } else if window < 2 { + window = 2 + } + return &TraceSnapshotStore{ + snapshots: make(map[int64]sctypes.Committer), + window: window, + } +} + +// Put records a snapshot and evicts entries older than (latest - window). +// In-flight traces use Lease, so evicted map entries can be closed explicitly. +func (s *TraceSnapshotStore) Put(height int64, snap sctypes.Committer) { + if s == nil || snap == nil { + return + } + var toRelease []sctypes.Committer + s.mu.Lock() + + if old := s.snapshots[height]; old != nil { + toRelease = append(toRelease, old) + } + s.snapshots[height] = snap + if height > s.latest { + s.latest = height + } + cutoff := s.latest - s.window + for h := range s.snapshots { + if h <= cutoff { + toRelease = append(toRelease, s.snapshots[h]) + delete(s.snapshots, h) + } + } + s.mu.Unlock() + + for _, snap := range toRelease { + releaseSnapshotRefs(snap) + } +} + +// Lease returns an owned snapshot copy and a release function for trace state. +func (s *TraceSnapshotStore) Lease(height int64) (sctypes.Committer, func()) { + if s == nil { + return nil, func() {} + } + s.mu.Lock() + defer s.mu.Unlock() + snap := s.snapshots[height] + if snap == nil { + return nil, func() {} + } + leased := snap.Copy() + if leased == nil { + return nil, func() {} + } + return leased, func() { releaseSnapshotRefs(leased) } +} + +func (s *TraceSnapshotStore) Get(height int64) sctypes.Committer { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.snapshots[height] +} + +// Close releases all retained snapshots. +func (s *TraceSnapshotStore) Close() { + if s == nil { + return + } + var toRelease []sctypes.Committer + s.mu.Lock() + for h := range s.snapshots { + toRelease = append(toRelease, s.snapshots[h]) + delete(s.snapshots, h) + } + s.mu.Unlock() + + for _, snap := range toRelease { + releaseSnapshotRefs(snap) + } +} + +func releaseSnapshotRefs(snap sctypes.Committer) { + if snap == nil { + return + } + if releaser, ok := snap.(snapshotRefReleaser); ok { + if err := releaser.ReleaseSnapshotRefs(); err != nil { + logger.Warn("failed to release trace snapshot refs", "err", err) + } + return + } + if err := snap.Close(); err != nil { + logger.Warn("failed to close trace snapshot", "err", err) + } +} diff --git a/x/evm/keeper/trace_snapshot_test.go b/x/evm/keeper/trace_snapshot_test.go new file mode 100644 index 0000000000..831dc76609 --- /dev/null +++ b/x/evm/keeper/trace_snapshot_test.go @@ -0,0 +1,129 @@ +package keeper + +import ( + "sync/atomic" + "testing" + + ics23 "github.com/confio/ics23/go" + "github.com/sei-protocol/sei-chain/sei-db/proto" + sctypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" + "github.com/stretchr/testify/require" + dbm "github.com/tendermint/tm-db" +) + +// fakeCommitter is a minimal Committer stub that records Close so we can +// assert lifecycle. Methods the snapshot store doesn't touch panic to make +// surprise calls visible. +type fakeCommitter struct { + closed int32 + copies int32 + id int64 +} + +func (f *fakeCommitter) Close() error { atomic.StoreInt32(&f.closed, 1); return nil } +func (f *fakeCommitter) IsClosed() bool { return atomic.LoadInt32(&f.closed) == 1 } +func (f *fakeCommitter) Version() int64 { return f.id } +func (f *fakeCommitter) Initialize(_ []string) { panic("unused") } +func (f *fakeCommitter) Commit() (int64, error) { panic("unused") } +func (f *fakeCommitter) GetLatestVersion() (int64, error) { panic("unused") } +func (f *fakeCommitter) GetEarliestVersion() (int64, error) { panic("unused") } +func (f *fakeCommitter) Get(string, []byte) ([]byte, bool, error) { panic("unused") } +func (f *fakeCommitter) Has(string, []byte) (bool, error) { panic("unused") } +func (f *fakeCommitter) Iterator(string, []byte, []byte, bool) (dbm.Iterator, error) { + panic("unused") +} +func (f *fakeCommitter) GetProof(string, []byte) (*ics23.CommitmentProof, error) { + panic("unused") +} +func (f *fakeCommitter) ApplyChangeSets(_ []*proto.NamedChangeSet) error { panic("unused") } +func (f *fakeCommitter) ApplyUpgrades(_ []*proto.TreeNameUpgrade) error { panic("unused") } +func (f *fakeCommitter) WorkingCommitInfo() *proto.CommitInfo { panic("unused") } +func (f *fakeCommitter) LastCommitInfo() *proto.CommitInfo { panic("unused") } +func (f *fakeCommitter) LoadVersion(int64, bool) (sctypes.Committer, error) { + panic("unused") +} +func (f *fakeCommitter) Rollback(int64) error { panic("unused") } +func (f *fakeCommitter) SetInitialVersion(int64) error { panic("unused") } +func (f *fakeCommitter) GetChildStoreByName(string) sctypes.CommitKVStore { panic("unused") } +func (f *fakeCommitter) Copy() sctypes.Committer { + atomic.AddInt32(&f.copies, 1) + return &fakeCommitter{id: f.id} +} +func (f *fakeCommitter) Importer(int64) (sctypes.Importer, error) { panic("unused") } +func (f *fakeCommitter) Exporter(int64) (sctypes.Exporter, error) { panic("unused") } + +func TestTraceSnapshotStorePutGet(t *testing.T) { + s := NewTraceSnapshotStore(8) + c := &fakeCommitter{id: 100} + s.Put(100, c) + require.Same(t, sctypes.Committer(c), s.Get(100)) + require.Nil(t, s.Get(99)) +} + +func TestTraceSnapshotStoreEvictsOlderThanWindow(t *testing.T) { + s := NewTraceSnapshotStore(3) + committers := make([]*fakeCommitter, 6) + for i := range committers { + committers[i] = &fakeCommitter{id: int64(100 + i)} + s.Put(int64(100+i), committers[i]) + } + // window=3 keeps heights in (105-3, 105] = {103, 104, 105}. + for _, h := range []int64{103, 104, 105} { + require.NotNil(t, s.Get(h), "height %d should be retained", h) + } + for _, h := range []int64{100, 101, 102} { + require.Nil(t, s.Get(h), "height %d should be evicted", h) + } + for i := 0; i < 3; i++ { + require.True(t, committers[i].IsClosed(), "evicted entry %d should be closed by Put", 100+i) + } +} + +func TestTraceSnapshotStoreReplaceClosesOld(t *testing.T) { + s := NewTraceSnapshotStore(8) + old := &fakeCommitter{id: 200} + s.Put(200, old) + newer := &fakeCommitter{id: 200} + s.Put(200, newer) + require.True(t, old.IsClosed()) + require.False(t, newer.IsClosed()) + require.Same(t, sctypes.Committer(newer), s.Get(200)) +} + +func TestTraceSnapshotStoreLeaseReturnsOwnedCopy(t *testing.T) { + s := NewTraceSnapshotStore(8) + orig := &fakeCommitter{id: 100} + s.Put(100, orig) + leased, release := s.Lease(100) + require.NotNil(t, leased) + require.False(t, orig.IsClosed()) + require.Equal(t, int32(1), atomic.LoadInt32(&orig.copies)) + require.NotSame(t, orig, leased) + + release() + require.False(t, orig.IsClosed(), "releasing lease must not close stored snapshot") + require.True(t, leased.(*fakeCommitter).IsClosed()) +} + +func TestTraceSnapshotStoreCloseDropsRefs(t *testing.T) { + s := NewTraceSnapshotStore(8) + cs := []*fakeCommitter{{id: 1}, {id: 2}, {id: 3}} + for i, c := range cs { + s.Put(int64(i+1), c) + } + s.Close() + for i, c := range cs { + require.True(t, c.IsClosed(), "Close must release retained snapshot") + require.Nil(t, s.Get(int64(i+1))) + } +} + +func TestTraceSnapshotStoreNilSafe(t *testing.T) { + var s *TraceSnapshotStore + require.Nil(t, s.Get(1)) + leased, release := s.Lease(1) + require.Nil(t, leased) + release() + s.Put(1, &fakeCommitter{id: 1}) // no panic + s.Close() // no panic +}