Skip to content

Commit

Permalink
fix(gno.land): make gno store cache respect rollbacks (#2319)
Browse files Browse the repository at this point in the history
This pull request modifies the current `defaultStore` to support
transactionality in the same way as our tm2 stores. A few new concepts
are introduced:

- A `TransactionStore` interface is added, which extends the
`gnolang.Store` interface to support a Write() method.
- Together with corresponding implementations allowing for
transactionality on its cache maps, this means that the Gno store
retained by the VM keeper is only modified atomically after a
transaction has completed.
- The tm2 `BaseApp` has the new "hooks" `BeginTxHook` and `EndTxHook`.
The former is called right before starting a transaction, and the latter
is called right after finishing it, together with the transaction
result.
- This allows us to plug in the Gno `TransactionalStore` in the
`sdk.Context` through the `BeginTxHook`; and commit the result, if
successful, in the `EndTxHook`.
## Overview of changes

- `gno.land`
- `pkg/gnoland/app.go`: the InitChainer is now additionally responsible
of loading standard libraries. To separate the options related to app
startup globally, and those to genesis, the InitChainer is now a method
of its config struct, `InitChainerConfig`, embedded into the
`AppOptions`.
- `pkg/gnoland/app.go`: `NewAppOptions` is only used in `NewApp`, where
most of its fields were modified, anyway. I replaced it by changing the
`validate()` method to write default values.
- `pkg/gnoland/node_inmemory.go`,
`pkg/integration/testing_integration.go`: these changes were made
necessary to support `gnoland restart` in our txtars, and supporting
fast reloading of standard libraries (`LoadStdlibCached`).
- `pkg/sdk/vm/keeper.go`: removed all the code to load standard
libraries on Initialize, as it's now done in the InitChainer. The hack
introduced in #2568 is no longer necessary as well. Other changes show
how the Gno Store is injected and retrieved from the `sdk.Context`.
- `gnovm/pkg/gnolang/store.go`
- Fork and SwapStores have been removed, in favour of BeginTransaction.
BeginTransaction creates a `TransactionalStore`; the
"transaction-scoped" fields of the defaultStore are swapped with
"transactional" versions (including the allocator, cacheObjects and
cacheTypes/cacheNodes - the latter write to a `txLogMap`).
- ClearObjectCache is still necessary for the case of a transaction with
multiple messages.
- The `Map` interface in `txlog` allows us to have a `txLog` data type
stacked on top of another. This is useful for the cases where we use
`BeginTransaction` in preprocess. (We previously used `Fork`.) See later
in the "open questions" section.
- I added an Iterator method on the `txlog.Map` interface - this will be
compatible with [RangeFunc](https://go.dev/wiki/RangefuncExperiment),
once we switch over to [go1.23](https://go.dev/doc/go1.23).
- `tm2/pkg/sdk`
- As previously mentioned, two hooks were added on the BaseApp to
support injecting application code right before starting a transaction
and then right after ending it; allowing us to inject the code relating
to the Gno.land store while maintaining the modules decoupled
- Other
- `gnovm/pkg/gnolang/machine.go` has a one-line fix for a bug printing
the machine, whereby it would panic if len(blocks) == 0.

## Open questions / notes

- TransactionalStore should be a different implementation altogether;
but decoupling the logic which is used within the stores without doing a
massive copy-and-paste of the entire defaultStore implementation is
non-trivial. See [this
comment](#2319 (comment)),
and [this PR](#2655) for a draft
proposed store refactor, which would render the store more modular and
testable.
- There is an alternative implementation, which in micro-benchmarks
would be somewhat faster, in place of the `txLog`; it's still in
`gnolang/internal/txlog/txlog_test.go` where it also has benchmarks. See
[1347c5f](1347c5f)
for the solution which uses `bufferedTxMap`, without using the
`txlog.Map` interface. The main problem with this approach is that it
does not support stacking bufferedTxMaps, thus there is a different
method to be used in preprocess - `preprocessFork()`.
  • Loading branch information
thehowl committed Sep 5, 2024
1 parent 65ee7a5 commit c1a3341
Show file tree
Hide file tree
Showing 29 changed files with 1,958 additions and 425 deletions.
6 changes: 4 additions & 2 deletions contribs/gnodev/pkg/dev/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,9 @@ func (n *Node) rebuildNode(ctx context.Context, genesis gnoland.GnoGenesisState)

// Setup node config
nodeConfig := newNodeConfig(n.config.TMConfig, n.config.ChainID, genesis)
nodeConfig.GenesisTxHandler = n.genesisTxHandler
nodeConfig.GenesisTxResultHandler = n.genesisTxResultHandler
// Speed up stdlib loading after first start (saves about 2-3 seconds on each reload).
nodeConfig.CacheStdlibLoad = true
nodeConfig.Genesis.ConsensusParams.Block.MaxGas = n.config.MaxGasPerBlock

// recoverFromError handles panics and converts them to errors.
Expand Down Expand Up @@ -512,7 +514,7 @@ func (n *Node) rebuildNode(ctx context.Context, genesis gnoland.GnoGenesisState)
return nil
}

func (n *Node) genesisTxHandler(ctx sdk.Context, tx std.Tx, res sdk.Result) {
func (n *Node) genesisTxResultHandler(ctx sdk.Context, tx std.Tx, res sdk.Result) {
if !res.IsErr() {
return
}
Expand Down
1 change: 0 additions & 1 deletion gno.land/cmd/gnoland/testdata/issue_2283_cacheTypes.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,3 @@ import (
func Call(s string) {
base64.StdEncoding.DecodeString("hey")
}

202 changes: 202 additions & 0 deletions gno.land/cmd/gnoland/testdata/restart_missing_type.txtar

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions gno.land/cmd/gnoland/testdata/restart_nonval.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# This txtar tests for starting up a non-validator node; then also restarting it.
loadpkg gno.land/p/demo/avl

gnoland start -non-validator
gnoland restart
268 changes: 175 additions & 93 deletions gno.land/pkg/gnoland/app.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// Package gnoland contains the bootstrapping code to launch a gno.land node.
package gnoland

import (
"fmt"
"log/slog"
"path/filepath"
"strconv"
"time"

"github.com/gnolang/gno/gno.land/pkg/sdk/vm"
"github.com/gnolang/gno/gnovm/pkg/gnoenv"
Expand All @@ -25,48 +27,46 @@ import (
// Only goleveldb is supported for now.
_ "github.com/gnolang/gno/tm2/pkg/db/_tags"
_ "github.com/gnolang/gno/tm2/pkg/db/goleveldb"
"github.com/gnolang/gno/tm2/pkg/db/memdb"
)

// AppOptions contains the options to create the gno.land ABCI application.
type AppOptions struct {
DB dbm.DB
// `gnoRootDir` should point to the local location of the gno repository.
// It serves as the gno equivalent of GOROOT.
GnoRootDir string
GenesisTxHandler GenesisTxHandler
Logger *slog.Logger
EventSwitch events.EventSwitch
MaxCycles int64
// Whether to cache the result of loading the standard libraries.
// This is useful if you have to start many nodes, like in testing.
// This disables loading existing packages; so it should only be used
// on a fresh database.
CacheStdlibLoad bool
DB dbm.DB // required
Logger *slog.Logger // required
EventSwitch events.EventSwitch // required
MaxCycles int64 // hard limit for cycles in GnoVM
InitChainerConfig // options related to InitChainer
}

func NewAppOptions() *AppOptions {
// DefaultAppOptions provides a "ready" default [AppOptions] for use with
// [NewAppWithOptions], using the provided db.
func TestAppOptions(db dbm.DB) *AppOptions {
return &AppOptions{
GenesisTxHandler: PanicOnFailingTxHandler,
Logger: log.NewNoopLogger(),
DB: memdb.NewMemDB(),
GnoRootDir: gnoenv.RootDir(),
EventSwitch: events.NilEventSwitch(),
DB: db,
Logger: log.NewNoopLogger(),
EventSwitch: events.NewEventSwitch(),
InitChainerConfig: InitChainerConfig{
GenesisTxResultHandler: PanicOnFailingTxResultHandler,
StdlibDir: filepath.Join(gnoenv.RootDir(), "gnovm", "stdlibs"),
CacheStdlibLoad: true,
},
}
}

func (c *AppOptions) validate() error {
if c.Logger == nil {
return fmt.Errorf("no logger provided")
}

if c.DB == nil {
func (c AppOptions) validate() error {
// Required fields
switch {
case c.DB == nil:
return fmt.Errorf("no db provided")
case c.Logger == nil:
return fmt.Errorf("no logger provided")
case c.EventSwitch == nil:
return fmt.Errorf("no event switch provided")
}

return nil
}

// NewAppWithOptions creates the GnoLand application with specified options
// NewAppWithOptions creates the gno.land application with specified options.
func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) {
if err := cfg.validate(); err != nil {
return nil, err
Expand All @@ -88,13 +88,13 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) {
// Construct keepers.
acctKpr := auth.NewAccountKeeper(mainKey, ProtoGnoAccount)
bankKpr := bank.NewBankKeeper(acctKpr)

// XXX: Embed this ?
stdlibsDir := filepath.Join(cfg.GnoRootDir, "gnovm", "stdlibs")
vmk := vm.NewVMKeeper(baseKey, mainKey, acctKpr, bankKpr, stdlibsDir, cfg.MaxCycles)
vmk := vm.NewVMKeeper(baseKey, mainKey, acctKpr, bankKpr, cfg.MaxCycles)

// Set InitChainer
baseApp.SetInitChainer(InitChainer(baseApp, acctKpr, bankKpr, cfg.GenesisTxHandler))
icc := cfg.InitChainerConfig
icc.baseApp = baseApp
icc.acctKpr, icc.bankKpr, icc.vmKpr = acctKpr, bankKpr, vmk
baseApp.SetInitChainer(icc.InitChainer)

// Set AnteHandler
authOptions := auth.AnteOptions{
Expand All @@ -108,14 +108,28 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) {
newCtx sdk.Context, res sdk.Result, abort bool,
) {
// Override auth params.
ctx = ctx.WithValue(
auth.AuthParamsContextKey{}, auth.DefaultParams())
ctx = ctx.
WithValue(auth.AuthParamsContextKey{}, auth.DefaultParams())
// Continue on with default auth ante handler.
newCtx, res, abort = authAnteHandler(ctx, tx, simulate)
return
},
)

// Set begin and end transaction hooks.
// These are used to create gno transaction stores and commit them when finishing
// the tx - in other words, data from a failing transaction won't be persisted
// to the gno store caches.
baseApp.SetBeginTxHook(func(ctx sdk.Context) sdk.Context {
// Create Gno transaction store.
return vmk.MakeGnoTransactionStore(ctx)
})
baseApp.SetEndTxHook(func(ctx sdk.Context, result sdk.Result) {
if result.IsOK() {
vmk.CommitGnoTransactionStore(ctx)
}
})

// Set up the event collector
c := newCollector[validatorUpdate](
cfg.EventSwitch, // global event switch filled by the node
Expand Down Expand Up @@ -143,13 +157,13 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) {

// Initialize the VMKeeper.
ms := baseApp.GetCacheMultiStore()
vmk.Initialize(cfg.Logger, ms, cfg.CacheStdlibLoad)
vmk.Initialize(cfg.Logger, ms)
ms.MultiWrite() // XXX why was't this needed?

return baseApp, nil
}

// NewApp creates the GnoLand application.
// NewApp creates the gno.land application.
func NewApp(
dataRootDir string,
skipFailingGenesisTxs bool,
Expand All @@ -158,9 +172,16 @@ func NewApp(
) (abci.Application, error) {
var err error

cfg := NewAppOptions()
cfg := &AppOptions{
Logger: logger,
EventSwitch: evsw,
InitChainerConfig: InitChainerConfig{
GenesisTxResultHandler: PanicOnFailingTxResultHandler,
StdlibDir: filepath.Join(gnoenv.RootDir(), "gnovm", "stdlibs"),
},
}
if skipFailingGenesisTxs {
cfg.GenesisTxHandler = NoopGenesisTxHandler
cfg.GenesisTxResultHandler = NoopGenesisTxResultHandler
}

// Get main DB.
Expand All @@ -169,74 +190,135 @@ func NewApp(
return nil, fmt.Errorf("error initializing database %q using path %q: %w", dbm.GoLevelDBBackend, dataRootDir, err)
}

cfg.Logger = logger
cfg.EventSwitch = evsw

return NewAppWithOptions(cfg)
}

type GenesisTxHandler func(ctx sdk.Context, tx std.Tx, res sdk.Result)
// GenesisTxResultHandler is called in the InitChainer after a genesis
// transaction is executed.
type GenesisTxResultHandler func(ctx sdk.Context, tx std.Tx, res sdk.Result)

func NoopGenesisTxHandler(_ sdk.Context, _ std.Tx, _ sdk.Result) {}
// NoopGenesisTxResultHandler is a no-op GenesisTxResultHandler.
func NoopGenesisTxResultHandler(_ sdk.Context, _ std.Tx, _ sdk.Result) {}

func PanicOnFailingTxHandler(_ sdk.Context, _ std.Tx, res sdk.Result) {
// PanicOnFailingTxResultHandler handles genesis transactions by panicking if
// res.IsErr() returns true.
func PanicOnFailingTxResultHandler(_ sdk.Context, _ std.Tx, res sdk.Result) {
if res.IsErr() {
panic(res.Log)
}
}

// InitChainer returns a function that can initialize the chain with genesis.
func InitChainer(
baseApp *sdk.BaseApp,
acctKpr auth.AccountKeeperI,
bankKpr bank.BankKeeperI,
resHandler GenesisTxHandler,
) func(sdk.Context, abci.RequestInitChain) abci.ResponseInitChain {
return func(ctx sdk.Context, req abci.RequestInitChain) abci.ResponseInitChain {
txResponses := []abci.ResponseDeliverTx{}

if req.AppState != nil {
// Get genesis state
genState := req.AppState.(GnoGenesisState)

// Parse and set genesis state balances
for _, bal := range genState.Balances {
acc := acctKpr.NewAccountWithAddress(ctx, bal.Address)
acctKpr.SetAccount(ctx, acc)
err := bankKpr.SetCoins(ctx, bal.Address, bal.Amount)
if err != nil {
panic(err)
}
}

// Run genesis txs
for _, tx := range genState.Txs {
res := baseApp.Deliver(tx)
if res.IsErr() {
ctx.Logger().Error(
"Unable to deliver genesis tx",
"log", res.Log,
"error", res.Error,
"gas-used", res.GasUsed,
)
}

txResponses = append(txResponses, abci.ResponseDeliverTx{
ResponseBase: res.ResponseBase,
GasWanted: res.GasWanted,
GasUsed: res.GasUsed,
})

resHandler(ctx, tx, res)
}
}
// InitChainerConfig keeps the configuration for the InitChainer.
// [NewAppWithOptions] will set [InitChainerConfig.InitChainer] as its InitChainer
// function.
type InitChainerConfig struct {
// Handles the results of each genesis transaction.
GenesisTxResultHandler

// Standard library directory.
StdlibDir string
// Whether to keep a record of the DB operations to load standard libraries,
// so they can be quickly replicated on additional genesis executions.
// This should be used for integration testing, where InitChainer will be
// called several times.
CacheStdlibLoad bool

// Done!
// These fields are passed directly by NewAppWithOptions, and should not be
// configurable by end-users.
baseApp *sdk.BaseApp
vmKpr vm.VMKeeperI
acctKpr auth.AccountKeeperI
bankKpr bank.BankKeeperI
}

// InitChainer is the function that can be used as a [sdk.InitChainer].
func (cfg InitChainerConfig) InitChainer(ctx sdk.Context, req abci.RequestInitChain) abci.ResponseInitChain {
start := time.Now()
ctx.Logger().Debug("InitChainer: started")

// load standard libraries; immediately committed to store so that they are
// available for use when processing the genesis transactions below.
cfg.loadStdlibs(ctx)
ctx.Logger().Debug("InitChainer: standard libraries loaded",
"elapsed", time.Since(start))

// load app state. AppState may be nil mostly in some minimal testing setups;
// so log a warning when that happens.
txResponses, err := cfg.loadAppState(ctx, req.AppState)
if err != nil {
return abci.ResponseInitChain{
Validators: req.Validators,
TxResponses: txResponses,
ResponseBase: abci.ResponseBase{
Error: abci.StringError(err.Error()),
},
}
}

ctx.Logger().Debug("InitChainer: genesis transactions loaded",
"elapsed", time.Since(start))

// Done!
return abci.ResponseInitChain{
Validators: req.Validators,
TxResponses: txResponses,
}
}

func (cfg InitChainerConfig) loadStdlibs(ctx sdk.Context) {
// cache-wrapping is necessary for non-validator nodes; in the tm2 BaseApp,
// this is done using BaseApp.cacheTxContext; so we replicate it here.
ms := ctx.MultiStore()
msCache := ms.MultiCacheWrap()

stdlibCtx := cfg.vmKpr.MakeGnoTransactionStore(ctx)
stdlibCtx = stdlibCtx.WithMultiStore(msCache)
if cfg.CacheStdlibLoad {
cfg.vmKpr.LoadStdlibCached(stdlibCtx, cfg.StdlibDir)
} else {
cfg.vmKpr.LoadStdlib(stdlibCtx, cfg.StdlibDir)
}
cfg.vmKpr.CommitGnoTransactionStore(stdlibCtx)

msCache.MultiWrite()
}

func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci.ResponseDeliverTx, error) {
state, ok := appState.(GnoGenesisState)
if !ok {
return nil, fmt.Errorf("invalid AppState of type %T", appState)
}

// Parse and set genesis state balances
for _, bal := range state.Balances {
acc := cfg.acctKpr.NewAccountWithAddress(ctx, bal.Address)
cfg.acctKpr.SetAccount(ctx, acc)
err := cfg.bankKpr.SetCoins(ctx, bal.Address, bal.Amount)
if err != nil {
panic(err)
}
}

txResponses := make([]abci.ResponseDeliverTx, 0, len(state.Txs))
// Run genesis txs
for _, tx := range state.Txs {
res := cfg.baseApp.Deliver(tx)
if res.IsErr() {
ctx.Logger().Error(
"Unable to deliver genesis tx",
"log", res.Log,
"error", res.Error,
"gas-used", res.GasUsed,
)
}

txResponses = append(txResponses, abci.ResponseDeliverTx{
ResponseBase: res.ResponseBase,
GasWanted: res.GasWanted,
GasUsed: res.GasUsed,
})

cfg.GenesisTxResultHandler(ctx, tx, res)
}
return txResponses, nil
}

// endBlockerApp is the app abstraction required by any EndBlocker
Expand Down
Loading

0 comments on commit c1a3341

Please sign in to comment.