Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cmd/workload: RPC workload tests for filters and history #31189

Merged
merged 18 commits into from
Feb 26, 2025
Merged
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
2 changes: 1 addition & 1 deletion cmd/abigen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func init() {
}

func abigen(c *cli.Context) error {
utils.CheckExclusive(c, abiFlag, jsonFlag) // Only one source can be selected.
flags.CheckExclusive(c, abiFlag, jsonFlag) // Only one source can be selected.

if c.String(pkgFlag.Name) == "" {
utils.Fatalf("No destination package specified (--pkg)")
Expand Down
53 changes: 6 additions & 47 deletions cmd/utils/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -1197,7 +1197,7 @@ func setWS(ctx *cli.Context, cfg *node.Config) {
// setIPC creates an IPC path configuration from the set command line flags,
// returning an empty string if IPC was explicitly disabled, or the set path.
func setIPC(ctx *cli.Context, cfg *node.Config) {
CheckExclusive(ctx, IPCDisabledFlag, IPCPathFlag)
flags.CheckExclusive(ctx, IPCDisabledFlag, IPCPathFlag)
switch {
case ctx.Bool(IPCDisabledFlag.Name):
cfg.IPCPath = ""
Expand Down Expand Up @@ -1295,8 +1295,8 @@ func SetP2PConfig(ctx *cli.Context, cfg *p2p.Config) {
cfg.NoDiscovery = true
}

CheckExclusive(ctx, DiscoveryV4Flag, NoDiscoverFlag)
CheckExclusive(ctx, DiscoveryV5Flag, NoDiscoverFlag)
flags.CheckExclusive(ctx, DiscoveryV4Flag, NoDiscoverFlag)
flags.CheckExclusive(ctx, DiscoveryV5Flag, NoDiscoverFlag)
cfg.DiscoveryV4 = ctx.Bool(DiscoveryV4Flag.Name)
cfg.DiscoveryV5 = ctx.Bool(DiscoveryV5Flag.Name)

Expand Down Expand Up @@ -1528,52 +1528,11 @@ func setRequiredBlocks(ctx *cli.Context, cfg *ethconfig.Config) {
}
}

// CheckExclusive verifies that only a single instance of the provided flags was
// set by the user. Each flag might optionally be followed by a string type to
// specialize it further.
func CheckExclusive(ctx *cli.Context, args ...interface{}) {
set := make([]string, 0, 1)
for i := 0; i < len(args); i++ {
// Make sure the next argument is a flag and skip if not set
flag, ok := args[i].(cli.Flag)
if !ok {
panic(fmt.Sprintf("invalid argument, not cli.Flag type: %T", args[i]))
}
// Check if next arg extends current and expand its name if so
name := flag.Names()[0]

if i+1 < len(args) {
switch option := args[i+1].(type) {
case string:
// Extended flag check, make sure value set doesn't conflict with passed in option
if ctx.String(flag.Names()[0]) == option {
name += "=" + option
set = append(set, "--"+name)
}
// shift arguments and continue
i++
continue

case cli.Flag:
default:
panic(fmt.Sprintf("invalid argument, not cli.Flag or string extension: %T", args[i+1]))
}
}
// Mark the flag if it's set
if ctx.IsSet(flag.Names()[0]) {
set = append(set, "--"+name)
}
}
if len(set) > 1 {
Fatalf("Flags %v can't be used at the same time", strings.Join(set, ", "))
}
}

// SetEthConfig applies eth-related command line flags to the config.
func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) {
// Avoid conflicting network flags
CheckExclusive(ctx, MainnetFlag, DeveloperFlag, SepoliaFlag, HoleskyFlag)
CheckExclusive(ctx, DeveloperFlag, ExternalSignerFlag) // Can't use both ephemeral unlocked and external signer
flags.CheckExclusive(ctx, MainnetFlag, DeveloperFlag, SepoliaFlag, HoleskyFlag)
flags.CheckExclusive(ctx, DeveloperFlag, ExternalSignerFlag) // Can't use both ephemeral unlocked and external signer

// Set configurations from CLI flags
setEtherbase(ctx, cfg)
Expand Down Expand Up @@ -1841,7 +1800,7 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) {
func MakeBeaconLightConfig(ctx *cli.Context) bparams.ClientConfig {
var config bparams.ClientConfig
customConfig := ctx.IsSet(BeaconConfigFlag.Name)
CheckExclusive(ctx, MainnetFlag, SepoliaFlag, HoleskyFlag, BeaconConfigFlag)
flags.CheckExclusive(ctx, MainnetFlag, SepoliaFlag, HoleskyFlag, BeaconConfigFlag)
switch {
case ctx.Bool(MainnetFlag.Name):
config.ChainConfig = *bparams.MainnetLightConfig
Expand Down
29 changes: 29 additions & 0 deletions cmd/workload/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## Workload Testing Tool

This tool performs RPC calls against a live node. It has tests for the Sepolia testnet and
Mainnet. Note the tests require a fully synced node.

To run the tests against a Sepolia node, use:

```shell
> ./workload test --sepolia http://host:8545
```

To run a specific test, use the `--run` flag to filter the test cases. Filtering works
similar to the `go test` command. For example, to run only tests for `eth_getBlockByHash`
and `eth_getBlockByNumber`, use this command:

```
> ./workload test --sepolia --run History/getBlockBy http://host:8545
```

### Regenerating tests

There is a facility for updating the tests from the chain. This can also be used to
generate the tests for a new network. As an example, to recreate tests for mainnet, run
the following commands (in this directory) against a synced mainnet node:

```shell
> go run . filtergen --queries queries/filter_queries_mainnet.json http://host:8545
> go run . historygen --history-tests queries/history_mainnet.json http://host:8545
```
216 changes: 216 additions & 0 deletions cmd/workload/filtertest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright 2025 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.

package main

import (
"context"
"encoding/json"
"fmt"
"math/big"
"time"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/internal/utesting"
"github.com/ethereum/go-ethereum/rlp"
"github.com/ethereum/go-ethereum/rpc"
)

type filterTestSuite struct {
cfg testConfig
queries [][]*filterQuery
}

func newFilterTestSuite(cfg testConfig) *filterTestSuite {
s := &filterTestSuite{cfg: cfg}
if err := s.loadQueries(); err != nil {
exit(err)
}
return s
}

func (s *filterTestSuite) allTests() []utesting.Test {
return []utesting.Test{
{Name: "Filter/ShortRange", Fn: s.filterShortRange},
{Name: "Filter/LongRange", Fn: s.filterLongRange, Slow: true},
{Name: "Filter/FullRange", Fn: s.filterFullRange, Slow: true},
}
}

func (s *filterTestSuite) filterRange(t *utesting.T, test func(query *filterQuery) bool, do func(t *utesting.T, query *filterQuery)) {
var count, total int
for _, bucket := range s.queries {
for _, query := range bucket {
if test(query) {
total++
}
}
}
if total == 0 {
t.Fatalf("No suitable queries available")
}
start := time.Now()
last := start
for _, bucket := range s.queries {
for _, query := range bucket {
if test(query) {
do(t, query)
count++
if time.Since(last) > time.Second*5 {
t.Logf("Making filter query %d/%d (elapsed: %v)", count, total, time.Since(start))
last = time.Now()
}
}
}
}
t.Logf("Made %d filter queries (elapsed: %v)", count, time.Since(start))
}

const filterRangeThreshold = 10000

// filterShortRange runs all short-range filter tests.
func (s *filterTestSuite) filterShortRange(t *utesting.T) {
s.filterRange(t, func(query *filterQuery) bool {
return query.ToBlock+1-query.FromBlock <= filterRangeThreshold
}, s.queryAndCheck)
}

// filterShortRange runs all long-range filter tests.
func (s *filterTestSuite) filterLongRange(t *utesting.T) {
s.filterRange(t, func(query *filterQuery) bool {
return query.ToBlock+1-query.FromBlock > filterRangeThreshold
}, s.queryAndCheck)
}

// filterFullRange runs all filter tests, extending their range from genesis up
// to the latest block. Note that results are only partially verified in this mode.
func (s *filterTestSuite) filterFullRange(t *utesting.T) {
finalized := mustGetFinalizedBlock(s.cfg.client)
s.filterRange(t, func(query *filterQuery) bool {
return query.ToBlock+1-query.FromBlock > finalized/2
}, s.fullRangeQueryAndCheck)
}

func (s *filterTestSuite) queryAndCheck(t *utesting.T, query *filterQuery) {
query.run(s.cfg.client)
if query.Err != nil {
t.Errorf("Filter query failed (fromBlock: %d toBlock: %d addresses: %v topics: %v error: %v)", query.FromBlock, query.ToBlock, query.Address, query.Topics, query.Err)
return
}
if *query.ResultHash != query.calculateHash() {
t.Fatalf("Filter query result mismatch (fromBlock: %d toBlock: %d addresses: %v topics: %v)", query.FromBlock, query.ToBlock, query.Address, query.Topics)
}
}

func (s *filterTestSuite) fullRangeQueryAndCheck(t *utesting.T, query *filterQuery) {
frQuery := &filterQuery{ // create full range query
FromBlock: 0,
ToBlock: int64(rpc.LatestBlockNumber),
Address: query.Address,
Topics: query.Topics,
}
frQuery.run(s.cfg.client)
if frQuery.Err != nil {
t.Errorf("Full range filter query failed (addresses: %v topics: %v error: %v)", frQuery.Address, frQuery.Topics, frQuery.Err)
return
}
// filter out results outside the original query range
j := 0
for _, log := range frQuery.results {
if int64(log.BlockNumber) >= query.FromBlock && int64(log.BlockNumber) <= query.ToBlock {
frQuery.results[j] = log
j++
}
}
frQuery.results = frQuery.results[:j]
if *query.ResultHash != frQuery.calculateHash() {
t.Fatalf("Full range filter query result mismatch (fromBlock: %d toBlock: %d addresses: %v topics: %v)", query.FromBlock, query.ToBlock, query.Address, query.Topics)
}
}

func (s *filterTestSuite) loadQueries() error {
file, err := s.cfg.fsys.Open(s.cfg.filterQueryFile)
if err != nil {
return fmt.Errorf("can't open filterQueryFile: %v", err)
}
defer file.Close()

var queries [][]*filterQuery
if err := json.NewDecoder(file).Decode(&queries); err != nil {
return fmt.Errorf("invalid JSON in %s: %v", s.cfg.filterQueryFile, err)
}
var count int
for _, bucket := range queries {
count += len(bucket)
}
if count == 0 {
return fmt.Errorf("filterQueryFile %s is empty", s.cfg.filterQueryFile)
}
s.queries = queries
return nil
}

// filterQuery is a single query for testing.
type filterQuery struct {
FromBlock int64 `json:"fromBlock"`
ToBlock int64 `json:"toBlock"`
Address []common.Address `json:"address"`
Topics [][]common.Hash `json:"topics"`
ResultHash *common.Hash `json:"resultHash,omitempty"`
results []types.Log
Err error `json:"error,omitempty"`
}

func (fq *filterQuery) isWildcard() bool {
if len(fq.Address) != 0 {
return false
}
for _, topics := range fq.Topics {
if len(topics) != 0 {
return false
}
}
return true
}

func (fq *filterQuery) calculateHash() common.Hash {
enc, err := rlp.EncodeToBytes(&fq.results)
if err != nil {
exit(fmt.Errorf("Error encoding logs: %v", err))
}
return crypto.Keccak256Hash(enc)
}

func (fq *filterQuery) run(client *client) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
logs, err := client.Eth.FilterLogs(ctx, ethereum.FilterQuery{
FromBlock: big.NewInt(fq.FromBlock),
ToBlock: big.NewInt(fq.ToBlock),
Addresses: fq.Address,
Topics: fq.Topics,
})
if err != nil {
fq.Err = err
fmt.Printf("Filter query failed: fromBlock: %d toBlock: %d addresses: %v topics: %v error: %v\n",
fq.FromBlock, fq.ToBlock, fq.Address, fq.Topics, err)
return
}
fq.results = logs
}
Loading