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/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/funder/funder.go b/funder/funder.go index 3fe36bf..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,16 +20,15 @@ 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 -// 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, addrs []common.Address) error { fc := cfg.Funding if fc == nil { return nil @@ -51,16 +52,16 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, pools []types.Acc } defer client.Close() - recipients := uniqueAddresses(pools) - 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, pools []types.Acc 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) @@ -88,10 +89,7 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, pools []types.Acc // 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) @@ -135,19 +133,12 @@ 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 { - 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) - } +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 7a8b04d..4b11a87 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -1,10 +1,13 @@ package generator import ( + "context" "errors" "fmt" "log" - "sync" + "maps" + mrand "math/rand/v2" + "slices" "github.com/ethereum/go-ethereum/common" @@ -14,70 +17,41 @@ import ( "github.com/sei-protocol/sei-load/utils/rng" ) -// 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 -} - // scenarioInstance represents a scenario instance with its configuration type scenarioInstance struct { Name string Weight int Scenario scenarios.TxGenerator - Accounts types.AccountPool - Deployed bool + Accounts *types.AccountPool } -// 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 - 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) - mu sync.RWMutex + deployer types.Account + 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 { - g.mu.Lock() - defer g.mu.Unlock() - - // Create shared account pool if top-level account config exists +func (g *generatorBuilder) createScenarios() error { if g.config.Accounts != nil { - accounts := types.GenerateAccounts(g.config.Accounts.Accounts) - g.sharedAccounts = types.NewAccountPool(&types.AccountConfig{ - Accounts: accounts, - NewAccountRate: g.config.Accounts.NewAccountRate, - Stream: g.rng.Stream(rng.StreamAccountsShared), - }) - g.accountPools = append(g.accountPools, g.sharedAccounts) + g.sharedAccounts = types.NewAccountPool( + g.config.Accounts.Accounts, + g.config.Accounts.NewAccountRate, + ) } 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 - if scenarioCfg.Accounts != nil { + var accountPool *types.AccountPool + if cfg := scenarioCfg.Accounts; cfg != 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, - Stream: g.rng.Stream(rng.AccountsScenarioStream(i)), - }) - g.accountPools = append(g.accountPools, accountPool) + accountPool = types.NewAccountPool(cfg.Accounts, cfg.NewAccountRate) } else if g.sharedAccounts != nil { // Use shared account pool from top-level config accountPool = g.sharedAccounts @@ -108,7 +82,6 @@ func (g *configBasedGenerator) createScenarios() error { Weight: scenarioCfg.Weight, Scenario: scenario, Accounts: accountPool, - Deployed: false, } g.instances = append(g.instances, instance) @@ -117,71 +90,29 @@ func (g *configBasedGenerator) createScenarios() error { return nil } -// bindGasStreams binds each configured gas picker for a scenario to its own -// deterministic sub-stream. The stream ids are keyed by the scenario's config -// index so they stay stable across runs of the same config. -// -// cfg is a value copy, but its *GasPicker fields are pointers shared with the -// copy the scenario stores, so SetStream reaches the picker the scenario draws -// through. A shallow copy is safe precisely because GasPicker.delegate is a -// *RandomGasGenerator shared by both copies; only a copy that ALSO clones the -// gas delegate would break the aliasing silently — see -// TestRandomGasPickerStreamSeeds, which fails loudly if the binding stops -// reaching the live picker. -func (g *configBasedGenerator) bindGasStreams(i int, cfg config.Scenario) { - if cfg.GasPicker != nil { - cfg.GasPicker.SetStream(g.rng.Stream(rng.GasBaseStream(i))) - } - if cfg.GasTipCapPicker != nil { - cfg.GasTipCapPicker.SetStream(g.rng.Stream(rng.GasTipStream(i))) - } - if cfg.GasFeeCapPicker != nil { - cfg.GasFeeCapPicker.SetStream(g.rng.Stream(rng.GasFeeCapStream(i))) - } -} - -// 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 { +func (g *generatorBuilder) mockDeployAll(deployer common.Address) error { for _, instance := range g.instances { - addr := types.GenerateAccounts(1)[0].Address - if err := instance.Scenario.Attach(g.config, addr); err != nil { + if err := instance.Scenario.Attach(g.config, deployer); err != nil { return err } - instance.Deployed = true } return nil } // DeployAll deploys all scenario instances that require deployment -func (g *configBasedGenerator) deployAll() error { +func (g *generatorBuilder) deployAll() error { + deployer := types.NewAccount(false) if g.config.MockDeploy { - return g.mockDeployAll() + return g.mockDeployAll(deployer.Address) } - g.mu.Lock() - defer g.mu.Unlock() // 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) - instance.Deployed = true - - if address.Cmp(common.Address{}) != 0 { + 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()) } } @@ -189,59 +120,97 @@ func (g *configBasedGenerator) deployAll() error { return nil } -// createWeightedGenerator creates a weighted scenarioGenerator from deployed scenarios -func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) { - g.mu.RLock() - defer g.mu.RUnlock() +type Generator struct{ scenarios []*scenarioInstance } - if len(g.instances) == 0 { - return nil, fmt.Errorf("no scenario instances created") +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)) +} - // 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 *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(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 + } + 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) +} +// Generate generates 1 transaction. +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 (b *generatorBuilder) build(rng *mrand.Rand) (*Generator, error) { // Create weighted configurations - var weightedConfigs []*WeightedCfg - for _, instance := range g.instances { + var gens []*scenarioInstance + 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 - 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(g.rng.Stream(rng.StreamWeightedShuffle), weightedConfigs...), nil -} - -// 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) - return pools + rng.Shuffle(len(gens), func(i, j int) { + gens[i], gens[j] = gens[j], gens[i] + }) + return &Generator{scenarios: gens}, 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) } @@ -251,30 +220,28 @@ 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) { - generator := &configBasedGenerator{ +// NewConfigBasedGenerator is a convenience method that combines all steps. +func NewGenerator(rng *mrand.Rand, cfg *config.LoadConfig) (*Generator, error) { + b := &generatorBuilder{ config: cfg, - rng: resolveSeed(cfg), instances: make([]*scenarioInstance, 0), - deployer: types.GenerateAccounts(1)[0], } // 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() + 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/generator_test.go b/generator/generator_test.go index adf4bdd..df347a1 100644 --- a/generator/generator_test.go +++ b/generator/generator_test.go @@ -8,6 +8,8 @@ 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" + testrng "github.com/sei-protocol/sei-load/utils/rng" ) func TestScenarioWeightsAndAccountDistribution(t *testing.T) { @@ -35,12 +37,13 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { }, } - gen, err := generator.NewConfigBasedGenerator(cfg) + rngSource := generator.ResolveSeed(cfg) + gen, err := generator.NewConfigBasedGenerator(rngSource.Rand(testrng.StreamWeightedShuffle), cfg, types.NewAccountRegistry()) require.NoError(t, err) require.NotNil(t, gen) totalTxs := 100 - txs := gen.GenerateN(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 deleted file mode 100644 index 98c0f6e..0000000 --- a/generator/prewarm.go +++ /dev/null @@ -1,110 +0,0 @@ -package generator - -import ( - "sync" - - "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 { - accountPools []types.AccountPool - evmScenario scenarios.TxGenerator - currentPoolIdx int - finished bool - mu sync.RWMutex -} - -// 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() - - // 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] - evmScenario.Deploy(cfg, deployer) - - return &PrewarmGenerator{ - accountPools: accountPools, - evmScenario: evmScenario, - currentPoolIdx: 0, - finished: false, - } -} - -// 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 - } - - // 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) - 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 - } - } - - // 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(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() - defer pg.mu.RUnlock() - - // Return a copy to prevent external modification - pools := make([]types.AccountPool, len(pg.accountPools)) - copy(pools, pg.accountPools) - return pools -} diff --git a/generator/scenario.go b/generator/scenario.go deleted file mode 100644 index 03b3b2f..0000000 --- a/generator/scenario.go +++ /dev/null @@ -1,49 +0,0 @@ -package generator - -import ( - "sync" - - "github.com/sei-protocol/sei-load/generator/scenarios" - "github.com/sei-protocol/sei-load/types" -) - -type scenarioGenerator struct { - scenario scenarios.TxGenerator - accountPool types.AccountPool - mu sync.RWMutex -} - -func NewScenarioGenerator(accounts types.AccountPool, txg scenarios.TxGenerator) Generator { - return &scenarioGenerator{ - scenario: txg, - accountPool: accounts, - } -} - -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() - return g.scenario.Generate(&types.TxScenario{ - Name: g.scenario.Name(), - Sender: sender, - Receiver: receiver.Address, - }), true -} - -func (sg *scenarioGenerator) GetAccountPools() []types.AccountPool { - sg.mu.RLock() - defer sg.mu.RUnlock() - return []types.AccountPool{sg.accountPool} -} diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index 0e5c7fd..2a9b963 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" @@ -17,16 +19,14 @@ const Disperse = "disperse" type DisperseScenario struct { *ContractScenarioBase[bindings.Disperse] contract *bindings.Disperse - pool types.AccountPool + pool *types.AccountPool } // NewDisperseScenario creates a new Disperse scenario func NewDisperseScenario(cfg config.Scenario) TxGenerator { scenario := &DisperseScenario{} - scenario.ContractScenarioBase = NewContractScenarioBase[bindings.Disperse](scenario, cfg) - scenario.pool = types.NewAccountPool(&types.AccountConfig{ - NewAccountRate: 1.0, - }) + scenario.ContractScenarioBase = NewContractScenarioBase(scenario, cfg) + scenario.pool = types.NewAccountPool(0, 1.0) return scenario } @@ -72,11 +72,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..8c5cc42 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" @@ -31,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{} @@ -45,10 +46,10 @@ 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(), + Nonce: scenario.Nonce, To: &scenario.Receiver, Value: big.NewInt(time.Now().Unix()), Gas: 21000, // Standard gas limit for ETH transfer @@ -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..b295d18 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" @@ -31,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{} @@ -45,10 +46,10 @@ 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(), + Nonce: scenario.Nonce, To: &scenario.Receiver, Value: big.NewInt(1_000_000_000_000), Gas: 21000, // Standard gas limit for ETH transfer @@ -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..16e9576 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" @@ -30,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{} @@ -44,10 +45,10 @@ 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(), + Nonce: scenario.Nonce, To: &scenario.Sender.Address, Value: big.NewInt(0), Gas: 21000, // Standard gas limit for ETH transfer @@ -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..6586fd8 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,9 +24,9 @@ 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) (*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 @@ -34,13 +35,13 @@ 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 // 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 @@ -83,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 } @@ -99,18 +100,12 @@ 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) (*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(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 @@ -131,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 } @@ -165,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()) } @@ -214,7 +207,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 2b9842b..9220ae3 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" @@ -11,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 { @@ -68,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)) + cfg := seededConfig(t, seed) + rngSource := generator.ResolveSeed(cfg) + gen, err := generator.NewConfigBasedGenerator(rngSource.Rand(rng.StreamWeightedShuffle), cfg, types.NewAccountRegistry()) require.NoError(t, err) - txs := gen.GenerateN(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 { @@ -100,92 +102,3 @@ func TestSingleWorkerOrderedReplay(t *testing.T) { 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/utils/utils.go b/generator/utils/utils.go index a7038d1..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.GetAndIncrementNonce())) + 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/generator/weighted.go b/generator/weighted.go deleted file mode 100644 index c1c799d..0000000 --- a/generator/weighted.go +++ /dev/null @@ -1,131 +0,0 @@ -package generator - -import ( - "context" - "math/rand/v2" - "sync" - - "github.com/sei-protocol/sei-load/types" - "github.com/sei-protocol/sei-load/utils/rng" -) - -// 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 - mx sync.RWMutex - counter int64 -} - -// GenerateInfinite generates transactions indefinitely. -func (w *weightedGenerator) GenerateInfinite(ctx context.Context) <-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() - return tx - }(): - } - } - } - }() - return output -} - -func (w *weightedGenerator) nextIndex() int64 { - w.mx.Lock() - defer w.mx.Unlock() - w.counter++ - if w.counter >= int64(len(w.generators)) { - w.counter = 0 - } - return w.counter -} - -// generators are randomized at startup. -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() -} - -// 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 - 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. -func NewWeightedGenerator(stream *rng.Stream, cfgs ...*WeightedCfg) Generator { - // no need for clever weighting logic if we just have 1 scenarioGenerator anyway. - if len(cfgs) == 1 { - return cfgs[0].Generator - } - var weighted []Generator - for _, cfg := range cfgs { - for range cfg.Weight { - weighted = append(weighted, cfg.Generator) - } - } - - shuffle := rand.Shuffle - if stream != nil { - shuffle = stream.Shuffle - } - shuffle(len(weighted), func(i, j int) { - weighted[i], weighted[j] = weighted[j], weighted[i] - }) - - return &weightedGenerator{ - generators: weighted, - } -} 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/main.go b/main.go index db3eee4..04a2a70 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ 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" @@ -27,6 +28,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" ) @@ -208,13 +210,13 @@ 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) + 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 - gen, err := generator.NewConfigBasedGenerator(cfg) + gen, err := generator.NewGenerator(rng, cfg) if err != nil { return fmt.Errorf("failed to create generator: %w", err) } @@ -262,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), @@ -277,36 +278,15 @@ 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) - } - - // 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, gen.GetAccountPools()); err != nil { - return fmt.Errorf("failed to fund accounts: %w", err) - } - } - - // Create dispatcher + // TODO: MaxInFlight should have a sensible default. + q := types.NewTxsQueue(cfg.Settings.MaxInFlight) 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) } @@ -314,48 +294,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)) - dispatcher = sender.NewDispatcher(gen, writer) + s.SpawnBgNamed("writer", func() error { return writer.Run(ctx, q) }) } 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 { + 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) + } + } + // Create the sender from the config struct + sharedSender := sender.NewShardedSender(cfg, sharedLimiter, collector, inclusion) + // Start the sender (starts all workers) + 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...") - prewarmGen := generator.NewPrewarmGenerator(cfg, gen) - dispatcher.SetPrewarmGenerator(prewarmGen) - 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); 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!") } // Start logger (after prewarming to capture only main load test metrics) @@ -363,7 +328,8 @@ 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("generator", func() error { return gen.Run(ctx, rng, q) }) + log.Printf("✅ Started dispatcher") // Set up signal handling for graceful shutdown @@ -401,21 +367,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/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/dispatcher.go b/sender/dispatcher.go deleted file mode 100644 index f21ae14..0000000 --- a/sender/dispatcher.go +++ /dev/null @@ -1,267 +0,0 @@ -package sender - -import ( - "context" - "fmt" - "log" - "sync" - "time" - - "golang.org/x/time/rate" - - "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 - - // 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(gen generator.Generator, sender TxSender) *Dispatcher { - return &Dispatcher{ - generator: gen, - sender: sender, - arrivalModel: ArrivalClosedLoop, - limiter: rate.NewLimiter(rate.Inf, 1), - } -} - -// 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) error { - d.mu.RLock() - prewarmGen := d.prewarmGen - // 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 - - // Run prewarm generator until completion - for ctx.Err() == nil { - if err := limiter.Wait(ctx); err != nil { - return err - } - - tx, ok := gen.Generate() - 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) error { - if d.ArrivalModel() == ArrivalOpenLoop { - return d.runOpenLoop(ctx) - } - return d.runClosedLoop(ctx) -} - -// 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 { - for ctx.Err() == nil { - // Generate a transaction from main generator - tx, ok := d.generator.Generate() - if !ok { - log.Print("Dispatcher: Generator returned no more transactions") - 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() - - // Send the transaction - if err := d.sender.Send(ctx, tx); err != nil { - return err - } - d.mu.Lock() - d.totalSent++ - d.mu.Unlock() - } - 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) 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) - }) - // 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, 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() - 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() - 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 -} diff --git a/sender/eth_client.go b/sender/eth_client.go index 796b071..4cb25b2 100644 --- a/sender/eth_client.go +++ b/sender/eth_client.go @@ -12,8 +12,6 @@ import ( "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" @@ -23,49 +21,49 @@ import ( 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 + cfg *ethClientConfig + 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())) - } - rpcClient, err := rpc.DialOptions(ctx, c.cfg.Endpoint, opts...) - if err != nil { - return fmt.Errorf("rpc.Dial(%q): %w", c.cfg.Endpoint, err) +func (c *ethClient) Close() { + for _, eth := range c.clients { + eth.Close() } - 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() + } + } + }() + 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) } - return nil - }) + clients = append(clients, ethclient.NewClient(rpcClient)) + } + return ðClient{cfg: cfg, clients: clients}, nil } // newHttpClient returns an otelhttp-wrapped client: injects traceparent on @@ -90,93 +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) - 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 { - 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/eth_client_test.go b/sender/eth_client_test.go index cad33ec..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") } @@ -148,8 +141,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/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/scheduler.go b/sender/scheduler.go deleted file mode 100644 index 0527c26..0000000 --- a/sender/scheduler.go +++ /dev/null @@ -1,156 +0,0 @@ -package sender - -import ( - "context" - "log" - "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, 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() - 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 deleted file mode 100644 index 7df3519..0000000 --- a/sender/scheduler_realworker_test.go +++ /dev/null @@ -1,510 +0,0 @@ -package sender - -import ( - "context" - "encoding/json" - "io" - "math/big" - "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" - "github.com/sei-protocol/sei-load/utils/service" -) - -// 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, err := types.NewAccount() - require.NoError(t, err) - chainID := big.NewInt(1) - return &signedTxGenerator{ - remaining: n, - acct: acct, - signer: ethtypes.NewCancunSigner(chainID), - chainID: chainID, - } -} - -func (g *signedTxGenerator) Generate() (*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) GenerateN(int) []*types.LoadTx { panic("unused") } -func (g *signedTxGenerator) GetAccountPools() []types.AccountPool { return nil } - -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.Add(1) - go func() { - 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) }) - // 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.Add(1) - go func() { - 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) - }) - }() - - // 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.Add(1) - go func() { - defer wg.Done() - _ = service.Run(ctx, func(ctx context.Context, scope service.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)) - - start := time.Now() - require.NoError(t, d.Prewarm(ctx)) - 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 01fbdb3..0000000 --- a/sender/scheduler_test.go +++ /dev/null @@ -1,584 +0,0 @@ -package sender - -import ( - "context" - "errors" - "slices" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" - "golang.org/x/time/rate" - - "github.com/sei-protocol/sei-load/types" - "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() (*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) GenerateN(int) []*types.LoadTx { panic("unused") } -func (g *fakeGenerator) GetAccountPools() []types.AccountPool { - return nil -} - -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() (*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] -} - -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 -// 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, sched *openLoopScheduler) { - _ = service.Run(ctx, func(ctx context.Context, s service.Scope) error { - return sched.Run(ctx, 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, 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, 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, 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, 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, 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, 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, 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, sched) - - require.Positive(t, checked.Load(), "onSent must observe stamped txs") -} 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 aacdaa3..8ca61df 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 @@ -112,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, @@ -149,12 +151,14 @@ func TestShardDistribution(t *testing.T) { } // Create generator - gen, err := generator.NewConfigBasedGenerator(cfg) + rngSource := generator.ResolveSeed(cfg) + gen, err := generator.NewConfigBasedGenerator(rngSource.Rand(testrng.StreamWeightedShuffle), cfg, types.NewAccountRegistry()) 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/sharded_sender.go b/sender/sharded_sender.go index 60f903c..bea9c18 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" @@ -16,103 +16,65 @@ 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 - clients []*ethClient - shards []*Queue[*types.LoadTx] + cfg *config.LoadConfig + limiter *rate.Limiter // Shared rate limiter for transaction sending + 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) + cfg: cfg, + limiter: limiter, + collector: collector, + inclusion: inclusion, + } } // 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 { + 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) - } + if ss.cfg.Settings.DryRun { + // In dry-run mode, simulate processing time and mark as successful + // Use very minimal delay to avoid channel overflow + 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 } - return ctx.Err() + if inclusion, ok := ss.inclusion.Get(); ok { + inclusion.Register(tx) + } + ack(utils.None[uint64]()) + 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..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` @@ -39,18 +40,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, ack, 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() + ack(utils.None[uint64]()) + } } type TxWriteData struct { @@ -76,7 +83,11 @@ 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/sender/writer_test.go b/sender/writer_test.go index e0247cc..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" ) @@ -19,8 +20,8 @@ func TestTxsWriter_Flush(t *testing.T) { ChainID: 7777, } - sharedAccounts := types.NewAccountPool(&types.AccountConfig{ - Accounts: types.GenerateAccounts(10), + sharedAccounts := types.NewAccountRegistry().NewPool(&types.AccountConfig{ + InitialSize: 10, NewAccountRate: 0.0, }) @@ -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)) - generator := generator.NewScenarioGenerator(sharedAccounts, evmScenario) + gen := generator.NewScenarioGenerator(sharedAccounts, evmScenario) - txs := generator.GenerateN(3) + txs := generator.GenerateN(rng, gen, 3) err := writer.Send(context.Background(), txs[0]) require.NoError(t, err) 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/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 45dc96b..06aab08 100644 --- a/types/account.go +++ b/types/account.go @@ -2,46 +2,34 @@ package types import ( "crypto/ecdsa" - "sync/atomic" "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. type Account struct { Address common.Address PrivKey *ecdsa.PrivateKey - Nonce uint64 + Tracked bool } // NewAccount generates new account. -func NewAccount() (*Account, error) { - privateKey, err := crypto.GenerateKey() - if err != nil { - return nil, err - } - return &Account{ +func NewAccount(tracked bool) 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 + Tracked: tracked, + } } // 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) +func GenerateAccounts(n int, tracked bool) []Account { + result := make([]Account, n) + for i := range result { + result[i] = NewAccount(tracked) } return result } diff --git a/types/account_pool.go b/types/account_pool.go index d1d66d0..376295c 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -1,71 +1,50 @@ package types import ( - "math/rand/v2" + mrand "math/rand/v2" "sync" - - "github.com/sei-protocol/sei-load/utils/rng" ) // 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 { + newAccountRate float64 + accounts []Account + mx sync.Mutex + idx int } // 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. - 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++ - a.idx %= len(a.Accounts) + a.idx %= len(a.accounts) return a.idx } -// NextAccount returns the next account. -func (a *accountPool) NextAccount() *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 { - return GenerateAccounts(1)[0] +// 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.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 } -// NewAccountPool creates a new account generator from a config. -func NewAccountPool(cfg *AccountConfig) AccountPool { - return &accountPool{ - Accounts: cfg.Accounts, - cfg: cfg, +// NewPool creates a new account generator from a config, records it, and returns it. +func NewAccountPool(size int, newAccountRate float64) *AccountPool { + return &AccountPool{ + accounts: GenerateAccounts(size, true), + newAccountRate: newAccountRate, } } diff --git a/types/scenario.go b/types/scenario.go index a70b8a4..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/λ @@ -48,16 +45,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 @@ -75,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()) @@ -99,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 33eb6a2..8fdf08f 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,46 +9,42 @@ 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/require" "github.com/sei-protocol/sei-load/utils/rng" ) 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 - 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, err := NewAccount() - require.NoError(t, err) + 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) { - account, err := NewAccount() - require.NoError(t, err) + account := NewAccount() const numGoroutines = 100 const noncesPerGoroutine = 10 @@ -74,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) { @@ -100,47 +95,49 @@ 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) } }) } } func TestAccountPoolRoundRobin(t *testing.T) { - accounts := GenerateAccounts(3) + registry := NewAccountRegistry() config := &AccountConfig{ - Accounts: accounts, + 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) // So the first call returns accounts[1], second returns accounts[2], third returns accounts[0] 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, + require.Equal(t, expectedAccount.Address, selectedAccount.Address, "Round %d, position %d: expected %s, got %s", round, i, expectedAccount.Address.Hex(), selectedAccount.Address.Hex()) } @@ -148,13 +145,14 @@ func TestAccountPoolRoundRobin(t *testing.T) { } func TestAccountPoolNewAccountRate(t *testing.T) { - accounts := GenerateAccounts(2) + registry := NewAccountRegistry() config := &AccountConfig{ - Accounts: accounts, + 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 originalAddresses := make(map[common.Address]bool) @@ -163,22 +161,22 @@ func TestAccountPoolNewAccountRate(t *testing.T) { } for i := 0; i < 10; i++ { - selectedAccount := pool.NextAccount() - assert.False(t, originalAddresses[selectedAccount.Address], + selectedAccount := pool.NextAccount(rng.NewSource(1).Rand("types:test")) + require.False(t, originalAddresses[selectedAccount.Address], "Iteration %d: got original account %s when expecting new account", i, selectedAccount.Address.Hex()) } } func TestAccountPoolMixedRate(t *testing.T) { - accounts := GenerateAccounts(5) + registry := NewAccountRegistry() config := &AccountConfig{ - Accounts: accounts, + 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) for _, account := range accounts { @@ -188,9 +186,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 { @@ -202,18 +201,19 @@ 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) { - accounts := GenerateAccounts(5) + registry := NewAccountRegistry() config := &AccountConfig{ - Accounts: accounts, + InitialSize: 5, NewAccountRate: 0.0, // Pure round-robin for predictable testing } - pool := NewAccountPool(config) + pool := registry.NewPool(config) + accounts := pool.GetAccounts() const numGoroutines = 50 const selectionsPerGoroutine = 20 @@ -226,8 +226,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) @@ -242,17 +243,16 @@ 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()) } } func TestCreateTxFromEthTx(t *testing.T) { // Create a test account and scenario - account, err := NewAccount() - require.NoError(t, err) + account := NewAccount() - account.Nonce = 42 + account.Nonce.Store(42) receiver := common.HexToAddress("0x1234567890123456789012345678901234567890") scenario := &TxScenario{ Name: "TestScenario", @@ -263,7 +263,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 @@ -277,20 +277,21 @@ 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.Sender.Address, loadTx.Sender) + 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) { @@ -320,11 +321,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 @@ -337,8 +338,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]++ } @@ -350,19 +351,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) } }) @@ -371,8 +372,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", @@ -382,7 +382,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 @@ -398,18 +398,17 @@ 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) } } func TestTxScenario(t *testing.T) { - account, err := NewAccount() - require.NoError(t, err) + account := NewAccount() receiver := common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd") - account.Nonce = 123 + account.Nonce.Store(123) scenario := &TxScenario{ Name: "TestScenario", @@ -418,10 +417,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) { @@ -438,32 +437,26 @@ 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() } } func BenchmarkAccountPoolNextAccount(b *testing.B) { - accounts := GenerateAccounts(100) + registry := NewAccountRegistry() config := &AccountConfig{ - Accounts: accounts, + InitialSize: 100, NewAccountRate: 0.0, } - pool := NewAccountPool(config) + pool := registry.NewPool(config) b.ResetTimer() for i := 0; i < b.N; i++ { - pool.NextAccount() + pool.NextAccount(rng.NewSource(1).Rand("types:test")) } } 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 +465,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", @@ -485,7 +475,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/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)) +} diff --git a/utils/rng/rng.go b/utils/rng/rng.go index 24ea692..761702b 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 } @@ -115,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. 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) -}