Skip to content
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
9 changes: 9 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ type LoadConfig struct {
Settings *Settings `json:"settings,omitempty"`
// Path to write a JSON report of the load test.
ReportPath string `json:"reportPath,omitempty"`
// Seed roots the deterministic PRNG sub-streams that drive the run. Same
// seed + config reproduces the per-stream draw multiset, so the workload
// (the distribution of keys, sizes, gas, and accounts) is statistically
// reproducible for fair A/B comparison. Per-tx emission ordering is
// reproducible only at a single worker; above one worker the multiset still
// matches but ordering does not, and on-chain arrival order is concurrent
// regardless. A nil Seed means "unseeded": the generator resolves a random
// one and records it for after-the-fact replay.
Seed *uint64 `json:"seed,omitempty"`
}

// Duration wraps time.Duration to provide JSON unmarshaling support
Expand Down
22 changes: 21 additions & 1 deletion config/gas.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"math/rand/v2"

"github.com/sei-protocol/sei-load/utils/rng"
)

var (
Expand All @@ -23,6 +25,18 @@ type GasPicker struct {

func (g *GasPicker) Name() string { return g.name }

// SetStream binds the picker's random delegate to a deterministic sub-stream. A
// nil stream leaves the picker on the unseeded global RNG.
//
// Only a random delegate has anything to seed: fixed and empty pickers draw no
// randomness, so the type assertion intentionally no-ops for them rather than
// erroring.
func (g *GasPicker) SetStream(s *rng.Stream) {
if r, ok := g.delegate.(*RandomGasGenerator); ok {
r.stream = s
}
}

func (g *GasPicker) GenerateGas() (uint64, error) {
if g.delegate == nil {
return 0, nil
Expand Down Expand Up @@ -71,11 +85,17 @@ func (f *FixedGasGenerator) GenerateGas() (uint64, error) {
type RandomGasGenerator struct {
Min uint64 `json:"Min"`
Max uint64 `json:"Max"`

stream *rng.Stream
}

func (r *RandomGasGenerator) GenerateGas() (uint64, error) {
if r.Min >= r.Max {
return 0, fmt.Errorf("invalid random gas range: min %d must be less than max %d", r.Min, r.Max)
}
return r.Min + rand.Uint64N(r.Max-r.Min+1), nil
span := r.Max - r.Min + 1
if r.stream != nil {
return r.Min + r.stream.Uint64N(span), nil
}
return r.Min + rand.Uint64N(span), nil
}
59 changes: 59 additions & 0 deletions config/gas_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package config_test

import (
"fmt"
"testing"

"github.com/sei-protocol/sei-load/config"
"github.com/sei-protocol/sei-load/utils/rng"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -36,3 +38,60 @@ func TestGasPicker(t *testing.T) {
require.Error(t, subject.UnmarshalJSON([]byte(`{"Name":"unknown"}`)))
})
}

// randomPicker unmarshals a fresh random gas picker over [min,max].
func randomPicker(t *testing.T, min, max uint64) *config.GasPicker {
t.Helper()
var gp config.GasPicker
require.NoError(t, gp.UnmarshalJSON(fmt.Appendf(nil, `{"Name":"random","Min":%d,"Max":%d}`, min, max)))
return &gp
}

// drawN binds the picker to the given stream and pulls n draws.
func drawN(t *testing.T, gp *config.GasPicker, s *rng.Stream, n int) []uint64 {
t.Helper()
gp.SetStream(s)
out := make([]uint64, n)
for i := range out {
v, err := gp.GenerateGas()
require.NoError(t, err)
out[i] = v
}
return out
}

// TestRandomGasPickerStreamSeeds guards the binding contract: after SetStream a
// random picker draws seed-determined values. Two same-seed builds must match
// AND differ from an unseeded build. This fails loudly if a refactor (e.g. a
// deep copy of config.Scenario) breaks the pointer aliasing bindGasStreams
// relies on, so the binding silently reverting to the global RNG cannot pass.
func TestRandomGasPickerStreamSeeds(t *testing.T) {
const seed, n = 17, 64

seededA := drawN(t, randomPicker(t, 20000, 30000), rng.NewSource(seed).Stream("gas:0:base"), n)
seededB := drawN(t, randomPicker(t, 20000, 30000), rng.NewSource(seed).Stream("gas:0:base"), n)
require.Equal(t, seededA, seededB, "same seed must reproduce the draw sequence")

unseeded := drawN(t, randomPicker(t, 20000, 30000), nil, n)
require.NotEqual(t, seededA, unseeded, "seeded draws must differ from the unseeded global RNG")
}

// TestSetStreamNoOpsForFixedAndEmpty confirms fixed/empty pickers ignore
// SetStream (they have no randomness to seed) rather than erroring.
func TestSetStreamNoOpsForFixedAndEmpty(t *testing.T) {
stream := rng.NewSource(1).Stream("gas:0:base")

var fixed config.GasPicker
require.NoError(t, fixed.UnmarshalJSON([]byte(`{"Name":"fixed","Gas":21000}`)))
fixed.SetStream(stream)
gas, err := fixed.GenerateGas()
require.NoError(t, err)
require.Equal(t, uint64(21000), gas)

var empty config.GasPicker
require.NoError(t, empty.UnmarshalJSON([]byte(`{}`)))
empty.SetStream(stream)
gas, err = empty.GenerateGas()
require.NoError(t, err)
require.Zero(t, gas)
}
44 changes: 43 additions & 1 deletion generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/sei-protocol/sei-load/config"
"github.com/sei-protocol/sei-load/generator/scenarios"
"github.com/sei-protocol/sei-load/types"
"github.com/sei-protocol/sei-load/utils/rng"
)

// Generator interface defines the contract for transaction generators
Expand All @@ -32,6 +33,7 @@ type scenarioInstance struct {
// configBasedGenerator manages scenario creation and deployment from config
type configBasedGenerator struct {
config *config.LoadConfig
rng *rng.Source
instances []*scenarioInstance
deployer *types.Account
sharedAccounts types.AccountPool // Shared account pool when using top-level config
Expand All @@ -51,13 +53,15 @@ func (g *configBasedGenerator) createScenarios() error {
g.sharedAccounts = types.NewAccountPool(&types.AccountConfig{
Accounts: accounts,
NewAccountRate: g.config.Accounts.NewAccountRate,
Stream: g.rng.Stream(rng.StreamAccountsShared),
})
g.accountPools = append(g.accountPools, g.sharedAccounts)
}

for i, scenarioCfg := range g.config.Scenarios {
// Create scenario instance using factory
scenario := scenarios.CreateScenario(scenarioCfg)
g.bindGasStreams(i, scenarioCfg)

// Determine account pool to use
var accountPool types.AccountPool
Expand All @@ -70,6 +74,7 @@ func (g *configBasedGenerator) createScenarios() error {
accountPool = types.NewAccountPool(&types.AccountConfig{
Accounts: accounts,
NewAccountRate: newAccountRate,
Stream: g.rng.Stream(rng.AccountsScenarioStream(i)),
})
g.accountPools = append(g.accountPools, accountPool)
} else if g.sharedAccounts != nil {
Expand Down Expand Up @@ -111,6 +116,29 @@ func (g *configBasedGenerator) createScenarios() error {
return nil
}

// bindGasStreams binds each configured gas picker for a scenario to its own
// deterministic sub-stream. The stream ids are keyed by the scenario's config
// index so they stay stable across runs of the same config.
//
// cfg is a value copy, but its *GasPicker fields are pointers shared with the
// copy the scenario stores, so SetStream reaches the picker the scenario draws
// through. A shallow copy is safe precisely because GasPicker.delegate is a
// *RandomGasGenerator shared by both copies; only a copy that ALSO clones the
// gas delegate would break the aliasing silently — see
// TestRandomGasPickerStreamSeeds, which fails loudly if the binding stops
// reaching the live picker.
func (g *configBasedGenerator) bindGasStreams(i int, cfg config.Scenario) {
if cfg.GasPicker != nil {
cfg.GasPicker.SetStream(g.rng.Stream(rng.GasBaseStream(i)))
}
if cfg.GasTipCapPicker != nil {
cfg.GasTipCapPicker.SetStream(g.rng.Stream(rng.GasTipStream(i)))
}
if cfg.GasFeeCapPicker != nil {
cfg.GasFeeCapPicker.SetStream(g.rng.Stream(rng.GasFeeCapStream(i)))
}
}

// mockDeployAll deploys all scenario instances that require deployment (for unit tests).
func (g *configBasedGenerator) mockDeployAll() error {
for _, instance := range g.instances {
Expand Down Expand Up @@ -181,7 +209,7 @@ func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) {
}

// Create and return the weighted scenarioGenerator
return NewWeightedGenerator(weightedConfigs...), nil
return NewWeightedGenerator(g.rng.Stream(rng.StreamWeightedShuffle), weightedConfigs...), nil
}

// GetAccountPools returns all account pools managed by this generator
Expand All @@ -195,10 +223,24 @@ func (g *configBasedGenerator) GetAccountPools() []types.AccountPool {
return pools
}

// resolveSeed returns the run's PRNG source, defaulting an unseeded config to a
// random seed. The resolved seed is written back to cfg.Seed and logged so any
// run is replayable after the fact; the run summary (PLT-467) reads it there.
func resolveSeed(cfg *config.LoadConfig) *rng.Source {
if cfg.Seed != nil {
return rng.NewSource(*cfg.Seed)
}
src, seed := rng.NewRandomSource()
cfg.Seed = &seed
log.Printf("🎲 No seed configured; generated random seed %d (set \"seed\" to replay)", seed)
return src
}

// NewConfigBasedGenerator is a convenience method that combines all steps
func NewConfigBasedGenerator(cfg *config.LoadConfig) (Generator, error) {
generator := &configBasedGenerator{
config: cfg,
rng: resolveSeed(cfg),
instances: make([]*scenarioInstance, 0),
deployer: types.GenerateAccounts(1)[0],
}
Expand Down
Loading
Loading