From be549af6f2c8d6ce2bab40b41082e2a66199afb1 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Tue, 23 Jun 2026 18:11:42 +0200 Subject: [PATCH 01/21] math --- utils/math.go | 48 +++++++++++++ utils/math_test.go | 170 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 utils/math.go create mode 100644 utils/math_test.go diff --git a/utils/math.go b/utils/math.go new file mode 100644 index 0000000..118c3b6 --- /dev/null +++ b/utils/math.go @@ -0,0 +1,48 @@ +package utils + +import ( + "unsafe" + + "golang.org/x/exp/constraints" +) + +// Bits returns the number of bits of the given integer type. +func Bits[I constraints.Integer]() uintptr { + return unsafe.Sizeof(I(0)) * 8 +} + +// Max returns the maximal value of the given integer type. +func Max[I constraints.Integer]() I { return ^Min[I]() } + +// Returns true iff I is a signed integer type. +func Signed[I constraints.Integer]() bool { return ^I(0) < 0 } + +// Min returns the minimal value of the given integer type. +func Min[I constraints.Integer]() I { + if Signed[I]() { + return I(1) << (Bits[I]() - 1) + } + return 0 +} + +// SafeCast casts between integer types, checking for overflows. +func SafeCast[To, From constraints.Integer](v From) (x To, ok bool) { + x = To(v) + // This can be further optimized by: + // * making compiler detect if From -> To conversion is always safe + // * making compiler detect if the parity check is necessary + ok = From(x) == v && (x < 0) == (v < 0) + return +} + +// Clamp converts an integer to another integer type clamping it to the target types' [min,max] range +// in case of overflow. +func Clamp[To, From constraints.Integer](v From) To { + if x, ok := SafeCast[To](v); ok { + return x + } + if v >= 0 { + return Max[To]() + } + return Min[To]() +} diff --git a/utils/math_test.go b/utils/math_test.go new file mode 100644 index 0000000..4a86a3e --- /dev/null +++ b/utils/math_test.go @@ -0,0 +1,170 @@ +package utils + +import ( + "math" + "testing" + "unsafe" + + "golang.org/x/exp/constraints" +) + +func runBitsTest[T constraints.Integer](t *testing.T, name string) { + t.Helper() + t.Run(name, func(t *testing.T) { + var zero T + want := uintptr(unsafe.Sizeof(zero)) * 8 + if got := Bits[T](); got != want { + t.Fatalf("Bits[%s]() = %d, want %d", name, got, want) + } + }) +} + +func runMaxMinTest[T constraints.Integer](t *testing.T, name string, wantMax, wantMin T) { + t.Helper() + t.Run(name, func(t *testing.T) { + if got := Max[T](); got != wantMax { + t.Fatalf("Max[%s]() = %v, want %v", name, got, wantMax) + } + if got := Min[T](); got != wantMin { + t.Fatalf("Min[%s]() = %v, want %v", name, got, wantMin) + } + }) +} + +func assertSafeCast[To, From constraints.Integer](t *testing.T, name string, v From, want To, wantOK bool) { + t.Helper() + t.Run(name, func(t *testing.T) { + got, ok := SafeCast[To](v) + if ok != wantOK { + t.Fatalf("SafeCast[%s] ok = %v, want %v", name, ok, wantOK) + } + if wantOK && got != want { + t.Fatalf("SafeCast[%s] = %v, want %v", name, got, want) + } + }) +} + +func runSafeCastIdentity[T constraints.Integer](t *testing.T, name string, value T) { + assertSafeCast[T, T](t, name, value, value, true) +} + +func assertClamp[To, From constraints.Integer](t *testing.T, name string, v From, want To) { + t.Helper() + t.Run(name, func(t *testing.T) { + if got := Clamp[To](v); got != want { + t.Fatalf("Clamp[%s] = %v, want %v", name, got, want) + } + }) +} + +func TestBits(t *testing.T) { + runBitsTest[int](t, "int") + runBitsTest[int8](t, "int8") + runBitsTest[int16](t, "int16") + runBitsTest[int32](t, "int32") + runBitsTest[int64](t, "int64") + runBitsTest[uint](t, "uint") + runBitsTest[uint8](t, "uint8") + runBitsTest[uint16](t, "uint16") + runBitsTest[uint32](t, "uint32") + runBitsTest[uint64](t, "uint64") + runBitsTest[uintptr](t, "uintptr") +} + +func TestMaxMin(t *testing.T) { + runMaxMinTest[int](t, "int", int(math.MaxInt), int(math.MinInt)) + runMaxMinTest[int8](t, "int8", math.MaxInt8, math.MinInt8) + runMaxMinTest[int16](t, "int16", math.MaxInt16, math.MinInt16) + runMaxMinTest[int32](t, "int32", math.MaxInt32, math.MinInt32) + runMaxMinTest[int64](t, "int64", math.MaxInt64, math.MinInt64) + + runMaxMinTest[uint](t, "uint", uint(math.MaxUint), 0) + runMaxMinTest[uint8](t, "uint8", math.MaxUint8, 0) + runMaxMinTest[uint16](t, "uint16", math.MaxUint16, 0) + runMaxMinTest[uint32](t, "uint32", math.MaxUint32, 0) + runMaxMinTest[uint64](t, "uint64", math.MaxUint64, 0) + runMaxMinTest[uintptr](t, "uintptr", ^uintptr(0), 0) +} + +func TestSafeCast(t *testing.T) { + // Identity casts at the boundaries for every integer type. + runSafeCastIdentity[int](t, "int/min", int(math.MinInt)) + runSafeCastIdentity[int](t, "int/max", int(math.MaxInt)) + runSafeCastIdentity[int8](t, "int8/min", math.MinInt8) + runSafeCastIdentity[int8](t, "int8/max", math.MaxInt8) + runSafeCastIdentity[int16](t, "int16/min", math.MinInt16) + runSafeCastIdentity[int16](t, "int16/max", math.MaxInt16) + runSafeCastIdentity[int32](t, "int32/min", math.MinInt32) + runSafeCastIdentity[int32](t, "int32/max", math.MaxInt32) + runSafeCastIdentity[int64](t, "int64/min", math.MinInt64) + runSafeCastIdentity[int64](t, "int64/max", math.MaxInt64) + + runSafeCastIdentity[uint](t, "uint/max", uint(math.MaxUint)) + runSafeCastIdentity[uint](t, "uint/zero", 0) + runSafeCastIdentity[uint8](t, "uint8/max", math.MaxUint8) + runSafeCastIdentity[uint8](t, "uint8/zero", 0) + runSafeCastIdentity[uint16](t, "uint16/max", math.MaxUint16) + runSafeCastIdentity[uint16](t, "uint16/zero", 0) + runSafeCastIdentity[uint32](t, "uint32/max", math.MaxUint32) + runSafeCastIdentity[uint32](t, "uint32/zero", 0) + runSafeCastIdentity[uint64](t, "uint64/max", math.MaxUint64) + runSafeCastIdentity[uint64](t, "uint64/zero", 0) + runSafeCastIdentity[uintptr](t, "uintptr/max", ^uintptr(0)) + runSafeCastIdentity[uintptr](t, "uintptr/zero", 0) + + // Successful cross-type casts. + assertSafeCast[int16, int8](t, "int8->int16", math.MinInt8, math.MinInt8, true) + assertSafeCast[int32, int16](t, "int16->int32", math.MaxInt16, math.MaxInt16, true) + assertSafeCast[int64, int32](t, "int32->int64", math.MinInt32, math.MinInt32, true) + assertSafeCast[int64, uint32](t, "uint32->int64", math.MaxUint32, math.MaxUint32, true) + assertSafeCast[uint32, uint16](t, "uint16->uint32", math.MaxUint16, math.MaxUint16, true) + assertSafeCast[uint64, uint32](t, "uint32->uint64", math.MaxUint32, math.MaxUint32, true) + assertSafeCast[uint, uint32](t, "uint32->uint", math.MaxUint32, uint(math.MaxUint32), true) + assertSafeCast[uintptr, uint32](t, "uint32->uintptr", math.MaxUint32, uintptr(math.MaxUint32), true) + assertSafeCast[int64, uint64](t, "uint64->int64 max", uint64(math.MaxInt64), math.MaxInt64, true) + + // Overflow and sign-mismatch detection. + assertSafeCast[int8, int16](t, "int16->int8 overflow+", int16(math.MaxInt8)+1, 0, false) + assertSafeCast[int16, int32](t, "int32->int16 overflow+", int32(math.MaxInt16)+1, 0, false) + assertSafeCast[int32, int64](t, "int64->int32 overflow+", int64(math.MaxInt32)+1, 0, false) + assertSafeCast[int64, uint64](t, "uint64->int64 overflow", uint64(math.MaxInt64)+1, 0, false) + assertSafeCast[uint8, int8](t, "int8->uint8 negative", math.MinInt8, 0, false) + assertSafeCast[uint16, int16](t, "int16->uint16 negative", math.MinInt16, 0, false) + assertSafeCast[uint32, int32](t, "int32->uint32 negative", math.MinInt32, 0, false) + assertSafeCast[uint64, int64](t, "int64->uint64 negative", math.MinInt64, 0, false) + assertSafeCast[int, uint64](t, "uint64->int overflow", math.MaxUint64, 0, false) + assertSafeCast[uint, int64](t, "int64->uint negative", math.MinInt64, 0, false) + assertSafeCast[uintptr, int64](t, "int64->uintptr negative", math.MinInt64, 0, false) + assertSafeCast[uint8, uint16](t, "uint16->uint8 overflow", uint16(math.MaxUint8)+1, 0, false) +} + +func TestClamp(t *testing.T) { + assertClamp[int](t, "int/high", uint64(math.MaxUint64), int(math.MaxInt)) + assertClamp[int](t, "int/low", int64(math.MinInt64), int(math.MinInt)) + + assertClamp[int8](t, "int8/high", int16(math.MaxInt8)+1, math.MaxInt8) + assertClamp[int8](t, "int8/low", int16(math.MinInt8)-1, math.MinInt8) + + assertClamp[int16](t, "int16/high", int32(math.MaxInt16)+1, math.MaxInt16) + assertClamp[int16](t, "int16/low", int32(math.MinInt16)-1, math.MinInt16) + assertClamp[int16](t, "int16/in-range", int32(12345), 12345) + + assertClamp[int32](t, "int32/high", int64(math.MaxInt64), math.MaxInt32) + assertClamp[int32](t, "int32/low", int64(math.MinInt64), math.MinInt32) + + assertClamp[int64](t, "int64/high", uint64(math.MaxUint64), math.MaxInt64) + assertClamp[int64](t, "int64/in-range", int64(-123456789), -123456789) + + assertClamp[uint](t, "uint/low", int64(-1), 0) + assertClamp[uint8](t, "uint8/high", int16(math.MaxUint8)+1, math.MaxUint8) + assertClamp[uint8](t, "uint8/low", int16(-1), 0) + assertClamp[uint16](t, "uint16/high", int32(1<<20), math.MaxUint16) + assertClamp[uint16](t, "uint16/in-range", int32(60000), 60000) + assertClamp[uint32](t, "uint32/high", int64(math.MaxInt64), math.MaxUint32) + assertClamp[uint32](t, "uint32/in-range", uint64(math.MaxUint32), math.MaxUint32) + assertClamp[uint64](t, "uint64/low", int64(-12345), 0) + assertClamp[uint64](t, "uint64/in-range", uint64(math.MaxUint64-1), math.MaxUint64-1) + + assertClamp[uintptr](t, "uintptr/low", int64(-1), 0) + assertClamp[uintptr](t, "uintptr/in-range", uintptr(^uintptr(0)), ^uintptr(0)) +} From 3f2a3b69b964af4f058c55ddbd6322806006f672 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Tue, 23 Jun 2026 18:25:34 +0200 Subject: [PATCH 02/21] accountPool -> AccountPool --- funder/funder.go | 4 ++-- generator/generator.go | 14 +++++++------- generator/prewarm.go | 6 +++--- generator/scenario.go | 8 ++++---- generator/scenarios/Disperse.go | 2 +- generator/weighted.go | 4 ++-- go.mod | 2 +- sender/scheduler_realworker_test.go | 4 ++-- sender/scheduler_test.go | 6 +++--- types/account_pool.go | 30 +++++++++++------------------ 10 files changed, 36 insertions(+), 44 deletions(-) diff --git a/funder/funder.go b/funder/funder.go index 3fe36bf..d80b03e 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -27,7 +27,7 @@ const balanceCheckConcurrency = 16 // per-account amount from cfg.Funding's root key, or is a no-op when // cfg.Funding is nil. See the package doc for the funding flow, the EVM // auto-association precondition, and the restart/idempotency semantics. -func FundAccounts(ctx context.Context, cfg *config.LoadConfig, pools []types.AccountPool) error { +func FundAccounts(ctx context.Context, cfg *config.LoadConfig, pools []*types.AccountPool) error { fc := cfg.Funding if fc == nil { return nil @@ -135,7 +135,7 @@ func resolveRootKey(fc *config.FundingConfig) (string, error) { return "", fmt.Errorf("funder: no root key (set funding.rootKeyFile or funding.rootKeyEnv)") } -func uniqueAddresses(pools []types.AccountPool) []common.Address { +func uniqueAddresses(pools []*types.AccountPool) []common.Address { seen := make(map[common.Address]struct{}) var out []common.Address for _, p := range pools { diff --git a/generator/generator.go b/generator/generator.go index 7a8b04d..5677194 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -18,7 +18,7 @@ import ( type Generator interface { Generate() (*types.LoadTx, bool) // Returns transaction and true if more available, nil/false when done GenerateN(n int) []*types.LoadTx - GetAccountPools() []types.AccountPool + GetAccountPools() []*types.AccountPool } // scenarioInstance represents a scenario instance with its configuration @@ -26,7 +26,7 @@ type scenarioInstance struct { Name string Weight int Scenario scenarios.TxGenerator - Accounts types.AccountPool + Accounts *types.AccountPool Deployed bool } @@ -36,8 +36,8 @@ type configBasedGenerator struct { rng *rng.Source instances []*scenarioInstance deployer *types.Account - sharedAccounts types.AccountPool // Shared account pool when using top-level config - accountPools []types.AccountPool // All account pools (shared + scenario-specific) + sharedAccounts *types.AccountPool // Shared account pool when using top-level config + accountPools []*types.AccountPool // All account pools (shared + scenario-specific) mu sync.RWMutex } @@ -65,7 +65,7 @@ func (g *configBasedGenerator) createScenarios() error { g.bindDistributionStreams(i, scenarioCfg) // Determine account pool to use - var accountPool types.AccountPool + var accountPool *types.AccountPool if scenarioCfg.Accounts != nil { // Scenario defines its own account settings - create separate pool accountCount := scenarioCfg.Accounts.Accounts @@ -228,12 +228,12 @@ func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) { } // GetAccountPools returns all account pools managed by this generator -func (g *configBasedGenerator) GetAccountPools() []types.AccountPool { +func (g *configBasedGenerator) GetAccountPools() []*types.AccountPool { g.mu.RLock() defer g.mu.RUnlock() // Return a copy of the slice to prevent external modification - pools := make([]types.AccountPool, len(g.accountPools)) + pools := make([]*types.AccountPool, len(g.accountPools)) copy(pools, g.accountPools) return pools } diff --git a/generator/prewarm.go b/generator/prewarm.go index 98c0f6e..2db1dd8 100644 --- a/generator/prewarm.go +++ b/generator/prewarm.go @@ -10,7 +10,7 @@ import ( // PrewarmGenerator generates self-transfer transactions to prewarm account nonces type PrewarmGenerator struct { - accountPools []types.AccountPool + accountPools []*types.AccountPool evmScenario scenarios.TxGenerator currentPoolIdx int finished bool @@ -99,12 +99,12 @@ func (pg *PrewarmGenerator) GenerateN(n int) []*types.LoadTx { } // GetAccountPools returns all account pools used by this prewarm generator -func (pg *PrewarmGenerator) GetAccountPools() []types.AccountPool { +func (pg *PrewarmGenerator) GetAccountPools() []*types.AccountPool { pg.mu.RLock() defer pg.mu.RUnlock() // Return a copy to prevent external modification - pools := make([]types.AccountPool, len(pg.accountPools)) + pools := make([]*types.AccountPool, len(pg.accountPools)) copy(pools, pg.accountPools) return pools } diff --git a/generator/scenario.go b/generator/scenario.go index 03b3b2f..bcaca65 100644 --- a/generator/scenario.go +++ b/generator/scenario.go @@ -9,11 +9,11 @@ import ( type scenarioGenerator struct { scenario scenarios.TxGenerator - accountPool types.AccountPool + accountPool *types.AccountPool mu sync.RWMutex } -func NewScenarioGenerator(accounts types.AccountPool, txg scenarios.TxGenerator) Generator { +func NewScenarioGenerator(accounts *types.AccountPool, txg scenarios.TxGenerator) Generator { return &scenarioGenerator{ scenario: txg, accountPool: accounts, @@ -42,8 +42,8 @@ func (g *scenarioGenerator) Generate() (*types.LoadTx, bool) { }), true } -func (sg *scenarioGenerator) GetAccountPools() []types.AccountPool { +func (sg *scenarioGenerator) GetAccountPools() []*types.AccountPool { sg.mu.RLock() defer sg.mu.RUnlock() - return []types.AccountPool{sg.accountPool} + return []*types.AccountPool{sg.accountPool} } diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index 0e5c7fd..46ec731 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -17,7 +17,7 @@ const Disperse = "disperse" type DisperseScenario struct { *ContractScenarioBase[bindings.Disperse] contract *bindings.Disperse - pool types.AccountPool + pool *types.AccountPool } // NewDisperseScenario creates a new Disperse scenario diff --git a/generator/weighted.go b/generator/weighted.go index c1c799d..1ff0b20 100644 --- a/generator/weighted.go +++ b/generator/weighted.go @@ -87,11 +87,11 @@ func (w *weightedGenerator) Generate() (*types.LoadTx, bool) { } // GetAccountPools returns all account pools from underlying generators -func (w *weightedGenerator) GetAccountPools() []types.AccountPool { +func (w *weightedGenerator) GetAccountPools() []*types.AccountPool { w.mx.RLock() defer w.mx.RUnlock() - var allPools []types.AccountPool + var allPools []*types.AccountPool // Collect pools from all underlying generators for _, gen := range w.generators { diff --git a/go.mod b/go.mod index 8aad25a..c2290d3 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/ethereum/go-ethereum v1.16.1 github.com/gogo/protobuf v1.3.2 github.com/google/go-cmp v0.7.0 - github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.22.0 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 @@ -20,6 +19,7 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df golang.org/x/sync v0.20.0 golang.org/x/time v0.9.0 google.golang.org/protobuf v1.36.11 diff --git a/sender/scheduler_realworker_test.go b/sender/scheduler_realworker_test.go index 7df3519..661f95a 100644 --- a/sender/scheduler_realworker_test.go +++ b/sender/scheduler_realworker_test.go @@ -206,8 +206,8 @@ func (g *signedTxGenerator) Generate() (*types.LoadTx, bool) { return types.CreateTxFromEthTx(signed, scenario), true } -func (g *signedTxGenerator) GenerateN(int) []*types.LoadTx { panic("unused") } -func (g *signedTxGenerator) GetAccountPools() []types.AccountPool { return nil } +func (g *signedTxGenerator) GenerateN(int) []*types.LoadTx { panic("unused") } +func (g *signedTxGenerator) GetAccountPools() []*types.AccountPool { return nil } func (g *signedTxGenerator) issuedCount() int { g.mu.Lock() diff --git a/sender/scheduler_test.go b/sender/scheduler_test.go index 01fbdb3..3e35684 100644 --- a/sender/scheduler_test.go +++ b/sender/scheduler_test.go @@ -40,7 +40,7 @@ func (g *fakeGenerator) Generate() (*types.LoadTx, bool) { } func (g *fakeGenerator) GenerateN(int) []*types.LoadTx { panic("unused") } -func (g *fakeGenerator) GetAccountPools() []types.AccountPool { +func (g *fakeGenerator) GetAccountPools() []*types.AccountPool { return nil } @@ -91,8 +91,8 @@ func (g *seededGenerator) draw(tx *types.LoadTx) uint64 { return g.drawIndex[tx] } -func (g *seededGenerator) GenerateN(int) []*types.LoadTx { panic("unused") } -func (g *seededGenerator) GetAccountPools() []types.AccountPool { return nil } +func (g *seededGenerator) GenerateN(int) []*types.LoadTx { panic("unused") } +func (g *seededGenerator) GetAccountPools() []*types.AccountPool { return nil } // asyncFakeSender models the production ShardedSender's send semantics: Send // returns when the tx lands in a buffered channel (enqueue-and-return), NOT when diff --git a/types/account_pool.go b/types/account_pool.go index d1d66d0..20037c2 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -8,12 +8,12 @@ import ( ) // AccountPool returns a next account for load generation. -type AccountPool interface { - NextAccount() *Account - // GetAccounts returns the fixed accounts backing the pool (excludes any - // on-demand accounts minted via NewAccountRate). Used to enumerate the pool - // for one-time funding. - GetAccounts() []*Account +type AccountPool struct { + Accounts []*Account + cfg *AccountConfig + + mx sync.Mutex + idx int } // AccountConfig stores the configuration for account generation. @@ -25,15 +25,7 @@ type AccountConfig struct { Stream *rng.Stream } -type accountPool struct { - Accounts []*Account - cfg *AccountConfig - - mx sync.Mutex - idx int -} - -func (a *accountPool) nextIndex() int { +func (a *AccountPool) nextIndex() int { a.mx.Lock() defer a.mx.Unlock() a.idx++ @@ -42,7 +34,7 @@ func (a *accountPool) nextIndex() int { } // NextAccount returns the next account. -func (a *accountPool) NextAccount() *Account { +func (a *AccountPool) NextAccount() *Account { if a.cfg.NewAccountRate > 0 { var randomNumber float64 if a.cfg.Stream != nil { @@ -58,13 +50,13 @@ func (a *accountPool) NextAccount() *Account { } // GetAccounts returns the fixed accounts backing the pool. -func (a *accountPool) GetAccounts() []*Account { +func (a *AccountPool) GetAccounts() []*Account { return a.Accounts } // NewAccountPool creates a new account generator from a config. -func NewAccountPool(cfg *AccountConfig) AccountPool { - return &accountPool{ +func NewAccountPool(cfg *AccountConfig) *AccountPool { + return &AccountPool{ Accounts: cfg.Accounts, cfg: cfg, } From 6aa8cecb210ba9a5bd975ab171a4340ec2db6132 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Tue, 23 Jun 2026 18:29:42 +0200 Subject: [PATCH 03/21] global GenerateN --- generator/generator.go | 14 +++++++++++++- generator/generator_test.go | 2 +- generator/prewarm.go | 13 ------------- generator/scenario.go | 12 ------------ generator/seed_test.go | 2 +- generator/weighted.go | 13 ------------- sender/scheduler_realworker_test.go | 1 - sender/scheduler_test.go | 2 -- sender/writer_test.go | 4 ++-- 9 files changed, 17 insertions(+), 46 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index 5677194..ce0d121 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -17,10 +17,22 @@ import ( // Generator interface defines the contract for transaction generators type Generator interface { Generate() (*types.LoadTx, bool) // Returns transaction and true if more available, nil/false when done - GenerateN(n int) []*types.LoadTx GetAccountPools() []*types.AccountPool } +// GenerateN drains up to n transactions from g by repeated Generate calls. +func GenerateN(g Generator, n int) []*types.LoadTx { + txs := make([]*types.LoadTx, 0, n) + for i := 0; i < n; i++ { + if tx, ok := g.Generate(); ok { + txs = append(txs, tx) + } else { + break + } + } + return txs +} + // scenarioInstance represents a scenario instance with its configuration type scenarioInstance struct { Name string diff --git a/generator/generator_test.go b/generator/generator_test.go index adf4bdd..f2df06f 100644 --- a/generator/generator_test.go +++ b/generator/generator_test.go @@ -40,7 +40,7 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { require.NotNil(t, gen) totalTxs := 100 - txs := gen.GenerateN(totalTxs) + txs := generator.GenerateN(gen, totalTxs) require.Len(t, txs, totalTxs) // Count occurrences per scenario diff --git a/generator/prewarm.go b/generator/prewarm.go index 2db1dd8..4dc8ea7 100644 --- a/generator/prewarm.go +++ b/generator/prewarm.go @@ -85,19 +85,6 @@ func (pg *PrewarmGenerator) Generate() (*types.LoadTx, bool) { return pg.evmScenario.Generate(scenario), true } -// GenerateN generates n prewarming transactions -func (pg *PrewarmGenerator) GenerateN(n int) []*types.LoadTx { - result := make([]*types.LoadTx, 0, n) - for i := 0; i < n; i++ { - if tx, ok := pg.Generate(); ok { - result = append(result, tx) - } else { - break // Generator is done - } - } - return result -} - // GetAccountPools returns all account pools used by this prewarm generator func (pg *PrewarmGenerator) GetAccountPools() []*types.AccountPool { pg.mu.RLock() diff --git a/generator/scenario.go b/generator/scenario.go index bcaca65..c9d8659 100644 --- a/generator/scenario.go +++ b/generator/scenario.go @@ -20,18 +20,6 @@ func NewScenarioGenerator(accounts *types.AccountPool, txg scenarios.TxGenerator } } -func (g *scenarioGenerator) GenerateN(n int) []*types.LoadTx { - result := make([]*types.LoadTx, 0, n) - for range n { - if tx, ok := g.Generate(); ok { - result = append(result, tx) - } else { - break // Generator is done - } - } - return result -} - func (g *scenarioGenerator) Generate() (*types.LoadTx, bool) { sender := g.accountPool.NextAccount() receiver := g.accountPool.NextAccount() diff --git a/generator/seed_test.go b/generator/seed_test.go index 2b9842b..6f663b5 100644 --- a/generator/seed_test.go +++ b/generator/seed_test.go @@ -70,7 +70,7 @@ func gasSeq(t *testing.T, seed uint64, n int) []gasDraw { t.Helper() gen, err := generator.NewConfigBasedGenerator(seededConfig(t, seed)) require.NoError(t, err) - txs := gen.GenerateN(n) + txs := generator.GenerateN(gen, n) require.Len(t, txs, n) out := make([]gasDraw, n) for i, tx := range txs { diff --git a/generator/weighted.go b/generator/weighted.go index 1ff0b20..a9ae8f2 100644 --- a/generator/weighted.go +++ b/generator/weighted.go @@ -68,19 +68,6 @@ func (w *weightedGenerator) nextGenerator() Generator { return w.generators[w.nextIndex()] } -// GenerateN generates n transactions. -func (w *weightedGenerator) GenerateN(n int) []*types.LoadTx { - txs := make([]*types.LoadTx, 0, n) - for range n { - if tx, ok := w.Generate(); ok { - txs = append(txs, tx) - } else { - break // Generator is done - } - } - return txs -} - // Generate generates 1 transaction. func (w *weightedGenerator) Generate() (*types.LoadTx, bool) { return w.nextGenerator().Generate() diff --git a/sender/scheduler_realworker_test.go b/sender/scheduler_realworker_test.go index 661f95a..f25cd73 100644 --- a/sender/scheduler_realworker_test.go +++ b/sender/scheduler_realworker_test.go @@ -206,7 +206,6 @@ func (g *signedTxGenerator) Generate() (*types.LoadTx, bool) { return types.CreateTxFromEthTx(signed, scenario), true } -func (g *signedTxGenerator) GenerateN(int) []*types.LoadTx { panic("unused") } func (g *signedTxGenerator) GetAccountPools() []*types.AccountPool { return nil } func (g *signedTxGenerator) issuedCount() int { diff --git a/sender/scheduler_test.go b/sender/scheduler_test.go index 3e35684..80cb127 100644 --- a/sender/scheduler_test.go +++ b/sender/scheduler_test.go @@ -39,7 +39,6 @@ func (g *fakeGenerator) Generate() (*types.LoadTx, bool) { return tx, true } -func (g *fakeGenerator) GenerateN(int) []*types.LoadTx { panic("unused") } func (g *fakeGenerator) GetAccountPools() []*types.AccountPool { return nil } @@ -91,7 +90,6 @@ func (g *seededGenerator) draw(tx *types.LoadTx) uint64 { return g.drawIndex[tx] } -func (g *seededGenerator) GenerateN(int) []*types.LoadTx { panic("unused") } func (g *seededGenerator) GetAccountPools() []*types.AccountPool { return nil } // asyncFakeSender models the production ShardedSender's send semantics: Send diff --git a/sender/writer_test.go b/sender/writer_test.go index e0247cc..32db898 100644 --- a/sender/writer_test.go +++ b/sender/writer_test.go @@ -30,9 +30,9 @@ func TestTxsWriter_Flush(t *testing.T) { }) evmScenario.Deploy(loadConfig, sharedAccounts.NextAccount()) - generator := generator.NewScenarioGenerator(sharedAccounts, evmScenario) + gen := generator.NewScenarioGenerator(sharedAccounts, evmScenario) - txs := generator.GenerateN(3) + txs := generator.GenerateN(gen, 3) err := writer.Send(context.Background(), txs[0]) require.NoError(t, err) From 8d7f4cdd27a36a23268bf3f7275664802b7e2d2d Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 14:16:10 +0200 Subject: [PATCH 04/21] removed mutexes --- generator/generator.go | 20 ++----- generator/prewarm.go | 13 +---- generator/scenario.go | 5 -- generator/seed_test.go | 89 ----------------------------- generator/weighted.go | 17 ++---- sender/eth_client_test.go | 3 +- sender/scheduler_realworker_test.go | 3 +- types/account.go | 23 +++----- types/types_test.go | 33 +++-------- 9 files changed, 30 insertions(+), 176 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index ce0d121..5c2ba6c 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "log" - "sync" "github.com/ethereum/go-ethereum/common" @@ -14,7 +13,10 @@ import ( "github.com/sei-protocol/sei-load/utils/rng" ) -// Generator interface defines the contract for transaction generators +// Generator defines the contract for transaction generators. +// +// Generators are not thread-safe. Callers must serialize all access to a given +// Generator instance, including Generate and GetAccountPools. type Generator interface { Generate() (*types.LoadTx, bool) // Returns transaction and true if more available, nil/false when done GetAccountPools() []*types.AccountPool @@ -23,7 +25,7 @@ type Generator interface { // GenerateN drains up to n transactions from g by repeated Generate calls. func GenerateN(g Generator, n int) []*types.LoadTx { txs := make([]*types.LoadTx, 0, n) - for i := 0; i < n; i++ { + for range n { if tx, ok := g.Generate(); ok { txs = append(txs, tx) } else { @@ -50,15 +52,11 @@ type configBasedGenerator struct { deployer *types.Account sharedAccounts *types.AccountPool // Shared account pool when using top-level config accountPools []*types.AccountPool // All account pools (shared + scenario-specific) - mu sync.RWMutex } // CreateScenarios creates scenario instances based on the configuration // Each scenario entry in config creates a separate instance, even if same name func (g *configBasedGenerator) createScenarios() error { - g.mu.Lock() - defer g.mu.Unlock() - // Create shared account pool if top-level account config exists if g.config.Accounts != nil { accounts := types.GenerateAccounts(g.config.Accounts.Accounts) @@ -183,8 +181,6 @@ func (g *configBasedGenerator) deployAll() error { if g.config.MockDeploy { return g.mockDeployAll() } - g.mu.Lock() - defer g.mu.Unlock() // Deploy sequentially to ensure proper nonce management for _, instance := range g.instances { @@ -203,9 +199,6 @@ func (g *configBasedGenerator) deployAll() error { // createWeightedGenerator creates a weighted scenarioGenerator from deployed scenarios func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) { - g.mu.RLock() - defer g.mu.RUnlock() - if len(g.instances) == 0 { return nil, fmt.Errorf("no scenario instances created") } @@ -241,9 +234,6 @@ func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) { // GetAccountPools returns all account pools managed by this generator func (g *configBasedGenerator) GetAccountPools() []*types.AccountPool { - g.mu.RLock() - defer g.mu.RUnlock() - // Return a copy of the slice to prevent external modification pools := make([]*types.AccountPool, len(g.accountPools)) copy(pools, g.accountPools) diff --git a/generator/prewarm.go b/generator/prewarm.go index 4dc8ea7..c7f2d58 100644 --- a/generator/prewarm.go +++ b/generator/prewarm.go @@ -1,7 +1,7 @@ package generator import ( - "sync" + "slices" "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/scenarios" @@ -14,7 +14,6 @@ type PrewarmGenerator struct { evmScenario scenarios.TxGenerator currentPoolIdx int finished bool - mu sync.RWMutex } // NewPrewarmGenerator creates a new prewarm generator using all account pools from the main generator @@ -40,9 +39,6 @@ func NewPrewarmGenerator(cfg *config.LoadConfig, mainGenerator Generator) *Prewa // Generate generates self-transfer transactions until all accounts are prewarmed func (pg *PrewarmGenerator) Generate() (*types.LoadTx, bool) { - pg.mu.Lock() - defer pg.mu.Unlock() - // Check if we're already finished if pg.finished || pg.currentPoolIdx >= len(pg.accountPools) { return nil, false @@ -87,11 +83,6 @@ func (pg *PrewarmGenerator) Generate() (*types.LoadTx, bool) { // GetAccountPools returns all account pools used by this prewarm generator func (pg *PrewarmGenerator) GetAccountPools() []*types.AccountPool { - pg.mu.RLock() - defer pg.mu.RUnlock() - // Return a copy to prevent external modification - pools := make([]*types.AccountPool, len(pg.accountPools)) - copy(pools, pg.accountPools) - return pools + return slices.Clone(pg.accountPools) } diff --git a/generator/scenario.go b/generator/scenario.go index c9d8659..1f0481d 100644 --- a/generator/scenario.go +++ b/generator/scenario.go @@ -1,8 +1,6 @@ package generator import ( - "sync" - "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" ) @@ -10,7 +8,6 @@ import ( type scenarioGenerator struct { scenario scenarios.TxGenerator accountPool *types.AccountPool - mu sync.RWMutex } func NewScenarioGenerator(accounts *types.AccountPool, txg scenarios.TxGenerator) Generator { @@ -31,7 +28,5 @@ func (g *scenarioGenerator) Generate() (*types.LoadTx, bool) { } func (sg *scenarioGenerator) GetAccountPools() []*types.AccountPool { - sg.mu.RLock() - defer sg.mu.RUnlock() return []*types.AccountPool{sg.accountPool} } diff --git a/generator/seed_test.go b/generator/seed_test.go index 6f663b5..4309053 100644 --- a/generator/seed_test.go +++ b/generator/seed_test.go @@ -2,7 +2,6 @@ package generator_test import ( "fmt" - "sync" "testing" "github.com/stretchr/testify/require" @@ -101,91 +100,3 @@ func TestDifferentSeedsDiverge(t *testing.T) { require.NotEqual(t, gasSeq(t, 1, 200), gasSeq(t, 2, 200)) } -// columns transposes a slice of per-tx gas draws into three per-stream column -// slices. The contract guarantees per-*stream* multisets, not per-tx tuples: -// concurrent txs interleave their base/tip/feecap draws across three -// independently-locked streams, so tuples reassemble differently while each -// stream's column multiset is unchanged. We assert columns, never tuples. -func columns(draws []gasDraw) (gas []uint64, tip, feeCap []int64) { - gas = make([]uint64, len(draws)) - tip = make([]int64, len(draws)) - feeCap = make([]int64, len(draws)) - for i, d := range draws { - gas[i] = d.gas - tip[i] = d.tip - feeCap[i] = d.feeCap - } - return gas, tip, feeCap -} - -// TestWorkerCountMultisetInvariant asserts the per-stream multiset guarantee, -// not ordered replay: each gas stream's column multiset that a seed yields does -// not depend on how many worker goroutines concurrently consume the generator. -// Streams are keyed by logical config id, not a live-goroutine counter, so the -// per-stream multiset is invariant to --workers. -// -// Two things make this test exercise the real contract rather than a stronger -// false one: -// -// - gen.Generate() runs OUTSIDE the worker lock, so workers genuinely draw -// concurrently and the three streams interleave. The lock guards only the -// work-claim bookkeeping. (Run under -race; -count=10 guards against flake.) -// - We assert each column's multiset independently via ElementsMatch, NOT the -// per-tx tuple. Tuples are not worker-invariant; columns are. -// -// Ordering within a column is deliberately NOT asserted; it is non-deterministic -// above one worker. -func TestWorkerCountMultisetInvariant(t *testing.T) { - const seed, total = 99, 600 - - serial := gasSeq(t, seed, total) - wantGas, wantTip, wantFeeCap := columns(serial) - - for _, workers := range []int{2, 4, 8} { - gen, err := generator.NewConfigBasedGenerator(seededConfig(t, seed)) - require.NoError(t, err) - - // Each worker collects into its own slice; we merge after the join so the - // only shared mutable state under lock is the work-claim counter, and - // Generate() itself runs unlocked and concurrently. - var mu sync.Mutex - remaining := total - perWorker := make([][]gasDraw, workers) - - var wg sync.WaitGroup - for w := 0; w < workers; w++ { - wg.Add(1) - go func(w int) { - defer wg.Done() - for { - mu.Lock() - if remaining <= 0 { - mu.Unlock() - return - } - remaining-- - mu.Unlock() - - tx, ok := gen.Generate() - require.True(t, ok) - perWorker[w] = append(perWorker[w], draw(tx)) - } - }(w) - } - wg.Wait() - - got := make([]gasDraw, 0, total) - for _, part := range perWorker { - got = append(got, part...) - } - require.Len(t, got, total) - - gotGas, gotTip, gotFeeCap := columns(got) - require.ElementsMatch(t, wantGas, gotGas, - "workers=%d: gas-stream column multiset diverged from serial", workers) - require.ElementsMatch(t, wantTip, gotTip, - "workers=%d: tip-stream column multiset diverged from serial", workers) - require.ElementsMatch(t, wantFeeCap, gotFeeCap, - "workers=%d: feecap-stream column multiset diverged from serial", workers) - } -} diff --git a/generator/weighted.go b/generator/weighted.go index a9ae8f2..9640d26 100644 --- a/generator/weighted.go +++ b/generator/weighted.go @@ -3,7 +3,6 @@ package generator import ( "context" "math/rand/v2" - "sync" "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/utils/rng" @@ -25,8 +24,7 @@ func WeightedConfig(weight int, generator Generator) *WeightedCfg { type weightedGenerator struct { generators []Generator - mx sync.RWMutex - counter int64 + counter uint64 } // GenerateInfinite generates transactions indefinitely. @@ -53,14 +51,10 @@ func (w *weightedGenerator) GenerateInfinite(ctx context.Context) <-chan *types. return output } -func (w *weightedGenerator) nextIndex() int64 { - w.mx.Lock() - defer w.mx.Unlock() +func (w *weightedGenerator) nextIndex() int { + idx := int(w.counter) % len(w.generators) w.counter++ - if w.counter >= int64(len(w.generators)) { - w.counter = 0 - } - return w.counter + return idx } // generators are randomized at startup. @@ -75,9 +69,6 @@ func (w *weightedGenerator) Generate() (*types.LoadTx, bool) { // GetAccountPools returns all account pools from underlying generators func (w *weightedGenerator) GetAccountPools() []*types.AccountPool { - w.mx.RLock() - defer w.mx.RUnlock() - var allPools []*types.AccountPool // Collect pools from all underlying generators diff --git a/sender/eth_client_test.go b/sender/eth_client_test.go index cad33ec..847f24f 100644 --- a/sender/eth_client_test.go +++ b/sender/eth_client_test.go @@ -148,8 +148,7 @@ func (m *mockEthAPI) RawTransactions() [][]byte { func testLoadTx(t *testing.T) *types.LoadTx { t.Helper() - account, err := types.NewAccount() - require.NoError(t, err) + account := types.NewAccount() to := common.HexToAddress("0x0000000000000000000000000000000000000001") tx := ethtypes.NewTx(ðtypes.LegacyTx{ diff --git a/sender/scheduler_realworker_test.go b/sender/scheduler_realworker_test.go index f25cd73..da6f1d8 100644 --- a/sender/scheduler_realworker_test.go +++ b/sender/scheduler_realworker_test.go @@ -167,8 +167,7 @@ type signedTxGenerator struct { func newSignedTxGenerator(t *testing.T, n int) *signedTxGenerator { t.Helper() - acct, err := types.NewAccount() - require.NoError(t, err) + acct := types.NewAccount() chainID := big.NewInt(1) return &signedTxGenerator{ remaining: n, diff --git a/types/account.go b/types/account.go index 45dc96b..e764593 100644 --- a/types/account.go +++ b/types/account.go @@ -6,6 +6,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" + "github.com/sei-protocol/sei-load/utils" ) // Account wraps address and private key. @@ -16,32 +17,24 @@ type Account struct { } // NewAccount generates new account. -func NewAccount() (*Account, error) { - privateKey, err := crypto.GenerateKey() - if err != nil { - return nil, err - } +func NewAccount() *Account { + privateKey := utils.OrPanic1(crypto.GenerateKey()) return &Account{ Address: crypto.PubkeyToAddress(privateKey.PublicKey), PrivKey: privateKey, - }, nil + } } // GetAndIncrementNonce increments the nonce. func (s *Account) GetAndIncrementNonce() uint64 { - next := atomic.AddUint64(&s.Nonce, 1) - return next - 1 + return atomic.AddUint64(&s.Nonce, 1)-1 } // GenerateAccounts generates random accounts. func GenerateAccounts(n int) []*Account { - result := make([]*Account, 0, n) - for range n { - newAcc, err := NewAccount() - if err != nil { - panic(err) - } - result = append(result, newAcc) + result := make([]*Account, n) + for i := range result { + result[i] = NewAccount() } return result } diff --git a/types/types_test.go b/types/types_test.go index 33eb6a2..e43945c 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -16,8 +16,7 @@ import ( ) func TestNewAccount(t *testing.T) { - account, err := NewAccount() - require.NoError(t, err) + account := NewAccount() require.NotNil(t, account) // Verify account has valid address and private key @@ -34,8 +33,7 @@ func TestNewAccount(t *testing.T) { } func TestAccountNonceManagement(t *testing.T) { - account, err := NewAccount() - require.NoError(t, err) + account := NewAccount() // Test sequential nonce increments for i := uint64(0); i < 10; i++ { @@ -48,8 +46,7 @@ func TestAccountNonceManagement(t *testing.T) { } func TestAccountNonceConcurrency(t *testing.T) { - account, err := NewAccount() - require.NoError(t, err) + account := NewAccount() const numGoroutines = 100 const noncesPerGoroutine = 10 @@ -249,8 +246,7 @@ func TestAccountPoolConcurrency(t *testing.T) { func TestCreateTxFromEthTx(t *testing.T) { // Create a test account and scenario - account, err := NewAccount() - require.NoError(t, err) + account := NewAccount() account.Nonce = 42 receiver := common.HexToAddress("0x1234567890123456789012345678901234567890") @@ -371,8 +367,7 @@ func TestLoadTxShardID(t *testing.T) { func TestLoadTxShardIDConsistency(t *testing.T) { // Test that the same sender always maps to the same shard - account, err := NewAccount() - require.NoError(t, err) + account := NewAccount() scenario := &TxScenario{ Name: "TestScenario", @@ -404,8 +399,7 @@ func TestLoadTxShardIDConsistency(t *testing.T) { } func TestTxScenario(t *testing.T) { - account, err := NewAccount() - require.NoError(t, err) + account := NewAccount() receiver := common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd") @@ -438,10 +432,7 @@ func TestJSONRPCPayloadFormat(t *testing.T) { func BenchmarkAccountGeneration(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := NewAccount() - if err != nil { - b.Fatal(err) - } + _ = NewAccount() } } @@ -460,10 +451,7 @@ func BenchmarkAccountPoolNextAccount(b *testing.B) { } func BenchmarkNonceIncrement(b *testing.B) { - account, err := NewAccount() - if err != nil { - b.Fatal(err) - } + account := NewAccount() b.ResetTimer() for i := 0; i < b.N; i++ { @@ -472,10 +460,7 @@ func BenchmarkNonceIncrement(b *testing.B) { } func BenchmarkCreateTxFromEthTx(b *testing.B) { - account, err := NewAccount() - if err != nil { - b.Fatal(err) - } + account := NewAccount() scenario := &TxScenario{ Name: "BenchmarkScenario", From ce7019ebc2231aa75732077e7a9e41c59af12826 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 14:25:05 +0200 Subject: [PATCH 05/21] accounts in accountpool --- generator/generator.go | 13 ++++--------- generator/scenarios/Disperse.go | 1 + generator/seed_test.go | 1 - sender/writer_test.go | 2 +- types/account.go | 2 +- types/account_pool.go | 4 ++-- types/types_test.go | 19 +++++++++---------- 7 files changed, 18 insertions(+), 24 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index 5c2ba6c..ef052fd 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -59,9 +59,8 @@ type configBasedGenerator struct { func (g *configBasedGenerator) createScenarios() error { // Create shared account pool if top-level account config exists if g.config.Accounts != nil { - accounts := types.GenerateAccounts(g.config.Accounts.Accounts) g.sharedAccounts = types.NewAccountPool(&types.AccountConfig{ - Accounts: accounts, + InitialSize: g.config.Accounts.Accounts, NewAccountRate: g.config.Accounts.NewAccountRate, Stream: g.rng.Stream(rng.StreamAccountsShared), }) @@ -76,15 +75,11 @@ func (g *configBasedGenerator) createScenarios() error { // Determine account pool to use var accountPool *types.AccountPool - if scenarioCfg.Accounts != nil { + if accounts := scenarioCfg.Accounts; accounts != nil { // Scenario defines its own account settings - create separate pool - accountCount := scenarioCfg.Accounts.Accounts - newAccountRate := scenarioCfg.Accounts.NewAccountRate - - accounts := types.GenerateAccounts(accountCount) accountPool = types.NewAccountPool(&types.AccountConfig{ - Accounts: accounts, - NewAccountRate: newAccountRate, + InitialSize: accounts.Accounts, + NewAccountRate: accounts.NewAccountRate, Stream: g.rng.Stream(rng.AccountsScenarioStream(i)), }) g.accountPools = append(g.accountPools, accountPool) diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index 46ec731..c2422b0 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -25,6 +25,7 @@ func NewDisperseScenario(cfg config.Scenario) TxGenerator { scenario := &DisperseScenario{} scenario.ContractScenarioBase = NewContractScenarioBase[bindings.Disperse](scenario, cfg) scenario.pool = types.NewAccountPool(&types.AccountConfig{ + InitialSize: 0, NewAccountRate: 1.0, }) return scenario diff --git a/generator/seed_test.go b/generator/seed_test.go index 4309053..e6f621b 100644 --- a/generator/seed_test.go +++ b/generator/seed_test.go @@ -99,4 +99,3 @@ func TestSingleWorkerOrderedReplay(t *testing.T) { func TestDifferentSeedsDiverge(t *testing.T) { require.NotEqual(t, gasSeq(t, 1, 200), gasSeq(t, 2, 200)) } - diff --git a/sender/writer_test.go b/sender/writer_test.go index 32db898..97c088c 100644 --- a/sender/writer_test.go +++ b/sender/writer_test.go @@ -20,7 +20,7 @@ func TestTxsWriter_Flush(t *testing.T) { } sharedAccounts := types.NewAccountPool(&types.AccountConfig{ - Accounts: types.GenerateAccounts(10), + InitialSize: 10, NewAccountRate: 0.0, }) diff --git a/types/account.go b/types/account.go index e764593..6c7ac8b 100644 --- a/types/account.go +++ b/types/account.go @@ -27,7 +27,7 @@ func NewAccount() *Account { // GetAndIncrementNonce increments the nonce. func (s *Account) GetAndIncrementNonce() uint64 { - return atomic.AddUint64(&s.Nonce, 1)-1 + return atomic.AddUint64(&s.Nonce, 1) - 1 } // GenerateAccounts generates random accounts. diff --git a/types/account_pool.go b/types/account_pool.go index 20037c2..8d7d950 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -18,7 +18,7 @@ type AccountPool struct { // AccountConfig stores the configuration for account generation. type AccountConfig struct { - Accounts []*Account + InitialSize int NewAccountRate float64 // Stream, when non-nil, makes the new-account roll deterministic. A nil // Stream leaves the pool on the unseeded global RNG. @@ -57,7 +57,7 @@ func (a *AccountPool) GetAccounts() []*Account { // NewAccountPool creates a new account generator from a config. func NewAccountPool(cfg *AccountConfig) *AccountPool { return &AccountPool{ - Accounts: cfg.Accounts, + Accounts: GenerateAccounts(cfg.InitialSize), cfg: cfg, } } diff --git a/types/types_test.go b/types/types_test.go index e43945c..fcf7610 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -120,13 +120,13 @@ func TestGenerateAccounts(t *testing.T) { } func TestAccountPoolRoundRobin(t *testing.T) { - accounts := GenerateAccounts(3) config := &AccountConfig{ - Accounts: accounts, + InitialSize: 3, NewAccountRate: 0.0, // No new accounts, pure round-robin } pool := NewAccountPool(config) + accounts := pool.GetAccounts() // The account pool starts from index 1 (due to nextIndex() incrementing first) // So the first call returns accounts[1], second returns accounts[2], third returns accounts[0] @@ -145,13 +145,13 @@ func TestAccountPoolRoundRobin(t *testing.T) { } func TestAccountPoolNewAccountRate(t *testing.T) { - accounts := GenerateAccounts(2) config := &AccountConfig{ - Accounts: accounts, + InitialSize: 2, NewAccountRate: 1.0, // Always generate new accounts } pool := NewAccountPool(config) + accounts := pool.GetAccounts() // With 100% new account rate, should never get original accounts originalAddresses := make(map[common.Address]bool) @@ -168,14 +168,14 @@ func TestAccountPoolNewAccountRate(t *testing.T) { } func TestAccountPoolMixedRate(t *testing.T) { - accounts := GenerateAccounts(5) config := &AccountConfig{ - Accounts: accounts, + InitialSize: 5, NewAccountRate: 0.5, // 50% new accounts Stream: rng.NewSource(1).Stream("accounts:test"), } pool := NewAccountPool(config) + accounts := pool.GetAccounts() originalAddresses := make(map[common.Address]bool) for _, account := range accounts { @@ -204,13 +204,13 @@ func TestAccountPoolMixedRate(t *testing.T) { } func TestAccountPoolConcurrency(t *testing.T) { - accounts := GenerateAccounts(5) config := &AccountConfig{ - Accounts: accounts, + InitialSize: 5, NewAccountRate: 0.0, // Pure round-robin for predictable testing } pool := NewAccountPool(config) + accounts := pool.GetAccounts() const numGoroutines = 50 const selectionsPerGoroutine = 20 @@ -437,9 +437,8 @@ func BenchmarkAccountGeneration(b *testing.B) { } func BenchmarkAccountPoolNextAccount(b *testing.B) { - accounts := GenerateAccounts(100) config := &AccountConfig{ - Accounts: accounts, + InitialSize: 100, NewAccountRate: 0.0, } pool := NewAccountPool(config) From 1eea5cda29fa317b753d48c94c2aab79506e072b Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 15:03:47 +0200 Subject: [PATCH 06/21] account registry --- funder/funder.go | 20 +++++------ generator/generator.go | 24 ++++--------- generator/generator_test.go | 3 +- generator/prewarm.go | 56 ++++++++--------------------- generator/scenario.go | 4 --- generator/scenarios/Disperse.go | 2 +- generator/seed_test.go | 2 +- generator/weighted.go | 13 ------- main.go | 7 ++-- sender/scheduler_realworker_test.go | 2 -- sender/scheduler_test.go | 6 ---- sender/sender_test.go | 2 +- sender/writer_test.go | 2 +- types/account_pool.go | 27 ++++++++++++-- types/types_test.go | 15 +++++--- utils/rng/rng.go | 4 +-- 16 files changed, 75 insertions(+), 114 deletions(-) diff --git a/funder/funder.go b/funder/funder.go index d80b03e..daac402 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -23,11 +23,11 @@ import ( const balanceCheckConcurrency = 16 -// FundAccounts funds every account across the pools to at least the configured +// FundAccounts funds every account to at least the configured // per-account amount from cfg.Funding's root key, or is a no-op when // cfg.Funding is nil. See the package doc for the funding flow, the EVM // auto-association precondition, and the restart/idempotency semantics. -func FundAccounts(ctx context.Context, cfg *config.LoadConfig, pools []*types.AccountPool) error { +func FundAccounts(ctx context.Context, cfg *config.LoadConfig, accounts []*types.Account) error { fc := cfg.Funding if fc == nil { return nil @@ -51,7 +51,7 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, pools []*types.Ac } defer client.Close() - recipients := uniqueAddresses(pools) + recipients := uniqueAddresses(accounts) if len(recipients) == 0 { log.Printf("💰 funder: no accounts to fund") return nil @@ -135,17 +135,15 @@ func resolveRootKey(fc *config.FundingConfig) (string, error) { return "", fmt.Errorf("funder: no root key (set funding.rootKeyFile or funding.rootKeyEnv)") } -func uniqueAddresses(pools []*types.AccountPool) []common.Address { +func uniqueAddresses(accounts []*types.Account) []common.Address { seen := make(map[common.Address]struct{}) var out []common.Address - for _, p := range pools { - for _, a := range p.GetAccounts() { - if _, ok := seen[a.Address]; ok { - continue - } - seen[a.Address] = struct{}{} - out = append(out, a.Address) + for _, a := range accounts { + if _, ok := seen[a.Address]; ok { + continue } + seen[a.Address] = struct{}{} + out = append(out, a.Address) } return out } diff --git a/generator/generator.go b/generator/generator.go index ef052fd..02a7dbc 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -16,10 +16,9 @@ import ( // Generator defines the contract for transaction generators. // // Generators are not thread-safe. Callers must serialize all access to a given -// Generator instance, including Generate and GetAccountPools. +// Generator instance. type Generator interface { Generate() (*types.LoadTx, bool) // Returns transaction and true if more available, nil/false when done - GetAccountPools() []*types.AccountPool } // GenerateN drains up to n transactions from g by repeated Generate calls. @@ -48,10 +47,10 @@ type scenarioInstance struct { type configBasedGenerator struct { config *config.LoadConfig rng *rng.Source + registry *types.AccountRegistry instances []*scenarioInstance deployer *types.Account sharedAccounts *types.AccountPool // Shared account pool when using top-level config - accountPools []*types.AccountPool // All account pools (shared + scenario-specific) } // CreateScenarios creates scenario instances based on the configuration @@ -59,12 +58,11 @@ type configBasedGenerator struct { func (g *configBasedGenerator) createScenarios() error { // Create shared account pool if top-level account config exists if g.config.Accounts != nil { - g.sharedAccounts = types.NewAccountPool(&types.AccountConfig{ + g.sharedAccounts = g.registry.NewPool(&types.AccountConfig{ InitialSize: g.config.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 { @@ -77,12 +75,11 @@ func (g *configBasedGenerator) createScenarios() error { var accountPool *types.AccountPool if accounts := scenarioCfg.Accounts; accounts != nil { // Scenario defines its own account settings - create separate pool - accountPool = types.NewAccountPool(&types.AccountConfig{ + accountPool = g.registry.NewPool(&types.AccountConfig{ InitialSize: accounts.Accounts, NewAccountRate: accounts.NewAccountRate, Stream: g.rng.Stream(rng.AccountsScenarioStream(i)), }) - g.accountPools = append(g.accountPools, accountPool) } else if g.sharedAccounts != nil { // Use shared account pool from top-level config accountPool = g.sharedAccounts @@ -227,14 +224,6 @@ func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) { return NewWeightedGenerator(g.rng.Stream(rng.StreamWeightedShuffle), weightedConfigs...), nil } -// GetAccountPools returns all account pools managed by this generator -func (g *configBasedGenerator) GetAccountPools() []*types.AccountPool { - // Return a copy of the slice to prevent external modification - pools := make([]*types.AccountPool, len(g.accountPools)) - copy(pools, g.accountPools) - 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. @@ -248,11 +237,12 @@ func resolveSeed(cfg *config.LoadConfig) *rng.Source { return src } -// NewConfigBasedGenerator is a convenience method that combines all steps -func NewConfigBasedGenerator(cfg *config.LoadConfig) (Generator, error) { +// NewConfigBasedGenerator is a convenience method that combines all steps. +func NewConfigBasedGenerator(cfg *config.LoadConfig, registry *types.AccountRegistry) (Generator, error) { generator := &configBasedGenerator{ config: cfg, rng: resolveSeed(cfg), + registry: registry, instances: make([]*scenarioInstance, 0), deployer: types.GenerateAccounts(1)[0], } diff --git a/generator/generator_test.go b/generator/generator_test.go index f2df06f..ace6b91 100644 --- a/generator/generator_test.go +++ b/generator/generator_test.go @@ -8,6 +8,7 @@ import ( "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator" "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" ) func TestScenarioWeightsAndAccountDistribution(t *testing.T) { @@ -35,7 +36,7 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { }, } - gen, err := generator.NewConfigBasedGenerator(cfg) + gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry()) require.NoError(t, err) require.NotNil(t, gen) diff --git a/generator/prewarm.go b/generator/prewarm.go index c7f2d58..fc0287a 100644 --- a/generator/prewarm.go +++ b/generator/prewarm.go @@ -1,8 +1,6 @@ package generator import ( - "slices" - "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" @@ -10,17 +8,14 @@ import ( // PrewarmGenerator generates self-transfer transactions to prewarm account nonces type PrewarmGenerator struct { - accountPools []*types.AccountPool + registry *types.AccountRegistry evmScenario scenarios.TxGenerator - currentPoolIdx int + currentAccount int finished bool } -// NewPrewarmGenerator creates a new prewarm generator using all account pools from the main generator -func NewPrewarmGenerator(cfg *config.LoadConfig, mainGenerator Generator) *PrewarmGenerator { - // Get all account pools from the main generator - accountPools := mainGenerator.GetAccountPools() - +// NewPrewarmGenerator creates a new prewarm generator using all account pools from the registry. +func NewPrewarmGenerator(cfg *config.LoadConfig, registry *types.AccountRegistry) *PrewarmGenerator { // Create EVMTransfer scenario for prewarming evmScenario := scenarios.NewEVMTransferScenario(config.Scenario{}) @@ -30,45 +25,28 @@ func NewPrewarmGenerator(cfg *config.LoadConfig, mainGenerator Generator) *Prewa evmScenario.Deploy(cfg, deployer) return &PrewarmGenerator{ - accountPools: accountPools, + registry: registry, evmScenario: evmScenario, - currentPoolIdx: 0, + currentAccount: 0, finished: false, } } -// Generate generates self-transfer transactions until all accounts are prewarmed +// Generate generates self-transfer transactions until all known accounts are prewarmed. func (pg *PrewarmGenerator) Generate() (*types.LoadTx, bool) { + accounts := pg.registry.Accounts() + // Check if we're already finished - if pg.finished || pg.currentPoolIdx >= len(pg.accountPools) { + if pg.finished || pg.currentAccount >= len(accounts) { return nil, false } - // Get current pool - currentPool := pg.accountPools[pg.currentPoolIdx] - account := currentPool.NextAccount() - - // If this account has nonce > 0, we've already prewarmed it (round-robin means we're done with this pool) + account := accounts[pg.currentAccount] if account.Nonce > 0 { - // Move to next pool - pg.currentPoolIdx++ - - // Check if we've finished all pools - if pg.currentPoolIdx >= len(pg.accountPools) { - pg.finished = true - return nil, false - } - - // Get account from next pool - currentPool = pg.accountPools[pg.currentPoolIdx] - account = currentPool.NextAccount() - - // If this account also has nonce > 0, we're completely done - if account.Nonce > 0 { - pg.finished = true - return nil, false - } + pg.currentAccount++ + return pg.Generate() } + pg.currentAccount++ // Create self-transfer transaction scenario := &types.TxScenario{ @@ -80,9 +58,3 @@ func (pg *PrewarmGenerator) Generate() (*types.LoadTx, bool) { // Generate the transaction using EVMTransfer scenario return pg.evmScenario.Generate(scenario), true } - -// GetAccountPools returns all account pools used by this prewarm generator -func (pg *PrewarmGenerator) GetAccountPools() []*types.AccountPool { - // Return a copy to prevent external modification - return slices.Clone(pg.accountPools) -} diff --git a/generator/scenario.go b/generator/scenario.go index 1f0481d..072fab3 100644 --- a/generator/scenario.go +++ b/generator/scenario.go @@ -26,7 +26,3 @@ func (g *scenarioGenerator) Generate() (*types.LoadTx, bool) { Receiver: receiver.Address, }), true } - -func (sg *scenarioGenerator) GetAccountPools() []*types.AccountPool { - return []*types.AccountPool{sg.accountPool} -} diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index c2422b0..819f543 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -24,7 +24,7 @@ type DisperseScenario struct { func NewDisperseScenario(cfg config.Scenario) TxGenerator { scenario := &DisperseScenario{} scenario.ContractScenarioBase = NewContractScenarioBase[bindings.Disperse](scenario, cfg) - scenario.pool = types.NewAccountPool(&types.AccountConfig{ + scenario.pool = types.NewAccountRegistry().NewPool(&types.AccountConfig{ InitialSize: 0, NewAccountRate: 1.0, }) diff --git a/generator/seed_test.go b/generator/seed_test.go index e6f621b..e1f01e9 100644 --- a/generator/seed_test.go +++ b/generator/seed_test.go @@ -67,7 +67,7 @@ func draw(tx *types.LoadTx) gasDraw { // seed-determined gas draw — the RNG-driven output we replay against. func gasSeq(t *testing.T, seed uint64, n int) []gasDraw { t.Helper() - gen, err := generator.NewConfigBasedGenerator(seededConfig(t, seed)) + gen, err := generator.NewConfigBasedGenerator(seededConfig(t, seed), types.NewAccountRegistry()) require.NoError(t, err) txs := generator.GenerateN(gen, n) require.Len(t, txs, n) diff --git a/generator/weighted.go b/generator/weighted.go index 9640d26..875fdd7 100644 --- a/generator/weighted.go +++ b/generator/weighted.go @@ -67,19 +67,6 @@ func (w *weightedGenerator) Generate() (*types.LoadTx, bool) { return w.nextGenerator().Generate() } -// GetAccountPools returns all account pools from underlying generators -func (w *weightedGenerator) GetAccountPools() []*types.AccountPool { - var allPools []*types.AccountPool - - // Collect pools from all underlying generators - for _, gen := range w.generators { - pools := gen.GetAccountPools() - allPools = append(allPools, pools...) - } - - return allPools -} - // NewWeightedGenerator creates a new scenarioGenerator that will randomly select // from the provided generators. A nil stream leaves the startup shuffle on the // unseeded global RNG. diff --git a/main.go b/main.go index db3eee4..a6cca30 100644 --- a/main.go +++ b/main.go @@ -214,7 +214,8 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { err = scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Create the generator from the config struct - gen, err := generator.NewConfigBasedGenerator(cfg) + registry := types.NewAccountRegistry() + gen, err := generator.NewConfigBasedGenerator(cfg, registry) if err != nil { return fmt.Errorf("failed to create generator: %w", err) } @@ -294,7 +295,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Fund the pool before prewarm/dispatch — both spend gas the accounts // don't have until funded. if cfg.Funding != nil && !cfg.Settings.DryRun { - if err := funder.FundAccounts(ctx, cfg, gen.GetAccountPools()); err != nil { + if err := funder.FundAccounts(ctx, cfg, registry.Accounts()); err != nil { return fmt.Errorf("failed to fund accounts: %w", err) } } @@ -340,7 +341,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Set up prewarming if enabled if cfg.Settings.Prewarm { log.Printf("🔥 Creating prewarm generator...") - prewarmGen := generator.NewPrewarmGenerator(cfg, gen) + prewarmGen := generator.NewPrewarmGenerator(cfg, registry) dispatcher.SetPrewarmGenerator(prewarmGen) log.Printf("✅ Prewarm generator ready") log.Printf("📝 Prewarm mode: Accounts will be prewarmed") diff --git a/sender/scheduler_realworker_test.go b/sender/scheduler_realworker_test.go index da6f1d8..85ad82d 100644 --- a/sender/scheduler_realworker_test.go +++ b/sender/scheduler_realworker_test.go @@ -205,8 +205,6 @@ func (g *signedTxGenerator) Generate() (*types.LoadTx, bool) { return types.CreateTxFromEthTx(signed, scenario), true } -func (g *signedTxGenerator) GetAccountPools() []*types.AccountPool { return nil } - func (g *signedTxGenerator) issuedCount() int { g.mu.Lock() defer g.mu.Unlock() diff --git a/sender/scheduler_test.go b/sender/scheduler_test.go index 80cb127..45b8217 100644 --- a/sender/scheduler_test.go +++ b/sender/scheduler_test.go @@ -39,10 +39,6 @@ func (g *fakeGenerator) Generate() (*types.LoadTx, bool) { return tx, true } -func (g *fakeGenerator) GetAccountPools() []*types.AccountPool { - return nil -} - func (g *fakeGenerator) issuedTxs() []*types.LoadTx { g.mu.Lock() defer g.mu.Unlock() @@ -90,8 +86,6 @@ func (g *seededGenerator) draw(tx *types.LoadTx) uint64 { return g.drawIndex[tx] } -func (g *seededGenerator) GetAccountPools() []*types.AccountPool { return nil } - // asyncFakeSender models the production ShardedSender's send semantics: Send // returns when the tx lands in a buffered channel (enqueue-and-return), NOT when // the network send completes. Background workers dequeue and, after an optional diff --git a/sender/sender_test.go b/sender/sender_test.go index aacdaa3..59df8b4 100644 --- a/sender/sender_test.go +++ b/sender/sender_test.go @@ -149,7 +149,7 @@ func TestShardDistribution(t *testing.T) { } // Create generator - gen, err := generator.NewConfigBasedGenerator(cfg) + gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry()) require.NoError(t, err) // Test shard calculation without creating actual sender diff --git a/sender/writer_test.go b/sender/writer_test.go index 97c088c..510bb4b 100644 --- a/sender/writer_test.go +++ b/sender/writer_test.go @@ -19,7 +19,7 @@ func TestTxsWriter_Flush(t *testing.T) { ChainID: 7777, } - sharedAccounts := types.NewAccountPool(&types.AccountConfig{ + sharedAccounts := types.NewAccountRegistry().NewPool(&types.AccountConfig{ InitialSize: 10, NewAccountRate: 0.0, }) diff --git a/types/account_pool.go b/types/account_pool.go index 8d7d950..c926886 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -7,6 +7,25 @@ import ( "github.com/sei-protocol/sei-load/utils/rng" ) +// AccountRegistry owns account pools created for a run. +type AccountRegistry struct { + pools []*AccountPool +} + +// NewAccountRegistry creates an empty account registry. +func NewAccountRegistry() *AccountRegistry { + return &AccountRegistry{} +} + +// Accounts returns a flat copy of all accounts across all pools. +func (r *AccountRegistry) Accounts() []*Account { + var accounts []*Account + for _, pool := range r.pools { + accounts = append(accounts, pool.GetAccounts()...) + } + return accounts +} + // AccountPool returns a next account for load generation. type AccountPool struct { Accounts []*Account @@ -54,10 +73,12 @@ func (a *AccountPool) GetAccounts() []*Account { return a.Accounts } -// NewAccountPool creates a new account generator from a config. -func NewAccountPool(cfg *AccountConfig) *AccountPool { - return &AccountPool{ +// NewPool creates a new account generator from a config, records it, and returns it. +func (r *AccountRegistry) NewPool(cfg *AccountConfig) *AccountPool { + pool := &AccountPool{ Accounts: GenerateAccounts(cfg.InitialSize), cfg: cfg, } + r.pools = append(r.pools, pool) + return pool } diff --git a/types/types_test.go b/types/types_test.go index fcf7610..79cad81 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -120,12 +120,13 @@ func TestGenerateAccounts(t *testing.T) { } func TestAccountPoolRoundRobin(t *testing.T) { + registry := NewAccountRegistry() config := &AccountConfig{ InitialSize: 3, NewAccountRate: 0.0, // No new accounts, pure round-robin } - pool := NewAccountPool(config) + pool := registry.NewPool(config) accounts := pool.GetAccounts() // The account pool starts from index 1 (due to nextIndex() incrementing first) @@ -145,12 +146,13 @@ func TestAccountPoolRoundRobin(t *testing.T) { } func TestAccountPoolNewAccountRate(t *testing.T) { + registry := NewAccountRegistry() config := &AccountConfig{ InitialSize: 2, NewAccountRate: 1.0, // Always generate new accounts } - pool := NewAccountPool(config) + pool := registry.NewPool(config) accounts := pool.GetAccounts() // With 100% new account rate, should never get original accounts @@ -168,13 +170,14 @@ func TestAccountPoolNewAccountRate(t *testing.T) { } func TestAccountPoolMixedRate(t *testing.T) { + registry := NewAccountRegistry() config := &AccountConfig{ InitialSize: 5, NewAccountRate: 0.5, // 50% new accounts Stream: rng.NewSource(1).Stream("accounts:test"), } - pool := NewAccountPool(config) + pool := registry.NewPool(config) accounts := pool.GetAccounts() originalAddresses := make(map[common.Address]bool) @@ -204,12 +207,13 @@ func TestAccountPoolMixedRate(t *testing.T) { } func TestAccountPoolConcurrency(t *testing.T) { + registry := NewAccountRegistry() config := &AccountConfig{ InitialSize: 5, NewAccountRate: 0.0, // Pure round-robin for predictable testing } - pool := NewAccountPool(config) + pool := registry.NewPool(config) accounts := pool.GetAccounts() const numGoroutines = 50 @@ -437,11 +441,12 @@ func BenchmarkAccountGeneration(b *testing.B) { } func BenchmarkAccountPoolNextAccount(b *testing.B) { + registry := NewAccountRegistry() config := &AccountConfig{ InitialSize: 100, NewAccountRate: 0.0, } - pool := NewAccountPool(config) + pool := registry.NewPool(config) b.ResetTimer() for i := 0; i < b.N; i++ { diff --git a/utils/rng/rng.go b/utils/rng/rng.go index 24ea692..f03fde7 100644 --- a/utils/rng/rng.go +++ b/utils/rng/rng.go @@ -96,9 +96,7 @@ func NewSource(seed uint64) *Source { // replayed after the fact by re-running with the returned seed. func NewRandomSource() (*Source, uint64) { var b [8]byte - if _, err := rand.Read(b[:]); err != nil { - panic("rng: crypto/rand failed: " + err.Error()) - } + rand.Read(b[:]) // documented to never return an error seed := binary.LittleEndian.Uint64(b[:]) return NewSource(seed), seed } From be075a1af74eda4b0e7322e99c616111b48a6037 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 15:29:51 +0200 Subject: [PATCH 07/21] wip --- funder/funder.go | 5 +---- main.go | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/funder/funder.go b/funder/funder.go index daac402..bbc2c08 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -88,10 +88,7 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, accounts []*types // batch keeps nonces ordered. Do not parallelize or set auth.Nonce. batch := fc.Batch() for start := 0; start < len(underfunded); start += batch { - end := start + batch - if end > len(underfunded) { - end = len(underfunded) - } + end := min(start + batch, len(underfunded)) chunk := underfunded[start:end] values := make([]*big.Int, len(chunk)) total := new(big.Int) diff --git a/main.go b/main.go index a6cca30..2a49c94 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( "github.com/sei-protocol/sei-load/observability" "github.com/sei-protocol/sei-load/sender" "github.com/sei-protocol/sei-load/stats" + "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/utils" "github.com/sei-protocol/sei-load/utils/scope" ) From da0e16eaa294a608d6c227acb4a78cc8ac2f2469 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 16:08:59 +0200 Subject: [PATCH 08/21] wip --- config/distribution.go | 48 ++++--------------- config/distribution_test.go | 51 +++++++------------- config/doc.go | 4 +- config/gas.go | 33 +++---------- config/gas_test.go | 41 ++++++---------- generator/generator.go | 66 +++++--------------------- generator/generator_test.go | 6 ++- generator/prewarm.go | 8 ++-- generator/scenario.go | 10 ++-- generator/scenarios/Disperse.go | 6 ++- generator/scenarios/ERC20.go | 4 +- generator/scenarios/ERC20Conflict.go | 4 +- generator/scenarios/ERC20Noop.go | 4 +- generator/scenarios/ERC721.go | 3 +- generator/scenarios/EVMTransfer.go | 9 ++-- generator/scenarios/EVMTransferFast.go | 9 ++-- generator/scenarios/EVMTransferNoop.go | 9 ++-- generator/scenarios/StorageRW.go | 3 +- generator/scenarios/StorageRW_test.go | 3 +- generator/scenarios/base.go | 15 +++--- generator/seed_test.go | 7 ++- generator/weighted.go | 22 ++++----- main.go | 10 ++-- sender/dispatcher.go | 23 ++++----- sender/scheduler.go | 5 +- sender/scheduler_realworker_test.go | 10 ++-- sender/scheduler_test.go | 26 +++++----- sender/sender_test.go | 7 ++- sender/writer_test.go | 6 ++- types/account_pool.go | 20 ++------ types/types_test.go | 14 +++--- utils/rng/rng.go | 6 +++ utils/rng/streams.go | 4 ++ 33 files changed, 209 insertions(+), 287 deletions(-) diff --git a/config/distribution.go b/config/distribution.go index 681ab43..5f3d250 100644 --- a/config/distribution.go +++ b/config/distribution.go @@ -4,10 +4,8 @@ import ( "encoding/json" "fmt" "math" - "math/rand/v2" + mrand "math/rand/v2" "sync" - - "github.com/sei-protocol/sei-load/utils/rng" ) var ( @@ -18,7 +16,7 @@ var ( // indexSampler draws an index in [0, n) from some keyspace distribution. type indexSampler interface { - SampleIndex(n uint64) (uint64, error) + SampleIndex(rng *mrand.Rand, n uint64) (uint64, error) } // Distribution is a tagged keyspace index sampler selected by a "Name" @@ -31,25 +29,13 @@ type Distribution struct { func (d *Distribution) Name() string { return d.name } -// SetStream binds the sampler to a deterministic sub-stream (nil = unseeded -// global RNG); a zero-value Distribution draws nothing, so it no-ops. See -// package doc for the reproducibility contract. -func (d *Distribution) SetStream(s *rng.Stream) { - switch delegate := d.delegate.(type) { - case *UniformDistribution: - delegate.stream = s - case *ZipfianDistribution: - delegate.stream = s - } -} - // SampleIndex delegates to the selected sampler; a zero-value (no Name) // Distribution returns 0. -func (d *Distribution) SampleIndex(n uint64) (uint64, error) { +func (d *Distribution) SampleIndex(rng *mrand.Rand, n uint64) (uint64, error) { if d.delegate == nil { return 0, nil } - return d.delegate.SampleIndex(n) + return d.delegate.SampleIndex(rng, n) } func (d *Distribution) UnmarshalJSON(data []byte) error { @@ -64,7 +50,7 @@ func (d *Distribution) UnmarshalJSON(data []byte) error { case "": return nil case "uniform": - // No JSON parameters; the stream is bound later via SetStream. + // No JSON parameters; the PRNG is supplied at draw time. d.delegate = &UniformDistribution{} return nil case "zipfian": @@ -83,20 +69,13 @@ func (d *Distribution) UnmarshalJSON(data []byte) error { } // UniformDistribution draws each index with equal probability. -// -// copy-safe: holds no mutex; the *rng.Stream pointer aliases on copy. -type UniformDistribution struct { - stream *rng.Stream -} +type UniformDistribution struct{} -func (u *UniformDistribution) SampleIndex(n uint64) (uint64, error) { +func (u *UniformDistribution) SampleIndex(rng *mrand.Rand, n uint64) (uint64, error) { if n == 0 { return 0, fmt.Errorf("uniform sample: empty keyspace (n == 0)") } - if u.stream != nil { - return u.stream.Uint64N(n), nil - } - return rand.Uint64N(n), nil + return rng.Uint64N(n), nil } // ZipfianDistribution is the YCSB precomputed-zeta generator: zeta(n, theta) is @@ -107,8 +86,6 @@ func (u *UniformDistribution) SampleIndex(n uint64) (uint64, error) { type ZipfianDistribution struct { Theta float64 `json:"theta"` - stream *rng.Stream - mu sync.Mutex state *zipfState // memoized for state.n; recomputed when n changes. } @@ -169,7 +146,7 @@ func (z *ZipfianDistribution) validate() error { // SampleIndex draws a Zipf-skewed index in [0, n). n must be stable per sampler: // the zeta cache is keyed on n, so a changing n recomputes O(n) every draw. See // package doc. -func (z *ZipfianDistribution) SampleIndex(n uint64) (uint64, error) { +func (z *ZipfianDistribution) SampleIndex(rng *mrand.Rand, n uint64) (uint64, error) { if n == 0 { return 0, fmt.Errorf("zipfian sample: empty keyspace (n == 0)") } @@ -181,12 +158,7 @@ func (z *ZipfianDistribution) SampleIndex(n uint64) (uint64, error) { st := z.state z.mu.Unlock() - var u float64 - if z.stream != nil { - u = z.stream.Float64() - } else { - u = rand.Float64() - } + u := rng.Float64() uz := u * st.zetaN if uz < 1.0 { return 0, nil diff --git a/config/distribution_test.go b/config/distribution_test.go index 7dbdf28..031c57b 100644 --- a/config/distribution_test.go +++ b/config/distribution_test.go @@ -3,6 +3,7 @@ package config_test import ( "encoding/json" "fmt" + mrand "math/rand/v2" "os" "path/filepath" "testing" @@ -19,7 +20,7 @@ func TestDistribution(t *testing.T) { var subject config.Distribution require.NoError(t, subject.UnmarshalJSON([]byte(`{}`))) require.Empty(t, subject.Name()) - idx, err := subject.SampleIndex(100) + idx, err := subject.SampleIndex(rng.NewSource(1).Rand("config:distribution:test"), 100) require.NoError(t, err) require.Zero(t, idx) }) @@ -55,13 +56,12 @@ func distribution(t *testing.T, raw string) *config.Distribution { return &d } -// sample binds d to stream and pulls count draws over keyspace n. -func sample(t *testing.T, d *config.Distribution, s *rng.Stream, n uint64, count int) []uint64 { +// sample binds d to a PRNG and pulls count draws over keyspace n. +func sample(t *testing.T, d *config.Distribution, rng *mrand.Rand, n uint64, count int) []uint64 { t.Helper() - d.SetStream(s) out := make([]uint64, count) for i := range out { - v, err := d.SampleIndex(n) + v, err := d.SampleIndex(rng, n) require.NoError(t, err) require.Less(t, v, n, "draw out of range [0, n)") out[i] = v @@ -74,7 +74,7 @@ func sample(t *testing.T, d *config.Distribution, s *rng.Stream, n uint64, count func TestSampleIndexEmptyKeyspace(t *testing.T) { t.Parallel() for _, raw := range []string{`{"Name":"uniform"}`, `{"Name":"zipfian","theta":0.9}`} { - _, err := distribution(t, raw).SampleIndex(0) + _, err := distribution(t, raw).SampleIndex(rng.NewSource(1).Rand("config:distribution:test"), 0) require.Error(t, err, raw) } } @@ -85,27 +85,12 @@ func TestSampleIndexDeterminism(t *testing.T) { t.Parallel() const seed, n, count = 99, 1000, 256 for _, raw := range []string{`{"Name":"uniform"}`, `{"Name":"zipfian","theta":0.8}`} { - a := sample(t, distribution(t, raw), rng.NewSource(seed).Stream(rng.KeyDistributionStream(0)), n, count) - b := sample(t, distribution(t, raw), rng.NewSource(seed).Stream(rng.KeyDistributionStream(0)), n, count) + a := sample(t, distribution(t, raw), rng.NewSource(seed).Rand(rng.KeyDistributionStream(0)), n, count) + b := sample(t, distribution(t, raw), rng.NewSource(seed).Rand(rng.KeyDistributionStream(0)), n, count) require.Equal(t, a, b, "same seed must reproduce the draw sequence: %s", raw) } } -// TestSampleIndexSeededDiffersFromUnseeded guards the binding the way -// TestRandomGasPickerStreamSeeds does for gas: a bound sampler draws -// seed-determined values that differ from the unseeded global RNG path. If a -// refactor silently broke the binding, the seeded and unseeded sequences would -// match by accident only with probability ~0. -func TestSampleIndexSeededDiffersFromUnseeded(t *testing.T) { - t.Parallel() - const seed, n, count = 7, 1000, 128 - for _, raw := range []string{`{"Name":"uniform"}`, `{"Name":"zipfian","theta":0.8}`} { - seeded := sample(t, distribution(t, raw), rng.NewSource(seed).Stream(rng.KeyDistributionStream(0)), n, count) - unseeded := sample(t, distribution(t, raw), nil, n, count) - require.NotEqual(t, seeded, unseeded, "seeded draws must differ from the unseeded global RNG: %s", raw) - } -} - // TestUniformIsUniform: a chi-square goodness-of-fit test over evenly-sized // buckets. With B buckets and N draws the statistic should sit well under the // upper critical value; a badly skewed "uniform" would blow far past it. @@ -114,7 +99,7 @@ func TestUniformIsUniform(t *testing.T) { const n, buckets, perBucket = 1000, 20, 5000 const draws = buckets * perBucket // 100k draws, expected 5k per bucket. - got := sample(t, distribution(t, `{"Name":"uniform"}`), rng.NewSource(1).Stream("x"), n, draws) + got := sample(t, distribution(t, `{"Name":"uniform"}`), rng.NewSource(1).Rand("x"), n, draws) counts := make([]float64, buckets) width := uint64(n / buckets) for _, v := range got { @@ -140,7 +125,7 @@ func TestZipfianSkewRisesWithTheta(t *testing.T) { topKMass := func(theta float64) float64 { raw := fmt.Sprintf(`{"Name":"zipfian","theta":%v}`, theta) - got := sample(t, distribution(t, raw), rng.NewSource(5).Stream("x"), n, draws) + got := sample(t, distribution(t, raw), rng.NewSource(5).Rand("x"), n, draws) var hot int for _, v := range got { if v < topK { @@ -176,17 +161,17 @@ func TestZipfianInitCostBounded(t *testing.T) { t.Parallel() const n = 1_000_000 d := distribution(t, `{"Name":"zipfian","theta":0.99}`) - d.SetStream(rng.NewSource(1).Stream("x")) + rand := rng.NewSource(1).Rand("x") // Warmup outside the timer: pay the one-time O(n) zeta precompute here so the // timed window measures only steady-state per-draw cost. - warm, err := d.SampleIndex(n) + warm, err := d.SampleIndex(rand, n) require.NoError(t, err) require.Less(t, warm, uint64(n)) start := time.Now() for i := 0; i < 1000; i++ { - v, err := d.SampleIndex(n) + v, err := d.SampleIndex(rand, n) require.NoError(t, err) require.Less(t, v, uint64(n)) } @@ -202,18 +187,18 @@ func TestZipfianInitCostBounded(t *testing.T) { func TestZipfianRecomputesOnNChange(t *testing.T) { t.Parallel() d := distribution(t, `{"Name":"zipfian","theta":0.9}`) - d.SetStream(rng.NewSource(1).Stream("x")) + rand := rng.NewSource(1).Rand("x") // Same seed + same draw index against two different keyspaces: if the cache // ignored the n change, the second n would reuse the first's zetaN/eta and the // draw could fall outside [0, n2). The in-range check is the recompute witness. const n1, n2 = 1_000_000, 10 - v1, err := d.SampleIndex(n1) + v1, err := d.SampleIndex(rand, n1) require.NoError(t, err) require.Less(t, v1, uint64(n1)) for i := 0; i < 1000; i++ { - v2, err := d.SampleIndex(n2) + v2, err := d.SampleIndex(rand, n2) require.NoError(t, err) require.Less(t, v2, uint64(n2), "draw must be in [0, n2) after n change; stale cache would overshoot") } @@ -228,9 +213,9 @@ func TestZipfianNoNaNAcrossThetaRange(t *testing.T) { for _, n := range []uint64{2, 3, 100, 1000} { raw := fmt.Sprintf(`{"Name":"zipfian","theta":%v}`, theta) d := distribution(t, raw) - d.SetStream(rng.NewSource(1).Stream("x")) + rand := rng.NewSource(1).Rand("x") for i := 0; i < 100; i++ { - v, err := d.SampleIndex(n) + v, err := d.SampleIndex(rand, n) require.NoError(t, err) // v is a uint64 index; the in-range check is the real guard that // the internal zeta/eta math never produced a bad (NaN-derived) draw. diff --git a/config/doc.go b/config/doc.go index 1a8e7dd..842b727 100644 --- a/config/doc.go +++ b/config/doc.go @@ -81,8 +81,8 @@ // // # Seeded-stream reproducibility (FROZEN inputs) // -// Draws go through a bound *rng.Stream (see SetStream): a per-scenario -// substream derived from the run seed. This is what gives the workload its +// Draws go through an explicitly supplied *rand.Rand: a per-scenario +// substream-derived PRNG from the run seed. This is what gives the workload its // reproducibility contract — same seed + same config yields the same per-stream // draw multiset (see package utils/rng for the precise contract and its limits // above one worker). diff --git a/config/gas.go b/config/gas.go index a9410ad..506e204 100644 --- a/config/gas.go +++ b/config/gas.go @@ -3,9 +3,7 @@ package config import ( "encoding/json" "fmt" - "math/rand/v2" - - "github.com/sei-protocol/sei-load/utils/rng" + mrand "math/rand/v2" ) var ( @@ -15,7 +13,7 @@ var ( ) type gasGenerator interface { - GenerateGas() (uint64, error) + GenerateGas(rng *mrand.Rand) (uint64, error) } type GasPicker struct { @@ -25,23 +23,11 @@ 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) { +func (g *GasPicker) GenerateGas(rng *mrand.Rand) (uint64, error) { if g.delegate == nil { return 0, nil } - return g.delegate.GenerateGas() + return g.delegate.GenerateGas(rng) } func (g *GasPicker) UnmarshalJSON(data []byte) error { @@ -78,24 +64,19 @@ type FixedGasGenerator struct { Gas uint64 `json:"Gas"` } -func (f *FixedGasGenerator) GenerateGas() (uint64, error) { +func (f *FixedGasGenerator) GenerateGas(rng *mrand.Rand) (uint64, error) { return f.Gas, nil } type RandomGasGenerator struct { Min uint64 `json:"Min"` Max uint64 `json:"Max"` - - stream *rng.Stream } -func (r *RandomGasGenerator) GenerateGas() (uint64, error) { +func (r *RandomGasGenerator) GenerateGas(rng *mrand.Rand) (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) } 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 + return r.Min + rng.Uint64N(span), nil } diff --git a/config/gas_test.go b/config/gas_test.go index 1ad98d8..4ee72de 100644 --- a/config/gas_test.go +++ b/config/gas_test.go @@ -2,6 +2,7 @@ package config_test import ( "fmt" + mrand "math/rand/v2" "testing" "github.com/sei-protocol/sei-load/config" @@ -14,21 +15,21 @@ func TestGasPicker(t *testing.T) { t.Run("empty", func(t *testing.T) { var subject config.GasPicker require.NoError(t, subject.UnmarshalJSON([]byte(`{}`))) - gas, err := subject.GenerateGas() + gas, err := subject.GenerateGas(rng.NewSource(1).Rand("config:gas:test")) require.NoError(t, err) require.Zero(t, gas) }) t.Run("fixed", func(t *testing.T) { var subject config.GasPicker require.NoError(t, subject.UnmarshalJSON([]byte(`{"Name":"fixed","Gas":21000}`))) - gas, err := subject.GenerateGas() + gas, err := subject.GenerateGas(rng.NewSource(1).Rand("config:gas:test")) require.NoError(t, err) require.Equal(t, uint64(21000), gas) }) t.Run("random", func(t *testing.T) { var subject config.GasPicker require.NoError(t, subject.UnmarshalJSON([]byte(`{"Name":"random","Min":20000,"Max":30000}`))) - gas, err := subject.GenerateGas() + gas, err := subject.GenerateGas(rng.NewSource(1).Rand("config:gas:test")) require.NoError(t, err) require.GreaterOrEqual(t, gas, uint64(20000)) require.LessOrEqual(t, gas, uint64(30000)) @@ -47,51 +48,39 @@ func randomPicker(t *testing.T, min, max uint64) *config.GasPicker { 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 { +func drawN(t *testing.T, gp *config.GasPicker, rng *mrand.Rand, n int) []uint64 { t.Helper() - gp.SetStream(s) out := make([]uint64, n) for i := range out { - v, err := gp.GenerateGas() + v, err := gp.GenerateGas(rng) 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. +// TestRandomGasPickerStreamSeeds guards the seeded-draw contract: a supplied +// PRNG yields deterministic values. 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) + seededA := drawN(t, randomPicker(t, 20000, 30000), rng.NewSource(seed).Rand("gas:0:base"), n) + seededB := drawN(t, randomPicker(t, 20000, 30000), rng.NewSource(seed).Rand("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") - +// TestGenerateGasForFixedAndEmpty confirms fixed/empty pickers still work while +// requiring an explicit rng argument. +func TestGenerateGasForFixedAndEmpty(t *testing.T) { var fixed config.GasPicker require.NoError(t, fixed.UnmarshalJSON([]byte(`{"Name":"fixed","Gas":21000}`))) - fixed.SetStream(stream) - gas, err := fixed.GenerateGas() + gas, err := fixed.GenerateGas(rng.NewSource(1).Rand("config:gas:test")) 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() + gas, err = empty.GenerateGas(rng.NewSource(1).Rand("config:gas:test")) require.NoError(t, err) require.Zero(t, gas) } diff --git a/generator/generator.go b/generator/generator.go index 02a7dbc..eb791c5 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log" + mrand "math/rand/v2" "github.com/ethereum/go-ethereum/common" @@ -18,14 +19,14 @@ import ( // Generators are not thread-safe. Callers must serialize all access to a given // Generator instance. type Generator interface { - Generate() (*types.LoadTx, bool) // Returns transaction and true if more available, nil/false when done + Generate(rng *mrand.Rand) (*types.LoadTx, bool) // Returns transaction and true if more available, nil/false when done } // GenerateN drains up to n transactions from g by repeated Generate calls. -func GenerateN(g Generator, n int) []*types.LoadTx { +func GenerateN(rng *mrand.Rand, g Generator, n int) []*types.LoadTx { txs := make([]*types.LoadTx, 0, n) for range n { - if tx, ok := g.Generate(); ok { + if tx, ok := g.Generate(rng); ok { txs = append(txs, tx) } else { break @@ -46,30 +47,25 @@ type scenarioInstance struct { // configBasedGenerator manages scenario creation and deployment from config type configBasedGenerator struct { config *config.LoadConfig - rng *rng.Source registry *types.AccountRegistry instances []*scenarioInstance deployer *types.Account - sharedAccounts *types.AccountPool // Shared account pool when using top-level config + sharedAccounts *types.AccountPool // Shared account pool when using top-level config } // CreateScenarios creates scenario instances based on the configuration // Each scenario entry in config creates a separate instance, even if same name func (g *configBasedGenerator) createScenarios() error { - // Create shared account pool if top-level account config exists if g.config.Accounts != nil { g.sharedAccounts = g.registry.NewPool(&types.AccountConfig{ InitialSize: g.config.Accounts.Accounts, NewAccountRate: g.config.Accounts.NewAccountRate, - Stream: g.rng.Stream(rng.StreamAccountsShared), }) } for i, scenarioCfg := range g.config.Scenarios { // Create scenario instance using factory scenario := scenarios.CreateScenario(scenarioCfg) - g.bindGasStreams(i, scenarioCfg) - g.bindDistributionStreams(i, scenarioCfg) // Determine account pool to use var accountPool *types.AccountPool @@ -78,7 +74,6 @@ func (g *configBasedGenerator) createScenarios() error { accountPool = g.registry.NewPool(&types.AccountConfig{ InitialSize: accounts.Accounts, NewAccountRate: accounts.NewAccountRate, - Stream: g.rng.Stream(rng.AccountsScenarioStream(i)), }) } else if g.sharedAccounts != nil { // Use shared account pool from top-level config @@ -119,43 +114,6 @@ 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))) - } -} - -// bindDistributionStreams binds each configured keyspace distribution for a -// scenario to its own deterministic sub-stream, keyed by the scenario's config -// index. The pointer-aliasing reasoning in bindGasStreams applies verbatim: cfg -// is a value copy but its *Distribution fields are pointers shared with the -// scenario's copy, so SetStream reaches the live sampler. -func (g *configBasedGenerator) bindDistributionStreams(i int, cfg config.Scenario) { - if cfg.KeyDistribution != nil { - cfg.KeyDistribution.SetStream(g.rng.Stream(rng.KeyDistributionStream(i))) - } - if cfg.SizeDistribution != nil { - cfg.SizeDistribution.SetStream(g.rng.Stream(rng.SizeDistributionStream(i))) - } -} - // mockDeployAll deploys all scenario instances that require deployment (for unit tests). func (g *configBasedGenerator) mockDeployAll() error { for _, instance := range g.instances { @@ -190,7 +148,7 @@ func (g *configBasedGenerator) deployAll() error { } // createWeightedGenerator creates a weighted scenarioGenerator from deployed scenarios -func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) { +func (g *configBasedGenerator) createWeightedGenerator(rng *mrand.Rand) (Generator, error) { if len(g.instances) == 0 { return nil, fmt.Errorf("no scenario instances created") } @@ -221,13 +179,13 @@ func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) { } // Create and return the weighted scenarioGenerator - return NewWeightedGenerator(g.rng.Stream(rng.StreamWeightedShuffle), weightedConfigs...), nil + return NewWeightedGenerator(rng, weightedConfigs...), nil } // 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 { +func ResolveSeed(cfg *config.LoadConfig) *rng.Source { if cfg.Seed != nil { return rng.NewSource(*cfg.Seed) } @@ -238,10 +196,12 @@ func resolveSeed(cfg *config.LoadConfig) *rng.Source { } // NewConfigBasedGenerator is a convenience method that combines all steps. -func NewConfigBasedGenerator(cfg *config.LoadConfig, registry *types.AccountRegistry) (Generator, error) { +func NewConfigBasedGenerator(cfg *config.LoadConfig, registry *types.AccountRegistry, rng *mrand.Rand) (Generator, error) { + if rng == nil { + panic("NewConfigBasedGenerator: rng must not be nil") + } generator := &configBasedGenerator{ config: cfg, - rng: resolveSeed(cfg), registry: registry, instances: make([]*scenarioInstance, 0), deployer: types.GenerateAccounts(1)[0], @@ -258,7 +218,7 @@ func NewConfigBasedGenerator(cfg *config.LoadConfig, registry *types.AccountRegi } // Step 3: Create weighted scenarioGenerator - weightedGen, err := generator.createWeightedGenerator() + weightedGen, err := generator.createWeightedGenerator(rng) if err != nil { return nil, fmt.Errorf("failed to create weighted scenarioGenerator: %w", err) } diff --git a/generator/generator_test.go b/generator/generator_test.go index ace6b91..7eda587 100644 --- a/generator/generator_test.go +++ b/generator/generator_test.go @@ -9,6 +9,7 @@ import ( "github.com/sei-protocol/sei-load/generator" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" + testrng "github.com/sei-protocol/sei-load/utils/rng" ) func TestScenarioWeightsAndAccountDistribution(t *testing.T) { @@ -36,12 +37,13 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { }, } - gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry()) + rngSource := generator.ResolveSeed(cfg) + gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry(), rngSource.Rand(testrng.StreamWeightedShuffle)) require.NoError(t, err) require.NotNil(t, gen) totalTxs := 100 - txs := generator.GenerateN(gen, totalTxs) + txs := generator.GenerateN(rngSource.Rand("generator:test:draws"), gen, totalTxs) require.Len(t, txs, totalTxs) // Count occurrences per scenario diff --git a/generator/prewarm.go b/generator/prewarm.go index fc0287a..615cf08 100644 --- a/generator/prewarm.go +++ b/generator/prewarm.go @@ -1,6 +1,8 @@ package generator import ( + mrand "math/rand/v2" + "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" @@ -33,7 +35,7 @@ func NewPrewarmGenerator(cfg *config.LoadConfig, registry *types.AccountRegistry } // Generate generates self-transfer transactions until all known accounts are prewarmed. -func (pg *PrewarmGenerator) Generate() (*types.LoadTx, bool) { +func (pg *PrewarmGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { accounts := pg.registry.Accounts() // Check if we're already finished @@ -44,7 +46,7 @@ func (pg *PrewarmGenerator) Generate() (*types.LoadTx, bool) { account := accounts[pg.currentAccount] if account.Nonce > 0 { pg.currentAccount++ - return pg.Generate() + return pg.Generate(rng) } pg.currentAccount++ @@ -56,5 +58,5 @@ func (pg *PrewarmGenerator) Generate() (*types.LoadTx, bool) { } // Generate the transaction using EVMTransfer scenario - return pg.evmScenario.Generate(scenario), true + return pg.evmScenario.Generate(rng, scenario), true } diff --git a/generator/scenario.go b/generator/scenario.go index 072fab3..e435e58 100644 --- a/generator/scenario.go +++ b/generator/scenario.go @@ -1,6 +1,8 @@ package generator import ( + mrand "math/rand/v2" + "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" ) @@ -17,10 +19,10 @@ func NewScenarioGenerator(accounts *types.AccountPool, txg scenarios.TxGenerator } } -func (g *scenarioGenerator) Generate() (*types.LoadTx, bool) { - sender := g.accountPool.NextAccount() - receiver := g.accountPool.NextAccount() - return g.scenario.Generate(&types.TxScenario{ +func (g *scenarioGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { + sender := g.accountPool.NextAccount(rng) + receiver := g.accountPool.NextAccount(rng) + return g.scenario.Generate(rng, &types.TxScenario{ Name: g.scenario.Name(), Sender: sender, Receiver: receiver.Address, diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index 819f543..f57551e 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -1,6 +1,8 @@ package scenarios import ( + mrand "math/rand/v2" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -73,11 +75,11 @@ func (s *DisperseScenario) Attach(config *config.LoadConfig, address common.Addr } // CreateContractTransaction implements ContractDeployer interface - creates Disperse transaction -func (s *DisperseScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { +func (s *DisperseScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { // create new accounts so that it auto-creates the accounts. targets := make([]common.Address, 0, 100) for range 100 { - targets = append(targets, s.pool.NextAccount().Address) + targets = append(targets, s.pool.NextAccount(rng).Address) } return s.contract.DisperseEtherFixed(auth, targets) } diff --git a/generator/scenarios/ERC20.go b/generator/scenarios/ERC20.go index f7a15c9..44d2d28 100644 --- a/generator/scenarios/ERC20.go +++ b/generator/scenarios/ERC20.go @@ -1,6 +1,8 @@ package scenarios import ( + mrand "math/rand/v2" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -69,7 +71,7 @@ func (s *ERC20Scenario) Attach(config *config.LoadConfig, address common.Address } // CreateContractTransaction implements ContractDeployer interface - creates ERC20 transaction -func (s *ERC20Scenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { +func (s *ERC20Scenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { auth.GasLimit = 72156 return s.contract.Transfer(auth, scenario.Receiver, bigOne) } diff --git a/generator/scenarios/ERC20Conflict.go b/generator/scenarios/ERC20Conflict.go index 85214e7..f506b44 100644 --- a/generator/scenarios/ERC20Conflict.go +++ b/generator/scenarios/ERC20Conflict.go @@ -1,6 +1,8 @@ package scenarios import ( + mrand "math/rand/v2" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -69,7 +71,7 @@ func (s *ERC20ConflictScenario) Attach(config *config.LoadConfig, address common } // CreateContractTransaction implements ContractDeployer interface - creates ERC20Conflict transaction -func (s *ERC20ConflictScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { +func (s *ERC20ConflictScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { auth.GasLimit = 22460 return s.contract.Transfer(auth, scenario.Receiver, bigOne) } diff --git a/generator/scenarios/ERC20Noop.go b/generator/scenarios/ERC20Noop.go index 3dd4e8f..0413b21 100644 --- a/generator/scenarios/ERC20Noop.go +++ b/generator/scenarios/ERC20Noop.go @@ -1,6 +1,8 @@ package scenarios import ( + mrand "math/rand/v2" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -69,7 +71,7 @@ func (s *ERC20NoopScenario) Attach(config *config.LoadConfig, address common.Add } // CreateContractTransaction implements ContractDeployer interface - creates ERC20Noop transaction -func (s *ERC20NoopScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { +func (s *ERC20NoopScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { auth.GasLimit = 22460 return s.contract.Transfer(auth, scenario.Receiver, bigOne) } diff --git a/generator/scenarios/ERC721.go b/generator/scenarios/ERC721.go index 0c1ff35..ed49ab3 100644 --- a/generator/scenarios/ERC721.go +++ b/generator/scenarios/ERC721.go @@ -2,6 +2,7 @@ package scenarios import ( "math/big" + mrand "math/rand/v2" "sync/atomic" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -53,7 +54,7 @@ func (s *ERC721Scenario) SetContract(contract *bindings.ERC721) { } // CreateContractTransaction implements ContractDeployer interface - creates ERC721 transaction -func (s *ERC721Scenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { +func (s *ERC721Scenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { auth.GasLimit = 22460 return s.contract.Mint(auth, scenario.Receiver, big.NewInt(atomic.AddInt64(&s.id, 1))) } diff --git a/generator/scenarios/EVMTransfer.go b/generator/scenarios/EVMTransfer.go index 9e30740..f9454b4 100644 --- a/generator/scenarios/EVMTransfer.go +++ b/generator/scenarios/EVMTransfer.go @@ -2,6 +2,7 @@ package scenarios import ( "math/big" + mrand "math/rand/v2" "time" "github.com/ethereum/go-ethereum/common" @@ -45,7 +46,7 @@ func (s *EVMTransferScenario) AttachScenario(config *config.LoadConfig, address } // CreateTransaction implements ScenarioDeployer interface - creates ETH transfer transaction -func (s *EVMTransferScenario) CreateTransaction(config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { +func (s *EVMTransferScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ Nonce: scenario.Sender.GetAndIncrementNonce(), @@ -59,20 +60,20 @@ func (s *EVMTransferScenario) CreateTransaction(config *config.LoadConfig, scena if s.scenarioConfig.GasPicker != nil { var err error - tx.Gas, err = s.scenarioConfig.GasPicker.GenerateGas() + tx.Gas, err = s.scenarioConfig.GasPicker.GenerateGas(rng) if err != nil { return nil, err } } if s.scenarioConfig.GasTipCapPicker != nil { - gasTipCap, err := s.scenarioConfig.GasTipCapPicker.GenerateGas() + gasTipCap, err := s.scenarioConfig.GasTipCapPicker.GenerateGas(rng) if err != nil { return nil, err } tx.GasTipCap = big.NewInt(int64(gasTipCap)) } if s.scenarioConfig.GasFeeCapPicker != nil { - gasFeeCap, err := s.scenarioConfig.GasFeeCapPicker.GenerateGas() + gasFeeCap, err := s.scenarioConfig.GasFeeCapPicker.GenerateGas(rng) if err != nil { return nil, err } diff --git a/generator/scenarios/EVMTransferFast.go b/generator/scenarios/EVMTransferFast.go index 12e42ba..812a9c5 100644 --- a/generator/scenarios/EVMTransferFast.go +++ b/generator/scenarios/EVMTransferFast.go @@ -2,6 +2,7 @@ package scenarios import ( "math/big" + mrand "math/rand/v2" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -45,7 +46,7 @@ func (s *EVMTransferFastScenario) AttachScenario(config *config.LoadConfig, addr } // CreateTransaction EVMTransferFastScenario ScenarioDeployer interface - creates ETH transfer transaction -func (s *EVMTransferFastScenario) CreateTransaction(config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { +func (s *EVMTransferFastScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ Nonce: scenario.Sender.GetAndIncrementNonce(), @@ -59,20 +60,20 @@ func (s *EVMTransferFastScenario) CreateTransaction(config *config.LoadConfig, s if s.scenarioConfig.GasPicker != nil { var err error - tx.Gas, err = s.scenarioConfig.GasPicker.GenerateGas() + tx.Gas, err = s.scenarioConfig.GasPicker.GenerateGas(rng) if err != nil { return nil, err } } if s.scenarioConfig.GasTipCapPicker != nil { - gasTipCap, err := s.scenarioConfig.GasTipCapPicker.GenerateGas() + gasTipCap, err := s.scenarioConfig.GasTipCapPicker.GenerateGas(rng) if err != nil { return nil, err } tx.GasTipCap = big.NewInt(int64(gasTipCap)) } if s.scenarioConfig.GasFeeCapPicker != nil { - gasFeeCap, err := s.scenarioConfig.GasFeeCapPicker.GenerateGas() + gasFeeCap, err := s.scenarioConfig.GasFeeCapPicker.GenerateGas(rng) if err != nil { return nil, err } diff --git a/generator/scenarios/EVMTransferNoop.go b/generator/scenarios/EVMTransferNoop.go index 83280c0..5781c57 100644 --- a/generator/scenarios/EVMTransferNoop.go +++ b/generator/scenarios/EVMTransferNoop.go @@ -2,6 +2,7 @@ package scenarios import ( "math/big" + mrand "math/rand/v2" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -44,7 +45,7 @@ func (s *EVMTransferNoopScenario) AttachScenario(config *config.LoadConfig, addr } // CreateTransaction implements ScenarioDeployer interface - creates ETH transfer transaction -func (s *EVMTransferNoopScenario) CreateTransaction(config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { +func (s *EVMTransferNoopScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ Nonce: scenario.Sender.GetAndIncrementNonce(), @@ -58,20 +59,20 @@ func (s *EVMTransferNoopScenario) CreateTransaction(config *config.LoadConfig, s if s.scenarioConfig.GasPicker != nil { var err error - tx.Gas, err = s.scenarioConfig.GasPicker.GenerateGas() + tx.Gas, err = s.scenarioConfig.GasPicker.GenerateGas(rng) if err != nil { return nil, err } } if s.scenarioConfig.GasTipCapPicker != nil { - gasTipCap, err := s.scenarioConfig.GasTipCapPicker.GenerateGas() + gasTipCap, err := s.scenarioConfig.GasTipCapPicker.GenerateGas(rng) if err != nil { return nil, err } tx.GasTipCap = big.NewInt(int64(gasTipCap)) } if s.scenarioConfig.GasFeeCapPicker != nil { - gasFeeCap, err := s.scenarioConfig.GasFeeCapPicker.GenerateGas() + gasFeeCap, err := s.scenarioConfig.GasFeeCapPicker.GenerateGas(rng) if err != nil { return nil, err } diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index e8d5a03..fdfa7df 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -2,6 +2,7 @@ package scenarios import ( "math/big" + mrand "math/rand/v2" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -80,7 +81,7 @@ func (s *StorageRWScenario) Attach(config *config.LoadConfig, address common.Add // CreateContractTransaction implements ContractDeployer interface - creates a // fixed StorageRWv1 rmw transaction. See package doc for the scaffold and gas // rationale. -func (s *StorageRWScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { +func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { // 50k fits rmw (SLOAD+SSTORE) with headroom; see package doc for sizing. // PLT-465 revisits with the distribution-driven pad. auth.GasLimit = 50000 diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index 5ddde6e..67b253d 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -9,6 +9,7 @@ import ( "github.com/sei-protocol/sei-load/generator/bindings" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" + testrng "github.com/sei-protocol/sei-load/utils/rng" ) // rmwSelector is the 4-byte function selector for StorageRWv1.rmw(uint256,bytes). @@ -46,7 +47,7 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { Sender: sender, } - loadTx := gen.Generate(txScenario) + loadTx := gen.Generate(testrng.NewSource(1).Rand("generator:storagerw:test"), txScenario) require.NotNil(t, loadTx) require.NotNil(t, loadTx.EthTx) diff --git a/generator/scenarios/base.go b/generator/scenarios/base.go index a285595..0d31edb 100644 --- a/generator/scenarios/base.go +++ b/generator/scenarios/base.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "math/big" + mrand "math/rand/v2" "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -23,7 +24,7 @@ var bigOne = big.NewInt(1) // TxGenerator defines the interface for generating transactions. type TxGenerator interface { Name() string - Generate(scenario *types.TxScenario) *types.LoadTx + Generate(rng *mrand.Rand, scenario *types.TxScenario) *types.LoadTx Attach(config *config.LoadConfig, address common.Address) error Deploy(config *config.LoadConfig, deployer *types.Account) common.Address } @@ -40,7 +41,7 @@ type ScenarioDeployer interface { AttachScenario(config *config.LoadConfig, address common.Address) common.Address // CreateTransaction creates a transaction for this scenario - CreateTransaction(config *config.LoadConfig, scenario *types.TxScenario) (*ethtypes.Transaction, error) + CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types.TxScenario) (*ethtypes.Transaction, error) } // ContractBindFunc defines a function that creates a contract instance from an address @@ -61,7 +62,7 @@ type ContractDeployer[T any] interface { SetContract(contract *T) // CreateContractTransaction creates a contract interaction transaction - CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) + CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) } // ScenarioBase provides common functionality for all scenarios @@ -99,13 +100,13 @@ func (s *ScenarioBase) Attach(config *config.LoadConfig, address common.Address) } // Generate handles the common transaction generation flow -func (s *ScenarioBase) Generate(scenario *types.TxScenario) *types.LoadTx { +func (s *ScenarioBase) Generate(rng *mrand.Rand, scenario *types.TxScenario) *types.LoadTx { if !s.deployed { panic("Scenario not deployed/initialized") } // Create transaction using scenario-specific logic - tx, err := s.deployer.CreateTransaction(s.config, scenario) + tx, err := s.deployer.CreateTransaction(rng, s.config, scenario) if err != nil { panic("Failed to create transaction: " + err.Error()) } @@ -214,7 +215,7 @@ func (c *ContractScenarioBase[T]) DeployScenario(config *config.LoadConfig, depl } // CreateTransaction implements ScenarioDeployer interface for contract scenarios -func (c *ContractScenarioBase[T]) CreateTransaction(config *config.LoadConfig, scenario *types.TxScenario) (*ethtypes.Transaction, error) { +func (c *ContractScenarioBase[T]) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types.TxScenario) (*ethtypes.Transaction, error) { auth := utils.CreateTransactionOpts(config.GetChainID(), scenario) - return c.deployer.CreateContractTransaction(auth, scenario) + return c.deployer.CreateContractTransaction(rng, auth, scenario) } diff --git a/generator/seed_test.go b/generator/seed_test.go index e1f01e9..2387efe 100644 --- a/generator/seed_test.go +++ b/generator/seed_test.go @@ -10,6 +10,7 @@ import ( "github.com/sei-protocol/sei-load/generator" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" + "github.com/sei-protocol/sei-load/utils/rng" ) func seededConfig(t *testing.T, seed uint64) *config.LoadConfig { @@ -67,9 +68,11 @@ func draw(tx *types.LoadTx) gasDraw { // seed-determined gas draw — the RNG-driven output we replay against. func gasSeq(t *testing.T, seed uint64, n int) []gasDraw { t.Helper() - gen, err := generator.NewConfigBasedGenerator(seededConfig(t, seed), types.NewAccountRegistry()) + cfg := seededConfig(t, seed) + rngSource := generator.ResolveSeed(cfg) + gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry(), rngSource.Rand(rng.StreamWeightedShuffle)) require.NoError(t, err) - txs := generator.GenerateN(gen, n) + txs := generator.GenerateN(rngSource.Rand("generator:seed:draws"), gen, n) require.Len(t, txs, n) out := make([]gasDraw, n) for i, tx := range txs { diff --git a/generator/weighted.go b/generator/weighted.go index 875fdd7..58c37c9 100644 --- a/generator/weighted.go +++ b/generator/weighted.go @@ -2,10 +2,9 @@ package generator import ( "context" - "math/rand/v2" + mrand "math/rand/v2" "github.com/sei-protocol/sei-load/types" - "github.com/sei-protocol/sei-load/utils/rng" ) // WeightedCfg is a configuration for a weighted scenarioGenerator. @@ -28,7 +27,7 @@ type weightedGenerator struct { } // GenerateInfinite generates transactions indefinitely. -func (w *weightedGenerator) GenerateInfinite(ctx context.Context) <-chan *types.LoadTx { +func (w *weightedGenerator) GenerateInfinite(ctx context.Context, rng *mrand.Rand) <-chan *types.LoadTx { output := make(chan *types.LoadTx, 10000) go func() { defer close(output) @@ -41,7 +40,7 @@ func (w *weightedGenerator) GenerateInfinite(ctx context.Context) <-chan *types. case <-ctx.Done(): return case output <- func() *types.LoadTx { - tx, _ := w.nextGenerator().Generate() + tx, _ := w.nextGenerator().Generate(rng) return tx }(): } @@ -63,14 +62,13 @@ func (w *weightedGenerator) nextGenerator() Generator { } // Generate generates 1 transaction. -func (w *weightedGenerator) Generate() (*types.LoadTx, bool) { - return w.nextGenerator().Generate() +func (w *weightedGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { + return w.nextGenerator().Generate(rng) } // NewWeightedGenerator creates a new scenarioGenerator that will randomly select -// from the provided generators. A nil stream leaves the startup shuffle on the -// unseeded global RNG. -func NewWeightedGenerator(stream *rng.Stream, cfgs ...*WeightedCfg) Generator { +// from the provided generators. +func NewWeightedGenerator(rng *mrand.Rand, cfgs ...*WeightedCfg) Generator { // no need for clever weighting logic if we just have 1 scenarioGenerator anyway. if len(cfgs) == 1 { return cfgs[0].Generator @@ -82,11 +80,7 @@ func NewWeightedGenerator(stream *rng.Stream, cfgs ...*WeightedCfg) Generator { } } - shuffle := rand.Shuffle - if stream != nil { - shuffle = stream.Shuffle - } - shuffle(len(weighted), func(i, j int) { + rng.Shuffle(len(weighted), func(i, j int) { weighted[i], weighted[j] = weighted[j], weighted[i] }) diff --git a/main.go b/main.go index 2a49c94..bcb77d5 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,7 @@ import ( "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/utils" + runrng "github.com/sei-protocol/sei-load/utils/rng" "github.com/sei-protocol/sei-load/utils/scope" ) @@ -209,6 +210,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Create statistics collector and logger collector := stats.NewCollector() logger := stats.NewLogger(collector, cfg.Settings.StatsInterval.ToDuration(), cfg.Settings.ReportPath, cfg.Settings.Debug) + rngSource := generator.ResolveSeed(cfg) var ramper *sender.Ramper var dispatcher *sender.Dispatcher var inclusionTracker *stats.InclusionTracker @@ -216,10 +218,12 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { err = scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Create the generator from the config struct registry := types.NewAccountRegistry() - gen, err := generator.NewConfigBasedGenerator(cfg, registry) + gen, err := generator.NewConfigBasedGenerator(cfg, registry, rngSource.Rand(runrng.StreamWeightedShuffle)) if err != nil { return fmt.Errorf("failed to create generator: %w", err) } + loadRNG := rngSource.Rand(runrng.StreamLoadGeneration) + prewarmRNG := rngSource.Rand(runrng.StreamPrewarmGeneration) // Create the shared rate authority for the whole run. sharedLimiter := rate.NewLimiter(rate.Inf, 1) @@ -355,7 +359,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { } // Perform prewarming if enabled (before starting logger to avoid logging prewarm transactions) if cfg.Settings.Prewarm { - if err := dispatcher.Prewarm(ctx); err != nil { + if err := dispatcher.Prewarm(ctx, prewarmRNG); err != nil { return fmt.Errorf("failed to prewarm accounts: %w", err) } } @@ -365,7 +369,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { log.Printf("✅ Started statistics logger") // Start dispatcher for main load test - s.SpawnBgNamed("dispatcher", func() error { return dispatcher.Run(ctx) }) + s.SpawnBgNamed("dispatcher", func() error { return dispatcher.Run(ctx, loadRNG) }) log.Printf("✅ Started dispatcher") // Set up signal handling for graceful shutdown diff --git a/sender/dispatcher.go b/sender/dispatcher.go index f21ae14..7f7e619 100644 --- a/sender/dispatcher.go +++ b/sender/dispatcher.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + mrand "math/rand/v2" "sync" "time" @@ -97,7 +98,7 @@ func (d *Dispatcher) SetPrewarmGenerator(prewarmGen generator.Generator) { } // Prewarm runs the prewarm generator to completion before starting the main load test -func (d *Dispatcher) Prewarm(ctx context.Context) error { +func (d *Dispatcher) Prewarm(ctx context.Context, rng *mrand.Rand) error { d.mu.RLock() prewarmGen := d.prewarmGen // Prewarm runs before the scheduler paces anything, so it must self-pace off @@ -120,7 +121,7 @@ func (d *Dispatcher) Prewarm(ctx context.Context) error { return err } - tx, ok := gen.Generate() + tx, ok := gen.Generate(rng) if !ok { break // Prewarming is complete } @@ -145,19 +146,19 @@ func (d *Dispatcher) Prewarm(ctx context.Context) error { // Run begins the dispatcher's transaction generation and sending loop, using // the configured arrival model. -func (d *Dispatcher) Run(ctx context.Context) error { +func (d *Dispatcher) Run(ctx context.Context, rng *mrand.Rand) error { if d.ArrivalModel() == ArrivalOpenLoop { - return d.runOpenLoop(ctx) + return d.runOpenLoop(ctx, rng) } - return d.runClosedLoop(ctx) + return d.runClosedLoop(ctx, rng) } // runClosedLoop is the legacy model: generate-then-send in lockstep, so a slow // SUT back-pressures the generator. Kept as the regression baseline. -func (d *Dispatcher) runClosedLoop(ctx context.Context) error { +func (d *Dispatcher) runClosedLoop(ctx context.Context, rng *mrand.Rand) error { for ctx.Err() == nil { // Generate a transaction from main generator - tx, ok := d.generator.Generate() + tx, ok := d.generator.Generate(rng) if !ok { log.Print("Dispatcher: Generator returned no more transactions") return nil @@ -181,14 +182,14 @@ func (d *Dispatcher) runClosedLoop(ctx context.Context) error { // runOpenLoop drives the open-loop scheduler (see scheduler.go), which owns the // arrival clock (t₀, sequence index i) and the in-flight bound. Send tasks are // spawned into a scope so they all complete on shutdown. -func (d *Dispatcher) runOpenLoop(ctx context.Context) error { +func (d *Dispatcher) runOpenLoop(ctx context.Context, rng *mrand.Rand) error { d.mu.RLock() limiter, maxInFlight := d.limiter, d.maxInFlight d.mu.RUnlock() sched := newOpenLoopScheduler(d.generator, d.sender, limiter, maxInFlight, d.onSent) err := service.Run(ctx, func(ctx context.Context, s service.Scope) error { - return sched.Run(ctx, s) + return sched.Run(ctx, rng, s) }) // Fold the scheduler's drop count into the summary accounting. d.mu.Lock() @@ -214,13 +215,13 @@ func (d *Dispatcher) onSent(tx *types.LoadTx, err error) { } // StartBatch generates and sends a specific number of transactions then stops -func (d *Dispatcher) RunBatch(ctx context.Context, count int) error { +func (d *Dispatcher) RunBatch(ctx context.Context, rng *mrand.Rand, count int) error { if count <= 0 { return fmt.Errorf("count must be positive") } for i := range count { // Generate a transaction - tx, ok := d.generator.Generate() + tx, ok := d.generator.Generate(rng) if !ok { return fmt.Errorf("dispatcher: generator returned nil transaction (batch %d/%d)", i+1, count) } diff --git a/sender/scheduler.go b/sender/scheduler.go index 0527c26..658266c 100644 --- a/sender/scheduler.go +++ b/sender/scheduler.go @@ -3,6 +3,7 @@ package sender import ( "context" "log" + mrand "math/rand/v2" "sync" "sync/atomic" "time" @@ -78,7 +79,7 @@ func (s *openLoopScheduler) Admitted() uint64 { return s.admitted.Load() } // Run drives the arrival clock until ctx is canceled or the generator is // exhausted, spawning each accepted tx as a send task bounded by the in-flight // semaphore. See the package doc for the arrival model. -func (s *openLoopScheduler) Run(ctx context.Context, scope service.Scope) error { +func (s *openLoopScheduler) Run(ctx context.Context, rng *mrand.Rand, scope service.Scope) error { t0 := time.Now() nextSend := t0 var i uint64 @@ -114,7 +115,7 @@ func (s *openLoopScheduler) Run(ctx context.Context, scope service.Scope) error continue } - tx, ok := s.generator.Generate() + tx, ok := s.generator.Generate(rng) if !ok { // Generator drained: not an arrival — release the permit and stop. s.inflight.Release(1) diff --git a/sender/scheduler_realworker_test.go b/sender/scheduler_realworker_test.go index 85ad82d..afd6935 100644 --- a/sender/scheduler_realworker_test.go +++ b/sender/scheduler_realworker_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "math/big" + mrand "math/rand/v2" "net/http" "net/http/httptest" "sync" @@ -18,6 +19,7 @@ import ( "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/types" + testrng "github.com/sei-protocol/sei-load/utils/rng" "github.com/sei-protocol/sei-load/utils/service" ) @@ -177,7 +179,7 @@ func newSignedTxGenerator(t *testing.T, n int) *signedTxGenerator { } } -func (g *signedTxGenerator) Generate() (*types.LoadTx, bool) { +func (g *signedTxGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { g.mu.Lock() defer g.mu.Unlock() if g.remaining == 0 { @@ -274,7 +276,7 @@ func TestRealSender_Conservation_OnRealSendPath(t *testing.T) { defer wg.Done() _ = service.Run(runCtx, func(ctx context.Context, scope service.Scope) error { scope.SpawnBg(func() error { return client.Run(ctx) }) - scope.SpawnBg(func() error { return sched.Run(ctx, scope) }) + scope.SpawnBg(func() error { return sched.Run(ctx, testrng.NewSource(1).Rand("sender:realworker:test"), scope) }) // Main task: hold the scope open until the test signals teardown. <-ctx.Done() return nil @@ -372,7 +374,7 @@ func TestRealSender_PermitReleasedBySender(t *testing.T) { defer wg.Done() _ = service.Run(ctx, func(ctx context.Context, scope service.Scope) error { scope.SpawnBg(func() error { return client.Run(ctx) }) - return sched.Run(ctx, scope) + return sched.Run(ctx, testrng.NewSource(1).Rand("sender:realworker:test"), scope) }) }() @@ -484,7 +486,7 @@ func TestDispatcher_PrewarmRateLimitedInOpenLoop(t *testing.T) { d.SetPrewarmGenerator(newSignedTxGenerator(t, prewarmTxs)) start := time.Now() - require.NoError(t, d.Prewarm(ctx)) + require.NoError(t, d.Prewarm(ctx, testrng.NewSource(1).Rand("sender:realworker:test"))) elapsed := time.Since(start) // Paced floor: (N-1) gaps at the limiter rate (burst=1 lets the first through diff --git a/sender/scheduler_test.go b/sender/scheduler_test.go index 45b8217..65fa3fb 100644 --- a/sender/scheduler_test.go +++ b/sender/scheduler_test.go @@ -3,6 +3,7 @@ package sender import ( "context" "errors" + mrand "math/rand/v2" "slices" "sync" "sync/atomic" @@ -13,6 +14,7 @@ import ( "golang.org/x/time/rate" "github.com/sei-protocol/sei-load/types" + testrng "github.com/sei-protocol/sei-load/utils/rng" "github.com/sei-protocol/sei-load/utils/service" ) @@ -27,7 +29,7 @@ type fakeGenerator struct { func newFakeGenerator(n int) *fakeGenerator { return &fakeGenerator{remaining: n} } -func (g *fakeGenerator) Generate() (*types.LoadTx, bool) { +func (g *fakeGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { g.mu.Lock() defer g.mu.Unlock() if g.remaining == 0 { @@ -66,7 +68,7 @@ func newSeededGenerator(n int) *seededGenerator { return &seededGenerator{remaining: n, drawIndex: map[*types.LoadTx]uint64{}} } -func (g *seededGenerator) Generate() (*types.LoadTx, bool) { +func (g *seededGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { g.mu.Lock() defer g.mu.Unlock() if g.remaining == 0 { @@ -149,9 +151,9 @@ func (s *asyncFakeSender) Send(ctx context.Context, tx *types.LoadTx) error { // runScheduler drives the scheduler in its own scope until the context expires, // returning the scheduler so the caller can read Dropped(). -func runScheduler(ctx context.Context, sched *openLoopScheduler) { +func runScheduler(ctx context.Context, rng *mrand.Rand, sched *openLoopScheduler) { _ = service.Run(ctx, func(ctx context.Context, s service.Scope) error { - return sched.Run(ctx, s) + return sched.Run(ctx, rng, s) }) } @@ -169,7 +171,7 @@ func TestOpenLoopSchedule_TracksT0PlusIOverLambda(t *testing.T) { sched := newOpenLoopScheduler(gen, snd, limiter, 1024, nil) start := time.Now() - runScheduler(ctx, sched) + runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) issued := gen.issuedTxs() require.GreaterOrEqual(t, len(issued), 30, "scheduler should issue most txs within the window") @@ -214,7 +216,7 @@ func TestOpenLoopSchedule_NotThrottledBySlowSender(t *testing.T) { sched := newOpenLoopScheduler(gen, snd, limiter, maxInFlight, nil) start := time.Now() - runScheduler(ctx, sched) + runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) admittedTxs := gen.issuedTxs() gap := time.Second / time.Duration(lambda) @@ -296,7 +298,7 @@ func TestOpenLoopSchedule_PermitHeldUntilCompletion(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 120*time.Millisecond) defer cancel() - runScheduler(ctx, sched) + runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) // Exactly one tx held the single permit through the whole run (never // completed), so the sender saw exactly one enqueue and everything else @@ -389,7 +391,7 @@ func TestOpenLoopSchedule_Conservation(t *testing.T) { // Fail every 5th send so failed > 0 and the invariant must absorb it. snd := newFlakyAsyncSender(ctx, 256, 16, 5) sched := newOpenLoopScheduler(gen, snd, limiter, 256, onSent) - runScheduler(ctx, sched) + runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) admitted := sched.Admitted() require.Positive(t, admitted) @@ -445,7 +447,7 @@ func TestOpenLoopSchedule_DroppedSlotsConsumeNoDraws(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - runScheduler(ctx, sched) + runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) }() // Periodically release the single held permit so flow inches forward one @@ -505,7 +507,7 @@ func TestOpenLoopSchedule_HonorsRampedLambda(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - runScheduler(ctx, sched) + runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) }() // Let it run at 50 tps, then ramp to 500 tps and let it run more. @@ -542,7 +544,7 @@ func TestOpenLoopSchedule_ClampsRunawayLambda(t *testing.T) { limiter := rate.NewLimiter(rate.Inf, 1) // runaway λ from the start sched := newOpenLoopScheduler(gen, snd, limiter, 4096, nil) - runScheduler(ctx, sched) + runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) issued := gen.issuedTxs() require.GreaterOrEqual(t, len(issued), 2, "scheduler must keep issuing under Inf λ") @@ -570,7 +572,7 @@ func TestOpenLoopSchedule_StampsBeforeHandoff(t *testing.T) { } sched := newOpenLoopScheduler(gen, snd, limiter, 64, onSent) - runScheduler(ctx, sched) + runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) require.Positive(t, checked.Load(), "onSent must observe stamped txs") } diff --git a/sender/sender_test.go b/sender/sender_test.go index 59df8b4..62cb37c 100644 --- a/sender/sender_test.go +++ b/sender/sender_test.go @@ -18,6 +18,7 @@ import ( "github.com/sei-protocol/sei-load/generator" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" + testrng "github.com/sei-protocol/sei-load/utils/rng" ) // JSONRPCRequest represents a captured JSON-RPC request @@ -149,12 +150,14 @@ func TestShardDistribution(t *testing.T) { } // Create generator - gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry()) + rngSource := generator.ResolveSeed(cfg) + gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry(), rngSource.Rand(testrng.StreamWeightedShuffle)) require.NoError(t, err) + rng := testrng.NewSource(1).Rand("sender:shards:test") // Test shard calculation without creating actual sender for i := 0; i < 10; i++ { - tx, ok := gen.Generate() + tx, ok := gen.Generate(rng) require.True(t, ok) require.NotNil(t, tx) diff --git a/sender/writer_test.go b/sender/writer_test.go index 510bb4b..12a9169 100644 --- a/sender/writer_test.go +++ b/sender/writer_test.go @@ -8,6 +8,7 @@ import ( "github.com/sei-protocol/sei-load/generator" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" + testrng "github.com/sei-protocol/sei-load/utils/rng" "github.com/stretchr/testify/require" ) @@ -28,11 +29,12 @@ func TestTxsWriter_Flush(t *testing.T) { Name: "EVMTransfer", Weight: 1, }) - evmScenario.Deploy(loadConfig, sharedAccounts.NextAccount()) + rng := testrng.NewSource(1).Rand("sender:writer:test") + evmScenario.Deploy(loadConfig, sharedAccounts.NextAccount(rng)) gen := generator.NewScenarioGenerator(sharedAccounts, evmScenario) - txs := generator.GenerateN(gen, 3) + txs := generator.GenerateN(rng, gen, 3) err := writer.Send(context.Background(), txs[0]) require.NoError(t, err) diff --git a/types/account_pool.go b/types/account_pool.go index c926886..dd43301 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -1,10 +1,8 @@ package types import ( - "math/rand/v2" + mrand "math/rand/v2" "sync" - - "github.com/sei-protocol/sei-load/utils/rng" ) // AccountRegistry owns account pools created for a run. @@ -39,9 +37,6 @@ type AccountPool struct { type AccountConfig struct { InitialSize int NewAccountRate float64 - // Stream, when non-nil, makes the new-account roll deterministic. A nil - // Stream leaves the pool on the unseeded global RNG. - Stream *rng.Stream } func (a *AccountPool) nextIndex() int { @@ -52,16 +47,11 @@ func (a *AccountPool) nextIndex() int { return a.idx } -// NextAccount returns the next account. -func (a *AccountPool) NextAccount() *Account { +// NextAccount returns the next account, using rng for the new-account roll when +// NewAccountRate > 0. +func (a *AccountPool) NextAccount(rng *mrand.Rand) *Account { if a.cfg.NewAccountRate > 0 { - var randomNumber float64 - if a.cfg.Stream != nil { - randomNumber = a.cfg.Stream.Float64() - } else { - randomNumber = rand.Float64() - } - if randomNumber <= a.cfg.NewAccountRate { + if rng.Float64() <= a.cfg.NewAccountRate { return GenerateAccounts(1)[0] } } diff --git a/types/types_test.go b/types/types_test.go index 79cad81..e293fef 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -134,9 +134,10 @@ func TestAccountPoolRoundRobin(t *testing.T) { expectedOrder := []int{1, 2, 0} // The actual order the pool returns accounts // Test multiple rounds of round-robin selection + rng := rng.NewSource(1).Rand("types:test") for round := 0; round < 3; round++ { for i, expectedIndex := range expectedOrder { - selectedAccount := pool.NextAccount() + selectedAccount := pool.NextAccount(rng) expectedAccount := accounts[expectedIndex] assert.Equal(t, expectedAccount.Address, selectedAccount.Address, "Round %d, position %d: expected %s, got %s", @@ -162,7 +163,7 @@ func TestAccountPoolNewAccountRate(t *testing.T) { } for i := 0; i < 10; i++ { - selectedAccount := pool.NextAccount() + selectedAccount := pool.NextAccount(rng.NewSource(1).Rand("types:test")) assert.False(t, originalAddresses[selectedAccount.Address], "Iteration %d: got original account %s when expecting new account", i, selectedAccount.Address.Hex()) @@ -174,7 +175,6 @@ func TestAccountPoolMixedRate(t *testing.T) { config := &AccountConfig{ InitialSize: 5, NewAccountRate: 0.5, // 50% new accounts - Stream: rng.NewSource(1).Stream("accounts:test"), } pool := registry.NewPool(config) @@ -188,9 +188,10 @@ func TestAccountPoolMixedRate(t *testing.T) { const iterations = 100 originalCount := 0 newCount := 0 + rng := rng.NewSource(1).Rand("accounts:test") for i := 0; i < iterations; i++ { - selectedAccount := pool.NextAccount() + selectedAccount := pool.NextAccount(rng) if originalAddresses[selectedAccount.Address] { originalCount++ } else { @@ -227,8 +228,9 @@ func TestAccountPoolConcurrency(t *testing.T) { wg.Add(1) go func(goroutineID int) { defer wg.Done() + rng := rng.NewSource(1).Rand("types:test") for j := 0; j < selectionsPerGoroutine; j++ { - account := pool.NextAccount() + account := pool.NextAccount(rng) selectedAccounts[goroutineID*selectionsPerGoroutine+j] = account.Address } }(i) @@ -450,7 +452,7 @@ func BenchmarkAccountPoolNextAccount(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - pool.NextAccount() + pool.NextAccount(rng.NewSource(1).Rand("types:test")) } } diff --git a/utils/rng/rng.go b/utils/rng/rng.go index f03fde7..761702b 100644 --- a/utils/rng/rng.go +++ b/utils/rng/rng.go @@ -113,6 +113,12 @@ func (s *Source) Stream(streamID string) *Stream { return &Stream{rand: mrand.New(substream(s.seed, streamID))} } +// Rand returns the sub-stream for a logical consumer named streamID as a +// plain math/rand/v2 Rand. Callers are responsible for serializing access. +func (s *Source) Rand(streamID string) *mrand.Rand { + return mrand.New(substream(s.seed, streamID)) +} + // Stream is a single consumer's reproducible sub-stream. It is safe for // concurrent use: draws are serialized so the per-stream sequence depends only // on call order into this stream, not on the goroutine that made the call. diff --git a/utils/rng/streams.go b/utils/rng/streams.go index 4f4aa60..a8256dd 100644 --- a/utils/rng/streams.go +++ b/utils/rng/streams.go @@ -13,6 +13,10 @@ const ( StreamAccountsShared = "accounts:shared" // StreamWeightedShuffle seeds the weighted scenario selector's shuffle. StreamWeightedShuffle = "weighted:shuffle" + // StreamLoadGeneration seeds the main load-generation draw stream. + StreamLoadGeneration = "load:generation" + // StreamPrewarmGeneration seeds the prewarm draw stream. + StreamPrewarmGeneration = "prewarm:generation" ) // AccountsScenarioStream is the stream id for scenario i's own account pool. From e551828158f87c7b011d5d528817fb62c8cbde60 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 16:13:20 +0200 Subject: [PATCH 09/21] wip --- generator/generator.go | 5 +---- generator/generator_test.go | 2 +- generator/seed_test.go | 2 +- main.go | 11 ++++------- sender/sender_test.go | 2 +- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index eb791c5..56ae618 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -196,10 +196,7 @@ func ResolveSeed(cfg *config.LoadConfig) *rng.Source { } // NewConfigBasedGenerator is a convenience method that combines all steps. -func NewConfigBasedGenerator(cfg *config.LoadConfig, registry *types.AccountRegistry, rng *mrand.Rand) (Generator, error) { - if rng == nil { - panic("NewConfigBasedGenerator: rng must not be nil") - } +func NewConfigBasedGenerator(rng *mrand.Rand, cfg *config.LoadConfig, registry *types.AccountRegistry) (Generator, error) { generator := &configBasedGenerator{ config: cfg, registry: registry, diff --git a/generator/generator_test.go b/generator/generator_test.go index 7eda587..df347a1 100644 --- a/generator/generator_test.go +++ b/generator/generator_test.go @@ -38,7 +38,7 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { } rngSource := generator.ResolveSeed(cfg) - gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry(), rngSource.Rand(testrng.StreamWeightedShuffle)) + gen, err := generator.NewConfigBasedGenerator(rngSource.Rand(testrng.StreamWeightedShuffle), cfg, types.NewAccountRegistry()) require.NoError(t, err) require.NotNil(t, gen) diff --git a/generator/seed_test.go b/generator/seed_test.go index 2387efe..9220ae3 100644 --- a/generator/seed_test.go +++ b/generator/seed_test.go @@ -70,7 +70,7 @@ func gasSeq(t *testing.T, seed uint64, n int) []gasDraw { t.Helper() cfg := seededConfig(t, seed) rngSource := generator.ResolveSeed(cfg) - gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry(), rngSource.Rand(rng.StreamWeightedShuffle)) + gen, err := generator.NewConfigBasedGenerator(rngSource.Rand(rng.StreamWeightedShuffle), cfg, types.NewAccountRegistry()) require.NoError(t, err) txs := generator.GenerateN(rngSource.Rand("generator:seed:draws"), gen, n) require.Len(t, txs, n) diff --git a/main.go b/main.go index bcb77d5..aa072f2 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,6 @@ import ( "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/utils" - runrng "github.com/sei-protocol/sei-load/utils/rng" "github.com/sei-protocol/sei-load/utils/scope" ) @@ -210,7 +209,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Create statistics collector and logger collector := stats.NewCollector() logger := stats.NewLogger(collector, cfg.Settings.StatsInterval.ToDuration(), cfg.Settings.ReportPath, cfg.Settings.Debug) - rngSource := generator.ResolveSeed(cfg) + rng := generator.ResolveSeed(cfg).Rand("") var ramper *sender.Ramper var dispatcher *sender.Dispatcher var inclusionTracker *stats.InclusionTracker @@ -218,12 +217,10 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { err = scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Create the generator from the config struct registry := types.NewAccountRegistry() - gen, err := generator.NewConfigBasedGenerator(cfg, registry, rngSource.Rand(runrng.StreamWeightedShuffle)) + gen, err := generator.NewConfigBasedGenerator(rng, cfg, registry) if err != nil { return fmt.Errorf("failed to create generator: %w", err) } - loadRNG := rngSource.Rand(runrng.StreamLoadGeneration) - prewarmRNG := rngSource.Rand(runrng.StreamPrewarmGeneration) // Create the shared rate authority for the whole run. sharedLimiter := rate.NewLimiter(rate.Inf, 1) @@ -359,7 +356,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { } // Perform prewarming if enabled (before starting logger to avoid logging prewarm transactions) if cfg.Settings.Prewarm { - if err := dispatcher.Prewarm(ctx, prewarmRNG); err != nil { + if err := dispatcher.Prewarm(ctx, rng); err != nil { return fmt.Errorf("failed to prewarm accounts: %w", err) } } @@ -369,7 +366,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { log.Printf("✅ Started statistics logger") // Start dispatcher for main load test - s.SpawnBgNamed("dispatcher", func() error { return dispatcher.Run(ctx, loadRNG) }) + s.SpawnBgNamed("dispatcher", func() error { return dispatcher.Run(ctx, rng) }) log.Printf("✅ Started dispatcher") // Set up signal handling for graceful shutdown diff --git a/sender/sender_test.go b/sender/sender_test.go index 62cb37c..825a874 100644 --- a/sender/sender_test.go +++ b/sender/sender_test.go @@ -151,7 +151,7 @@ func TestShardDistribution(t *testing.T) { // Create generator rngSource := generator.ResolveSeed(cfg) - gen, err := generator.NewConfigBasedGenerator(cfg, types.NewAccountRegistry(), rngSource.Rand(testrng.StreamWeightedShuffle)) + gen, err := generator.NewConfigBasedGenerator(rngSource.Rand(testrng.StreamWeightedShuffle), cfg, types.NewAccountRegistry()) require.NoError(t, err) rng := testrng.NewSource(1).Rand("sender:shards:test") From aa86ad535246132a6ad0abe759acd32376b453cb Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 16:54:23 +0200 Subject: [PATCH 10/21] WIP --- generator/generator.go | 2 +- generator/prewarm.go | 30 ++----- generator/scenarios/Disperse.go | 2 +- generator/weighted.go | 59 ++----------- main.go | 77 ++++++---------- sender/dispatcher.go | 150 ++------------------------------ types/account.go | 4 +- 7 files changed, 51 insertions(+), 273 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index 56ae618..921429a 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -179,7 +179,7 @@ func (g *configBasedGenerator) createWeightedGenerator(rng *mrand.Rand) (Generat } // Create and return the weighted scenarioGenerator - return NewWeightedGenerator(rng, weightedConfigs...), nil + return NewWeightedGenerator(rng, weightedConfigs), nil } // resolveSeed returns the run's PRNG source, defaulting an unseeded config to a diff --git a/generator/prewarm.go b/generator/prewarm.go index 615cf08..b6c7cf8 100644 --- a/generator/prewarm.go +++ b/generator/prewarm.go @@ -10,53 +10,37 @@ import ( // PrewarmGenerator generates self-transfer transactions to prewarm account nonces type PrewarmGenerator struct { - registry *types.AccountRegistry + accounts []*types.Account evmScenario scenarios.TxGenerator - currentAccount int - finished bool } // NewPrewarmGenerator creates a new prewarm generator using all account pools from the registry. -func NewPrewarmGenerator(cfg *config.LoadConfig, registry *types.AccountRegistry) *PrewarmGenerator { +func NewPrewarmGenerator(cfg *config.LoadConfig, accounts []*types.Account) *PrewarmGenerator { // Create EVMTransfer scenario for prewarming evmScenario := scenarios.NewEVMTransferScenario(config.Scenario{}) // Deploy/initialize the scenario (EVMTransfer doesn't need actual deployment) - deployerAccounts := types.GenerateAccounts(1) - deployer := deployerAccounts[0] + deployer := types.NewAccount() evmScenario.Deploy(cfg, deployer) - return &PrewarmGenerator{ - registry: registry, + accounts: accounts, evmScenario: evmScenario, - currentAccount: 0, - finished: false, } } // Generate generates self-transfer transactions until all known accounts are prewarmed. func (pg *PrewarmGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { - accounts := pg.registry.Accounts() - - // Check if we're already finished - if pg.finished || pg.currentAccount >= len(accounts) { + if len(pg.accounts)==0 { return nil, false } - - account := accounts[pg.currentAccount] - if account.Nonce > 0 { - pg.currentAccount++ - return pg.Generate(rng) - } - pg.currentAccount++ - + account := pg.accounts[0] + pg.accounts = pg.accounts[1:] // Create self-transfer transaction scenario := &types.TxScenario{ Name: "EVMTransfer", Sender: account, Receiver: account.Address, // Send to self } - // Generate the transaction using EVMTransfer scenario return pg.evmScenario.Generate(rng, scenario), true } diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index f57551e..7ffee13 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -25,7 +25,7 @@ type DisperseScenario struct { // NewDisperseScenario creates a new Disperse scenario func NewDisperseScenario(cfg config.Scenario) TxGenerator { scenario := &DisperseScenario{} - scenario.ContractScenarioBase = NewContractScenarioBase[bindings.Disperse](scenario, cfg) + scenario.ContractScenarioBase = NewContractScenarioBase(scenario, cfg) scenario.pool = types.NewAccountRegistry().NewPool(&types.AccountConfig{ InitialSize: 0, NewAccountRate: 1.0, diff --git a/generator/weighted.go b/generator/weighted.go index 58c37c9..e43ede7 100644 --- a/generator/weighted.go +++ b/generator/weighted.go @@ -1,7 +1,6 @@ package generator import ( - "context" mrand "math/rand/v2" "github.com/sei-protocol/sei-load/types" @@ -26,65 +25,25 @@ type weightedGenerator struct { counter uint64 } -// GenerateInfinite generates transactions indefinitely. -func (w *weightedGenerator) GenerateInfinite(ctx context.Context, rng *mrand.Rand) <-chan *types.LoadTx { - output := make(chan *types.LoadTx, 10000) - go func() { - defer close(output) - for ctx.Err() == nil { - select { - case <-ctx.Done(): - return - default: - select { - case <-ctx.Done(): - return - case output <- func() *types.LoadTx { - tx, _ := w.nextGenerator().Generate(rng) - return tx - }(): - } - } - } - }() - return output -} - -func (w *weightedGenerator) nextIndex() int { - idx := int(w.counter) % len(w.generators) - w.counter++ - return idx -} - -// generators are randomized at startup. -func (w *weightedGenerator) nextGenerator() Generator { - return w.generators[w.nextIndex()] -} - -// Generate generates 1 transaction. -func (w *weightedGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { - return w.nextGenerator().Generate(rng) -} - // NewWeightedGenerator creates a new scenarioGenerator that will randomly select // from the provided generators. -func NewWeightedGenerator(rng *mrand.Rand, cfgs ...*WeightedCfg) Generator { - // no need for clever weighting logic if we just have 1 scenarioGenerator anyway. - if len(cfgs) == 1 { - return cfgs[0].Generator - } +func NewWeightedGenerator(rng *mrand.Rand, cfgs []*WeightedCfg) Generator { var weighted []Generator for _, cfg := range cfgs { for range cfg.Weight { weighted = append(weighted, cfg.Generator) } } - rng.Shuffle(len(weighted), func(i, j int) { weighted[i], weighted[j] = weighted[j], weighted[i] }) - return &weightedGenerator{ - generators: weighted, - } + return &weightedGenerator{generators: weighted} +} + +// Generate generates 1 transaction. +func (w *weightedGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { + idx := int(w.counter) % len(w.generators) + w.counter++ + return w.generators[idx].Generate(rng) } diff --git a/main.go b/main.go index aa072f2..f69656f 100644 --- a/main.go +++ b/main.go @@ -286,77 +286,52 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { senderLimiter := sharedLimiter if cfg.Settings.ArrivalModel == config.ArrivalModelOpenLoop && cfg.Settings.TxsDir == "" { senderLimiter = rate.NewLimiter(rate.Inf, 1) - } - - // Create the sender from the config struct - snd, err := sender.NewShardedSender(cfg, senderLimiter, collector, inclusion) - if err != nil { - return fmt.Errorf("failed to create sender: %w", err) - } - - // Fund the pool before prewarm/dispatch — both spend gas the accounts - // don't have until funded. - if cfg.Funding != nil && !cfg.Settings.DryRun { - if err := funder.FundAccounts(ctx, cfg, registry.Accounts()); err != nil { - return fmt.Errorf("failed to fund accounts: %w", err) - } - } + } + var snd sender.TxSender // Create dispatcher if cfg.Settings.TxsDir != "" { // get latest height - ethclient, err := ethclient.Dial(cfg.Endpoints[0]) + eth, err := ethclient.Dial(cfg.Endpoints[0]) if err != nil { return fmt.Errorf("failed to create ethclient: %w", err) } - latestHeight, err := ethclient.BlockNumber(ctx) + latestHeight, err := eth.BlockNumber(ctx) if err != nil { return fmt.Errorf("failed to get latest height: %w", err) } numBlocksToWrite := cfg.Settings.NumBlocksToWrite writerHeight := latestHeight + 10 // some buffer log.Printf("🔍 Latest height: %d, writer start height: %d", latestHeight, writerHeight) - writer := sender.NewTxsWriter(cfg.Settings.TargetGas, cfg.Settings.TxsDir, writerHeight, uint64(numBlocksToWrite)) - dispatcher = sender.NewDispatcher(gen, writer) + snd = sender.NewTxsWriter(cfg.Settings.TargetGas, cfg.Settings.TxsDir, writerHeight, uint64(numBlocksToWrite)) } else { - dispatcher = sender.NewDispatcher(gen, snd) - } - - // Set statistics collector for dispatcher - dispatcher.SetStatsCollector(collector) - - // Open-loop drives arrivals from the scheduler (see sender doc); the - // txs-writer path has no arrival clock, so it stays closed-loop. - openLoop := cfg.Settings.ArrivalModel == config.ArrivalModelOpenLoop - switch { - case openLoop && cfg.Settings.TxsDir == "": - dispatcher.SetOpenLoop(sharedLimiter, cfg.Settings.MaxInFlight) - log.Printf("📤 Arrival model: open_loop (max in-flight: %d)", cfg.Settings.MaxInFlight) - case openLoop: - // open_loop was requested but the txs-writer path has no arrival clock, - // so the run falls back to closed_loop. Surface the downgrade. - log.Printf("📤 Arrival model: closed_loop (txs-writer path; --arrival-model open_loop ignored)") - default: - log.Printf("📤 Arrival model: closed_loop") + // Fund the pool before prewarm/dispatch — both spend gas the accounts + // don't have until funded. + if cfg.Funding != nil && !cfg.Settings.DryRun { + if err := funder.FundAccounts(ctx, cfg, registry.Accounts()); err != nil { + return fmt.Errorf("failed to fund accounts: %w", err) + } + } + // Create the sender from the config struct + sharedSender, err := sender.NewShardedSender(cfg, senderLimiter, collector, inclusion) + if err != nil { + return fmt.Errorf("failed to create sender: %w", err) + } + // Start the sender (starts all workers) + s.SpawnBgNamed("sender", func() error { return sharedSender.Run(ctx) }) + snd = sharedSender + log.Printf("✅ Connected to %d endpoints", len(cfg.Endpoints)) + } + dispatcher := sender.NewDispatcher(gen, snd, collector, cfg.MaxInFlight) // Set up prewarming if enabled if cfg.Settings.Prewarm { log.Printf("🔥 Creating prewarm generator...") - prewarmGen := generator.NewPrewarmGenerator(cfg, registry) - dispatcher.SetPrewarmGenerator(prewarmGen) + prewarmGen := generator.NewPrewarmGenerator(cfg, registry.Accounts()) log.Printf("✅ Prewarm generator ready") log.Printf("📝 Prewarm mode: Accounts will be prewarmed") - } - - if cfg.Settings.TxsDir == "" { - // Start the sender (starts all workers) - s.SpawnBgNamed("sender", func() error { return snd.Run(ctx) }) - log.Printf("✅ Connected to %d endpoints", len(cfg.Endpoints)) - } - // Perform prewarming if enabled (before starting logger to avoid logging prewarm transactions) - if cfg.Settings.Prewarm { - if err := dispatcher.Prewarm(ctx, rng); err != nil { + if err := dispatcher.RunPrewarm(ctx, rng, prewarmGen); err != nil { return fmt.Errorf("failed to prewarm accounts: %w", err) } } @@ -366,7 +341,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { log.Printf("✅ Started statistics logger") // Start dispatcher for main load test - s.SpawnBgNamed("dispatcher", func() error { return dispatcher.Run(ctx, rng) }) + s.SpawnBgNamed("dispatcher", func() error { return dispatcher.Run(ctx, rng, gen) }) log.Printf("✅ Started dispatcher") // Set up signal handling for graceful shutdown diff --git a/sender/dispatcher.go b/sender/dispatcher.go index 7f7e619..83d2d36 100644 --- a/sender/dispatcher.go +++ b/sender/dispatcher.go @@ -2,7 +2,6 @@ package sender import ( "context" - "fmt" "log" mrand "math/rand/v2" "sync" @@ -12,33 +11,11 @@ import ( "github.com/sei-protocol/sei-load/generator" "github.com/sei-protocol/sei-load/stats" - "github.com/sei-protocol/sei-load/types" - "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/service" -) - -// ArrivalModel selects how the dispatcher times transaction arrival. -type ArrivalModel string - -const ( - // ArrivalClosedLoop is the legacy model: a tx is generated and sent only - // when a sender is free, so a slow SUT slows the generator (coordinated - // omission). Kept reachable as the regression baseline. - ArrivalClosedLoop ArrivalModel = "closed_loop" - // ArrivalOpenLoop schedules tx i at t₀ + i/λ independent of sender - // availability; overdue txs are dropped and counted. See scheduler.go. - ArrivalOpenLoop ArrivalModel = "open_loop" ) // Dispatcher continuously generates transactions and dispatches them to the sender type Dispatcher struct { - generator generator.Generator - prewarmGen utils.Option[generator.Generator] // Optional prewarm generator sender TxSender - - // Open-loop arrival configuration. arrivalModel defaults to closed-loop. - // limiter is always present; open-loop additionally consults maxInFlight. - arrivalModel ArrivalModel limiter *rate.Limiter maxInFlight int @@ -52,66 +29,20 @@ type Dispatcher struct { } // NewDispatcher creates a new dispatcher in the legacy closed-loop arrival model. -func NewDispatcher(gen generator.Generator, sender TxSender) *Dispatcher { +func NewDispatcher(sender TxSender, collector *stats.Collector, maxInFlight int) *Dispatcher { return &Dispatcher{ - generator: gen, sender: sender, - arrivalModel: ArrivalClosedLoop, limiter: rate.NewLimiter(rate.Inf, 1), + maxInFlight: maxInFlight, } } -// SetOpenLoop switches the dispatcher to the open-loop arrival model, driven by -// the shared limiter (the one rate authority, also driven by the ramper) and -// bounded by maxInFlight concurrent sends. A non-positive maxInFlight is treated -// as 1 so admission control is always live. -func (d *Dispatcher) SetOpenLoop(limiter *rate.Limiter, maxInFlight int) { - if maxInFlight < 1 { - maxInFlight = 1 - } - d.mu.Lock() - defer d.mu.Unlock() - d.arrivalModel = ArrivalOpenLoop - d.limiter = limiter - d.maxInFlight = maxInFlight -} - -// ArrivalModel reports the configured arrival model (for recording/reporting). -func (d *Dispatcher) ArrivalModel() ArrivalModel { - d.mu.RLock() - defer d.mu.RUnlock() - return d.arrivalModel -} - -// SetStatsCollector sets the statistics collector for this dispatcher -func (d *Dispatcher) SetStatsCollector(collector *stats.Collector) { - d.mu.Lock() - defer d.mu.Unlock() - d.collector = collector -} - -// SetPrewarmGenerator sets the prewarm generator for this dispatcher -func (d *Dispatcher) SetPrewarmGenerator(prewarmGen generator.Generator) { - d.mu.Lock() - defer d.mu.Unlock() - d.prewarmGen = utils.Some(prewarmGen) -} - // Prewarm runs the prewarm generator to completion before starting the main load test -func (d *Dispatcher) Prewarm(ctx context.Context, rng *mrand.Rand) error { - d.mu.RLock() - prewarmGen := d.prewarmGen +func (d *Dispatcher) RunPrewarm(ctx context.Context, rng *mrand.Rand, gen generator.Generator) error { // Prewarm runs before the scheduler paces anything, so it must self-pace off // the shared limiter or it floods the SUT. limiter := d.limiter - d.mu.RUnlock() - gen, ok := prewarmGen.Get() - if !ok { - return nil - } // No prewarming configured - - log.Print("🔥 Starting account prewarming...") processedAccounts := 0 logInterval := 100 @@ -146,19 +77,10 @@ func (d *Dispatcher) Prewarm(ctx context.Context, rng *mrand.Rand) error { // Run begins the dispatcher's transaction generation and sending loop, using // the configured arrival model. -func (d *Dispatcher) Run(ctx context.Context, rng *mrand.Rand) error { - if d.ArrivalModel() == ArrivalOpenLoop { - return d.runOpenLoop(ctx, rng) - } - return d.runClosedLoop(ctx, rng) -} - -// runClosedLoop is the legacy model: generate-then-send in lockstep, so a slow -// SUT back-pressures the generator. Kept as the regression baseline. -func (d *Dispatcher) runClosedLoop(ctx context.Context, rng *mrand.Rand) error { +func (d *Dispatcher) Run(ctx context.Context, rng *mrand.Rand, gen generator.Generator) error { for ctx.Err() == nil { // Generate a transaction from main generator - tx, ok := d.generator.Generate(rng) + tx, ok := gen.Generate(rng) if !ok { log.Print("Dispatcher: Generator returned no more transactions") return nil @@ -179,68 +101,6 @@ func (d *Dispatcher) runClosedLoop(ctx context.Context, rng *mrand.Rand) error { return ctx.Err() } -// runOpenLoop drives the open-loop scheduler (see scheduler.go), which owns the -// arrival clock (t₀, sequence index i) and the in-flight bound. Send tasks are -// spawned into a scope so they all complete on shutdown. -func (d *Dispatcher) runOpenLoop(ctx context.Context, rng *mrand.Rand) error { - d.mu.RLock() - limiter, maxInFlight := d.limiter, d.maxInFlight - d.mu.RUnlock() - - sched := newOpenLoopScheduler(d.generator, d.sender, limiter, maxInFlight, d.onSent) - err := service.Run(ctx, func(ctx context.Context, s service.Scope) error { - return sched.Run(ctx, rng, s) - }) - // Fold the scheduler's drop count into the summary accounting. - d.mu.Lock() - d.dropped = sched.Dropped() - d.mu.Unlock() - return err -} - -// onSent records a completed open-loop send: nil err advances totalSent -// (succeeded), non-nil advances failed. Each admitted tx is counted exactly -// once, never lost (doc.go: admitted == succeeded + failed). -func (d *Dispatcher) onSent(tx *types.LoadTx, err error) { - if err != nil { - log.Printf("Scheduler: send failed (seq %d): %v", tx.SequenceIndex, err) - d.mu.Lock() - d.failed++ - d.mu.Unlock() - return - } - d.mu.Lock() - d.totalSent++ - d.mu.Unlock() -} - -// StartBatch generates and sends a specific number of transactions then stops -func (d *Dispatcher) RunBatch(ctx context.Context, rng *mrand.Rand, count int) error { - if count <= 0 { - return fmt.Errorf("count must be positive") - } - for i := range count { - // Generate a transaction - tx, ok := d.generator.Generate(rng) - if !ok { - return fmt.Errorf("dispatcher: generator returned nil transaction (batch %d/%d)", i+1, count) - } - // Stamp before hand-off (see Run). - tx.IntendedSendTime = time.Now() - - // Send the transaction - if err := d.sender.Send(ctx, tx); err != nil { - log.Printf("Dispatcher: Failed to send transaction %d/%d: %v", i+1, count, err) - // Continue despite errors - } else { - d.mu.Lock() - d.totalSent++ - d.mu.Unlock() - } - } - return ctx.Err() -} - // GetStats returns dispatcher statistics func (d *Dispatcher) GetStats() DispatcherStats { d.mu.RLock() diff --git a/types/account.go b/types/account.go index 6c7ac8b..7d45203 100644 --- a/types/account.go +++ b/types/account.go @@ -13,7 +13,7 @@ import ( type Account struct { Address common.Address PrivKey *ecdsa.PrivateKey - Nonce uint64 + Nonce atomic.Uint64 } // NewAccount generates new account. @@ -27,7 +27,7 @@ func NewAccount() *Account { // GetAndIncrementNonce increments the nonce. func (s *Account) GetAndIncrementNonce() uint64 { - return atomic.AddUint64(&s.Nonce, 1) - 1 + return s.Nonce.Add(1) - 1 } // GenerateAccounts generates random accounts. From d349c0b6a0508829805952ce6890cba6326cb42c Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 17:19:57 +0200 Subject: [PATCH 11/21] WIP --- main.go | 40 ++++----------- sender/dispatcher.go | 104 ++------------------------------------- sender/sharded_sender.go | 1 + 3 files changed, 13 insertions(+), 132 deletions(-) diff --git a/main.go b/main.go index f69656f..6582cf1 100644 --- a/main.go +++ b/main.go @@ -211,8 +211,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { logger := stats.NewLogger(collector, cfg.Settings.StatsInterval.ToDuration(), cfg.Settings.ReportPath, cfg.Settings.Debug) rng := generator.ResolveSeed(cfg).Rand("") var ramper *sender.Ramper - var dispatcher *sender.Dispatcher - var inclusionTracker *stats.InclusionTracker + inclusion := utils.None[*stats.InclusionTracker]() err = scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Create the generator from the config struct @@ -265,10 +264,9 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // tracker (the lossy per-tx receipt path is retired). // Not wired under --dry-run: simulated sends never hit the chain, so they // would all reap as expired and pollute the inclusion stats. - inclusion := utils.None[*stats.InclusionTracker]() if len(cfg.Endpoints) > 0 && cfg.Settings.TrackReceipts && !cfg.Settings.DryRun { reapAfter := cfg.Settings.InclusionReapAfter.ToDuration() - inclusionTracker = stats.NewInclusionTracker( + inclusionTracker := stats.NewInclusionTracker( cfg.SeiChainID, reapAfter, inclusionRegistryCap(cfg.Settings.MaxInFlight, cfg.Settings.TPS, reapAfter), @@ -280,16 +278,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { }) } - // Open-loop owns the arrival clock in the scheduler, so the sender must - // not add a second finite gate. Prewarm and the scheduler still use the - // real shared limiter. - senderLimiter := sharedLimiter - if cfg.Settings.ArrivalModel == config.ArrivalModelOpenLoop && cfg.Settings.TxsDir == "" { - senderLimiter = rate.NewLimiter(rate.Inf, 1) - } - var snd sender.TxSender - // Create dispatcher if cfg.Settings.TxsDir != "" { // get latest height eth, err := ethclient.Dial(cfg.Endpoints[0]) @@ -313,7 +302,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { } } // Create the sender from the config struct - sharedSender, err := sender.NewShardedSender(cfg, senderLimiter, collector, inclusion) + sharedSender, err := sender.NewShardedSender(cfg, sharedLimiter, collector, inclusion) if err != nil { return fmt.Errorf("failed to create sender: %w", err) } @@ -323,17 +312,18 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { log.Printf("✅ Connected to %d endpoints", len(cfg.Endpoints)) } - dispatcher := sender.NewDispatcher(gen, snd, collector, cfg.MaxInFlight) // Set up prewarming if enabled if cfg.Settings.Prewarm { log.Printf("🔥 Creating prewarm generator...") - prewarmGen := generator.NewPrewarmGenerator(cfg, registry.Accounts()) + accounts := registry.Accounts() + prewarmGen := generator.NewPrewarmGenerator(cfg, accounts) log.Printf("✅ Prewarm generator ready") log.Printf("📝 Prewarm mode: Accounts will be prewarmed") - if err := dispatcher.RunPrewarm(ctx, rng, prewarmGen); err != nil { + if err := sender.Run(ctx, rng, prewarmGen, snd); err != nil { return fmt.Errorf("failed to prewarm accounts: %w", err) } + log.Printf("🔥 Prewarming complete! Processed %d accounts", len(accounts)) } // Start logger (after prewarming to capture only main load test metrics) @@ -341,7 +331,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { log.Printf("✅ Started statistics logger") // Start dispatcher for main load test - s.SpawnBgNamed("dispatcher", func() error { return dispatcher.Run(ctx, rng, gen) }) + s.SpawnBgNamed("dispatcher", func() error { return sender.Run(ctx, rng, gen, snd) }) log.Printf("✅ Started dispatcher") // Set up signal handling for graceful shutdown @@ -379,21 +369,9 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { ramper.LogFinalStats() } summary := stats.RunSummary{ArrivalModel: config.ArrivalModelClosedLoop} - if dispatcher != nil { - summary.ArrivalModel = string(dispatcher.ArrivalModel()) - dstats := dispatcher.GetStats() - summary.Dropped = dstats.Dropped - summary.Failed = dstats.Failed - if summary.Dropped > 0 { - log.Printf("⚠️ Open-loop dropped %d txs (in-flight saturated; not throttled)", summary.Dropped) - } - if summary.Failed > 0 { - log.Printf("⚠️ Open-loop %d txs failed to send (admitted but errored; not lost)", summary.Failed) - } - } // Read AFTER service.Run returns: both workers and the tracker have joined, // so inflightAtShutdown is final and the conservation identity holds. - if inclusionTracker != nil { + if inclusionTracker,ok := inclusion.Get(); ok { incl := inclusionTracker.Summary() summary.InclusionTracked = true summary.Included = incl.Included diff --git a/sender/dispatcher.go b/sender/dispatcher.go index 83d2d36..c1a3fea 100644 --- a/sender/dispatcher.go +++ b/sender/dispatcher.go @@ -2,87 +2,18 @@ package sender import ( "context" - "log" mrand "math/rand/v2" - "sync" "time" - - "golang.org/x/time/rate" - "github.com/sei-protocol/sei-load/generator" - "github.com/sei-protocol/sei-load/stats" ) -// Dispatcher continuously generates transactions and dispatches them to the sender -type Dispatcher struct { - sender TxSender - limiter *rate.Limiter - maxInFlight int - - // Conservation counters (doc.go): scheduled = dropped + admitted, - // admitted = succeeded + failed. - totalSent uint64 // admitted, nil send error (succeeded) - failed uint64 // admitted, non-nil send error - dropped uint64 - mu sync.RWMutex - collector *stats.Collector -} - -// NewDispatcher creates a new dispatcher in the legacy closed-loop arrival model. -func NewDispatcher(sender TxSender, collector *stats.Collector, maxInFlight int) *Dispatcher { - return &Dispatcher{ - sender: sender, - limiter: rate.NewLimiter(rate.Inf, 1), - maxInFlight: maxInFlight, - } -} - -// Prewarm runs the prewarm generator to completion before starting the main load test -func (d *Dispatcher) RunPrewarm(ctx context.Context, rng *mrand.Rand, gen generator.Generator) error { - // Prewarm runs before the scheduler paces anything, so it must self-pace off - // the shared limiter or it floods the SUT. - limiter := d.limiter - - processedAccounts := 0 - logInterval := 100 - - // Run prewarm generator until completion - for ctx.Err() == nil { - if err := limiter.Wait(ctx); err != nil { - return err - } - - tx, ok := gen.Generate(rng) - if !ok { - break // Prewarming is complete - } - - // Send the prewarming transaction - if err := d.sender.Send(ctx, tx); err != nil { - log.Printf("🔥 Failed to send prewarm transaction for account %s: %v", tx.Scenario.Sender.Address.Hex(), err) - continue - } - - processedAccounts++ - - // Log progress periodically - if processedAccounts%logInterval == 0 { - log.Printf("🔥 Prewarming progress: %d accounts processed...", processedAccounts) - } - } - - log.Printf("🔥 Prewarming complete! Processed %d accounts", processedAccounts) - return nil -} - // Run begins the dispatcher's transaction generation and sending loop, using // the configured arrival model. -func (d *Dispatcher) Run(ctx context.Context, rng *mrand.Rand, gen generator.Generator) error { +func Run(ctx context.Context, rng *mrand.Rand, gen generator.Generator, snd TxSender) error { for ctx.Err() == nil { // Generate a transaction from main generator tx, ok := gen.Generate(rng) if !ok { - log.Print("Dispatcher: Generator returned no more transactions") return nil } @@ -91,38 +22,9 @@ func (d *Dispatcher) Run(ctx context.Context, rng *mrand.Rand, gen generator.Gen tx.IntendedSendTime = time.Now() // Send the transaction - if err := d.sender.Send(ctx, tx); err != nil { + if err := snd.Send(ctx, tx); err != nil { return err } - d.mu.Lock() - d.totalSent++ - d.mu.Unlock() - } - return ctx.Err() -} - -// GetStats returns dispatcher statistics -func (d *Dispatcher) GetStats() DispatcherStats { - d.mu.RLock() - defer d.mu.RUnlock() - - return DispatcherStats{ - TotalSent: d.totalSent, - Failed: d.failed, - Dropped: d.dropped, } -} - -// DispatcherStats contains statistics for the dispatcher -type DispatcherStats struct { - // TotalSent is the number of admitted sends that completed with a nil error - // (succeeded). - TotalSent uint64 - // Failed is the number of admitted open-loop sends that completed with a - // non-nil error: counted, not lost (see package doc, Conservation: - // admitted = succeeded + failed). Always 0 in closed-loop mode. - Failed uint64 - // Dropped is the number of open-loop txs shed because in-flight was - // saturated at their scheduled instant. Always 0 in closed-loop mode. - Dropped uint64 + return nil } diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 60f903c..8f14c40 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -63,6 +63,7 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * } // Send implements TxSender interface - calculates shard ID and routes to appropriate worker +// TODO: make it respect Settings.MaxInFlight func (s *ShardedSender) Send(ctx context.Context, tx *types.LoadTx) error { return s.shards[tx.ShardID(len(s.shards))].Send(ctx, tx) } From 1aa66b6045938e8d6378af23079c6b0f6f552c61 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 17:20:49 +0200 Subject: [PATCH 12/21] fmt --- funder/funder.go | 2 +- generator/prewarm.go | 10 +++++----- main.go | 4 ++-- sender/dispatcher.go | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/funder/funder.go b/funder/funder.go index bbc2c08..523def9 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -88,7 +88,7 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, accounts []*types // batch keeps nonces ordered. Do not parallelize or set auth.Nonce. batch := fc.Batch() for start := 0; start < len(underfunded); start += batch { - end := min(start + batch, len(underfunded)) + end := min(start+batch, len(underfunded)) chunk := underfunded[start:end] values := make([]*big.Int, len(chunk)) total := new(big.Int) diff --git a/generator/prewarm.go b/generator/prewarm.go index b6c7cf8..34f54c6 100644 --- a/generator/prewarm.go +++ b/generator/prewarm.go @@ -10,8 +10,8 @@ import ( // PrewarmGenerator generates self-transfer transactions to prewarm account nonces type PrewarmGenerator struct { - accounts []*types.Account - evmScenario scenarios.TxGenerator + accounts []*types.Account + evmScenario scenarios.TxGenerator } // NewPrewarmGenerator creates a new prewarm generator using all account pools from the registry. @@ -23,14 +23,14 @@ func NewPrewarmGenerator(cfg *config.LoadConfig, accounts []*types.Account) *Pre deployer := types.NewAccount() evmScenario.Deploy(cfg, deployer) return &PrewarmGenerator{ - accounts: accounts, - evmScenario: evmScenario, + accounts: accounts, + evmScenario: evmScenario, } } // Generate generates self-transfer transactions until all known accounts are prewarmed. func (pg *PrewarmGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { - if len(pg.accounts)==0 { + if len(pg.accounts) == 0 { return nil, false } account := pg.accounts[0] diff --git a/main.go b/main.go index 6582cf1..8d29a29 100644 --- a/main.go +++ b/main.go @@ -310,7 +310,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { s.SpawnBgNamed("sender", func() error { return sharedSender.Run(ctx) }) snd = sharedSender log.Printf("✅ Connected to %d endpoints", len(cfg.Endpoints)) - + } // Set up prewarming if enabled @@ -371,7 +371,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { summary := stats.RunSummary{ArrivalModel: config.ArrivalModelClosedLoop} // Read AFTER service.Run returns: both workers and the tracker have joined, // so inflightAtShutdown is final and the conservation identity holds. - if inclusionTracker,ok := inclusion.Get(); ok { + if inclusionTracker, ok := inclusion.Get(); ok { incl := inclusionTracker.Summary() summary.InclusionTracked = true summary.Included = incl.Included diff --git a/sender/dispatcher.go b/sender/dispatcher.go index c1a3fea..81d976d 100644 --- a/sender/dispatcher.go +++ b/sender/dispatcher.go @@ -2,9 +2,9 @@ package sender import ( "context" + "github.com/sei-protocol/sei-load/generator" mrand "math/rand/v2" "time" - "github.com/sei-protocol/sei-load/generator" ) // Run begins the dispatcher's transaction generation and sending loop, using @@ -26,5 +26,5 @@ func Run(ctx context.Context, rng *mrand.Rand, gen generator.Generator, snd TxSe return err } } - return nil + return nil } From 3bbfad6cc2e34e1b7384cf5d73732bda5a477f96 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 17:27:04 +0200 Subject: [PATCH 13/21] removed scheduler --- sender/arrival_model_test.go | 23 ---- sender/scheduler.go | 157 ---------------------------- sender/scheduler_realworker_test.go | 35 +++---- stats/inclusion_tracker.go | 4 +- types/types_test.go | 14 +-- utils/service/parallel.go | 9 -- utils/service/start.go | 23 ---- 7 files changed, 22 insertions(+), 243 deletions(-) delete mode 100644 sender/arrival_model_test.go delete mode 100644 sender/scheduler.go delete mode 100644 utils/service/parallel.go delete mode 100644 utils/service/start.go diff --git a/sender/arrival_model_test.go b/sender/arrival_model_test.go deleted file mode 100644 index 9fb1934..0000000 --- a/sender/arrival_model_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package sender - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-load/config" -) - -// TestArrivalModelConstantsMatchConfig pins the two representations of the -// arrival model together: config.ArrivalModel* (string, the CLI/config wire -// values) and sender.Arrival* (typed, the dispatcher's internal model). main.go -// bridges them via string(dispatcher.ArrivalModel()) into the run summary, so a -// drift between the two would silently mislabel a run. They live in separate -// packages on purpose (the sender core stays config-free); this test is the -// cheap drift guard in lieu of coupling the packages to share one constant. -func TestArrivalModelConstantsMatchConfig(t *testing.T) { - require.Equal(t, config.ArrivalModelOpenLoop, string(ArrivalOpenLoop), - "open_loop value drifted between config and sender") - require.Equal(t, config.ArrivalModelClosedLoop, string(ArrivalClosedLoop), - "closed_loop value drifted between config and sender") -} diff --git a/sender/scheduler.go b/sender/scheduler.go deleted file mode 100644 index 658266c..0000000 --- a/sender/scheduler.go +++ /dev/null @@ -1,157 +0,0 @@ -package sender - -import ( - "context" - "log" - mrand "math/rand/v2" - "sync" - "sync/atomic" - "time" - - "golang.org/x/sync/semaphore" - "golang.org/x/time/rate" - - "github.com/sei-protocol/sei-load/generator" - "github.com/sei-protocol/sei-load/types" - "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/service" -) - -// openLoopScheduler issues tx i at t₀ + i/λ on an arrival clock decoupled from -// sender availability, bounding true in-flight with a semaphore and dropping -// (counting) overdue txs rather than throttling the clock. λ comes from the -// shared limiter (one rate authority). See the package doc for the open-loop -// arrival model: coordinated omission, drop-and-count, and the permit lifecycle. -type openLoopScheduler struct { - generator generator.Generator - sender TxSender - limiter *rate.Limiter - inflight *semaphore.Weighted - onSent func(tx *types.LoadTx, err error) - maxInFlight int - - // Written by Run; read after Run returns or concurrently via Dropped/Admitted. - // See package doc (Conservation) for scheduled = dropped + admitted. - dropped atomic.Uint64 - admitted atomic.Uint64 -} - -// minScheduleRate floors λ so a zero/negative limit can't divide-by-zero or -// yield a +Inf gap; the degenerate λ=Inf/TPS=0 case is rejected up front -// (config.Settings.Validate). -const minScheduleRate = 1e-9 - -// maxScheduleRate ceilings λ so a mid-run ramp toward rate.Inf can't collapse -// the gap to 0 (gap = 1s/λ truncates to 0 once λ exceeds ~1e9 = 1s in ns), -// which would stall nextSend and spin the scheduler. Startup validation only -// rejects λ=Inf at config time; this defends the ramper driving λ→Inf after the -// run starts. The ceiling is ~1e8 TPS — orders of magnitude above any realistic -// load — so it never affects a real run; at λ=maxScheduleRate the gap is 10ns>0. -const maxScheduleRate = 1e8 - -func newOpenLoopScheduler( - gen generator.Generator, - snd TxSender, - limiter *rate.Limiter, - maxInFlight int, - onSent func(tx *types.LoadTx, err error), -) *openLoopScheduler { - if maxInFlight < 1 { - maxInFlight = 1 - } - return &openLoopScheduler{ - generator: gen, - sender: snd, - limiter: limiter, - inflight: semaphore.NewWeighted(int64(maxInFlight)), - onSent: onSent, - maxInFlight: maxInFlight, - } -} - -// Dropped returns the number of ticks shed so far because in-flight was saturated. -func (s *openLoopScheduler) Dropped() uint64 { return s.dropped.Load() } - -// Admitted returns the admitted-tick count (took a permit and drew a tx), for -// the conservation invariant in tests/audit; mirrors Dropped. -func (s *openLoopScheduler) Admitted() uint64 { return s.admitted.Load() } - -// Run drives the arrival clock until ctx is canceled or the generator is -// exhausted, spawning each accepted tx as a send task bounded by the in-flight -// semaphore. See the package doc for the arrival model. -func (s *openLoopScheduler) Run(ctx context.Context, rng *mrand.Rand, scope service.Scope) error { - t0 := time.Now() - nextSend := t0 - var i uint64 - - for ctx.Err() == nil { - // Sample λ per step (honors a ramping limit; at fixed λ sums to t₀ + i/λ). - lambda := float64(s.limiter.Limit()) - if lambda < minScheduleRate { - lambda = minScheduleRate - } - if lambda > maxScheduleRate { - lambda = maxScheduleRate - } - gap := time.Duration(float64(time.Second) / lambda) - - // Sleep to the absolute instant (not "gap from now") to avoid drift. - if err := utils.SleepUntil(ctx, nextSend); err != nil { - return err - } - - // Snapshot the schedule; clock/index advance only when a tick resolves to - // a real arrival, so the terminal exhaust probe isn't counted (see doc). - intendedSendTime := nextSend - seqIndex := i - - // Admit before generating: a dropped tick must not consume a seeded - // generator draw (determinism). TryAcquire is non-blocking. - ok := s.inflight.TryAcquire(1) - if !ok { - s.dropped.Add(1) - nextSend = nextSend.Add(gap) - i++ - continue - } - - tx, ok := s.generator.Generate(rng) - if !ok { - // Generator drained: not an arrival — release the permit and stop. - s.inflight.Release(1) - log.Print("Scheduler: generator returned no more transactions") - return nil - } - - // Stamp while sole owner (LoadTx concurrency contract), then advance. - tx.IntendedSendTime = intendedSendTime - tx.SequenceIndex = seqIndex - s.admitted.Add(1) - nextSend = nextSend.Add(gap) - i++ - - // complete releases the permit and reports the result, exactly once: the - // worker invokes it via tx.OnComplete after the real send; the Once guards - // the enqueue-failure fallback below from racing the worker. - var once sync.Once - complete := func(err error) { - once.Do(func() { - s.inflight.Release(1) - if s.onSent != nil { - s.onSent(tx, err) - } - }) - } - tx.OnComplete = complete - - scope.Spawn(func() error { - // On enqueue failure the tx never reaches a worker; complete here so the - // permit isn't leaked. A send error must not tear down the campaign. - if err := s.sender.Send(ctx, tx); err != nil { - complete(err) - } - return nil - }) - } - return ctx.Err() -} diff --git a/sender/scheduler_realworker_test.go b/sender/scheduler_realworker_test.go index afd6935..f5a6bc0 100644 --- a/sender/scheduler_realworker_test.go +++ b/sender/scheduler_realworker_test.go @@ -20,7 +20,7 @@ import ( "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/types" testrng "github.com/sei-protocol/sei-load/utils/rng" - "github.com/sei-protocol/sei-load/utils/service" + "github.com/sei-protocol/sei-load/utils/scope" ) // This file is the production-path safety net for the open-loop in-flight bound. @@ -271,17 +271,15 @@ func TestRealSender_Conservation_OnRealSendPath(t *testing.T) { // test has observed quiescence and torn down deliberately. runCtx, runCancel := context.WithCancel(ctx) var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - _ = service.Run(runCtx, func(ctx context.Context, scope service.Scope) error { + wg.Go(func() { + _ = scope.Run(runCtx, func(ctx context.Context, scope scope.Scope) error { scope.SpawnBg(func() error { return client.Run(ctx) }) scope.SpawnBg(func() error { return sched.Run(ctx, testrng.NewSource(1).Rand("sender:realworker:test"), scope) }) // Main task: hold the scope open until the test signals teardown. <-ctx.Done() return nil }) - }() + }) // Assert ONLY at quiescence. All invariants are sampled together in one // predicate so we never read them mid-flight. Post-reorder, the generator is @@ -369,14 +367,12 @@ func TestRealSender_PermitReleasedBySender(t *testing.T) { defer cancel() var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - _ = service.Run(ctx, func(ctx context.Context, scope service.Scope) error { + wg.Go(func() { + _ = scope.Run(ctx, func(ctx context.Context, scope scope.Scope) error { scope.SpawnBg(func() error { return client.Run(ctx) }) return sched.Run(ctx, testrng.NewSource(1).Rand("sender:realworker:test"), scope) }) - }() + }) // Wait until exactly one send is genuinely in flight (parked in the handler). <-srv.arrived @@ -470,23 +466,18 @@ func TestDispatcher_PrewarmRateLimitedInOpenLoop(t *testing.T) { defer cancel() var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - _ = service.Run(ctx, func(ctx context.Context, scope service.Scope) error { + wg.Go(func() { + _ = scope.Run(ctx, func(ctx context.Context, scope scope.Scope) error { scope.SpawnBg(func() error { return client.Run(ctx) }) <-ctx.Done() return nil }) - }() - - limiter := rate.NewLimiter(rate.Limit(rps), 1) - d := NewDispatcher(newSignedTxGenerator(t, 0), client) - d.SetOpenLoop(limiter, 256) // sets d.limiter so Prewarm self-paces - d.SetPrewarmGenerator(newSignedTxGenerator(t, prewarmTxs)) + }) + prewarmGen := newSignedTxGenerator(t, prewarmTxs) + rng := testrng.NewSource(1).Rand("sender:realworker:test") start := time.Now() - require.NoError(t, d.Prewarm(ctx, testrng.NewSource(1).Rand("sender:realworker:test"))) + require.NoError(t, Run(ctx, rng, prewarmGen, client)) elapsed := time.Since(start) // Paced floor: (N-1) gaps at the limiter rate (burst=1 lets the first through diff --git a/stats/inclusion_tracker.go b/stats/inclusion_tracker.go index 0d4210f..b198ab1 100644 --- a/stats/inclusion_tracker.go +++ b/stats/inclusion_tracker.go @@ -15,7 +15,7 @@ import ( "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/service" + "github.com/sei-protocol/sei-load/utils/scope" ) // blockSource yields the tx hashes of a single block by number. Consumer-side @@ -138,7 +138,7 @@ func (t *InclusionTracker) Run(ctx context.Context, firstEndpoint string) error defer client.Close() t.source = ethBlockSource{client: client} } - return service.Run(ctx, func(ctx context.Context, s service.Scope) error { + return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { client, err := ethclient.Dial(wsEndpoint) if err != nil { return fmt.Errorf("inclusion tracker: connect WebSocket %s: %w", wsEndpoint, err) diff --git a/types/types_test.go b/types/types_test.go index e293fef..62d58b8 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -254,7 +254,7 @@ func TestCreateTxFromEthTx(t *testing.T) { // Create a test account and scenario account := NewAccount() - account.Nonce = 42 + account.Nonce.Store(42) receiver := common.HexToAddress("0x1234567890123456789012345678901234567890") scenario := &TxScenario{ Name: "TestScenario", @@ -265,7 +265,7 @@ func TestCreateTxFromEthTx(t *testing.T) { // Create a test transaction using DynamicFeeTx (EIP-1559) tx := types.NewTx(&types.DynamicFeeTx{ ChainID: big.NewInt(713714), // Sei testnet chain ID - Nonce: scenario.Sender.Nonce, + Nonce: scenario.Sender.Nonce.Load(), GasTipCap: big.NewInt(2000000000), // 2 Gwei tip GasFeeCap: big.NewInt(20000000000), // 20 Gwei max fee Gas: 21000, // Gas limit @@ -322,11 +322,11 @@ func TestLoadTxShardID(t *testing.T) { Receiver: common.Address{}, } - scenario.Sender.Nonce = uint64(i) + scenario.Sender.Nonce.Store(uint64(i)) // Create a simple transaction tx := types.NewTx(&types.DynamicFeeTx{ ChainID: big.NewInt(713714), // Sei testnet chain ID - Nonce: scenario.Sender.Nonce, + Nonce: scenario.Sender.Nonce.Load(), GasTipCap: big.NewInt(2000000000), // 2 Gwei tip GasFeeCap: big.NewInt(20000000000), // 20 Gwei max fee Gas: 21000, // Gas limit @@ -383,7 +383,7 @@ func TestLoadTxShardIDConsistency(t *testing.T) { tx := types.NewTx(&types.DynamicFeeTx{ ChainID: big.NewInt(713714), // Sei testnet chain ID - Nonce: scenario.Sender.Nonce, + Nonce: scenario.Sender.Nonce.Load(), GasTipCap: big.NewInt(2000000000), // 2 Gwei tip GasFeeCap: big.NewInt(20000000000), // 20 Gwei max fee Gas: 21000, // Gas limit @@ -409,7 +409,7 @@ func TestTxScenario(t *testing.T) { receiver := common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd") - account.Nonce = 123 + account.Nonce.Store(123) scenario := &TxScenario{ Name: "TestScenario", @@ -476,7 +476,7 @@ func BenchmarkCreateTxFromEthTx(b *testing.B) { tx := types.NewTx(&types.DynamicFeeTx{ ChainID: big.NewInt(713714), // Sei testnet chain ID - Nonce: scenario.Sender.Nonce, + Nonce: scenario.Sender.Nonce.Load(), GasTipCap: big.NewInt(2000000000), // 2 Gwei tip GasFeeCap: big.NewInt(20000000000), // 20 Gwei max fee Gas: 21000, // Gas limit diff --git a/utils/service/parallel.go b/utils/service/parallel.go deleted file mode 100644 index 14a7fcd..0000000 --- a/utils/service/parallel.go +++ /dev/null @@ -1,9 +0,0 @@ -package service - -import "github.com/sei-protocol/sei-load/utils/scope" - -type ParallelScope = scope.ParallelScope - -func Parallel(main func(ParallelScope) error) error { - return scope.Parallel(main) -} diff --git a/utils/service/start.go b/utils/service/start.go deleted file mode 100644 index 04312c3..0000000 --- a/utils/service/start.go +++ /dev/null @@ -1,23 +0,0 @@ -package service - -import ( - "context" - - "github.com/sei-protocol/sei-load/utils/scope" -) - -type Scope = scope.Scope - -type JoinHandle[R any] = scope.JoinHandle[R] - -func Spawn1[R any](s Scope, t func() (R, error)) JoinHandle[R] { - return scope.Spawn1(s, t) -} - -func Run(ctx context.Context, main func(context.Context, Scope) error) error { - return scope.Run(ctx, main) -} - -func Run1[R any](ctx context.Context, main func(context.Context, Scope) (R, error)) (R, error) { - return scope.Run1(ctx, main) -} From c3e58dada6ac229cf15eda1a262c15bc5bc9ae78 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 17:31:11 +0200 Subject: [PATCH 14/21] removed more stuff --- sender/scheduler_realworker_test.go | 499 ------------------------ sender/scheduler_test.go | 578 ---------------------------- types/types_test.go | 88 +++-- 3 files changed, 43 insertions(+), 1122 deletions(-) delete mode 100644 sender/scheduler_realworker_test.go delete mode 100644 sender/scheduler_test.go diff --git a/sender/scheduler_realworker_test.go b/sender/scheduler_realworker_test.go deleted file mode 100644 index f5a6bc0..0000000 --- a/sender/scheduler_realworker_test.go +++ /dev/null @@ -1,499 +0,0 @@ -package sender - -import ( - "context" - "encoding/json" - "io" - "math/big" - mrand "math/rand/v2" - "net/http" - "net/http/httptest" - "sync" - "sync/atomic" - "testing" - "time" - - ethtypes "github.com/ethereum/go-ethereum/core/types" - "github.com/stretchr/testify/require" - "golang.org/x/time/rate" - - "github.com/sei-protocol/sei-load/stats" - "github.com/sei-protocol/sei-load/types" - testrng "github.com/sei-protocol/sei-load/utils/rng" - "github.com/sei-protocol/sei-load/utils/scope" -) - -// This file is the production-path safety net for the open-loop in-flight bound. -// -// Every other scheduler test drives a FAKE TxSender that invokes tx.OnComplete -// itself, so the suite would stay green even if the real ethClient forgot the -// `if tx.OnComplete != nil { tx.OnComplete(err) }` line in runSender — the one -// load-bearing line that makes the maxInFlight semaphore bound true unacked -// sends rather than nothing (permits would never be released → leak/meaningless -// bound). The tests here wire the REAL ethClient (runSender → sendTx → -// the real ethclient → OnComplete) behind the scheduler and assert the permit -// is genuinely released by the sender on send completion. -// -// Harness: an httptest.Server speaking the minimal JSON-RPC the ethclient send -// path touches. SendTransaction issues exactly one eth_sendRawTransaction call -// per tx (verified against go-ethereum v1.16.1: HTTP dial makes no RPC call, and -// SendTransaction marshals the tx and calls eth_sendRawTransaction; no -// eth_chainId round-trip). This keeps the harness loopback-only and lets us -// exercise the real worker send path end to end with a controllable response — -// including a "block until released" mode for the maxInFlight=1 assertion. - -// jsonRPCReq is the subset of a JSON-RPC request we parse from the ethclient. -type jsonRPCReq struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Method string `json:"method"` -} - -// rpcServer is an httptest-backed JSON-RPC endpoint that answers -// eth_sendRawTransaction. It counts handled sends and can be put into a -// "block" mode where each send parks until explicitly released, so a test can -// hold a send in flight and observe the in-flight bound. -type rpcServer struct { - srv *httptest.Server - - entered atomic.Uint64 // eth_sendRawTransaction calls that entered the handler - handled atomic.Uint64 // eth_sendRawTransaction calls that returned a result - - // When blocking, every send waits on a fresh gate handed out via started so - // the test can release them one at a time. arrived is signaled when a send - // has entered the handler (so the test knows a send is genuinely in flight). - mu sync.Mutex - blocking bool - gates []chan struct{} // one per blocked send, in arrival order - arrived chan struct{} // buffered; one token per send that entered the handler -} - -func newRPCServer(t *testing.T) *rpcServer { - t.Helper() - s := &rpcServer{arrived: make(chan struct{}, 1024)} - s.srv = httptest.NewServer(http.HandlerFunc(s.handle)) - t.Cleanup(s.srv.Close) - return s -} - -func (s *rpcServer) url() string { return s.srv.URL } - -// setBlocking toggles the block-until-released mode. -func (s *rpcServer) setBlocking(b bool) { - s.mu.Lock() - s.blocking = b - s.mu.Unlock() -} - -// releaseOne unblocks the oldest parked send. Returns false if none is parked -// yet (caller should retry after observing an arrival). -func (s *rpcServer) releaseOne() bool { - s.mu.Lock() - defer s.mu.Unlock() - if len(s.gates) == 0 { - return false - } - gate := s.gates[0] - s.gates = s.gates[1:] - close(gate) - return true -} - -func (s *rpcServer) handle(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - var req jsonRPCReq - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if req.Method == "eth_sendRawTransaction" { - s.entered.Add(1) - s.mu.Lock() - blocking := s.blocking - var gate chan struct{} - if blocking { - gate = make(chan struct{}) - s.gates = append(s.gates, gate) - } - s.mu.Unlock() - - if blocking { - // Announce arrival, then park until released or the request ctx is - // canceled (campaign teardown). Parking here holds the real worker - // inside sendTransaction, so the scheduler's permit stays held. - s.arrived <- struct{}{} - select { - case <-gate: - case <-r.Context().Done(): - } - } - } - - id := req.ID - if len(id) == 0 { - id = json.RawMessage("0") - } - // A non-error result is enough; ethclient discards the value (nil out arg). - resp := struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Result string `json:"result"` - }{ - JSONRPC: "2.0", - ID: id, - Result: "0x0000000000000000000000000000000000000000000000000000000000000000", - } - if req.Method == "eth_sendRawTransaction" { - s.handled.Add(1) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(&resp) -} - -// signedTxGenerator yields real, signed DynamicFee transactions (the production -// EVMTransfer shape) so the real ethclient marshals and ships a valid raw tx to -// the JSON-RPC server. It is the generator.Generator the scheduler drives. -type signedTxGenerator struct { - mu sync.Mutex - remaining int - acct *types.Account - signer ethtypes.Signer - chainID *big.Int - issued int -} - -func newSignedTxGenerator(t *testing.T, n int) *signedTxGenerator { - t.Helper() - acct := types.NewAccount() - chainID := big.NewInt(1) - return &signedTxGenerator{ - remaining: n, - acct: acct, - signer: ethtypes.NewCancunSigner(chainID), - chainID: chainID, - } -} - -func (g *signedTxGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { - g.mu.Lock() - defer g.mu.Unlock() - if g.remaining == 0 { - return nil, false - } - g.remaining-- - g.issued++ - - to := g.acct.Address - inner := ðtypes.DynamicFeeTx{ - ChainID: g.chainID, - Nonce: g.acct.GetAndIncrementNonce(), - To: &to, - Value: big.NewInt(1), - Gas: 21000, - GasTipCap: big.NewInt(2_000_000_000), - GasFeeCap: big.NewInt(200_000_000_000), - } - signed, err := ethtypes.SignTx(ethtypes.NewTx(inner), g.signer, g.acct.PrivKey) - if err != nil { - // Generators have no error channel; a signing failure here is a test bug. - panic(err) - } - scenario := &types.TxScenario{Name: "realworker", Sender: g.acct, Receiver: to} - return types.CreateTxFromEthTx(signed, scenario), true -} - -func (g *signedTxGenerator) issuedCount() int { - g.mu.Lock() - defer g.mu.Unlock() - return g.issued -} - -// newRealSender builds the production ethClient against the given endpoint. It -// is the real -// TxSender the scheduler drives. -func newRealSender(endpoint string, tasks int) *ethClient { - return newEthClient(ðClientConfig{ - ChainID: "test", - ID: 0, - Endpoint: endpoint, - Tasks: tasks, - DryRun: false, - Debug: false, - Collector: stats.NewCollector(), - }) -} - -// TestRealSender_Conservation_OnRealSendPath asserts conservation -// (issued == completed + dropped) where `completed` is driven exclusively by the -// REAL sender invoking tx.OnComplete after sendTx returns — not by a fake. If -// runSender stopped calling OnComplete, completed would stall and -// this would fail. -func TestRealSender_Conservation_OnRealSendPath(t *testing.T) { - const txCount = 200 - srv := newRPCServer(t) - gen := newSignedTxGenerator(t, txCount) - client := newRealSender(srv.url(), 8) - - var completed, succeeded atomic.Uint64 - onSent := func(_ *types.LoadTx, err error) { - completed.Add(1) - if err == nil { - succeeded.Add(1) - } - } - - limiter := rate.NewLimiter(rate.Limit(2000), 1) - sched := newOpenLoopScheduler(gen, client, limiter, 256, onSent) - - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - - // Run the sender and scheduler in a scope whose teardown WE control via - // runCancel — not the scheduler's return. - // - // service.Run cancels the scope's context as soon as every MAIN task returns. - // If the scheduler were a main task, the instant it exhausts the generator and - // returns, service.Run would cancel the sender's context — aborting any send - // still in flight. A send whose 200 OK the server already counted (handled++) - // but whose client.SendTransaction had not yet returned would then fail with - // context-canceled: OnComplete fires with err != nil, so completed++ but NOT - // succeeded++. That is exactly the observed flake (handled=200, succeeded=199): - // not a sampling artifact but a teardown that races the last in-flight send. - // - // So the scheduler and sender are BACKGROUND tasks, and the lone MAIN task is a - // gate that blocks until the test calls runCancel(). The scope therefore stays - // alive — the sender keeps draining reqs and firing OnComplete — until the - // test has observed quiescence and torn down deliberately. - runCtx, runCancel := context.WithCancel(ctx) - var wg sync.WaitGroup - wg.Go(func() { - _ = scope.Run(runCtx, func(ctx context.Context, scope scope.Scope) error { - scope.SpawnBg(func() error { return client.Run(ctx) }) - scope.SpawnBg(func() error { return sched.Run(ctx, testrng.NewSource(1).Rand("sender:realworker:test"), scope) }) - // Main task: hold the scope open until the test signals teardown. - <-ctx.Done() - return nil - }) - }) - - // Assert ONLY at quiescence. All invariants are sampled together in one - // predicate so we never read them mid-flight. Post-reorder, the generator is - // drawn only on admitted ticks, so the conservation anchor is the scheduler's - // OWN counters, not the generator draw count: - // - // admitted: Admitted() == txCount (every draw was admitted — the - // generator is drained and dropped ticks consumed no draw; - // the precondition that makes the fixpoint below stable) - // conservation: completed == Admitted() (every admitted tx reached a - // terminal state via the real sender's OnComplete) - // equality: succeeded == handled (every server-handled send - // produced exactly one successful worker-driven completion) - // - // conservation and equality are transiently off WHILE a send is in flight: the - // server bumps `handled` when it RECEIVES eth_sendRawTransaction, but the sender - // bumps `succeeded` only AFTER SendTransaction returns and OnComplete fires — the - // instants differ by the server→worker-return window. Sampling any of them alone, - // or at different instants, can catch that window. Requiring all three together, - // only once they hold, observes the system after that window has drained. The - // counters are monotonic; once the generator is exhausted no new work is admitted, - // so once ALL THREE hold they stay held — that stable fixpoint is the quiescent - // point. (The deeper hazard the gate above fixes is teardown racing that same - // window; here we additionally refuse to read until the window is empty.) - // - // Driven by the real sender's OnComplete — a missing invoke leaves completed - // (and succeeded) short forever, so convergence never happens and the test - // fails on the Eventually deadline. CI is slow, so the window is generous; - // correctness depends on convergence, not on the deadline firing. - const total = uint64(txCount) - require.Eventually(t, func() bool { - admittedAll := sched.Admitted() == total - conserved := completed.Load() == sched.Admitted() - balanced := succeeded.Load() == srv.handled.Load() - return admittedAll && conserved && balanced - }, 10*time.Second, 2*time.Millisecond, - "never reached quiescence (want admitted=completed=%d, succeeded=handled)", total) - - // System is quiescent: the generator is drained, no send is in flight, every - // admitted tx is terminal, and every handled send has its OnComplete recorded. - // Only now tear down — the counters cannot move under us, so the assertions - // below re-read a frozen state, not a sampled one. - runCancel() - wg.Wait() - - require.Equal(t, total, sched.Admitted(), "every generator draw must be an admitted tx") - require.Equal(t, total, uint64(gen.issuedCount()), "the generator must be fully drained") - require.Positive(t, srv.handled.Load(), "the real RPC server must have handled sends") - require.Equal(t, sched.Admitted(), completed.Load(), - "every admitted tx must reach a terminal state via the sender's OnComplete") - require.Equal(t, succeeded.Load(), srv.handled.Load(), - "each successful completion must correspond to one eth_sendRawTransaction") -} - -// TestRealSender_PermitReleasedBySender is the teeth: with maxInFlight=1 and a -// single sender task, the RPC server blocks the first send. The real sender is -// parked inside sendTx, so it has NOT yet called tx.OnComplete and the -// single permit stays held — every subsequent arrival must drop. Releasing the -// blocked send lets the sender return from sendTx, fire OnComplete, and -// free the permit, so flow resumes. -// -// If someone deletes the `if tx.OnComplete != nil { tx.OnComplete(err) }` invoke -// in runSender, the permit is never released even after the send completes: the -// sender would never accept a second tx, so handled stays at 1 and the -// resume assertion fails. That is the falsification this test exists for. -func TestRealSender_PermitReleasedBySender(t *testing.T) { - srv := newRPCServer(t) - srv.setBlocking(true) - - // Plenty of arrivals so the scheduler keeps offering txs while the first is - // parked; the surplus must drop because the lone permit is held. - gen := newSignedTxGenerator(t, 1000) - // One task: a single runSender owns the only permit's lifecycle, so the - // permit can only be freed by that sender calling OnComplete. - client := newRealSender(srv.url(), 1) - - var completed atomic.Uint64 - onSent := func(_ *types.LoadTx, _ error) { completed.Add(1) } - - // Fast arrival clock so many txs are offered during the blocked window. - limiter := rate.NewLimiter(rate.Limit(5000), 1) // 0.2ms gap - sched := newOpenLoopScheduler(gen, client, limiter, 1, onSent) - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - var wg sync.WaitGroup - wg.Go(func() { - _ = scope.Run(ctx, func(ctx context.Context, scope scope.Scope) error { - scope.SpawnBg(func() error { return client.Run(ctx) }) - return sched.Run(ctx, testrng.NewSource(1).Rand("sender:realworker:test"), scope) - }) - }) - - // Wait until exactly one send is genuinely in flight (parked in the handler). - <-srv.arrived - - // While that send is parked, the sender has not fired OnComplete, so the lone - // permit is held. Give the fast scheduler time to offer (and drop) a slew of - // arrivals, then assert the bound held: exactly one send in flight, none - // completed yet, and the rest dropped. - require.Eventually(t, func() bool { - return sched.Dropped() > 10 - }, 2*time.Second, 2*time.Millisecond, - "arrivals past the single held permit must drop while the send is in flight") - - require.Equal(t, uint64(0), completed.Load(), - "no completion may be reported while the only send is still parked") - require.Equal(t, uint64(1), srv.entered.Load(), - "exactly one send may be in flight under maxInFlight=1") - require.Equal(t, uint64(0), srv.handled.Load(), - "the parked send has not returned a result yet, so the permit is still held") - - // Release the blocked send. The real sender now returns from sendTx - // and MUST invoke tx.OnComplete to free the permit. If it does not (the bug), - // no further send is ever admitted and handled stays at 1 forever. - require.True(t, srv.releaseOne(), "one send must be parked and releasable") - - // Switch off blocking so resumed sends complete immediately, and prove flow - // resumes: more than one send is now handled, which is only possible if the - // permit from the first send was released by the worker's OnComplete. - srv.setBlocking(false) - // Drain any further parked sends that arrived between release and unblocking. - go func() { - for ctx.Err() == nil { - if !srv.releaseOne() { - select { - case <-ctx.Done(): - return - case <-time.After(time.Millisecond): - } - } - } - }() - - require.Eventually(t, func() bool { - return srv.handled.Load() > 1 && completed.Load() > 1 - }, 3*time.Second, 2*time.Millisecond, - "flow must resume after the sender releases the permit via OnComplete "+ - "(handled=%d completed=%d)", srv.handled.Load(), completed.Load()) - - // Flow resumed: at least one further send completed after the release, so the - // worker really did free the permit. Record the post-resume state before tear - // down (strict end-to-end conservation is covered by the conservation test; - // here cancel may leave a single tx mid-flight, so we only bound leaks). - require.Greater(t, completed.Load(), uint64(1), "resumed sends must complete") - - cancel() - wg.Wait() - - // No leak past the one tx that may be mid-flight at cancel. Post-reorder, the - // generator is drawn only on admitted ticks, so every draw is an admitted tx: - // generator-draw count must equal Admitted() exactly (dropped ticks consumed - // no draw — the determinism property, on the real worker path). - admitted := sched.Admitted() - require.Equal(t, admitted, uint64(gen.issuedCount()), - "every generator draw must be an admitted tx; dropped ticks consume no draw") - - // Each admitted tx completes exactly once via the sender's OnComplete, so - // completed must equal Admitted() — minus at most the single tx left mid-flight - // when cancel raced its in-flight send. - require.LessOrEqual(t, completed.Load(), admitted, "no admitted tx may complete more than once") - require.GreaterOrEqual(t, completed.Load()+1, admitted, - "at most one admitted tx may be unaccounted at cancel (admitted=%d completed=%d dropped=%d)", - admitted, completed.Load(), sched.Dropped()) -} - -// TestDispatcher_PrewarmRateLimitedInOpenLoop guards the prewarm-flood -// regression: in open-loop the sender loop is ungated, but -// the scheduler paces only the MAIN load. Prewarm runs first over those same -// ungated senders, so it must pace itself off the shared limiter or it floods -// the SUT. With workers wired exactly as in open-loop, a low limit, and many -// more prewarm txs than the worker pool could absorb instantly, an unpaced -// prewarm would drain in well under the limiter's minimum span. We assert the -// run took at least the paced floor — i.e. the limiter actually gated prewarm — -// and that every prewarm tx still reached the RPC server (no drops on prewarm). -func TestDispatcher_PrewarmRateLimitedInOpenLoop(t *testing.T) { - srv := newRPCServer(t) - const prewarmTxs = 40 - const rps = 200.0 // limiter: 200 tx/s → unpaced 40 txs is near-instant - - client := newRealSender(srv.url(), 8) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - var wg sync.WaitGroup - wg.Go(func() { - _ = scope.Run(ctx, func(ctx context.Context, scope scope.Scope) error { - scope.SpawnBg(func() error { return client.Run(ctx) }) - <-ctx.Done() - return nil - }) - }) - - prewarmGen := newSignedTxGenerator(t, prewarmTxs) - rng := testrng.NewSource(1).Rand("sender:realworker:test") - start := time.Now() - require.NoError(t, Run(ctx, rng, prewarmGen, client)) - elapsed := time.Since(start) - - // Paced floor: (N-1) gaps at the limiter rate (burst=1 lets the first through - // immediately). Use half as a generous lower bound to absorb scheduling slop - // while still excluding the unpaced (near-zero) case decisively. - pacedFloor := time.Duration(float64(prewarmTxs-1) / rps * float64(time.Second)) - require.Greater(t, elapsed, pacedFloor/2, - "prewarm must be limiter-paced in open-loop, not flooded (elapsed=%s floor=%s)", - elapsed, pacedFloor) - - require.Eventually(t, func() bool { - return srv.handled.Load() == uint64(prewarmTxs) - }, 2*time.Second, 2*time.Millisecond, - "every prewarm tx must reach the SUT (handled=%d want=%d)", - srv.handled.Load(), prewarmTxs) - - cancel() - wg.Wait() -} diff --git a/sender/scheduler_test.go b/sender/scheduler_test.go deleted file mode 100644 index 65fa3fb..0000000 --- a/sender/scheduler_test.go +++ /dev/null @@ -1,578 +0,0 @@ -package sender - -import ( - "context" - "errors" - mrand "math/rand/v2" - "slices" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" - "golang.org/x/time/rate" - - "github.com/sei-protocol/sei-load/types" - testrng "github.com/sei-protocol/sei-load/utils/rng" - "github.com/sei-protocol/sei-load/utils/service" -) - -// fakeGenerator hands out blank LoadTx values until count is exhausted. It -// records the IntendedSendTime/SequenceIndex the scheduler stamped, since those -// are the open-loop schedule under test. -type fakeGenerator struct { - mu sync.Mutex - remaining int - issued []*types.LoadTx -} - -func newFakeGenerator(n int) *fakeGenerator { return &fakeGenerator{remaining: n} } - -func (g *fakeGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { - g.mu.Lock() - defer g.mu.Unlock() - if g.remaining == 0 { - return nil, false - } - g.remaining-- - tx := &types.LoadTx{Scenario: &types.TxScenario{Name: "fake"}} - g.issued = append(g.issued, tx) - return tx, true -} - -func (g *fakeGenerator) issuedTxs() []*types.LoadTx { - g.mu.Lock() - defer g.mu.Unlock() - out := make([]*types.LoadTx, len(g.issued)) - copy(out, g.issued) - return out -} - -// seededGenerator stands in for a PLT-456 seeded generator: each Generate() -// draw is the next element of a deterministic stream, recorded here in draw -// order via a strictly increasing draw index stamped on the LoadTx. The point -// the determinism guard pins: a draw is consumed only when Generate() is -// called, so if the scheduler advances the stream on a dropped tick the draw -// indices admitted under saturation would be non-contiguous (gaps where a -// dropped tick stole a draw). Admitted draws forming the prefix 0..N-1 with no -// gaps proves dropped slots consume zero seeded draws. -type seededGenerator struct { - mu sync.Mutex - remaining int - drawIndex map[*types.LoadTx]uint64 // tx → its draw position in the stream - nextDraw uint64 -} - -func newSeededGenerator(n int) *seededGenerator { - return &seededGenerator{remaining: n, drawIndex: map[*types.LoadTx]uint64{}} -} - -func (g *seededGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { - g.mu.Lock() - defer g.mu.Unlock() - if g.remaining == 0 { - return nil, false - } - g.remaining-- - tx := &types.LoadTx{Scenario: &types.TxScenario{Name: "seeded"}} - g.drawIndex[tx] = g.nextDraw - g.nextDraw++ - return tx, true -} - -// draw returns the stream position at which tx was produced by Generate(). -func (g *seededGenerator) draw(tx *types.LoadTx) uint64 { - g.mu.Lock() - defer g.mu.Unlock() - return g.drawIndex[tx] -} - -// asyncFakeSender models the production ShardedSender's send semantics: Send -// returns when the tx lands in a buffered channel (enqueue-and-return), NOT when -// the network send completes. Background workers dequeue and, after an optional -// per-send delay (a slow SUT), invoke tx.OnComplete to release the scheduler's -// in-flight permit. This is what exercises the HONEST in-flight bound (B2): a -// synchronous sender that blocks in Send would hide that the permit must be tied -// to real completion, not to enqueue. -type asyncFakeSender struct { - ch chan *types.LoadTx - delay time.Duration - sent atomic.Uint64 // incremented when a send actually completes -} - -// newAsyncFakeSender starts `workers` background senders draining a buffer of -// `buffer` slots. Mirrors a worker pool behind a bounded channel. -func newAsyncFakeSender(ctx context.Context, buffer, workers int, delay time.Duration) *asyncFakeSender { - s := &asyncFakeSender{ch: make(chan *types.LoadTx, buffer), delay: delay} - if workers < 1 { - workers = 1 - } - for range workers { - go s.drain(ctx) - } - return s -} - -func (s *asyncFakeSender) drain(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case tx := <-s.ch: - if s.delay > 0 { - t := time.NewTimer(s.delay) - select { - case <-t.C: - case <-ctx.Done(): - t.Stop() - } - } - s.sent.Add(1) - if tx.OnComplete != nil { - tx.OnComplete(nil) - } - } - } -} - -// Send enqueues without blocking on completion, returning at enqueue. If the -// buffer is full it blocks on the channel until a slot frees or ctx is done — -// like utils.Send in the real worker. The scheduler must never see this block -// throttle its clock because admission is gated by the in-flight permit upstream. -func (s *asyncFakeSender) Send(ctx context.Context, tx *types.LoadTx) error { - select { - case s.ch <- tx: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -// runScheduler drives the scheduler in its own scope until the context expires, -// returning the scheduler so the caller can read Dropped(). -func runScheduler(ctx context.Context, rng *mrand.Rand, sched *openLoopScheduler) { - _ = service.Run(ctx, func(ctx context.Context, s service.Scope) error { - return sched.Run(ctx, rng, s) - }) -} - -// TestOpenLoopSchedule_TracksT0PlusIOverLambda is the core Done-criterion test: -// at a fixed λ against a fast sender, the IntendedSendTime stamped on tx i must -// track t₀ + i/λ within tolerance, independent of completion. -func TestOpenLoopSchedule_TracksT0PlusIOverLambda(t *testing.T) { - const lambda = 200.0 // tx/s → 5ms gap - gen := newFakeGenerator(40) - - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - snd := newAsyncFakeSender(ctx, 1024, 8, 0) - limiter := rate.NewLimiter(rate.Limit(lambda), 1) - sched := newOpenLoopScheduler(gen, snd, limiter, 1024, nil) - - start := time.Now() - runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) - - issued := gen.issuedTxs() - require.GreaterOrEqual(t, len(issued), 30, "scheduler should issue most txs within the window") - - gap := time.Second / time.Duration(lambda) - // t₀ is the scheduler's internal start; bound it to [start, start+gap]. - t0 := issued[0].IntendedSendTime - require.WithinDuration(t, start, t0, gap, "t₀ must be the campaign start") - - const tol = 2 * time.Millisecond - for i, tx := range issued { - require.Equal(t, uint64(i), tx.SequenceIndex, "sequence index must be monotonic from 0") - want := t0.Add(time.Duration(i) * gap) - require.WithinDuration(t, want, tx.IntendedSendTime, tol, - "tx %d IntendedSendTime must track t₀ + i/λ", i) - } -} - -// TestOpenLoopSchedule_NotThrottledBySlowSender proves the arrival clock is not -// dragged by a slow SUT: with a sender far slower than the in-flight bound can -// absorb, the schedule must still advance at λ and the overrun must be dropped, -// not absorbed by blocking. -// -// It uses an ASYNC sender (Send returns at enqueue) so the drop count reflects -// the HONEST in-flight bound (B2): the permit is held until each send actually -// completes, so a slow SUT saturates maxInFlight and forces genuine load-shed — -// not buffer geometry. A synchronous sender would have masked this. -func TestOpenLoopSchedule_NotThrottledBySlowSender(t *testing.T) { - const lambda = 500.0 // 2ms gap - gen := newFakeGenerator(200) - - ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) - defer cancel() - - // Each send completes after 100ms; with maxInFlight=4 and only 4 draining - // workers the senders can sustain only ~40 tx/s, an order of magnitude under - // λ → most txs must be dropped. The buffer is deliberately small: with the - // honest bound the in-flight permit (not the buffer) is the gate. - const maxInFlight = 4 - snd := newAsyncFakeSender(ctx, maxInFlight, maxInFlight, 100*time.Millisecond) - limiter := rate.NewLimiter(rate.Limit(lambda), 1) - sched := newOpenLoopScheduler(gen, snd, limiter, maxInFlight, nil) - - start := time.Now() - runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) - - admittedTxs := gen.issuedTxs() - gap := time.Second / time.Duration(lambda) - - // The clock must have kept advancing at λ despite the slow sender: the - // number of SCHEDULED ARRIVALS (admitted + dropped) — not generator draws — - // must have walked far past what the senders could absorb. Generate() now - // runs only on admitted ticks (post-reorder), so generated count is small; - // the never-throttled-clock property lives in the scheduled-arrival count. - scheduled := sched.Admitted() + sched.Dropped() - require.GreaterOrEqual(t, scheduled, uint64(100), - "arrival clock must not be throttled by the slow sender (scheduled=%d)", scheduled) - - // Schedule accuracy still holds for each admitted tx, keyed on its - // SequenceIndex (the arrival-tick index i): IntendedSendTime ≈ t₀ + i/λ. - // Admitted txs have NON-CONTIGUOUS SequenceIndex under drops, so the schedule - // must be checked against tx.SequenceIndex, never the slice position. - require.Positive(t, len(admittedTxs), "some txs must have been admitted") - // Recover the scheduler's internal t₀ from the first admitted tx and its - // arrival index: t₀ = IntendedSendTime − SequenceIndex·gap. Bound it to the - // test's observed start window, then assert every admitted tx sits on the - // t₀ + i/λ grid at its own SequenceIndex. - first := admittedTxs[0] - t0 := first.IntendedSendTime.Add(-time.Duration(first.SequenceIndex) * gap) - require.WithinDuration(t, start, t0, gap, "recovered t₀ must be the campaign start") - const tol = 3 * time.Millisecond - for _, tx := range admittedTxs { - want := t0.Add(time.Duration(tx.SequenceIndex) * gap) - require.WithinDuration(t, want, tx.IntendedSendTime, tol, - "admitted tx (seq %d) schedule must hold under a slow sender", tx.SequenceIndex) - } - - // Overrun is dropped-and-counted, not blocked on. With ~150 scheduled arrivals - // in 300ms and senders able to complete only ~a dozen, the vast majority drop. - require.Positive(t, sched.Dropped(), "overrun must be counted as dropped") - require.Greater(t, sched.Dropped(), sched.Admitted(), - "a slow SUT must shed most of the load through the in-flight bound") -} - -// gatedSender enqueues instantly but holds completion until release is called, -// letting a test observe the window where a tx is enqueued-but-not-completed. -type gatedSender struct { - enqueued atomic.Uint64 - mu sync.Mutex - pending []*types.LoadTx -} - -func (s *gatedSender) Send(_ context.Context, tx *types.LoadTx) error { - s.mu.Lock() - s.pending = append(s.pending, tx) - s.mu.Unlock() - s.enqueued.Add(1) - return nil // returns at enqueue, like the production worker -} - -// completeAll fires OnComplete for every tx enqueued so far, releasing permits. -func (s *gatedSender) completeAll() { - s.mu.Lock() - pending := s.pending - s.pending = nil - s.mu.Unlock() - for _, tx := range pending { - if tx.OnComplete != nil { - tx.OnComplete(nil) - } - } -} - -// TestOpenLoopSchedule_PermitHeldUntilCompletion is the B2 guard: the in-flight -// permit must be tied to real send completion, not to enqueue. With maxInFlight=1 -// and a sender that enqueues instantly but never completes, the first tx takes -// the only permit and holds it; every subsequent tx must drop. If the permit -// released at enqueue (the masked bug), the sender would have enqueued many. -func TestOpenLoopSchedule_PermitHeldUntilCompletion(t *testing.T) { - gen := newFakeGenerator(100) - snd := &gatedSender{} - limiter := rate.NewLimiter(rate.Limit(1000), 1) // 1ms gap → many arrivals - sched := newOpenLoopScheduler(gen, snd, limiter, 1, nil) - - ctx, cancel := context.WithTimeout(t.Context(), 120*time.Millisecond) - defer cancel() - runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) - - // Exactly one tx held the single permit through the whole run (never - // completed), so the sender saw exactly one enqueue and everything else - // dropped. Enqueue-time release would have let many through. - require.Equal(t, uint64(1), snd.enqueued.Load(), - "permit must be held until completion: only one tx may be in flight") - require.Positive(t, sched.Dropped(), "arrivals past the held permit must drop") - - // Release the held permit; conservation still holds (issued == sent+dropped - // is checked elsewhere). Drain to avoid a leaked OnComplete at teardown. - snd.completeAll() -} - -// flakyAsyncSender drains like asyncFakeSender but completes every failEvery-th -// send (1-indexed) with an error, so a tx can be ADMITTED, enqueued, and then -// fail in the send — the path the failed-send accounting must capture. A failed -// send must be counted as failed (not lost and not dropped), so the conservation -// invariant becomes issued == dropped + succeeded + failed. -type flakyAsyncSender struct { - ch chan *types.LoadTx - failEvery uint64 - seen atomic.Uint64 -} - -func newFlakyAsyncSender(ctx context.Context, buffer, workers int, failEvery uint64) *flakyAsyncSender { - s := &flakyAsyncSender{ch: make(chan *types.LoadTx, buffer), failEvery: failEvery} - if workers < 1 { - workers = 1 - } - for range workers { - go s.drain(ctx) - } - return s -} - -func (s *flakyAsyncSender) drain(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case tx := <-s.ch: - n := s.seen.Add(1) - var err error - if s.failEvery > 0 && n%s.failEvery == 0 { - err = errors.New("injected send failure") - } - if tx.OnComplete != nil { - tx.OnComplete(err) - } - } - } -} - -func (s *flakyAsyncSender) Send(ctx context.Context, tx *types.LoadTx) error { - select { - case s.ch <- tx: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -// TestOpenLoopSchedule_Conservation checks the two-level accounting invariant -// (see package doc, Conservation) under injected send failures: -// -// scheduled == dropped + admitted (every tick has one arrival outcome) -// admitted == succeeded + failed (every admitted tx has one send outcome) -// -// scheduled is the scheduler's own arrival count (Admitted + Dropped), NOT the -// generator's issued count: after admit-before-generate a dropped tick never -// calls Generate, so len(issuedTxs) tracks admitted draws, not scheduled ticks. -// A failed send must land in failed, never be silently lost. The dispatcher's -// onSent is the accountant under test (via a stand-in mirroring its split). -func TestOpenLoopSchedule_Conservation(t *testing.T) { - gen := newFakeGenerator(300) - // Generous capacity so most txs complete; a few may drop on brief bursts. - limiter := rate.NewLimiter(rate.Limit(1000), 1) - - var succeeded, failed atomic.Uint64 - onSent := func(_ *types.LoadTx, err error) { - if err != nil { - failed.Add(1) - return - } - succeeded.Add(1) - } - - ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) - defer cancel() - // Fail every 5th send so failed > 0 and the invariant must absorb it. - snd := newFlakyAsyncSender(ctx, 256, 16, 5) - sched := newOpenLoopScheduler(gen, snd, limiter, 256, onSent) - runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) - - admitted := sched.Admitted() - require.Positive(t, admitted) - // scheduled == dropped + admitted: a dropped tick consumes no Generate draw, - // so the generator's issued count tracks admitted ticks, not scheduled ticks. - require.Equal(t, admitted, uint64(len(gen.issuedTxs())), - "each admitted tick must draw exactly one tx (admit-before-generate)") - - // Allow the in-flight sends spawned just before deadline to settle, then - // assert admitted == succeeded + failed (the send-outcome partition). - require.Eventually(t, func() bool { - return succeeded.Load()+failed.Load() == sched.Admitted() - }, time.Second, 5*time.Millisecond, - "every admitted tx must be succeeded or failed exactly once "+ - "(admitted=%d succeeded=%d failed=%d)", - sched.Admitted(), succeeded.Load(), failed.Load()) - require.Positive(t, failed.Load(), - "injected failures must be counted as failed, not lost or dropped") -} - -// TestOpenLoopSchedule_DroppedSlotsConsumeNoDraws is the determinism guard for -// the PLT-456 × PLT-458 interaction: under saturation the scheduler must admit a -// deterministic PREFIX of the seeded stream — a dropped tick consumes zero -// generator draws (and zero signing CPU), so the same seed yields the same -// admitted multiset no matter how many ticks the SUT speed forced to drop. -// -// The falsification: if Generate() were called before TryAcquire (the original -// bug), every dropped tick would still advance the seeded stream, so the admitted -// txs' draw indices would have gaps — admitted draw k+1 would jump past the draws -// the dropped ticks consumed. Here we drive a saturated sender (maxInFlight=1, -// completion gated) so the great majority of ticks drop, then assert the admitted -// txs' draw indices are exactly 0,1,2,… contiguous with no gaps. -func TestOpenLoopSchedule_DroppedSlotsConsumeNoDraws(t *testing.T) { - gen := newSeededGenerator(2000) - snd := &gatedSender{} - limiter := rate.NewLimiter(rate.Limit(5000), 1) // 0.2ms gap → many ticks - // maxInFlight=1 with a sender that never auto-completes: the first admitted - // tx holds the only permit, so every later tick drops until we release. - var admitted []uint64 - var mu sync.Mutex - onSent := func(tx *types.LoadTx, err error) { - require.NoError(t, err) - mu.Lock() - admitted = append(admitted, gen.draw(tx)) - mu.Unlock() - } - sched := newOpenLoopScheduler(gen, snd, limiter, 1, onSent) - - ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) - defer cancel() - - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) - }() - - // Periodically release the single held permit so flow inches forward one - // admitted tx at a time while the fast clock drops the rest. This guarantees - // many drops interleaved with a handful of admits — the regime that would - // expose any draw consumed by a dropped tick. - relCtx, relCancel := context.WithCancel(ctx) - defer relCancel() - go func() { - for relCtx.Err() == nil { - snd.completeAll() - select { - case <-relCtx.Done(): - case <-time.After(2 * time.Millisecond): - } - } - }() - - wg.Wait() - relCancel() - snd.completeAll() // drain any final held permit's OnComplete - - mu.Lock() - got := append([]uint64(nil), admitted...) - mu.Unlock() - - require.Positive(t, sched.Dropped(), "the saturated sender must force drops") - require.Positive(t, len(got), "some txs must have been admitted") - require.Greater(t, sched.Dropped(), uint64(len(got)), - "saturation must drop far more ticks than it admits") - - // The admitted draws must be the contiguous prefix 0,1,2,…,N-1: each admitted - // tx consumed exactly the next stream draw, and no dropped tick consumed any. - // Sort first: with the permit released just before onSent fires, two adjacent - // completions can be recorded out of order, but the SET must still be gapless. - slices.Sort(got) - for k, draw := range got { - require.Equal(t, uint64(k), draw, - "admitted draw set must be the gapless prefix; slot %d is draw %d (a dropped tick must consume no draw)", - k, draw) - } -} - -// TestOpenLoopSchedule_HonorsRampedLambda verifies the schedule responds to a -// λ change applied via the shared limiter (the ramper's rate authority): after -// SetLimit, the inter-arrival gap tracks the new λ. -func TestOpenLoopSchedule_HonorsRampedLambda(t *testing.T) { - gen := newFakeGenerator(1000) - ctx, cancel := context.WithTimeout(t.Context(), 600*time.Millisecond) - defer cancel() - snd := newAsyncFakeSender(ctx, 1024, 8, 0) - // Start slow so the first gaps are large and easy to distinguish. - limiter := rate.NewLimiter(rate.Limit(50), 1) // 20ms gap - sched := newOpenLoopScheduler(gen, snd, limiter, 1024, nil) - - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) - }() - - // Let it run at 50 tps, then ramp to 500 tps and let it run more. - time.Sleep(200 * time.Millisecond) - limiter.SetLimit(rate.Limit(500)) // 2ms gap - wg.Wait() - - issued := gen.issuedTxs() - require.GreaterOrEqual(t, len(issued), 2, "scheduler must issue txs") - - // The min gap observed in the back half must reflect the faster λ: with a - // 2ms target the later gaps are far under the initial 20ms gap. - minGap := time.Hour - for i := 1; i < len(issued); i++ { - g := issued[i].IntendedSendTime.Sub(issued[i-1].IntendedSendTime) - if g < minGap { - minGap = g - } - } - require.Less(t, minGap, 10*time.Millisecond, - "ramped-up λ must shrink the inter-arrival gap below the initial 20ms") -} - -// TestOpenLoopSchedule_ClampsRunawayLambda guards the maxScheduleRate ceiling: -// if the ramper drives the limiter to rate.Inf mid-run, the gap (1s/λ) would -// truncate to 0, nextSend would stop advancing, and the scheduler would spin. -// The clamp keeps gap > 0, so IntendedSendTime stays strictly increasing across -// the issued stream. -func TestOpenLoopSchedule_ClampsRunawayLambda(t *testing.T) { - gen := newFakeGenerator(2000) - ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) - defer cancel() - snd := newAsyncFakeSender(ctx, 4096, 8, 0) - limiter := rate.NewLimiter(rate.Inf, 1) // runaway λ from the start - sched := newOpenLoopScheduler(gen, snd, limiter, 4096, nil) - - runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) - - issued := gen.issuedTxs() - require.GreaterOrEqual(t, len(issued), 2, "scheduler must keep issuing under Inf λ") - for i := 1; i < len(issued); i++ { - require.True(t, issued[i].IntendedSendTime.After(issued[i-1].IntendedSendTime), - "clamp must keep IntendedSendTime advancing (gap>0) under rate.Inf, tick %d", i) - } -} - -// TestOpenLoopSchedule_StampsBeforeHandoff guards the LoadTx concurrency -// contract: the scheduler stamps IntendedSendTime and SequenceIndex before the -// send task can touch the tx. Run under -race to catch a regression. -func TestOpenLoopSchedule_StampsBeforeHandoff(t *testing.T) { - gen := newFakeGenerator(50) - ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) - defer cancel() - snd := newAsyncFakeSender(ctx, 64, 8, 0) - limiter := rate.NewLimiter(rate.Limit(1000), 1) - - var checked atomic.Uint64 - onSent := func(tx *types.LoadTx, err error) { - require.NoError(t, err) - require.False(t, tx.IntendedSendTime.IsZero(), "schedule must be stamped before send") - checked.Add(1) - } - sched := newOpenLoopScheduler(gen, snd, limiter, 64, onSent) - - runScheduler(ctx, testrng.NewSource(1).Rand("sender:scheduler:test"), sched) - - require.Positive(t, checked.Load(), "onSent must observe stamped txs") -} diff --git a/types/types_test.go b/types/types_test.go index 62d58b8..73781a9 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -1,7 +1,6 @@ package types import ( - "crypto/ecdsa" "math/big" "sync" "testing" @@ -10,9 +9,9 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-load/utils/rng" + "github.com/sei-protocol/sei-load/utils/require" ) func TestNewAccount(t *testing.T) { @@ -20,29 +19,28 @@ func TestNewAccount(t *testing.T) { require.NotNil(t, account) // Verify account has valid address and private key - assert.NotEqual(t, common.Address{}, account.Address) - assert.NotNil(t, account.PrivKey) - assert.IsType(t, &ecdsa.PrivateKey{}, account.PrivKey) + require.NotEqual(t, common.Address{}, account.Address) + require.NotNil(t, account.PrivKey) // Verify address matches private key expectedAddress := crypto.PubkeyToAddress(account.PrivKey.PublicKey) - assert.Equal(t, expectedAddress, account.Address) + require.Equal(t, expectedAddress, account.Address) // Verify initial nonce is 0 - assert.Equal(t, uint64(0), account.Nonce) + require.Equal(t, uint64(0), account.Nonce.Load()) } func TestAccountNonceManagement(t *testing.T) { account := NewAccount() // Test sequential nonce increments - for i := uint64(0); i < 10; i++ { + for i := range uint64(10) { nonce := account.GetAndIncrementNonce() - assert.Equal(t, i, nonce) + require.Equal(t, i, nonce) } // Verify final nonce value - assert.Equal(t, uint64(10), account.Nonce) + require.Equal(t, 10, account.Nonce.Load()) } func TestAccountNonceConcurrency(t *testing.T) { @@ -71,16 +69,16 @@ func TestAccountNonceConcurrency(t *testing.T) { // Verify all nonces are unique and in expected range nonceSet := make(map[uint64]bool) for _, nonce := range nonces { - assert.False(t, nonceSet[nonce], "Duplicate nonce found: %d", nonce) + require.False(t, nonceSet[nonce], "Duplicate nonce found: %d", nonce) nonceSet[nonce] = true - assert.Less(t, nonce, uint64(numGoroutines*noncesPerGoroutine)) + require.Less(t, nonce, uint64(numGoroutines*noncesPerGoroutine)) } // Verify we got exactly the expected number of unique nonces - assert.Len(t, nonceSet, numGoroutines*noncesPerGoroutine) + require.Len(t, nonceSet, numGoroutines*noncesPerGoroutine) // Verify final nonce value - assert.Equal(t, uint64(numGoroutines*noncesPerGoroutine), account.Nonce) + require.Equal(t, uint64(numGoroutines*noncesPerGoroutine), account.Nonce.Load()) } func TestGenerateAccounts(t *testing.T) { @@ -97,23 +95,23 @@ func TestGenerateAccounts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { accounts := GenerateAccounts(tt.count) - assert.Len(t, accounts, tt.count) + require.Len(t, accounts, tt.count) // Verify all accounts are unique and valid addressSet := make(map[common.Address]bool) for i, account := range accounts { - assert.NotNil(t, account, "Account %d is nil", i) - assert.NotEqual(t, common.Address{}, account.Address, "Account %d has zero address", i) - assert.NotNil(t, account.PrivKey, "Account %d has nil private key", i) - assert.Equal(t, uint64(0), account.Nonce, "Account %d has non-zero initial nonce", i) + require.NotNil(t, account, "Account %d is nil", i) + require.NotEqual(t, common.Address{}, account.Address, "Account %d has zero address", i) + require.NotNil(t, account.PrivKey, "Account %d has nil private key", i) + require.Equal(t, 0, account.Nonce.Load(), "Account %d has non-zero initial nonce", i) // Verify address uniqueness - assert.False(t, addressSet[account.Address], "Duplicate address found: %s", account.Address.Hex()) + require.False(t, addressSet[account.Address], "Duplicate address found: %s", account.Address.Hex()) addressSet[account.Address] = true // Verify address matches private key expectedAddress := crypto.PubkeyToAddress(account.PrivKey.PublicKey) - assert.Equal(t, expectedAddress, account.Address, "Account %d address doesn't match private key", i) + require.Equal(t, expectedAddress, account.Address, "Account %d address doesn't match private key", i) } }) } @@ -139,7 +137,7 @@ func TestAccountPoolRoundRobin(t *testing.T) { for i, expectedIndex := range expectedOrder { selectedAccount := pool.NextAccount(rng) expectedAccount := accounts[expectedIndex] - assert.Equal(t, expectedAccount.Address, selectedAccount.Address, + require.Equal(t, expectedAccount.Address, selectedAccount.Address, "Round %d, position %d: expected %s, got %s", round, i, expectedAccount.Address.Hex(), selectedAccount.Address.Hex()) } @@ -164,7 +162,7 @@ func TestAccountPoolNewAccountRate(t *testing.T) { for i := 0; i < 10; i++ { selectedAccount := pool.NextAccount(rng.NewSource(1).Rand("types:test")) - assert.False(t, originalAddresses[selectedAccount.Address], + require.False(t, originalAddresses[selectedAccount.Address], "Iteration %d: got original account %s when expecting new account", i, selectedAccount.Address.Hex()) } @@ -203,8 +201,8 @@ func TestAccountPoolMixedRate(t *testing.T) { // the same seeded pool must reproduce these counts. If the frozen derivation // changes, these expected values change with it. const expectedNew = 51 - assert.Equal(t, expectedNew, newCount, "seeded new-account count is not reproducible") - assert.Equal(t, iterations, originalCount+newCount, "Total accounts don't match iterations") + require.Equal(t, expectedNew, newCount, "seeded new-account count is not reproducible") + require.Equal(t, iterations, originalCount+newCount, "Total accounts don't match iterations") } func TestAccountPoolConcurrency(t *testing.T) { @@ -245,7 +243,7 @@ func TestAccountPoolConcurrency(t *testing.T) { } for i, address := range selectedAccounts { - assert.True(t, originalAddresses[address], + require.True(t, originalAddresses[address], "Selection %d: got unexpected address %s", i, address.Hex()) } } @@ -279,20 +277,20 @@ func TestCreateTxFromEthTx(t *testing.T) { // Verify LoadTx structure require.NotNil(t, loadTx) - assert.Equal(t, tx, loadTx.EthTx) - assert.Equal(t, scenario, loadTx.Scenario) - assert.NotEmpty(t, loadTx.JSONRPCPayload) - assert.NotEmpty(t, loadTx.Payload) + require.Equal(t, tx, loadTx.EthTx) + require.Equal(t, scenario, loadTx.Scenario) + require.NotEmpty(t, loadTx.JSONRPCPayload) + require.NotEmpty(t, loadTx.Payload) // Verify JSON-RPC payload is valid JSON - assert.Contains(t, string(loadTx.JSONRPCPayload), `"jsonrpc":"2.0"`) - assert.Contains(t, string(loadTx.JSONRPCPayload), `"method":"eth_sendRawTransaction"`) - assert.Contains(t, string(loadTx.JSONRPCPayload), `"id":0`) // Numeric ID, not string + require.Contains(t, string(loadTx.JSONRPCPayload), `"jsonrpc":"2.0"`) + require.Contains(t, string(loadTx.JSONRPCPayload), `"method":"eth_sendRawTransaction"`) + require.Contains(t, string(loadTx.JSONRPCPayload), `"id":0`) // Numeric ID, not string // Verify payload matches transaction binary data expectedPayload, err := tx.MarshalBinary() require.NoError(t, err) - assert.Equal(t, expectedPayload, loadTx.Payload) + require.Equal(t, expectedPayload, loadTx.Payload) } func TestLoadTxShardID(t *testing.T) { @@ -339,8 +337,8 @@ func TestLoadTxShardID(t *testing.T) { shardID := loadTx.ShardID(tt.numShards) // Verify shard ID is in valid range - assert.GreaterOrEqual(t, shardID, 0, "Shard ID should be non-negative") - assert.Less(t, shardID, tt.numShards, "Shard ID should be less than number of shards") + require.GreaterOrEqual(t, shardID, 0, "Shard ID should be non-negative") + require.Less(t, shardID, tt.numShards, "Shard ID should be less than number of shards") shardCounts[shardID]++ } @@ -352,19 +350,19 @@ func TestLoadTxShardID(t *testing.T) { for shardID, count := range shardCounts { totalCount += count // Verify shard IDs are in valid range - assert.GreaterOrEqual(t, shardID, 0, "Shard ID should be non-negative") - assert.Less(t, shardID, tt.numShards, "Shard ID should be less than number of shards") + require.GreaterOrEqual(t, shardID, 0, "Shard ID should be non-negative") + require.Less(t, shardID, tt.numShards, "Shard ID should be less than number of shards") } // Verify total count matches iterations - assert.Equal(t, tt.iterations, totalCount, "Total shard counts should match iterations") + require.Equal(t, tt.iterations, totalCount, "Total shard counts should match iterations") // For large numbers of shards, verify we're using a reasonable number of them // (at least 50% of available shards for sufficient iterations) if tt.numShards > 4 && tt.iterations >= tt.numShards*8 { usedShards := len(shardCounts) minExpectedShards := tt.numShards / 2 - assert.GreaterOrEqual(t, usedShards, minExpectedShards, + require.GreaterOrEqual(t, usedShards, minExpectedShards, "Expected at least %d shards to be used, got %d", minExpectedShards, usedShards) } }) @@ -399,7 +397,7 @@ func TestLoadTxShardIDConsistency(t *testing.T) { // Test multiple times with the same sender for i := 0; i < 10; i++ { shardID := loadTx.ShardID(numShards) - assert.Equal(t, expectedShardID, shardID, + require.Equal(t, expectedShardID, shardID, "Shard ID should be consistent for the same sender (iteration %d)", i) } } @@ -418,10 +416,10 @@ func TestTxScenario(t *testing.T) { } // Verify all fields are set correctly - assert.Equal(t, "TestScenario", scenario.Name) - assert.Equal(t, uint64(123), scenario.Sender.Nonce) - assert.Equal(t, account, scenario.Sender) - assert.Equal(t, receiver, scenario.Receiver) + require.Equal(t, "TestScenario", scenario.Name) + require.Equal(t, 123, scenario.Sender.Nonce.Load()) + require.Equal(t, account, scenario.Sender) + require.Equal(t, receiver, scenario.Receiver) } func TestJSONRPCPayloadFormat(t *testing.T) { From 480cf5e7cd1507cf2a7dfb1763ae30e3897c6d02 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 17:41:26 +0200 Subject: [PATCH 15/21] generatorBuilder --- generator/generator.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index 921429a..cd933f8 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -44,8 +44,8 @@ type scenarioInstance struct { Deployed bool } -// configBasedGenerator manages scenario creation and deployment from config -type configBasedGenerator struct { +// generatorBuilder manages scenario creation and deployment from config +type generatorBuilder struct { config *config.LoadConfig registry *types.AccountRegistry instances []*scenarioInstance @@ -55,7 +55,7 @@ type configBasedGenerator struct { // CreateScenarios creates scenario instances based on the configuration // Each scenario entry in config creates a separate instance, even if same name -func (g *configBasedGenerator) createScenarios() error { +func (g *generatorBuilder) createScenarios() error { if g.config.Accounts != nil { g.sharedAccounts = g.registry.NewPool(&types.AccountConfig{ InitialSize: g.config.Accounts.Accounts, @@ -115,9 +115,9 @@ func (g *configBasedGenerator) createScenarios() error { } // mockDeployAll deploys all scenario instances that require deployment (for unit tests). -func (g *configBasedGenerator) mockDeployAll() error { +func (g *generatorBuilder) mockDeployAll() error { for _, instance := range g.instances { - addr := types.GenerateAccounts(1)[0].Address + addr := types.NewAccount().Address if err := instance.Scenario.Attach(g.config, addr); err != nil { return err } @@ -127,7 +127,7 @@ func (g *configBasedGenerator) mockDeployAll() error { } // DeployAll deploys all scenario instances that require deployment -func (g *configBasedGenerator) deployAll() error { +func (g *generatorBuilder) deployAll() error { if g.config.MockDeploy { return g.mockDeployAll() } @@ -148,7 +148,7 @@ func (g *configBasedGenerator) deployAll() error { } // createWeightedGenerator creates a weighted scenarioGenerator from deployed scenarios -func (g *configBasedGenerator) createWeightedGenerator(rng *mrand.Rand) (Generator, error) { +func (g *generatorBuilder) createWeightedGenerator(rng *mrand.Rand) (Generator, error) { if len(g.instances) == 0 { return nil, fmt.Errorf("no scenario instances created") } @@ -197,25 +197,25 @@ func ResolveSeed(cfg *config.LoadConfig) *rng.Source { // NewConfigBasedGenerator is a convenience method that combines all steps. func NewConfigBasedGenerator(rng *mrand.Rand, cfg *config.LoadConfig, registry *types.AccountRegistry) (Generator, error) { - generator := &configBasedGenerator{ + b := &generatorBuilder{ config: cfg, registry: registry, instances: make([]*scenarioInstance, 0), - deployer: types.GenerateAccounts(1)[0], + deployer: types.NewAccount(), } // Step 1: Create scenarios - if err := generator.createScenarios(); err != nil { + if err := b.createScenarios(); err != nil { return nil, fmt.Errorf("failed to create scenarios: %w", err) } // Step 2: Deploy all scenarios - if err := generator.deployAll(); err != nil { + if err := b.deployAll(); err != nil { return nil, fmt.Errorf("failed to deploy scenarios: %w", err) } // Step 3: Create weighted scenarioGenerator - weightedGen, err := generator.createWeightedGenerator(rng) + weightedGen, err := b.createWeightedGenerator(rng) if err != nil { return nil, fmt.Errorf("failed to create weighted scenarioGenerator: %w", err) } From 26389cc72bae7fbba991a79499865371f346b2a7 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Wed, 24 Jun 2026 18:26:57 +0200 Subject: [PATCH 16/21] reduced generators logic --- generator/generator.go | 94 ++++++++++++++++++++++----------------- generator/prewarm.go | 46 ------------------- generator/scenario.go | 30 ------------- generator/weighted.go | 49 -------------------- sender/eth_client.go | 3 -- sender/eth_client_test.go | 7 --- types/account.go | 13 +++--- types/account_pool.go | 20 ++++++++- types/scenario.go | 10 ----- 9 files changed, 78 insertions(+), 194 deletions(-) delete mode 100644 generator/prewarm.go delete mode 100644 generator/scenario.go delete mode 100644 generator/weighted.go diff --git a/generator/generator.go b/generator/generator.go index cd933f8..9a5715c 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -19,20 +19,7 @@ import ( // Generators are not thread-safe. Callers must serialize all access to a given // Generator instance. type Generator interface { - Generate(rng *mrand.Rand) (*types.LoadTx, bool) // Returns transaction and true if more available, nil/false when done -} - -// GenerateN drains up to n transactions from g by repeated Generate calls. -func GenerateN(rng *mrand.Rand, g Generator, n int) []*types.LoadTx { - txs := make([]*types.LoadTx, 0, n) - for range n { - if tx, ok := g.Generate(rng); ok { - txs = append(txs, tx) - } else { - break - } - } - return txs + Generate(rng *mrand.Rand) // Returns transaction and true if more available, nil/false when done } // scenarioInstance represents a scenario instance with its configuration @@ -41,7 +28,6 @@ type scenarioInstance struct { Weight int Scenario scenarios.TxGenerator Accounts *types.AccountPool - Deployed bool } // generatorBuilder manages scenario creation and deployment from config @@ -105,7 +91,6 @@ func (g *generatorBuilder) createScenarios() error { Weight: scenarioCfg.Weight, Scenario: scenario, Accounts: accountPool, - Deployed: false, } g.instances = append(g.instances, instance) @@ -121,7 +106,6 @@ func (g *generatorBuilder) mockDeployAll() error { if err := instance.Scenario.Attach(g.config, addr); err != nil { return err } - instance.Deployed = true } return nil } @@ -137,9 +121,7 @@ func (g *generatorBuilder) deployAll() error { // Deploy the scenario log.Printf("Deploying scenario %s", instance.Name) address := instance.Scenario.Deploy(g.config, g.deployer) - instance.Deployed = true - - if address.Cmp(common.Address{}) != 0 { + if address!=(common.Address{}) { log.Printf("🚀 Deployed %s at address: %s\n", instance.Name, address.Hex()) } } @@ -147,39 +129,67 @@ func (g *generatorBuilder) deployAll() error { return nil } -// createWeightedGenerator creates a weighted scenarioGenerator from deployed scenarios -func (g *generatorBuilder) createWeightedGenerator(rng *mrand.Rand) (Generator, error) { - if len(g.instances) == 0 { - return nil, fmt.Errorf("no scenario instances created") - } +type weightedGenerator struct { + registry *types.AccountRegistry + scenarios []*scenarioInstance + counter uint64 +} - // Check that all scenarios are deployed - for _, instance := range g.instances { - if !instance.Deployed { - return nil, fmt.Errorf("scenario %s is not deployed", instance.Name) +// NewPrewarmGenerator creates a new prewarm generator using all account pools from the registry. +func (g *weightedGenerator) PrewarmTxs(rng *mrand.Rand, cfg *config.LoadConfig, accounts []*types.Account) []*types.LoadTx { + // Create EVMTransfer scenario for prewarming + evmScenario := scenarios.NewEVMTransferScenario(config.Scenario{}) + // Deploy/initialize the scenario (EVMTransfer doesn't need actual deployment) + evmScenario.Deploy(cfg, types.NewAccount()) + var txs []*types.LoadTx + for _,account := range accounts { + // Create self-transfer transaction + scenario := &types.TxScenario{ + Name: "EVMTransfer", + Sender: account, + Receiver: account.Address, // Send to self } + txs = append(txs,evmScenario.Generate(rng, scenario)) } + return txs +} + +// Generate generates 1 transaction. +func (w *weightedGenerator) Generate(rng *mrand.Rand) { + g := w.scenarios[int(w.counter) % len(w.scenarios)] + w.counter++ + sender := g.Accounts.NextAccount(rng) + receiver := g.Accounts.NextAccount(rng) + // TODO: This should probably hold a lock on sender. + sender.PushTx(g.Scenario.Generate(rng, &types.TxScenario{ + Name: g.Scenario.Name(), + Sender: sender, + Receiver: receiver.Address, + })) +} +// createWeightedGenerator creates a weighted scenarioGenerator from deployed scenarios +func (g *generatorBuilder) build(rng *mrand.Rand) (*weightedGenerator, error) { // Create weighted configurations - var weightedConfigs []*WeightedCfg + var gens []*scenarioInstance for _, instance := range g.instances { if instance.Weight == 0 { log.Printf("Skipping scenario %s with weight 0", instance.Name) continue } // Create a scenarioGenerator for this scenario instance - gen := NewScenarioGenerator(instance.Accounts, instance.Scenario) - - // Add to weighted config with the specified weight - weightedConfigs = append(weightedConfigs, WeightedConfig(instance.Weight, gen)) + for range instance.Weight { + gens = append(gens,instance) + } } - if len(weightedConfigs) == 0 { + if len(gens) == 0 { return nil, fmt.Errorf("no scenario instances created (define some scenarios)") } - - // Create and return the weighted scenarioGenerator - return NewWeightedGenerator(rng, weightedConfigs), nil + rng.Shuffle(len(gens), func(i, j int) { + gens[i], gens[j] = gens[j], gens[i] + }) + return &weightedGenerator{scenarios: gens}, nil } // resolveSeed returns the run's PRNG source, defaulting an unseeded config to a @@ -196,10 +206,10 @@ func ResolveSeed(cfg *config.LoadConfig) *rng.Source { } // NewConfigBasedGenerator is a convenience method that combines all steps. -func NewConfigBasedGenerator(rng *mrand.Rand, cfg *config.LoadConfig, registry *types.AccountRegistry) (Generator, error) { +func NewConfigBasedGenerator(rng *mrand.Rand, cfg *config.LoadConfig) (Generator, error) { b := &generatorBuilder{ config: cfg, - registry: registry, + registry: types.NewAccountRegistry(), instances: make([]*scenarioInstance, 0), deployer: types.NewAccount(), } @@ -215,10 +225,10 @@ func NewConfigBasedGenerator(rng *mrand.Rand, cfg *config.LoadConfig, registry * } // Step 3: Create weighted scenarioGenerator - weightedGen, err := b.createWeightedGenerator(rng) + g, err := b.build(rng) if err != nil { return nil, fmt.Errorf("failed to create weighted scenarioGenerator: %w", err) } - return weightedGen, nil + return g, nil } diff --git a/generator/prewarm.go b/generator/prewarm.go deleted file mode 100644 index 34f54c6..0000000 --- a/generator/prewarm.go +++ /dev/null @@ -1,46 +0,0 @@ -package generator - -import ( - mrand "math/rand/v2" - - "github.com/sei-protocol/sei-load/config" - "github.com/sei-protocol/sei-load/generator/scenarios" - "github.com/sei-protocol/sei-load/types" -) - -// PrewarmGenerator generates self-transfer transactions to prewarm account nonces -type PrewarmGenerator struct { - accounts []*types.Account - evmScenario scenarios.TxGenerator -} - -// NewPrewarmGenerator creates a new prewarm generator using all account pools from the registry. -func NewPrewarmGenerator(cfg *config.LoadConfig, accounts []*types.Account) *PrewarmGenerator { - // Create EVMTransfer scenario for prewarming - evmScenario := scenarios.NewEVMTransferScenario(config.Scenario{}) - - // Deploy/initialize the scenario (EVMTransfer doesn't need actual deployment) - deployer := types.NewAccount() - evmScenario.Deploy(cfg, deployer) - return &PrewarmGenerator{ - accounts: accounts, - evmScenario: evmScenario, - } -} - -// Generate generates self-transfer transactions until all known accounts are prewarmed. -func (pg *PrewarmGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { - if len(pg.accounts) == 0 { - return nil, false - } - account := pg.accounts[0] - pg.accounts = pg.accounts[1:] - // Create self-transfer transaction - scenario := &types.TxScenario{ - Name: "EVMTransfer", - Sender: account, - Receiver: account.Address, // Send to self - } - // Generate the transaction using EVMTransfer scenario - return pg.evmScenario.Generate(rng, scenario), true -} diff --git a/generator/scenario.go b/generator/scenario.go deleted file mode 100644 index e435e58..0000000 --- a/generator/scenario.go +++ /dev/null @@ -1,30 +0,0 @@ -package generator - -import ( - mrand "math/rand/v2" - - "github.com/sei-protocol/sei-load/generator/scenarios" - "github.com/sei-protocol/sei-load/types" -) - -type scenarioGenerator struct { - scenario scenarios.TxGenerator - accountPool *types.AccountPool -} - -func NewScenarioGenerator(accounts *types.AccountPool, txg scenarios.TxGenerator) Generator { - return &scenarioGenerator{ - scenario: txg, - accountPool: accounts, - } -} - -func (g *scenarioGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { - sender := g.accountPool.NextAccount(rng) - receiver := g.accountPool.NextAccount(rng) - return g.scenario.Generate(rng, &types.TxScenario{ - Name: g.scenario.Name(), - Sender: sender, - Receiver: receiver.Address, - }), true -} diff --git a/generator/weighted.go b/generator/weighted.go deleted file mode 100644 index e43ede7..0000000 --- a/generator/weighted.go +++ /dev/null @@ -1,49 +0,0 @@ -package generator - -import ( - mrand "math/rand/v2" - - "github.com/sei-protocol/sei-load/types" -) - -// WeightedCfg is a configuration for a weighted scenarioGenerator. -type WeightedCfg struct { - Weight int - Generator Generator -} - -// WeightedConfig creates a configuration for a weighted scenarioGenerator. -func WeightedConfig(weight int, generator Generator) *WeightedCfg { - return &WeightedCfg{ - Weight: weight, - Generator: generator, - } -} - -type weightedGenerator struct { - generators []Generator - counter uint64 -} - -// NewWeightedGenerator creates a new scenarioGenerator that will randomly select -// from the provided generators. -func NewWeightedGenerator(rng *mrand.Rand, cfgs []*WeightedCfg) Generator { - var weighted []Generator - for _, cfg := range cfgs { - for range cfg.Weight { - weighted = append(weighted, cfg.Generator) - } - } - rng.Shuffle(len(weighted), func(i, j int) { - weighted[i], weighted[j] = weighted[j], weighted[i] - }) - - return &weightedGenerator{generators: weighted} -} - -// Generate generates 1 transaction. -func (w *weightedGenerator) Generate(rng *mrand.Rand) (*types.LoadTx, bool) { - idx := int(w.counter) % len(w.generators) - w.counter++ - return w.generators[idx].Generate(rng) -} diff --git a/sender/eth_client.go b/sender/eth_client.go index 796b071..424f5d9 100644 --- a/sender/eth_client.go +++ b/sender/eth_client.go @@ -123,9 +123,6 @@ func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client) err // so stamping the actual send-attempt time here is race-free (see LoadTx). req.tx.AttemptedSendTime = startTime err = c.sendTx(ctx, client, req.tx) - if req.tx.OnComplete != nil { - req.tx.OnComplete(err) - } req.done <- err c.cfg.Collector.RecordTransaction(req.tx.Scenario.Name, c.cfg.Endpoint, time.Since(startTime), err == nil) if err == nil { diff --git a/sender/eth_client_test.go b/sender/eth_client_test.go index 847f24f..0242aa4 100644 --- a/sender/eth_client_test.go +++ b/sender/eth_client_test.go @@ -8,7 +8,6 @@ import ( "net/http/httptest" "slices" "strings" - "sync/atomic" "testing" "time" @@ -103,11 +102,6 @@ func TestEthClientRunSender_RegistersSuccessfulSendAfterOnComplete(t *testing.T) }) tx := testLoadTx(t) - var inflightAtComplete atomic.Uint64 - tx.OnComplete = func(error) { - inflightAtComplete.Store(tracker.Summary().InflightAtShutdown) - } - ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() @@ -117,7 +111,6 @@ func TestEthClientRunSender_RegistersSuccessfulSendAfterOnComplete(t *testing.T) require.NoError(t, client.Send(ctx, tx)) cancel() require.ErrorIs(t, <-errCh, context.Canceled) - require.Zero(t, inflightAtComplete.Load(), "inclusion must register after OnComplete") require.Equal(t, uint64(1), tracker.Summary().InflightAtShutdown, "successful send must register exactly once") } diff --git a/types/account.go b/types/account.go index 7d45203..44fad86 100644 --- a/types/account.go +++ b/types/account.go @@ -2,7 +2,6 @@ package types import ( "crypto/ecdsa" - "sync/atomic" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" @@ -13,7 +12,8 @@ import ( type Account struct { Address common.Address PrivKey *ecdsa.PrivateKey - Nonce atomic.Uint64 + Nonce uint64 + Txs []*LoadTx } // NewAccount generates new account. @@ -25,9 +25,12 @@ func NewAccount() *Account { } } -// GetAndIncrementNonce increments the nonce. -func (s *Account) GetAndIncrementNonce() uint64 { - return s.Nonce.Add(1) - 1 +func (s *Account) PushTx(tx *LoadTx) { + if tx.EthTx.Nonce()!=s.Nonce { + return + } + s.Nonce += 1 + s.Txs = append(s.Txs,tx) } // GenerateAccounts generates random accounts. diff --git a/types/account_pool.go b/types/account_pool.go index dd43301..81809b3 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -3,16 +3,28 @@ package types import ( mrand "math/rand/v2" "sync" + "github.com/ethereum/go-ethereum/common" ) // AccountRegistry owns account pools created for a run. type AccountRegistry struct { + accounts map[common.Address]*Account + newAccountsTxs []*LoadTx pools []*AccountPool } // NewAccountRegistry creates an empty account registry. func NewAccountRegistry() *AccountRegistry { - return &AccountRegistry{} + return &AccountRegistry{ + accounts: map[common.Address]*Account{}, + } +} + +func (r *AccountRegistry) ResetNonce(addr common.Address, nonce uint64) { + if a,ok := r.accounts[addr]; ok { + a.Txs = nil + a.Nonce = nonce + } } // Accounts returns a flat copy of all accounts across all pools. @@ -65,10 +77,14 @@ func (a *AccountPool) GetAccounts() []*Account { // NewPool creates a new account generator from a config, records it, and returns it. func (r *AccountRegistry) NewPool(cfg *AccountConfig) *AccountPool { + accounts := GenerateAccounts(cfg.InitialSize) pool := &AccountPool{ - Accounts: GenerateAccounts(cfg.InitialSize), + Accounts: accounts, cfg: cfg, } + for _,a := range accounts { + r.accounts[a.Address] = a + } r.pools = append(r.pools, pool) return pool } diff --git a/types/scenario.go b/types/scenario.go index a70b8a4..2630ed5 100644 --- a/types/scenario.go +++ b/types/scenario.go @@ -48,16 +48,6 @@ type LoadTx struct { // AttemptedSendTime is when the send was actually attempted, written by the // sender goroutine that owns the tx between dequeue and send completion. AttemptedSendTime time.Time - // OnComplete, if set, is invoked exactly once when the network send attempt - // for this tx finishes (after sendTransaction returns), with the send error - // or nil. The open-loop scheduler sets it to release the in-flight permit so - // the bound covers true unacked sends (enqueue + send), not just queue depth; - // see the open-loop scheduler. The sender invokes it after send completion - // and is the sole invoker on the happy path. Nil in the closed-loop and batch - // paths, where the sender simply skips it. The callback must be cheap and - // non-blocking — the sender holds the tx and calls it inline. Written by the - // owning goroutine before hand-off, per the lifecycle concurrency contract. - OnComplete func(err error) // InclusionTime is when the tx was observed included on-chain, written only // by the inclusion tracker (single writer, under its registry lock). The // clock is the wall-clock instant the including block's newHead header From d836c10f8941fa2747ade74ccd91938e0e2661a3 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Thu, 25 Jun 2026 09:59:45 +0200 Subject: [PATCH 17/21] WIP --- generator/generator.go | 35 +++++++++++++------------- generator/scenarios/EVMTransfer.go | 2 +- generator/scenarios/EVMTransferFast.go | 2 +- generator/scenarios/EVMTransferNoop.go | 2 +- generator/utils/utils.go | 2 +- main.go | 17 ++++++------- sender/dispatcher.go | 13 +++------- 7 files changed, 33 insertions(+), 40 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index 9a5715c..fbefd0d 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "log" + "time" mrand "math/rand/v2" "github.com/ethereum/go-ethereum/common" @@ -14,14 +15,6 @@ import ( "github.com/sei-protocol/sei-load/utils/rng" ) -// Generator defines the contract for transaction generators. -// -// Generators are not thread-safe. Callers must serialize all access to a given -// Generator instance. -type Generator interface { - Generate(rng *mrand.Rand) // Returns transaction and true if more available, nil/false when done -} - // scenarioInstance represents a scenario instance with its configuration type scenarioInstance struct { Name string @@ -129,20 +122,24 @@ func (g *generatorBuilder) deployAll() error { return nil } -type weightedGenerator struct { +type Generator struct { registry *types.AccountRegistry scenarios []*scenarioInstance counter uint64 } +func (g *Generator) Accounts() []*types.Account { + return g.registry.Accounts() +} + // NewPrewarmGenerator creates a new prewarm generator using all account pools from the registry. -func (g *weightedGenerator) PrewarmTxs(rng *mrand.Rand, cfg *config.LoadConfig, accounts []*types.Account) []*types.LoadTx { +func (g *Generator) PrewarmTxs(rng *mrand.Rand, cfg *config.LoadConfig) []*types.LoadTx { // Create EVMTransfer scenario for prewarming evmScenario := scenarios.NewEVMTransferScenario(config.Scenario{}) // Deploy/initialize the scenario (EVMTransfer doesn't need actual deployment) evmScenario.Deploy(cfg, types.NewAccount()) var txs []*types.LoadTx - for _,account := range accounts { + for _,account := range g.registry.Accounts() { // Create self-transfer transaction scenario := &types.TxScenario{ Name: "EVMTransfer", @@ -155,21 +152,25 @@ func (g *weightedGenerator) PrewarmTxs(rng *mrand.Rand, cfg *config.LoadConfig, } // Generate generates 1 transaction. -func (w *weightedGenerator) Generate(rng *mrand.Rand) { +func (w *Generator) Generate(rng *mrand.Rand) { g := w.scenarios[int(w.counter) % len(w.scenarios)] w.counter++ sender := g.Accounts.NextAccount(rng) receiver := g.Accounts.NextAccount(rng) // TODO: This should probably hold a lock on sender. - sender.PushTx(g.Scenario.Generate(rng, &types.TxScenario{ + // Stamp before hand-off while sole owner: race-free (see LoadTx). This is + // the back-pressured enqueue time, not a true schedule instant. + tx := g.Scenario.Generate(rng, &types.TxScenario{ Name: g.Scenario.Name(), Sender: sender, Receiver: receiver.Address, - })) + }) + tx.IntendedSendTime = time.Now() + sender.PushTx(tx) } // createWeightedGenerator creates a weighted scenarioGenerator from deployed scenarios -func (g *generatorBuilder) build(rng *mrand.Rand) (*weightedGenerator, error) { +func (g *generatorBuilder) build(rng *mrand.Rand) (*Generator, error) { // Create weighted configurations var gens []*scenarioInstance for _, instance := range g.instances { @@ -189,7 +190,7 @@ func (g *generatorBuilder) build(rng *mrand.Rand) (*weightedGenerator, error) { rng.Shuffle(len(gens), func(i, j int) { gens[i], gens[j] = gens[j], gens[i] }) - return &weightedGenerator{scenarios: gens}, nil + return &Generator{scenarios: gens}, nil } // resolveSeed returns the run's PRNG source, defaulting an unseeded config to a @@ -206,7 +207,7 @@ func ResolveSeed(cfg *config.LoadConfig) *rng.Source { } // NewConfigBasedGenerator is a convenience method that combines all steps. -func NewConfigBasedGenerator(rng *mrand.Rand, cfg *config.LoadConfig) (Generator, error) { +func NewGenerator(rng *mrand.Rand, cfg *config.LoadConfig) (*Generator, error) { b := &generatorBuilder{ config: cfg, registry: types.NewAccountRegistry(), diff --git a/generator/scenarios/EVMTransfer.go b/generator/scenarios/EVMTransfer.go index f9454b4..403d851 100644 --- a/generator/scenarios/EVMTransfer.go +++ b/generator/scenarios/EVMTransfer.go @@ -49,7 +49,7 @@ func (s *EVMTransferScenario) AttachScenario(config *config.LoadConfig, address func (s *EVMTransferScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ - Nonce: scenario.Sender.GetAndIncrementNonce(), + Nonce: scenario.Sender.Nonce, To: &scenario.Receiver, Value: big.NewInt(time.Now().Unix()), Gas: 21000, // Standard gas limit for ETH transfer diff --git a/generator/scenarios/EVMTransferFast.go b/generator/scenarios/EVMTransferFast.go index 812a9c5..fa81fa6 100644 --- a/generator/scenarios/EVMTransferFast.go +++ b/generator/scenarios/EVMTransferFast.go @@ -49,7 +49,7 @@ func (s *EVMTransferFastScenario) AttachScenario(config *config.LoadConfig, addr func (s *EVMTransferFastScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ - Nonce: scenario.Sender.GetAndIncrementNonce(), + Nonce: scenario.Sender.Nonce, To: &scenario.Receiver, Value: big.NewInt(1_000_000_000_000), Gas: 21000, // Standard gas limit for ETH transfer diff --git a/generator/scenarios/EVMTransferNoop.go b/generator/scenarios/EVMTransferNoop.go index 5781c57..14f9186 100644 --- a/generator/scenarios/EVMTransferNoop.go +++ b/generator/scenarios/EVMTransferNoop.go @@ -48,7 +48,7 @@ func (s *EVMTransferNoopScenario) AttachScenario(config *config.LoadConfig, addr func (s *EVMTransferNoopScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ - Nonce: scenario.Sender.GetAndIncrementNonce(), + Nonce: scenario.Sender.Nonce, To: &scenario.Sender.Address, Value: big.NewInt(0), Gas: 21000, // Standard gas limit for ETH transfer diff --git a/generator/utils/utils.go b/generator/utils/utils.go index a7038d1..c414cc3 100644 --- a/generator/utils/utils.go +++ b/generator/utils/utils.go @@ -45,7 +45,7 @@ func createTransactOpts(chainID *big.Int, account *loadtypes.Account, gasLimit u } // Set transaction parameters - auth.Nonce = big.NewInt(int64(account.GetAndIncrementNonce())) + auth.Nonce = big.NewInt(int64(account.Nonce)) auth.NoSend = noSend auth.GasLimit = gasLimit diff --git a/main.go b/main.go index 8d29a29..ca0048d 100644 --- a/main.go +++ b/main.go @@ -27,7 +27,6 @@ import ( "github.com/sei-protocol/sei-load/observability" "github.com/sei-protocol/sei-load/sender" "github.com/sei-protocol/sei-load/stats" - "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/utils" "github.com/sei-protocol/sei-load/utils/scope" ) @@ -215,8 +214,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { err = scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Create the generator from the config struct - registry := types.NewAccountRegistry() - gen, err := generator.NewConfigBasedGenerator(rng, cfg, registry) + gen, err := generator.NewGenerator(rng, cfg) if err != nil { return fmt.Errorf("failed to create generator: %w", err) } @@ -297,7 +295,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Fund the pool before prewarm/dispatch — both spend gas the accounts // don't have until funded. if cfg.Funding != nil && !cfg.Settings.DryRun { - if err := funder.FundAccounts(ctx, cfg, registry.Accounts()); err != nil { + if err := funder.FundAccounts(ctx, cfg, gen.Accounts()); err != nil { return fmt.Errorf("failed to fund accounts: %w", err) } } @@ -316,14 +314,15 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Set up prewarming if enabled if cfg.Settings.Prewarm { log.Printf("🔥 Creating prewarm generator...") - accounts := registry.Accounts() - prewarmGen := generator.NewPrewarmGenerator(cfg, accounts) + txs := gen.PrewarmTxs(rng,cfg) log.Printf("✅ Prewarm generator ready") log.Printf("📝 Prewarm mode: Accounts will be prewarmed") - if err := sender.Run(ctx, rng, prewarmGen, snd); err != nil { - return fmt.Errorf("failed to prewarm accounts: %w", err) + for _,tx := range txs { + if err:=snd.Send(ctx, tx); err!=nil { + return fmt.Errorf("failed to prewarm accounts: %w", err) + } } - log.Printf("🔥 Prewarming complete! Processed %d accounts", len(accounts)) + log.Printf("🔥 Prewarming complete! Processed %d accounts", len(txs)) } // Start logger (after prewarming to capture only main load test metrics) diff --git a/sender/dispatcher.go b/sender/dispatcher.go index 81d976d..e60fdfe 100644 --- a/sender/dispatcher.go +++ b/sender/dispatcher.go @@ -9,18 +9,11 @@ import ( // Run begins the dispatcher's transaction generation and sending loop, using // the configured arrival model. -func Run(ctx context.Context, rng *mrand.Rand, gen generator.Generator, snd TxSender) error { +func Run(ctx context.Context, rng *mrand.Rand, gen *generator.Generator, snd TxSender) error { for ctx.Err() == nil { + // TODO: make AccountRegistry a proper queue. // Generate a transaction from main generator - tx, ok := gen.Generate(rng) - if !ok { - return nil - } - - // Stamp before hand-off while sole owner: race-free (see LoadTx). This is - // the back-pressured enqueue time, not a true schedule instant. - tx.IntendedSendTime = time.Now() - + gen.Generate(rng) // Send the transaction if err := snd.Send(ctx, tx); err != nil { return err From 3deb622dfef210e1fb6033f9a47ab21dedea4081 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Thu, 25 Jun 2026 17:21:10 +0200 Subject: [PATCH 18/21] WIP --- funder/funder.go | 30 +++---- generator/generator.go | 108 +++++++++++++------------ generator/scenarios/Disperse.go | 5 +- generator/scenarios/EVMTransfer.go | 4 +- generator/scenarios/EVMTransferFast.go | 4 +- generator/scenarios/EVMTransferNoop.go | 4 +- generator/scenarios/base.go | 30 +++---- generator/utils/utils.go | 38 ++------- main.go | 25 +++--- sender/dispatcher.go | 23 ------ sender/sender.go | 1 + sender/sender_test.go | 3 +- sender/sharded_sender.go | 3 +- stats/inclusion_tracker_test.go | 1 + types/account.go | 22 ++--- types/account_pool.go | 107 ++++++++++++++---------- types/scenario.go | 42 ++-------- types/types_test.go | 3 +- 18 files changed, 191 insertions(+), 262 deletions(-) delete mode 100644 sender/dispatcher.go diff --git a/funder/funder.go b/funder/funder.go index 523def9..d3b638d 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "log" + "maps" "math/big" "os" + "slices" "strings" "sync" @@ -18,7 +20,6 @@ import ( "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/bindings" - "github.com/sei-protocol/sei-load/types" ) const balanceCheckConcurrency = 16 @@ -27,7 +28,7 @@ const balanceCheckConcurrency = 16 // per-account amount from cfg.Funding's root key, or is a no-op when // cfg.Funding is nil. See the package doc for the funding flow, the EVM // auto-association precondition, and the restart/idempotency semantics. -func FundAccounts(ctx context.Context, cfg *config.LoadConfig, accounts []*types.Account) error { +func FundAccounts(ctx context.Context, cfg *config.LoadConfig, addrs []common.Address) error { fc := cfg.Funding if fc == nil { return nil @@ -51,16 +52,16 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, accounts []*types } defer client.Close() - recipients := uniqueAddresses(accounts) - if len(recipients) == 0 { + addrs = unique(addrs) + if len(addrs) == 0 { log.Printf("💰 funder: no accounts to fund") return nil } amount := fc.FundAmount() log.Printf("💰 funder: %d accounts, target %s wei each, from %s", - len(recipients), amount.String(), crypto.PubkeyToAddress(rootKey.PublicKey).Hex()) + len(addrs), amount.String(), crypto.PubkeyToAddress(rootKey.PublicKey).Hex()) - underfunded, err := filterUnderfunded(ctx, client, recipients, amount) + underfunded, err := filterUnderfunded(ctx, client, addrs, amount) if err != nil { return err } @@ -68,7 +69,7 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, accounts []*types log.Printf("💰 funder: all accounts already funded — nothing to do") return nil } - log.Printf("💰 funder: %d of %d need funding", len(underfunded), len(recipients)) + log.Printf("💰 funder: %d of %d need funding", len(underfunded), len(addrs)) chainID := cfg.GetChainID() auth, err := bind.NewKeyedTransactorWithChainID(rootKey, chainID) @@ -132,17 +133,12 @@ func resolveRootKey(fc *config.FundingConfig) (string, error) { return "", fmt.Errorf("funder: no root key (set funding.rootKeyFile or funding.rootKeyEnv)") } -func uniqueAddresses(accounts []*types.Account) []common.Address { - seen := make(map[common.Address]struct{}) - var out []common.Address - for _, a := range accounts { - if _, ok := seen[a.Address]; ok { - continue - } - seen[a.Address] = struct{}{} - out = append(out, a.Address) +func unique[T comparable](vs []T) []T { + m := make(map[T]struct{}) + for _, v := range vs { + m[v] = struct{}{} } - return out + return slices.Collect(maps.Keys(m)) } // filterUnderfunded returns the addresses whose balance is below amount. The diff --git a/generator/generator.go b/generator/generator.go index fbefd0d..2a0de7a 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -1,11 +1,13 @@ package generator import ( + "context" "errors" "fmt" "log" - "time" + "maps" mrand "math/rand/v2" + "slices" "github.com/ethereum/go-ethereum/common" @@ -26,9 +28,8 @@ type scenarioInstance struct { // generatorBuilder manages scenario creation and deployment from config type generatorBuilder struct { config *config.LoadConfig - registry *types.AccountRegistry instances []*scenarioInstance - deployer *types.Account + deployer types.Account sharedAccounts *types.AccountPool // Shared account pool when using top-level config } @@ -36,10 +37,10 @@ type generatorBuilder struct { // Each scenario entry in config creates a separate instance, even if same name func (g *generatorBuilder) createScenarios() error { if g.config.Accounts != nil { - g.sharedAccounts = g.registry.NewPool(&types.AccountConfig{ - InitialSize: g.config.Accounts.Accounts, - NewAccountRate: g.config.Accounts.NewAccountRate, - }) + g.sharedAccounts = types.NewAccountPool( + g.config.Accounts.Accounts, + g.config.Accounts.NewAccountRate, + ) } for i, scenarioCfg := range g.config.Scenarios { @@ -48,12 +49,9 @@ func (g *generatorBuilder) createScenarios() error { // Determine account pool to use var accountPool *types.AccountPool - if accounts := scenarioCfg.Accounts; accounts != nil { + if cfg := scenarioCfg.Accounts; cfg != nil { // Scenario defines its own account settings - create separate pool - accountPool = g.registry.NewPool(&types.AccountConfig{ - InitialSize: accounts.Accounts, - NewAccountRate: accounts.NewAccountRate, - }) + accountPool = types.NewAccountPool(cfg.Accounts, cfg.NewAccountRate) } else if g.sharedAccounts != nil { // Use shared account pool from top-level config accountPool = g.sharedAccounts @@ -93,10 +91,9 @@ func (g *generatorBuilder) createScenarios() error { } // mockDeployAll deploys all scenario instances that require deployment (for unit tests). -func (g *generatorBuilder) mockDeployAll() error { +func (g *generatorBuilder) mockDeployAll(deployer common.Address) error { for _, instance := range g.instances { - addr := types.NewAccount().Address - if err := instance.Scenario.Attach(g.config, addr); err != nil { + if err := instance.Scenario.Attach(g.config, deployer); err != nil { return err } } @@ -105,16 +102,17 @@ func (g *generatorBuilder) mockDeployAll() error { // DeployAll deploys all scenario instances that require deployment func (g *generatorBuilder) deployAll() error { + deployer := types.NewAccount(false) if g.config.MockDeploy { - return g.mockDeployAll() + return g.mockDeployAll(deployer.Address) } // Deploy sequentially to ensure proper nonce management - for _, instance := range g.instances { + for i, instance := range g.instances { // Deploy the scenario log.Printf("Deploying scenario %s", instance.Name) - address := instance.Scenario.Deploy(g.config, g.deployer) - if address!=(common.Address{}) { + address := instance.Scenario.Deploy(g.config, deployer, uint64(i)) + if address != (common.Address{}) { log.Printf("🚀 Deployed %s at address: %s\n", instance.Name, address.Hex()) } } @@ -122,65 +120,73 @@ func (g *generatorBuilder) deployAll() error { return nil } -type Generator struct { - registry *types.AccountRegistry - scenarios []*scenarioInstance - counter uint64 -} +type Generator struct {scenarios []*scenarioInstance} -func (g *Generator) Accounts() []*types.Account { - return g.registry.Accounts() +func (g *Generator) Accounts() []types.Account { + accs := map[common.Address]types.Account{} + for _, s := range g.scenarios { + for _, a := range s.Accounts.Accounts() { + accs[a.Address] = a + } + } + return slices.Collect(maps.Values(accs)) } // NewPrewarmGenerator creates a new prewarm generator using all account pools from the registry. -func (g *Generator) PrewarmTxs(rng *mrand.Rand, cfg *config.LoadConfig) []*types.LoadTx { +func (g *Generator) Prewarm(ctx context.Context, rng *mrand.Rand, cfg *config.LoadConfig, q *types.TxsQueue) error { // Create EVMTransfer scenario for prewarming evmScenario := scenarios.NewEVMTransferScenario(config.Scenario{}) // Deploy/initialize the scenario (EVMTransfer doesn't need actual deployment) - evmScenario.Deploy(cfg, types.NewAccount()) - var txs []*types.LoadTx - for _,account := range g.registry.Accounts() { + evmScenario.Deploy(cfg, types.NewAccount(false), 0) + for _, account := range g.Accounts() { // Create self-transfer transaction scenario := &types.TxScenario{ Name: "EVMTransfer", + Nonce: q.Nonce(account.Address), Sender: account, Receiver: account.Address, // Send to self } - txs = append(txs,evmScenario.Generate(rng, scenario)) + tx,err := evmScenario.Generate(rng, scenario) + if err!=nil { return fmt.Errorf("evmScenario.Generate(): %w",err) } + if err := q.Push(ctx,scenario,tx); err!=nil { return err } } - return txs + return q.WaitUntilEmpty(ctx) } // Generate generates 1 transaction. -func (w *Generator) Generate(rng *mrand.Rand) { - g := w.scenarios[int(w.counter) % len(w.scenarios)] - w.counter++ - sender := g.Accounts.NextAccount(rng) - receiver := g.Accounts.NextAccount(rng) - // TODO: This should probably hold a lock on sender. - // Stamp before hand-off while sole owner: race-free (see LoadTx). This is - // the back-pressured enqueue time, not a true schedule instant. - tx := g.Scenario.Generate(rng, &types.TxScenario{ - Name: g.Scenario.Name(), - Sender: sender, - Receiver: receiver.Address, - }) - tx.IntendedSendTime = time.Now() - sender.PushTx(tx) +func (w *Generator) Run(ctx context.Context, rng *mrand.Rand, q *types.TxsQueue) error { + counter := 0 + for { + g := w.scenarios[int(counter)%len(w.scenarios)] + counter++ + sender := g.Accounts.NextAccount(rng) + receiver := g.Accounts.NextAccount(rng) + // TODO: This should probably hold a lock on sender. + // Stamp before hand-off while sole owner: race-free (see LoadTx). This is + // the back-pressured enqueue time, not a true schedule instant. + scenario := &types.TxScenario{ + Name: g.Scenario.Name(), + Sender: sender, + Receiver: receiver.Address, + } + tx,err := g.Scenario.Generate(rng, scenario) + if err!=nil { return fmt.Errorf("g.Scenario.Generate(): %w",err) } + if err:=q.Push(ctx,scenario,tx); err!=nil { return err } + } } // createWeightedGenerator creates a weighted scenarioGenerator from deployed scenarios -func (g *generatorBuilder) build(rng *mrand.Rand) (*Generator, error) { +func (b *generatorBuilder) build(rng *mrand.Rand) (*Generator, error) { // Create weighted configurations var gens []*scenarioInstance - for _, instance := range g.instances { + for _, instance := range b.instances { if instance.Weight == 0 { log.Printf("Skipping scenario %s with weight 0", instance.Name) continue } // Create a scenarioGenerator for this scenario instance for range instance.Weight { - gens = append(gens,instance) + gens = append(gens, instance) } } @@ -210,9 +216,7 @@ func ResolveSeed(cfg *config.LoadConfig) *rng.Source { func NewGenerator(rng *mrand.Rand, cfg *config.LoadConfig) (*Generator, error) { b := &generatorBuilder{ config: cfg, - registry: types.NewAccountRegistry(), instances: make([]*scenarioInstance, 0), - deployer: types.NewAccount(), } // Step 1: Create scenarios diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index 7ffee13..2a9b963 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -26,10 +26,7 @@ type DisperseScenario struct { func NewDisperseScenario(cfg config.Scenario) TxGenerator { scenario := &DisperseScenario{} scenario.ContractScenarioBase = NewContractScenarioBase(scenario, cfg) - scenario.pool = types.NewAccountRegistry().NewPool(&types.AccountConfig{ - InitialSize: 0, - NewAccountRate: 1.0, - }) + scenario.pool = types.NewAccountPool(0, 1.0) return scenario } diff --git a/generator/scenarios/EVMTransfer.go b/generator/scenarios/EVMTransfer.go index 403d851..8c5cc42 100644 --- a/generator/scenarios/EVMTransfer.go +++ b/generator/scenarios/EVMTransfer.go @@ -32,7 +32,7 @@ func (s *EVMTransferScenario) Name() string { } // DeployScenario implements ScenarioDeployer interface - no deployment needed for ETH transfers -func (s *EVMTransferScenario) DeployScenario(config *config.LoadConfig, deployer *types2.Account) common.Address { +func (s *EVMTransferScenario) DeployScenario(config *config.LoadConfig, deployer types2.Account, nonce uint64) common.Address { // No deployment needed for simple ETH transfers // Return zero address to indicate no contract deployment return common.Address{} @@ -49,7 +49,7 @@ func (s *EVMTransferScenario) AttachScenario(config *config.LoadConfig, address func (s *EVMTransferScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ - Nonce: scenario.Sender.Nonce, + Nonce: scenario.Nonce, To: &scenario.Receiver, Value: big.NewInt(time.Now().Unix()), Gas: 21000, // Standard gas limit for ETH transfer diff --git a/generator/scenarios/EVMTransferFast.go b/generator/scenarios/EVMTransferFast.go index fa81fa6..b295d18 100644 --- a/generator/scenarios/EVMTransferFast.go +++ b/generator/scenarios/EVMTransferFast.go @@ -32,7 +32,7 @@ func (s *EVMTransferFastScenario) Name() string { } // DeployScenario implements ScenarioDeployer interface - no deployment needed for ETH transfers -func (s *EVMTransferFastScenario) DeployScenario(config *config.LoadConfig, deployer *types2.Account) common.Address { +func (s *EVMTransferFastScenario) DeployScenario(config *config.LoadConfig, deployer types2.Account, nonce uint64) common.Address { // No deployment needed for simple ETH transfers // Return zero address to indicate no contract deployment return common.Address{} @@ -49,7 +49,7 @@ func (s *EVMTransferFastScenario) AttachScenario(config *config.LoadConfig, addr func (s *EVMTransferFastScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ - Nonce: scenario.Sender.Nonce, + Nonce: scenario.Nonce, To: &scenario.Receiver, Value: big.NewInt(1_000_000_000_000), Gas: 21000, // Standard gas limit for ETH transfer diff --git a/generator/scenarios/EVMTransferNoop.go b/generator/scenarios/EVMTransferNoop.go index 14f9186..16e9576 100644 --- a/generator/scenarios/EVMTransferNoop.go +++ b/generator/scenarios/EVMTransferNoop.go @@ -31,7 +31,7 @@ func (s *EVMTransferNoopScenario) Name() string { } // DeployScenario implements ScenarioDeployer interface - no deployment needed for ETH transfers -func (s *EVMTransferNoopScenario) DeployScenario(config *config.LoadConfig, deployer *types2.Account) common.Address { +func (s *EVMTransferNoopScenario) DeployScenario(config *config.LoadConfig, deployer types2.Account, nonce uint64) common.Address { // No deployment needed for simple ETH transfers // Return zero address to indicate no contract deployment return common.Address{} @@ -48,7 +48,7 @@ func (s *EVMTransferNoopScenario) AttachScenario(config *config.LoadConfig, addr func (s *EVMTransferNoopScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ - Nonce: scenario.Sender.Nonce, + Nonce: scenario.Nonce, To: &scenario.Sender.Address, Value: big.NewInt(0), Gas: 21000, // Standard gas limit for ETH transfer diff --git a/generator/scenarios/base.go b/generator/scenarios/base.go index 0d31edb..6586fd8 100644 --- a/generator/scenarios/base.go +++ b/generator/scenarios/base.go @@ -24,9 +24,9 @@ var bigOne = big.NewInt(1) // TxGenerator defines the interface for generating transactions. type TxGenerator interface { Name() string - Generate(rng *mrand.Rand, scenario *types.TxScenario) *types.LoadTx + Generate(rng *mrand.Rand, scenario *types.TxScenario) (*ethtypes.Transaction, error) Attach(config *config.LoadConfig, address common.Address) error - Deploy(config *config.LoadConfig, deployer *types.Account) common.Address + Deploy(config *config.LoadConfig, deployer types.Account, nonce uint64) common.Address } // ScenarioDeployer defines the interface for scenario-specific deployment logic @@ -35,7 +35,7 @@ type ScenarioDeployer interface { // DeployScenario handles any setup required for the scenario // For contracts: deploys the contract and returns its address // For non-contracts: performs any initialization and returns zero address - DeployScenario(config *config.LoadConfig, deployer *types.Account) common.Address + DeployScenario(config *config.LoadConfig, deployer types.Account, nonce uint64) common.Address // AttachScenario connects to an existing contract. AttachScenario(config *config.LoadConfig, address common.Address) common.Address @@ -84,9 +84,9 @@ func NewScenarioBase(deployer ScenarioDeployer, cfg config.Scenario) *ScenarioBa } // Deploy handles the common deployment flow -func (s *ScenarioBase) Deploy(config *config.LoadConfig, deployer *types.Account) common.Address { +func (s *ScenarioBase) Deploy(config *config.LoadConfig, deployer types.Account, nonce uint64) common.Address { s.config = config - s.address = s.deployer.DeployScenario(config, deployer) + s.address = s.deployer.DeployScenario(config, deployer, nonce) s.deployed = true return s.address } @@ -100,18 +100,12 @@ func (s *ScenarioBase) Attach(config *config.LoadConfig, address common.Address) } // Generate handles the common transaction generation flow -func (s *ScenarioBase) Generate(rng *mrand.Rand, scenario *types.TxScenario) *types.LoadTx { +func (s *ScenarioBase) Generate(rng *mrand.Rand, scenario *types.TxScenario) (*ethtypes.Transaction, error) { if !s.deployed { - panic("Scenario not deployed/initialized") + return nil, fmt.Errorf("Scenario not deployed/initialized") } - // Create transaction using scenario-specific logic - tx, err := s.deployer.CreateTransaction(rng, s.config, scenario) - if err != nil { - panic("Failed to create transaction: " + err.Error()) - } - - return types.CreateTxFromEthTx(tx, scenario) + return s.deployer.CreateTransaction(rng, s.config, scenario) } // GetConfig returns the configuration @@ -132,9 +126,7 @@ type ContractScenarioBase[T any] struct { // NewContractScenarioBase creates a new base scenario with the given contract deployer func NewContractScenarioBase[T any](deployer ContractDeployer[T], cfg config.Scenario) *ContractScenarioBase[T] { - base := &ContractScenarioBase[T]{ - deployer: deployer, - } + base := &ContractScenarioBase[T]{deployer: deployer} base.ScenarioBase = NewScenarioBase(base, cfg) return base } @@ -166,14 +158,14 @@ func (c *ContractScenarioBase[T]) AttachScenario(config *config.LoadConfig, addr } // DeployScenario implements ScenarioDeployer interface for contract scenarios -func (c *ContractScenarioBase[T]) DeployScenario(config *config.LoadConfig, deployer *types.Account) common.Address { +func (c *ContractScenarioBase[T]) DeployScenario(config *config.LoadConfig, deployer types.Account, nonce uint64) common.Address { client, err := dial(config) if err != nil { panic("Failed to connect to Ethereum client: " + err.Error()) } // Create deployment options - auth, err := utils.CreateDeploymentOpts(config.GetChainID(), client, deployer) + auth, err := utils.CreateDeploymentOpts(config.GetChainID(), client, deployer, nonce) if err != nil { panic("Failed to create deployment options: " + err.Error()) } diff --git a/generator/utils/utils.go b/generator/utils/utils.go index c414cc3..c485bd5 100644 --- a/generator/utils/utils.go +++ b/generator/utils/utils.go @@ -4,40 +4,12 @@ import ( "math/big" "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" - - "github.com/sei-protocol/sei-load/config" loadtypes "github.com/sei-protocol/sei-load/types" ) -type DeployFunc[T any] func( - opts *bind.TransactOpts, - client *ethclient.Client) (common.Address, *ethtypes.Transaction, *T, error) - -func Deploy[T any](config *config.LoadConfig, deployer *loadtypes.Account, deployFunc DeployFunc[T]) common.Address { - client, err := ethclient.Dial(config.Endpoints[0]) - if err != nil { - panic("Failed to connect to Ethereum client: " + err.Error()) - } - // Use the utility function to create transaction options - - auth, err := CreateDeploymentOpts(big.NewInt(config.ChainID), client, deployer) - if err != nil { - panic("Failed to create deployment options: " + err.Error()) - } - - addr, _, _, err := deployFunc(auth, client) - if err != nil { - panic("Failed to deploy contract: " + err.Error()) - } - - return addr -} - // CreateTransactOpts creates transaction options for contract deployment or interaction -func createTransactOpts(chainID *big.Int, account *loadtypes.Account, gasLimit uint64, noSend bool) (*bind.TransactOpts, error) { +func createTransactOpts(chainID *big.Int, account loadtypes.Account, gasLimit uint64, nonce uint64, noSend bool) (*bind.TransactOpts, error) { // Create transactor auth, err := bind.NewKeyedTransactorWithChainID(account.PrivKey, chainID) if err != nil { @@ -45,7 +17,7 @@ func createTransactOpts(chainID *big.Int, account *loadtypes.Account, gasLimit u } // Set transaction parameters - auth.Nonce = big.NewInt(int64(account.Nonce)) + auth.Nonce = big.NewInt(int64(nonce)) auth.NoSend = noSend auth.GasLimit = gasLimit @@ -56,15 +28,15 @@ func createTransactOpts(chainID *big.Int, account *loadtypes.Account, gasLimit u } // CreateDeploymentOpts creates transaction options specifically for contract deployment -func CreateDeploymentOpts(chainID *big.Int, client *ethclient.Client, account *loadtypes.Account) (*bind.TransactOpts, error) { +func CreateDeploymentOpts(chainID *big.Int, client *ethclient.Client, account loadtypes.Account, nonce uint64) (*bind.TransactOpts, error) { // For deployment, use the account's current nonce (don't fetch from blockchain) // This allows sequential deployments with incrementing nonces - return createTransactOpts(chainID, account, 3000000, false) // 3M gas limit for deployment + return createTransactOpts(chainID, account, 3000000, nonce, false) // 3M gas limit for deployment } // CreateTransactionOpts creates transaction options for regular contract interactions func CreateTransactionOpts(chainID *big.Int, scenario *loadtypes.TxScenario) *bind.TransactOpts { - opts, err := createTransactOpts(chainID, scenario.Sender, 200000, true) // 200k gas limit for transactions + opts, err := createTransactOpts(chainID, scenario.Sender, 200000, scenario.Nonce, true) // 200k gas limit for transactions if err != nil { panic("Failed to create transaction options: " + err.Error()) } diff --git a/main.go b/main.go index ca0048d..36115f3 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,9 @@ import ( "github.com/spf13/cobra" "go.opentelemetry.io/otel" "golang.org/x/time/rate" + "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/funder" "github.com/sei-protocol/sei-load/generator" @@ -277,6 +279,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { } var snd sender.TxSender + q := types.NewTxsQueue() if cfg.Settings.TxsDir != "" { // get latest height eth, err := ethclient.Dial(cfg.Endpoints[0]) @@ -295,7 +298,11 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Fund the pool before prewarm/dispatch — both spend gas the accounts // don't have until funded. if cfg.Funding != nil && !cfg.Settings.DryRun { - if err := funder.FundAccounts(ctx, cfg, gen.Accounts()); err != nil { + var addrs []common.Address + for _,a := range gen.Accounts() { + addrs = append(addrs,a.Address) + } + if err := funder.FundAccounts(ctx, cfg, addrs); err != nil { return fmt.Errorf("failed to fund accounts: %w", err) } } @@ -305,22 +312,16 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { return fmt.Errorf("failed to create sender: %w", err) } // Start the sender (starts all workers) - s.SpawnBgNamed("sender", func() error { return sharedSender.Run(ctx) }) snd = sharedSender log.Printf("✅ Connected to %d endpoints", len(cfg.Endpoints)) - } + s.SpawnBgNamed("sender", func() error { return snd.Run(ctx,q) }) // Set up prewarming if enabled if cfg.Settings.Prewarm { log.Printf("🔥 Creating prewarm generator...") - txs := gen.PrewarmTxs(rng,cfg) - log.Printf("✅ Prewarm generator ready") - log.Printf("📝 Prewarm mode: Accounts will be prewarmed") - for _,tx := range txs { - if err:=snd.Send(ctx, tx); err!=nil { - return fmt.Errorf("failed to prewarm accounts: %w", err) - } + if err:=gen.Prewarm(ctx, rng, cfg, q); err!=nil { + return fmt.Errorf("gen.Prewarm(): %w",err) } log.Printf("🔥 Prewarming complete! Processed %d accounts", len(txs)) } @@ -330,7 +331,9 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { log.Printf("✅ Started statistics logger") // Start dispatcher for main load test - s.SpawnBgNamed("dispatcher", func() error { return sender.Run(ctx, rng, gen, snd) }) + s.SpawnBgNamed("generator", func() error { return gen.Run(ctx, rng, q) }) + + s.SpawnBgNamed("sender", func() error { return snd.Run(ctx, q) }) log.Printf("✅ Started dispatcher") // Set up signal handling for graceful shutdown diff --git a/sender/dispatcher.go b/sender/dispatcher.go deleted file mode 100644 index e60fdfe..0000000 --- a/sender/dispatcher.go +++ /dev/null @@ -1,23 +0,0 @@ -package sender - -import ( - "context" - "github.com/sei-protocol/sei-load/generator" - mrand "math/rand/v2" - "time" -) - -// Run begins the dispatcher's transaction generation and sending loop, using -// the configured arrival model. -func Run(ctx context.Context, rng *mrand.Rand, gen *generator.Generator, snd TxSender) error { - for ctx.Err() == nil { - // TODO: make AccountRegistry a proper queue. - // Generate a transaction from main generator - gen.Generate(rng) - // Send the transaction - if err := snd.Send(ctx, tx); err != nil { - return err - } - } - return nil -} diff --git a/sender/sender.go b/sender/sender.go index a2b92b5..49c957c 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -6,5 +6,6 @@ import ( ) type TxSender interface { + Run(ctx context.Context, q *types.TxsQueue) error Send(ctx context.Context, tx *types.LoadTx) error } diff --git a/sender/sender_test.go b/sender/sender_test.go index 825a874..8ca61df 100644 --- a/sender/sender_test.go +++ b/sender/sender_test.go @@ -113,7 +113,8 @@ func TestShardDistributionVerification(t *testing.T) { } mockTx := &types.LoadTx{ - EthTx: ethtypes.NewTransaction(0, common.Address{}, big.NewInt(0), 21000, big.NewInt(1000000000), nil), + Sender: common.HexToAddress("0x1234567890123456789012345678901234567890"), + EthTx: ethtypes.NewTransaction(0, common.Address{}, big.NewInt(0), 21000, big.NewInt(1000000000), nil), Scenario: &types.TxScenario{ Name: "TestScenario", Sender: mockAccount, diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 8f14c40..7ae9498 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -63,13 +63,12 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * } // Send implements TxSender interface - calculates shard ID and routes to appropriate worker -// TODO: make it respect Settings.MaxInFlight func (s *ShardedSender) Send(ctx context.Context, tx *types.LoadTx) error { return s.shards[tx.ShardID(len(s.shards))].Send(ctx, tx) } // Start initializes and starts all workers -func (ss *ShardedSender) Run(ctx context.Context) error { +func (ss *ShardedSender) Run(ctx context.Context, q *types.TxsQueue) error { cancel := meteredSenders.MustRegister(ss) defer cancel() return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { diff --git a/stats/inclusion_tracker_test.go b/stats/inclusion_tracker_test.go index cf00cac..3fd2dfb 100644 --- a/stats/inclusion_tracker_test.go +++ b/stats/inclusion_tracker_test.go @@ -72,6 +72,7 @@ func loadTx(nonce uint64, intended time.Time) *types.LoadTx { Value: big.NewInt(0), }) return &types.LoadTx{ + Sender: common.Address{}, EthTx: eth, Scenario: &types.TxScenario{Name: "test"}, IntendedSendTime: intended, diff --git a/types/account.go b/types/account.go index 44fad86..06aab08 100644 --- a/types/account.go +++ b/types/account.go @@ -12,32 +12,24 @@ import ( type Account struct { Address common.Address PrivKey *ecdsa.PrivateKey - Nonce uint64 - Txs []*LoadTx + Tracked bool } // NewAccount generates new account. -func NewAccount() *Account { +func NewAccount(tracked bool) Account { privateKey := utils.OrPanic1(crypto.GenerateKey()) - return &Account{ + return Account{ Address: crypto.PubkeyToAddress(privateKey.PublicKey), PrivKey: privateKey, + Tracked: tracked, } } -func (s *Account) PushTx(tx *LoadTx) { - if tx.EthTx.Nonce()!=s.Nonce { - return - } - s.Nonce += 1 - s.Txs = append(s.Txs,tx) -} - // GenerateAccounts generates random accounts. -func GenerateAccounts(n int) []*Account { - result := make([]*Account, n) +func GenerateAccounts(n int, tracked bool) []Account { + result := make([]Account, n) for i := range result { - result[i] = NewAccount() + result[i] = NewAccount(tracked) } return result } diff --git a/types/account_pool.go b/types/account_pool.go index 81809b3..868169d 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -1,48 +1,79 @@ package types import ( + "context" + "time" + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" mrand "math/rand/v2" "sync" - "github.com/ethereum/go-ethereum/common" ) +type accQueue struct { + Txs []*LoadTx + Nonce uint64 +} + // AccountRegistry owns account pools created for a run. -type AccountRegistry struct { - accounts map[common.Address]*Account - newAccountsTxs []*LoadTx - pools []*AccountPool +type TxsQueue struct { + byAddr map[common.Address]*accQueue + untracked []*LoadTx } // NewAccountRegistry creates an empty account registry. -func NewAccountRegistry() *AccountRegistry { - return &AccountRegistry{ - accounts: map[common.Address]*Account{}, +func NewTxsQueue() *TxsQueue { + return &TxsQueue{byAddr: map[common.Address]*accQueue{}} +} + +func (q *TxsQueue) Push(ctx context.Context, scenario *TxScenario, tx *ethtypes.Transaction) error { + // TODO: make it respect Settings.MaxInFlight + // TODO: make it blocking + sender := scenario.Sender + ltx := &LoadTx{EthTx:tx,IntendedSendTime:time.Now(),Scenario:scenario} + if sender.Tracked { + aq, ok := q.byAddr[sender.Address] + if !ok { + aq = &accQueue{} + q.byAddr[sender.Address] = aq + } + if aq.Nonce != tx.Nonce() { + return nil + } + aq.Nonce += 1 + aq.Txs = append(aq.Txs, ltx) + } else { + if tx.Nonce() != 0 { + return nil + } + q.untracked = append(q.untracked, ltx) } + return nil } -func (r *AccountRegistry) ResetNonce(addr common.Address, nonce uint64) { - if a,ok := r.accounts[addr]; ok { - a.Txs = nil - a.Nonce = nonce +func (q *TxsQueue) WaitUntilEmpty(ctx context.Context) error { + panic("unimplemented") +} + +func (q *TxsQueue) Nonce(addr common.Address) uint64 { + if aq, ok := q.byAddr[addr]; ok { + return aq.Nonce } + return 0 } -// Accounts returns a flat copy of all accounts across all pools. -func (r *AccountRegistry) Accounts() []*Account { - var accounts []*Account - for _, pool := range r.pools { - accounts = append(accounts, pool.GetAccounts()...) +func (q *TxsQueue) ResetNonce(addr common.Address, nonce uint64) { + if a, ok := q.byAddr[addr]; ok { + a.Txs = nil + a.Nonce = nonce } - return accounts } // AccountPool returns a next account for load generation. type AccountPool struct { - Accounts []*Account - cfg *AccountConfig - - mx sync.Mutex - idx int + newAccountRate float64 + accounts []Account + mx sync.Mutex + idx int } // AccountConfig stores the configuration for account generation. @@ -55,36 +86,28 @@ func (a *AccountPool) nextIndex() int { a.mx.Lock() defer a.mx.Unlock() a.idx++ - a.idx %= len(a.Accounts) + a.idx %= len(a.accounts) return a.idx } // NextAccount returns the next account, using rng for the new-account roll when // NewAccountRate > 0. -func (a *AccountPool) NextAccount(rng *mrand.Rand) *Account { - if a.cfg.NewAccountRate > 0 { - if rng.Float64() <= a.cfg.NewAccountRate { - return GenerateAccounts(1)[0] +func (a *AccountPool) NextAccount(rng *mrand.Rand) Account { + if a.newAccountRate > 0 { + if rng.Float64() <= a.newAccountRate { + return NewAccount(false) } } - return a.Accounts[a.nextIndex()] + return a.accounts[a.nextIndex()] } // GetAccounts returns the fixed accounts backing the pool. -func (a *AccountPool) GetAccounts() []*Account { - return a.Accounts -} +func (a *AccountPool) Accounts() []Account { return a.accounts } // NewPool creates a new account generator from a config, records it, and returns it. -func (r *AccountRegistry) NewPool(cfg *AccountConfig) *AccountPool { - accounts := GenerateAccounts(cfg.InitialSize) - pool := &AccountPool{ - Accounts: accounts, - cfg: cfg, - } - for _,a := range accounts { - r.accounts[a.Address] = a +func NewAccountPool(size int, newAccountRate float64) *AccountPool { + return &AccountPool{ + accounts: GenerateAccounts(size, true), + newAccountRate: newAccountRate, } - r.pools = append(r.pools, pool) - return pool } diff --git a/types/scenario.go b/types/scenario.go index 2630ed5..7a2c7ae 100644 --- a/types/scenario.go +++ b/types/scenario.go @@ -2,7 +2,6 @@ package types import ( "encoding/json" - "fmt" "math/big" "time" @@ -23,10 +22,8 @@ import ( // prewarm txs, or a stage not yet reached) — consumers must treat it as // untracked, never as the zero epoch. type LoadTx struct { - EthTx *ethtypes.Transaction - JSONRPCPayload []byte - Payload []byte - Scenario *TxScenario + EthTx *ethtypes.Transaction + Scenario *TxScenario // IntendedSendTime is when the tx was scheduled to be sent. In the open-loop // arrival model the scheduler writes the true scheduled instant t₀ + i/λ @@ -65,20 +62,6 @@ type JSONRPCRequest struct { Params json.RawMessage `json:"params,omitempty"` } -func toJSONRequestBytes(rawTx []byte) ([]byte, error) { - req := &JSONRPCRequest{ - Version: "2.0", - Method: "eth_sendRawTransaction", - Params: json.RawMessage(fmt.Sprintf(`["0x%x"]`, rawTx)), - ID: json.RawMessage("0"), - } - b, err := json.Marshal(req) - if err != nil { - return nil, err - } - return b, nil -} - // ShardID returns the shard id for the given number of shards. func (tx *LoadTx) ShardID(n int) int { addressBigInt := new(big.Int).SetBytes(tx.Scenario.Sender.Address.Bytes()) @@ -89,29 +72,16 @@ func (tx *LoadTx) ShardID(n int) int { // TxScenario captures the scenario of this test transaction. type TxScenario struct { Name string - Sender *Account + Nonce uint64 + Sender Account Receiver common.Address } // CreateTxFromEthTx creates a LoadTx from an EthTx (pre-marshaled). func CreateTxFromEthTx(tx *ethtypes.Transaction, scenario *TxScenario) *LoadTx { - // Convert to raw transaction bytes for JSON-RPC payload - rawTx, err := tx.MarshalBinary() - if err != nil { - panic("Failed to marshal transaction: " + err.Error()) - } - - // Create JSON-RPC payload - jsonRPCPayload, err := toJSONRequestBytes(rawTx) - if err != nil { - panic("Failed to create JSON-RPC payload: " + err.Error()) - } - // Return the complete LoadTx object return &LoadTx{ - EthTx: tx, - JSONRPCPayload: jsonRPCPayload, - Payload: rawTx, - Scenario: scenario, + EthTx: tx, + Scenario: scenario, } } diff --git a/types/types_test.go b/types/types_test.go index 73781a9..8fdf08f 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -10,8 +10,8 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" - "github.com/sei-protocol/sei-load/utils/rng" "github.com/sei-protocol/sei-load/utils/require" + "github.com/sei-protocol/sei-load/utils/rng" ) func TestNewAccount(t *testing.T) { @@ -278,6 +278,7 @@ func TestCreateTxFromEthTx(t *testing.T) { // Verify LoadTx structure require.NotNil(t, loadTx) require.Equal(t, tx, loadTx.EthTx) + require.Equal(t, scenario.Sender.Address, loadTx.Sender) require.Equal(t, scenario, loadTx.Scenario) require.NotEmpty(t, loadTx.JSONRPCPayload) require.NotEmpty(t, loadTx.Payload) From a9677779837ca182d1df2316881f438cfd8268ad Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Fri, 26 Jun 2026 14:17:32 +0200 Subject: [PATCH 19/21] sender rewrite --- main.go | 7 +- sender/eth_client.go | 174 ++++++++++++++------------------------- sender/sharded_sender.go | 118 +++++++++----------------- sender/writer.go | 28 ++++--- types/account_pool.go | 4 + 5 files changed, 125 insertions(+), 206 deletions(-) diff --git a/main.go b/main.go index 36115f3..f0fb691 100644 --- a/main.go +++ b/main.go @@ -278,7 +278,6 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { }) } - var snd sender.TxSender q := types.NewTxsQueue() if cfg.Settings.TxsDir != "" { // get latest height @@ -293,7 +292,8 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { numBlocksToWrite := cfg.Settings.NumBlocksToWrite writerHeight := latestHeight + 10 // some buffer log.Printf("🔍 Latest height: %d, writer start height: %d", latestHeight, writerHeight) - snd = sender.NewTxsWriter(cfg.Settings.TargetGas, cfg.Settings.TxsDir, writerHeight, uint64(numBlocksToWrite)) + writer := sender.NewTxsWriter(cfg.Settings.TargetGas, cfg.Settings.TxsDir, writerHeight, uint64(numBlocksToWrite)) + s.SpawnBgNamed("writer", func() error { return writer.Run(ctx,q) }) } else { // Fund the pool before prewarm/dispatch — both spend gas the accounts // don't have until funded. @@ -312,10 +312,9 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { return fmt.Errorf("failed to create sender: %w", err) } // Start the sender (starts all workers) - snd = sharedSender + s.SpawnBgNamed("sender", func() error { return sharedSender.Run(ctx,q) }) log.Printf("✅ Connected to %d endpoints", len(cfg.Endpoints)) } - s.SpawnBgNamed("sender", func() error { return snd.Run(ctx,q) }) // Set up prewarming if enabled if cfg.Settings.Prewarm { diff --git a/sender/eth_client.go b/sender/eth_client.go index 424f5d9..7f695ad 100644 --- a/sender/eth_client.go +++ b/sender/eth_client.go @@ -10,62 +10,60 @@ import ( "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" - "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/types" - "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/scope" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" + "github.com/sei-protocol/sei-load/stats" ) var tracer = otel.Tracer("github.com/sei-protocol/sei-load/sender") -type sendReq struct { - tx *types.LoadTx - done chan error -} - type ethClientConfig struct { ChainID string - ID int - Endpoint string - Tasks int - Debug bool - DryRun bool + Endpoints []string Collector *stats.Collector - Inclusion utils.Option[*stats.InclusionTracker] } type ethClient struct { cfg *ethClientConfig - reqs chan sendReq + clients []*ethclient.Client } -func (c *ethClient) Run(ctx context.Context) error { - u, err := url.Parse(c.cfg.Endpoint) - if err != nil { - return fmt.Errorf("parse endpoint %q: %w", c.cfg.Endpoint, err) - } - var opts []rpc.ClientOption - switch u.Scheme { - case "http", "https": - opts = append(opts, rpc.WithHTTPClient(newHttpClient())) +func (c *ethClient) Close() { + for _,eth := range c.clients { + eth.Close() } - rpcClient, err := rpc.DialOptions(ctx, c.cfg.Endpoint, opts...) - if err != nil { - return fmt.Errorf("rpc.Dial(%q): %w", c.cfg.Endpoint, err) - } - client := ethclient.NewClient(rpcClient) - defer client.Close() - return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - for range c.cfg.Tasks { - s.Spawn(func() error { return c.runSender(ctx, client) }) +} + +func newEthClient(ctx context.Context, cfg *ethClientConfig) (_ *ethClient,err error) { + var clients []*ethclient.Client + defer func() { + if err!=nil { + for _,eth := range clients { + eth.Close() + } } - return nil - }) + }() + for _,endpoint := range cfg.Endpoints { + u, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("parse endpoint %q: %w", endpoint, err) + } + var opts []rpc.ClientOption + switch u.Scheme { + case "http", "https": + opts = append(opts, rpc.WithHTTPClient(newHttpClient())) + } + rpcClient, err := rpc.DialOptions(ctx, endpoint, opts...) + if err != nil { + return nil, fmt.Errorf("rpc.Dial(%q): %w", endpoint, err) + } + clients = append(clients, ethclient.NewClient(rpcClient)) + } + return ðClient{cfg:cfg,clients:clients},nil } // newHttpClient returns an otelhttp-wrapped client: injects traceparent on @@ -90,90 +88,42 @@ func newHttpClient() *http.Client { } } -func newEthClient(cfg *ethClientConfig) *ethClient { - return ðClient{ - cfg: cfg, - reqs: make(chan sendReq), - } -} - -// Send queues a transaction for this endpoint client to process. -func (c *ethClient) Send(ctx context.Context, tx *types.LoadTx) error { - done := make(chan error, 1) - if err := utils.Send(ctx, c.reqs, sendReq{tx, done}); err != nil { - return err - } - err, recvErr := utils.Recv(ctx, done) - if recvErr != nil { - return recvErr - } - return err -} - -// runSender handles the tx send requests. -func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client) error { - for ctx.Err() == nil { - req, err := utils.Recv(ctx, c.reqs) - if err != nil { - return err - } - - startTime := time.Now() - // This goroutine solely owns tx between dequeue and the sentTxs hand-off, - // so stamping the actual send-attempt time here is race-free (see LoadTx). - req.tx.AttemptedSendTime = startTime - err = c.sendTx(ctx, client, req.tx) - req.done <- err - c.cfg.Collector.RecordTransaction(req.tx.Scenario.Name, c.cfg.Endpoint, time.Since(startTime), err == nil) - if err == nil { - if t, ok := c.cfg.Inclusion.Get(); ok { - t.Register(req.tx) - } - } - } - return ctx.Err() -} - -func (c *ethClient) sendTx(ctx context.Context, eth *ethclient.Client, tx *types.LoadTx) (_err error) { +func (c *ethClient) Send(ctx context.Context, tx *types.LoadTx) (_err error) { + // TODO: make client stickiness optional + id := tx.ShardID(len(c.cfg.Endpoints)) ctx, span := tracer.Start(ctx, "sender.send_tx", trace.WithAttributes( attribute.String("seiload.scenario", tx.Scenario.Name), - attribute.String("seiload.endpoint", c.cfg.Endpoint), - attribute.Int("seiload.worker_id", c.cfg.ID), + attribute.String("seiload.endpoint", c.cfg.Endpoints[id]), + attribute.Int("seiload.worker_id", id), attribute.String("seiload.chain_id", c.cfg.ChainID), )) - defer func(start time.Time) { - if _err != nil { - span.RecordError(_err) - } - span.End() - // Record inside the span ctx so exemplars link to the trace. - sendLatency.Record(ctx, time.Since(start).Seconds(), - metric.WithAttributes( - attribute.String("scenario", tx.Scenario.Name), - attribute.String("endpoint", c.cfg.Endpoint), - attribute.String("chain_id", c.cfg.ChainID), - statusAttrFromError(_err)), - ) - }(time.Now()) - if c.cfg.DryRun { - // In dry-run mode, simulate processing time and mark as successful - // Use very minimal delay to avoid channel overflow - return utils.Sleep(ctx, 10*time.Microsecond) // Much faster simulation - } - - // Send through go-ethereum so the same code path supports both HTTP(S) and WS(S) RPC. - if err := eth.SendTransaction(ctx, tx.EthTx); err != nil { + defer span.End() + start := time.Now() + // This goroutine solely owns tx between dequeue and the sentTxs hand-off, + // so stamping the actual send-attempt time here is race-free (see LoadTx). + tx.AttemptedSendTime = start + err := c.clients[id].SendTransaction(ctx, tx.EthTx) + // Record inside the span ctx so exemplars link to the trace. + sendLatency.Record(ctx, time.Since(start).Seconds(), + metric.WithAttributes( + attribute.String("scenario", tx.Scenario.Name), + attribute.String("endpoint", c.cfg.Endpoints[id]), + attribute.String("chain_id", c.cfg.ChainID), + statusAttrFromError(err)), + ) + if err != nil { txsRejected.Add(ctx, 1, metric.WithAttributes( - attribute.String("endpoint", c.cfg.Endpoint), + attribute.String("endpoint", c.cfg.Endpoints[id]), attribute.String("scenario", tx.Scenario.Name), attribute.String("reason", "rpc"), )) - return fmt.Errorf("eth.SendTransaction(): %w", err) + span.RecordError(err) + } else { + txsAccepted.Add(ctx, 1, metric.WithAttributes( + attribute.String("endpoint", c.cfg.Endpoints[id]), + attribute.String("scenario", tx.Scenario.Name), + )) } - - txsAccepted.Add(ctx, 1, metric.WithAttributes( - attribute.String("endpoint", c.cfg.Endpoint), - attribute.String("scenario", tx.Scenario.Name), - )) - return nil + c.cfg.Collector.RecordTransaction(tx.Scenario.Name, c.cfg.Endpoints[id], time.Since(start), err == nil) + return err } diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 7ae9498..4da8f6a 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -3,7 +3,7 @@ package sender import ( "context" "fmt" - "log" + "time" "golang.org/x/time/rate" @@ -18,101 +18,59 @@ import ( type ShardedSender struct { cfg *config.LoadConfig limiter *rate.Limiter // Shared rate limiter for transaction sending - clients []*ethClient - shards []*Queue[*types.LoadTx] + collector *stats.Collector + inclusion utils.Option[*stats.InclusionTracker] } // NewShardedSender creates a new sharded sender. // Txs of each shard are sent sequentially, using a single eth client. -func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector, inclusion utils.Option[*stats.InclusionTracker]) (*ShardedSender, error) { - if len(cfg.Endpoints) == 0 { - return nil, fmt.Errorf("no endpoints configured") - } - numShards := cfg.GetNumShards() - if numShards <= 0 { - return nil, fmt.Errorf("no shards configured") - } - totalQueueSize := cfg.TotalQueueSize() - if totalQueueSize <= 0 { - return nil, fmt.Errorf("queue size has to be positive") - } - var clients []*ethClient - for id, endpoint := range cfg.Endpoints { - clients = append(clients, newEthClient(ðClientConfig{ - ChainID: cfg.SeiChainID, - ID: id, - Endpoint: endpoint, - Tasks: cfg.Settings.TasksPerEndpoint, - DryRun: cfg.Settings.DryRun, - Debug: cfg.Settings.Debug, - Collector: collector, - Inclusion: inclusion, - })) - } - pool := NewQueuePool[*types.LoadTx](totalQueueSize) - var shards []*Queue[*types.LoadTx] - for range numShards { - shards = append(shards, pool.NewQueue()) - } +func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector, inclusion utils.Option[*stats.InclusionTracker]) *ShardedSender { return &ShardedSender{ cfg: cfg, limiter: limiter, - clients: clients, - shards: shards, - }, nil -} - -// Send implements TxSender interface - calculates shard ID and routes to appropriate worker -func (s *ShardedSender) Send(ctx context.Context, tx *types.LoadTx) error { - return s.shards[tx.ShardID(len(s.shards))].Send(ctx, tx) + collector: collector, + inclusion: inclusion, + } } // Start initializes and starts all workers -func (ss *ShardedSender) Run(ctx context.Context, q *types.TxsQueue) error { +func (ss *ShardedSender) Run(ctx context.Context, q *types.TxsQueue) error { + if len(ss.cfg.Endpoints) == 0 { + return fmt.Errorf("no endpoints configured") + } cancel := meteredSenders.MustRegister(ss) defer cancel() + client,err := newEthClient(ctx,ðClientConfig{ + ChainID: ss.cfg.SeiChainID, + Endpoints: ss.cfg.Endpoints, + Collector: ss.collector, + }) + if err!=nil { + return fmt.Errorf("newEthClient(): %w",err) + } + defer client.Close() return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - for _, client := range ss.clients { - s.Spawn(func() error { return client.Run(ctx) }) - } - for i, shard := range ss.shards { + for { + if err := ss.limiter.Wait(ctx); err != nil { + return err + } + tx,ack,err := q.Pop(ctx) + if err!=nil { return err } s.Spawn(func() error { - client := ss.clients[i%len(ss.clients)] - for ctx.Err() == nil { - tx, err := shard.Recv(ctx) - if err != nil { - return err - } - if err := ss.limiter.Wait(ctx); err != nil { - return err - } - if err := client.Send(ctx, tx); err != nil { - log.Printf("%v", err) - } + defer ack() + if ss.cfg.Settings.DryRun { + // In dry-run mode, simulate processing time and mark as successful + // Use very minimal delay to avoid channel overflow + return utils.Sleep(ctx, 10*time.Microsecond) // Much faster simulation + } + if err := client.Send(ctx,tx); err != nil { + return err } - return ctx.Err() + if inclusion, ok := ss.inclusion.Get(); ok { + inclusion.Register(tx) + } + return nil }) } - return nil }) } - -type ShardStats struct { - ChainID string - ID int - Endpoint string - TxsQueued int -} - -func (ss *ShardedSender) ShardStats() []ShardStats { - var stats []ShardStats - for i, shard := range ss.shards { - stats = append(stats, ShardStats{ - ChainID: ss.cfg.SeiChainID, - ID: i, - Endpoint: ss.clients[i%len(ss.clients)].cfg.Endpoint, - TxsQueued: shard.Len(), - }) - } - return stats -} diff --git a/sender/writer.go b/sender/writer.go index 8be5542..90c5ac3 100644 --- a/sender/writer.go +++ b/sender/writer.go @@ -39,18 +39,24 @@ func NewTxsWriter(gasPerBlock uint64, txsDir string, startHeight uint64, numBloc } // Send writes the transaction to the writer -func (w *TxsWriter) Send(ctx context.Context, tx *types.LoadTx) error { - // if bwe would exceed gasPerBlock, flush - if w.bufferGas+tx.EthTx.Gas() > w.gasPerBlock { - if err := w.Flush(); err != nil { +func (w *TxsWriter) Run(ctx context.Context, q *types.TxsQueue) error { + for { + tx,err := q.Pop(ctx) + if err!=nil { return err } - } + // if bwe would exceed gasPerBlock, flush + if w.bufferGas+tx.EthTx.Gas() > w.gasPerBlock { + if err := w.Flush(); err != nil { + return err + } + } - // add to buffer - w.txBuffer = append(w.txBuffer, tx) - w.bufferGas += tx.EthTx.Gas() - return nil + // add to buffer + w.txBuffer = append(w.txBuffer, tx) + w.bufferGas += tx.EthTx.Gas() + return nil + } } type TxWriteData struct { @@ -76,7 +82,9 @@ func (w *TxsWriter) Flush() error { TxPayloads: make([][]byte, 0), } for _, tx := range w.txBuffer { - txData.TxPayloads = append(txData.TxPayloads, tx.Payload) + payload,err := tx.EthTx.MarshalBinary() + if err!=nil { return fmt.Errorf("tx.EthTx.MarshalBinary(): %w",err) } + txData.TxPayloads = append(txData.TxPayloads, payload) } txDataJSON, err := json.Marshal(txData) diff --git a/types/account_pool.go b/types/account_pool.go index 868169d..03a5fd0 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -25,6 +25,10 @@ func NewTxsQueue() *TxsQueue { return &TxsQueue{byAddr: map[common.Address]*accQueue{}} } +func (q *TxsQueue) Pop(ctx context.Context) (tx *LoadTx, ack func(), err error) { + panic("unimplemented") +} + func (q *TxsQueue) Push(ctx context.Context, scenario *TxScenario, tx *ethtypes.Transaction) error { // TODO: make it respect Settings.MaxInFlight // TODO: make it blocking From 3a5d556bce6eedd7233bae06b7e1fb5ec5d4e3c1 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Fri, 26 Jun 2026 15:04:50 +0200 Subject: [PATCH 20/21] fmt --- generator/generator.go | 22 +++++++++++++++------- main.go | 24 ++++++++++-------------- sender/eth_client.go | 20 ++++++++++---------- sender/metrics.go | 5 +++-- sender/sharded_sender.go | 26 ++++++++++++++------------ sender/writer.go | 12 +++++++----- types/account_pool.go | 4 ++-- 7 files changed, 61 insertions(+), 52 deletions(-) diff --git a/generator/generator.go b/generator/generator.go index 2a0de7a..4b11a87 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -120,7 +120,7 @@ func (g *generatorBuilder) deployAll() error { return nil } -type Generator struct {scenarios []*scenarioInstance} +type Generator struct{ scenarios []*scenarioInstance } func (g *Generator) Accounts() []types.Account { accs := map[common.Address]types.Account{} @@ -146,9 +146,13 @@ func (g *Generator) Prewarm(ctx context.Context, rng *mrand.Rand, cfg *config.Lo Sender: account, Receiver: account.Address, // Send to self } - tx,err := evmScenario.Generate(rng, scenario) - if err!=nil { return fmt.Errorf("evmScenario.Generate(): %w",err) } - if err := q.Push(ctx,scenario,tx); err!=nil { return err } + tx, err := evmScenario.Generate(rng, scenario) + if err != nil { + return fmt.Errorf("evmScenario.Generate(): %w", err) + } + if err := q.Push(ctx, scenario, tx); err != nil { + return err + } } return q.WaitUntilEmpty(ctx) } @@ -169,9 +173,13 @@ func (w *Generator) Run(ctx context.Context, rng *mrand.Rand, q *types.TxsQueue) Sender: sender, Receiver: receiver.Address, } - tx,err := g.Scenario.Generate(rng, scenario) - if err!=nil { return fmt.Errorf("g.Scenario.Generate(): %w",err) } - if err:=q.Push(ctx,scenario,tx); err!=nil { return err } + tx, err := g.Scenario.Generate(rng, scenario) + if err != nil { + return fmt.Errorf("g.Scenario.Generate(): %w", err) + } + if err := q.Push(ctx, scenario, tx); err != nil { + return err + } } } diff --git a/main.go b/main.go index f0fb691..be43522 100644 --- a/main.go +++ b/main.go @@ -14,21 +14,21 @@ import ( "syscall" "time" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/spf13/cobra" "go.opentelemetry.io/otel" "golang.org/x/time/rate" - "github.com/ethereum/go-ethereum/common" - "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/funder" "github.com/sei-protocol/sei-load/generator" "github.com/sei-protocol/sei-load/observability" "github.com/sei-protocol/sei-load/sender" "github.com/sei-protocol/sei-load/stats" + "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/utils" "github.com/sei-protocol/sei-load/utils/scope" ) @@ -293,36 +293,33 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { writerHeight := latestHeight + 10 // some buffer log.Printf("🔍 Latest height: %d, writer start height: %d", latestHeight, writerHeight) writer := sender.NewTxsWriter(cfg.Settings.TargetGas, cfg.Settings.TxsDir, writerHeight, uint64(numBlocksToWrite)) - s.SpawnBgNamed("writer", func() error { return writer.Run(ctx,q) }) + s.SpawnBgNamed("writer", func() error { return writer.Run(ctx, q) }) } else { // Fund the pool before prewarm/dispatch — both spend gas the accounts // don't have until funded. if cfg.Funding != nil && !cfg.Settings.DryRun { var addrs []common.Address - for _,a := range gen.Accounts() { - addrs = append(addrs,a.Address) + for _, a := range gen.Accounts() { + addrs = append(addrs, a.Address) } if err := funder.FundAccounts(ctx, cfg, addrs); err != nil { return fmt.Errorf("failed to fund accounts: %w", err) } } // Create the sender from the config struct - sharedSender, err := sender.NewShardedSender(cfg, sharedLimiter, collector, inclusion) - if err != nil { - return fmt.Errorf("failed to create sender: %w", err) - } + sharedSender := sender.NewShardedSender(cfg, sharedLimiter, collector, inclusion) // Start the sender (starts all workers) - s.SpawnBgNamed("sender", func() error { return sharedSender.Run(ctx,q) }) + s.SpawnBgNamed("sender", func() error { return sharedSender.Run(ctx, q) }) log.Printf("✅ Connected to %d endpoints", len(cfg.Endpoints)) } // Set up prewarming if enabled if cfg.Settings.Prewarm { log.Printf("🔥 Creating prewarm generator...") - if err:=gen.Prewarm(ctx, rng, cfg, q); err!=nil { - return fmt.Errorf("gen.Prewarm(): %w",err) + if err := gen.Prewarm(ctx, rng, cfg, q); err != nil { + return fmt.Errorf("gen.Prewarm(): %w", err) } - log.Printf("🔥 Prewarming complete! Processed %d accounts", len(txs)) + log.Printf("🔥 Prewarming complete!") } // Start logger (after prewarming to capture only main load test metrics) @@ -332,7 +329,6 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Start dispatcher for main load test s.SpawnBgNamed("generator", func() error { return gen.Run(ctx, rng, q) }) - s.SpawnBgNamed("sender", func() error { return snd.Run(ctx, q) }) log.Printf("✅ Started dispatcher") // Set up signal handling for graceful shutdown diff --git a/sender/eth_client.go b/sender/eth_client.go index 7f695ad..4cb25b2 100644 --- a/sender/eth_client.go +++ b/sender/eth_client.go @@ -10,13 +10,13 @@ import ( "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" + "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/types" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" - "github.com/sei-protocol/sei-load/stats" ) var tracer = otel.Tracer("github.com/sei-protocol/sei-load/sender") @@ -28,26 +28,26 @@ type ethClientConfig struct { } type ethClient struct { - cfg *ethClientConfig + cfg *ethClientConfig clients []*ethclient.Client } func (c *ethClient) Close() { - for _,eth := range c.clients { + for _, eth := range c.clients { eth.Close() } } -func newEthClient(ctx context.Context, cfg *ethClientConfig) (_ *ethClient,err error) { +func newEthClient(ctx context.Context, cfg *ethClientConfig) (_ *ethClient, err error) { var clients []*ethclient.Client defer func() { - if err!=nil { - for _,eth := range clients { + if err != nil { + for _, eth := range clients { eth.Close() } } }() - for _,endpoint := range cfg.Endpoints { + for _, endpoint := range cfg.Endpoints { u, err := url.Parse(endpoint) if err != nil { return nil, fmt.Errorf("parse endpoint %q: %w", endpoint, err) @@ -63,7 +63,7 @@ func newEthClient(ctx context.Context, cfg *ethClientConfig) (_ *ethClient,err e } clients = append(clients, ethclient.NewClient(rpcClient)) } - return ðClient{cfg:cfg,clients:clients},nil + return ðClient{cfg: cfg, clients: clients}, nil } // newHttpClient returns an otelhttp-wrapped client: injects traceparent on @@ -97,12 +97,12 @@ func (c *ethClient) Send(ctx context.Context, tx *types.LoadTx) (_err error) { attribute.Int("seiload.worker_id", id), attribute.String("seiload.chain_id", c.cfg.ChainID), )) - defer span.End() + defer span.End() start := time.Now() // This goroutine solely owns tx between dequeue and the sentTxs hand-off, // so stamping the actual send-attempt time here is race-free (see LoadTx). tx.AttemptedSendTime = start - err := c.clients[id].SendTransaction(ctx, tx.EthTx) + err := c.clients[id].SendTransaction(ctx, tx.EthTx) // Record inside the span ctx so exemplars link to the trace. sendLatency.Record(ctx, time.Since(start).Seconds(), metric.WithAttributes( diff --git a/sender/metrics.go b/sender/metrics.go index e433290..d024769 100644 --- a/sender/metrics.go +++ b/sender/metrics.go @@ -44,13 +44,14 @@ func init() { metric.WithUnit("{count}"), metric.WithInt64Callback(func(ctx context.Context, observer metric.Int64Observer) error { for _, ss := range meteredSenders.Get() { - for _, stats := range ss.ShardStats() { + _ = ss + /*for _, stats := range ss.ShardStats() { observer.Observe(int64(stats.TxsQueued), metric.WithAttributes( attribute.String("endpoint", stats.Endpoint), attribute.Int("worker_id", stats.ID), attribute.String("chain_id", stats.ChainID), )) - } + }*/ } return nil }))) diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 4da8f6a..e3bb06f 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -16,8 +16,8 @@ import ( // ShardedSender implements TxSender with multiple workers, one per endpoint type ShardedSender struct { - cfg *config.LoadConfig - limiter *rate.Limiter // Shared rate limiter for transaction sending + cfg *config.LoadConfig + limiter *rate.Limiter // Shared rate limiter for transaction sending collector *stats.Collector inclusion utils.Option[*stats.InclusionTracker] } @@ -26,27 +26,27 @@ type ShardedSender struct { // Txs of each shard are sent sequentially, using a single eth client. func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector, inclusion utils.Option[*stats.InclusionTracker]) *ShardedSender { return &ShardedSender{ - cfg: cfg, - limiter: limiter, + cfg: cfg, + limiter: limiter, collector: collector, inclusion: inclusion, } } // Start initializes and starts all workers -func (ss *ShardedSender) Run(ctx context.Context, q *types.TxsQueue) error { +func (ss *ShardedSender) Run(ctx context.Context, q *types.TxsQueue) error { if len(ss.cfg.Endpoints) == 0 { return fmt.Errorf("no endpoints configured") } cancel := meteredSenders.MustRegister(ss) defer cancel() - client,err := newEthClient(ctx,ðClientConfig{ + client, err := newEthClient(ctx, ðClientConfig{ ChainID: ss.cfg.SeiChainID, - Endpoints: ss.cfg.Endpoints, + Endpoints: ss.cfg.Endpoints, Collector: ss.collector, }) - if err!=nil { - return fmt.Errorf("newEthClient(): %w",err) + if err != nil { + return fmt.Errorf("newEthClient(): %w", err) } defer client.Close() return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { @@ -54,8 +54,10 @@ func (ss *ShardedSender) Run(ctx context.Context, q *types.TxsQueue) error { if err := ss.limiter.Wait(ctx); err != nil { return err } - tx,ack,err := q.Pop(ctx) - if err!=nil { return err } + tx, ack, err := q.Pop(ctx) + if err != nil { + return err + } s.Spawn(func() error { defer ack() if ss.cfg.Settings.DryRun { @@ -63,7 +65,7 @@ func (ss *ShardedSender) Run(ctx context.Context, q *types.TxsQueue) error { // Use very minimal delay to avoid channel overflow return utils.Sleep(ctx, 10*time.Microsecond) // Much faster simulation } - if err := client.Send(ctx,tx); err != nil { + if err := client.Send(ctx, tx); err != nil { return err } if inclusion, ok := ss.inclusion.Get(); ok { diff --git a/sender/writer.go b/sender/writer.go index 90c5ac3..626b7bf 100644 --- a/sender/writer.go +++ b/sender/writer.go @@ -41,8 +41,8 @@ func NewTxsWriter(gasPerBlock uint64, txsDir string, startHeight uint64, numBloc // Send writes the transaction to the writer func (w *TxsWriter) Run(ctx context.Context, q *types.TxsQueue) error { for { - tx,err := q.Pop(ctx) - if err!=nil { + tx, ack, err := q.Pop(ctx) + if err != nil { return err } // if bwe would exceed gasPerBlock, flush @@ -55,7 +55,7 @@ func (w *TxsWriter) Run(ctx context.Context, q *types.TxsQueue) error { // add to buffer w.txBuffer = append(w.txBuffer, tx) w.bufferGas += tx.EthTx.Gas() - return nil + ack() } } @@ -82,8 +82,10 @@ func (w *TxsWriter) Flush() error { TxPayloads: make([][]byte, 0), } for _, tx := range w.txBuffer { - payload,err := tx.EthTx.MarshalBinary() - if err!=nil { return fmt.Errorf("tx.EthTx.MarshalBinary(): %w",err) } + payload, err := tx.EthTx.MarshalBinary() + if err != nil { + return fmt.Errorf("tx.EthTx.MarshalBinary(): %w", err) + } txData.TxPayloads = append(txData.TxPayloads, payload) } diff --git a/types/account_pool.go b/types/account_pool.go index 03a5fd0..1f6a76d 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -2,11 +2,11 @@ package types import ( "context" - "time" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" mrand "math/rand/v2" "sync" + "time" ) type accQueue struct { @@ -33,7 +33,7 @@ func (q *TxsQueue) Push(ctx context.Context, scenario *TxScenario, tx *ethtypes. // TODO: make it respect Settings.MaxInFlight // TODO: make it blocking sender := scenario.Sender - ltx := &LoadTx{EthTx:tx,IntendedSendTime:time.Now(),Scenario:scenario} + ltx := &LoadTx{EthTx: tx, IntendedSendTime: time.Now(), Scenario: scenario} if sender.Tracked { aq, ok := q.byAddr[sender.Address] if !ok { From 73f13273850121473a8ea7a7b2d07c12d100d88d Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Fri, 26 Jun 2026 18:04:36 +0200 Subject: [PATCH 21/21] WIP --- config/settings.go | 2 +- main.go | 3 +- sender/sharded_sender.go | 6 ++-- sender/writer.go | 3 +- types/account_pool.go | 67 ---------------------------------------- 5 files changed, 9 insertions(+), 72 deletions(-) diff --git a/config/settings.go b/config/settings.go index 6f2e7ab..cab0f2d 100644 --- a/config/settings.go +++ b/config/settings.go @@ -40,7 +40,7 @@ type Settings struct { ArrivalModel string `json:"arrivalModel,omitempty"` // MaxInFlight bounds concurrent in-flight sends in the open-loop model; // txs that would exceed it at their scheduled instant are dropped and - // counted rather than throttling the arrival clock. Ignored in closed-loop. + // counted rather than throttling the arrival clock. MaxInFlight int `json:"maxInFlight,omitempty"` } diff --git a/main.go b/main.go index be43522..04a2a70 100644 --- a/main.go +++ b/main.go @@ -278,7 +278,8 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { }) } - q := types.NewTxsQueue() + // TODO: MaxInFlight should have a sensible default. + q := types.NewTxsQueue(cfg.Settings.MaxInFlight) if cfg.Settings.TxsDir != "" { // get latest height eth, err := ethclient.Dial(cfg.Endpoints[0]) diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index e3bb06f..bea9c18 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -59,18 +59,20 @@ func (ss *ShardedSender) Run(ctx context.Context, q *types.TxsQueue) error { return err } s.Spawn(func() error { - defer ack() if ss.cfg.Settings.DryRun { // In dry-run mode, simulate processing time and mark as successful // Use very minimal delay to avoid channel overflow - return utils.Sleep(ctx, 10*time.Microsecond) // Much faster simulation + defer ack(utils.None[uint64]()) + return utils.Sleep(ctx, 10*time.Millisecond) // Much faster simulation } if err := client.Send(ctx, tx); err != nil { + // TODO: correct nonce return err } if inclusion, ok := ss.inclusion.Get(); ok { inclusion.Register(tx) } + ack(utils.None[uint64]()) return nil }) } diff --git a/sender/writer.go b/sender/writer.go index 626b7bf..1e973ec 100644 --- a/sender/writer.go +++ b/sender/writer.go @@ -9,6 +9,7 @@ import ( "path/filepath" "github.com/sei-protocol/sei-load/types" + "github.com/sei-protocol/sei-load/utils" ) // implements `Send` @@ -55,7 +56,7 @@ func (w *TxsWriter) Run(ctx context.Context, q *types.TxsQueue) error { // add to buffer w.txBuffer = append(w.txBuffer, tx) w.bufferGas += tx.EthTx.Gas() - ack() + ack(utils.None[uint64]()) } } diff --git a/types/account_pool.go b/types/account_pool.go index 1f6a76d..376295c 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -1,77 +1,10 @@ package types import ( - "context" - "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" mrand "math/rand/v2" "sync" - "time" ) -type accQueue struct { - Txs []*LoadTx - Nonce uint64 -} - -// AccountRegistry owns account pools created for a run. -type TxsQueue struct { - byAddr map[common.Address]*accQueue - untracked []*LoadTx -} - -// NewAccountRegistry creates an empty account registry. -func NewTxsQueue() *TxsQueue { - return &TxsQueue{byAddr: map[common.Address]*accQueue{}} -} - -func (q *TxsQueue) Pop(ctx context.Context) (tx *LoadTx, ack func(), err error) { - panic("unimplemented") -} - -func (q *TxsQueue) Push(ctx context.Context, scenario *TxScenario, tx *ethtypes.Transaction) error { - // TODO: make it respect Settings.MaxInFlight - // TODO: make it blocking - sender := scenario.Sender - ltx := &LoadTx{EthTx: tx, IntendedSendTime: time.Now(), Scenario: scenario} - if sender.Tracked { - aq, ok := q.byAddr[sender.Address] - if !ok { - aq = &accQueue{} - q.byAddr[sender.Address] = aq - } - if aq.Nonce != tx.Nonce() { - return nil - } - aq.Nonce += 1 - aq.Txs = append(aq.Txs, ltx) - } else { - if tx.Nonce() != 0 { - return nil - } - q.untracked = append(q.untracked, ltx) - } - return nil -} - -func (q *TxsQueue) WaitUntilEmpty(ctx context.Context) error { - panic("unimplemented") -} - -func (q *TxsQueue) Nonce(addr common.Address) uint64 { - if aq, ok := q.byAddr[addr]; ok { - return aq.Nonce - } - return 0 -} - -func (q *TxsQueue) ResetNonce(addr common.Address, nonce uint64) { - if a, ok := q.byAddr[addr]; ok { - a.Txs = nil - a.Nonce = nonce - } -} - // AccountPool returns a next account for load generation. type AccountPool struct { newAccountRate float64