Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ and report on node performance by executing transactions and measuring response-
## Key Features

- 🚀 Batch transactions to make stress testing easier to orchestrate
- 🛠 Multiple stress testing modes: REALM_DEPLOYMENT, PACKAGE_DEPLOYMENT, and REALM_CALL
- 🛠 Multiple stress testing modes: REALM_DEPLOYMENT, PACKAGE_DEPLOYMENT, REALM_CALL, and MIXED
- 💰 Distributed transaction stress testing through subaccounts
- 💸 Automatic subaccount fund top-up
- 📊 Detailed statistics calculation
Expand Down Expand Up @@ -57,7 +57,9 @@ FLAGS
-batch 100 the batch size of JSON-RPC transactions
-chain-id dev the chain ID of the Gno blockchain
-mnemonic string the mnemonic used to generate sub-accounts
-mode REALM_DEPLOYMENT the mode for the stress test. Possible modes: [REALM_DEPLOYMENT, PACKAGE_DEPLOYMENT, REALM_CALL]
-mode REALM_DEPLOYMENT the mode for the stress test. Possible modes: [REALM_DEPLOYMENT, PACKAGE_DEPLOYMENT, REALM_CALL, MIXED]
-mix-ratio string transaction mix ratios for MIXED mode, e.g., "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10"
-mix-seed 0 optional seed for reproducible transaction shuffling in MIXED mode (0 = random)
-output string the output path for the results JSON
-sub-accounts 10 the number of sub-accounts that will send out transactions
-transactions 100 the total number of transactions to be emitted
Expand All @@ -80,3 +82,24 @@ deploy a package.

The `REALM_CALL` mode deploys a `Realm` to the Gno blockchain network being tested before starting the cycle run.
When the cycle run begins, the transactions that are sent out are method calls.

### MIXED

The `MIXED` mode allows combining multiple transaction types in a single stress test run. You specify the ratio of each
transaction type using the `-mix-ratio` flag. The percentages must sum to 100.

Example:
```bash
./build/supernova -mode MIXED -mix-ratio "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10" ...
```

This will generate 70% REALM_CALL transactions, 20% REALM_DEPLOYMENT transactions, and 10% PACKAGE_DEPLOYMENT transactions,
shuffled randomly throughout the run.

For reproducible runs (useful for debugging or benchmarking), use the `-mix-seed` flag:
```bash
./build/supernova -mode MIXED -mix-ratio "REALM_CALL:70,REALM_DEPLOYMENT:30" -mix-seed 12345 ...
```

When running without a seed (or with `-mix-seed 0`), the tool logs the randomly generated seed so you can reproduce
the exact same transaction ordering later.
19 changes: 17 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,26 @@ func registerFlags(fs *flag.FlagSet, c *internal.Config) {
"mode",
runtime.RealmDeployment.String(),
fmt.Sprintf(
"the mode for the stress test. Possible modes: [%s, %s, %s]",
runtime.RealmDeployment.String(), runtime.PackageDeployment.String(), runtime.RealmCall.String(),
"the mode for the stress test. Possible modes: [%s, %s, %s, %s]",
runtime.RealmDeployment.String(), runtime.PackageDeployment.String(),
runtime.RealmCall.String(), runtime.Mixed.String(),
),
)

fs.StringVar(
&c.MixRatio,
"mix-ratio",
"",
"transaction mix ratios for MIXED mode, e.g., \"REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10\"",
)

fs.Int64Var(
&c.MixSeed,
"mix-seed",
0,
"optional seed for reproducible transaction shuffling in MIXED mode (0 = random)",
)

fs.StringVar(
&c.Output,
"output",
Expand Down
15 changes: 15 additions & 0 deletions internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package internal

import (
"errors"
"fmt"
"regexp"

"github.com/gnolang/gno/tm2/pkg/crypto/bip39"
Expand All @@ -15,6 +16,7 @@ var (
errInvalidSubaccounts = errors.New("invalid number of subaccounts specified")
errInvalidTransactions = errors.New("invalid number of transactions specified")
errInvalidBatchSize = errors.New("invalid batch size specified")
errMixRatioRequired = errors.New("mix-ratio is required for MIXED mode")
)

var (
Expand All @@ -31,6 +33,8 @@ type Config struct {
ChainID string // the chain ID of the cluster
Mnemonic string // the mnemonic for the keyring
Mode string // the stress test mode
MixRatio string // transaction mix ratios for MIXED mode
MixSeed int64 // optional seed for reproducible shuffling in MIXED mode
Output string // output path for results JSON, if any

SubAccounts uint64 // the number of sub-accounts in the run
Expand Down Expand Up @@ -71,5 +75,16 @@ func (cfg *Config) Validate() error {
return errInvalidBatchSize
}

// Validate mix ratio for MIXED mode
if runtime.Type(cfg.Mode) == runtime.Mixed {
if cfg.MixRatio == "" {
return errMixRatioRequired
}

if _, err := runtime.ParseMixRatio(cfg.MixRatio); err != nil {
return fmt.Errorf("invalid mix-ratio: %w", err)
}
}

return nil
}
68 changes: 68 additions & 0 deletions internal/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package internal

import (
"testing"

"github.com/gnolang/supernova/internal/runtime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// validBaseConfig returns a config with all fields valid except Mode/MixRatio
func validBaseConfig() *Config {
return &Config{
URL: "http://localhost:26657",
ChainID: "dev",
Mnemonic: "source bonus chronic canvas draft south burst lottery " +
"vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast",
SubAccounts: 10,
Transactions: 100,
BatchSize: 100,
}
}

func TestConfig_Validate_MixedModeRequiresRatio(t *testing.T) {
t.Parallel()

cfg := validBaseConfig()
cfg.Mode = runtime.Mixed.String()
cfg.MixRatio = ""

err := cfg.Validate()
require.Error(t, err)
assert.ErrorIs(t, err, errMixRatioRequired)
}

func TestConfig_Validate_MixedModeInvalidRatio(t *testing.T) {
t.Parallel()

cfg := validBaseConfig()
cfg.Mode = runtime.Mixed.String()
cfg.MixRatio = "REALM_CALL:50" // only one type, needs at least 2

err := cfg.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid mix-ratio")
}

func TestConfig_Validate_MixedModeValidRatio(t *testing.T) {
t.Parallel()

cfg := validBaseConfig()
cfg.Mode = runtime.Mixed.String()
cfg.MixRatio = "REALM_CALL:70,REALM_DEPLOYMENT:30"

err := cfg.Validate()
assert.NoError(t, err)
}

func TestConfig_Validate_NonMixedModeIgnoresRatio(t *testing.T) {
t.Parallel()

cfg := validBaseConfig()
cfg.Mode = runtime.RealmCall.String()
cfg.MixRatio = "this is invalid but should be ignored"

err := cfg.Validate()
assert.NoError(t, err)
}
30 changes: 26 additions & 4 deletions internal/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,27 @@ func NewPipeline(cfg *Config) (*Pipeline, error) {

// Execute runs the entire pipeline process
func (p *Pipeline) Execute(ctx context.Context) error {
var (
mode = runtime.Type(p.cfg.Mode)
mode := runtime.Type(p.cfg.Mode)

// Setup the context for mixed mode
if mode == runtime.Mixed {
mixConfig, err := runtime.ParseMixRatio(p.cfg.MixRatio)
if err != nil {
return fmt.Errorf("unable to parse mix ratio: %w", err)
}

mixConfig.Seed = p.cfg.MixSeed
ctx = runtime.WithMixConfig(ctx, mixConfig)
}

txRuntime, err := runtime.GetRuntime(ctx, mode)
if err != nil {
return fmt.Errorf("unable to get runtime: %w", err)
}

var (
txBatcher = batcher.NewBatcher(ctx, p.cli)
txCollector = collector.NewCollector(ctx, p.cli)
txRuntime = runtime.GetRuntime(ctx, mode)
)

// Initialize the accounts for the runtime
Expand Down Expand Up @@ -229,7 +244,14 @@ func prepareRuntime(

signCB := runtime.SignTransactionsCb(chainID, deployer, deployerKey)

if mode != runtime.RealmCall {
needsPredeploy := mode == runtime.RealmCall

if mode == runtime.Mixed {
mixConfig := runtime.GetMixConfig(ctx)
needsPredeploy = mixConfig != nil && mixConfig.HasType(runtime.RealmCall)
}

if !needsPredeploy {
return txRuntime.CalculateRuntimeCosts(deployer, cli.EstimateGas, signCB, currentMaxGas, gasPrice, transactions)
}

Expand Down
158 changes: 158 additions & 0 deletions internal/runtime/mix_ratio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package runtime

import (
"errors"
"fmt"
"strconv"
"strings"
)

var (
errEmptyMixRatio = errors.New("mix ratio cannot be empty")
errInvalidRatioFormat = errors.New("invalid ratio format, expected TYPE:PERCENTAGE")
errInvalidPercentage = errors.New("percentage must be a positive integer between 1 and 100")
errUnknownType = errors.New("unknown runtime type in mix ratio")
errDuplicateType = errors.New("duplicate runtime type in mix ratio")
errMixedInMix = errors.New("MIXED type cannot be used in mix ratio")
errRatioSumNot100 = errors.New("mix ratio percentages must sum to 100")
errInsufficientTypes = errors.New("mix ratio must contain at least 2 types")
)

type mixRatio struct {
Type Type
Percentage int
}

type MixConfig struct {
Ratios []mixRatio

This comment was marked as resolved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Seed int64 // Optional seed for reproducible shuffling (0 = use current time)
}

// ParseMixRatio parses a mix ratio string into a MixConfig
// Example input: "REALM_CALL:70,REALM_DEPLOYMENT:20,PACKAGE_DEPLOYMENT:10"
func ParseMixRatio(input string) (*MixConfig, error) {
if strings.TrimSpace(input) == "" {
return nil, errEmptyMixRatio
}

parts := strings.Split(input, ",")
if len(parts) < 2 {
return nil, errInsufficientTypes
}

config := &MixConfig{
Ratios: make([]mixRatio, 0, len(parts)),
}

seenTypes := make(map[Type]bool)

for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}

ratio, err := parseRatioPart(part)
if err != nil {
return nil, err
}

if seenTypes[ratio.Type] {
return nil, fmt.Errorf("%w: %s", errDuplicateType, ratio.Type)
}

seenTypes[ratio.Type] = true

config.Ratios = append(config.Ratios, ratio)
}

if err := config.Validate(); err != nil {
return nil, err
}

return config, nil
}

// parseRatioPart parses a single TYPE:PERCENTAGE part of the mix ratio
// Example input: "REALM_CALL:70" and ensures validity
func parseRatioPart(part string) (mixRatio, error) {
colonIdx := strings.LastIndex(part, ":")
if colonIdx == -1 {
return mixRatio{}, fmt.Errorf("%w: %s", errInvalidRatioFormat, part)
}

typeName := strings.TrimSpace(part[:colonIdx])
percentageStr := strings.TrimSpace(part[colonIdx+1:])

percentage, err := strconv.Atoi(percentageStr)
if err != nil || percentage < 1 || percentage > 100 {
return mixRatio{}, fmt.Errorf("%w: %s", errInvalidPercentage, percentageStr)
}

runtimeType := Type(typeName)

if runtimeType == Mixed {
return mixRatio{}, errMixedInMix
}

if !IsMixableRuntime(runtimeType) {
return mixRatio{}, fmt.Errorf("%w: %s", errUnknownType, typeName)
}

return mixRatio{
Type: runtimeType,
Percentage: percentage,
}, nil
}

// Validate checks the MixConfig for correctness
// ensuring at least two types and that percentages sum to 100
func (mc *MixConfig) Validate() error {
if len(mc.Ratios) < 2 {
return errInsufficientTypes
}

sum := 0
for _, ratio := range mc.Ratios {
sum += ratio.Percentage
}

if sum != 100 {
return fmt.Errorf("%w: got %d", errRatioSumNot100, sum)
}

return nil
}

// HasType checks if the MixConfig includes the specified runtime type
// For example in: REALM_CALL:70,REALM_DEPLOYMENT:30, HasType(REALM_CALL) returns true
// but HasType(PACKAGE_DEPLOYMENT) returns false
func (mc *MixConfig) HasType(t Type) bool {
for _, ratio := range mc.Ratios {
if ratio.Type == t {
return true
}
}

return false
}

// CalculateTransactionCounts computes the number of transactions
// for each runtime type based on the total and the defined ratios
func (mc *MixConfig) CalculateTransactionCounts(total uint64) map[Type]uint64 {
counts := make(map[Type]uint64)

var allocated uint64

for i, ratio := range mc.Ratios {
if i == len(mc.Ratios)-1 {
counts[ratio.Type] = total - allocated
} else {
count := (total * uint64(ratio.Percentage)) / 100
counts[ratio.Type] = count
allocated += count
}
}

return counts
}
Loading
Loading