Skip to content

Commit fe99227

Browse files
authored
refactor: move spamoor benchmark into testify suite (#3107)
* refactor: move spamoor benchmark into testify suite in test/e2e/benchmark - Create test/e2e/benchmark/ subpackage with SpamoorSuite (testify/suite) - Move spamoor smoke test into suite as TestSpamoorSmoke - Split helpers into focused files: traces.go, output.go, metrics.go - Introduce resultWriter for defer-based benchmark JSON output - Export shared symbols from evm_test_common.go for cross-package use - Restructure CI to fan-out benchmark jobs and fan-in publishing - Run benchmarks on PRs only when benchmark-related files change * fix: correct BENCH_JSON_OUTPUT path for spamoor benchmark go test sets the working directory to the package under test, so the env var should be relative to test/e2e/benchmark/, not test/e2e/. * fix: place package pattern before test binary flags in benchmark CI go test treats all arguments after an unknown flag (--evm-binary) as test binary args, so ./benchmark/ was never recognized as a package pattern. * fix: adjust evm-binary path for benchmark subpackage working directory go test sets the cwd to the package directory (test/e2e/benchmark/), so the binary path needs an extra parent traversal. * fix: exclude benchmark subpackage from make test-e2e The benchmark package doesn't define the --binary flag that test-e2e passes. It has its own CI workflow so it doesn't need to run here. * fix: address PR review feedback for benchmark suite - make reth tag configurable via EV_RETH_TAG env var (default pr-140) - fix OTLP config: remove duplicate env vars, use http/protobuf protocol - use require.Eventually for host readiness polling - rename requireHTTP to requireHostUp - use non-fatal logging in resultWriter.flush deferred context - fix stale doc comment (setupCommonEVMEnv -> SetupCommonEVMEnv) - rename loop variable to avoid shadowing testing.TB convention - add block/internal/executing/** to CI path trigger - remove unused require import from output.go * chore: specify http * chore: filter out benchmark tests from test-e2e
1 parent f73a124 commit fe99227

File tree

13 files changed

+513
-383
lines changed

13 files changed

+513
-383
lines changed

.github/workflows/benchmark.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ permissions: {}
88
pull_request:
99
branches:
1010
- main
11+
paths:
12+
- 'test/e2e/benchmark/**'
13+
- 'test/e2e/evm_contract_bench_test.go'
14+
- 'test/e2e/evm_test_common.go'
15+
- 'test/e2e/sut_helper.go'
16+
- 'block/internal/executing/**'
17+
- '.github/workflows/benchmark.yml'
1118
workflow_dispatch:
1219

1320
jobs:
@@ -62,12 +69,13 @@ jobs:
6269
- name: Run Spamoor smoke test
6370
run: |
6471
cd test/e2e && BENCH_JSON_OUTPUT=spamoor_bench.json go test -tags evm \
65-
-run='^TestSpamoorSmoke$' -v -timeout=15m --evm-binary=../../build/evm
72+
-run='^TestSpamoorSuite$/^TestSpamoorSmoke$' -v -timeout=15m \
73+
./benchmark/ --evm-binary=../../../build/evm
6674
- name: Upload benchmark results
6775
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
6876
with:
6977
name: spamoor-benchmark-results
70-
path: test/e2e/spamoor_bench.json
78+
path: test/e2e/benchmark/spamoor_bench.json
7179

7280
# single job to push all results to gh-pages sequentially, avoiding race conditions
7381
publish-benchmarks:
@@ -88,7 +96,7 @@ jobs:
8896
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
8997
with:
9098
name: spamoor-benchmark-results
91-
path: test/e2e/
99+
path: test/e2e/benchmark/
92100

93101
# only update the benchmark baseline on push/dispatch, not on PRs
94102
- name: Store EVM Contract Roundtrip result
@@ -135,7 +143,7 @@ jobs:
135143
with:
136144
name: Spamoor Trace Benchmarks
137145
tool: 'customSmallerIsBetter'
138-
output-file-path: test/e2e/spamoor_bench.json
146+
output-file-path: test/e2e/benchmark/spamoor_bench.json
139147
auto-push: ${{ github.event_name != 'pull_request' }}
140148
save-data-file: ${{ github.event_name != 'pull_request' }}
141149
github-token: ${{ secrets.GITHUB_TOKEN }}

.just/test.just

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ test-integration:
2525
[group('test')]
2626
test-e2e: build build-da build-evm docker-build-if-local
2727
@echo "--> Running e2e tests"
28-
@cd test/e2e && go test -mod=readonly -failfast -timeout=15m -tags='e2e evm' ./... --binary=../../build/testapp --evm-binary=../../build/evm
28+
@cd test/e2e && go test -mod=readonly -failfast -timeout=15m -tags='e2e evm' $(go list -tags='e2e evm' ./... | grep -v /benchmark) --binary=../../build/testapp --evm-binary=../../build/evm
2929

3030
# Run integration tests with coverage
3131
[group('test')]

test/e2e/benchmark/metrics.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//go:build evm
2+
3+
package benchmark
4+
5+
import (
6+
"net/http"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
12+
dto "github.com/prometheus/client_model/go"
13+
)
14+
15+
// requireHostUp polls a URL until it returns a 2xx status code or the timeout expires.
16+
func requireHostUp(t testing.TB, url string, timeout time.Duration) {
17+
t.Helper()
18+
client := &http.Client{Timeout: 200 * time.Millisecond}
19+
require.Eventually(t, func() bool {
20+
resp, err := client.Get(url)
21+
if err != nil {
22+
return false
23+
}
24+
_ = resp.Body.Close()
25+
return resp.StatusCode >= 200 && resp.StatusCode < 300
26+
}, timeout, 100*time.Millisecond, "daemon not ready at %s", url)
27+
}
28+
29+
// sumCounter sums all counter values in a prometheus MetricFamily.
30+
func sumCounter(f *dto.MetricFamily) float64 {
31+
if f == nil || f.GetType() != dto.MetricType_COUNTER {
32+
return 0
33+
}
34+
var sum float64
35+
for _, m := range f.GetMetric() {
36+
if m.GetCounter() != nil && m.GetCounter().Value != nil {
37+
sum += m.GetCounter().GetValue()
38+
}
39+
}
40+
return sum
41+
}

test/e2e/benchmark/output.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//go:build evm
2+
3+
package benchmark
4+
5+
import (
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"sort"
10+
"testing"
11+
12+
e2e "github.com/evstack/ev-node/test/e2e"
13+
)
14+
15+
// entry matches the customSmallerIsBetter format for github-action-benchmark.
16+
type entry struct {
17+
Name string `json:"name"`
18+
Unit string `json:"unit"`
19+
Value float64 `json:"value"`
20+
}
21+
22+
// resultWriter accumulates benchmark entries and writes them to a JSON file
23+
// when flush is called. Create one early in a test and defer flush so results
24+
// are written regardless of where the test exits.
25+
type resultWriter struct {
26+
t testing.TB
27+
label string
28+
entries []entry
29+
}
30+
31+
func newResultWriter(t testing.TB, label string) *resultWriter {
32+
return &resultWriter{t: t, label: label}
33+
}
34+
35+
// addSpans aggregates trace spans into per-operation avg duration entries.
36+
func (w *resultWriter) addSpans(spans []e2e.TraceSpan) {
37+
m := e2e.AggregateSpanStats(spans)
38+
if len(m) == 0 {
39+
return
40+
}
41+
42+
names := make([]string, 0, len(m))
43+
for name := range m {
44+
names = append(names, name)
45+
}
46+
sort.Strings(names)
47+
48+
for _, name := range names {
49+
s := m[name]
50+
avg := float64(s.Total.Microseconds()) / float64(s.Count)
51+
w.entries = append(w.entries, entry{
52+
Name: fmt.Sprintf("%s - %s (avg)", w.label, name),
53+
Unit: "us",
54+
Value: avg,
55+
})
56+
}
57+
}
58+
59+
// addEntry appends a custom entry to the results.
60+
func (w *resultWriter) addEntry(e entry) {
61+
w.entries = append(w.entries, e)
62+
}
63+
64+
// flush writes accumulated entries to the path in BENCH_JSON_OUTPUT.
65+
// It is a no-op when the env var is unset or no entries were added.
66+
func (w *resultWriter) flush() {
67+
outputPath := os.Getenv("BENCH_JSON_OUTPUT")
68+
if outputPath == "" || len(w.entries) == 0 {
69+
return
70+
}
71+
72+
data, err := json.MarshalIndent(w.entries, "", " ")
73+
if err != nil {
74+
w.t.Logf("WARNING: failed to marshal benchmark JSON: %v", err)
75+
return
76+
}
77+
if err := os.WriteFile(outputPath, data, 0644); err != nil {
78+
w.t.Logf("WARNING: failed to write benchmark JSON to %s: %v", outputPath, err)
79+
return
80+
}
81+
w.t.Logf("wrote %d benchmark entries to %s", len(w.entries), outputPath)
82+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//go:build evm
2+
3+
package benchmark
4+
5+
import (
6+
"os"
7+
"time"
8+
9+
"github.com/celestiaorg/tastora/framework/docker/evstack/spamoor"
10+
e2e "github.com/evstack/ev-node/test/e2e"
11+
)
12+
13+
// TestSpamoorSmoke spins up reth + sequencer and a Spamoor node, starts a few
14+
// basic spammers, waits briefly, then validates trace spans and prints a concise
15+
// metrics summary.
16+
func (s *SpamoorSuite) TestSpamoorSmoke() {
17+
t := s.T()
18+
w := newResultWriter(t, "SpamoorSmoke")
19+
defer w.flush()
20+
21+
// TODO: temporary hardcoded tag, will be replaced with a proper release tag
22+
rethTag := os.Getenv("EV_RETH_TAG")
23+
if rethTag == "" {
24+
rethTag = "pr-140"
25+
}
26+
e := s.setupEnv(config{
27+
rethTag: rethTag,
28+
serviceName: "ev-node-smoke",
29+
})
30+
api := e.spamoorAPI
31+
32+
eoatx := map[string]any{
33+
"throughput": 100,
34+
"total_count": 3000,
35+
"max_pending": 4000,
36+
"max_wallets": 300,
37+
"amount": 100,
38+
"random_amount": true,
39+
"random_target": true,
40+
"base_fee": 20,
41+
"tip_fee": 2,
42+
"refill_amount": "1000000000000000000",
43+
"refill_balance": "500000000000000000",
44+
"refill_interval": 600,
45+
}
46+
47+
gasburner := map[string]any{
48+
"throughput": 25,
49+
"total_count": 2000,
50+
"max_pending": 8000,
51+
"max_wallets": 500,
52+
"gas_units_to_burn": 3000000,
53+
"base_fee": 20,
54+
"tip_fee": 5,
55+
"rebroadcast": 5,
56+
"refill_amount": "5000000000000000000",
57+
"refill_balance": "2000000000000000000",
58+
"refill_interval": 300,
59+
}
60+
61+
var ids []int
62+
id, err := api.CreateSpammer("smoke-eoatx", spamoor.ScenarioEOATX, eoatx, true)
63+
s.Require().NoError(err, "failed to create eoatx spammer")
64+
ids = append(ids, id)
65+
id, err = api.CreateSpammer("smoke-gasburner", spamoor.ScenarioGasBurnerTX, gasburner, true)
66+
s.Require().NoError(err, "failed to create gasburner spammer")
67+
ids = append(ids, id)
68+
69+
for _, id := range ids {
70+
idToDelete := id
71+
t.Cleanup(func() { _ = api.DeleteSpammer(idToDelete) })
72+
}
73+
74+
// allow spamoor enough time to generate transaction throughput
75+
// so that the expected tracing spans appear in Jaeger.
76+
time.Sleep(60 * time.Second)
77+
78+
// fetch parsed metrics and print a concise summary.
79+
metrics, err := api.GetMetrics()
80+
s.Require().NoError(err, "failed to get metrics")
81+
sent := sumCounter(metrics["spamoor_transactions_sent_total"])
82+
fail := sumCounter(metrics["spamoor_transactions_failed_total"])
83+
84+
// collect traces
85+
evNodeSpans := s.collectServiceTraces(e, "ev-node-smoke")
86+
evRethSpans := s.collectServiceTraces(e, "ev-reth")
87+
e2e.PrintTraceReport(t, "ev-node-smoke", evNodeSpans)
88+
e2e.PrintTraceReport(t, "ev-reth", evRethSpans)
89+
90+
w.addSpans(append(evNodeSpans, evRethSpans...))
91+
92+
// assert expected ev-node span names
93+
assertSpanNames(t, evNodeSpans, []string{
94+
"BlockExecutor.ProduceBlock",
95+
"BlockExecutor.ApplyBlock",
96+
"BlockExecutor.CreateBlock",
97+
"BlockExecutor.RetrieveBatch",
98+
"Executor.ExecuteTxs",
99+
"Executor.SetFinal",
100+
"Engine.ForkchoiceUpdated",
101+
"Engine.NewPayload",
102+
"Engine.GetPayload",
103+
"Eth.GetBlockByNumber",
104+
"Sequencer.GetNextBatch",
105+
"DASubmitter.SubmitHeaders",
106+
"DASubmitter.SubmitData",
107+
"DA.Submit",
108+
}, "ev-node-smoke")
109+
110+
// assert expected ev-reth span names
111+
assertSpanNames(t, evRethSpans, []string{
112+
"build_payload",
113+
"execute_tx",
114+
"try_build",
115+
"validate_transaction",
116+
}, "ev-reth")
117+
118+
s.Require().Greater(sent, float64(0), "at least one transaction should have been sent")
119+
s.Require().Zero(fail, "no transactions should have failed")
120+
}

0 commit comments

Comments
 (0)