diff --git a/apps/grpc/go.mod b/apps/grpc/go.mod index 3b77d49f9c..405947c767 100644 --- a/apps/grpc/go.mod +++ b/apps/grpc/go.mod @@ -82,7 +82,7 @@ require ( github.com/googleapis/gax-go/v2 v2.19.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect - github.com/hashicorp/go-hclog v1.6.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-metrics v0.5.4 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect diff --git a/apps/grpc/go.sum b/apps/grpc/go.sum index 8670e575e8..bd9f4eaf2b 100644 --- a/apps/grpc/go.sum +++ b/apps/grpc/go.sum @@ -650,8 +650,9 @@ github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyN github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= diff --git a/execution/evm/test/go.sum b/execution/evm/test/go.sum index d582a7777a..82a00b7700 100644 --- a/execution/evm/test/go.sum +++ b/execution/evm/test/go.sum @@ -390,8 +390,8 @@ github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NM github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= -github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= diff --git a/go.mod b/go.mod index b83bfa413c..e795ba4c4a 100644 --- a/go.mod +++ b/go.mod @@ -102,7 +102,7 @@ require ( github.com/googleapis/gax-go/v2 v2.19.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect - github.com/hashicorp/go-hclog v1.6.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-metrics v0.5.4 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect diff --git a/go.sum b/go.sum index 28b58f6e03..357491a58b 100644 --- a/go.sum +++ b/go.sum @@ -331,8 +331,9 @@ github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyN github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= diff --git a/test/e2e/benchmark/config.go b/test/e2e/benchmark/config.go index aec12b5138..e568068bfa 100644 --- a/test/e2e/benchmark/config.go +++ b/test/e2e/benchmark/config.go @@ -41,7 +41,7 @@ func newBenchConfig(serviceName string) benchConfig { SlotDuration: envOrDefault("BENCH_SLOT_DURATION", "250ms"), GasLimit: envOrDefault("BENCH_GAS_LIMIT", ""), ScrapeInterval: envOrDefault("BENCH_SCRAPE_INTERVAL", "1s"), - NumSpammers: envInt("BENCH_NUM_SPAMMERS", 2), + NumSpammers: envInt("BENCH_NUM_SPAMMERS", 4), CountPerSpammer: envInt("BENCH_COUNT_PER_SPAMMER", 5000), Throughput: envInt("BENCH_THROUGHPUT", 200), WarmupTxs: envInt("BENCH_WARMUP_TXS", 200), diff --git a/test/e2e/benchmark/distribute_spammers_test.go b/test/e2e/benchmark/distribute_spammers_test.go new file mode 100644 index 0000000000..1a65bf184a --- /dev/null +++ b/test/e2e/benchmark/distribute_spammers_test.go @@ -0,0 +1,78 @@ +//go:build evm + +package benchmark + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDistributeSpammers(t *testing.T) { + tests := []struct { + name string + total int + pcts [4]int + expected [4]int + }{ + { + name: "minimum total equals types", + total: 4, + pcts: [4]int{40, 30, 20, 10}, + expected: [4]int{1, 1, 1, 1}, + }, + { + name: "equal percentages", + total: 8, + pcts: [4]int{25, 25, 25, 25}, + expected: [4]int{2, 2, 2, 2}, + }, + { + name: "default mix 8 spammers", + total: 8, + pcts: [4]int{40, 30, 20, 10}, + expected: [4]int{3, 2, 2, 1}, + }, + { + name: "large total distributes proportionally", + total: 104, + pcts: [4]int{40, 30, 20, 10}, + expected: [4]int{41, 31, 21, 11}, + }, + { + name: "sum of counts equals total", + total: 7, + pcts: [4]int{40, 30, 20, 10}, + expected: [4]int{2, 2, 2, 1}, + }, + { + name: "5 spammers with uneven split", + total: 5, + pcts: [4]int{40, 30, 20, 10}, + expected: [4]int{2, 1, 1, 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := distributeSpammers(tt.total, tt.pcts) + require.Equal(t, tt.expected, result) + + sum := result[0] + result[1] + result[2] + result[3] + require.Equal(t, tt.total, sum, "sum of distributed spammers must equal total") + + for i, c := range result { + require.GreaterOrEqual(t, c, 1, "type %d must have at least 1 spammer", i) + } + }) + } +} + +func TestDistributeSpammersPanicsOnLowTotal(t *testing.T) { + require.Panics(t, func() { + distributeSpammers(3, [4]int{40, 30, 20, 10}) + }) + require.Panics(t, func() { + distributeSpammers(0, [4]int{25, 25, 25, 25}) + }) +} diff --git a/test/e2e/benchmark/helpers.go b/test/e2e/benchmark/helpers.go index 1f5e1349c7..a11493e347 100644 --- a/test/e2e/benchmark/helpers.go +++ b/test/e2e/benchmark/helpers.go @@ -471,11 +471,9 @@ func waitForMetricTarget(t testing.TB, name string, poll func() (float64, error) } select { case <-ctx.Done(): - t.Logf("metric %s: context cancelled (target %.0f)", name, target) - return + t.Fatalf("metric %s: context cancelled (target %.0f)", name, target) case <-timer.C: - t.Logf("metric %s did not reach target %.0f within %v", name, target, timeout) - return + t.Fatalf("metric %s did not reach target %.0f within %v", name, target, timeout) case <-ticker.C: } } diff --git a/test/e2e/benchmark/mixed_workload_test.go b/test/e2e/benchmark/mixed_workload_test.go new file mode 100644 index 0000000000..1a6e5e7792 --- /dev/null +++ b/test/e2e/benchmark/mixed_workload_test.go @@ -0,0 +1,250 @@ +//go:build evm + +package benchmark + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/celestiaorg/tastora/framework/docker/evstack/spamoor" +) + +// TestMixedWorkload measures chain performance under a realistic mix of +// transaction types running concurrently. The default mix is 40% ERC20 +// transfers, 30% Uniswap swaps, 20% gasburner, 10% storage writes. +// +// Mix proportions are configurable via BENCH_MIX_ERC20_PCT, BENCH_MIX_DEFI_PCT, +// BENCH_MIX_GASBURN_PCT, BENCH_MIX_STATE_PCT. These must sum to 100. +// +// BENCH_NUM_SPAMMERS is distributed across workload types proportionally, +// with a minimum of 1 spammer per type (so the minimum is 4 spammers). +// +// Primary metrics: MGas/s, TPS. +// Diagnostic metrics: per-span latency breakdown, ev-node overhead %. +func (s *SpamoorSuite) TestMixedWorkload() { + cfg := newBenchConfig("ev-node-mixed") + + t := s.T() + ctx := t.Context() + cfg.log(t) + w := newResultWriter(t, "MixedWorkload") + defer w.flush() + + var result *benchmarkResult + var wallClock time.Duration + var spamoorStats *runSpamoorStats + defer func() { + if result != nil { + emitRunResult(t, cfg, result, wallClock, spamoorStats) + } + }() + + // read mix proportions + pcts := [4]int{ + envInt("BENCH_MIX_ERC20_PCT", 40), + envInt("BENCH_MIX_DEFI_PCT", 30), + envInt("BENCH_MIX_GASBURN_PCT", 20), + envInt("BENCH_MIX_STATE_PCT", 10), + } + pctSum := pcts[0] + pcts[1] + pcts[2] + pcts[3] + s.Require().Equal(100, pctSum, "BENCH_MIX_*_PCT values must sum to 100, got %d", pctSum) + s.Require().GreaterOrEqual(cfg.NumSpammers, 4, "mixed workload requires at least 4 spammers (1 per type)") + + counts := distributeSpammers(cfg.NumSpammers, pcts) + t.Logf("mix distribution: erc20=%d, defi=%d, gasburner=%d, state=%d (total=%d)", + counts[0], counts[1], counts[2], counts[3], cfg.NumSpammers) + + e := s.setupEnv(cfg) + s.Require().NoError(deleteAllSpammers(e.spamoorAPI), "failed to delete stale spammers") + + type workloadType struct { + scenario string + prefix string + config map[string]any + count int + } + + workloads := []workloadType{ + { + scenario: spamoor.ScenarioERC20TX, + prefix: "bench-mixed-erc20", + count: counts[0], + config: map[string]any{ + "throughput": cfg.Throughput, + "total_count": cfg.CountPerSpammer, + "max_pending": cfg.MaxPending, + "max_wallets": cfg.MaxWallets, + "base_fee": cfg.BaseFee, + "tip_fee": cfg.TipFee, + "refill_amount": "5000000000000000000", // 5 ETH + "refill_balance": "2000000000000000000", // 2 ETH + "refill_interval": 600, + }, + }, + { + scenario: spamoor.ScenarioUniswapSwaps, + prefix: "bench-mixed-defi", + count: counts[1], + config: map[string]any{ + "throughput": cfg.Throughput, + "total_count": cfg.CountPerSpammer, + "max_pending": cfg.MaxPending, + "max_wallets": cfg.MaxWallets, + "pair_count": envInt("BENCH_PAIR_COUNT", 1), + "rebroadcast": cfg.Rebroadcast, + "base_fee": cfg.BaseFee, + "tip_fee": cfg.TipFee, + "refill_amount": "10000000000000000000", // 10 ETH (swaps need ETH for WETH wrapping) + "refill_balance": "5000000000000000000", // 5 ETH + "refill_interval": 600, + }, + }, + { + scenario: spamoor.ScenarioGasBurnerTX, + prefix: "bench-mixed-gasburner", + count: counts[2], + config: map[string]any{ + "gas_units_to_burn": cfg.GasUnitsToBurn, + "total_count": cfg.CountPerSpammer, + "throughput": cfg.Throughput, + "max_pending": cfg.MaxPending, + "max_wallets": cfg.MaxWallets, + "rebroadcast": cfg.Rebroadcast, + "base_fee": cfg.BaseFee, + "tip_fee": cfg.TipFee, + "refill_amount": "500000000000000000000", // 500 ETH + "refill_balance": "200000000000000000000", // 200 ETH + "refill_interval": 300, + }, + }, + { + scenario: spamoor.ScenarioStorageSpam, + prefix: "bench-mixed-state", + count: counts[3], + config: map[string]any{ + "throughput": cfg.Throughput, + "total_count": cfg.CountPerSpammer, + "gas_units_to_burn": cfg.GasUnitsToBurn, + "max_pending": cfg.MaxPending, + "max_wallets": cfg.MaxWallets, + "rebroadcast": cfg.Rebroadcast, + "base_fee": cfg.BaseFee, + "tip_fee": cfg.TipFee, + "refill_amount": "5000000000000000000", // 5 ETH + "refill_balance": "2000000000000000000", // 2 ETH + "refill_interval": 600, + }, + }, + } + + spammerIDs := make([]int, 0, cfg.NumSpammers) + for _, wl := range workloads { + for i := range wl.count { + name := fmt.Sprintf("%s-%d", wl.prefix, i) + id, err := e.spamoorAPI.CreateSpammer(name, wl.scenario, wl.config, true) + s.Require().NoError(err, "failed to create spammer %s", name) + spammerIDs = append(spammerIDs, id) + t.Cleanup(func() { _ = e.spamoorAPI.DeleteSpammer(id) }) + } + } + + requireSpammersRunning(t, e.spamoorAPI, spammerIDs) + + pollSentTotal := func() (float64, error) { + metrics, mErr := e.spamoorAPI.GetMetrics() + if mErr != nil { + return 0, mErr + } + return sumCounter(metrics["spamoor_transactions_sent_total"]), nil + } + + // wait for at least one tx per spammer to confirm contract deployments are done + waitForMetricTarget(t, "spamoor_transactions_sent_total (deploy)", pollSentTotal, float64(len(spammerIDs)), cfg.WaitTimeout) + waitForMetricTarget(t, "spamoor_transactions_sent_total (warmup)", pollSentTotal, float64(cfg.WarmupTxs), cfg.WaitTimeout) + + e.traces.resetStartTime() + + startHeader, err := e.ethClient.HeaderByNumber(ctx, nil) + s.Require().NoError(err, "failed to get start block header") + startBlock := startHeader.Number.Uint64() + loadStart := time.Now() + t.Logf("start block: %d (after warmup)", startBlock) + + waitForMetricTarget(t, "spamoor_transactions_sent_total", pollSentTotal, float64(cfg.totalCount()), cfg.WaitTimeout) + + drainCtx, drainCancel := context.WithTimeout(ctx, 30*time.Second) + defer drainCancel() + if err := waitForDrain(drainCtx, t.Logf, e.ethClient, 10); err != nil { + t.Logf("warning: %v", err) + } + wallClock = time.Since(loadStart) + + endHeader, err := e.ethClient.HeaderByNumber(ctx, nil) + s.Require().NoError(err, "failed to get end block header") + endBlock := endHeader.Number.Uint64() + t.Logf("end block: %d (range %d blocks)", endBlock, endBlock-startBlock) + + bm, err := collectBlockMetrics(ctx, e.ethClient, startBlock, endBlock) + s.Require().NoError(err, "failed to collect block metrics") + + traces := s.collectTraces(e) + + result = newBenchmarkResult("MixedWorkload", bm, traces) + s.Require().Greater(result.summary.SteadyState, time.Duration(0), "expected non-zero steady-state duration") + result.log(t, wallClock) + w.addEntries(result.entries()) + + metrics, mErr := e.spamoorAPI.GetMetrics() + s.Require().NoError(mErr, "failed to get final metrics") + sent := sumCounter(metrics["spamoor_transactions_sent_total"]) + failed := sumCounter(metrics["spamoor_transactions_failed_total"]) + spamoorStats = &runSpamoorStats{Sent: sent, Failed: failed} + + s.Require().Greater(sent, float64(0), "at least one transaction should have been sent") + s.Require().Zero(failed, "no transactions should have failed") +} + +// distributeSpammers divides total spammers across 4 workload types +// proportionally to pcts using the largest-remainder method. Each type +// gets at least 1 spammer. total must be >= 4. +func distributeSpammers(total int, pcts [4]int) [4]int { + if total < 4 { + panic(fmt.Sprintf("distributeSpammers: total must be >= 4, got %d", total)) + } + var counts [4]int + for i := range counts { + counts[i] = 1 + } + remaining := total - 4 + if remaining == 0 { + return counts + } + + type indexedFrac struct { + index int + frac float64 + } + + var fracs [4]indexedFrac + allocated := 0 + for i, pct := range pcts { + ideal := float64(remaining) * float64(pct) / 100.0 + floor := int(ideal) + counts[i] += floor + allocated += floor + fracs[i] = indexedFrac{index: i, frac: ideal - float64(floor)} + } + + // distribute leftover by descending fractional part + sort.Slice(fracs[:], func(a, b int) bool { + return fracs[a].frac > fracs[b].frac + }) + leftover := remaining - allocated + for i := 0; i < leftover && i < 4; i++ { + counts[fracs[i].index]++ + } + + return counts +}