From 6980a948083308c2d2b547c2579aed0361010c41 Mon Sep 17 00:00:00 2001 From: chatton Date: Thu, 12 Mar 2026 10:55:39 +0000 Subject: [PATCH 1/4] feat: add TestStatePressure benchmark for state trie stress test --- .../benchmark/spamoor_state_pressure_test.go | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 test/e2e/benchmark/spamoor_state_pressure_test.go diff --git a/test/e2e/benchmark/spamoor_state_pressure_test.go b/test/e2e/benchmark/spamoor_state_pressure_test.go new file mode 100644 index 000000000..9d980adad --- /dev/null +++ b/test/e2e/benchmark/spamoor_state_pressure_test.go @@ -0,0 +1,112 @@ +//go:build evm + +package benchmark + +import ( + "context" + "fmt" + "time" + + "github.com/celestiaorg/tastora/framework/docker/evstack/spamoor" +) + +// TestStatePressure measures throughput under maximum storage write pressure. +// Each tx maximizes SSTORE operations, creating rapid state growth that stresses +// the state trie and disk I/O. +// +// Shares system configuration with TestERC20Throughput (100ms blocks, 100M gas, +// 25ms scrape) so results are directly comparable. The gap between +// TestEVMComputeCeiling and this test isolates state root + storage I/O cost. +// +// Primary metrics: MGas/s. +// Diagnostic metrics: Engine.NewPayload latency, ev-node overhead %. +func (s *SpamoorSuite) TestStatePressure() { + const ( + numSpammers = 4 + countPerSpammer = 5000 + totalCount = numSpammers * countPerSpammer + waitTimeout = 10 * time.Minute + ) + + cfg := newBenchConfig("ev-node-state-pressure") + + t := s.T() + ctx := t.Context() + w := newResultWriter(t, "StatePressure") + defer w.flush() + + e := s.setupEnv(cfg) + + storageSpamConfig := map[string]any{ + "throughput": 20, // 20 tx per 100ms slot = 200 tx/s per spammer, 800 tx/s total + "total_count": countPerSpammer, + "gas_units_to_burn": 2000000, // 2M gas per tx via SSTORE. 100M / 2M = 50 txs to fill block + "max_pending": 50000, + "max_wallets": 100, + "base_fee": 20, + "tip_fee": 2, + "refill_amount": "5000000000000000000", // 5 ETH + "refill_balance": "2000000000000000000", // 2 ETH + "refill_interval": 600, + } + + s.Require().NoError(deleteAllSpammers(e.spamoorAPI), "failed to delete stale spammers") + + var spammerIDs []int + for i := range numSpammers { + name := fmt.Sprintf("bench-storage-%d", i) + id, err := e.spamoorAPI.CreateSpammer(name, spamoor.ScenarioStorageSpam, storageSpamConfig, true) + s.Require().NoError(err, "failed to create spammer %s", name) + spammerIDs = append(spammerIDs, id) + t.Cleanup(func() { _ = e.spamoorAPI.DeleteSpammer(id) }) + } + + time.Sleep(3 * time.Second) + assertSpammersRunning(t, e.spamoorAPI, spammerIDs) + + // wait for wallet funding to finish before recording start block + pollSentTotal := func() (float64, error) { + metrics, mErr := e.spamoorAPI.GetMetrics() + if mErr != nil { + return 0, mErr + } + return sumCounter(metrics["spamoor_transactions_sent_total"]), nil + } + waitForMetricTarget(t, "spamoor_transactions_sent_total (warmup)", pollSentTotal, float64(cfg.WarmupTxs), cfg.WaitTimeout) + + // reset trace window to exclude warmup spans + 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) + + // wait for all transactions to be sent + waitForMetricTarget(t, "spamoor_transactions_sent_total", pollSentTotal, float64(totalCount), cfg.WaitTimeout) + + // wait for pending txs to drain + 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) + + // collect block-level gas/tx metrics + bm, err := collectBlockMetrics(ctx, e.ethClient, startBlock, endBlock) + s.Require().NoError(err, "failed to collect block metrics") + + traces := s.collectTraces(e, cfg.ServiceName) + + result := newBenchmarkResult("StatePressure", 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()) +} From 58e5bb6dca684c4aa5223f8a81e8cc63c1bef75a Mon Sep 17 00:00:00 2001 From: chatton Date: Mon, 23 Mar 2026 12:30:37 +0000 Subject: [PATCH 2/4] refactor: use benchConfig for TestStatePressure spamoor parameters --- .../benchmark/spamoor_state_pressure_test.go | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/test/e2e/benchmark/spamoor_state_pressure_test.go b/test/e2e/benchmark/spamoor_state_pressure_test.go index 9d980adad..6d42dd559 100644 --- a/test/e2e/benchmark/spamoor_state_pressure_test.go +++ b/test/e2e/benchmark/spamoor_state_pressure_test.go @@ -21,28 +21,22 @@ import ( // Primary metrics: MGas/s. // Diagnostic metrics: Engine.NewPayload latency, ev-node overhead %. func (s *SpamoorSuite) TestStatePressure() { - const ( - numSpammers = 4 - countPerSpammer = 5000 - totalCount = numSpammers * countPerSpammer - waitTimeout = 10 * time.Minute - ) - cfg := newBenchConfig("ev-node-state-pressure") t := s.T() ctx := t.Context() + cfg.log(t) w := newResultWriter(t, "StatePressure") defer w.flush() e := s.setupEnv(cfg) storageSpamConfig := map[string]any{ - "throughput": 20, // 20 tx per 100ms slot = 200 tx/s per spammer, 800 tx/s total - "total_count": countPerSpammer, - "gas_units_to_burn": 2000000, // 2M gas per tx via SSTORE. 100M / 2M = 50 txs to fill block + "throughput": cfg.Throughput, + "total_count": cfg.CountPerSpammer, + "gas_units_to_burn": envInt("BENCH_GAS_UNITS_TO_BURN", 2000000), "max_pending": 50000, - "max_wallets": 100, + "max_wallets": cfg.MaxWallets, "base_fee": 20, "tip_fee": 2, "refill_amount": "5000000000000000000", // 5 ETH @@ -53,7 +47,7 @@ func (s *SpamoorSuite) TestStatePressure() { s.Require().NoError(deleteAllSpammers(e.spamoorAPI), "failed to delete stale spammers") var spammerIDs []int - for i := range numSpammers { + for i := range cfg.NumSpammers { name := fmt.Sprintf("bench-storage-%d", i) id, err := e.spamoorAPI.CreateSpammer(name, spamoor.ScenarioStorageSpam, storageSpamConfig, true) s.Require().NoError(err, "failed to create spammer %s", name) @@ -62,7 +56,7 @@ func (s *SpamoorSuite) TestStatePressure() { } time.Sleep(3 * time.Second) - assertSpammersRunning(t, e.spamoorAPI, spammerIDs) + requireSpammersRunning(t, e.spamoorAPI, spammerIDs) // wait for wallet funding to finish before recording start block pollSentTotal := func() (float64, error) { @@ -84,7 +78,7 @@ func (s *SpamoorSuite) TestStatePressure() { t.Logf("start block: %d (after warmup)", startBlock) // wait for all transactions to be sent - waitForMetricTarget(t, "spamoor_transactions_sent_total", pollSentTotal, float64(totalCount), cfg.WaitTimeout) + waitForMetricTarget(t, "spamoor_transactions_sent_total", pollSentTotal, float64(cfg.totalCount()), cfg.WaitTimeout) // wait for pending txs to drain drainCtx, drainCancel := context.WithTimeout(ctx, 30*time.Second) From 737908aa5b8b65f1438fc6d3c4f0c4d9ed639717 Mon Sep 17 00:00:00 2001 From: chatton Date: Mon, 23 Mar 2026 12:30:39 +0000 Subject: [PATCH 3/4] ci: add state pressure benchmark to CI workflow --- .github/workflows/benchmark.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 0c0ee74de..493218b52 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -122,6 +122,29 @@ jobs: -run='^TestSpamoorSuite$/^TestDeFiSimulation$' -v -timeout=15m \ ./benchmark/ --evm-binary=../../../build/evm + # TODO: wire up to publish results once additional tests are in place. + state-pressure-benchmark: + name: State Pressure Benchmark + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: ./go.mod + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Install just + uses: extractions/setup-just@v3 + - name: Build binaries + run: just build-evm build-da + - name: Run state pressure test + run: | + cd test/e2e && go test -tags evm \ + -run='^TestSpamoorSuite$/^TestStatePressure$' -v -timeout=15m \ + ./benchmark/ --evm-binary=../../../build/evm + # single job to push all results to gh-pages sequentially, avoiding race conditions publish-benchmarks: name: Publish Benchmark Results From b6b0af430e0557586cd559b54ab32ff860dfc64d Mon Sep 17 00:00:00 2001 From: chatton Date: Mon, 23 Mar 2026 13:19:43 +0000 Subject: [PATCH 4/4] fix: address PR review feedback for TestStatePressure - use cfg.GasUnitsToBurn instead of re-reading env var with different default - add comment explaining time.Sleep before requireSpammersRunning - add post-run assertions for sent > 0 and failed == 0 - add TODO comment for CI result publishing --- test/e2e/benchmark/spamoor_state_pressure_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/e2e/benchmark/spamoor_state_pressure_test.go b/test/e2e/benchmark/spamoor_state_pressure_test.go index 6d42dd559..6e3bd9cae 100644 --- a/test/e2e/benchmark/spamoor_state_pressure_test.go +++ b/test/e2e/benchmark/spamoor_state_pressure_test.go @@ -34,7 +34,7 @@ func (s *SpamoorSuite) TestStatePressure() { storageSpamConfig := map[string]any{ "throughput": cfg.Throughput, "total_count": cfg.CountPerSpammer, - "gas_units_to_burn": envInt("BENCH_GAS_UNITS_TO_BURN", 2000000), + "gas_units_to_burn": cfg.GasUnitsToBurn, "max_pending": 50000, "max_wallets": cfg.MaxWallets, "base_fee": 20, @@ -55,6 +55,7 @@ func (s *SpamoorSuite) TestStatePressure() { t.Cleanup(func() { _ = e.spamoorAPI.DeleteSpammer(id) }) } + // allow spamoor time to initialise spammer goroutines before polling status time.Sleep(3 * time.Second) requireSpammersRunning(t, e.spamoorAPI, spammerIDs) @@ -103,4 +104,11 @@ func (s *SpamoorSuite) TestStatePressure() { 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"]) + s.Require().Greater(sent, float64(0), "at least one transaction should have been sent") + s.Require().Zero(failed, "no transactions should have failed") }