Skip to content

feat: add nightly RPC benchmark harness - #3930

Closed
danielntmd wants to merge 15 commits into
mainfrom
danielntmd/benchmark-image
Closed

feat: add nightly RPC benchmark harness#3930
danielntmd wants to merge 15 commits into
mainfrom
danielntmd/benchmark-image

Conversation

@danielntmd

@danielntmd danielntmd commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the Juno-side foundation for reproducible nightly RPC performance benchmarks against a fixed mainnet snapshot.

Changes

  • Adds a fixed, seeded mainnet corpus for starknet_getTransactionByHash.
  • Adds a pinned k6 runner covering sequential latency, concurrency, and throughput.
  • Validates node readiness, chain, block height, corpus checksum, and matching Juno/runner versions before measurement.
  • Records benchmark configuration, provenance, results, saturation, and failures in a manifest.
  • Adds integration tests for successful runs, invalid configuration, mismatched targets, RPC failures, saturation, and graceful termination.
  • Publishes matched Juno and runner images using immutable commit tags and moving nightly tags.
  • Supports exact commit versioning for dedicated benchmark images without changing the Makefile or normal Juno builds.

See https://github.com/NethermindEth/argocd/pull/9780

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.08602% with 230 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.62%. Comparing base (6d423c7) to head (88ee5e3).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
bench/rpc/cmd/runner/runner.go 59.16% 125 Missing and 60 partials ⚠️
bench/rpc/cmd/runner/config.go 84.17% 11 Missing and 11 partials ⚠️
bench/rpc/cmd/runner/metrics.go 75.51% 7 Missing and 5 partials ⚠️
bench/rpc/cmd/runner/manifest.go 92.92% 4 Missing and 3 partials ⚠️
bench/rpc/cmd/runner/main.go 0.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3930      +/-   ##
==========================================
- Coverage   75.77%   74.62%   -1.15%     
==========================================
  Files         461      466       +5     
  Lines       40876    41659     +783     
==========================================
+ Hits        30972    31089     +117     
- Misses       7738     8386     +648     
- Partials     2166     2184      +18     
Flag Coverage Δ
jsonv2 77.36% <ø> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@danielntmd
danielntmd force-pushed the danielntmd/benchmark-image branch from f75870f to 54fc41f Compare August 11, 2026 20:34
@danielntmd
danielntmd force-pushed the danielntmd/benchmark-image branch from 54fc41f to 6854c63 Compare August 13, 2026 07:40
@danielntmd danielntmd changed the title Danielntmd/benchmark image feat: add nightly RPC benchmark harness Aug 13, 2026
@danielntmd
danielntmd force-pushed the danielntmd/benchmark-image branch from 6854c63 to d222dcc Compare August 13, 2026 08:13
@danielntmd
danielntmd marked this pull request as ready for review August 13, 2026 08:14
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @danielntmd's task in 8m 49s —— View job


Review: nightly RPC benchmark harness

  • Gather context (diff, Makefile, Dockerfile, RPC/readiness wiring)
  • Review runner.sh
  • Review k6 scripts + summary-metrics.jq
  • Review test-runner.sh / stub server / corpus
  • Review Dockerfiles + workflow + .gitignore
  • Post inline comments

No Go changes here, so no consensus/sync/concurrency surface. I verified the plumbing this depends on: juno_version returns main.Version verbatim (rpc/handlers.go:130node.New(config, Version, …), cmd/juno/juno.go:340), so the sha-<commit> ldflag assertion holds; /ready/rpc exists (node/node.go:585); "params":[] on a zero-param method is accepted (jsonrpc/server.go:611); both committed checksums verify; .dockerignore doesn't exclude bench/; the .gitignore re-include chain works. The shell is careful — set -eu + pipefail, readonly on the embedded commit, background-child + wait so TERM isn't swallowed by a foreground sleep, atomic mv for the manifest, defensive as_number in jq. Build RPC Benchmark Images passed on this PR, so the runner contract is exercised end to end.

No blockers. My findings are about what the harness will measure, not whether it runs.

Important

  1. runner.sh:466 — the measured single scenario replays exactly the corpus entries the warmup just fetched. iterationInTest restarts at 0 per k6 run, and WARMUP_ITERATIONS == ITERATIONS == 200, so both hit corpus[0..199]. Sequential latency is measured entirely warm, and 9,800 of the 10,000 committed entries are unused by that scenario.
  2. throughput.js:19maxVUs == preAllocatedVUs == 50 means dropped_iterations can't separate node saturation from runner VU starvation: 3000 req/s with 50 VUs requires latency < 16.7 ms. This is a ~60× reduction from main's preAllocatedVUs: Math.max(...RATES).
  3. throughput.js:20ramping-arrival-rate with the default startRate: 0 never holds 1000/2000/3000 req/s steady, and --summary-export emits one aggregate, so the manifest advertises rates next to percentiles that belong to none of them. constant-arrival-rate per rate (tagged) gives attributable numbers; 5 s/stage is also short for a stable p99.
  4. runner.sh:359CONCURRENCY_DURATION/THROUGHPUT_DURATION skip the configuration stage, so a typo (e.g. bare 30, which k6 reads as nanoseconds) is reported as a measurement failure with exit 1 after the readiness wait and warmup, instead of a config rejection with exit 2. The duration_seconds helper already exists.

Nits

  1. runner.sh:24 — pre-flight failures exit before mkdir -p "$RESULTS_DIR"/trap on_exit, so a missing required variable yields no manifest; and the four provenance variables (SNAPSHOT_SHA256, both image digests, SNAPSHOT_ID) are required but never format-checked or verified — test-runner.sh passes sha256:juno, the shape of exactly the typo that would silently poison a run's provenance.
  2. summary-metrics.jq:6-13 — extracts failure counters only, no latency, so the manifest of a performance run contains no performance; also http_req_failed's passes key counts failures (worth a comment) and httpRequestFailures is the one field nothing asserts.
  3. Dockerfile:49-56 — duplicates the make juno recipe and has already drifted (drops $(GO_TAGS), hardcodes Linux CGO_LDFLAGS). Harmless today, but a future GO_TAGS addition would silently stop the benchmark binary from matching released builds. A JUNO_VERSION ?= override in the Makefile collapses this to RUN make juno.
  4. test-runner.sh:311-322 — 10 s to reach the concurrency stage after two full k6 runs is tight on a contended runner (suggest 30 s); docker stop --time 5 assumes the TERM path beats k6's 30 s gracefulStop, and a deployment copying it gets exit 137 with no manifest; no test asserts the deliberate exit 2 vs exit 1 contract.
  5. runner.sh:266-274 — startup cleanup globs "$RESULTS_DIR"/.manifest.json.tmp.* across all PIDs, so two runners sharing a results volume would delete each other's in-flight temp files. Harmless today given one run per volume.
  6. bench/rpc/corpus/v0_10/getTransactionByHash.json is 1.6 MB on a single line; every regeneration adds another ~1.6 MB to history permanently. Reproducibility justifies committing it, but consider compressing (checksum the compressed artifact) or pretty-printing one request per line so future regenerations diff.
  7. .github/workflows/benchmark-image.yaml:10-12 — the push trigger has no paths filter, so every commit to main runs a full -a Go + Rust build and moves :nightly. That makes :nightly mean "latest main", not "nightly". The pairing is safe (the runner rejects a Juno image whose version ≠ its own embedded commit, so a torn tag update fails loudly rather than measuring a mismatched pair) — just worth naming accurately, and it's ~one full image build per merge. I can't modify .github/workflows/ (GitHub App permissions), so this is comment-only.

Nothing was changed on the branch — this is review feedback only, as requested.
· branch danielntmd/benchmark-image

Comment thread bench/rpc/runner.sh Outdated
run_tracked_input "$CORPUS_PATH" k6 run --quiet \
-e NODE_URL="$NODE_URL" \
--vus 1 \
--iterations "$ITERATIONS_VALUE" \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: the measured single scenario replays exactly the same corpus entries the warmup just fetched.

run.js indexes with corpus[exec.scenario.iterationInTest % corpus.length], and iterationInTest restarts at 0 for every k6 run. Warmup does 200 iterations (WARMUP_ITERATIONS=200, line 10) → corpus[0..199]; single defaults to ITERATIONS=200corpus[0..199] again, in the same order.

Consequences:

  • sequential latency is measured entirely on entries whose state/receipt data was just pulled into the node's caches, so single reports best-case cache-hit latency rather than the mixed behaviour concurrency/throughput see;
  • 9,800 of the 10,000 committed corpus entries are never touched by single, so the corpus size buys nothing for that scenario, and a regression that only affects cold reads is invisible.

If the warm-cache measurement is intentional, please say so explicitly in README.md — otherwise warm from a disjoint slice (e.g. pass an offset via -e CORPUS_OFFSET and index (offset + iterationInTest) % corpus.length, with warmup using offset 0 and single using offset WARMUP_ITERATIONS).

Fix this →

Comment thread bench/rpc/throughput.js
Comment thread bench/rpc/throughput.js
Comment thread bench/rpc/runner.sh Outdated
echo $((value * multiplier))
}

timeout_seconds=$(duration_seconds "$READY_TIMEOUT") || fail "READY_TIMEOUT must be a positive duration using s, m, or h"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: CONCURRENCY_DURATION and THROUGHPUT_DURATION bypass the configuration stage entirely.

duration_seconds is only applied to READY_TIMEOUT; the two scenario durations are passed straight through to k6 (--duration "$CONCURRENCY_DURATION_VALUE" at line 475, -e DURATION= at line 486). A typo such as CONCURRENCY_DURATION=30 (k6 reads bare integers as nanoseconds) or 30x therefore:

  • is not rejected at configuration with exit 2, but
  • surfaces much later as fail "$scenario did not produce a valid summary" with failure.stage = "concurrency" and exit 1 — i.e. after waiting up to READY_TIMEOUT (30 m default) and running the whole warmup, and reported as a measurement failure rather than a misconfiguration.

The helper already exists, so this is a two-line fix:

duration_seconds "$CONCURRENCY_DURATION_VALUE" >/dev/null || fail "CONCURRENCY_DURATION must be a positive duration using s, m, or h"
duration_seconds "$THROUGHPUT_DURATION_VALUE" >/dev/null || fail "THROUGHPUT_DURATION must be a positive duration using s, m, or h"

Worth adding cases to test-runner.sh too — it covers READY_TIMEOUT=30x but neither scenario duration.

Fix this →

Comment thread bench/rpc/runner.sh Outdated
case "$actual_block_number" in
''|*[!0-9]*) fail "node returned an invalid block number: $actual_block_number" ;;
esac
if [ "$actual_block_number" != "$EXPECTED_BLOCK_NUMBER" ]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: string comparison of two values you've already proven to be decimal digits. EXPECTED_BLOCK_NUMBER=0800000 passes the digit check at line 338 and then spuriously fails here against 800000. Since both sides are validated as digits-only, use arithmetic comparison:

Suggested change
if [ "$actual_block_number" != "$EXPECTED_BLOCK_NUMBER" ]; then
if [ "$actual_block_number" -ne "$EXPECTED_BLOCK_NUMBER" ]; then

Comment thread bench/rpc/runner.sh Outdated
fi
readonly JUNO_COMMIT

required_env="

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (two related points on this pre-flight block):

  1. No manifest is written for pre-flight failures. mkdir -p "$RESULTS_DIR" / trap on_exit EXIT only happen at lines 261/281, after this loop and after the JUNO_COMMIT checks. So the single most likely operational failure — a deployment forgetting one of these eight variables — produces exit 2, a line on stderr, and no manifest.json at all, while the README states the manifest records failures. Consumers need a separate "no manifest" code path. Since RESULTS_DIR is defaulted at line 44, moving mkdir -p/trap above this loop would let pre-flight failures land in the manifest too (stage: "preflight", which current_stage is already initialised to).

  2. SNAPSHOT_ID, SNAPSHOT_SHA256, JUNO_IMAGE_DIGEST and RUNNER_IMAGE_DIGEST are required but never validated or verified — they're only echoed into the manifest as provenance. test-runner.sh passes JUNO_IMAGE_DIGEST=sha256:juno, which is exactly the shape of a typo that would silently poison the provenance record for a whole run of nightly results. A cheap format check at the configuration stage would catch it:

printf '%s' "$SNAPSHOT_SHA256" | grep -Eq '^[0-9a-f]{64}$' || fail "SNAPSHOT_SHA256 must be a 64-character hex digest"
for digest_name in JUNO_IMAGE_DIGEST RUNNER_IMAGE_DIGEST; do
  eval "digest=\$$digest_name"
  printf '%s' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' || fail "$digest_name must be sha256:<64 hex>"
done

Given the whole point of the harness is reproducibility, unverifiable provenance fields are the weakest link in the manifest.

Comment thread Dockerfile
Comment thread bench/rpc/test-runner.sh
Comment thread bench/rpc/summary-metrics.jq Outdated
Comment on lines +6 to +13
{
failedChecks: metric_value("checks"; "fails"),
requestFailures: metric_value("rpc_request_failures"; "count"),
httpRequestFailures: metric_value("http_req_failed"; "passes"),
vuFailures: metric_value("vu_failures"; "count"),
droppedIterations: metric_value("dropped_iterations"; "count"),
completedIterations: metric_value("iterations"; "count")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the extracted metric set is failure counters only — no latency. manifest.json therefore records the full configuration and provenance of a performance run but none of its performance, so anything trending nightly results has to open single.json/concurrency.json/throughput.json and re-derive the percentiles that --summary-trend-stats 'avg,min,med,p(90),p(99),max' was passed to produce. Adding them here is a few lines and makes the manifest self-contained:

  httpReqDuration: {
    avg: metric_value("http_req_duration"; "avg"),
    med: metric_value("http_req_duration"; "med"),
    p90: metric_value("http_req_duration"; "p(90)"),
    p99: metric_value("http_req_duration"; "p(99)"),
    max: metric_value("http_req_duration"; "max")
  },

(the existing metric_value guard already handles absent metrics by returning 0, and trend values are floats, which is fine here since nothing shell-side does arithmetic on them).

Two smaller observations while here:

  • httpRequestFailures: metric_value("http_req_failed"; "passes") is correct for k6's Rate metric but reads backwards — passes counts failed requests. A one-line comment would save the next reader a trip to the k6 docs.
  • httpRequestFailures is the only extracted field the runner never checks; failedChecks/requestFailures already catch connection errors via isSuccess, so it's pure record-keeping — worth a comment saying so, otherwise it looks like a missed assertion.

@infrmtcs
infrmtcs self-requested a review August 13, 2026 10:33
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review: nightly RPC benchmark harness (Go runner)

The shell runner has been replaced with Go since my last pass, so I'm re-reviewing from scratch.

  • Re-read diff (Go runner replaced runner.sh)
  • Review runner.go (lifecycle, signals, subprocess, exit codes)
  • Review config.go / manifest.go / metrics.go
  • Review run.js / throughput.js
  • Review runner_test.go / test-runner.sh / Dockerfiles / workflow
  • Post inline comments + summary

View job run · branch danielntmd/benchmark-image

Comment thread bench/rpc/cmd/runner/config.go Outdated
if c.readyTimeout, err = parseRunnerDuration(c.readyTimeoutRaw); err != nil {
return fmt.Errorf("READY_TIMEOUT must be a positive duration using s, m, or h")
}
c.readyPollInterval, _ = parseRunnerDuration(c.readyPollIntervalRaw)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: the READY_POLL_INTERVAL parse error is discarded here, which moves a configuration error out of the configuration stage — and sometimes drops it entirely.

parseRunnerDuration returns 0 on failure, so READY_POLL_INTERVAL=30x:

  • is not rejected by validate() (exit 2, failure.stage = "configuration"), and
  • surfaces in runReadiness (runner.go:350) as fail("READY_POLL_INTERVAL must be a positive duration using s, m, or h") with exit 1 and failure.stage = "readiness" — i.e. the wrong exit code and the wrong stage for a consumer branching on that contract;
  • and if the node answers /ready/rpc on the first probe, the loop breaks before ever reaching line 350, so the misconfiguration is silently accepted for the whole run.

Every other knob (ITERATIONS, VUS, THROUGHPUT_VUS, RATES, EXPECTED_BLOCK_NUMBER, READY_TIMEOUT) is validated up front; this is the one that isn't. Suggest treating it like READY_TIMEOUT and deleting the runtime check:

Suggested change
c.readyPollInterval, _ = parseRunnerDuration(c.readyPollIntervalRaw)
if c.readyPollInterval, err = parseRunnerDuration(c.readyPollIntervalRaw); err != nil {
return fmt.Errorf("READY_POLL_INTERVAL must be a positive duration using s, m, or h")
}

test-runner.sh covers READY_TIMEOUT=30x but not this one, and TestConfigValidationFailures has no case for it either.

Fix this →

Comment thread bench/rpc/cmd/runner/config.go
Comment thread bench/rpc/cmd/runner/runner.go Outdated
if !digitsPattern.MatchString(r.actualBlockNumber) {
return r.fail("node returned an invalid block number: " + r.actualBlockNumber)
}
if r.actualBlockNumber != r.config.expectedBlockNumber {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: string comparison of two values that have already been proven to be decimal digits, which can produce a self-contradictory manifest.

EXPECTED_BLOCK_NUMBER=0800000 passes digitsPattern at config.go:157 and parses cleanly to expectedBlock = 800000. Against a node at block 800000 this branch then fails the run with block number mismatch: expected 0800000, got 800000, while manifest.json records node.expectedBlockNumber: 800000 and node.blockNumber: 800000 — a failure manifest whose own two numbers are equal.

config.expectedBlock is already parsed for exactly this, so compare numerically (actualBlockNumber is guaranteed digits-only by the check two lines above):

Suggested change
if r.actualBlockNumber != r.config.expectedBlockNumber {
actualBlock, err := strconv.ParseUint(r.actualBlockNumber, 10, 64)
if err != nil {
return r.fail("node returned an invalid block number: " + r.actualBlockNumber)
}
if actualBlock != r.config.expectedBlock {

(and drop the now-redundant digitsPattern guard above, or keep it — ParseUint also rejects a negative/overflowing value that the regex would let through only as +Inf-style garbage).

Fix this →

Comment thread bench/rpc/cmd/runner/runner.go Outdated
Comment on lines +492 to +496
exitCode, commandErr := r.runK6(args, r.stdout)
metrics, err := promoteScenarioSummary(temporary, result)
if err != nil {
return r.fail(name + " did not produce a valid summary")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: commandErr is discarded on the path where it carries the most information.

If k6 can't start at all — binary missing from PATH, corpus unreadable, --duration rejected as a bad config, OOM-kill — runK6 returns a meaningful error and no summary is written, so promoteScenarioSummary fails and the run reports "concurrency did not produce a valid summary". The actual cause (exec: "k6": executable file not found in $PATH, or k6's own config error) is dropped on the floor and never reaches manifest.json, which is the only artifact a nightly job leaves behind. k6's stderr does go to r.stderr, but the manifest — the thing the deployment parses — records only the generic string.

Cheap fix: fold it into the reason, e.g.

	metrics, err := promoteScenarioSummary(temporary, result)
	if err != nil {
		if commandErr != nil {
			return r.fail(fmt.Sprintf("%s did not produce a valid summary (exit %d): %v", name, exitCode, commandErr))
		}
		return r.fail(fmt.Sprintf("%s did not produce a valid summary: %v", name, err))
	}

Two related spots:

  • runWarmup (runner.go:468-471) folds commandErr != nil || exitCode != 0 into fail("warmup recorded check, request, or VU failures"), so a k6 exit that had nothing to do with checks is reported as a check failure. runScenario already separates these two cases (lines 501 and 504) — worth mirroring that here.
  • validateTarget (runner.go:405, 414, 422) collapses transport errors, non-2xx, JSON-RPC errors and unmarshal errors into "could not read Juno version" / "could not read chain ID" / "could not read block number". Connection-refused vs. a malformed response are very different operational signals for a nightly run; %w-wrapping the underlying error into the reason costs nothing.

Fix this →

Comment thread bench/rpc/cmd/runner/metrics.go Outdated
Comment on lines +51 to +63
values := make([]float64, len(keysForSummaryMetrics()))
keys := keysForSummaryMetrics()
for i, key := range keys {
values[i], err = metric(key[0], key[1])
if err != nil {
return nil, err
}
}
return &summaryMetrics{
FailedChecks: values[0], RequestFailures: values[1],
HTTPRequestFailures: values[2], VUFailures: values[3],
DroppedIterations: values[4], CompletedIterations: values[5],
}, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the struct is built by positional index into a slice whose order lives in a different function, so reordering keysForSummaryMetrics silently relabels every metric — FailedChecks would start reporting dropped_iterations and the run would keep passing. Nothing in the compiler or the tests catches that (TestParseSummaryMetricsSupportsBothK6Shapes uses distinct values 2/3/4/5/6, so it would catch a swap — but only if someone remembers to keep those distinct, and dropped_iterations is 0 there, so a swap involving it is invisible).

Named locals remove the coupling entirely:

	get := func(name, key string) float64 { ... }   // with the error captured once
	return &summaryMetrics{
		FailedChecks:        get("checks", "fails"),
		RequestFailures:     get("rpc_request_failures", metricCountKey),
		HTTPRequestFailures: get("http_req_failed", "passes"),
		VUFailures:          get("vu_failures", metricCountKey),
		DroppedIterations:   get("dropped_iterations", metricCountKey),
		CompletedIterations: get("iterations", metricCountKey),
	}, err

Smaller points in this file:

  • lines 51–52 call keysForSummaryMetrics() twice; it should be a package-level var (or gone, per above).
  • metric() returns 0 for an absent metric, so if a future k6 renames or stops emitting checks/rpc_request_failures, every assertion in runScenario reads zero and the run passes green. Given the harness exists to detect regressions, "metric missing" and "metric is zero" arguably shouldn't be the same value — at minimum worth a comment saying the fallback is deliberate.
  • {"http_req_failed", "passes"} reads backwards: for a k6 Rate, passes counts the failed requests. One comment saves the next reader a trip to the k6 docs.
  • HTTPRequestFailures is the only extracted field nothing ever asserts on (FailedChecks/RequestFailures/VUFailures are all checked in runner.go:504); if that's deliberate record-keeping, say so, otherwise it looks like a missed check.

Comment thread bench/rpc/run.js
Comment thread bench/rpc/cmd/runner/runner_test.go
Comment thread bench/rpc/cmd/runner/config.go Outdated
Comment on lines +75 to +92
if !commitPattern.MatchString(junoCommit) {
return nil, fmt.Errorf("embedded Juno commit is invalid")
}

required := []string{
"NODE_URL",
"READY_URL",
"EXPECTED_CHAIN_ID",
"EXPECTED_BLOCK_NUMBER",
"SNAPSHOT_ID",
"SNAPSHOT_SHA256",
"JUNO_IMAGE_DIGEST",
"RUNNER_IMAGE_DIGEST",
}
values := make(map[string]string, len(required))
for _, name := range required {
value, _ := getenv(name)
if value == "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (two related points, both carried over from the shell version):

  1. loadConfig failures are now the only path that produces no manifest. main.go:12 prints to stderr and exits 2 before a runner exists, so the single most likely operational failure — a deployment forgetting one of these eight variables — leaves /results empty, while the README says the manifest records failures. Everything downstream of this (corpus validation, configuration, readiness, …) does write a manifest now, so consumers need a separate "no manifest at all" code path just for this case. RESULTS_DIR is resolvable independently of the required set, so a minimal manifest with stage: "preflight" is reachable here.

  2. SNAPSHOT_ID, SNAPSHOT_SHA256, JUNO_IMAGE_DIGEST and RUNNER_IMAGE_DIGEST are required but never format-checked — they're only echoed into manifest.json as provenance. test-runner.sh:95 passes JUNO_IMAGE_DIGEST=sha256:juno, which is exactly the shape of a typo that would silently poison the provenance record for a whole run of nightly results. A cheap check in validate() catches it at the right stage:

if !sha256Pattern.MatchString(c.snapshotSHA256) {
	return fmt.Errorf("SNAPSHOT_SHA256 must be a 64-character hex digest")
}
for name, digest := range map[string]string{"JUNO_IMAGE_DIGEST": c.junoImageDigest, "RUNNER_IMAGE_DIGEST": c.runnerImageDigest} {
	if !digestPattern.MatchString(digest) { // ^sha256:[0-9a-f]{64}$
		return fmt.Errorf("%s must be sha256:<64 hex>", name)
	}
}

Given the harness's whole value proposition is reproducibility, unverifiable provenance fields are the weakest link in the manifest.

Fix this →

@danielntmd
danielntmd force-pushed the danielntmd/benchmark-image branch from 32b6f69 to 6584da8 Compare August 14, 2026 12:54
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @danielntmd's task in 3m 34s —— View job


Review: nightly RPC benchmark harness — 6584da8b (rebase)

  • Diff the new commit against what I already reviewed
  • Re-verify which previously posted findings still apply
  • Review the new code fresh
  • Post inline comments + summary

This push is a rebase, not new work. The seven branch commits now sit directly on 6d423c72a (current main), and the tip commit's diff is the same change set I reviewed as 32b6f69tailWriter, reflect.DeepEqual in TestScenarioArgs, K6_READY_FILE/K6_SIGNAL_FILE, and the parseDuration unification. I re-verified each still-open finding against the working tree rather than assuming, so the list below is checked, not carried over. main moved only in rpc/ (gzip writer reuse, #3940), which doesn't interact with bench/.

go vet ./bench/rpc/... is clean. go test needs an approval I don't have in this run, so I read the tests rather than executing them; CI covers them.

Nits (new)

  1. runner.go:107-116 — two early exits still return 2 with no manifest.json, and unlike the MkdirAll failure above them, both happen after RESULTS_DIR exists, so a manifest is writable: an unwritable RUN_ID_FILE (a documented deployment integration point, README.md:99-102) and a cleanKnownOutputs failure on a stale root-owned artifact. Same class as the loadConfig hole the previous commit deliberately closed, and r.fail(...) is already reachable there (failExitCode 2, stage preflight).

Still open — verified against the current tree

  1. config.go:226parseDuration has no lower bound, so READY_POLL_INTERVAL=1ms is accepted and runReadiness polls and logs a line on every tick (~1.8 M requests and 1.8 M log lines over the default 30 m timeout, aimed at a node still restoring a snapshot). Same thread: READY_TIMEOUT=30 used to mean 30 s and is now rejected (worth checking argocd#9780 before merge), the message no longer names the accepted forms, and neither variable is in README.md's override table — I re-confirmed the table lists only ITERATIONS, VUS, CONCURRENCY_DURATION, THROUGHPUT_VUS, RATES, THROUGHPUT_DURATION.
  2. run.js:51iterationInTest restarts per k6 run, so the 200-iteration warmup and the 200-iteration single scenario both replay corpus[0..199]: sequential latency is measured entirely warm, and 9,800 of the 10,000 committed entries never touch that scenario.
  3. throughput.js:19maxVUs == preAllocatedVUs == 50, so dropped_iterations cannot separate node saturation from runner VU starvation (3000 req/s with 50 VUs requires latency < 16.7 ms).
  4. throughput.js:20ramping-arrival-rate from the default startRate: 0 never holds 1000/2000/3000 steady, and --summary-export emits one aggregate, so the manifest advertises rates next to percentiles that belong to none of them.
  5. metrics.go:57-77checks, http_req_failed, rpc_request_failures, vu_failures and iterations are all in the required set while dropped_iterations is the one optional metric, so a scenario that executes zero iterations fails as missing metric checks.fails instead of reaching the droppedIterations != 0 branch — the one input the harness exists to characterise. http_req_failed's passes counting failures also still deserves a comment now that runner.go:545 asserts on it.
  6. manifest.go — records no latency at all (summaryMetrics is six failure/iteration counters), so a performance run's manifest carries its full configuration and provenance but none of its performance.
  7. runner.go:570-586tailWriter truncation is silent and can start mid-rune, so a clipped failure.reason is indistinguishable from a complete one and may open with a U+FFFD.
  8. runner_test.go:426-438TestTailWriter's Fatalf reports only lengths, which are equal by construction, and the sub-cap accumulate path (the short executable file not found case the capture exists to preserve verbatim) is never executed.
  9. Dockerfile:49-56 — duplicates the make juno recipe and has already drifted (drops $(GO_TAGS), hardcodes the Linux CGO_LDFLAGS).
  10. test-runner.sh — negative cases still assert only "non-zero"; nothing pins the deliberate exit 2 (configuration, corpus-validation) vs 1 (readiness onwards) split the deployment side will branch on.
  11. .github/workflows/benchmark-image.yaml:10-12 — the push trigger still has no paths filter, so every merge to main runs a full -a Go + Rust build and moves :nightly. The pairing is safe (the runner rejects a Juno image whose version ≠ its embedded commit), just worth naming accurately. Comment-only: I can't modify .github/workflows/.
  12. bench/rpc/corpus/v0_10/getTransactionByHash.json is 1.6 MB on a single line; every regeneration adds another ~1.6 MB to history permanently.

No blockers, and nothing outside bench/ touches consensus, sync, or RPC-serving paths. Nothing was changed on the branch; this is review feedback only.
· branch danielntmd/benchmark-image

Comment on lines +107 to +116
if r.config.runIDFile != "" {
if err := writeAtomic(r.config.runIDFile, []byte(r.config.runID+"\n")); err != nil {
fmt.Fprintf(r.stderr, "write run ID: %v\n", err)
return 2
}
}
if err := r.cleanKnownOutputs(); err != nil {
fmt.Fprintf(r.stderr, "clean result outputs: %v\n", err)
return 2
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (not raised before — a residual of the "every failure writes a manifest" fix): moving the required-variable checks into validateProvenance() closed the big hole, but two reachable early exits still return 2 with no manifest.json, and both happen after RESULTS_DIR already exists — so writing one is possible here, unlike the MkdirAll failure above.

  • RUN_ID_FILE (line 108) is a documented deployment integration point — README.md:99-102 says the deployment sets it "for a deployment-side publisher". A wrong path, a missing parent directory, or a read-only mount is exactly the sort of thing that goes wrong once in a deployment's lifetime, and it produces an empty results volume rather than a configuration-stage manifest. Note writeAtomic writes its temp file into filepath.Dir(RUN_ID_FILE), so this fails for a directory that doesn't exist even if the final path would have been writable.
  • cleanKnownOutputs (line 113) fails on a stale root-owned manifest.json from a previous run under a different UID, or a permissions change on the volume — again silently manifest-less.

Both are the same class the last commit deliberately fixed for loadConfig; r.fail(...) is already reachable at this point (failExitCode is still 2 and currentStage is "preflight"), so the fix is mechanical:

	if r.config.runIDFile != "" {
		if err := writeAtomic(r.config.runIDFile, []byte(r.config.runID+"\n")); err != nil {
			return r.fail(fmt.Sprintf("write run ID: %v", err))
		}
	}
	if err := r.cleanKnownOutputs(); err != nil {
		return r.fail(fmt.Sprintf("clean result outputs: %v", err))
	}

with startSignalHandler hoisted above them if you want the signal contract to hold across these too. MkdirAll genuinely can't write a manifest, so leaving that one as a bare stderr line is right — worth saying so in README.md since it becomes the only no-manifest exit.

Fix this →

Comment thread bench/rpc/cmd/runner/manifest.go Outdated
return fmt.Errorf("marshal manifest: %w", err)
}
data = append(data, '\n')
return writeAtomic(filepath.Join(r.config.resultsDir, "manifest.json"), data)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it named atomic? If it's referring to an atomic operation, I don't think it's actually atomic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, naming changed during migration, will rename since it does guarantee stronger guarantees implied by atomic.

@@ -0,0 +1,698 @@
package main

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
package main
package k6runner

Or something along these lines

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should keep it as main for now as bench/rpc/cmd/corpus-gen and cmd/junoalso follows this; renaming it would require exporting an entry point and more directory churn.

If we have another consumer, then we can rename and create those internal file structure boundaries.

Comment thread Dockerfile Outdated
# Build with make juno
RUN make juno
# Dedicated benchmark images provide an exact source version. Existing image
# builds retain the Makefile's git-describe version.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered updating Makefile to something like this:

JUNO_VERSION ?= $(shell git describe --tags)
juno: rustdeps
      @go build $(GO_TAGS) -a -ldflags="-X main.Version=$(JUNO_VERSION)" -o build/juno ./cmd/juno/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started with this approach, but failed the full suite CI (mandated by makefile change) on ARM and macOS.

I'll restore and try again.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @danielntmd's task in 4m 50s —— View job


Review: nightly RPC benchmark harness — b33a20a9 (review-feedback pass)

  • Diff b33a20a9 against what I already reviewed (6584da8b)
  • Re-verify which previously posted findings still apply
  • Review the new code fresh
  • Post inline comments + summary

b33a20a9 is small — it answers @brbrr's two review comments and touches nothing else. Both are closed, verified rather than assumed:

review comment status
writeAtomic isn't actually atomic fixed — renamed to writeFileViaRename / temporaryFilePath / temporaryFilePattern, and the rename is complete (grep for writeAtomic|atomicTemporary across *.go/*.md/*.sh returns nothing, so no stale doc or shell reference). The new name is the accurate one: rename(2) within a filesystem is atomic for a concurrent reader, so the manifest can never be observed half-written; what the function never had is fsync on the temp file and parent dir, i.e. crash durability, which is not something a benchmark manifest needs
duplicate make juno recipe in the Dockerfile fixedJUNO_VERSION ?= $(shell git describe --tags) in the Makefile, Dockerfile reduced to make juno JUNO_VERSION="${JUNO_VERSION}", so $(GO_TAGS) and the platform CGO_LDFLAGS are no longer forked and can't drift. juno-cached picked up the same variable, and Makefile was added to the workflow's pull_request paths. This closes the Dockerfile:49-56 finding I'd carried across four passes
keep package main acknowledged, agreed — bench/rpc/cmd/corpus-gen and cmd/juno are both main, and a library package would need an exported entry point for one consumer

go vet ./bench/rpc/... is clean. go test needs an approval I don't have in this run, so I read the tests rather than executing them; CI covers them.

Nits (new)

  1. Makefile:33?= is ifeq ($(origin V),undefined), and GNU Make gives a set-but-empty environment variable origin environment, so JUNO_VERSION= does not fall back to git describe — it stamps -X main.Version= silently. That lands on the branch this PR didn't change: Dockerfile:49 takes else make juno exactly when JUNO_VERSION is empty, so if BuildKit puts a valueless ARG into the RUN env as "", the guard is self-defeating. I believe BuildKit omits valueless args (dispatchRun only AddEnvs non-nil values), which would make this latent rather than live — but docker and make both needed approvals I don't have in this job, so please confirm rather than take my word for it. An ifeq ($(strip $(JUNO_VERSION)),) guard makes the question moot and collapses Dockerfile:49-53 to a single un-branched RUN.
  2. .github/workflows/benchmark-image.yaml:10 — adding Makefile to the paths filter exposes that the test job builds only bench/rpc/Dockerfile; the main Dockerfile, and therefore the whole JUNO_VERSION plumbing this commit rewrote, is built only in publish (gated on main, and it pushes). So Build RPC Benchmark Images passing here says nothing about whether make juno JUNO_VERSION=sha-<sha> works. The failure mode is recoverable but late — publish succeeds, :nightly moves, and the first signal is nightly runs failing in validateTarget. make -n juno JUNO_VERSION=sha-test | grep -F asserts the substitution for free, without compiling. Comment-only: I can't modify .github/workflows/.

Still open — re-verified against the current tree, not carried over

  1. run.js:51 — still corpus[exec.scenario.iterationInTest % corpus.length] with no offset, so the 200-iteration warmup and the 200-iteration single scenario both replay corpus[0..199]: sequential latency is measured entirely warm, and 9,800 of the 10,000 committed entries never touch that scenario.
  2. config.go:226parseDuration is time.ParseDuration + > 0 with no lower bound, so READY_POLL_INTERVAL=1ms is accepted and runReadiness polls and logs a line every tick (~1.8 M requests and 1.8 M log lines over the default 30 m timeout, aimed at a node still restoring a snapshot). Same thread: READY_TIMEOUT=30 used to mean 30 s and is now rejected (worth checking argocd#9780 before merge), the message no longer names the accepted forms, and neither variable is in README.md's override table — re-confirmed it lists only ITERATIONS, VUS, CONCURRENCY_DURATION, THROUGHPUT_VUS, RATES, THROUGHPUT_DURATION.
  3. throughput.js:19 — still maxVUs: THROUGHPUT_VUS alongside preAllocatedVUs: THROUGHPUT_VUS, so dropped_iterations cannot separate node saturation from runner VU starvation (3000 req/s with 50 VUs requires latency < 16.7 ms).
  4. throughput.js:20 — still ramping-arrival-rate with stages from the default startRate: 0, so 1000/2000/3000 are never held steady, and --summary-export emits one aggregate; the manifest advertises rates next to percentiles that belong to none of them.
  5. metrics.go:57-77checks, rpc_request_failures, http_req_failed, vu_failures and iterations are all still in the required table while dropped_iterations is the one optional metric, so a scenario that executes zero iterations fails as missing metric checks.fails instead of reaching the droppedIterations != 0 branch — the one input the harness exists to characterise. http_req_failed's passes counting failures still deserves a comment now that it's asserted on.
  6. runner.go:107-116 — two early exits still return 2 with no manifest.json, both after RESULTS_DIR exists so a manifest is writable: an unwritable RUN_ID_FILE (a documented integration point, README.md:99-102) and a cleanKnownOutputs failure. r.fail(...) is already reachable there.
  7. manifest.go — records no latency at all, so a performance run's manifest carries its full configuration and provenance but none of its performance.
  8. runner.go:570-586tailWriter truncation is silent and can start mid-rune, so a clipped failure.reason is indistinguishable from a complete one and may open with a U+FFFD.
  9. runner_test.go:426-438TestTailWriter's Fatalf reports only lengths, equal by construction, and the sub-cap accumulate path (the short executable file not found case the capture exists to preserve verbatim) is never executed.
  10. test-runner.sh — negative cases still assert only "non-zero"; nothing pins the deliberate exit 2 (configuration, corpus-validation) vs 1 (readiness onwards) split the deployment side will branch on.
  11. bench/rpc/corpus/v0_10/getTransactionByHash.json is 1.6 MB on a single line; every regeneration adds another ~1.6 MB to history permanently.

No blockers, and nothing outside bench/ touches consensus, sync, or RPC-serving paths — the Makefile edit is version-string-only and platform-independent, so if the full suite trips on macOS/ARM again it won't be from this. Nothing was changed on the branch; this is review feedback only.
· branch danielntmd/benchmark-image

Comment thread Makefile
Comment thread .github/workflows/benchmark-image.yaml
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @danielntmd's task in 5m 19s —— View job


Review: nightly RPC benchmark harness — 88ee5e3b

  • Diff 88ee5e3b against what I already reviewed (b33a20a9)
  • Verify the new version-plumbing assertion actually holds
  • Re-verify which previously posted findings still apply
  • Post inline comments + summary

88ee5e3b is three lines in .github/workflows/benchmark-image.yaml and nothing else (git diff b33a20a9..HEAD --stat = one file, +3), so every Go/JS/shell finding below is unchanged code I'd already read — I spot-re-verified each against the working tree rather than carrying it over.

The new step closes the positive half of the JUNO_VERSION plumbing gap, and it's cheaper than it looks. I traced it: make -n prints the @-silenced recipe lines, and the only lines it actually executes are the $(MAKE) -C vm/rust / starknet/compiler/rust recursions — which inherit -n via MAKEFLAGS and so merely print cargo build --release. No Go or Rust toolchain is needed, and because JUNO_VERSION=sha-test is a command-line override the ?= never expands, so this job's tag-less checkout doesn't matter. needs: test means a broken substitution now blocks publish. Details, plus a correction to my own earlier suggestion, in the inline comment.

Two notes on the ?= thread, since you accepted the risk (fair — publish always passes sha-${{ github.sha }}, which I re-confirmed):

  • The second assertion I'd suggested (env JUNO_VERSION= make -n juno | grep -F -- '-X main.Version=v') would have failed here, so omitting it was correct: this job's checkout doesn't fetch tags, so git describe --tags errors and the fallback stamps an empty version.
  • New data point on the BuildKit question I asked you to confirm: deploy-dev-and-test.yml runs on every non-fork PR to main with no paths filter and calls build-image.yaml, which builds the root Dockerfile at fetch-depth: 0 with no JUNO_VERSION build-arg. So the else make juno branch is compiled on this PR — exercised, not asserted (an empty -X main.Version= builds fine). juno --version on the dev image this PR produces answers it definitively if you ever want it answered.

go vet ./bench/rpc/... is clean. go test still needs an approval I don't have in this job, so I read the tests rather than executing them; CI covers them.

Nits (new)

  1. .github/workflows/benchmark-image.yaml:45-46 — the step works; the residuals are that a make failure before the recipe prints surfaces as a bare exit 1 from grep (no pipefail, since there's no shell: bash), and juno-cached picked up $(JUNO_VERSION) in the same commit without coverage. Comment-only either way: I can't modify .github/workflows/.

Still open — re-verified against the current tree

  1. bench/rpc/run.js:51 — still corpus[exec.scenario.iterationInTest % corpus.length] with no offset, so the 200-iteration warmup and the 200-iteration single scenario both replay corpus[0..199]: sequential latency is measured entirely warm, and 9,800 of the 10,000 committed entries never touch that scenario.
  2. bench/rpc/throughput.js:19 — re-read the file: still preAllocatedVUs: THROUGHPUT_VUS and maxVUs: THROUGHPUT_VUS, so dropped_iterations can't separate node saturation from runner VU starvation (3000 req/s with 50 VUs requires latency < 16.7 ms).
  3. bench/rpc/throughput.js:20 — still ramping-arrival-rate with stages from the default startRate: 0, so 1000/2000/3000 are never held steady while --summary-export emits one aggregate; the manifest advertises rates next to percentiles that belong to none of them.
  4. bench/rpc/cmd/runner/metrics.go:57-77checks, rpc_request_failures, http_req_failed, vu_failures and iterations are all still in required while dropped_iterations is the lone optional metric, so a scenario that executes zero iterations fails as missing metric checks.fails instead of reaching the droppedIterations != 0 branch — the one input the harness exists to characterise. http_req_failed's passes counting failures still deserves a comment now that it's asserted on.
  5. bench/rpc/cmd/runner/config.go:226parseDuration is time.ParseDuration + > 0 with no floor, so READY_POLL_INTERVAL=1ms is accepted and runReadiness polls and logs every tick (~1.8 M requests and log lines over the default 30 m timeout, aimed at a node still restoring a snapshot). Same thread: READY_TIMEOUT=30 used to mean 30 s and is now rejected (worth a look at argocd#9780 before merge), the message no longer names the accepted forms, and README.md's override table still lists only ITERATIONS, VUS, CONCURRENCY_DURATION, THROUGHPUT_VUS, RATES, THROUGHPUT_DURATION — neither ready variable appears.
  6. bench/rpc/cmd/runner/runner.go:107-116 — still two fmt.Fprintf(r.stderr, …); return 2 exits with no manifest.json, both after MkdirAll succeeded so a manifest is writable: an unwritable RUN_ID_FILE (a documented integration point) and a cleanKnownOutputs failure. r.fail(...) is already reachable there.
  7. bench/rpc/cmd/runner/manifest.go — records no latency at all (the scenario Duration fields are the configured durations, not measurements), so a performance run's manifest carries its full configuration and provenance but none of its performance.
  8. bench/rpc/cmd/runner/runner.go:570-586tailWriter truncation is silent and can start mid-rune.
  9. bench/rpc/cmd/runner/runner_test.go:426-438TestTailWriter's Fatalf reports only lengths, equal by construction, and the sub-cap accumulate path (the short executable file not found case the capture exists to preserve verbatim) is never executed.
  10. bench/rpc/test-runner.sh — negative cases still assert only "non-zero"; nothing pins the deliberate exit 2 (configuration, corpus-validation) vs 1 (readiness onwards) split the deployment side will branch on.
  11. .github/workflows/benchmark-image.yaml:10-12 — the push trigger still has no paths filter, so every merge to main runs the full -a Go + Rust build and moves :nightly, which makes :nightly mean "latest main". The pairing is safe (the runner rejects a Juno image whose version ≠ its embedded commit). Comment-only.
  12. bench/rpc/corpus/v0_10/getTransactionByHash.json is 1.6 MB on a single line; every regeneration adds another ~1.6 MB to history permanently.

No blockers. Nothing outside bench/ touches consensus, sync, or RPC-serving paths, and this commit touches only CI. Nothing was changed on the branch — this is review feedback only.
· branch danielntmd/benchmark-image

Comment thread .github/workflows/benchmark-image.yaml
@danielntmd

Copy link
Copy Markdown
Contributor Author

superseded by #3944

@danielntmd danielntmd closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants