Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
6 changes: 3 additions & 3 deletions cmd/util/cmd/checkpoint-collect-stats/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import (

"github.com/onflow/flow-go/cmd/util/ledger/reporters"
"github.com/onflow/flow-go/cmd/util/ledger/util"
"github.com/onflow/flow-go/fvm/environment"
"github.com/onflow/flow-go/fvm/evm/emulator/state"
"github.com/onflow/flow-go/fvm/evm/handler"
"github.com/onflow/flow-go/ledger"
"github.com/onflow/flow-go/ledger/common/pathfinder"
"github.com/onflow/flow-go/ledger/complete"
Expand Down Expand Up @@ -465,9 +465,9 @@ func getRegisterType(key ledger.Key) string {
return "account storage ID"
case state.CodesStorageIDKey:
return "code storage ID"
case handler.BlockStoreLatestBlockKey:
case environment.BlockStoreLatestBlockKey:
return "latest block"
case handler.BlockStoreLatestBlockProposalKey:
case environment.BlockStoreLatestBlockProposalKey:
return "latest block proposal"
}

Expand Down
2 changes: 2 additions & 0 deletions fvm/environment/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ type Environment interface {
// Reset resets all stateful environment modules (e.g., ContractUpdater,
// EventEmitter) to initial state.
Reset()

EVMBlockStore
}

// ReusableCadenceRuntime is a wrapper around the cadence runtime and environment that
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package handler
package environment

import (
"encoding/binary"
Expand All @@ -7,7 +7,6 @@ import (

gethCommon "github.com/ethereum/go-ethereum/common"

"github.com/onflow/flow-go/fvm/evm/types"
"github.com/onflow/flow-go/model/flow"
)

Expand Down Expand Up @@ -41,7 +40,7 @@ func IsBlockHashListMetaKey(id flow.RegisterID) bool {
// smaller fixed size buckets to minimize the
// number of bytes read and written during set/get operations.
type BlockHashList struct {
backend types.BackendStorage
backend ValueStore
rootAddress flow.Address

// cached meta data
Expand All @@ -55,7 +54,7 @@ type BlockHashList struct {
// It tries to load the metadata from the backend
// and if not exist it creates one
func NewBlockHashList(
backend types.BackendStorage,
backend ValueStore,
rootAddress flow.Address,
capacity int,
) (*BlockHashList, error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
package handler_test
package environment_test

import (
"testing"

gethCommon "github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"

"github.com/onflow/flow-go/fvm/evm/handler"
"github.com/onflow/flow-go/fvm/environment"
"github.com/onflow/flow-go/fvm/evm/testutils"
"github.com/onflow/flow-go/model/flow"
)

func TestBlockHashList(t *testing.T) {
testutils.RunWithTestBackend(t, func(backend *testutils.TestBackend) {
testutils.RunWithTestBackend(t, flow.Testnet, func(backend *testutils.TestBackend) {
testutils.RunWithTestFlowEVMRootAddress(t, backend, func(root flow.Address) {
capacity := 256
bhl, err := handler.NewBlockHashList(backend, root, capacity)
bhl, err := environment.NewBlockHashList(backend, root, capacity)
require.NoError(t, err)
require.True(t, bhl.IsEmpty())

Expand Down Expand Up @@ -75,7 +75,7 @@ func TestBlockHashList(t *testing.T) {
require.Equal(t, gethCommon.Hash{byte(capacity + 2)}, h)

// construct a new one and check
bhl, err = handler.NewBlockHashList(backend, root, capacity)
bhl, err = environment.NewBlockHashList(backend, root, capacity)
require.NoError(t, err)
require.False(t, bhl.IsEmpty())

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package handler
package environment

import (
"fmt"
Expand All @@ -10,6 +10,25 @@
"github.com/onflow/flow-go/model/flow"
)

// BlockStore stores the chain of blocks
type EVMBlockStore interface {
// LatestBlock returns the latest appended block
LatestBlock() (*types.Block, error)

// BlockHash returns the hash of the block at the given height
BlockHash(height uint64) (gethCommon.Hash, error)

// BlockProposal returns the active block proposal
BlockProposal() (*types.BlockProposal, error)

// StageBlockProposal updates the in-memory block proposal without writing to
// storage. Persistence happens at transaction end via FlushBlockProposal.
StageBlockProposal(*types.BlockProposal)

// CommitBlockProposal commits the block proposal and update the chain of blocks
CommitBlockProposal(*types.BlockProposal) error
}

const (
BlockHashListCapacity = 256
BlockStoreLatestBlockKey = "LatestBlock"
Expand All @@ -18,55 +37,66 @@

type BlockStore struct {
chainID flow.ChainID
backend types.Backend
storage ValueStore
blockInfo BlockInfo
randGen RandomGenerator
rootAddress flow.Address
cached *types.BlockProposal
}
Comment on lines 82 to 89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

BlockHashList still is not cached per transaction.

BlockStore only keeps cached *types.BlockProposal; getBlockHashList() reconstructs the hash list from storage on every BlockHash() / CommitBlockProposal() path. Repeated BLOCKHASH access inside one Cadence transaction will therefore stay on the metered storage path, which leaves part of the targeted optimization on the table.

Possible direction
 type BlockStore struct {
 	chainID     flow.ChainID
 	storage     ValueStore
 	blockInfo   BlockInfo
 	randGen     RandomGenerator
 	rootAddress flow.Address
 	cached      *types.BlockProposal
+	cachedBlockHashList *BlockHashList
 }
@@
 func (bs *BlockStore) getBlockHashList() (*BlockHashList, error) {
+	if bs.cachedBlockHashList != nil {
+		return bs.cachedBlockHashList, nil
+	}
+
 	bhl, err := NewBlockHashList(bs.storage, bs.rootAddress, BlockHashListCapacity)
 	if err != nil {
 		return nil, err
 	}
@@
 	if bhl.IsEmpty() {
 		err = bhl.Push(
 			types.GenesisBlock(bs.chainID).Height,
 			types.GenesisBlockHash(bs.chainID),
 		)
 		if err != nil {
 			return nil, err
 		}
 	}
 
+	bs.cachedBlockHashList = bhl
 	return bhl, nil
 }

Also applies to: 224-240

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fvm/environment/evm_block_store.go` around lines 46 - 53, BlockStore
currently only caches a BlockProposal (cached) but not the reconstructed
block-hash list, so getBlockHashList() rebuilds it from storage on every
BLOCKHASH access; add a cached block-hash list field (e.g., cachedHashList) to
BlockStore, modify getBlockHashList() to return the cached list when present and
populate it the first time it is built, and update/invalidate that cache in
CommitBlockProposal() and anywhere the cached BlockProposal (cached) is set or
cleared (so BlockHash(), CommitBlockProposal(), and any setter that touches
cached use the cachedHashList instead of always reconstructing).


var _ types.BlockStore = &BlockStore{}
var _ EVMBlockStore = &BlockStore{}

// NewBlockStore constructs a new block store
func NewBlockStore(
chainID flow.ChainID,
backend types.Backend,
storage ValueStore,
blockInfo BlockInfo,
randGen RandomGenerator,
rootAddress flow.Address,
) *BlockStore {
return &BlockStore{
chainID: chainID,
backend: backend,
storage: storage,
blockInfo: blockInfo,
randGen: randGen,
rootAddress: rootAddress,
}
}

// BlockProposal returns the block proposal to be updated by the handler
func (bs *BlockStore) BlockProposal() (*types.BlockProposal, error) {
if bs.cached != nil {
return bs.cached, nil
}
// first fetch it from the storage
data, err := bs.backend.GetValue(bs.rootAddress[:], []byte(BlockStoreLatestBlockProposalKey))
data, err := bs.storage.GetValue(bs.rootAddress[:], []byte(BlockStoreLatestBlockProposalKey))
if err != nil {
return nil, err
}
if len(data) != 0 {
return types.NewBlockProposalFromBytes(data)
bp, err := types.NewBlockProposalFromBytes(data)
if err != nil {
return nil, err
}
bs.cached = bp
return bp, nil
}
bp, err := bs.constructBlockProposal()
if err != nil {
return nil, err
}
// store block proposal
err = bs.UpdateBlockProposal(bp)
if err != nil {
return nil, err
}
bs.cached = bp
return bp, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func (bs *BlockStore) constructBlockProposal() (*types.BlockProposal, error) {
// if available construct a new one
cadenceHeight, err := bs.backend.GetCurrentBlockHeight()
cadenceHeight, err := bs.blockInfo.GetCurrentBlockHeight()
if err != nil {
return nil, err
}

cadenceBlock, found, err := bs.backend.GetBlockAtHeight(cadenceHeight)
cadenceBlock, found, err := bs.blockInfo.GetBlockAtHeight(cadenceHeight)
if err != nil {
return nil, err
}
Expand All @@ -90,7 +120,7 @@

// read a random value for block proposal
prevrandao := gethCommon.Hash{}
err = bs.backend.ReadRandom(prevrandao[:])
err = bs.randGen.ReadRandom(prevrandao[:])
if err != nil {
return nil, err
}
Expand All @@ -107,13 +137,13 @@
}

// UpdateBlockProposal updates the block proposal
func (bs *BlockStore) UpdateBlockProposal(bp *types.BlockProposal) error {
func (bs *BlockStore) updateBlockProposal(bp *types.BlockProposal) error {
blockProposalBytes, err := bp.ToBytes()
if err != nil {
return types.NewFatalError(err)
}

return bs.backend.SetValue(
return bs.storage.SetValue(
bs.rootAddress[:],
[]byte(BlockStoreLatestBlockProposalKey),
blockProposalBytes,
Expand All @@ -129,7 +159,7 @@
return types.NewFatalError(err)
}

err = bs.backend.SetValue(bs.rootAddress[:], []byte(BlockStoreLatestBlockKey), blockBytes)
err = bs.storage.SetValue(bs.rootAddress[:], []byte(BlockStoreLatestBlockKey), blockBytes)
if err != nil {
return err
}
Expand All @@ -153,17 +183,17 @@
if err != nil {
return err
}
err = bs.UpdateBlockProposal(newBP)
err = bs.updateBlockProposal(newBP)
if err != nil {
return err
}

bs.cached = newBP
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we can clear the cache after the block proposal is committed, because it won't be used any more.

Suggested change
bs.cached = newBP
bs.cached = nil

The CommitBlockProposal is used at the system tx, which is the last tx in a flow block, so there is no more tx to read this cache.

- Flow Block 100
|--cadence tx 1 (succeed)
  |-- EVM Tx A 
    |-- StageBlockProposal() -> storage unchanged, create cache
  |-- EVM Tx B 
    |-- FlushBlockProposal() -> write cache to storage (LatestBlockProposal), cache = nil
|--cadence tx 2 (failed)
  |-- EVM Tx C
    |-- StageBlockProposal() -> storage unchanged, create cache
  |-- EVM Tx D (fail and revert)
    |-- ResetBlockProposal() -> storage unchanged, cache = nil
|--system chunk tx (last tx)
  |--heartbeat()
    |--CommitBlockProposal() -> write cache to storage (LatestBlock, LatestBlockProposal), cache = nil

There are two keys in storage:

  • LatestBlock
  • LatestBlockProposal

The relationship is that LatestBlockProposal.Parent == LatestBlock, but when CommitBlockProposal updates the LatestBlock, this equation no longer holds, that's why we need to also call constructBlockProposal() to create a new empty block proposal in order to satisfy the equation (LatestBlockProposal.Parent == LatestBlock). However, we don't have to set cache = nil, because the cache won't be used anymore.

IMO, the CommitBlockProposal could just remove the LatestBlockProposal key after updating LatestBlock, because the bs.BlockProposal() method already handles the case where LatestBlockProposal is missing, we could just reuse the same code path. And then it would look more consistent when we remove both the LatestBlockProposal key and set cache = nil in CommitBlockProposal().

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

⏺ Flow Block K Execution
  ├── Cadence tx 1 (succeed)
  │   ├── EVM Tx A
  │   │   ├── BlockProposal()
  │   │   │   ├── cache miss
  │   │   │   ├── read LatestBlockProposal from storage
  │   │   │   │   └── (if empty) read LatestBlock from storage (for parent hash, height)
  │   │   │   └── cache it
  │   │   └── StageBlockProposal()  → update cache
  │   ├── EVM Tx B
  │   │   ├── BlockProposal()       → cache hit
  │   │   └── StageBlockProposal()  → update cache
  │   └── [tx end]
  │       └── FlushBlockProposal()  → write LatestBlockProposal, cache = nil
  │
  ├── Cadence tx 2 (failed)
  │   ├── EVM Tx C
  │   │   ├── BlockProposal()
  │   │   │   ├── cache miss
  │   │   │   └── read LatestBlockProposal from storage → cache it
  │   │   └── StageBlockProposal()  → update cache
  │   ├── EVM Tx D
  │   │   ├── BlockProposal()       → cache hit
  │   │   └── StageBlockProposal()  → update cache
  │   └── [tx fail/revert]
  │       └── Reset()               → cache = nil, storage unchanged
  │
  └── System chunk tx (last)
      └── heartbeat()
          └── CommitBlockProposal()
              ├── write LatestBlock
              ├── remove LatestBlockProposal (new proposal will be constructed lazily in next flow block)
              └── cache = nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

return nil
}

// LatestBlock returns the latest executed block
func (bs *BlockStore) LatestBlock() (*types.Block, error) {
data, err := bs.backend.GetValue(bs.rootAddress[:], []byte(BlockStoreLatestBlockKey))
data, err := bs.storage.GetValue(bs.rootAddress[:], []byte(BlockStoreLatestBlockKey))
if err != nil {
return nil, err
}
Expand All @@ -184,7 +214,7 @@
}

func (bs *BlockStore) getBlockHashList() (*BlockHashList, error) {
bhl, err := NewBlockHashList(bs.backend, bs.rootAddress, BlockHashListCapacity)
bhl, err := NewBlockHashList(bs.storage, bs.rootAddress, BlockHashListCapacity)
if err != nil {
return nil, err
}
Expand All @@ -201,3 +231,23 @@

return bhl, nil
}

func (bs *BlockStore) ResetBlockProposal() {
bs.cached = nil
}

func (bs *BlockStore) StageBlockProposal(bp *types.BlockProposal) {
bs.cached = bp
}

func (bs *BlockStore) FlushBlockProposal() error {
if bs.cached == nil {
return nil
}
err := bs.updateBlockProposal(bs.cached)
if err != nil {
return err
}
return nil
}

Check failure on line 253 in fvm/environment/evm_block_store.go

View workflow job for this annotation

GitHub Actions / Lint (./)

File is not properly formatted (goimports)
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
package handler_test
package environment_test

import (
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/onflow/flow-go/fvm/evm/handler"
"github.com/onflow/flow-go/fvm/environment"
"github.com/onflow/flow-go/fvm/evm/testutils"
"github.com/onflow/flow-go/model/flow"
)

func BenchmarkProposalGrowth(b *testing.B) { benchmarkBlockProposalGrowth(b, 1000) }

func benchmarkBlockProposalGrowth(b *testing.B, txCounts int) {
testutils.RunWithTestBackend(b, func(backend *testutils.TestBackend) {
testutils.RunWithTestBackend(b, flow.Testnet, func(backend *testutils.TestBackend) {
testutils.RunWithTestFlowEVMRootAddress(b, backend, func(rootAddr flow.Address) {

bs := handler.NewBlockStore(flow.Testnet, backend, rootAddr)
bs := environment.NewBlockStore(flow.Testnet, backend, backend, backend, rootAddr)
for i := 0; i < txCounts; i++ {
bp, err := bs.BlockProposal()
require.NoError(b, err)
res := testutils.RandomResultFixture(b)
bp.AppendTransaction(res)
err = bs.UpdateBlockProposal(bp)
require.NoError(b, err)
bs.StageBlockProposal(bp)
}

// check the impact of updating block proposal after x number of transactions
Expand All @@ -34,8 +33,7 @@ func benchmarkBlockProposalGrowth(b *testing.B, txCounts int) {
require.NoError(b, err)
res := testutils.RandomResultFixture(b)
bp.AppendTransaction(res)
err = bs.UpdateBlockProposal(bp)
require.NoError(b, err)
bs.StageBlockProposal(bp)

b.ReportMetric(float64(time.Since(startTime).Nanoseconds()), "proposal_update_time_ns")
b.ReportMetric(float64(backend.TotalBytesRead()), "proposal_update_bytes_read")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package handler_test
package environment_test

import (
"math/big"
Expand All @@ -7,7 +7,7 @@ import (
gethCommon "github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"

"github.com/onflow/flow-go/fvm/evm/handler"
"github.com/onflow/flow-go/fvm/environment"
"github.com/onflow/flow-go/fvm/evm/testutils"
"github.com/onflow/flow-go/fvm/evm/types"
"github.com/onflow/flow-go/model/flow"
Expand All @@ -16,9 +16,9 @@ import (
func TestBlockStore(t *testing.T) {

var chainID = flow.Testnet
testutils.RunWithTestBackend(t, func(backend *testutils.TestBackend) {
testutils.RunWithTestBackend(t, chainID, func(backend *testutils.TestBackend) {
testutils.RunWithTestFlowEVMRootAddress(t, backend, func(root flow.Address) {
bs := handler.NewBlockStore(chainID, backend, root)
bs := environment.NewBlockStore(chainID, backend, backend, backend, root)

// check the Genesis block
b, err := bs.LatestBlock()
Expand All @@ -43,19 +43,20 @@ func TestBlockStore(t *testing.T) {

// update the block proposal
bp.TotalGasUsed += 100
err = bs.UpdateBlockProposal(bp)
bs.StageBlockProposal(bp)
err = bs.FlushBlockProposal()
require.NoError(t, err)

// reset the bs and check if it still return the block proposal
bs = handler.NewBlockStore(chainID, backend, root)
bs = environment.NewBlockStore(chainID, backend, backend, backend, root)
retbp, err = bs.BlockProposal()
require.NoError(t, err)
require.Equal(t, bp, retbp)

// update the block proposal again
supply := big.NewInt(100)
bp.TotalSupply = supply
err = bs.UpdateBlockProposal(bp)
bs.StageBlockProposal(bp)
err = bs.FlushBlockProposal()
require.NoError(t, err)
// this should still return the genesis block
retb, err := bs.LatestBlock()
Expand Down
Loading
Loading