Compare commits

..

1 Commits

Author SHA1 Message Date
Arsenii Kulikov
7b7921c4ae convert spans 2026-02-28 01:04:15 +04:00
361 changed files with 6850 additions and 24716 deletions

View File

@@ -1,5 +0,0 @@
---
reth-trie-sparse: patch
---
Fixed a bug in `merge_subtrie_updates` where source insertions did not cancel destination removals (and vice versa), causing inconsistent trie updates accumulated across multiple `root()` calls without intermediate `take_updates()`. Added a test covering the cross-cancellation behavior.

View File

@@ -1,5 +0,0 @@
---
reth-tasks: patch
---
Added panic handler to all rayon thread pools that logs panics via `tracing::error` instead of aborting the process.

View File

@@ -1,5 +0,0 @@
---
reth-network-types: patch
---
Increased default maximum concurrent outbound dials from 15 to 30.

View File

@@ -1,5 +0,0 @@
---
reth-trie-sparse: patch
---
Refactored arena trie internals by adding a `BranchChildIdx::sibling()` helper, deduplicating `Index`/`NodeArena` type aliases, and replacing `is_empty()` with a `drop_root()` method. Fixed a bug where `cursor.pop()` was called before checking if the leaf was the root node, which could cause incorrect dirty-state propagation.

View File

@@ -1,5 +0,0 @@
---
reth-payload-builder: minor
---
Added observability metrics for payload resolve latency and new payload job creation latency to the payload builder service.

View File

@@ -1,10 +0,0 @@
---
reth-chain-state: minor
reth-engine-primitives: minor
reth-engine-tree: minor
reth-node-core: minor
reth-node-events: minor
reth: patch
---
Added configurable slow block logging (`--engine.slow-block-threshold`) that emits a structured `warn!` log with detailed timing, state-operation counts, and cache hit-rate metrics for blocks whose total processing time exceeds the threshold. Introduced `ExecutionTimingStats`, `CacheStats`, `StateProviderStats`, and `SlowBlockInfo` types to carry execution statistics from block validation through persistence, and refactored `PersistenceResult` to carry commit duration alongside the last persisted block.

View File

@@ -1,6 +0,0 @@
---
reth-trie-db: minor
reth-engine-tree: minor
---
Added `PendingChangeset` and `PendingChangesetGuard` to `ChangesetCache` so concurrent readers wait for an in-progress computation instead of falling back to the expensive DB-based path. The guard automatically cancels the pending entry on drop (e.g. task panic), ensuring waiters always make progress.

View File

@@ -1,8 +0,0 @@
---
reth-engine-primitives: minor
reth-engine-tree: minor
reth-node-core: minor
reth-trie-parallel: minor
---
Added `--engine.proof-jitter` CLI option behind the `trie-debug` feature flag. When set, each proof worker sleeps for a random duration up to the specified value before starting proof computation, useful for stress-testing timing-sensitive proof logic.

View File

@@ -1,5 +0,0 @@
---
reth-rpc-convert: patch
---
Updated `alloy-evm` dependency to git revision `9bc2dba` and adapted `TxEnvConverter` impl to the updated `TryIntoTxEnv` trait signature that now includes a `Spec` generic parameter.

View File

@@ -1,6 +0,0 @@
---
reth-trie: patch
reth-trie-sparse: patch
---
Refactored test harness for sparse trie tests by extracting `TrieTestHarness` into a shared `reth-trie` test utility, replacing duplicated inline harness code across multiple test modules. Updated `proof_v2` return type to include an optional root hash, and converted `original_root` and `storage` from public fields to accessor methods.

View File

@@ -1,5 +0,0 @@
---
reth-trie: patch
---
Removed the local `increment_and_strip_trailing_zeros` function and `PATH_ALL_ZEROS` static in `proof_v2`, replacing them with the equivalent `Nibbles::next_without_prefix` and `Nibbles::is_zeroes` builtins. Also replaced manual `.get()` calls on `state_mask`/`hash_mask` with direct field access and switched to `Nibbles::unpack_array` over the unsafe `unpack_unchecked`.

View File

@@ -1,7 +0,0 @@
---
reth-cli-commands: minor
reth-node-core: minor
reth: patch
---
Made v2 storage the default for all new databases, deprecating the `--storage.v2` flag to a hidden no-op kept for backwards compatibility. Updated CLI reference docs to remove the now-hidden flag from all command help pages.

View File

@@ -1,7 +0,0 @@
---
reth-engine-tree: patch
reth-trie-sparse: patch
reth-tasks: patch
---
Offloaded deallocation of expensive proof node buffers to a persistent background thread (`Runtime::spawn_drop`) to avoid blocking state root computation or lock-holding code.

View File

@@ -1,6 +0,0 @@
---
reth-trie-sparse: patch
reth-engine-tree: patch
---
Removed the `skip_proof_node_filtering` flag, `revealed_account_paths`/`revealed_paths` tracking, and the `filter_revealed_v2_proof_nodes` function from the sparse trie implementation. Also removed the corresponding skipped-nodes metrics, simplifying the proof node reveal path to always pass nodes directly to the sparse trie without pre-filtering.

View File

@@ -1,5 +0,0 @@
---
reth-trie-sparse: minor
---
Added a comprehensive generic `SparseTrie` test suite covering `set_root`, `reveal_nodes`, `update_leaves`, `root`, `take_updates`, `commit_updates`, `prune`, `wipe`/`clear`, `get_leaf_value`, `find_leaf`, `size_hint`, and integration lifecycle scenarios. Tests are stamped out for all concrete `SparseTrie` implementations via a macro.

View File

@@ -1,5 +0,0 @@
---
reth-cli-commands: minor
---
Added `reth_version` field to `SnapshotManifest` to record the Reth version that produced a snapshot. The field is optional and populated automatically during manifest generation.

View File

@@ -1,14 +0,0 @@
---
reth-engine-tree: patch
reth-evm-ethereum: patch
reth-evm: patch
reth-primitives-traits: patch
reth-revm: patch
reth-rpc-eth-api: patch
reth-rpc-eth-types: patch
reth-provider: patch
example-custom-evm: patch
example-precompile-cache: patch
---
Bumped revm to v35.0.0, revm-inspectors to 0.35.0, and alloy-evm to 0.29.0. Updated call sites throughout the codebase to align with the new APIs, including `ExecutionResult` field changes (`gas_used``gas.used()`/`gas.final_refunded()`), removal of `.without_state_clear()`, updated `EthPrecompiles::new(spec)` constructor, and updated `block_hashes.lowest()` access.

View File

@@ -1,9 +0,0 @@
---
reth-trie-sparse: minor
reth-engine-primitives: minor
reth-engine-tree: minor
reth-node-core: minor
reth-trie-common: patch
---
Added an arena-based sparse trie implementation (`ArenaParallelSparseTrie`) using `slotmap` arena allocation for node storage, enabling parallel subtrie mutation without per-node hashing overhead. Added `ConfigurableSparseTrie` enum to switch between the arena and hash-map implementations, and a `--engine.enable-arena-sparse-trie` CLI flag to opt in at runtime.

View File

@@ -1,7 +0,0 @@
---
reth-trie: major
reth-trie-db: major
reth-provider: minor
---
Added `MaskedTrieCursorFactory` and `MaskedTrieCursor` to handle prefix-set-based hash invalidation at the cursor layer, replacing the `DatabaseTrieWitness` trait abstraction. Removed `with_prefix_sets_mut` from `TrieWitness` and deleted `DatabaseTrieWitness` — callers should now wrap their cursor factory with `MaskedTrieCursorFactory` to apply prefix sets during witness/proof computation.

View File

@@ -1,5 +0,0 @@
---
reth-trie: patch
---
Fixed a potential panic in `ProofCalculator` by clearing internal computation state (`branch_stack`, `child_stack`, `branch_path`, etc.) after errors, preventing stale state from causing `usize` underflow panics when the calculator is reused. Added a test verifying correct behavior after simulated mid-computation errors.

View File

@@ -20,11 +20,6 @@
# include dist directory, where the reth binary is located after compilation
!/dist
# include PGO build helper used by Dockerfile.depot
!/.github
!/.github/scripts
!/.github/scripts/build_pgo_bolt.sh
# include licenses
!LICENSE-*

View File

@@ -5,4 +5,3 @@ self-hosted-runner:
- depot-ubuntu-latest-4
- depot-ubuntu-latest-8
- depot-ubuntu-latest-16
- available

View File

@@ -1,276 +0,0 @@
#!/usr/bin/env python3
"""
Prometheus metrics proxy that fetches from a local reth node and
re-exposes with additional benchmark labels.
Reads labels from a JSON file (updated by local-reth-bench.sh between runs)
and injects them into every Prometheus metric line.
Returns empty 200 when reth is not running (clean Grafana gaps).
"""
import argparse
import ipaddress
import json
import subprocess
import sys
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.request import urlopen
from urllib.error import URLError
def read_labels(path):
try:
with open(path) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def inject_labels(metrics_bytes, label_str, label_names):
"""Inject labels into Prometheus text format.
Operates on bytes and uses simple string ops instead of regex
for speed on large payloads (reth exposes thousands of metrics).
Skips injecting into lines that already contain any of the label names
to avoid duplicate labels (which Prometheus rejects).
"""
if not label_str:
return metrics_bytes
label_bytes = label_str.encode("utf-8")
# Pre-encode label names for fast duplicate detection
label_name_bytes = [n.encode("utf-8") for n in label_names]
out = []
for line in metrics_bytes.split(b"\n"):
# Skip comments and blank lines
if line.startswith(b"#") or not line:
out.append(line)
continue
brace = line.find(b"{")
space = line.find(b" ")
if space == -1:
# Malformed, pass through
out.append(line)
elif brace != -1 and brace < space:
# Has labels: metric{existing="val"} 123
close = line.find(b"}", brace)
if close == -1:
out.append(line)
continue
# Filter out labels that already exist in this line
existing = line[brace + 1:close]
inject = label_bytes
if existing:
for name in label_name_bytes:
if name + b"=" in existing:
# Rebuild inject string excluding this label
inject = _remove_label(inject, name)
if not inject:
out.append(line)
continue
if close == brace + 1:
# Empty braces: metric{} 123
out.append(line[:close] + inject + line[close:])
else:
out.append(line[:close] + b"," + inject + line[close:])
else:
# No labels: metric 123
out.append(line[:space] + b"{" + label_bytes + b"}" + line[space:])
return b"\n".join(out)
def _remove_label(label_bytes, name):
"""Remove a single label (name=\"...\") from a comma-separated label string."""
parts = []
for part in label_bytes.split(b","):
if not part.startswith(name + b"="):
parts.append(part)
return b",".join(parts)
def build_label_str(labels):
"""Pre-format the label injection string: key1="val1",key2="val2" """
if not labels:
return ""
return ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
def build_elapsed_gauge(labels):
"""Build a bench_elapsed_seconds gauge from run_start_epoch in labels."""
start = labels.get("run_start_epoch")
if not start:
return b""
try:
elapsed = time.time() - float(start)
except (ValueError, TypeError):
return b""
# Build labels excluding internal keys
display = {k: v for k, v in labels.items()
if k not in ("run_start_epoch", "reference_epoch")}
lstr = build_label_str(display)
return (
f"# HELP bench_elapsed_seconds Seconds since benchmark run started\n"
f"# TYPE bench_elapsed_seconds gauge\n"
f"bench_elapsed_seconds{{{lstr}}} {elapsed:.1f}\n"
).encode("utf-8")
def compute_timestamp_ms(labels):
"""Compute a synthetic timestamp so all runs share a common time origin.
Returns the timestamp in milliseconds, or None if not enough info.
Uses: reference_epoch + (now - run_start_epoch) → all runs overlay at
the same Grafana time range.
"""
ref = labels.get("reference_epoch")
start = labels.get("run_start_epoch")
if not ref or not start:
return None
try:
elapsed = time.time() - float(start)
return int((float(ref) + elapsed) * 1000)
except (ValueError, TypeError):
return None
def inject_timestamps(metrics_bytes, timestamp_ms):
"""Append a Prometheus timestamp (ms) to every data line.
Prometheus text format: metric{labels} value [timestamp_ms]
Adding timestamps causes Prometheus to store all runs' samples
at the same relative time, enabling natural overlay in Grafana.
"""
if timestamp_ms is None:
return metrics_bytes
ts = str(timestamp_ms).encode("utf-8")
out = []
for line in metrics_bytes.split(b"\n"):
if line.startswith(b"#") or not line:
out.append(line)
else:
out.append(line + b" " + ts)
return b"\n".join(out)
class MetricsHandler(BaseHTTPRequestHandler):
# Use HTTP/1.1 so Content-Length is respected and Prometheus
# doesn't have to rely on connection close to detect end of body.
protocol_version = "HTTP/1.1"
def do_GET(self):
src = self.client_address[0]
try:
resp = urlopen(self.server.upstream, timeout=2)
metrics = resp.read()
except (URLError, ConnectionError, OSError):
# reth not running — return empty 200
self._send(b"")
#print(f" scrape from {src}: empty (reth not running)", flush=True)
return
all_labels = read_labels(self.server.labels_file)
# Internal keys — not injected as Prometheus labels
internal = ("run_start_epoch", "reference_epoch")
labels = {k: v for k, v in all_labels.items() if k not in internal}
label_str = build_label_str(labels)
label_names = sorted(labels.keys())
t0 = time.monotonic()
result = inject_labels(metrics, label_str, label_names)
result += build_elapsed_gauge(all_labels)
ts_ms = compute_timestamp_ms(all_labels)
result = inject_timestamps(result, ts_ms)
dt = time.monotonic() - t0
self._send(result)
print(f" scrape from {src}: {len(metrics)} -> {len(result)} bytes, "
f"inject {dt*1000:.1f}ms", flush=True)
def _send(self, body):
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
if body:
self.wfile.write(body)
def log_message(self, format, *args):
pass # suppress per-request logging
def resolve_bind_address(subnet_cidr):
"""Find the local IP address that belongs to the given subnet.
Uses ``ip -j addr show`` to enumerate interfaces and returns the first
address that falls within *subnet_cidr* (e.g. ``10.10.0.0/24``).
"""
network = ipaddress.ip_network(subnet_cidr, strict=False)
try:
result = subprocess.run(
["ip", "-j", "addr", "show"],
capture_output=True, text=True, check=True,
)
interfaces = json.loads(result.stdout)
except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError) as exc:
print(f"Error: cannot enumerate interfaces: {exc}", file=sys.stderr)
sys.exit(1)
for iface in interfaces:
for addr_info in iface.get("addr_info", []):
try:
addr = ipaddress.ip_address(addr_info["local"])
except (KeyError, ValueError):
continue
if addr in network:
return str(addr)
print(f"Error: no interface address found in subnet {subnet_cidr}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Prometheus metrics proxy with label injection")
parser.add_argument("--labels", default="/tmp/bench-metrics-labels.json",
help="Path to JSON file with labels to inject (default: /tmp/bench-metrics-labels.json)")
parser.add_argument("--upstream", default="http://127.0.0.1:9100/",
help="Upstream reth metrics URL (default: http://127.0.0.1:9100/)")
bind_group = parser.add_mutually_exclusive_group()
bind_group.add_argument("--bind", default=None,
help="Address to bind the proxy (default: 0.0.0.0)")
bind_group.add_argument("--subnet", default=None,
help="Auto-detect bind address from a local interface in this subnet (e.g. 10.10.0.0/24)")
parser.add_argument("--port", type=int, default=9090,
help="Port to bind the proxy (default: 9090)")
args = parser.parse_args()
if args.subnet:
bind_addr = resolve_bind_address(args.subnet)
elif args.bind:
bind_addr = args.bind
else:
bind_addr = "0.0.0.0"
server = HTTPServer((bind_addr, args.port), MetricsHandler)
server.upstream = args.upstream
server.labels_file = args.labels
print(f"bench-metrics-proxy listening on {bind_addr}:{args.port}")
print(f" upstream: {args.upstream}")
print(f" labels: {args.labels}")
sys.stdout.flush()
server.serve_forever()
if __name__ == "__main__":
main()

View File

@@ -22,22 +22,6 @@ MODE="$1"
SOURCE_DIR="$2"
COMMIT="$3"
# Tracy support: when BENCH_TRACY is "on" or "full", add Tracy cargo features
# and frame pointers for accurate stack traces.
EXTRA_FEATURES=""
EXTRA_RUSTFLAGS=""
if [ "${BENCH_TRACY:-off}" != "off" ]; then
EXTRA_FEATURES="tracy,tracy-client/ondemand"
EXTRA_RUSTFLAGS=" -C force-frame-pointers=yes"
fi
# Cache suffix: hash of features+rustflags so different build configs get separate cache entries
if [ -n "$EXTRA_FEATURES" ] || [ -n "$EXTRA_RUSTFLAGS" ]; then
BUILD_SUFFIX="-$(echo "${EXTRA_FEATURES}${EXTRA_RUSTFLAGS}" | sha256sum | cut -c1-12)"
else
BUILD_SUFFIX=""
fi
# Verify a cached reth binary was built from the expected commit.
# `reth --version` outputs "Commit SHA: <full-sha>" on its own line.
verify_binary() {
@@ -58,7 +42,7 @@ verify_binary() {
case "$MODE" in
baseline|main)
BUCKET="minio/reth-binaries/${COMMIT}${BUILD_SUFFIX}"
BUCKET="minio/reth-binaries/${COMMIT}"
mkdir -p "${SOURCE_DIR}/target/profiling"
CACHE_VALID=false
@@ -75,23 +59,14 @@ case "$MODE" in
if [ "$CACHE_VALID" = false ]; then
echo "Building baseline (${COMMIT}) from source..."
cd "${SOURCE_DIR}"
FEATURES_ARG=""
WORKSPACE_ARG=""
if [ -n "$EXTRA_FEATURES" ]; then
# --workspace is needed for cross-package feature syntax (tracy-client/ondemand)
FEATURES_ARG="--features ${EXTRA_FEATURES}"
WORKSPACE_ARG="--workspace"
fi
# shellcheck disable=SC2086
RUSTFLAGS="-C target-cpu=native${EXTRA_RUSTFLAGS}" \
cargo build --profile profiling --bin reth $WORKSPACE_ARG $FEATURES_ARG
cargo build --profile profiling --bin reth
$MC cp target/profiling/reth "${BUCKET}/reth"
fi
;;
feature|branch)
BRANCH_SHA="${4:-$COMMIT}"
BUCKET="minio/reth-binaries/${BRANCH_SHA}${BUILD_SUFFIX}"
BUCKET="minio/reth-binaries/${BRANCH_SHA}"
CACHE_VALID=false
if $MC stat "${BUCKET}/reth" &>/dev/null && $MC stat "${BUCKET}/reth-bench" &>/dev/null; then
@@ -110,14 +85,7 @@ case "$MODE" in
echo "Building feature (${COMMIT}) from source..."
cd "${SOURCE_DIR}"
rustup show active-toolchain || rustup default stable
if [ -n "$EXTRA_FEATURES" ]; then
# Can't use `make profiling` when adding features; build explicitly
# --workspace is needed for cross-package feature syntax (tracy-client/ondemand)
RUSTFLAGS="-C target-cpu=native${EXTRA_RUSTFLAGS}" \
cargo build --profile profiling --workspace --bin reth --features "${EXTRA_FEATURES}"
else
make profiling
fi
make profiling
make install-reth-bench
$MC cp target/profiling/reth "${BUCKET}/reth"
$MC cp "$(which reth-bench)" "${BUCKET}/reth-bench"

View File

@@ -1,581 +0,0 @@
#!/usr/bin/env bash
#
# local-reth-bench.sh — Run the reth Engine API benchmark locally.
#
# Replicates the CI bench.yml workflow (build, snapshot, system tuning,
# interleaved B-F-F-B execution, summary, charts) without any GitHub
# Actions glue (no PR comments, no artifact upload, no Slack).
#
# Usage:
# local-reth-bench.sh <baseline-ref> <feature-ref> [options]
#
# Options:
# --blocks N Number of blocks to benchmark (default: 500)
# --warmup N Number of warmup blocks (default: 100)
# --cores N Limit reth to N CPU cores, 0 = all available (default: 0)
# --samply Enable samply profiling
# --tracy MODE Tracy profiling: off, on, full (default: off)
# --tracy-filter F Tracy tracing filter (default: debug)
# --no-tune Skip system tuning (useful on dev machines / macOS)
#
# Requires: the reth repo at RETH_REPO (default: ~/reth)
#
# Dependencies (install before first run):
# mc (MinIO client), schelk, cpupower, taskset, stdbuf, python3, curl,
# make, uv, pzstd, jq, Rust toolchain (cargo/rustup)
#
# The script delegates to the existing bench-reth-*.sh scripts in the reth
# repo for the actual build, snapshot, and run steps.
set -euo pipefail
# ── PATH ──────────────────────────────────────────────────────────────
# Ensure cargo and user-local bins (mc, uv) are visible
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
# ── Defaults ──────────────────────────────────────────────────────────
RETH_REPO="${RETH_REPO:-$HOME/reth}"
BLOCKS=500
WARMUP=100
CORES=0
SAMPLY=false
TRACY="off"
TRACY_FILTER="debug"
TUNE=true
BASELINE_REF=""
FEATURE_REF=""
# ── Parse arguments ──────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") <baseline-ref> <feature-ref> [options]
Options:
--blocks N Number of blocks to benchmark (default: 500)
--warmup N Number of warmup blocks (default: 100)
--cores N Limit reth to N CPU cores (default: 0 = all)
--samply Enable samply profiling
--tracy MODE Tracy profiling: off, on, full (default: off)
on = tracing only (lower overhead)
full = tracing + CPU sampling (higher overhead)
--tracy-filter F Tracy tracing filter (default: debug)
--no-tune Skip system tuning
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--blocks) BLOCKS="$2"; shift 2 ;;
--warmup) WARMUP="$2"; shift 2 ;;
--cores) CORES="$2"; shift 2 ;;
--samply) SAMPLY=true; shift ;;
--tracy) TRACY="$2"; shift 2 ;;
--tracy-filter) TRACY_FILTER="$2"; shift 2 ;;
--no-tune) TUNE=false; shift ;;
--help|-h) usage ;;
-*) echo "Unknown option: $1"; usage ;;
*)
if [ -z "$BASELINE_REF" ]; then
BASELINE_REF="$1"
elif [ -z "$FEATURE_REF" ]; then
FEATURE_REF="$1"
else
echo "Unexpected argument: $1"; usage
fi
shift
;;
esac
done
if [ -z "$BASELINE_REF" ] || [ -z "$FEATURE_REF" ]; then
echo "Error: both <baseline-ref> and <feature-ref> are required."
usage
fi
# Validate --tracy value
case "$TRACY" in
off|on|full) ;;
*) echo "Error: --tracy must be off, on, or full (got: $TRACY)"; usage ;;
esac
# Samply + tracy=full are mutually exclusive (both use perf sampling)
if [ "$SAMPLY" = "true" ] && [ "$TRACY" = "full" ]; then
echo "Warning: samply and tracy=full both use perf sampling; downgrading tracy to 'on'."
TRACY="on"
fi
# ── Check dependencies ───────────────────────────────────────────────
missing=()
for cmd in mc schelk cpupower taskset stdbuf python3 curl make uv pzstd jq cargo; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "Error: missing required tools: ${missing[*]}"
echo "See the CI 'Install dependencies' step in .github/workflows/bench.yml for install instructions."
exit 1
fi
if [ "$TRACY" != "off" ]; then
if ! command -v tracy-capture &>/dev/null; then
echo "Error: tracy-capture is required for --tracy $TRACY"
exit 1
fi
fi
# Ensure tools that run via sudo are in a sudo-visible path.
# The bench scripts use `sudo schelk` / `sudo samply` but cargo installs
# them to ~/.cargo/bin which sudo's secure_path doesn't include.
for cmd in schelk samply; do
if command -v "$cmd" &>/dev/null && ! sudo sh -c "command -v $cmd" &>/dev/null; then
echo "Installing $cmd to /usr/local/bin (needed for sudo)..."
sudo install "$(command -v "$cmd")" /usr/local/bin/
fi
done
if [ ! -d "$RETH_REPO/.git" ]; then
echo "Error: RETH_REPO=$RETH_REPO is not a git repository."
echo "Set RETH_REPO or clone reth to ~/reth"
exit 1
fi
# ── Resolve paths ────────────────────────────────────────────────────
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="${RETH_REPO}/.github/scripts"
BENCH_WORK_DIR="${RETH_REPO}/../bench-work-$(date +%Y%m%d-%H%M%S)"
BASELINE_SRC="${RETH_REPO}/../reth-baseline"
FEATURE_SRC="${RETH_REPO}/../reth-feature"
mkdir -p "$BENCH_WORK_DIR"
BENCH_WORK_DIR="$(cd "$BENCH_WORK_DIR" && pwd)"
# ── Global cleanup trap (restores system tuning on any exit) ─────────
TUNING_APPLIED=false
CSTATE_PID=
METRICS_PROXY_PID=
cleanup_global() {
[ -n "$METRICS_PROXY_PID" ] && kill "$METRICS_PROXY_PID" 2>/dev/null || true
if [ "$TUNING_APPLIED" = true ]; then
echo
echo "▸ Restoring system settings..."
[ -n "$CSTATE_PID" ] && kill "$CSTATE_PID" 2>/dev/null || true
sudo systemctl start irqbalance cron atd 2>/dev/null || true
echo " System settings restored."
fi
}
trap cleanup_global EXIT
echo "═══════════════════════════════════════════════════════════"
echo " reth local benchmark"
echo "═══════════════════════════════════════════════════════════"
echo " Baseline ref : $BASELINE_REF"
echo " Feature ref : $FEATURE_REF"
echo " Blocks : $BLOCKS"
echo " Warmup : $WARMUP"
echo " Cores : $CORES"
echo " Samply : $SAMPLY"
echo " Tracy : $TRACY"
echo " Tracy filter : $TRACY_FILTER"
echo " System tune : $TUNE"
echo " Work dir : $BENCH_WORK_DIR"
echo " Reth repo : $RETH_REPO"
echo "═══════════════════════════════════════════════════════════"
echo
# Enable sccache if available (matches CI's RUSTC_WRAPPER=sccache)
if command -v sccache &>/dev/null; then
export RUSTC_WRAPPER="sccache"
fi
# Export env vars expected by the bench-reth-*.sh scripts
export BENCH_BLOCKS="$BLOCKS"
export BENCH_WARMUP_BLOCKS="$WARMUP"
export BENCH_CORES="$CORES"
export BENCH_SAMPLY="$SAMPLY"
export BENCH_TRACY="$TRACY"
export BENCH_TRACY_FILTER="$TRACY_FILTER"
export BENCH_WORK_DIR
export SCHELK_MOUNT="${SCHELK_MOUNT:-/reth-bench}"
export BENCH_RPC_URL="${BENCH_RPC_URL:-https://ethereum.reth.rs/rpc}"
export BENCH_METRICS_ADDR="127.0.0.1:9100"
# ── Step 1: Resolve refs to full SHAs ────────────────────────────────
echo "▸ Resolving git refs..."
cd "$RETH_REPO"
resolve_ref() {
local ref="$1"
git fetch origin "$ref" --quiet 2>/dev/null || true
git rev-parse "$ref" 2>/dev/null \
|| git rev-parse "origin/$ref" 2>/dev/null \
|| { echo "Error: cannot resolve ref '$ref'"; exit 1; }
}
BASELINE_SHA="$(resolve_ref "$BASELINE_REF")"
FEATURE_SHA="$(resolve_ref "$FEATURE_REF")"
echo " Baseline SHA : $BASELINE_SHA"
echo " Feature SHA : $FEATURE_SHA"
echo
# ── Step 2: Prepare source directories ───────────────────────────────
echo "▸ Preparing source directories..."
prepare_source() {
local src_dir="$1" ref="$2"
if [ -d "$src_dir" ]; then
git -C "$src_dir" fetch origin "$ref" 2>/dev/null || true
else
git clone --recurse-submodules "$RETH_REPO" "$src_dir"
fi
git -C "$src_dir" checkout "$ref" --force
git -C "$src_dir" submodule update --init --recursive
}
prepare_source "$BASELINE_SRC" "$BASELINE_SHA"
prepare_source "$FEATURE_SRC" "$FEATURE_SHA"
BASELINE_SRC="$(cd "$BASELINE_SRC" && pwd)"
FEATURE_SRC="$(cd "$FEATURE_SRC" && pwd)"
echo " Baseline src : $BASELINE_SRC"
echo " Feature src : $FEATURE_SRC"
echo
# ── Step 3: Check / download snapshot ────────────────────────────────
echo "▸ Checking snapshot..."
cd "$RETH_REPO"
SNAPSHOT_NEEDED=false
if ! "${SCRIPTS_DIR}/bench-reth-snapshot.sh" --check; then
SNAPSHOT_NEEDED=true
echo " Snapshot needs update."
else
echo " Snapshot is up-to-date."
fi
echo
# ── Step 4: Build binaries (+ snapshot download) in parallel ─────────
echo "▸ Building binaries (parallel)..."
cd "$RETH_REPO"
FAIL=0
"${SCRIPTS_DIR}/bench-reth-build.sh" baseline "$BASELINE_SRC" "$BASELINE_SHA" &
PID_BASELINE=$!
"${SCRIPTS_DIR}/bench-reth-build.sh" feature "$FEATURE_SRC" "$FEATURE_SHA" &
PID_FEATURE=$!
PID_SNAPSHOT=
if [ "$SNAPSHOT_NEEDED" = "true" ]; then
echo " Also downloading snapshot in parallel..."
"${SCRIPTS_DIR}/bench-reth-snapshot.sh" &
PID_SNAPSHOT=$!
fi
wait $PID_BASELINE || FAIL=1
wait $PID_FEATURE || FAIL=1
[ -n "$PID_SNAPSHOT" ] && { wait $PID_SNAPSHOT || FAIL=1; }
if [ $FAIL -ne 0 ]; then
echo "Error: one or more parallel tasks failed (builds / snapshot)"
exit 1
fi
echo " Binaries built successfully."
echo
# ── Step 5: System tuning (optional) ────────────────────────────────
if [ "$TUNE" = "true" ]; then
echo "▸ Applying system tuning..."
sudo cpupower frequency-set -g performance 2>/dev/null || true
# Disable turbo boost (Intel + AMD)
echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo 2>/dev/null || true
echo 0 | sudo tee /sys/devices/system/cpu/cpufreq/boost 2>/dev/null || true
sudo swapoff -a 2>/dev/null || true
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space 2>/dev/null || true
# Disable SMT (hyperthreading)
for cpu in /sys/devices/system/cpu/cpu*/topology/thread_siblings_list; do
[ -f "$cpu" ] || continue
first=$(cut -d, -f1 < "$cpu" | cut -d- -f1)
current=$(echo "$cpu" | grep -o 'cpu[0-9]*' | grep -o '[0-9]*')
if [ "$current" != "$first" ]; then
echo 0 | sudo tee "/sys/devices/system/cpu/cpu${current}/online" 2>/dev/null || true
fi
done
echo " Online CPUs: $(nproc)"
# Disable transparent huge pages
for p in /sys/kernel/mm/transparent_hugepage /sys/kernel/mm/transparent_hugepages; do
if [ -d "$p" ]; then
echo never | sudo tee "$p/enabled" 2>/dev/null || true
echo never | sudo tee "$p/defrag" 2>/dev/null || true
break
fi
done
# Prevent deep C-states
sudo sh -c 'exec 3<>/dev/cpu_dma_latency; echo -ne "\x00\x00\x00\x00" >&3; sleep infinity' &
CSTATE_PID=$!
# Pin IRQs to core 0
for irq in /proc/irq/*/smp_affinity_list; do
echo 0 | sudo tee "$irq" 2>/dev/null || true
done
# Stop noisy background services
sudo systemctl stop irqbalance cron atd unattended-upgrades snapd 2>/dev/null || true
TUNING_APPLIED=true
# Log environment for reproducibility (matches CI)
echo " === Benchmark environment ==="
echo " Kernel : $(uname -r)"
lscpu | grep -E 'Model name|CPU\(s\)|MHz|NUMA' | sed 's/^/ /'
echo " Governor : $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null || echo unknown)"
echo " Freq : $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq 2>/dev/null || echo unknown)"
echo " THP : $(cat /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null || cat /sys/kernel/mm/transparent_hugepages/enabled 2>/dev/null || echo unknown)"
free -h | sed 's/^/ /'
echo " System tuning applied."
echo
fi
# ── Step 5b: Tracefs mount (tracy=full only) ─────────────────────────
if [ "$TRACY" = "full" ] && [ "$(uname)" = "Linux" ]; then
echo "▸ Mounting tracefs for Tracy full mode..."
sudo mount -t tracefs tracefs /sys/kernel/tracing -o mode=755 2>/dev/null || true
fi
# ── Tracy upload & viewer helpers ────────────────────────────────────
TRACY_VIEWER_BASE="${TRACY_VIEWER_BASE:-}"
tracy_viewer_url() {
local profile_url="$1"
if [ -z "$TRACY_VIEWER_BASE" ]; then
echo ""
return
fi
local encoded
encoded=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$profile_url")
echo "${TRACY_VIEWER_BASE}?profile_url=${encoded}"
}
upload_tracy() {
local label="$1" output_dir="$2" sha="$3"
local tracy_file="$output_dir/tracy-profile.tracy"
if [ ! -f "$tracy_file" ]; then
echo " Tracy: no profile found, skipping upload."
return
fi
local timestamp short_sha remote_name bucket mc_alias
timestamp=$(date +%Y%m%d-%H%M%S)
short_sha="${sha:0:7}"
remote_name="${label}-${short_sha}-${timestamp}.tracy"
bucket="${TRACY_BUCKET:-tracy-profiles}"
mc_alias="${MC_ALIAS:-minio}"
local minio_base="${TRACY_MINIO_URL:-http://minio.minio.svc.cluster.local:9000}"
echo " Tracy: uploading profile..."
if mc cp "$tracy_file" "${mc_alias}/${bucket}/${remote_name}"; then
local url="${minio_base}/${bucket}/${remote_name}"
echo "$url" > "$output_dir/tracy_url.txt"
local viewer
viewer=$(tracy_viewer_url "$url")
if [ -n "$viewer" ]; then
echo "$viewer" > "$output_dir/tracy_viewer_url.txt"
echo " Tracy: uploaded → $viewer"
else
echo " Tracy: uploaded → $url"
fi
else
echo " Tracy: upload failed (non-fatal)."
fi
# Delete large profile to free disk
rm -f "$tracy_file"
}
# ── Step 6: Pre-flight cleanup ───────────────────────────────────────
echo "▸ Pre-flight cleanup..."
pkill -f bench-metrics-proxy 2>/dev/null || true
sudo pkill -9 reth 2>/dev/null || true
sleep 1
if mountpoint -q "$SCHELK_MOUNT" 2>/dev/null; then
sudo umount -l "$SCHELK_MOUNT" 2>/dev/null || true
sudo schelk recover -y 2>/dev/null || true
fi
echo
# ── Step 7: Interleaved benchmark runs (B-F-F-B) ────────────────────
# This ordering reduces systematic bias from thermal drift and cache warming.
BASELINE_BIN="${BASELINE_SRC}/target/profiling/reth"
FEATURE_BIN="${FEATURE_SRC}/target/profiling/reth"
# Start metrics proxy (reth → label injection → Prometheus)
LABELS_FILE="/tmp/bench-metrics-labels.json"
echo '{}' > "$LABELS_FILE"
METRICS_SUBNET="${METRICS_SUBNET:-10.10.0.0/24}"
METRICS_PORT="${METRICS_PORT:-9090}"
python3 "${SELF_DIR}/bench-metrics-proxy.py" \
--labels "$LABELS_FILE" \
--upstream "http://${BENCH_METRICS_ADDR}/" \
--subnet "$METRICS_SUBNET" \
--port "$METRICS_PORT" &
METRICS_PROXY_PID=$!
echo "▸ Metrics proxy started (PID $METRICS_PROXY_PID) on subnet ${METRICS_SUBNET}, port ${METRICS_PORT}"
# Unique benchmark ID: local-<timestamp> for local runs, ci-<run_id> for CI
BENCH_ID="local-$(basename "$BENCH_WORK_DIR" | sed 's/bench-work-//')"
# Reference epoch: shared time origin so all runs overlay in Grafana.
# The proxy maps each run's elapsed time onto this common origin.
BENCH_REFERENCE_EPOCH=$(date +%s)
write_labels() {
local run_label="$1" run_type="$2" ref="$3" sha="$4"
LAST_RUN_START=$(date +%s)
cat > "$LABELS_FILE" <<-EOF
{"benchmark_run":"${run_label}","run_type":"${run_type}","git_ref":"${ref}","bench_sha":"${sha}","benchmark_id":"${BENCH_ID}","run_start_epoch":"${LAST_RUN_START}","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
EOF
}
run_bench() {
local label="$1" binary="$2" output_dir="$3"
echo "▸ Running benchmark: ${label}..."
cd "$RETH_REPO"
if command -v taskset &>/dev/null; then
taskset -c 0 "${SCRIPTS_DIR}/bench-reth-run.sh" "$label" "$binary" "$output_dir"
else
"${SCRIPTS_DIR}/bench-reth-run.sh" "$label" "$binary" "$output_dir"
fi
echo "${label} complete."
echo
}
write_labels "baseline-1" "baseline" "$BASELINE_REF" "$BASELINE_SHA"
run_bench "baseline-1" "$BASELINE_BIN" "$BENCH_WORK_DIR/baseline-1"
write_labels "feature-1" "feature" "$FEATURE_REF" "$FEATURE_SHA"
run_bench "feature-1" "$FEATURE_BIN" "$BENCH_WORK_DIR/feature-1"
write_labels "feature-2" "feature" "$FEATURE_REF" "$FEATURE_SHA"
run_bench "feature-2" "$FEATURE_BIN" "$BENCH_WORK_DIR/feature-2"
write_labels "baseline-2" "baseline" "$BASELINE_REF" "$BASELINE_SHA"
run_bench "baseline-2" "$BASELINE_BIN" "$BENCH_WORK_DIR/baseline-2"
# ── Compute Grafana URL ──────────────────────────────────────────────
GRAFANA_BASE_URL="https://tempoxyz.grafana.net/d/reth-bench-ghr/reth-bench-ghr"
GRAFANA_DATASOURCE="ef57fux92e9z4e"
LAST_RUN_DURATION=$(( $(date +%s) - LAST_RUN_START ))
FROM_MS=$(( BENCH_REFERENCE_EPOCH * 1000 ))
TO_MS=$(( (BENCH_REFERENCE_EPOCH + LAST_RUN_DURATION) * 1000 ))
GRAFANA_URL="${GRAFANA_BASE_URL}?orgId=1&from=${FROM_MS}&to=${TO_MS}&timezone=browser&var-datasource=${GRAFANA_DATASOURCE}&var-job=reth-bench&var-benchmark_id=${BENCH_ID}&var-benchmark_run=\$__all"
# ── Step 8: Scan logs for errors ─────────────────────────────────────
echo "▸ Scanning logs for errors..."
ERRORS_FILE="$BENCH_WORK_DIR/errors.md"
found_errors=false
for run_dir in baseline-1 feature-1 feature-2 baseline-2; do
LOG="$BENCH_WORK_DIR/$run_dir/node.log"
[ -f "$LOG" ] || continue
panics=$(grep -c -E 'panicked at' "$LOG" 2>/dev/null || true)
errors=$(grep -c ' ERROR ' "$LOG" 2>/dev/null || true)
if [ "$panics" -gt 0 ] || [ "$errors" -gt 0 ]; then
if [ "$found_errors" = false ]; then
printf '### ⚠️ Node Errors\n\n' >> "$ERRORS_FILE"
found_errors=true
fi
printf '<details><summary><b>%s</b>: %d panic(s), %d error(s)</summary>\n\n' \
"$run_dir" "$panics" "$errors" >> "$ERRORS_FILE"
if [ "$panics" -gt 0 ]; then
printf '**Panics:**\n```\n' >> "$ERRORS_FILE"
grep -E 'panicked at' "$LOG" | head -10 >> "$ERRORS_FILE"
printf '```\n' >> "$ERRORS_FILE"
fi
if [ "$errors" -gt 0 ]; then
printf '**Errors (first 20):**\n```\n' >> "$ERRORS_FILE"
grep ' ERROR ' "$LOG" | head -20 >> "$ERRORS_FILE"
printf '```\n' >> "$ERRORS_FILE"
fi
printf '\n</details>\n\n' >> "$ERRORS_FILE"
fi
done
if [ "$found_errors" = true ]; then
echo " ⚠ Errors found — see $ERRORS_FILE"
else
echo " No errors found."
fi
echo
# ── Step 9: Parse results ───────────────────────────────────────────
echo "▸ Parsing results..."
cd "$RETH_REPO"
SUMMARY_ARGS=(
--output-summary "$BENCH_WORK_DIR/summary.json"
--output-markdown "$BENCH_WORK_DIR/comment.md"
--repo "paradigmxyz/reth"
--baseline-ref "$BASELINE_SHA"
--baseline-name "$BASELINE_REF"
--feature-name "$FEATURE_REF"
--feature-ref "$FEATURE_SHA"
--baseline-csv "$BENCH_WORK_DIR/baseline-1/combined_latency.csv" "$BENCH_WORK_DIR/baseline-2/combined_latency.csv"
--feature-csv "$BENCH_WORK_DIR/feature-1/combined_latency.csv" "$BENCH_WORK_DIR/feature-2/combined_latency.csv"
--gas-csv "$BENCH_WORK_DIR/feature-1/total_gas.csv"
--grafana-url "$GRAFANA_URL"
)
python3 "${SCRIPTS_DIR}/bench-reth-summary.py" "${SUMMARY_ARGS[@]}"
echo
# ── Step 10: Generate charts ─────────────────────────────────────────
echo "▸ Generating charts..."
CHART_ARGS=(
--output-dir "$BENCH_WORK_DIR/charts"
--feature "$BENCH_WORK_DIR/feature-1/combined_latency.csv" "$BENCH_WORK_DIR/feature-2/combined_latency.csv"
--baseline "$BENCH_WORK_DIR/baseline-1/combined_latency.csv" "$BENCH_WORK_DIR/baseline-2/combined_latency.csv"
--baseline-name "$BASELINE_REF"
--feature-name "$FEATURE_REF"
)
if python3 -c "import matplotlib" 2>/dev/null; then
python3 "${SCRIPTS_DIR}/bench-reth-charts.py" "${CHART_ARGS[@]}"
elif command -v uv &>/dev/null; then
uv run --with matplotlib python3 "${SCRIPTS_DIR}/bench-reth-charts.py" "${CHART_ARGS[@]}"
else
echo " Warning: matplotlib not available, skipping chart generation."
fi
echo
# ── Step 11: Upload Tracy profiles ────────────────────────────────────
if [ "$TRACY" != "off" ]; then
echo "▸ Uploading Tracy profiles..."
upload_tracy "baseline-1" "$BENCH_WORK_DIR/baseline-1" "$BASELINE_SHA"
upload_tracy "feature-1" "$BENCH_WORK_DIR/feature-1" "$FEATURE_SHA"
upload_tracy "feature-2" "$BENCH_WORK_DIR/feature-2" "$FEATURE_SHA"
upload_tracy "baseline-2" "$BENCH_WORK_DIR/baseline-2" "$BASELINE_SHA"
echo
fi
# ── Done (system restore happens via EXIT trap) ─────────────────────
echo "═══════════════════════════════════════════════════════════"
echo " Benchmark complete!"
echo "═══════════════════════════════════════════════════════════"
echo " Results : $BENCH_WORK_DIR/summary.json"
echo " Markdown : $BENCH_WORK_DIR/comment.md"
echo " Charts : $BENCH_WORK_DIR/charts/"
if [ -f "$ERRORS_FILE" ]; then
echo " Errors : $ERRORS_FILE"
fi
echo " Grafana : $GRAFANA_URL"
if [ "$TRACY" != "off" ]; then
echo " ─── Tracy Profiles ───"
for run_dir in baseline-1 feature-1 feature-2 baseline-2; do
url_file="$BENCH_WORK_DIR/$run_dir/tracy_viewer_url.txt"
if [ -f "$url_file" ]; then
echo " $run_dir : $(cat "$url_file")"
fi
done
fi
echo "═══════════════════════════════════════════════════════════"

View File

@@ -6,13 +6,6 @@
# Usage: bench-reth-run.sh <label> <binary> <output-dir>
#
# Required env: SCHELK_MOUNT, BENCH_RPC_URL, BENCH_BLOCKS, BENCH_WARMUP_BLOCKS
# Optional env: BENCH_BIG_BLOCKS (true/false), BENCH_WORK_DIR (for big blocks path)
# BENCH_RETH_NEW_PAYLOAD (true/false, default true)
# BENCH_WAIT_TIME (duration like 500ms, default empty)
# BENCH_BASELINE_ARGS (extra reth node args for baseline runs)
# BENCH_FEATURE_ARGS (extra reth node args for feature runs)
# BENCH_OTLP_TRACES_ENDPOINT (OTLP HTTP endpoint for traces, e.g. https://host/insert/opentelemetry/v1/traces)
# BENCH_OTLP_LOGS_ENDPOINT (OTLP HTTP endpoint for logs, e.g. https://host/insert/opentelemetry/v1/logs)
set -euo pipefail
LABEL="$1"
@@ -24,24 +17,6 @@ LOG="${OUTPUT_DIR}/node.log"
cleanup() {
kill "$TAIL_PID" 2>/dev/null || true
# Stop tracy-capture first (SIGINT makes it disconnect and flush to disk)
# Must happen before killing reth, otherwise reth keeps streaming data.
if [ -n "${TRACY_PID:-}" ] && kill -0 "$TRACY_PID" 2>/dev/null; then
echo "Stopping tracy-capture..."
kill -INT "$TRACY_PID" 2>/dev/null || true
for i in $(seq 1 30); do
kill -0 "$TRACY_PID" 2>/dev/null || break
if [ $((i % 10)) -eq 0 ]; then
echo "Waiting for tracy-capture to finish writing... (${i}s)"
fi
sleep 1
done
if kill -0 "$TRACY_PID" 2>/dev/null; then
echo "tracy-capture still running after 30s, killing..."
kill -9 "$TRACY_PID" 2>/dev/null || true
fi
wait "$TRACY_PID" 2>/dev/null || true
fi
if [ -n "${RETH_PID:-}" ] && sudo kill -0 "$RETH_PID" 2>/dev/null; then
if [ "${BENCH_SAMPLY:-false}" = "true" ]; then
# Send SIGINT to the inner reth process by exact name (not -f which
@@ -78,14 +53,8 @@ cleanup() {
fi
}
TAIL_PID=
TRACY_PID=
trap cleanup EXIT
# Clean up stale schelk state from a previous cancelled run.
# If schelk thinks it's still mounted (e.g. a cancelled run skipped cleanup),
# recover first to reset state.
sudo schelk recover -y -k || true
# Mount
sudo schelk mount -y
sync
@@ -104,8 +73,6 @@ if [ "${BENCH_CORES:-0}" -gt 0 ] && [ "$BENCH_CORES" -lt "$MAX_RETH" ]; then
fi
RETH_CPUS="1-${MAX_RETH}"
BIG_BLOCKS="${BENCH_BIG_BLOCKS:-false}"
RETH_ARGS=(
node
--datadir "$DATADIR"
@@ -120,68 +87,16 @@ RETH_ARGS=(
--no-persist-peers
)
# Big blocks mode requires the testing API and skip-invalid-transactions
if [ "$BIG_BLOCKS" = "true" ]; then
RETH_ARGS+=(--http.api eth,net,web3,reth,testing --testing.skip-invalid-transactions)
fi
# Append per-label extra node args (baseline or feature)
EXTRA_NODE_ARGS=""
case "$LABEL" in
baseline*) EXTRA_NODE_ARGS="${BENCH_BASELINE_ARGS:-}" ;;
feature*) EXTRA_NODE_ARGS="${BENCH_FEATURE_ARGS:-}" ;;
esac
if [ -n "$EXTRA_NODE_ARGS" ]; then
# Word-split the string into individual args
# shellcheck disable=SC2206
RETH_ARGS+=($EXTRA_NODE_ARGS)
fi
if [ -n "${BENCH_METRICS_ADDR:-}" ]; then
RETH_ARGS+=(--metrics "$BENCH_METRICS_ADDR")
fi
# OTLP traces and logs export
if [ -n "${BENCH_OTLP_TRACES_ENDPOINT:-}" ]; then
RETH_ARGS+=(--tracing-otlp="${BENCH_OTLP_TRACES_ENDPOINT}" --tracing-otlp.service-name=reth-bench)
fi
if [ -n "${BENCH_OTLP_LOGS_ENDPOINT:-}" ]; then
RETH_ARGS+=(--logs-otlp="${BENCH_OTLP_LOGS_ENDPOINT}" --logs-otlp.filter=debug)
fi
# Tracy profiling: add --log.tracy flags and set environment
if [ "${BENCH_TRACY:-off}" != "off" ]; then
RETH_ARGS+=(--log.tracy --log.tracy.filter "${BENCH_TRACY_FILTER:-debug}")
if [ "${BENCH_TRACY}" = "on" ]; then
export TRACY_NO_SYS_TRACE=1
elif [ "${BENCH_TRACY}" = "full" ]; then
export TRACY_SAMPLING_HZ="${BENCH_TRACY_SAMPLING_HZ:-1}"
fi
fi
SUDO_ENV=()
if [ -n "${OTEL_RESOURCE_ATTRIBUTES:-}" ]; then
SUDO_ENV+=("OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES}")
SUDO_ENV+=("OTEL_BSP_MAX_QUEUE_SIZE=65536" "OTEL_BLRP_MAX_QUEUE_SIZE=65536")
fi
# Limit reth memory to 95% of available RAM to prevent OOM kills
TOTAL_MEM_KB=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
MEM_LIMIT=$(( TOTAL_MEM_KB * 95 / 100 * 1024 ))
echo "Memory limit: $(( MEM_LIMIT / 1024 / 1024 ))MB (95% of $(( TOTAL_MEM_KB / 1024 ))MB)"
if [ "${BENCH_SAMPLY:-false}" = "true" ]; then
RETH_ARGS+=(--log.samply)
SAMPLY="$(which samply)"
sudo systemd-run --scope -p MemoryMax="$MEM_LIMIT" -p AllowedCPUs="$RETH_CPUS" \
env "${SUDO_ENV[@]}" nice -n -20 \
sudo taskset -c "$RETH_CPUS" nice -n -20 \
"$SAMPLY" record --save-only --presymbolicate --rate 10000 \
--output "$OUTPUT_DIR/samply-profile.json.gz" \
-- "$BINARY" "${RETH_ARGS[@]}" \
> "$LOG" 2>&1 &
else
sudo systemd-run --scope -p MemoryMax="$MEM_LIMIT" -p AllowedCPUs="$RETH_CPUS" \
env "${SUDO_ENV[@]}" nice -n -20 "$BINARY" "${RETH_ARGS[@]}" \
sudo taskset -c "$RETH_CPUS" nice -n -20 "$BINARY" "${RETH_ARGS[@]}" \
> "$LOG" 2>&1 &
fi
@@ -209,65 +124,21 @@ done
# files are not root-owned (avoids EACCES on next checkout).
BENCH_NICE="sudo nice -n -20 sudo -u $(id -un)"
# Build optional flags
EXTRA_BENCH_ARGS=()
if [ "${BENCH_RETH_NEW_PAYLOAD:-true}" != "false" ]; then
EXTRA_BENCH_ARGS+=(--reth-new-payload)
fi
if [ -n "${BENCH_WAIT_TIME:-}" ]; then
EXTRA_BENCH_ARGS+=(--wait-time "$BENCH_WAIT_TIME")
fi
# Warmup
$BENCH_NICE "$RETH_BENCH" new-payload-fcu \
--rpc-url "$BENCH_RPC_URL" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "${BENCH_WARMUP_BLOCKS:-50}" \
--reth-new-payload 2>&1 | sed -u "s/^/[bench] /"
if [ "$BIG_BLOCKS" = "true" ]; then
# Big blocks mode: replay pre-generated payloads with gas ramp
BIG_BLOCKS_DIR="${BENCH_WORK_DIR}/big-blocks"
# Count gas ramp blocks for reporting
GAS_RAMP_COUNT=$(find "$BIG_BLOCKS_DIR/gas-ramp-dir" -name '*.json' | wc -l)
echo "$GAS_RAMP_COUNT" > "$OUTPUT_DIR/gas_ramp_blocks.txt"
echo "Gas ramp blocks: $GAS_RAMP_COUNT"
# Start tracy-capture so profile only covers the benchmark
if [ "${BENCH_TRACY:-off}" != "off" ]; then
echo "Starting tracy-capture..."
tracy-capture -f -o "$OUTPUT_DIR/tracy-profile.tracy" &
TRACY_PID=$!
sleep 0.5 # give tracy-capture time to connect
fi
echo "Running big blocks benchmark (replay-payloads)..."
$BENCH_NICE "$RETH_BENCH" replay-payloads \
"${EXTRA_BENCH_ARGS[@]}" \
--gas-ramp-dir "$BIG_BLOCKS_DIR/gas-ramp-dir" \
--payload-dir "$BIG_BLOCKS_DIR/payloads" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--output "$OUTPUT_DIR" 2>&1 | sed -u "s/^/[bench] /"
else
# Standard mode: warmup + new-payload-fcu
# Warmup
$BENCH_NICE "$RETH_BENCH" new-payload-fcu \
--rpc-url "$BENCH_RPC_URL" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "${BENCH_WARMUP_BLOCKS:-50}" \
"${EXTRA_BENCH_ARGS[@]}" 2>&1 | sed -u "s/^/[bench] /"
# Start tracy-capture after warmup so profile only covers the benchmark
if [ "${BENCH_TRACY:-off}" != "off" ]; then
echo "Starting tracy-capture..."
tracy-capture -f -o "$OUTPUT_DIR/tracy-profile.tracy" &
TRACY_PID=$!
sleep 0.5 # give tracy-capture time to connect
fi
# Benchmark
$BENCH_NICE "$RETH_BENCH" new-payload-fcu \
--rpc-url "$BENCH_RPC_URL" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "$BENCH_BLOCKS" \
"${EXTRA_BENCH_ARGS[@]}" \
--output "$OUTPUT_DIR" 2>&1 | sed -u "s/^/[bench] /"
fi
# Benchmark
$BENCH_NICE "$RETH_BENCH" new-payload-fcu \
--rpc-url "$BENCH_RPC_URL" \
--engine-rpc-url http://127.0.0.1:8551 \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "$BENCH_BLOCKS" \
--reth-new-payload \
--output "$OUTPUT_DIR" 2>&1 | sed -u "s/^/[bench] /"
# cleanup runs via trap

View File

@@ -107,10 +107,6 @@ def compute_stats(combined: list[dict]) -> dict:
mgas_s_values.append(r["gas_used"] / lat_s / 1_000_000)
mean_mgas_s = sum(mgas_s_values) / len(mgas_s_values) if mgas_s_values else 0
total_latencies_ms = [r["total_latency_us"] / 1_000 for r in combined]
wall_clock_s = sum(total_latencies_ms) / 1_000
mean_total_lat_ms = sum(total_latencies_ms) / n
return {
"n": n,
"mean_ms": mean_lat,
@@ -119,8 +115,6 @@ def compute_stats(combined: list[dict]) -> dict:
"p90_ms": percentile(sorted_lat, 90),
"p99_ms": percentile(sorted_lat, 99),
"mean_mgas_s": mean_mgas_s,
"wall_clock_s": wall_clock_s,
"mean_total_lat_ms": mean_total_lat_ms,
}
@@ -145,14 +139,13 @@ def compute_wait_stats(combined: list[dict], field: str) -> dict:
def _paired_data(
baseline: list[dict], feature: list[dict]
) -> tuple[list[tuple[float, float]], list[float], list[float], list[float]]:
) -> tuple[list[tuple[float, float]], list[float], list[float]]:
"""Match blocks and return paired latencies and per-block diffs.
Returns:
pairs: list of (baseline_ms, feature_ms) tuples
lat_diffs_ms: list of feature baseline latency diffs in ms
mgas_diffs: list of feature baseline Mgas/s diffs
total_lat_diffs_ms: list of feature baseline total latency diffs in ms
"""
baseline_by_block = {r["block_number"]: r for r in baseline}
feature_by_block = {r["block_number"]: r for r in feature}
@@ -161,7 +154,6 @@ def _paired_data(
pairs = []
lat_diffs_ms = []
mgas_diffs = []
total_lat_diffs_ms = []
for bn in common_blocks:
b = baseline_by_block[bn]
f = feature_by_block[bn]
@@ -176,10 +168,7 @@ def _paired_data(
f["gas_used"] / f_lat_s / 1_000_000
- b["gas_used"] / b_lat_s / 1_000_000
)
total_lat_diffs_ms.append(
f["total_latency_us"] / 1_000 - b["total_latency_us"] / 1_000
)
return pairs, lat_diffs_ms, mgas_diffs, total_lat_diffs_ms
return pairs, lat_diffs_ms, mgas_diffs
def compute_paired_stats(
@@ -194,14 +183,12 @@ def compute_paired_stats(
all_pairs = []
all_lat_diffs = []
all_mgas_diffs = []
all_total_lat_diffs = []
blocks_per_pair = []
for baseline, feature in zip(baseline_runs, feature_runs):
pairs, lat_diffs, mgas_diffs, total_lat_diffs = _paired_data(baseline, feature)
pairs, lat_diffs, mgas_diffs = _paired_data(baseline, feature)
all_pairs.extend(pairs)
all_lat_diffs.extend(lat_diffs)
all_mgas_diffs.extend(mgas_diffs)
all_total_lat_diffs.extend(total_lat_diffs)
blocks_per_pair.append(len(pairs))
if not all_lat_diffs:
@@ -240,11 +227,6 @@ def compute_paired_stats(
mgas_se = std_mgas_diff / math.sqrt(len(all_mgas_diffs)) if all_mgas_diffs else 0.0
mgas_ci = T_CRITICAL * mgas_se
mean_total_diff = sum(all_total_lat_diffs) / len(all_total_lat_diffs) if all_total_lat_diffs else 0.0
std_total_diff = stddev(all_total_lat_diffs, mean_total_diff) if len(all_total_lat_diffs) > 1 else 0.0
total_se = std_total_diff / math.sqrt(len(all_total_lat_diffs)) if all_total_lat_diffs else 0.0
wall_clock_ci_ms = T_CRITICAL * total_se
return {
"n": n,
"mean_diff_ms": mean_diff,
@@ -257,7 +239,6 @@ def compute_paired_stats(
"p99_ci_ms": (p99_boot[hi] - p99_boot[lo]) / 2,
"mean_mgas_diff": mean_mgas_diff,
"mgas_ci": mgas_ci,
"wall_clock_ci_ms": wall_clock_ci_ms,
"blocks": max(blocks_per_pair),
}
@@ -286,10 +267,6 @@ def fmt_mgas(v: float) -> str:
return f"{v:.2f}"
def fmt_s(v: float) -> str:
return f"{v:.2f}s"
def significance(pct: float, ci_pct: float, lower_is_better: bool) -> str:
"""Return significance label: 'good', 'bad', or 'neutral'."""
significant = abs(pct) > ci_pct
@@ -327,7 +304,6 @@ def compute_changes(
("p90", "p90_ms", "p90_ci_ms", "p90_ms", True),
("p99", "p99_ms", "p99_ci_ms", "p99_ms", True),
("mgas_s", "mean_mgas_s", "mgas_ci", "mean_mgas_s", False),
("wall_clock", "wall_clock_s", "wall_clock_ci_ms", "mean_total_lat_ms", True),
]
changes = {}
for name, stat_key, ci_key, base_key, lower_is_better in metrics:
@@ -350,7 +326,6 @@ def generate_comparison_table(
baseline_name: str,
feature_name: str,
feature_sha: str,
big_blocks: bool = False,
) -> str:
"""Generate a markdown comparison table between baseline and feature."""
n = paired["blocks"]
@@ -360,7 +335,6 @@ def generate_comparison_table(
mean_pct = pct(run1["mean_ms"], run2["mean_ms"])
gas_pct = pct(run1["mean_mgas_s"], run2["mean_mgas_s"])
wall_pct = pct(run1["wall_clock_s"], run2["wall_clock_s"])
p50_pct = pct(run1["p50_ms"], run2["p50_ms"])
p90_pct = pct(run1["p90_ms"], run2["p90_ms"])
@@ -374,7 +348,6 @@ def generate_comparison_table(
# CI as a percentage of baseline mean
lat_ci_pct = paired["ci_ms"] / run1["mean_ms"] * 100.0 if run1["mean_ms"] > 0 else 0.0
mgas_ci_pct = paired["mgas_ci"] / run1["mean_mgas_s"] * 100.0 if run1["mean_mgas_s"] > 0 else 0.0
wall_ci_pct = paired["wall_clock_ci_ms"] / run1["mean_total_lat_ms"] * 100.0 if run1["mean_total_lat_ms"] > 0 else 0.0
base_url = f"https://github.com/{repo}/commit"
baseline_label = f"[`{baseline_name}`]({base_url}/{baseline_ref})"
@@ -389,9 +362,8 @@ def generate_comparison_table(
f"| P90 | {fmt_ms(run1['p90_ms'])} | {fmt_ms(run2['p90_ms'])} | {change_str(p90_pct, p90_ci_pct, lower_is_better=True)} |",
f"| P99 | {fmt_ms(run1['p99_ms'])} | {fmt_ms(run2['p99_ms'])} | {change_str(p99_pct, p99_ci_pct, lower_is_better=True)} |",
f"| Mgas/s | {fmt_mgas(run1['mean_mgas_s'])} | {fmt_mgas(run2['mean_mgas_s'])} | {change_str(gas_pct, mgas_ci_pct, lower_is_better=False)} |",
f"| Wall Clock | {fmt_s(run1['wall_clock_s'])} | {fmt_s(run2['wall_clock_s'])} | {change_str(wall_pct, wall_ci_pct, lower_is_better=True)} |",
"",
f"*{n} {'big blocks' if big_blocks else 'blocks'}*",
f"*{n} blocks*",
]
return "\n".join(lines)
@@ -422,7 +394,6 @@ def generate_markdown(
summary: dict, comparison_table: str,
wait_time_tables: list[str] | None = None,
behind_baseline: int = 0, repo: str = "", baseline_ref: str = "", baseline_name: str = "",
grafana_url: str | None = None,
) -> str:
"""Generate a markdown comment body."""
lines = ["## Benchmark Results", ""]
@@ -442,9 +413,6 @@ def generate_markdown(
lines.append(table)
lines.append("")
lines.append("</details>")
if grafana_url:
lines.append("")
lines.append(f"**[Grafana Dashboard]({grafana_url})**")
return "\n".join(lines)
@@ -471,9 +439,6 @@ def main():
parser.add_argument("--feature-name", "--branch-name", default=None, help="Feature branch name")
parser.add_argument("--feature-ref", "--branch-sha", "--feature-sha", default=None, help="Feature commit SHA")
parser.add_argument("--behind-baseline", "--behind-main", type=int, default=0, help="Commits behind baseline")
parser.add_argument("--big-blocks", action="store_true", default=False, help="Big blocks mode")
parser.add_argument("--gas-ramp-blocks", type=int, default=0, help="Number of gas ramp blocks (big blocks mode)")
parser.add_argument("--grafana-url", default=None, help="Grafana dashboard URL for this benchmark run")
args = parser.parse_args()
if len(args.baseline_csv) != len(args.feature_csv):
@@ -522,7 +487,6 @@ def main():
baseline_name=baseline_name,
feature_name=feature_name,
feature_sha=feature_sha,
big_blocks=args.big_blocks,
)
print(f"Generated comparison ({paired_stats['n']} paired blocks, "
f"mean diff {paired_stats['mean_diff_ms']:+.3f}ms ± {paired_stats['ci_ms']:.3f}ms)")
@@ -553,8 +517,6 @@ def main():
summary = {
"blocks": paired_stats["blocks"],
"big_blocks": args.big_blocks,
"gas_ramp_blocks": args.gas_ramp_blocks,
"baseline": {
"name": baseline_name,
"ref": baseline_ref,
@@ -580,7 +542,6 @@ def main():
repo=args.repo,
baseline_ref=baseline_ref,
baseline_name=baseline_name,
grafana_url=args.grafana_url,
)
with open(args.output_markdown, "w") as f:

View File

@@ -7,8 +7,6 @@
// BENCH_PR PR number (may be empty)
// BENCH_ACTOR GitHub user who triggered the bench
// BENCH_JOB_URL URL to the Actions job page
// BENCH_BASELINE_ARGS Extra CLI args for the baseline reth node
// BENCH_FEATURE_ARGS Extra CLI args for the feature reth node
// BENCH_SAMPLY 'true' if samply profiling was enabled
//
// Usage from actions/github-script:
@@ -70,7 +68,6 @@ function buildSuccessBlocks({ summary, prNumber, actor, actorSlackId, jobUrl, re
function fmtMs(v) { return v.toFixed(2) + 'ms'; }
function fmtMgas(v) { return v.toFixed(2); }
function fmtS(v) { return v.toFixed(2) + 's'; }
function fmtChange(ch) {
if (!ch.pct && !ch.ci_pct) return ' ';
const pctStr = `${ch.pct >= 0 ? '+' : ''}${ch.pct.toFixed(2)}%`;
@@ -120,27 +117,15 @@ function buildSuccessBlocks({ summary, prNumber, actor, actorSlackId, jobUrl, re
if (fl1) featureLine += ` | <${fl1}|Samply 1>`;
if (fl2) featureLine += ` | <${fl2}|Samply 2>`;
const warmup = summary.warmup_blocks || process.env.BENCH_WARMUP_BLOCKS || '';
const cores = process.env.BENCH_CORES || '0';
const countsParts = [];
if (summary.big_blocks) {
const gasRamp = summary.gas_ramp_blocks || 0;
if (gasRamp > 0) countsParts.push(`*Gas Ramp:* ${gasRamp}`);
countsParts.push(`*Big Blocks:* ${summary.blocks}`);
} else {
const warmup = summary.warmup_blocks || process.env.BENCH_WARMUP_BLOCKS || '';
if (warmup) countsParts.push(`*Warmup:* ${warmup}`);
countsParts.push(`*Blocks:* ${summary.blocks}`);
}
if (warmup) countsParts.push(`*Warmup:* ${warmup}`);
countsParts.push(`*Blocks:* ${summary.blocks}`);
if (cores !== '0') countsParts.push(`*Cores:* ${cores}`);
const countsLine = countsParts.join(' | ');
const baselineArgs = process.env.BENCH_BASELINE_ARGS || '';
const featureArgs = process.env.BENCH_FEATURE_ARGS || '';
const argsLines = [];
if (baselineArgs) argsLines.push(`*Baseline Args:* \`${baselineArgs}\``);
if (featureArgs) argsLines.push(`*Feature Args:* \`${featureArgs}\``);
const sectionText = [metaParts.join(' | '), '', baselineLine, featureLine, ...argsLines, countsLine].join('\n');
const sectionText = [metaParts.join(' | '), '', baselineLine, featureLine, countsLine].join('\n');
// Action buttons
const diffUrl = `https://github.com/${repo}/compare/${summary.baseline.ref}...${summary.feature.ref}`;
@@ -184,7 +169,6 @@ function buildSuccessBlocks({ summary, prNumber, actor, actorSlackId, jobUrl, re
[cell('P90'), cell(fmtMs(b.p90_ms)), cell(fmtMs(f.p90_ms)), cell(fmtChange(c.p90))],
[cell('P99'), cell(fmtMs(b.p99_ms)), cell(fmtMs(f.p99_ms)), cell(fmtChange(c.p99))],
[cell('Mgas/s'), cell(fmtMgas(b.mean_mgas_s)), cell(fmtMgas(f.mean_mgas_s)), cell(fmtChange(c.mgas_s))],
[cell('Wall Clock'), cell(fmtS(b.wall_clock_s)), cell(fmtS(f.wall_clock_s)), cell(fmtChange(c.wall_clock))],
],
},
{

View File

@@ -1,414 +0,0 @@
#!/usr/bin/env bash
#
# Full PGO+BOLT optimized build for reth using real reth-bench workloads.
#
# Phases:
# 1. Build PGO-instrumented reth, run reth-bench → collect PGO profiles
# 2. Build BOLT-instrumented reth (with PGO), run reth-bench → collect BOLT profiles
# 3. Build final PGO+BOLT optimized binary
#
# Required environment variables:
# DATADIR - Path to reth datadir (must already contain chain data)
# RPC_URL - Source RPC URL for reth-bench to fetch payloads from
#
# Optional environment variables:
# PGO_BLOCKS - Number of blocks for PGO profiling (default: 20)
# BOLT_BLOCKS - Number of blocks for BOLT profiling (default: 20)
# SKIP_BOLT - Temporarily skip BOLT phases (default: false)
# STRIP_SYMBOLS - Strip debug symbols from output binary (default: true)
# COLLECT_PGO_ONLY - Stop after producing merged.profdata (default: false)
# PGO_PROFDATA - Path to pre-collected merged.profdata (optional)
# PROFILE - Cargo profile (default: maxperf-symbols)
# FEATURES - Cargo features (default: jemalloc,asm-keccak,min-debug-logs)
# TARGET - Target triple (default: auto-detected)
# EXTRA_RUSTFLAGS - Additional RUSTFLAGS (e.g. -C target-cpu=x86-64-v3)
#
# Output:
# target/$PROFILE_DIR/reth — final optimized binary
set -euo pipefail
gha_section_start() {
local title="$1"
if [ -n "${GITHUB_ACTIONS:-}" ]; then
echo "::group::$title"
else
echo ""
echo "=== $title ==="
fi
}
gha_section_end() {
if [ -n "${GITHUB_ACTIONS:-}" ]; then
echo "::endgroup::"
fi
}
cd "$(dirname "$0")/../.."
# ── Configuration ──────────────────────────────────────────────────────────────
PGO_BLOCKS="${PGO_BLOCKS:-20}"
BOLT_BLOCKS="${BOLT_BLOCKS:-20}"
SKIP_BOLT="${SKIP_BOLT:-false}"
STRIP_SYMBOLS="${STRIP_SYMBOLS:-true}"
COLLECT_PGO_ONLY="${COLLECT_PGO_ONLY:-false}"
PROFILE="${PROFILE:-maxperf-symbols}"
FEATURES="${FEATURES:-jemalloc,asm-keccak,min-debug-logs}"
TARGET="${TARGET:-$(rustc -Vv | grep host | cut -d' ' -f2)}"
BASE_RUSTFLAGS="${RUSTFLAGS:-}"
EXTRA_RUSTFLAGS="${EXTRA_RUSTFLAGS:-}"
COMBINED_RUSTFLAGS="$BASE_RUSTFLAGS $EXTRA_RUSTFLAGS"
PGO_PROFDATA="${PGO_PROFDATA:-}"
DATADIR="${DATADIR:-}"
RPC_URL="${RPC_URL:-}"
SKIP_BOLT_BOOL=false
if [[ "${SKIP_BOLT,,}" == "true" || "$SKIP_BOLT" == "1" ]]; then
SKIP_BOLT_BOOL=true
fi
STRIP_SYMBOLS_BOOL=false
if [[ "${STRIP_SYMBOLS,,}" == "true" || "$STRIP_SYMBOLS" == "1" ]]; then
STRIP_SYMBOLS_BOOL=true
fi
COLLECT_PGO_ONLY_BOOL=false
if [[ "${COLLECT_PGO_ONLY,,}" == "true" || "$COLLECT_PGO_ONLY" == "1" ]]; then
COLLECT_PGO_ONLY_BOOL=true
fi
USE_PRECOLLECTED_PGO=false
if [ -n "$PGO_PROFDATA" ]; then
if [ ! -f "$PGO_PROFDATA" ]; then
echo "error: PGO_PROFDATA points to a missing file: $PGO_PROFDATA"
exit 1
fi
USE_PRECOLLECTED_PGO=true
fi
NEEDS_BENCH_WORKLOAD=true
if [ "$USE_PRECOLLECTED_PGO" = true ] && [ "$SKIP_BOLT_BOOL" = true ]; then
NEEDS_BENCH_WORKLOAD=false
fi
if [ "$NEEDS_BENCH_WORKLOAD" = true ]; then
: "${DATADIR:?DATADIR must be set to the reth data directory}"
: "${RPC_URL:?RPC_URL must be set}"
fi
if [[ "$PROFILE" == dev ]]; then
PROFILE_DIR=debug
else
PROFILE_DIR=$PROFILE
fi
MANIFEST_PATH="bin/reth"
LLVM_VERSION=$(rustc -Vv | grep -oP 'LLVM version: \K\d+')
PGO_DIR="$PWD/target/pgo-profiles"
BOLT_DIR="$PWD/target/bolt-profiles"
CARGO_ARGS=(--profile "$PROFILE" --features "$FEATURES" --manifest-path "$MANIFEST_PATH/Cargo.toml" --bin "reth" --locked)
# Enable debug symbols for BOLT (requires symbols to reorder code).
# Strip them at the end.
PROFILE_UPPER=$(echo "$PROFILE" | tr '[:lower:]-' '[:upper:]_')
export "CARGO_PROFILE_${PROFILE_UPPER}_STRIP=debuginfo"
gha_section_start "Full PGO+BOLT Build"
echo "Binary: reth"
echo "Manifest: $MANIFEST_PATH"
echo "Target: $TARGET"
echo "Profile: $PROFILE"
echo "Features: $FEATURES"
echo "LLVM: $LLVM_VERSION"
echo "PGO blocks: $PGO_BLOCKS"
echo "BOLT blocks: $BOLT_BLOCKS"
echo "Skip BOLT: $SKIP_BOLT"
echo "Strip symbols: $STRIP_SYMBOLS"
echo "Collect only: $COLLECT_PGO_ONLY"
echo "PGO profdata: ${PGO_PROFDATA:-<collect with reth-bench>}"
echo "RUSTFLAGS: ${BASE_RUSTFLAGS:-<unset>}"
echo "EXTRA_RUSTFLAGS: ${EXTRA_RUSTFLAGS:-<unset>}"
if [ "$NEEDS_BENCH_WORKLOAD" = true ]; then
echo "Datadir: $DATADIR"
echo "RPC URL: $RPC_URL"
else
echo "Datadir: <not required>"
echo "RPC URL: <not required>"
fi
gha_section_end
# ── Prerequisites ──────────────────────────────────────────────────────────────
gha_section_start "Installing prerequisites"
rustup component add llvm-tools-preview
LLVM_PROFDATA=$(find "$(rustc --print sysroot)" -name llvm-profdata -type f | head -1)
if [ -z "$LLVM_PROFDATA" ]; then
echo "error: llvm-profdata not found"
exit 1
fi
install_bolt() {
if command -v llvm-bolt &>/dev/null; then
echo "BOLT already installed"
return
fi
echo "Installing BOLT from apt.llvm.org..."
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc >/dev/null
CODENAME=$(lsb_release -cs)
echo "deb http://apt.llvm.org/$CODENAME/ llvm-toolchain-$CODENAME-$LLVM_VERSION main" | sudo tee /etc/apt/sources.list.d/llvm.list >/dev/null
sudo apt-get update -qq
sudo apt-get install -y -qq "bolt-$LLVM_VERSION"
sudo ln -sf "/usr/bin/llvm-bolt-$LLVM_VERSION" /usr/local/bin/llvm-bolt
sudo ln -sf "/usr/bin/merge-fdata-$LLVM_VERSION" /usr/local/bin/merge-fdata
}
if [ "$SKIP_BOLT_BOOL" = true ]; then
echo "Skipping BOLT installation (SKIP_BOLT=$SKIP_BOLT)"
else
install_bolt
fi
gha_section_end
if [ "$NEEDS_BENCH_WORKLOAD" = true ]; then
# Build reth-bench once (non-instrumented) — reused for both phases.
gha_section_start "Building reth-bench"
RUSTFLAGS="$COMBINED_RUSTFLAGS" \
cargo build --profile "$PROFILE" --features "$FEATURES" \
--manifest-path bin/reth-bench/Cargo.toml --bin reth-bench --locked
RETH_BENCH_BIN="$(find target -name reth-bench -type f -executable | head -1)"
echo "reth-bench: $RETH_BENCH_BIN"
gha_section_end
else
gha_section_start "Building reth-bench"
echo "Skipping reth-bench build (pre-collected PGO with SKIP_BOLT=true)"
gha_section_end
fi
# ── Helpers ────────────────────────────────────────────────────────────────────
RETH_PID=
cleanup() {
if [ -n "${RETH_PID:-}" ] && kill -0 "$RETH_PID" 2>/dev/null; then
echo "Stopping reth (pid $RETH_PID)..."
sudo kill "$RETH_PID" 2>/dev/null || true
for i in $(seq 1 60); do
sudo kill -0 "$RETH_PID" 2>/dev/null || break
if [ $((i % 10)) -eq 0 ]; then
echo " waiting... (${i}s)"
fi
sleep 1
done
sudo kill -9 "$RETH_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
# Start reth, wait for RPC, run reth-bench, then stop reth.
# Arguments: $1 = reth binary path, $2 = number of blocks, $3 = log label
run_bench_workload() {
local reth_bin="$1" blocks="$2" label="$3"
local http_port=8545 authrpc_port=8551
echo "--- Starting reth ($label) ---"
sudo "$reth_bin" node \
--datadir "$DATADIR" \
--log.file.directory "/tmp/reth-${label}-logs" \
--engine.accept-execution-requests-hash \
--http --http.port "$http_port" \
--authrpc.port "$authrpc_port" \
--disable-discovery --no-persist-peers \
> "/tmp/reth-${label}.log" 2>&1 &
RETH_PID=$!
echo "Waiting for reth RPC..."
for i in $(seq 1 120); do
if curl -sf "http://127.0.0.1:$http_port" -X POST \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
> /dev/null 2>&1; then
echo "reth is ready after ${i}s"
break
fi
if [ "$i" -eq 120 ]; then
echo "error: reth failed to start within 120s"
cat "/tmp/reth-${label}.log"
exit 1
fi
sleep 1
done
echo "Running reth-bench ($blocks blocks)..."
"$RETH_BENCH_BIN" new-payload-fcu \
--rpc-url "$RPC_URL" \
--engine-rpc-url "http://127.0.0.1:$authrpc_port" \
--jwt-secret "$DATADIR/jwt.hex" \
--advance "$blocks" \
--reth-new-payload 2>&1 | sed -u "s/^/[$label] /"
echo "Stopping reth ($label)..."
sudo kill "$RETH_PID" 2>/dev/null || true
for i in $(seq 1 60); do
sudo kill -0 "$RETH_PID" 2>/dev/null || break
sleep 1
done
sudo kill -9 "$RETH_PID" 2>/dev/null || true
RETH_PID=
}
publish_binary() {
local source_bin="$1"
for out in "target/$TARGET/$PROFILE_DIR" "target/$PROFILE_DIR"; do
local destination="$out/reth"
mkdir -p "$out"
# Skip copying when source and destination resolve to the same inode.
if [ -e "$destination" ] && [ "$source_bin" -ef "$destination" ]; then
continue
fi
cp "$source_bin" "$destination"
done
}
if [ "$USE_PRECOLLECTED_PGO" = true ]; then
gha_section_start "Phase 1: Using Pre-Collected PGO Profile"
rm -rf "$PGO_DIR"
mkdir -p "$PGO_DIR"
cp "$PGO_PROFDATA" "$PGO_DIR/merged.profdata"
echo "Using pre-collected profile: $PGO_PROFDATA"
echo "PGO profile: $PGO_DIR/merged.profdata ($(ls -lh "$PGO_DIR/merged.profdata" | awk '{print $5}'))"
gha_section_end
else
# ── Phase 1: PGO profile collection ───────────────────────────────────────
gha_section_start "Phase 1: PGO Profile Collection"
rm -rf "$PGO_DIR"
mkdir -p "$PGO_DIR"
echo "Building PGO-instrumented binary..."
RUSTFLAGS="-Cprofile-generate=$PGO_DIR -Crelocation-model=pic $COMBINED_RUSTFLAGS" \
cargo build "${CARGO_ARGS[@]}" --target "$TARGET"
PGO_RETH_BIN="$PWD/target/$TARGET/$PROFILE_DIR/reth"
echo "Instrumented binary: $PGO_RETH_BIN ($(ls -lh "$PGO_RETH_BIN" | awk '{print $5}'))"
run_bench_workload "$PGO_RETH_BIN" "$PGO_BLOCKS" "pgo"
# Fix ownership if reth ran as root.
sudo chown -R "$(id -un):$(id -gn)" "$PGO_DIR" 2>/dev/null || true
# Merge PGO profiles.
echo "Merging PGO profiles..."
PROFRAW_COUNT=$(find "$PGO_DIR" -name '*.profraw' | wc -l)
echo "Found $PROFRAW_COUNT .profraw files"
if [ "$PROFRAW_COUNT" -eq 0 ]; then
echo "error: no .profraw files — instrumented binary did not produce profiles"
exit 1
fi
"$LLVM_PROFDATA" merge -o "$PGO_DIR/merged.profdata" "$PGO_DIR"/*.profraw
echo "PGO profile: $PGO_DIR/merged.profdata ($(ls -lh "$PGO_DIR/merged.profdata" | awk '{print $5}'))"
gha_section_end
fi
if [ "$COLLECT_PGO_ONLY_BOOL" = true ]; then
gha_section_start "PGO Collection Complete"
echo "COLLECT_PGO_ONLY=true, skipping PGO/BOLT optimized binary build"
echo "Profile: $PGO_DIR/merged.profdata"
gha_section_end
exit 0
fi
if [ "$SKIP_BOLT_BOOL" = true ]; then
gha_section_start "BOLT Phase Skipped"
echo "SKIP_BOLT=$SKIP_BOLT, building PGO-only binary"
echo "Building PGO-optimized binary..."
RUSTFLAGS="-Cprofile-use=$PGO_DIR/merged.profdata $COMBINED_RUSTFLAGS" \
cargo build "${CARGO_ARGS[@]}" --target "$TARGET"
BUILT_BIN="$PWD/target/$TARGET/$PROFILE_DIR/reth"
if [ "$STRIP_SYMBOLS_BOOL" = true ]; then
echo "Stripping debug symbols..."
strip "$BUILT_BIN"
else
echo "Skipping strip (STRIP_SYMBOLS=$STRIP_SYMBOLS)"
fi
publish_binary "$BUILT_BIN"
gha_section_end
else
# ── Phase 2: BOLT profile collection (with PGO) ──────────────────────────
gha_section_start "Phase 2: BOLT Profile Collection (with PGO)"
rm -rf "$BOLT_DIR"
mkdir -p "$BOLT_DIR"
echo "Building BOLT-instrumented binary with PGO..."
# --emit-relocs preserves relocation entries in the binary, required by llvm-bolt -instrument
RUSTFLAGS="-Cprofile-use=$PGO_DIR/merged.profdata -Clink-arg=-Wl,--emit-relocs $COMBINED_RUSTFLAGS" \
cargo build "${CARGO_ARGS[@]}" --target "$TARGET"
# Instrument with BOLT
BUILT_BIN="$PWD/target/$TARGET/$PROFILE_DIR/reth"
BOLT_INSTRUMENTED_BIN="$BUILT_BIN-bolt-instrumented"
echo "Instrumenting binary with BOLT..."
# --skip-funcs: skip compiler-generated drop_in_place functions that BOLT can't handle
# as split functions in relocation mode (triggered by --emit-relocs)
llvm-bolt "$BUILT_BIN" \
-instrument \
--instrumentation-file-append-pid \
--instrumentation-file="$BOLT_DIR/prof" \
--skip-funcs='.*drop_in_place.*' \
-o "$BOLT_INSTRUMENTED_BIN"
echo "BOLT-instrumented binary: $BOLT_INSTRUMENTED_BIN ($(ls -lh "$BOLT_INSTRUMENTED_BIN" | awk '{print $5}'))"
run_bench_workload "$BOLT_INSTRUMENTED_BIN" "$BOLT_BLOCKS" "bolt"
# Fix ownership for BOLT profiles
sudo chown -R "$(id -un):$(id -gn)" "$BOLT_DIR" 2>/dev/null || true
# Merge BOLT profiles
echo "Merging BOLT profiles..."
FDATA_COUNT=$(find "$BOLT_DIR" -name '*.fdata' | wc -l)
echo "Found $FDATA_COUNT .fdata files"
if [ "$FDATA_COUNT" -eq 0 ]; then
echo "error: no .fdata files — BOLT-instrumented binary did not produce profiles"
exit 1
fi
merge-fdata "$BOLT_DIR"/*.fdata > "$BOLT_DIR/merged.fdata"
echo "BOLT profile: $BOLT_DIR/merged.fdata ($(ls -lh "$BOLT_DIR/merged.fdata" | awk '{print $5}'))"
gha_section_end
# ── Phase 3: Final optimized build ───────────────────────────────────────
gha_section_start "Phase 3: Final PGO+BOLT Optimized Build"
echo "Building PGO-optimized binary..."
# --emit-relocs preserves relocation entries in the binary, required by llvm-bolt for code reordering
RUSTFLAGS="-Cprofile-use=$PGO_DIR/merged.profdata -Clink-arg=-Wl,--emit-relocs $COMBINED_RUSTFLAGS" \
cargo build "${CARGO_ARGS[@]}" --target "$TARGET"
BUILT_BIN="$PWD/target/$TARGET/$PROFILE_DIR/reth"
OPTIMIZED_BIN="$BUILT_BIN-bolt-optimized"
echo "Optimizing with BOLT..."
llvm-bolt "$BUILT_BIN" \
-o "$OPTIMIZED_BIN" \
--data "$BOLT_DIR/merged.fdata" \
-reorder-blocks=ext-tsp \
-reorder-functions=cdsort \
-split-functions \
-split-all-cold \
-dyno-stats \
-icf=1 \
-use-gnu-stack \
--skip-funcs='.*drop_in_place.*'
if [ "$STRIP_SYMBOLS_BOOL" = true ]; then
echo "Stripping debug symbols..."
strip "$OPTIMIZED_BIN"
else
echo "Skipping strip (STRIP_SYMBOLS=$STRIP_SYMBOLS)"
fi
publish_binary "$OPTIMIZED_BIN"
gha_section_end
fi
gha_section_start "Build Complete"
ls -lh "target/$PROFILE_DIR/reth"
echo "Output: target/$PROFILE_DIR/reth"
gha_section_end

View File

@@ -16,11 +16,28 @@ rpc-compat:
# syncing mode, the test expects syncing to be false on start
- eth_syncing/check-syncing (reth)
engine-withdrawals: [ ]
# no fix due to https://github.com/paradigmxyz/reth/issues/8732
engine-withdrawals:
- Withdrawals Fork On Genesis (Paris) (reth)
- Withdrawals Fork on Block 1 (Paris) (reth)
- Withdrawals Fork on Block 2 (Paris) (reth)
- Withdrawals Fork on Block 3 (Paris) (reth)
- Withdraw to a single account (Paris) (reth)
- Withdraw to two accounts (Paris) (reth)
- Withdraw many accounts (Paris) (reth)
- Withdraw zero amount (Paris) (reth)
- Empty Withdrawals (Paris) (reth)
- Corrupted Block Hash Payload (INVALID) (Paris) (reth)
- Withdrawals Fork on Canonical Block 8 / Side Block 7 - 10 Block Re-Org (Paris) (reth)
engine-api: [ ]
engine-cancun: [ ]
# no fix due to https://github.com/paradigmxyz/reth/issues/8732
engine-cancun:
- Invalid PayloadAttributes, Missing BeaconRoot, Syncing=True (Cancun) (reth)
# the test fails with older versions of the code for which it passed before, probably related to changes
# in hive or its dependencies
- Blob Transaction Ordering, Multiple Clients (Cancun) (reth)
sync: [ ]
@@ -32,7 +49,7 @@ engine-auth: [ ]
# The test artificially creates an empty account with storage, then tests EIP-7610's behavior.
# On mainnet, ~25 such accounts exist as contract addresses (derived from keccak(prefix, caller,
# nonce/salt), not from public keys). No private key exists for contract addresses. To trigger
# this with EIP-7702, you'd need to recover a private key from one of the already deployed contract addresses - mathematically impossible.
# this with EIP-7702, you'd need to recover a private key from one of the already deployed contract addresses - mathematically impossible.
#
# tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_*
# Requires hash collision on create2 address to target already deployed accounts with storage.

View File

@@ -8,11 +8,11 @@
on:
issue_comment:
types: [created]
types: [created, edited]
workflow_dispatch:
inputs:
blocks:
description: "Number of blocks to benchmark (or 'big' for big blocks mode)"
description: "Number of blocks to benchmark"
required: false
default: "500"
type: string
@@ -31,46 +31,16 @@ on:
required: false
default: ""
type: string
wait_time:
description: "Fixed wait time between blocks (e.g. 500ms, 1s)"
required: false
default: ""
type: string
baseline_args:
description: "Extra CLI args for the baseline reth node"
required: false
default: ""
type: string
feature_args:
description: "Extra CLI args for the feature reth node"
required: false
default: ""
type: string
samply:
description: "Enable samply profiling"
required: false
default: "false"
type: boolean
reth_newPayload:
description: "Use reth_newPayload RPC (server-side timing)"
required: false
default: "true"
type: boolean
cores:
description: "Limit reth to N CPU cores (0 = all available)"
required: false
default: "0"
type: string
no_slack:
description: "Suppress Slack notifications for benchmark results"
required: false
default: "true"
type: boolean
abba:
description: "Run ABBA (BFFB) interleaved order; false = single AB pass"
required: false
default: "true"
type: boolean
env:
CARGO_TERM_COLOR: always
@@ -102,14 +72,7 @@ jobs:
baseline-name: ${{ steps.args.outputs.baseline-name }}
feature-name: ${{ steps.args.outputs.feature-name }}
samply: ${{ steps.args.outputs.samply }}
no-slack: ${{ steps.args.outputs.no-slack }}
cores: ${{ steps.args.outputs.cores }}
big-blocks: ${{ steps.args.outputs.big-blocks }}
reth-new-payload: ${{ steps.args.outputs.reth-new-payload }}
wait-time: ${{ steps.args.outputs.wait-time }}
baseline-args: ${{ steps.args.outputs.baseline-args }}
feature-args: ${{ steps.args.outputs.feature-args }}
abba: ${{ steps.args.outputs.abba }}
comment-id: ${{ steps.ack.outputs.comment-id }}
steps:
- name: Check org membership
@@ -137,7 +100,7 @@ jobs:
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
let pr, actor, blocks, warmup, baseline, feature, samply, cores, bigBlocks;
let pr, actor, blocks, warmup, baseline, feature, samply, cores;
if (context.eventName === 'workflow_dispatch') {
actor = '${{ github.actor }}';
@@ -146,14 +109,7 @@ jobs:
baseline = '${{ github.event.inputs.baseline }}';
feature = '${{ github.event.inputs.feature }}';
samply = '${{ github.event.inputs.samply }}' === 'true' ? 'true' : 'false';
var noSlack = '${{ github.event.inputs.no_slack }}' !== 'false' ? 'true' : 'false';
cores = '${{ github.event.inputs.cores }}' || '0';
bigBlocks = blocks === 'big' ? 'true' : 'false';
var rethNewPayload = '${{ github.event.inputs.reth_newPayload }}' !== 'false' ? 'true' : 'false';
var abba = '${{ github.event.inputs.abba }}' !== 'false' ? 'true' : 'false';
var waitTime = '${{ github.event.inputs.wait_time }}' || '';
var baselineNodeArgs = '${{ github.event.inputs.baseline_args }}' || '';
var featureNodeArgs = '${{ github.event.inputs.feature_args }}' || '';
// Find PR for the selected branch
const branch = '${{ github.ref_name }}';
@@ -173,75 +129,37 @@ jobs:
actor = context.payload.comment.user.login;
const body = context.payload.comment.body.trim();
const intArgs = new Set(['warmup', 'cores']);
const intOrKeywordArgs = new Map([['blocks', new Set(['big'])]]);
const intArgs = new Set(['blocks', 'warmup', 'cores']);
const refArgs = new Set(['baseline', 'feature']);
const boolArgs = new Set(['samply', 'no-slack']);
const boolDefaultTrue = new Set(['reth_newPayload', 'abba']);
const durationArgs = new Set(['wait-time']);
const stringArgs = new Set(['baseline-args', 'feature-args']);
const defaults = { blocks: '500', warmup: '100', baseline: '', feature: '', samply: 'false', 'no-slack': 'false', cores: '0', reth_newPayload: 'true', abba: 'true', 'wait-time': '', 'baseline-args': '', 'feature-args': '' };
const boolArgs = new Set(['samply']);
const defaults = { blocks: '500', warmup: '100', baseline: '', feature: '', samply: 'false', cores: '0' };
const unknown = [];
const invalid = [];
const args = body.replace(/^(?:@decofe|derek) bench\s*/, '');
// Parse args, handling quoted values like key="value with spaces"
const parts = [];
const argRegex = /(\S+?="[^"]*"|\S+?='[^']*'|\S+)/g;
let m;
while ((m = argRegex.exec(args)) !== null) parts.push(m[1]);
for (const part of parts) {
for (const part of args.split(/\s+/).filter(Boolean)) {
const eq = part.indexOf('=');
if (eq === -1) {
if (boolArgs.has(part)) {
defaults[part] = 'true';
} else if (boolDefaultTrue.has(part)) {
defaults[part] = 'true';
} else {
unknown.push(part);
}
continue;
}
const key = part.slice(0, eq);
let value = part.slice(eq + 1);
// Strip surrounding quotes
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (boolDefaultTrue.has(key)) {
if (value === 'true' || value === 'false') {
defaults[key] = value;
} else {
invalid.push(`\`${key}=${value}\` (must be true or false)`);
}
} else if (durationArgs.has(key)) {
if (/^\d+(ms|s|m)$/.test(value)) {
defaults[key] = value;
} else {
invalid.push(`\`${key}=${value}\` (must be a duration like 500ms, 1s, 2m)`);
}
} else if (intArgs.has(key)) {
const value = part.slice(eq + 1);
if (intArgs.has(key)) {
if (!/^\d+$/.test(value)) {
invalid.push(`\`${key}=${value}\` (must be a positive integer)`);
} else {
defaults[key] = value;
}
} else if (intOrKeywordArgs.has(key)) {
const keywords = intOrKeywordArgs.get(key);
if (keywords.has(value)) {
defaults[key] = value;
} else if (/^\d+$/.test(value)) {
defaults[key] = value;
} else {
invalid.push(`\`${key}=${value}\` (must be a positive integer or one of: ${[...keywords].join(', ')})`);
}
} else if (refArgs.has(key)) {
if (!value) {
invalid.push(`\`${key}=\` (must be a git ref)`);
} else {
defaults[key] = value;
}
} else if (stringArgs.has(key)) {
defaults[key] = value;
} else {
unknown.push(key);
}
@@ -250,7 +168,7 @@ jobs:
if (unknown.length) errors.push(`Unknown argument(s): \`${unknown.join('`, `')}\``);
if (invalid.length) errors.push(`Invalid value(s): ${invalid.join(', ')}`);
if (errors.length) {
const msg = `❌ **Invalid bench command**\n\n${errors.join('\n')}\n\n**Usage:** \`@decofe bench [blocks=N|big] [warmup=N] [baseline=REF] [feature=REF] [samply] [no-slack] [cores=N] [reth_newPayload=true|false] [abba=true|false] [wait-time=DURATION] [baseline-args="..."] [feature-args="..."]\``;
const msg = `❌ **Invalid bench command**\n\n${errors.join('\n')}\n\n**Usage:** \`@decofe bench [blocks=N] [warmup=N] [baseline=REF] [feature=REF] [samply] [cores=N]\``;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -265,14 +183,7 @@ jobs:
baseline = defaults.baseline;
feature = defaults.feature;
samply = defaults.samply;
var noSlack = defaults['no-slack'];
cores = defaults.cores;
bigBlocks = blocks === 'big' ? 'true' : 'false';
var rethNewPayload = defaults.reth_newPayload;
var abba = defaults.abba;
var waitTime = defaults['wait-time'];
var baselineNodeArgs = defaults['baseline-args'];
var featureNodeArgs = defaults['feature-args'];
}
// Resolve display names for baseline/feature
@@ -300,14 +211,7 @@ jobs:
core.setOutput('baseline-name', baselineName);
core.setOutput('feature-name', featureName);
core.setOutput('samply', samply);
core.setOutput('no-slack', noSlack);
core.setOutput('cores', cores);
core.setOutput('big-blocks', bigBlocks);
core.setOutput('reth-new-payload', rethNewPayload);
core.setOutput('wait-time', waitTime);
core.setOutput('baseline-args', baselineNodeArgs);
core.setOutput('feature-args', featureNodeArgs);
core.setOutput('abba', abba);
- name: Acknowledge request
id: ack
@@ -365,24 +269,10 @@ jobs:
const baseline = '${{ steps.args.outputs.baseline-name }}';
const feature = '${{ steps.args.outputs.feature-name }}';
const samply = '${{ steps.args.outputs.samply }}' === 'true';
const noSlack = '${{ steps.args.outputs.no-slack }}' === 'true';
const bigBlocks = '${{ steps.args.outputs.big-blocks }}' === 'true';
const samplyNote = samply ? ', samply: `enabled`' : '';
const noSlackNote = noSlack ? ', no-slack' : '';
const cores = '${{ steps.args.outputs.cores }}';
const coresNote = cores && cores !== '0' ? `, cores: \`${cores}\`` : '';
const rethNP = '${{ steps.args.outputs.reth-new-payload }}' !== 'false';
const rethNPNote = !rethNP ? ', reth_newPayload: `disabled`' : '';
const abbaEnabled = '${{ steps.args.outputs.abba }}' !== 'false';
const abbaNote = !abbaEnabled ? ', abba: `disabled`' : '';
const waitTimeVal = '${{ steps.args.outputs.wait-time }}';
const waitTimeNote = waitTimeVal ? `, wait-time: \`${waitTimeVal}\`` : '';
const baselineArgsVal = '${{ steps.args.outputs.baseline-args }}';
const baselineArgsNote = baselineArgsVal ? `, baseline-args: \`${baselineArgsVal}\`` : '';
const featureArgsVal = '${{ steps.args.outputs.feature-args }}';
const featureArgsNote = featureArgsVal ? `, feature-args: \`${featureArgsVal}\`` : '';
const blocksDesc = bigBlocks ? 'blocks: `big`' : `${blocks} blocks, ${warmup} warmup blocks`;
const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${noSlackNote}${coresNote}${rethNPNote}${abbaNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`;
const config = `**Config:** ${blocks} blocks, ${warmup} warmup blocks, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${coresNote}`;
const { data: comment } = await github.rest.issues.createComment({
owner: context.repo.owner,
@@ -407,24 +297,10 @@ jobs:
const baseline = '${{ steps.args.outputs.baseline-name }}';
const feature = '${{ steps.args.outputs.feature-name }}';
const samply = '${{ steps.args.outputs.samply }}' === 'true';
const noSlack = '${{ steps.args.outputs.no-slack }}' === 'true';
const bigBlocks = '${{ steps.args.outputs.big-blocks }}' === 'true';
const samplyNote = samply ? ', samply: `enabled`' : '';
const noSlackNote = noSlack ? ', no-slack' : '';
const cores = '${{ steps.args.outputs.cores }}';
const coresNote = cores && cores !== '0' ? `, cores: \`${cores}\`` : '';
const rethNP = '${{ steps.args.outputs.reth-new-payload }}' !== 'false';
const rethNPNote = !rethNP ? ', reth_newPayload: `disabled`' : '';
const abbaEnabled = '${{ steps.args.outputs.abba }}' !== 'false';
const abbaNote = !abbaEnabled ? ', abba: `disabled`' : '';
const waitTimeVal = '${{ steps.args.outputs.wait-time }}';
const waitTimeNote = waitTimeVal ? `, wait-time: \`${waitTimeVal}\`` : '';
const baselineArgsVal = '${{ steps.args.outputs.baseline-args }}';
const baselineArgsNote = baselineArgsVal ? `, baseline-args: \`${baselineArgsVal}\`` : '';
const featureArgsVal = '${{ steps.args.outputs.feature-args }}';
const featureArgsNote = featureArgsVal ? `, feature-args: \`${featureArgsVal}\`` : '';
const blocksDesc = bigBlocks ? 'blocks: `big`' : `${blocks} blocks, ${warmup} warmup blocks`;
const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${noSlackNote}${coresNote}${rethNPNote}${abbaNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`;
const config = `**Config:** ${blocks} blocks, ${warmup} warmup blocks, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${coresNote}`;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const numRunners = parseInt(process.env.BENCH_RUNNERS) || 1;
@@ -476,7 +352,7 @@ jobs:
reth-bench:
needs: reth-bench-ack
name: reth-bench
runs-on: [self-hosted, Linux, X64, available]
runs-on: [self-hosted, Linux, X64]
timeout-minutes: 120
env:
BENCH_RPC_URL: https://ethereum.reth.rs/rpc
@@ -488,17 +364,7 @@ jobs:
BENCH_WARMUP_BLOCKS: ${{ needs.reth-bench-ack.outputs.warmup }}
BENCH_SAMPLY: ${{ needs.reth-bench-ack.outputs.samply }}
BENCH_CORES: ${{ needs.reth-bench-ack.outputs.cores }}
BENCH_BIG_BLOCKS: ${{ needs.reth-bench-ack.outputs.big-blocks }}
BENCH_RETH_NEW_PAYLOAD: ${{ needs.reth-bench-ack.outputs.reth-new-payload }}
BENCH_WAIT_TIME: ${{ needs.reth-bench-ack.outputs.wait-time }}
BENCH_BASELINE_ARGS: ${{ needs.reth-bench-ack.outputs.baseline-args }}
BENCH_FEATURE_ARGS: ${{ needs.reth-bench-ack.outputs.feature-args }}
BENCH_ABBA: ${{ needs.reth-bench-ack.outputs.abba }}
BENCH_COMMENT_ID: ${{ needs.reth-bench-ack.outputs.comment-id }}
BENCH_NO_SLACK: ${{ needs.reth-bench-ack.outputs.no-slack }}
BENCH_METRICS_ADDR: "127.0.0.1:9100"
BENCH_OTLP_TRACES_ENDPOINT: ${{ secrets.BENCH_OTLP_TRACES_ENDPOINT }}
BENCH_OTLP_LOGS_ENDPOINT: ${{ secrets.BENCH_OTLP_LOGS_ENDPOINT }}
steps:
- name: Clean up previous bench-work
run: sudo rm -rf "$BENCH_WORK_DIR" 2>/dev/null || true
@@ -517,11 +383,13 @@ jobs:
repo: context.repo.repo,
pull_number: parseInt(process.env.BENCH_PR),
});
// Always use head SHA — the merge ref (refs/pull/N/merge) may not
// exist if the PR has conflicts, was force-pushed, or was
// merged/closed between this step and checkout.
core.info(`PR #${process.env.BENCH_PR} (${pr.state}), using head SHA ${pr.head.sha}`);
core.setOutput('ref', pr.head.sha);
// For closed/merged PRs, the merge ref doesn't exist — use head SHA
if (pr.state !== 'open') {
core.info(`PR #${process.env.BENCH_PR} is ${pr.state}, using head SHA ${pr.head.sha}`);
core.setOutput('ref', pr.head.sha);
} else {
core.setOutput('ref', `refs/pull/${process.env.BENCH_PR}/merge`);
}
- uses: actions/checkout@v6
with:
@@ -549,24 +417,10 @@ jobs:
const baseline = '${{ needs.reth-bench-ack.outputs.baseline-name }}';
const feature = '${{ needs.reth-bench-ack.outputs.feature-name }}';
const samply = process.env.BENCH_SAMPLY === 'true';
const noSlack = process.env.BENCH_NO_SLACK === 'true';
const bigBlocks = process.env.BENCH_BIG_BLOCKS === 'true';
const samplyNote = samply ? ', samply: `enabled`' : '';
const noSlackNote = noSlack ? ', no-slack' : '';
const cores = process.env.BENCH_CORES || '0';
const coresNote = cores && cores !== '0' ? `, cores: \`${cores}\`` : '';
const rethNP = (process.env.BENCH_RETH_NEW_PAYLOAD || 'true') !== 'false';
const rethNPNote = !rethNP ? ', reth_newPayload: `disabled`' : '';
const abbaEnabled = (process.env.BENCH_ABBA || 'true') !== 'false';
const abbaNote = !abbaEnabled ? ', abba: `disabled`' : '';
const waitTimeVal = process.env.BENCH_WAIT_TIME || '';
const waitTimeNote = waitTimeVal ? `, wait-time: \`${waitTimeVal}\`` : '';
const baselineArgsVal = process.env.BENCH_BASELINE_ARGS || '';
const baselineArgsNote = baselineArgsVal ? `, baseline-args: \`${baselineArgsVal}\`` : '';
const featureArgsVal = process.env.BENCH_FEATURE_ARGS || '';
const featureArgsNote = featureArgsVal ? `, feature-args: \`${featureArgsVal}\`` : '';
const blocksDesc = bigBlocks ? 'blocks: `big`' : `${blocks} blocks, ${warmup} warmup blocks`;
core.exportVariable('BENCH_CONFIG', `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${noSlackNote}${coresNote}${rethNPNote}${abbaNote}${waitTimeNote}${baselineArgsNote}${featureArgsNote}`);
core.exportVariable('BENCH_CONFIG', `**Config:** ${blocks} blocks, ${warmup} warmup blocks, baseline: \`${baseline}\`, feature: \`${feature}\`${samplyNote}${coresNote}`);
const { buildBody } = require('./.github/scripts/bench-update-status.js');
await github.rest.issues.updateComment({
@@ -826,45 +680,6 @@ jobs:
rm -rf "$BENCH_WORK_DIR"
mkdir -p "$BENCH_WORK_DIR"
- name: Download big blocks
if: env.BENCH_BIG_BLOCKS == 'true'
run: |
set -euo pipefail
MC="mc --config-dir /home/ubuntu/.mc"
BUCKET="minio/reth-snapshots/reth-1-minimal-nightly-previous-big-blocks.tar.zst"
BIG_BLOCKS_DIR="${BENCH_WORK_DIR}/big-blocks"
rm -rf "$BIG_BLOCKS_DIR"; mkdir -p "$BIG_BLOCKS_DIR"
echo "Downloading big blocks from $BUCKET..."
$MC cat "$BUCKET" | pzstd -d -p 6 | tar -xf - -C "$BIG_BLOCKS_DIR"
echo "Big blocks downloaded to $BIG_BLOCKS_DIR"
# Verify expected directory structure
if [ ! -d "$BIG_BLOCKS_DIR/gas-ramp-dir" ] || [ ! -d "$BIG_BLOCKS_DIR/payloads" ]; then
echo "::error::Big blocks archive missing expected gas-ramp-dir/ or payloads/ directories"
ls -laR "$BIG_BLOCKS_DIR"
exit 1
fi
echo "Payload files: $(find "$BIG_BLOCKS_DIR/payloads" -name '*.json' | wc -l)"
- name: Start metrics proxy
run: |
BENCH_ID="ci-${{ github.run_id }}"
BENCH_REFERENCE_EPOCH=$(date +%s)
echo "BENCH_ID=${BENCH_ID}" >> "$GITHUB_ENV"
echo "BENCH_REFERENCE_EPOCH=${BENCH_REFERENCE_EPOCH}" >> "$GITHUB_ENV"
LABELS_FILE="/tmp/bench-metrics-labels.json"
echo '{}' > "$LABELS_FILE"
echo "BENCH_LABELS_FILE=${LABELS_FILE}" >> "$GITHUB_ENV"
python3 .github/scripts/bench-metrics-proxy.py \
--labels "$LABELS_FILE" \
--upstream "http://${BENCH_METRICS_ADDR}/" \
--subnet 10.10.0.0/24 \
--port 9090 &
PROXY_PID=$!
echo "BENCH_METRICS_PROXY_PID=${PROXY_PID}" >> "$GITHUB_ENV"
echo "Metrics proxy started (PID $PROXY_PID)"
- name: Update status (running benchmarks)
if: success() && env.BENCH_COMMENT_ID
uses: actions/github-script@v8
@@ -878,64 +693,19 @@ jobs:
# thermal drift and cache warming.
- name: "Run benchmark: baseline (1/2)"
id: run-baseline-1
env:
BASELINE_REF: ${{ steps.refs.outputs.baseline-ref }}
OTEL_RESOURCE_ATTRIBUTES: "benchmark_id=${{ env.BENCH_ID }},benchmark_run=baseline-1,run_type=baseline,git_ref=${{ steps.refs.outputs.baseline-ref }}"
run: |
cat > "$BENCH_LABELS_FILE" <<LABELS
{"benchmark_run":"baseline-1","run_type":"baseline","git_ref":"${BASELINE_REF}","bench_sha":"${BASELINE_REF}","benchmark_id":"${BENCH_ID}","run_start_epoch":"$(date +%s)","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
LABELS
taskset -c 0 .github/scripts/bench-reth-run.sh baseline ../reth-baseline/target/profiling/reth "$BENCH_WORK_DIR/baseline-1"
run: taskset -c 0 .github/scripts/bench-reth-run.sh baseline ../reth-baseline/target/profiling/reth "$BENCH_WORK_DIR/baseline-1"
- name: "Run benchmark: feature (1/2)"
id: run-feature-1
env:
FEATURE_REF: ${{ steps.refs.outputs.feature-ref }}
OTEL_RESOURCE_ATTRIBUTES: "benchmark_id=${{ env.BENCH_ID }},benchmark_run=feature-1,run_type=feature,git_ref=${{ steps.refs.outputs.feature-ref }}"
run: |
cat > "$BENCH_LABELS_FILE" <<LABELS
{"benchmark_run":"feature-1","run_type":"feature","git_ref":"${FEATURE_REF}","bench_sha":"${FEATURE_REF}","benchmark_id":"${BENCH_ID}","run_start_epoch":"$(date +%s)","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
LABELS
taskset -c 0 .github/scripts/bench-reth-run.sh feature ../reth-feature/target/profiling/reth "$BENCH_WORK_DIR/feature-1"
run: taskset -c 0 .github/scripts/bench-reth-run.sh feature ../reth-feature/target/profiling/reth "$BENCH_WORK_DIR/feature-1"
- name: "Run benchmark: feature (2/2)"
if: env.BENCH_ABBA != 'false'
id: run-feature-2
env:
FEATURE_REF: ${{ steps.refs.outputs.feature-ref }}
OTEL_RESOURCE_ATTRIBUTES: "benchmark_id=${{ env.BENCH_ID }},benchmark_run=feature-2,run_type=feature,git_ref=${{ steps.refs.outputs.feature-ref }}"
run: |
cat > "$BENCH_LABELS_FILE" <<LABELS
{"benchmark_run":"feature-2","run_type":"feature","git_ref":"${FEATURE_REF}","bench_sha":"${FEATURE_REF}","benchmark_id":"${BENCH_ID}","run_start_epoch":"$(date +%s)","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
LABELS
taskset -c 0 .github/scripts/bench-reth-run.sh feature ../reth-feature/target/profiling/reth "$BENCH_WORK_DIR/feature-2"
run: taskset -c 0 .github/scripts/bench-reth-run.sh feature ../reth-feature/target/profiling/reth "$BENCH_WORK_DIR/feature-2"
- name: "Run benchmark: baseline (2/2)"
if: env.BENCH_ABBA != 'false'
id: run-baseline-2
env:
BASELINE_REF: ${{ steps.refs.outputs.baseline-ref }}
OTEL_RESOURCE_ATTRIBUTES: "benchmark_id=${{ env.BENCH_ID }},benchmark_run=baseline-2,run_type=baseline,git_ref=${{ steps.refs.outputs.baseline-ref }}"
run: |
LAST_RUN_START=$(date +%s)
echo "BENCH_LAST_RUN_START=${LAST_RUN_START}" >> "$GITHUB_ENV"
cat > "$BENCH_LABELS_FILE" <<LABELS
{"benchmark_run":"baseline-2","run_type":"baseline","git_ref":"${BASELINE_REF}","bench_sha":"${BASELINE_REF}","benchmark_id":"${BENCH_ID}","run_start_epoch":"${LAST_RUN_START}","reference_epoch":"${BENCH_REFERENCE_EPOCH}"}
LABELS
taskset -c 0 .github/scripts/bench-reth-run.sh baseline ../reth-baseline/target/profiling/reth "$BENCH_WORK_DIR/baseline-2"
- name: Stop metrics proxy & generate Grafana URL
id: metrics
if: "!cancelled()"
run: |
kill "$BENCH_METRICS_PROXY_PID" 2>/dev/null || true
LAST_RUN_DURATION=$(( $(date +%s) - BENCH_LAST_RUN_START ))
FROM_MS=$(( BENCH_REFERENCE_EPOCH * 1000 ))
TO_MS=$(( (BENCH_REFERENCE_EPOCH + LAST_RUN_DURATION) * 1000 ))
GRAFANA_URL="https://tempoxyz.grafana.net/d/reth-bench-ghr/reth-bench-ghr?orgId=1&from=${FROM_MS}&to=${TO_MS}&timezone=browser&var-datasource=ef57fux92e9z4e&var-job=reth-bench&var-benchmark_id=${BENCH_ID}&var-benchmark_run=\$__all"
echo "grafana-url=${GRAFANA_URL}" >> "$GITHUB_OUTPUT"
echo "Grafana URL: ${GRAFANA_URL}"
run: taskset -c 0 .github/scripts/bench-reth-run.sh baseline ../reth-baseline/target/profiling/reth "$BENCH_WORK_DIR/baseline-2"
- name: Scan logs for errors
if: "!cancelled()"
@@ -1037,30 +807,12 @@ jobs:
SUMMARY_ARGS="$SUMMARY_ARGS --baseline-name ${BASELINE_NAME}"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-name ${FEATURE_NAME}"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-ref ${FEATURE_REF}"
BASELINE_CSVS="$BENCH_WORK_DIR/baseline-1/combined_latency.csv"
FEATURE_CSVS="$BENCH_WORK_DIR/feature-1/combined_latency.csv"
if [ "${BENCH_ABBA:-true}" = "true" ]; then
BASELINE_CSVS="$BASELINE_CSVS $BENCH_WORK_DIR/baseline-2/combined_latency.csv"
FEATURE_CSVS="$FEATURE_CSVS $BENCH_WORK_DIR/feature-2/combined_latency.csv"
fi
SUMMARY_ARGS="$SUMMARY_ARGS --baseline-csv $BASELINE_CSVS"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-csv $FEATURE_CSVS"
SUMMARY_ARGS="$SUMMARY_ARGS --baseline-csv $BENCH_WORK_DIR/baseline-1/combined_latency.csv $BENCH_WORK_DIR/baseline-2/combined_latency.csv"
SUMMARY_ARGS="$SUMMARY_ARGS --feature-csv $BENCH_WORK_DIR/feature-1/combined_latency.csv $BENCH_WORK_DIR/feature-2/combined_latency.csv"
SUMMARY_ARGS="$SUMMARY_ARGS --gas-csv $BENCH_WORK_DIR/feature-1/total_gas.csv"
if [ "$BEHIND_BASELINE" -gt 0 ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --behind-baseline $BEHIND_BASELINE"
fi
if [ "${BENCH_BIG_BLOCKS:-false}" = "true" ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --big-blocks"
# Read gas ramp blocks count from first baseline run (same for all runs)
GAS_RAMP_FILE="$BENCH_WORK_DIR/baseline-1/gas_ramp_blocks.txt"
if [ -f "$GAS_RAMP_FILE" ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --gas-ramp-blocks $(cat "$GAS_RAMP_FILE" | tr -d '[:space:]')"
fi
fi
GRAFANA_URL='${{ steps.metrics.outputs.grafana-url }}'
if [ -n "$GRAFANA_URL" ]; then
SUMMARY_ARGS="$SUMMARY_ARGS --grafana-url $GRAFANA_URL"
fi
# shellcheck disable=SC2086
python3 .github/scripts/bench-reth-summary.py $SUMMARY_ARGS
@@ -1071,14 +823,8 @@ jobs:
FEATURE_NAME: ${{ steps.refs.outputs.feature-name }}
run: |
CHART_ARGS="--output-dir $BENCH_WORK_DIR/charts"
FEATURE_CSVS="$BENCH_WORK_DIR/feature-1/combined_latency.csv"
BASELINE_CSVS="$BENCH_WORK_DIR/baseline-1/combined_latency.csv"
if [ "${BENCH_ABBA:-true}" = "true" ]; then
FEATURE_CSVS="$FEATURE_CSVS $BENCH_WORK_DIR/feature-2/combined_latency.csv"
BASELINE_CSVS="$BASELINE_CSVS $BENCH_WORK_DIR/baseline-2/combined_latency.csv"
fi
CHART_ARGS="$CHART_ARGS --feature $FEATURE_CSVS"
CHART_ARGS="$CHART_ARGS --baseline $BASELINE_CSVS"
CHART_ARGS="$CHART_ARGS --feature $BENCH_WORK_DIR/feature-1/combined_latency.csv $BENCH_WORK_DIR/feature-2/combined_latency.csv"
CHART_ARGS="$CHART_ARGS --baseline $BENCH_WORK_DIR/baseline-1/combined_latency.csv $BENCH_WORK_DIR/baseline-2/combined_latency.csv"
CHART_ARGS="$CHART_ARGS --baseline-name ${BASELINE_NAME}"
CHART_ARGS="$CHART_ARGS --feature-name ${FEATURE_NAME}"
# shellcheck disable=SC2086
@@ -1086,7 +832,7 @@ jobs:
- name: Upload results
if: "!cancelled()"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: bench-reth-results
path: ${{ env.BENCH_WORK_DIR }}
@@ -1118,7 +864,7 @@ jobs:
rm -rf "${TMP_DIR}"
- name: Compare & comment
if: success() && env.BENCH_COMMENT_ID
if: success()
uses: actions/github-script@v8
with:
github-token: ${{ secrets.DEREK_PAT }}
@@ -1154,8 +900,7 @@ jobs:
// Samply profile links (URLs point directly to Firefox Profiler)
if (process.env.BENCH_SAMPLY === 'true') {
const abba = (process.env.BENCH_ABBA || 'true') !== 'false';
const runs = abba ? ['baseline-1', 'feature-1', 'feature-2', 'baseline-2'] : ['baseline-1', 'feature-1'];
const runs = ['baseline-1', 'feature-1', 'feature-2', 'baseline-2'];
const links = [];
for (const run of runs) {
try {
@@ -1170,12 +915,6 @@ jobs:
}
}
// Grafana dashboard link
const grafanaUrl = '${{ steps.metrics.outputs.grafana-url }}';
if (grafanaUrl) {
comment += `\n\n### Grafana Dashboard\n\n[View real-time metrics](${grafanaUrl})\n`;
}
// Node errors (panics / ERROR logs)
try {
const errors = fs.readFileSync(process.env.BENCH_WORK_DIR + '/errors.md', 'utf8');
@@ -1201,7 +940,7 @@ jobs:
}
- name: Send Slack notification (success)
if: success() && env.BENCH_NO_SLACK != 'true'
if: success()
uses: actions/github-script@v8
env:
SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }}
@@ -1217,13 +956,12 @@ jobs:
with:
github-token: ${{ secrets.DEREK_PAT }}
script: |
const abba = (process.env.BENCH_ABBA || 'true') !== 'false';
const steps_status = [
['building binaries${{ steps.snapshot-check.outputs.needed == 'true' && ' & downloading snapshot' || '' }}', '${{ steps.build.outcome }}'],
['running baseline benchmark (1/2)', '${{ steps.run-baseline-1.outcome }}'],
['running feature benchmark (1/2)', '${{ steps.run-feature-1.outcome }}'],
...(abba ? [['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}']] : []),
...(abba ? [['running baseline benchmark (2/2)', '${{ steps.run-baseline-2.outcome }}']] : []),
['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}'],
['running baseline benchmark (2/2)', '${{ steps.run-baseline-2.outcome }}'],
];
const failed = steps_status.find(([, o]) => o === 'failure');
const failedStep = failed ? failed[0] : 'unknown step';
@@ -1252,13 +990,12 @@ jobs:
SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }}
with:
script: |
const abba = (process.env.BENCH_ABBA || 'true') !== 'false';
const steps_status = [
['building binaries${{ steps.snapshot-check.outputs.needed == 'true' && ' & downloading snapshot' || '' }}', '${{ steps.build.outcome }}'],
['running baseline benchmark (1/2)', '${{ steps.run-baseline-1.outcome }}'],
['running feature benchmark (1/2)', '${{ steps.run-feature-1.outcome }}'],
...(abba ? [['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}']] : []),
...(abba ? [['running baseline benchmark (2/2)', '${{ steps.run-baseline-2.outcome }}']] : []),
['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}'],
['running baseline benchmark (2/2)', '${{ steps.run-baseline-2.outcome }}'],
];
const failed = steps_status.find(([, o]) => o === 'failure');
const failedStep = failed ? failed[0] : 'unknown step';

View File

@@ -6,7 +6,7 @@ on:
hive_target:
required: true
type: string
description: "Docker bake target to build (e.g. hive)"
description: "Docker bake target to build (e.g. hive-stable, hive-edge)"
artifact_name:
required: false
type: string
@@ -76,7 +76,7 @@ jobs:
*.dockerfile=Dockerfile
- name: Upload reth image
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: ${{ inputs.artifact_name }}
path: ./artifacts

View File

@@ -28,30 +28,12 @@ on:
required: false
type: boolean
default: false
pgo:
description: "Enable PGO profiling"
required: false
type: boolean
default: false
pgo_blocks:
description: "Number of blocks to execute for PGO profiling"
required: false
type: string
default: "20"
jobs:
collect-pgo-profile:
if: github.repository == 'paradigmxyz/reth' && github.event_name == 'workflow_dispatch' && inputs.pgo
uses: ./.github/workflows/pgo-profile.yml
with:
pgo_blocks: ${{ inputs.pgo_blocks || '20' }}
secrets: inherit
build:
if: github.repository == 'paradigmxyz/reth' && !failure() && !cancelled()
if: github.repository == 'paradigmxyz/reth'
name: Build Docker images
runs-on: ubuntu-24.04
needs: collect-pgo-profile
permissions:
packages: write
contents: read
@@ -63,7 +45,7 @@ jobs:
uses: depot/setup-action@v1
- name: Log in to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -76,30 +58,6 @@ jobs:
echo "describe=$(git describe --always --tags)" >> "$GITHUB_OUTPUT"
echo "dirty=false" >> "$GITHUB_OUTPUT"
- name: Download pre-collected PGO profile
if: ${{ github.event_name == 'workflow_dispatch' && inputs.pgo }}
uses: actions/download-artifact@v7
with:
name: pgo-profdata
path: dist
- name: Configure PGO build args
id: pgo
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] && [[ "${{ inputs.pgo }}" == "true" ]]; then
if [ ! -f dist/merged.profdata ]; then
echo "::error::Expected dist/merged.profdata from collect-pgo-profile job"
exit 1
fi
echo "use_pgo_bolt=true" >> "$GITHUB_OUTPUT"
echo "pgo_profdata=dist/merged.profdata" >> "$GITHUB_OUTPUT"
echo "Using pre-collected PGO profile from collect-pgo-profile job"
else
echo "use_pgo_bolt=false" >> "$GITHUB_OUTPUT"
echo "pgo_profdata=" >> "$GITHUB_OUTPUT"
echo "PGO disabled"
fi
- name: Determine build parameters
id: params
run: |
@@ -149,9 +107,6 @@ jobs:
push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
set: |
${{ steps.params.outputs.ethereum_set }}
*.args.USE_PGO_BOLT=${{ steps.pgo.outputs.use_pgo_bolt }}
*.args.PGO_PROFDATA=${{ steps.pgo.outputs.pgo_profdata }}
*.args.STRIP_SYMBOLS=false
- name: Verify image architectures
env:

View File

@@ -63,6 +63,6 @@ jobs:
run: |
cargo nextest run \
--no-fail-fast \
--locked \
--locked --features "edge" \
-p reth-e2e-test-utils \
-E 'binary(rocksdb)'

View File

@@ -15,11 +15,18 @@ concurrency:
cancel-in-progress: true
jobs:
build-reth:
build-reth-stable:
uses: ./.github/workflows/docker-test.yml
with:
hive_target: hive
artifact_name: "reth"
hive_target: hive-stable
artifact_name: "reth-stable"
secrets: inherit
build-reth-edge:
uses: ./.github/workflows/docker-test.yml
with:
hive_target: hive-edge
artifact_name: "reth-edge"
secrets: inherit
prepare-hive:
@@ -68,7 +75,7 @@ jobs:
chmod +x hive
- name: Upload hive assets
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: hive_assets
path: ./hive_assets
@@ -77,6 +84,7 @@ jobs:
strategy:
fail-fast: false
matrix:
storage: [stable, edge]
# ethereum/rpc to be deprecated:
# https://github.com/ethereum/hive/pull/1117
scenario:
@@ -176,9 +184,10 @@ jobs:
- sim: ethereum/eels/consume-rlp
limit: .*tests/paris.*
needs:
- build-reth
- build-reth-stable
- build-reth-edge
- prepare-hive
name: ${{ matrix.scenario.sim }}${{ matrix.scenario.limit && format(' - {0}', matrix.scenario.limit) }}
name: ${{ matrix.storage }} / ${{ matrix.scenario.sim }}${{ matrix.scenario.limit && format(' - {0}', matrix.scenario.limit) }}
# Use larger runners for eels tests to avoid OOM runner crashes
runs-on: ${{ github.repository == 'paradigmxyz/reth' && (contains(matrix.scenario.sim, 'eels') && 'depot-ubuntu-latest-8' || 'depot-ubuntu-latest-4') || 'ubuntu-latest' }}
permissions:
@@ -189,15 +198,15 @@ jobs:
fetch-depth: 0
- name: Download hive assets
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: hive_assets
path: /tmp
- name: Download reth image
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: reth
name: reth-${{ matrix.storage }}
path: /tmp
- name: Load Docker images

View File

@@ -22,7 +22,7 @@ concurrency:
jobs:
test:
name: test / ${{ matrix.network }}
name: test / ${{ matrix.network }} / ${{ matrix.storage }}
if: github.event_name != 'schedule'
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-4' || 'ubuntu-latest' }}
env:
@@ -30,6 +30,7 @@ jobs:
strategy:
matrix:
network: ["ethereum"]
storage: ["stable", "edge"]
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
@@ -46,7 +47,7 @@ jobs:
run: |
cargo nextest run \
--no-fail-fast \
--locked --features "asm-keccak ${{ matrix.network }}" \
--locked --features "asm-keccak ${{ matrix.network }} ${{ matrix.storage == 'edge' && 'edge' || '' }}" \
--workspace --exclude ef-tests \
-E "kind(test) and not binary(e2e_testsuite)"

View File

@@ -40,7 +40,7 @@ jobs:
fetch-depth: 0
- name: Download reth image
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: artifacts
path: /tmp

View File

@@ -1,107 +0,0 @@
name: pgo-profile
on:
workflow_call:
inputs:
pgo_blocks:
description: "Number of blocks to execute for PGO profiling"
required: false
type: string
default: "20"
workflow_dispatch:
inputs:
pgo_blocks:
description: "Number of blocks to execute for PGO profiling"
required: false
type: string
default: "20"
jobs:
collect:
name: collect PGO profiles
runs-on: [self-hosted, Linux, X64]
timeout-minutes: 180
env:
SCHELK_MOUNT: /reth-bench
BENCH_RPC_URL: https://ethereum.reth.rs/rpc
RUSTC_WRAPPER: "sccache"
steps:
- uses: actions/checkout@v6
with:
submodules: true
- uses: rui314/setup-mold@v1
- uses: dtolnay/rust-toolchain@stable
with:
target: x86_64-unknown-linux-gnu
- uses: mozilla-actions/sccache-action@v0.0.9
continue-on-error: true
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Install dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
dmsetup lsb-release wget linux-tools-"$(uname -r)" || \
sudo apt-get install -y --no-install-recommends linux-tools-generic
- name: Download snapshot if needed
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BENCH_REPO: ${{ github.repository }}
run: |
if ! .github/scripts/bench-reth-snapshot.sh --check; then
echo "Snapshot outdated or missing, downloading..."
.github/scripts/bench-reth-snapshot.sh
fi
- name: Mount snapshot
run: |
sudo pkill -9 reth || true
sleep 1
if mountpoint -q "$SCHELK_MOUNT"; then
sudo umount -l "$SCHELK_MOUNT" || true
sudo schelk recover -y || true
fi
sudo schelk mount -y
sync
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
- name: Collect PGO profile
run: |
DATADIR="$SCHELK_MOUNT/datadir" \
RPC_URL="$BENCH_RPC_URL" \
PGO_BLOCKS="${{ inputs.pgo_blocks || '20' }}" \
BOLT_BLOCKS="${{ inputs.pgo_blocks || '20' }}" \
COLLECT_PGO_ONLY=true \
SKIP_BOLT=true \
PROFILE=maxperf-symbols \
FEATURES="jemalloc,asm-keccak,min-debug-logs" \
TARGET=x86_64-unknown-linux-gnu \
EXTRA_RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq" \
.github/scripts/build_pgo_bolt.sh
- name: Show PGO profile stats
run: |
LLVM_PROFDATA=$(find "$(rustc --print sysroot)" -name llvm-profdata -type f | head -1)
if [ -z "$LLVM_PROFDATA" ]; then
echo "::error::llvm-profdata not found in rust toolchain"
exit 1
fi
"$LLVM_PROFDATA" show --detailed-summary --topn=20 target/pgo-profiles/merged.profdata
- name: Upload PGO profile
uses: actions/upload-artifact@v7
with:
name: pgo-profdata
path: target/pgo-profiles/merged.profdata
retention-days: 1
- name: Recover snapshot
if: always()
run: |
if mountpoint -q "$SCHELK_MOUNT"; then
sudo umount -l "$SCHELK_MOUNT" || true
sudo schelk recover -y || true
fi

View File

@@ -1,30 +0,0 @@
name: Pull request audit
on:
pull_request:
types: [labeled]
jobs:
publish:
runs-on: ubuntu-latest
if: github.event.label.name == 'cyclops'
steps:
- name: Publish event
run: |
set -euo pipefail
echo "${{ secrets.EVENTS_KEY }}" > ${{ runner.temp }}/key
echo "${{ secrets.EVENTS_CERT }}" > ${{ runner.temp }}/cert
curl -sf -o /dev/null -X POST ${{ secrets.EVENTS_ARGS }} \
-H "Content-Type: application/json" \
--key ${{ runner.temp }}/key \
--cert ${{ runner.temp }}/cert \
-d '{
"repository": "${{ github.repository }}",
"event": "pr_audit",
"data": {
"pr_number": ${{ github.event.pull_request.number }},
"sha": "${{ github.event.pull_request.head.sha }}"
}
}'

View File

@@ -52,7 +52,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -13,14 +13,6 @@ on:
description: "Enable dry run mode (builds artifacts but skips uploads and release creation)"
type: boolean
default: false
pgo:
description: "Enable PGO profiling"
type: boolean
default: false
pgo_blocks:
description: "Number of blocks to execute for PGO profiling on self-hosted runner"
type: string
default: "20"
env:
REPO_NAME: ${{ github.repository_owner }}/reth
@@ -45,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Extract version
run: echo "VERSION=${GITHUB_REF_NAME//\//-}" >> $GITHUB_OUTPUT
run: echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
id: extract_version
outputs:
VERSION: ${{ steps.extract_version.outputs.VERSION }}
@@ -77,12 +69,16 @@ jobs:
fail-fast: true
matrix:
configs:
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
profile: maxperf
allow_fail: false
rustflags: "-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq"
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
profile: maxperf
allow_fail: false
rustflags: ""
native: true
- target: x86_64-apple-darwin
os: macos-14
profile: maxperf
@@ -104,7 +100,6 @@ jobs:
target: ${{ matrix.configs.target }}
- uses: mozilla-actions/sccache-action@v0.0.9
- name: Install cross main
if: ${{ !matrix.configs.native }}
id: cross_main
run: |
cargo install cross --locked --git https://github.com/cross-rs/cross
@@ -119,12 +114,7 @@ jobs:
echo "MACOSX_DEPLOYMENT_TARGET=$(xcrun -sdk macosx --show-sdk-platform-version)" >> $GITHUB_ENV
- name: Build Reth
run: |
if [ "${{ matrix.configs.native }}" = "true" ]; then
make PROFILE=${{ matrix.configs.profile }} EXTRA_RUSTFLAGS="${{ matrix.configs.rustflags }}" ${{ matrix.build.command }}-native-${{ matrix.configs.target }}
else
make PROFILE=${{ matrix.configs.profile }} EXTRA_RUSTFLAGS="${{ matrix.configs.rustflags }}" ${{ matrix.build.command }}-${{ matrix.configs.target }}
fi
run: make PROFILE=${{ matrix.configs.profile }} EXTRA_RUSTFLAGS="${{ matrix.configs.rustflags }}" ${{ matrix.build.command }}-${{ matrix.configs.target }}
- name: Move binary
run: |
mkdir artifacts
@@ -145,105 +135,23 @@ jobs:
- name: Upload artifact
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz
path: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz
- name: Upload signature
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz.asc
path: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz.asc
collect-pgo-profile:
if: github.event_name == 'workflow_dispatch' && inputs.pgo
uses: ./.github/workflows/pgo-profile.yml
with:
pgo_blocks: ${{ inputs.pgo_blocks || '20' }}
secrets: inherit
build-pgo:
if: github.event_name == 'workflow_dispatch' && inputs.pgo
name: build release (x86_64-linux PGO+BOLT)
runs-on: [self-hosted, Linux, X64]
needs: [extract-version, collect-pgo-profile]
timeout-minutes: 120
env:
RUSTC_WRAPPER: "sccache"
steps:
- uses: actions/checkout@v6
with:
submodules: true
- uses: rui314/setup-mold@v1
- uses: dtolnay/rust-toolchain@stable
with:
target: x86_64-unknown-linux-gnu
- uses: mozilla-actions/sccache-action@v0.0.9
continue-on-error: true
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Download pre-collected PGO profile
uses: actions/download-artifact@v7
with:
name: pgo-profdata
path: dist
- name: Verify PGO profile artifact
run: |
test -f dist/merged.profdata
ls -lh dist/merged.profdata
- name: Build Reth with PGO+BOLT
run: |
SKIP_BOLT=true \
PGO_PROFDATA="$PWD/dist/merged.profdata" \
PROFILE=maxperf-symbols \
FEATURES="jemalloc,asm-keccak,min-debug-logs" \
TARGET=x86_64-unknown-linux-gnu \
EXTRA_RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq" \
.github/scripts/build_pgo_bolt.sh
- name: Move binary
run: |
mkdir artifacts
mv target/maxperf-symbols/reth ./artifacts
- name: Configure GPG and create artifacts
env:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
export GPG_TTY=$(tty)
echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --batch --import
cd artifacts
tar -czf reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz reth*
echo "$GPG_PASSPHRASE" | gpg --passphrase-fd 0 --pinentry-mode loopback --batch -ab reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz
mv *tar.gz* ..
shell: bash
- name: Upload artifact
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/upload-artifact@v6
with:
name: reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz
path: reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz
- name: Upload signature
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/upload-artifact@v6
with:
name: reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz.asc
path: reth-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.tar.gz.asc
draft-release:
name: draft release
runs-on: ubuntu-latest
needs: [build, build-pgo, extract-version]
if: ${{ !failure() && !cancelled() && github.event.inputs.dry_run != 'true' }}
needs: [build, extract-version]
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
VERSION: ${{ needs.extract-version.outputs.VERSION }}
permissions:
@@ -256,7 +164,7 @@ jobs:
with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
- name: Generate full changelog
id: changelog
run: |

View File

@@ -43,7 +43,7 @@ jobs:
echo "Binaries SHA256 on ${{ matrix.machine }}: $(cat checksum.sha256)"
- name: Upload the hash
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: checksum-${{ matrix.machine }}
path: |
@@ -56,12 +56,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download artifacts from machine-1
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: checksum-machine-1
path: machine-1/
- name: Download artifacts from machine-2
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: checksum-machine-2
path: machine-2/

View File

@@ -51,12 +51,15 @@ jobs:
- name: Run execution stage
run: |
reth stage run execution --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints
# NOTE: account-hashing, storage-hashing, and hashing stages are omitted.
# With storage v2 (now default), these stages are no-ops because the
# execution stage writes directly to HashedAccounts/HashedStorages.
# Running them here is harmful: `stage run` unwinds before executing,
# and the unwind reverts the hashed state that execution wrote, but
# the no-op execute never restores it — causing merkle to fail.
- name: Run account-hashing stage
run: |
reth stage run account-hashing --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints
- name: Run storage hashing stage
run: |
reth stage run storage-hashing --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints
- name: Run hashing stage
run: |
reth stage run hashing --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints
- name: Run merkle stage
run: |
reth stage run merkle --from ${{ env.FROM_BLOCK }} --to ${{ env.TO_BLOCK }} --commit --checkpoints

View File

@@ -19,13 +19,15 @@ concurrency:
jobs:
test:
name: test / ${{ matrix.type }}
name: test / ${{ matrix.type }} / ${{ matrix.storage }}
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-4' || 'ubuntu-latest' }}
env:
RUST_BACKTRACE: 1
EDGE_FEATURES: ${{ matrix.storage == 'edge' && 'edge' || '' }}
strategy:
matrix:
type: [ethereum]
storage: [stable, edge]
include:
- type: ethereum
features: asm-keccak ethereum
@@ -48,14 +50,14 @@ jobs:
run: |
cargo nextest run \
--no-fail-fast \
--features "${{ matrix.features }}" --locked \
--features "${{ matrix.features }} $EDGE_FEATURES" --locked \
${{ matrix.exclude_args }} --workspace \
--exclude ef-tests --no-tests=warn \
-E "!kind(test) and not binary(e2e_testsuite)"
state:
name: Ethereum state tests
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-4' || 'ubuntu-latest' }}
env:
RUST_LOG: info,sync=error
RUST_BACKTRACE: 1

949
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace.package]
version = "1.11.3"
version = "1.11.1"
edition = "2024"
rust-version = "1.93"
license = "MIT OR Apache-2.0"
@@ -170,7 +170,6 @@ rust.rust_2018_idioms = { level = "deny", priority = -1 }
rust.unreachable_pub = "warn"
rust.unused_must_use = "deny"
rust.rust_2024_incompatible_pat = "warn"
rust.unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tokio_unstable)'] }
rustdoc.all = "warn"
# rust.unnameable-types = "warn"
@@ -437,15 +436,15 @@ reth-trie-sparse = { path = "crates/trie/sparse", default-features = false }
reth-zstd-compressors = { path = "crates/storage/zstd-compressors", default-features = false }
# revm
revm = { version = "36.0.0", default-features = false }
revm-bytecode = { version = "9.0.0", default-features = false }
revm-database = { version = "12.0.0", default-features = false }
revm-state = { version = "10.0.0", default-features = false }
revm-primitives = { version = "22.1.0", default-features = false }
revm-interpreter = { version = "34.0.0", default-features = false }
revm-database-interface = { version = "10.0.0", default-features = false }
op-revm = { version = "17.0.0", default-features = false }
revm-inspectors = "0.36.0"
revm = { version = "34.0.0", default-features = false }
revm-bytecode = { version = "8.0.0", default-features = false }
revm-database = { version = "10.0.0", default-features = false }
revm-state = { version = "9.0.0", default-features = false }
revm-primitives = { version = "22.0.0", default-features = false }
revm-interpreter = { version = "32.0.0", default-features = false }
revm-database-interface = { version = "9.0.0", default-features = false }
op-revm = { version = "15.0.0", default-features = false }
revm-inspectors = "0.34.2"
# eth
alloy-dyn-abi = "1.5.6"
@@ -455,7 +454,7 @@ alloy-sol-types = { version = "1.5.6", default-features = false }
alloy-chains = { version = "0.2.5", default-features = false }
alloy-eip2124 = { version = "0.2.0", default-features = false }
alloy-eip7928 = { version = "0.3.0", default-features = false }
alloy-evm = { version = "0.29.2", default-features = false }
alloy-evm = { version = "0.28.0", default-features = false }
alloy-rlp = { version = "0.3.13", default-features = false, features = ["core-net"] }
alloy-trie = { version = "0.9.4", default-features = false }
@@ -490,9 +489,14 @@ alloy-transport-ipc = { version = "1.7.3", default-features = false }
alloy-transport-ws = { version = "1.7.3", default-features = false }
# op
op-alloy-rpc-types = { version = "0.24.0", default-features = false }
op-alloy-rpc-types-engine = { version = "0.24.0", default-features = false }
op-alloy-consensus = { version = "0.24.0", default-features = false }
alloy-op-evm = { version = "0.27.2", default-features = false }
alloy-op-hardforks = "0.4.4"
op-alloy-rpc-types = { version = "0.23.1", default-features = false }
op-alloy-rpc-types-engine = { version = "0.23.1", default-features = false }
op-alloy-network = { version = "0.23.1", default-features = false }
op-alloy-consensus = { version = "0.23.1", default-features = false }
op-alloy-rpc-jsonrpsee = { version = "0.23.1", default-features = false }
op-alloy-flz = { version = "0.13.1", default-features = false }
# misc
either = { version = "1.15.0", default-features = false }
@@ -504,7 +508,6 @@ bincode = "1.3"
bitflags = "2.4"
boyer-moore-magiclen = "0.2.16"
bytes = { version = "1.11.1", default-features = false }
blake3 = "1.8"
brotli = "8"
cfg-if = "1.0"
clap = "4"
@@ -520,7 +523,6 @@ humantime = "2.1"
humantime-serde = "1.1"
itertools = { version = "0.14", default-features = false }
linked_hash_set = "0.1"
libc = "0.2"
lz4 = "1.28.1"
modular-bitfield = "0.13.1"
notify = { version = "8.0.0", default-features = false, features = ["macos_fsevent"] }
@@ -539,8 +541,6 @@ serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
serde_with = { version = "3", default-features = false, features = ["macros"] }
sha2 = { version = "0.10", default-features = false }
shlex = "1.3"
# https://github.com/orlp/slotmap/pull/148
slotmap = { git = "https://github.com/DaniPopes/slotmap.git", branch = "dani/shrink-methods" }
smallvec = "1"
strum = { version = "0.27", default-features = false }
strum_macros = "0.27"
@@ -665,7 +665,6 @@ cipher = "0.4.3"
comfy-table = "7.0"
concat-kdf = "0.1.0"
crossbeam-channel = "0.5.13"
crossbeam-queue = "0.3"
crossbeam-utils = "0.8"
crossterm = "0.29.0"
csv = "1.3.0"
@@ -742,8 +741,10 @@ ipnet = "2.11"
# alloy-transport-ws = { git = "https://github.com/alloy-rs/alloy", rev = "3049f232fbb44d1909883e154eb38ec5962f53a3" }
# op-alloy-consensus = { git = "https://github.com/alloy-rs/op-alloy", rev = "a79d6fc" }
# op-alloy-network = { git = "https://github.com/alloy-rs/op-alloy", rev = "a79d6fc" }
# op-alloy-rpc-types = { git = "https://github.com/alloy-rs/op-alloy", rev = "a79d6fc" }
# op-alloy-rpc-types-engine = { git = "https://github.com/alloy-rs/op-alloy", rev = "a79d6fc" }
# op-alloy-rpc-jsonrpsee = { git = "https://github.com/alloy-rs/op-alloy", rev = "a79d6fc" }
#
# revm-inspectors = { git = "https://github.com/paradigmxyz/revm-inspectors", rev = "1207e33" }
#
@@ -753,6 +754,10 @@ ipnet = "2.11"
# jsonrpsee-http-client = { git = "https://github.com/paradigmxyz/jsonrpsee", branch = "matt/make-rpc-service-pub" }
# jsonrpsee-types = { git = "https://github.com/paradigmxyz/jsonrpsee", branch = "matt/make-rpc-service-pub" }
# alloy-evm = { git = "https://github.com/alloy-rs/evm", rev = "9bc2dba" }
# alloy-evm = { git = "https://github.com/alloy-rs/evm", rev = "df124c0" }
# alloy-op-evm = { git = "https://github.com/alloy-rs/evm", rev = "df124c0" }
# revm-inspectors = { git = "https://github.com/paradigmxyz/revm-inspectors", rev = "3020ea8" }
# alloy-evm = { git = "https://github.com/alloy-rs/evm", rev = "072c248" }
# alloy-op-evm = { git = "https://github.com/alloy-rs/evm", rev = "072c248" }

View File

@@ -1,10 +1,8 @@
# syntax=docker/dockerfile:1
# Dockerfile for reth, optimized for Depot builds
# Supports PGO+BOLT optimization for maximum performance
# Usage:
# reth: --build-arg BINARY=reth
# PGO+BOLT: --build-arg USE_PGO_BOLT=true (Linux x86_64/aarch64 only)
FROM rust:1.93 AS builder
WORKDIR /app
@@ -45,18 +43,6 @@ ENV VERGEN_GIT_SHA=$VERGEN_GIT_SHA
ENV VERGEN_GIT_DESCRIBE=$VERGEN_GIT_DESCRIBE
ENV VERGEN_GIT_DIRTY=$VERGEN_GIT_DIRTY
# Enable PGO+BOLT optimization (Linux only)
ARG USE_PGO_BOLT=false
ENV USE_PGO_BOLT=$USE_PGO_BOLT
# Optional path to a pre-collected merged.profdata file in build context.
ARG PGO_PROFDATA=""
ENV PGO_PROFDATA=$PGO_PROFDATA
# Whether to strip debug symbols from PGO-built binaries.
ARG STRIP_SYMBOLS=true
ENV STRIP_SYMBOLS=$STRIP_SYMBOLS
# Build application
# Platform-specific RUSTFLAGS: amd64 uses x86-64-v3 (Haswell+) with pclmulqdq for rocksdb
ARG TARGETPLATFORM
@@ -67,21 +53,12 @@ RUN --mount=type=secret,id=DEPOT_TOKEN,env=SCCACHE_WEBDAV_TOKEN \
--mount=type=cache,target=$SCCACHE_DIR,sharing=shared \
export RUSTC_WRAPPER=sccache SCCACHE_WEBDAV_ENDPOINT=https://cache.depot.dev SCCACHE_DIR=/sccache && \
sccache --start-server && \
if [ "$USE_PGO_BOLT" = "true" ] && [ "$TARGETPLATFORM" = "linux/amd64" ] && [ -n "$PGO_PROFDATA" ] && [ -f "$PGO_PROFDATA" ]; then \
apt-get update && apt-get install -y -qq lsb-release wget sudo && \
BINARY="$BINARY" PROFILE="$BUILD_PROFILE" FEATURES="$FEATURES" SKIP_BOLT=true STRIP_SYMBOLS="$STRIP_SYMBOLS" PGO_PROFDATA="$PGO_PROFDATA" \
./.github/scripts/build_pgo_bolt.sh; \
else \
if [ "$USE_PGO_BOLT" = "true" ]; then \
echo "PGO requested but pre-collected profile missing at '${PGO_PROFDATA:-<unset>}' - falling back to non-PGO build"; \
fi; \
if [ -n "$RUSTFLAGS" ]; then \
export RUSTFLAGS="$RUSTFLAGS"; \
elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
export RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq"; \
fi && \
cargo build --profile $BUILD_PROFILE --features "$FEATURES" --locked --bin $BINARY --manifest-path $MANIFEST_PATH/Cargo.toml; \
if [ -n "$RUSTFLAGS" ]; then \
export RUSTFLAGS="$RUSTFLAGS"; \
elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
export RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq"; \
fi && \
cargo build --profile $BUILD_PROFILE --features "$FEATURES" --locked --bin $BINARY --manifest-path $MANIFEST_PATH/Cargo.toml && \
sccache --show-stats
# Copy binary to a known location (ARG not resolved in COPY)

View File

@@ -95,7 +95,6 @@ build-native-%:
# on other systems. JEMALLOC_SYS_WITH_LG_PAGE=16 tells jemalloc to use 64-KiB
# pages. See: https://github.com/paradigmxyz/reth/issues/6742
build-aarch64-unknown-linux-gnu: export JEMALLOC_SYS_WITH_LG_PAGE=16
build-native-aarch64-unknown-linux-gnu: export JEMALLOC_SYS_WITH_LG_PAGE=16
# Note: The additional rustc compiler flags are for intrinsics needed by MDBX.
# See: https://github.com/cross-rs/cross/wiki/FAQ#undefined-reference-with-build-std

View File

@@ -7,7 +7,7 @@ use alloy_primitives::address;
use alloy_provider::{network::AnyNetwork, Provider, RootProvider};
use alloy_rpc_client::ClientBuilder;
use alloy_rpc_types_engine::JwtSecret;
use alloy_transport::layers::{RateLimitRetryPolicy, RetryBackoffLayer};
use alloy_transport::layers::RetryBackoffLayer;
use reqwest::Url;
use reth_node_core::args::BenchmarkArgs;
use tracing::info;
@@ -53,15 +53,9 @@ impl BenchContext {
}
}
// set up alloy client for blocks, retrying on 429/503 (default) and 502
let retry_policy =
RateLimitRetryPolicy::default().or(|err: &alloy_transport::TransportError| -> bool {
err.as_transport_err()
.and_then(|t| t.as_http_error())
.is_some_and(|e| e.status == 502)
});
// set up alloy client for blocks
let client = ClientBuilder::default()
.layer(RetryBackoffLayer::new_with_policy(10, 800, u64::MAX, retry_policy))
.layer(RetryBackoffLayer::new(10, 800, u64::MAX))
.http(rpc_url.parse()?);
let block_provider = RootProvider::<AnyNetwork>::new(client);
@@ -94,11 +88,9 @@ impl BenchContext {
// Computes the block range for the benchmark.
//
// - If `--advance` is provided, fetches the latest block from the engine and sets:
// - If `--advance` is provided, fetches the latest block and sets:
// - `from = head + 1`
// - `to = head + advance`
// - If only `--to` is provided, fetches the latest block from the engine and sets:
// - `from = head`
// - Otherwise, uses the values from `--from` and `--to`.
let (from, to) = if let Some(advance) = bench_args.advance {
if advance == 0 {
@@ -111,14 +103,6 @@ impl BenchContext {
.ok_or_else(|| eyre::eyre!("Failed to fetch latest block for --advance"))?;
let head_number = head_block.header.number;
(Some(head_number), Some(head_number + advance))
} else if bench_args.from.is_none() && bench_args.to.is_some() {
let head_block = auth_provider
.get_block_by_number(BlockNumberOrTag::Latest)
.await?
.ok_or_else(|| eyre::eyre!("Failed to fetch latest block from engine"))?;
let head_number = head_block.header.number;
info!(target: "reth-bench", "No --from provided, derived from engine head: {}", head_number);
(Some(head_number), bench_args.to)
} else {
(bench_args.from, bench_args.to)
};
@@ -130,7 +114,7 @@ impl BenchContext {
.full()
.await?
.ok_or_else(|| eyre::eyre!("Failed to fetch latest block from RPC"))?;
let mut benchmark_mode = BenchMode::new(from, to, latest_block.into_inner().number());
let mut benchmark_mode = BenchMode::new(from, to, latest_block.into_inner().number())?;
let first_block = match benchmark_mode {
BenchMode::Continuous(start) => {

View File

@@ -24,7 +24,7 @@ pub(crate) struct GasRampPayloadFile {
/// Engine API version (1-5).
///
/// `None` indicates that `reth_newPayload` should be used.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) version: Option<u8>,
/// The block hash for FCU.
pub(crate) block_hash: B256,
@@ -114,27 +114,16 @@ impl CombinedResult {
impl std::fmt::Display for CombinedResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let np = &self.new_payload_result;
write!(
f,
"Block {} processed at {:.4} Ggas/s, used {} total gas. Combined: {:.4} Ggas/s. fcu: {:?}, newPayload: {:?}",
self.block_number,
np.gas_per_second() / GIGAGAS as f64,
np.gas_used,
self.new_payload_result.gas_per_second() / GIGAGAS as f64,
self.new_payload_result.gas_used,
self.combined_gas_per_second() / GIGAGAS as f64,
self.fcu_latency,
np.latency,
)?;
if !np.execution_cache_wait.is_zero() {
write!(f, ", execution cache wait: {:?}", np.execution_cache_wait)?;
}
if !np.sparse_trie_wait.is_zero() {
write!(f, ", trie cache wait: {:?}", np.sparse_trie_wait)?;
}
if let Some(d) = np.persistence_wait {
write!(f, ", persistence wait: {d:?}")?;
}
Ok(())
self.new_payload_result.latency
)
}
}

View File

@@ -292,6 +292,10 @@ impl Command {
info!(target: "reth-bench", gas_ramp_payload = i + 1, "Gas ramp payload executed successfully");
if let Some(w) = &mut waiter {
w.on_block(payload.block_number).await?;
}
parent_hash = payload.file.block_hash;
}

View File

@@ -34,16 +34,17 @@ impl BenchMode {
}
/// Create a [`BenchMode`] from optional `from` and `to` fields.
///
/// If only `--to` is provided, `from` is derived as `latest_block + 1`.
pub const fn new(from: Option<u64>, to: Option<u64>, latest_block: u64) -> Self {
pub fn new(from: Option<u64>, to: Option<u64>, latest_block: u64) -> Result<Self, eyre::Error> {
// If neither `--from` nor `--to` are provided, we will run the benchmark continuously,
// starting at the latest block.
match (from, to) {
(Some(from), Some(to)) => Self::Range(from..=to),
(None, None) => Self::Continuous(latest_block),
(Some(start), None) => Self::Continuous(start),
(None, Some(to)) => Self::Range(latest_block + 1..=to),
(Some(from), Some(to)) => Ok(Self::Range(from..=to)),
(None, None) => Ok(Self::Continuous(latest_block)),
(Some(start), None) => Ok(Self::Continuous(start)),
_ => {
// both or neither are allowed, everything else is ambiguous
Err(eyre::eyre!("`from` and `to` must be provided together, or not at all."))
}
}
}
}

View File

@@ -235,8 +235,8 @@ pub(crate) fn payload_to_new_payload(
))?,
)
} else {
// Preserve the original RequestsOrHash payload for engine_newPayloadV4.
let requests = prague.requests.clone();
// Extract actual Requests from RequestsOrHash
let requests = prague.requests.requests_hash();
(
version,
serde_json::to_value((

View File

@@ -89,6 +89,7 @@ default = [
"keccak-cache-global",
"asm-keccak",
"min-debug-logs",
"rocksdb",
]
otlp = [
@@ -190,6 +191,8 @@ min-trace-logs = [
]
trie-debug = ["reth-node-builder/trie-debug", "reth-node-core/trie-debug"]
rocksdb = ["reth-ethereum-cli/rocksdb", "reth-node-core/rocksdb"]
edge = ["rocksdb"]
[[bin]]
name = "reth"

View File

@@ -9,7 +9,7 @@ use std::{
fmt,
sync::{Arc, LazyLock},
};
use tracing::{debug_span, instrument};
use tracing::instrument;
/// Shared handle to asynchronously populated trie data.
///
@@ -163,8 +163,6 @@ impl DeferredTrieData {
anchor_hash: B256,
ancestors: &[Self],
) -> ComputedTrieData {
let _span = debug_span!(target: "engine::tree::deferred_trie", "sort_inputs").entered();
#[cfg(feature = "rayon")]
let (sorted_hashed_state, sorted_trie_updates) = rayon::join(
|| match Arc::try_unwrap(hashed_state) {
@@ -189,10 +187,6 @@ impl DeferredTrieData {
},
);
drop(_span);
let _span = debug_span!(target: "engine::tree::deferred_trie", "build_overlay").entered();
// Reuse parent's overlay if available and anchors match.
// We can only reuse the parent's overlay if it was built on top of the same
// persisted anchor. If the anchor has changed (e.g., due to persistence),
@@ -211,9 +205,6 @@ impl DeferredTrieData {
Arc::clone(&trie_input.state),
Default::default(), // prefix_sets are per-block, not cumulative
);
let _span =
debug_span!(target: "engine::tree::deferred_trie", "extend_overlay")
.entered();
// Only trigger COW clone if there's actually data to add.
#[cfg(feature = "rayon")]
{
@@ -281,7 +272,6 @@ impl DeferredTrieData {
sorted_hashed_state: &HashedPostStateSorted,
sorted_trie_updates: &TrieUpdatesSorted,
) -> TrieInputSorted {
let _span = debug_span!(target: "engine::tree::deferred_trie", "merge_ancestors", num_ancestors = ancestors.len()).entered();
let mut overlay = TrieInputSorted::default();
let state_mut = Arc::make_mut(&mut overlay.state);

View File

@@ -1,69 +0,0 @@
//! Execution timing statistics for detailed block logging.
//!
//! This module provides types for collecting and passing execution timing statistics
//! through the block processing pipeline, enabling unified detailed block logging after
//! database commit.
use std::time::Duration;
use alloy_primitives::B256;
/// Statistics collected during block execution for cross-client performance analysis.
///
/// These statistics are populated during block validation and carried through to
/// persistence, where they are used to emit a single unified log entry that includes
/// complete timing information (including commit time).
#[derive(Debug, Clone, Default)]
pub struct ExecutionTimingStats {
/// Block number
pub block_number: u64,
/// Block hash
pub block_hash: B256,
/// Total gas used by the block
pub gas_used: u64,
/// Number of transactions in the block
pub tx_count: usize,
/// Time spent executing transactions (includes state reads)
pub execution_duration: Duration,
/// Time spent fetching state during execution (subset of `execution_duration`, includes cache
/// hits)
pub state_read_duration: Duration,
/// Time spent computing state root hash
pub state_hash_duration: Duration,
/// Number of accounts read during execution
pub accounts_read: usize,
/// Number of storage slots read (SLOAD operations)
pub storage_read: usize,
/// Number of code reads (EXTCODE* operations)
pub code_read: usize,
/// Total bytes of code read
pub code_bytes_read: usize,
/// Number of accounts changed (balance/nonce updates)
pub accounts_changed: usize,
/// Number of accounts deleted (SELFDESTRUCT)
pub accounts_deleted: usize,
/// Number of storage slots changed (SSTORE operations)
pub storage_slots_changed: usize,
/// Number of storage slots deleted (set to zero)
pub storage_slots_deleted: usize,
/// Number of bytecodes created/changed (contract deployments)
pub bytecodes_changed: usize,
/// Total bytes of code written
pub code_bytes_written: usize,
/// Number of EIP-7702 delegations set
pub eip7702_delegations_set: usize,
/// Number of EIP-7702 delegations cleared
pub eip7702_delegations_cleared: usize,
/// Account cache hits
pub account_cache_hits: usize,
/// Account cache misses
pub account_cache_misses: usize,
/// Storage cache hits
pub storage_cache_hits: usize,
/// Storage cache misses
pub storage_cache_misses: usize,
/// Code cache hits
pub code_cache_hits: usize,
/// Code cache misses
pub code_cache_misses: usize,
}

View File

@@ -992,7 +992,7 @@ impl<N: NodePrimitives<SignedTx: SignedTransaction>> NewCanonicalChain<N> {
///
/// Returns the new tip for [`Self::Reorg`] and [`Self::Commit`] variants which commit at least
/// 1 new block.
pub fn tip(&self) -> &RecoveredBlock<N::Block> {
pub fn tip(&self) -> &SealedBlock<N::Block> {
match self {
Self::Commit { new } | Self::Reorg { new, .. } => {
new.last().expect("non empty blocks").recovered_block()

View File

@@ -8,9 +8,6 @@
#![cfg_attr(not(test), warn(unused_crate_dependencies))]
#![cfg_attr(docsrs, feature(doc_cfg))]
mod execution_stats;
pub use execution_stats::ExecutionTimingStats;
mod in_memory;
pub use in_memory::*;

View File

@@ -21,7 +21,7 @@ use crate::chainspec::ChainSpecParser;
///
/// This trait is supposed to be implemented by the main struct of the CLI.
///
/// It provides commonly used functionality for running commands and information about the CLI, such
/// It provides commonly used functionality for running commands and information about the CL, such
/// as the name and version.
pub trait RethCli: Sized {
/// The associated `ChainSpecParser` type

View File

@@ -46,7 +46,7 @@ reth-prune.workspace = true
reth-prune-types.workspace = true
reth-revm.workspace = true
reth-stages.workspace = true
reth-stages-types.workspace = true
reth-stages-types = { workspace = true, optional = true }
reth-static-file-types = { workspace = true, features = ["clap"] }
reth-static-file.workspace = true
reth-tasks.workspace = true
@@ -87,8 +87,6 @@ tokio-stream.workspace = true
reqwest.workspace = true
url.workspace = true
metrics.workspace = true
blake3.workspace = true
rayon.workspace = true
# io
fdlimit.workspace = true
@@ -110,6 +108,7 @@ reth-provider = { workspace = true, features = ["test-utils"] }
tempfile.workspace = true
[features]
default = []
arbitrary = [
"dep:proptest",
"dep:arbitrary",
@@ -128,9 +127,12 @@ arbitrary = [
"reth-trie-common?/test-utils",
"reth-codecs/arbitrary",
"reth-prune-types/arbitrary",
"reth-stages-types/arbitrary",
"reth-stages-types?/arbitrary",
"reth-trie-common?/arbitrary",
"alloy-consensus/arbitrary",
"reth-primitives-traits/arbitrary",
"reth-ethereum-primitives/arbitrary",
]
rocksdb = ["reth-db-common/rocksdb", "reth-stages/rocksdb", "reth-provider/rocksdb", "reth-prune/rocksdb"]
edge = ["rocksdb"]

View File

@@ -73,12 +73,17 @@ pub struct EnvironmentArgs<C: ChainSpecParser> {
}
impl<C: ChainSpecParser> EnvironmentArgs<C> {
/// Returns the storage settings for new database initialization.
/// Returns the effective storage settings derived from `--storage.v2`.
///
/// Always returns [`StorageSettings::v2()`] — v2 is the default for all new
/// databases. Existing databases use the settings persisted in their metadata.
/// The base storage mode is determined by `--storage.v2`:
/// - When `--storage.v2` is set: uses [`StorageSettings::v2()`] defaults
/// - Otherwise: uses [`StorageSettings::base()`] defaults
pub fn storage_settings(&self) -> StorageSettings {
StorageSettings::v2()
if self.storage.v2 {
StorageSettings::v2()
} else {
StorageSettings::base()
}
}
/// Initializes environment according to [`AccessRights`] and returns an instance of

View File

@@ -21,6 +21,7 @@ use std::{
};
use tracing::{info, warn};
#[cfg(all(unix, feature = "rocksdb"))]
mod rocksdb;
/// Interval for logging progress during checksum computation.
@@ -72,6 +73,7 @@ enum Subcommand {
limit: Option<usize>,
},
/// Calculates the checksum of a RocksDB table
#[cfg(all(unix, feature = "rocksdb"))]
Rocksdb {
/// The RocksDB table
#[arg(value_enum)]
@@ -98,6 +100,7 @@ impl Command {
Subcommand::StaticFile { segment, start_block, end_block, limit } => {
checksum_static_file(tool, segment, start_block, end_block, limit)?;
}
#[cfg(all(unix, feature = "rocksdb"))]
Subcommand::Rocksdb { table, limit } => {
rocksdb::checksum_rocksdb(tool, table, limit)?;
}

View File

@@ -102,14 +102,14 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> Command<C>
let static_files_path = data_dir.static_files();
let exex_wal_path = data_dir.exex_wal();
// ensure the provided datadir exists
// ensure the provided datadir exist
eyre::ensure!(
data_dir.data_dir().is_dir(),
"Datadir does not exist: {:?}",
data_dir.data_dir()
);
// ensure the provided database exists
// ensure the provided database exist
eyre::ensure!(db_path.is_dir(), "Database does not exist: {:?}", db_path);
match self.command {

View File

@@ -9,7 +9,7 @@ use reth_db_api::{
};
use reth_db_common::DbTool;
use reth_node_builder::NodeTypesWithDB;
use reth_provider::{providers::ProviderNodeTypes, StaticFileProviderFactory};
use reth_provider::providers::ProviderNodeTypes;
use reth_storage_api::{BlockNumReader, StateProvider, StorageSettingsCache};
use reth_tasks::spawn_scoped_os_thread;
use std::{
@@ -17,9 +17,9 @@ use std::{
thread,
time::{Duration, Instant},
};
use tracing::info;
use tracing::{error, info};
/// Log progress every 30 seconds
/// Log progress every 5 seconds
const LOG_INTERVAL: Duration = Duration::from_secs(30);
/// The arguments for the `reth db state` command
@@ -152,11 +152,17 @@ impl Command {
let mut storage_keys = BTreeSet::new();
if history_in_rocksdb {
self.collect_staticfile_storage_keys(tool, address, &mut storage_keys)?;
} else {
self.collect_mdbx_storage_keys_parallel(tool, address, &mut storage_keys)?;
error!(
target: "reth::cli",
"Historical storage queries with RocksDB backend are not yet supported. \
Use MDBX for storage history or query current state without --block."
);
return Ok(());
}
// Collect keys from MDBX StorageChangeSets using parallel scanning
self.collect_mdbx_storage_keys_parallel(tool, address, &mut storage_keys)?;
info!(
target: "reth::cli",
address = %address,
@@ -201,63 +207,6 @@ impl Command {
Ok(())
}
/// Collects storage keys from static file StorageChangeSets (storage_v2).
fn collect_staticfile_storage_keys<N: NodeTypesWithDB + ProviderNodeTypes>(
&self,
tool: &DbTool<N>,
address: Address,
keys: &mut BTreeSet<B256>,
) -> eyre::Result<()> {
let tip = tool.provider_factory.provider()?.best_block_number()?;
if tip == 0 {
return Ok(());
}
info!(
target: "reth::cli",
address = %address,
tip,
"Scanning static file storage changesets"
);
let static_file_provider = tool.provider_factory.static_file_provider();
let walker = static_file_provider.walk_storage_changeset_range(0..=tip);
let mut total_scanned = 0usize;
let mut last_log = Instant::now();
for changeset_result in walker {
let (block_addr, storage_entry) = changeset_result?;
total_scanned += 1;
if block_addr.address() == address {
keys.insert(storage_entry.key);
}
if last_log.elapsed() >= LOG_INTERVAL {
info!(
target: "reth::cli",
address = %address,
entries_scanned = total_scanned,
unique_keys = keys.len(),
"Scanning static file storage changesets"
);
last_log = Instant::now();
}
}
info!(
target: "reth::cli",
address = %address,
total_entries = total_scanned,
unique_keys = keys.len(),
"Finished static file storage changeset scan"
);
Ok(())
}
/// Collects storage keys from MDBX StorageChangeSets using parallel block range scanning.
fn collect_mdbx_storage_keys_parallel<N: NodeTypesWithDB + ProviderNodeTypes>(
&self,

View File

@@ -0,0 +1,630 @@
use crate::common::EnvironmentArgs;
use clap::Parser;
use eyre::Result;
use lz4::Decoder;
use reqwest::{blocking::Client as BlockingClient, header::RANGE, Client, StatusCode};
use reth_chainspec::{EthChainSpec, EthereumHardforks};
use reth_cli::chainspec::ChainSpecParser;
use reth_fs_util as fs;
use std::{
borrow::Cow,
fs::OpenOptions,
io::{self, BufWriter, Read, Write},
path::{Path, PathBuf},
sync::{Arc, OnceLock},
time::{Duration, Instant},
};
use tar::Archive;
use tokio::task;
use tracing::info;
use url::Url;
use zstd::stream::read::Decoder as ZstdDecoder;
const BYTE_UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
const MERKLE_BASE_URL: &str = "https://downloads.merkle.io";
const EXTENSION_TAR_LZ4: &str = ".tar.lz4";
const EXTENSION_TAR_ZSTD: &str = ".tar.zst";
/// Global static download defaults
static DOWNLOAD_DEFAULTS: OnceLock<DownloadDefaults> = OnceLock::new();
/// Download configuration defaults
///
/// Global defaults can be set via [`DownloadDefaults::try_init`].
#[derive(Debug, Clone)]
pub struct DownloadDefaults {
/// List of available snapshot sources
pub available_snapshots: Vec<Cow<'static, str>>,
/// Default base URL for snapshots
pub default_base_url: Cow<'static, str>,
/// Default base URL for chain-aware snapshots.
///
/// When set, the chain ID is appended to form the full URL: `{base_url}/{chain_id}`.
/// For example, given a base URL of `https://snapshots.example.com` and chain ID `1`,
/// the resulting URL would be `https://snapshots.example.com/1`.
///
/// Falls back to [`default_base_url`](Self::default_base_url) when `None`.
pub default_chain_aware_base_url: Option<Cow<'static, str>>,
/// Optional custom long help text that overrides the generated help
pub long_help: Option<String>,
}
impl DownloadDefaults {
/// Initialize the global download defaults with this configuration
pub fn try_init(self) -> Result<(), Self> {
DOWNLOAD_DEFAULTS.set(self)
}
/// Get a reference to the global download defaults
pub fn get_global() -> &'static DownloadDefaults {
DOWNLOAD_DEFAULTS.get_or_init(DownloadDefaults::default_download_defaults)
}
/// Default download configuration with defaults from merkle.io and publicnode
pub fn default_download_defaults() -> Self {
Self {
available_snapshots: vec![
Cow::Borrowed("https://www.merkle.io/snapshots (default, mainnet archive)"),
Cow::Borrowed("https://publicnode.com/snapshots (full nodes & testnets)"),
],
default_base_url: Cow::Borrowed(MERKLE_BASE_URL),
default_chain_aware_base_url: None,
long_help: None,
}
}
/// Generates the long help text for the download URL argument using these defaults.
///
/// If a custom long_help is set, it will be returned. Otherwise, help text is generated
/// from the available_snapshots list.
pub fn long_help(&self) -> String {
if let Some(ref custom_help) = self.long_help {
return custom_help.clone();
}
let mut help = String::from(
"Specify a snapshot URL or let the command propose a default one.\n\nAvailable snapshot sources:\n",
);
for source in &self.available_snapshots {
help.push_str("- ");
help.push_str(source);
help.push('\n');
}
help.push_str(
"\nIf no URL is provided, the latest archive snapshot for the selected chain\nwill be proposed for download from ",
);
help.push_str(
self.default_chain_aware_base_url.as_deref().unwrap_or(&self.default_base_url),
);
help.push_str(
".\n\nLocal file:// URLs are also supported for extracting snapshots from disk.",
);
help
}
/// Add a snapshot source to the list
pub fn with_snapshot(mut self, source: impl Into<Cow<'static, str>>) -> Self {
self.available_snapshots.push(source.into());
self
}
/// Replace all snapshot sources
pub fn with_snapshots(mut self, sources: Vec<Cow<'static, str>>) -> Self {
self.available_snapshots = sources;
self
}
/// Set the default base URL, e.g. `https://downloads.merkle.io`.
pub fn with_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
self.default_base_url = url.into();
self
}
/// Set the default chain-aware base URL.
pub fn with_chain_aware_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
self.default_chain_aware_base_url = Some(url.into());
self
}
/// Builder: Set custom long help text, overriding the generated help
pub fn with_long_help(mut self, help: impl Into<String>) -> Self {
self.long_help = Some(help.into());
self
}
}
impl Default for DownloadDefaults {
fn default() -> Self {
Self::default_download_defaults()
}
}
#[derive(Debug, Parser)]
pub struct DownloadCommand<C: ChainSpecParser> {
#[command(flatten)]
env: EnvironmentArgs<C>,
/// Custom URL to download the snapshot from
#[arg(long, short, long_help = DownloadDefaults::get_global().long_help())]
url: Option<String>,
}
impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCommand<C> {
pub async fn execute<N>(self) -> Result<()> {
let data_dir = self.env.datadir.resolve_datadir(self.env.chain.chain());
fs::create_dir_all(&data_dir)?;
let url = match self.url {
Some(url) => url,
None => {
let url = get_latest_snapshot_url(self.env.chain.chain().id()).await?;
info!(target: "reth::cli", "Using default snapshot URL: {}", url);
url
}
};
info!(target: "reth::cli",
chain = %self.env.chain.chain(),
dir = ?data_dir.data_dir(),
url = %url,
"Starting snapshot download and extraction"
);
stream_and_extract(&url, data_dir.data_dir()).await?;
info!(target: "reth::cli", "Snapshot downloaded and extracted successfully");
Ok(())
}
}
impl<C: ChainSpecParser> DownloadCommand<C> {
/// Returns the underlying chain being used to run this command
pub fn chain_spec(&self) -> Option<&Arc<C::ChainSpec>> {
Some(&self.env.chain)
}
}
// Monitor process status and display progress every 100ms
// to avoid overwhelming stdout
struct DownloadProgress {
downloaded: u64,
total_size: u64,
last_displayed: Instant,
started_at: Instant,
}
impl DownloadProgress {
/// Creates new progress tracker with given total size
fn new(total_size: u64) -> Self {
let now = Instant::now();
Self { downloaded: 0, total_size, last_displayed: now, started_at: now }
}
/// Converts bytes to human readable format (B, KB, MB, GB)
fn format_size(size: u64) -> String {
let mut size = size as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < BYTE_UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.2} {}", size, BYTE_UNITS[unit_index])
}
/// Format duration as human readable string
fn format_duration(duration: Duration) -> String {
let secs = duration.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
/// Updates progress bar
fn update(&mut self, chunk_size: u64) -> Result<()> {
self.downloaded += chunk_size;
// Only update display at most 10 times per second for efficiency
if self.last_displayed.elapsed() >= Duration::from_millis(100) {
let formatted_downloaded = Self::format_size(self.downloaded);
let formatted_total = Self::format_size(self.total_size);
let progress = (self.downloaded as f64 / self.total_size as f64) * 100.0;
// Calculate ETA based on current speed
let elapsed = self.started_at.elapsed();
let eta = if self.downloaded > 0 {
let remaining = self.total_size.saturating_sub(self.downloaded);
let speed = self.downloaded as f64 / elapsed.as_secs_f64();
if speed > 0.0 {
Duration::from_secs_f64(remaining as f64 / speed)
} else {
Duration::ZERO
}
} else {
Duration::ZERO
};
let eta_str = Self::format_duration(eta);
// Pad with spaces to clear any previous longer line
print!(
"\rDownloading and extracting... {progress:.2}% ({formatted_downloaded} / {formatted_total}) ETA: {eta_str} ",
);
io::stdout().flush()?;
self.last_displayed = Instant::now();
}
Ok(())
}
}
/// Adapter to track progress while reading
struct ProgressReader<R> {
reader: R,
progress: DownloadProgress,
}
impl<R: Read> ProgressReader<R> {
fn new(reader: R, total_size: u64) -> Self {
Self { reader, progress: DownloadProgress::new(total_size) }
}
}
impl<R: Read> Read for ProgressReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let bytes = self.reader.read(buf)?;
if bytes > 0 &&
let Err(e) = self.progress.update(bytes as u64)
{
return Err(io::Error::other(e));
}
Ok(bytes)
}
}
/// Supported compression formats for snapshots
#[derive(Debug, Clone, Copy)]
enum CompressionFormat {
Lz4,
Zstd,
}
impl CompressionFormat {
/// Detect compression format from file extension
fn from_url(url: &str) -> Result<Self> {
let path =
Url::parse(url).map(|u| u.path().to_string()).unwrap_or_else(|_| url.to_string());
if path.ends_with(EXTENSION_TAR_LZ4) {
Ok(Self::Lz4)
} else if path.ends_with(EXTENSION_TAR_ZSTD) {
Ok(Self::Zstd)
} else {
Err(eyre::eyre!(
"Unsupported file format. Expected .tar.lz4 or .tar.zst, got: {}",
path
))
}
}
}
/// Extracts a compressed tar archive to the target directory with progress tracking.
fn extract_archive<R: Read>(
reader: R,
total_size: u64,
format: CompressionFormat,
target_dir: &Path,
) -> Result<()> {
let progress_reader = ProgressReader::new(reader, total_size);
match format {
CompressionFormat::Lz4 => {
let decoder = Decoder::new(progress_reader)?;
Archive::new(decoder).unpack(target_dir)?;
}
CompressionFormat::Zstd => {
let decoder = ZstdDecoder::new(progress_reader)?;
Archive::new(decoder).unpack(target_dir)?;
}
}
info!(target: "reth::cli", "Extraction complete.");
Ok(())
}
/// Extracts a snapshot from a local file.
fn extract_from_file(path: &Path, format: CompressionFormat, target_dir: &Path) -> Result<()> {
let file = std::fs::File::open(path)?;
let total_size = file.metadata()?.len();
extract_archive(file, total_size, format, target_dir)
}
const MAX_DOWNLOAD_RETRIES: u32 = 10;
const RETRY_BACKOFF_SECS: u64 = 5;
/// Wrapper that tracks download progress while writing data.
/// Used with [`io::copy`] to display progress during downloads.
struct ProgressWriter<W> {
inner: W,
progress: DownloadProgress,
}
impl<W: Write> Write for ProgressWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.inner.write(buf)?;
let _ = self.progress.update(n as u64);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
/// Downloads a file with resume support using HTTP Range requests.
/// Automatically retries on failure, resuming from where it left off.
/// Returns the path to the downloaded file and its total size.
fn resumable_download(url: &str, target_dir: &Path) -> Result<(PathBuf, u64)> {
let file_name = Url::parse(url)
.ok()
.and_then(|u| u.path_segments()?.next_back().map(|s| s.to_string()))
.unwrap_or_else(|| "snapshot.tar".to_string());
let final_path = target_dir.join(&file_name);
let part_path = target_dir.join(format!("{file_name}.part"));
let client = BlockingClient::builder().timeout(Duration::from_secs(30)).build()?;
let mut total_size: Option<u64> = None;
let mut last_error: Option<eyre::Error> = None;
let finalize_download = |size: u64| -> Result<(PathBuf, u64)> {
fs::rename(&part_path, &final_path)?;
info!(target: "reth::cli", "Download complete: {}", final_path.display());
Ok((final_path.clone(), size))
};
for attempt in 1..=MAX_DOWNLOAD_RETRIES {
let existing_size = fs::metadata(&part_path).map(|m| m.len()).unwrap_or(0);
if let Some(total) = total_size &&
existing_size >= total
{
return finalize_download(total);
}
if attempt > 1 {
info!(target: "reth::cli",
"Retry attempt {}/{} - resuming from {} bytes",
attempt, MAX_DOWNLOAD_RETRIES, existing_size
);
}
let mut request = client.get(url);
if existing_size > 0 {
request = request.header(RANGE, format!("bytes={existing_size}-"));
if attempt == 1 {
info!(target: "reth::cli", "Resuming download from {} bytes", existing_size);
}
}
let response = match request.send().and_then(|r| r.error_for_status()) {
Ok(r) => r,
Err(e) => {
last_error = Some(e.into());
if attempt < MAX_DOWNLOAD_RETRIES {
info!(target: "reth::cli",
"Download failed, retrying in {} seconds...", RETRY_BACKOFF_SECS
);
std::thread::sleep(Duration::from_secs(RETRY_BACKOFF_SECS));
}
continue;
}
};
let is_partial = response.status() == StatusCode::PARTIAL_CONTENT;
let size = if is_partial {
response
.headers()
.get("Content-Range")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split('/').next_back())
.and_then(|v| v.parse().ok())
} else {
response.content_length()
};
if total_size.is_none() {
total_size = size;
}
let current_total = total_size.ok_or_else(|| {
eyre::eyre!("Server did not provide Content-Length or Content-Range header")
})?;
let file = if is_partial && existing_size > 0 {
OpenOptions::new()
.append(true)
.open(&part_path)
.map_err(|e| fs::FsPathError::open(e, &part_path))?
} else {
fs::create_file(&part_path)?
};
let start_offset = if is_partial { existing_size } else { 0 };
let mut progress = DownloadProgress::new(current_total);
progress.downloaded = start_offset;
let mut writer = ProgressWriter { inner: BufWriter::new(file), progress };
let mut reader = response;
let copy_result = io::copy(&mut reader, &mut writer);
let flush_result = writer.inner.flush();
println!();
if let Err(e) = copy_result.and(flush_result) {
last_error = Some(e.into());
if attempt < MAX_DOWNLOAD_RETRIES {
info!(target: "reth::cli",
"Download interrupted, retrying in {} seconds...", RETRY_BACKOFF_SECS
);
std::thread::sleep(Duration::from_secs(RETRY_BACKOFF_SECS));
}
continue;
}
return finalize_download(current_total);
}
Err(last_error
.unwrap_or_else(|| eyre::eyre!("Download failed after {} attempts", MAX_DOWNLOAD_RETRIES)))
}
/// Fetches the snapshot from a remote URL with resume support, then extracts it.
fn download_and_extract(url: &str, format: CompressionFormat, target_dir: &Path) -> Result<()> {
let (downloaded_path, total_size) = resumable_download(url, target_dir)?;
info!(target: "reth::cli", "Extracting snapshot...");
let file = fs::open(&downloaded_path)?;
extract_archive(file, total_size, format, target_dir)?;
fs::remove_file(&downloaded_path)?;
info!(target: "reth::cli", "Removed downloaded archive");
Ok(())
}
/// Downloads and extracts a snapshot, blocking until finished.
///
/// Supports both `file://` URLs for local files and HTTP(S) URLs for remote downloads.
fn blocking_download_and_extract(url: &str, target_dir: &Path) -> Result<()> {
let format = CompressionFormat::from_url(url)?;
if let Ok(parsed_url) = Url::parse(url) &&
parsed_url.scheme() == "file"
{
let file_path = parsed_url
.to_file_path()
.map_err(|_| eyre::eyre!("Invalid file:// URL path: {}", url))?;
extract_from_file(&file_path, format, target_dir)
} else {
download_and_extract(url, format, target_dir)
}
}
async fn stream_and_extract(url: &str, target_dir: &Path) -> Result<()> {
let target_dir = target_dir.to_path_buf();
let url = url.to_string();
task::spawn_blocking(move || blocking_download_and_extract(&url, &target_dir)).await??;
Ok(())
}
// Builds default URL for latest mainnet archive snapshot using configured defaults
async fn get_latest_snapshot_url(chain_id: u64) -> Result<String> {
let defaults = DownloadDefaults::get_global();
let base_url = match &defaults.default_chain_aware_base_url {
Some(url) => format!("{url}/{chain_id}"),
None => defaults.default_base_url.to_string(),
};
let latest_url = format!("{base_url}/latest.txt");
let filename = Client::new()
.get(latest_url)
.send()
.await?
.error_for_status()?
.text()
.await?
.trim()
.to_string();
Ok(format!("{base_url}/{filename}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_download_defaults_builder() {
let defaults = DownloadDefaults::default()
.with_snapshot("https://example.com/snapshots (example)")
.with_base_url("https://example.com");
assert_eq!(defaults.default_base_url, "https://example.com");
assert_eq!(defaults.available_snapshots.len(), 3); // 2 defaults + 1 added
}
#[test]
fn test_download_defaults_replace_snapshots() {
let defaults = DownloadDefaults::default().with_snapshots(vec![
Cow::Borrowed("https://custom1.com"),
Cow::Borrowed("https://custom2.com"),
]);
assert_eq!(defaults.available_snapshots.len(), 2);
assert_eq!(defaults.available_snapshots[0], "https://custom1.com");
}
#[test]
fn test_long_help_generation() {
let defaults = DownloadDefaults::default();
let help = defaults.long_help();
assert!(help.contains("Available snapshot sources:"));
assert!(help.contains("merkle.io"));
assert!(help.contains("publicnode.com"));
assert!(help.contains("file://"));
}
#[test]
fn test_long_help_override() {
let custom_help = "This is custom help text for downloading snapshots.";
let defaults = DownloadDefaults::default().with_long_help(custom_help);
let help = defaults.long_help();
assert_eq!(help, custom_help);
assert!(!help.contains("Available snapshot sources:"));
}
#[test]
fn test_builder_chaining() {
let defaults = DownloadDefaults::default()
.with_base_url("https://custom.example.com")
.with_snapshot("https://snapshot1.com")
.with_snapshot("https://snapshot2.com")
.with_long_help("Custom help for snapshots");
assert_eq!(defaults.default_base_url, "https://custom.example.com");
assert_eq!(defaults.available_snapshots.len(), 4); // 2 defaults + 2 added
assert_eq!(defaults.long_help, Some("Custom help for snapshots".to_string()));
}
#[test]
fn test_compression_format_detection() {
assert!(matches!(
CompressionFormat::from_url("https://example.com/snapshot.tar.lz4"),
Ok(CompressionFormat::Lz4)
));
assert!(matches!(
CompressionFormat::from_url("https://example.com/snapshot.tar.zst"),
Ok(CompressionFormat::Zstd)
));
assert!(matches!(
CompressionFormat::from_url("file:///path/to/snapshot.tar.lz4"),
Ok(CompressionFormat::Lz4)
));
assert!(matches!(
CompressionFormat::from_url("file:///path/to/snapshot.tar.zst"),
Ok(CompressionFormat::Zstd)
));
assert!(CompressionFormat::from_url("https://example.com/snapshot.tar.gz").is_err());
}
}

View File

@@ -1,610 +0,0 @@
use crate::download::{
manifest::{ComponentManifest, ComponentSelection, SnapshotComponentType, SnapshotManifest},
SelectionPreset,
};
use reth_chainspec::{EthereumHardfork, EthereumHardforks};
use reth_config::config::{BlocksPerFileConfig, Config, PruneConfig, StaticFilesConfig};
use reth_db::tables;
use reth_db_api::transaction::{DbTx, DbTxMut};
use reth_node_core::args::DefaultPruningValues;
use reth_prune_types::{PruneCheckpoint, PruneMode, PruneSegment};
use reth_stages_types::StageCheckpoint;
use std::{collections::BTreeMap, path::Path};
use tracing::info;
/// Minimum blocks to keep for receipts, matching `--minimal` prune settings.
const MINIMUM_RECEIPTS_DISTANCE: u64 = 64;
/// Minimum blocks to keep for history/bodies, matching `--minimal` prune settings
/// (`MINIMUM_UNWIND_SAFE_DISTANCE`).
const MINIMUM_HISTORY_DISTANCE: u64 = 10064;
/// Writes a [`Config`] as TOML to `<data_dir>/reth.toml`.
///
/// If the file already exists, it is not overwritten. Returns `true` if the file was written.
pub fn write_config(config: &Config, data_dir: &Path) -> eyre::Result<bool> {
let config_path = data_dir.join("reth.toml");
if config_path.exists() {
info!(target: "reth::cli",
path = ?config_path,
"reth.toml already exists, skipping config generation"
);
return Ok(false);
}
let toml_str = toml::to_string_pretty(config)?;
reth_fs_util::write(&config_path, toml_str)?;
info!(target: "reth::cli",
path = ?config_path,
"Generated reth.toml based on downloaded components"
);
Ok(true)
}
/// Writes prune checkpoints to the provided write transaction.
pub(crate) fn write_prune_checkpoints_tx<Tx>(
tx: &Tx,
config: &Config,
snapshot_block: u64,
) -> eyre::Result<()>
where
Tx: DbTx + DbTxMut,
{
let segments = &config.prune.segments;
// Collect (segment, mode) pairs for all configured prune segments
let checkpoints: Vec<(PruneSegment, PruneMode)> = [
(PruneSegment::SenderRecovery, segments.sender_recovery),
(PruneSegment::TransactionLookup, segments.transaction_lookup),
(PruneSegment::Receipts, segments.receipts),
(PruneSegment::AccountHistory, segments.account_history),
(PruneSegment::StorageHistory, segments.storage_history),
(PruneSegment::Bodies, segments.bodies_history),
]
.into_iter()
.filter_map(|(segment, mode)| mode.map(|m| (segment, m)))
.collect();
if checkpoints.is_empty() {
return Ok(());
}
// Look up the last tx number for the snapshot block from BlockBodyIndices
let tx_number =
tx.get::<tables::BlockBodyIndices>(snapshot_block)?.map(|indices| indices.last_tx_num());
for (segment, prune_mode) in &checkpoints {
let checkpoint = PruneCheckpoint {
block_number: Some(snapshot_block),
tx_number,
prune_mode: *prune_mode,
};
tx.put::<tables::PruneCheckpoints>(*segment, checkpoint)?;
info!(target: "reth::cli",
segment = %segment,
block = snapshot_block,
tx = ?tx_number,
mode = ?prune_mode,
"Set prune checkpoint"
);
}
Ok(())
}
/// Stage IDs for index stages whose output is stored in RocksDB and is never
/// distributed in snapshots.
const INDEX_STAGE_IDS: [&str; 3] =
["TransactionLookup", "IndexAccountHistory", "IndexStorageHistory"];
/// Prune segments that correspond to the index stages.
const INDEX_PRUNE_SEGMENTS: [PruneSegment; 3] =
[PruneSegment::TransactionLookup, PruneSegment::AccountHistory, PruneSegment::StorageHistory];
/// Resets stage and prune checkpoints for stages whose output is not included
/// in the snapshot inside an existing write transaction.
///
/// A snapshot's mdbx comes from a fully synced node, so it has stage checkpoints
/// at the tip for `TransactionLookup`, `IndexAccountHistory`, and
/// `IndexStorageHistory`. Since we don't distribute the rocksdb indices those
/// stages produced, we must reset their checkpoints to block 0. Otherwise the
/// pipeline would see "already done" and skip rebuilding entirely.
///
/// We intentionally do not reset `SenderRecovery`: sender static files are
/// distributed for archive downloads, and non-archive downloads rely on the
/// configured prune checkpoints for this segment.
pub(crate) fn reset_index_stage_checkpoints_tx<Tx>(tx: &Tx) -> eyre::Result<()>
where
Tx: DbTx + DbTxMut,
{
for stage_id in INDEX_STAGE_IDS {
tx.put::<tables::StageCheckpoints>(stage_id.to_string(), StageCheckpoint::default())?;
// Also clear any stage-specific progress data
tx.delete::<tables::StageCheckpointProgresses>(stage_id.to_string(), None)?;
info!(target: "reth::cli", stage = stage_id, "Reset stage checkpoint to block 0");
}
// Clear corresponding prune checkpoints so the pruner doesn't inherit
// state from the source node
for segment in INDEX_PRUNE_SEGMENTS {
tx.delete::<tables::PruneCheckpoints>(segment, None)?;
}
Ok(())
}
/// Generates a [`Config`] from per-component range selections.
///
/// When all data components are selected as `All`, no pruning is configured (archive node).
/// Otherwise, `--minimal` style pruning is applied for missing/partial components.
pub(crate) fn config_for_selections(
selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
manifest: &SnapshotManifest,
preset: Option<SelectionPreset>,
chain_spec: Option<&impl EthereumHardforks>,
) -> Config {
let selection_for = |ty| selections.get(&ty).copied().unwrap_or(ComponentSelection::None);
let tx_sel = selection_for(SnapshotComponentType::Transactions);
let senders_sel = selection_for(SnapshotComponentType::TransactionSenders);
let receipt_sel = selection_for(SnapshotComponentType::Receipts);
let account_cs_sel = selection_for(SnapshotComponentType::AccountChangesets);
let storage_cs_sel = selection_for(SnapshotComponentType::StorageChangesets);
// Archive node — all data components present, no pruning
let is_archive = [tx_sel, senders_sel, receipt_sel, account_cs_sel, storage_cs_sel]
.iter()
.all(|s| *s == ComponentSelection::All);
// Extract blocks_per_file from manifest for all component types
let blocks_per_file = |ty: SnapshotComponentType| -> Option<u64> {
match manifest.component(ty)? {
ComponentManifest::Chunked(c) => Some(c.blocks_per_file),
ComponentManifest::Single(_) => None,
}
};
let static_files = StaticFilesConfig {
blocks_per_file: BlocksPerFileConfig {
headers: blocks_per_file(SnapshotComponentType::Headers),
transactions: blocks_per_file(SnapshotComponentType::Transactions),
receipts: blocks_per_file(SnapshotComponentType::Receipts),
transaction_senders: blocks_per_file(SnapshotComponentType::TransactionSenders),
account_change_sets: blocks_per_file(SnapshotComponentType::AccountChangesets),
storage_change_sets: blocks_per_file(SnapshotComponentType::StorageChangesets),
},
};
if is_archive || matches!(preset, Some(SelectionPreset::Archive)) {
return Config { static_files, ..Default::default() };
}
if matches!(preset, Some(SelectionPreset::Full)) {
let defaults = DefaultPruningValues::get_global();
let mut segments = defaults.full_prune_modes.clone();
if defaults.full_bodies_history_use_pre_merge {
segments.bodies_history = chain_spec.and_then(|chain_spec| {
chain_spec
.ethereum_fork_activation(EthereumHardfork::Paris)
.block_number()
.map(PruneMode::Before)
});
}
return Config {
prune: PruneConfig { block_interval: PruneConfig::default().block_interval, segments },
static_files,
..Default::default()
};
}
let mut config = Config::default();
let mut prune = PruneConfig::default();
if senders_sel != ComponentSelection::All {
prune.segments.sender_recovery = Some(PruneMode::Full);
}
prune.segments.transaction_lookup = Some(PruneMode::Full);
if let Some(mode) = selection_to_prune_mode(tx_sel, Some(MINIMUM_HISTORY_DISTANCE)) {
prune.segments.bodies_history = Some(mode);
}
if let Some(mode) = selection_to_prune_mode(receipt_sel, Some(MINIMUM_RECEIPTS_DISTANCE)) {
prune.segments.receipts = Some(mode);
}
if let Some(mode) = selection_to_prune_mode(account_cs_sel, Some(MINIMUM_HISTORY_DISTANCE)) {
prune.segments.account_history = Some(mode);
}
if let Some(mode) = selection_to_prune_mode(storage_cs_sel, Some(MINIMUM_HISTORY_DISTANCE)) {
prune.segments.storage_history = Some(mode);
}
config.prune = prune;
config.static_files = static_files;
config
}
/// Converts a [`ComponentSelection`] to an optional [`PruneMode`].
///
/// `min_distance` enforces the minimum blocks required for this segment.
/// When set, `None` and distances below the minimum are clamped to it
/// instead of producing `PruneMode::Full` which reth would reject.
fn selection_to_prune_mode(
sel: ComponentSelection,
min_distance: Option<u64>,
) -> Option<PruneMode> {
match sel {
ComponentSelection::All => None,
ComponentSelection::Distance(d) => {
Some(PruneMode::Distance(min_distance.map_or(d, |min| d.max(min))))
}
ComponentSelection::None => Some(min_distance.map_or(PruneMode::Full, PruneMode::Distance)),
}
}
/// Human-readable prune config summary.
pub(crate) fn describe_prune_config(config: &Config) -> Vec<String> {
let segments = &config.prune.segments;
[
("sender_recovery", segments.sender_recovery),
("transaction_lookup", segments.transaction_lookup),
("bodies_history", segments.bodies_history),
("receipts", segments.receipts),
("account_history", segments.account_history),
("storage_history", segments.storage_history),
]
.into_iter()
.filter_map(|(name, mode)| mode.map(|m| format!("{name}={}", format_mode(&m))))
.collect()
}
fn format_mode(mode: &PruneMode) -> String {
match mode {
PruneMode::Full => "\"full\"".to_string(),
PruneMode::Distance(d) => format!("{{ distance = {d} }}"),
PruneMode::Before(b) => format!("{{ before = {b} }}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use reth_db::Database;
/// Empty manifest for tests that only care about prune config.
fn empty_manifest() -> SnapshotManifest {
SnapshotManifest {
block: 0,
chain_id: 1,
storage_version: 2,
timestamp: 0,
base_url: None,
reth_version: None,
components: BTreeMap::new(),
}
}
#[test]
fn write_prune_checkpoints_sets_all_segments() {
let dir = tempfile::tempdir().unwrap();
let db = reth_db::init_db(dir.path(), reth_db::mdbx::DatabaseArguments::default()).unwrap();
let mut selections = BTreeMap::new();
selections.insert(SnapshotComponentType::State, ComponentSelection::All);
selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
let config = config_for_selections(
&selections,
&empty_manifest(),
None,
None::<&reth_chainspec::ChainSpec>,
);
let snapshot_block = 21_000_000;
{
let tx = db.tx_mut().unwrap();
write_prune_checkpoints_tx(&tx, &config, snapshot_block).unwrap();
tx.commit().unwrap();
}
// Verify all expected segments have checkpoints
let tx = db.tx().unwrap();
for segment in [
PruneSegment::SenderRecovery,
PruneSegment::TransactionLookup,
PruneSegment::Receipts,
PruneSegment::AccountHistory,
PruneSegment::StorageHistory,
PruneSegment::Bodies,
] {
let checkpoint = tx
.get::<tables::PruneCheckpoints>(segment)
.unwrap()
.unwrap_or_else(|| panic!("expected checkpoint for {segment}"));
assert_eq!(checkpoint.block_number, Some(snapshot_block));
// No BlockBodyIndices in empty DB, so tx_number should be None
assert_eq!(checkpoint.tx_number, None);
}
}
#[test]
fn write_prune_checkpoints_archive_no_checkpoints() {
let dir = tempfile::tempdir().unwrap();
let db = reth_db::init_db(dir.path(), reth_db::mdbx::DatabaseArguments::default()).unwrap();
// Archive node — no pruning configured, so no checkpoints written
let mut selections = BTreeMap::new();
for ty in SnapshotComponentType::ALL {
selections.insert(ty, ComponentSelection::All);
}
let config = config_for_selections(
&selections,
&empty_manifest(),
None,
None::<&reth_chainspec::ChainSpec>,
);
{
let tx = db.tx_mut().unwrap();
write_prune_checkpoints_tx(&tx, &config, 21_000_000).unwrap();
tx.commit().unwrap();
}
let tx = db.tx().unwrap();
for segment in [PruneSegment::SenderRecovery, PruneSegment::TransactionLookup] {
assert!(
tx.get::<tables::PruneCheckpoints>(segment).unwrap().is_none(),
"expected no checkpoint for {segment} on archive node"
);
}
}
#[test]
fn selections_all_no_pruning() {
let mut selections = BTreeMap::new();
for ty in SnapshotComponentType::ALL {
selections.insert(ty, ComponentSelection::All);
}
let config = config_for_selections(
&selections,
&empty_manifest(),
None,
None::<&reth_chainspec::ChainSpec>,
);
// Archive node — nothing pruned
assert_eq!(config.prune.segments.transaction_lookup, None);
assert_eq!(config.prune.segments.sender_recovery, None);
assert_eq!(config.prune.segments.bodies_history, None);
assert_eq!(config.prune.segments.receipts, None);
assert_eq!(config.prune.segments.account_history, None);
assert_eq!(config.prune.segments.storage_history, None);
}
#[test]
fn selections_none_clamps_to_minimum_distance() {
let mut selections = BTreeMap::new();
selections.insert(SnapshotComponentType::State, ComponentSelection::All);
selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
let config = config_for_selections(
&selections,
&empty_manifest(),
None,
None::<&reth_chainspec::ChainSpec>,
);
assert_eq!(config.prune.segments.transaction_lookup, Some(PruneMode::Full));
assert_eq!(config.prune.segments.sender_recovery, Some(PruneMode::Full));
// All segments clamped to their minimum distances
assert_eq!(
config.prune.segments.bodies_history,
Some(PruneMode::Distance(MINIMUM_HISTORY_DISTANCE))
);
assert_eq!(
config.prune.segments.receipts,
Some(PruneMode::Distance(MINIMUM_RECEIPTS_DISTANCE))
);
assert_eq!(
config.prune.segments.account_history,
Some(PruneMode::Distance(MINIMUM_HISTORY_DISTANCE))
);
assert_eq!(
config.prune.segments.storage_history,
Some(PruneMode::Distance(MINIMUM_HISTORY_DISTANCE))
);
}
#[test]
fn selections_distance_maps_bodies_history() {
let mut selections = BTreeMap::new();
selections.insert(SnapshotComponentType::State, ComponentSelection::All);
selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
selections
.insert(SnapshotComponentType::Transactions, ComponentSelection::Distance(10_064));
selections.insert(SnapshotComponentType::Receipts, ComponentSelection::None);
selections
.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::Distance(10_064));
selections
.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::Distance(10_064));
let config = config_for_selections(
&selections,
&empty_manifest(),
None,
None::<&reth_chainspec::ChainSpec>,
);
assert_eq!(config.prune.segments.transaction_lookup, Some(PruneMode::Full));
assert_eq!(config.prune.segments.sender_recovery, Some(PruneMode::Full));
// Bodies follows tx selection
assert_eq!(config.prune.segments.bodies_history, Some(PruneMode::Distance(10_064)));
assert_eq!(
config.prune.segments.receipts,
Some(PruneMode::Distance(MINIMUM_RECEIPTS_DISTANCE))
);
assert_eq!(config.prune.segments.account_history, Some(PruneMode::Distance(10_064)));
assert_eq!(config.prune.segments.storage_history, Some(PruneMode::Distance(10_064)));
}
#[test]
fn full_preset_matches_default_full_prune_config() {
let mut selections = BTreeMap::new();
selections.insert(SnapshotComponentType::State, ComponentSelection::All);
selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
selections
.insert(SnapshotComponentType::Transactions, ComponentSelection::Distance(500_000));
selections.insert(SnapshotComponentType::Receipts, ComponentSelection::Distance(10_064));
let chain_spec = reth_chainspec::MAINNET.clone();
let config = config_for_selections(
&selections,
&empty_manifest(),
Some(SelectionPreset::Full),
Some(chain_spec.as_ref()),
);
assert_eq!(config.prune.segments.sender_recovery, Some(PruneMode::Full));
assert_eq!(config.prune.segments.transaction_lookup, None);
assert_eq!(
config.prune.segments.receipts,
Some(PruneMode::Distance(MINIMUM_HISTORY_DISTANCE))
);
assert_eq!(
config.prune.segments.account_history,
Some(PruneMode::Distance(MINIMUM_HISTORY_DISTANCE))
);
assert_eq!(
config.prune.segments.storage_history,
Some(PruneMode::Distance(MINIMUM_HISTORY_DISTANCE))
);
let paris_block = chain_spec
.ethereum_fork_activation(EthereumHardfork::Paris)
.block_number()
.expect("mainnet Paris block should be known");
assert_eq!(config.prune.segments.bodies_history, Some(PruneMode::Before(paris_block)));
}
#[test]
fn describe_selections_all_no_pruning() {
let mut selections = BTreeMap::new();
for ty in SnapshotComponentType::ALL {
selections.insert(ty, ComponentSelection::All);
}
let config = config_for_selections(
&selections,
&empty_manifest(),
None,
None::<&reth_chainspec::ChainSpec>,
);
let desc = describe_prune_config(&config);
// Archive node — no prune segments described
assert!(desc.is_empty());
}
#[test]
fn describe_selections_with_distances() {
let mut selections = BTreeMap::new();
selections.insert(SnapshotComponentType::State, ComponentSelection::All);
selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
selections
.insert(SnapshotComponentType::Transactions, ComponentSelection::Distance(10_064));
selections.insert(SnapshotComponentType::Receipts, ComponentSelection::None);
let config = config_for_selections(
&selections,
&empty_manifest(),
None,
None::<&reth_chainspec::ChainSpec>,
);
let desc = describe_prune_config(&config);
assert!(desc.contains(&"sender_recovery=\"full\"".to_string()));
// Bodies follows tx selection
assert!(desc.contains(&"bodies_history={ distance = 10064 }".to_string()));
assert!(desc.contains(&"receipts={ distance = 64 }".to_string()));
}
#[test]
fn reset_index_stage_checkpoints_clears_only_rocksdb_index_stages() {
let dir = tempfile::tempdir().unwrap();
let db = reth_db::init_db(dir.path(), reth_db::mdbx::DatabaseArguments::default()).unwrap();
// Simulate a fully synced node: set stage checkpoints at tip
let tip_checkpoint = StageCheckpoint::new(24_500_000);
{
let tx = db.tx_mut().unwrap();
for stage_id in INDEX_STAGE_IDS {
tx.put::<tables::StageCheckpoints>(stage_id.to_string(), tip_checkpoint).unwrap();
}
for segment in INDEX_PRUNE_SEGMENTS {
tx.put::<tables::PruneCheckpoints>(
segment,
PruneCheckpoint {
block_number: Some(24_500_000),
tx_number: None,
prune_mode: PruneMode::Full,
},
)
.unwrap();
}
// Sender recovery checkpoints should be preserved by reset.
tx.put::<tables::StageCheckpoints>("SenderRecovery".to_string(), tip_checkpoint)
.unwrap();
tx.put::<tables::PruneCheckpoints>(
PruneSegment::SenderRecovery,
PruneCheckpoint {
block_number: Some(24_500_000),
tx_number: None,
prune_mode: PruneMode::Full,
},
)
.unwrap();
tx.commit().unwrap();
}
// Reset
{
let tx = db.tx_mut().unwrap();
reset_index_stage_checkpoints_tx(&tx).unwrap();
tx.commit().unwrap();
}
// Verify stage checkpoints are at block 0
let tx = db.tx().unwrap();
for stage_id in INDEX_STAGE_IDS {
let checkpoint = tx
.get::<tables::StageCheckpoints>(stage_id.to_string())
.unwrap()
.expect("checkpoint should exist");
assert_eq!(checkpoint.block_number, 0, "stage {stage_id} should be reset to block 0");
}
// Verify prune checkpoints are deleted
for segment in INDEX_PRUNE_SEGMENTS {
assert!(
tx.get::<tables::PruneCheckpoints>(segment).unwrap().is_none(),
"prune checkpoint for {segment} should be deleted"
);
}
// Verify sender checkpoints are left untouched.
let sender_stage_checkpoint = tx
.get::<tables::StageCheckpoints>("SenderRecovery".to_string())
.unwrap()
.expect("sender checkpoint should exist");
assert_eq!(sender_stage_checkpoint.block_number, tip_checkpoint.block_number);
let sender_prune_checkpoint = tx
.get::<tables::PruneCheckpoints>(PruneSegment::SenderRecovery)
.unwrap()
.expect("sender prune checkpoint should exist");
assert_eq!(sender_prune_checkpoint.block_number, Some(tip_checkpoint.block_number));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,232 +0,0 @@
use crate::download::manifest::generate_manifest;
use clap::Parser;
use eyre::{Result, WrapErr};
use reth_db::{mdbx::DatabaseArguments, open_db_read_only, tables, Database};
use reth_db_api::transaction::DbTx;
use reth_stages_types::StageId;
use reth_static_file_types::DEFAULT_BLOCKS_PER_STATIC_FILE;
use std::{path::PathBuf, time::Instant};
use tracing::{info, warn};
/// Generate modular chunk archives and a snapshot manifest from a source datadir.
///
/// Archive naming convention:
/// - Chunked: `{component}-{start}-{end}.tar.zst` (e.g. `transactions-0-499999.tar.zst`)
#[derive(Debug, Parser)]
pub struct SnapshotManifestCommand {
/// Source datadir containing static files.
#[arg(long, short = 'd')]
source_datadir: PathBuf,
/// Optional base URL where archives will be hosted.
#[arg(long)]
base_url: Option<String>,
/// Output directory where chunk archives and manifest.json are written.
#[arg(long, short = 'o')]
output_dir: PathBuf,
/// Block number this snapshot was taken at.
///
/// If omitted, this is inferred from the source datadir's `Finish` stage checkpoint.
#[arg(long)]
block: Option<u64>,
/// Chain ID.
#[arg(long, default_value = "1")]
chain_id: u64,
/// Blocks per archive file for chunked components.
///
/// If omitted, this is inferred from header static file ranges in the source datadir.
#[arg(long)]
blocks_per_file: Option<u64>,
}
impl SnapshotManifestCommand {
pub fn execute(self) -> Result<()> {
let block = match self.block {
Some(block) => block,
None => infer_snapshot_block(&self.source_datadir)?,
};
let blocks_per_file = match self.blocks_per_file {
Some(blocks_per_file) => blocks_per_file,
None => infer_blocks_per_file(&self.source_datadir)?,
};
info!(target: "reth::cli",
dir = ?self.source_datadir,
output = ?self.output_dir,
block,
blocks_per_file,
"Packaging modular snapshot archives"
);
let start = Instant::now();
let manifest = generate_manifest(
&self.source_datadir,
&self.output_dir,
self.base_url.as_deref(),
block,
self.chain_id,
blocks_per_file,
)?;
let num_components = manifest.components.len();
let json = serde_json::to_string_pretty(&manifest)?;
let output = self.output_dir.join("manifest.json");
reth_fs_util::write(&output, &json)?;
info!(target: "reth::cli",
path = ?output,
components = num_components,
block = manifest.block,
elapsed = ?start.elapsed(),
"Manifest written"
);
Ok(())
}
}
fn infer_snapshot_block(source_datadir: &std::path::Path) -> Result<u64> {
if let Ok(block) = infer_snapshot_block_from_db(source_datadir) {
return Ok(block);
}
let block = infer_snapshot_block_from_headers(source_datadir)?;
warn!(
target: "reth::cli",
block,
"Could not read Finish stage checkpoint from source DB, using header static-file tip"
);
Ok(block)
}
fn infer_snapshot_block_from_db(source_datadir: &std::path::Path) -> Result<u64> {
let candidates = [source_datadir.join("db"), source_datadir.to_path_buf()];
for db_path in candidates {
if !db_path.exists() {
continue;
}
let db = match open_db_read_only(&db_path, DatabaseArguments::default()) {
Ok(db) => db,
Err(_) => continue,
};
let tx = db.tx()?;
if let Some(checkpoint) = tx.get::<tables::StageCheckpoints>(StageId::Finish.to_string())? {
return Ok(checkpoint.block_number);
}
}
eyre::bail!(
"Could not infer --block from source DB (Finish checkpoint missing); pass --block manually"
)
}
fn infer_snapshot_block_from_headers(source_datadir: &std::path::Path) -> Result<u64> {
let max_end = header_ranges(source_datadir)?
.into_iter()
.map(|(_, end)| end)
.max()
.ok_or_else(|| eyre::eyre!("No header static files found to infer --block"))?;
Ok(max_end)
}
fn infer_blocks_per_file(source_datadir: &std::path::Path) -> Result<u64> {
let mut inferred = None;
for (start, end) in header_ranges(source_datadir)? {
let span = end.saturating_sub(start).saturating_add(1);
if span == 0 {
continue;
}
if let Some(existing) = inferred {
if existing != span {
eyre::bail!(
"Inconsistent header static file ranges; pass --blocks-per-file manually"
);
}
} else {
inferred = Some(span);
}
}
inferred.ok_or_else(|| {
eyre::eyre!(
"Could not infer --blocks-per-file from header static files; pass it manually (default is {DEFAULT_BLOCKS_PER_STATIC_FILE})"
)
})
}
fn header_ranges(source_datadir: &std::path::Path) -> Result<Vec<(u64, u64)>> {
let static_files_dir = source_datadir.join("static_files");
let static_files_dir =
if static_files_dir.exists() { static_files_dir } else { source_datadir.to_path_buf() };
let entries = std::fs::read_dir(&static_files_dir).wrap_err_with(|| {
format!("Failed to read static files directory: {}", static_files_dir.display())
})?;
let mut ranges = Vec::new();
for entry in entries {
let entry = entry?;
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
if let Some(range) = parse_headers_range(&file_name) {
ranges.push(range);
}
}
Ok(ranges)
}
fn parse_headers_range(file_name: &str) -> Option<(u64, u64)> {
let remainder = file_name.strip_prefix("static_file_headers_")?;
let (start, end_with_suffix) = remainder.split_once('_')?;
let start = start.parse::<u64>().ok()?;
let end_digits: String = end_with_suffix.chars().take_while(|ch| ch.is_ascii_digit()).collect();
let end = end_digits.parse::<u64>().ok()?;
Some((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parse_headers_range_works_with_suffixes() {
assert_eq!(parse_headers_range("static_file_headers_0_499999"), Some((0, 499_999)));
assert_eq!(
parse_headers_range("static_file_headers_500000_999999.jar"),
Some((500_000, 999_999))
);
assert_eq!(parse_headers_range("static_file_transactions_0_499999"), None);
}
#[test]
fn infer_blocks_per_file_from_header_ranges() {
let dir = tempdir().unwrap();
let sf = dir.path().join("static_files");
std::fs::create_dir_all(&sf).unwrap();
std::fs::write(sf.join("static_file_headers_0_499999"), []).unwrap();
std::fs::write(sf.join("static_file_headers_500000_999999.jar"), []).unwrap();
assert_eq!(infer_blocks_per_file(dir.path()).unwrap(), 500_000);
}
#[test]
fn infer_snapshot_block_from_headers_uses_max_end() {
let dir = tempdir().unwrap();
let sf = dir.path().join("static_files");
std::fs::create_dir_all(&sf).unwrap();
std::fs::write(sf.join("static_file_headers_0_499999"), []).unwrap();
std::fs::write(sf.join("static_file_headers_500000_999999"), []).unwrap();
assert_eq!(infer_snapshot_block_from_headers(dir.path()).unwrap(), 999_999);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,437 +0,0 @@
use crate::download::{
manifest::{ComponentSelection, SnapshotComponentType, SnapshotManifest},
DownloadProgress, SelectionPreset,
};
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame, Terminal,
};
use std::{
collections::BTreeMap,
io,
time::{Duration, Instant},
};
/// Result of the interactive component selector.
pub struct SelectorOutput {
/// User-confirmed selections with per-component ranges.
pub selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
/// Last preset action used in the TUI, if any.
pub preset: Option<SelectionPreset>,
}
/// All distance presets. Groups filter this to only valid options.
const DISTANCE_PRESETS: [ComponentSelection; 6] = [
ComponentSelection::None,
ComponentSelection::Distance(64),
ComponentSelection::Distance(10_064),
ComponentSelection::Distance(100_000),
ComponentSelection::Distance(1_000_000),
ComponentSelection::All,
];
/// Presets for components that require at least 64 blocks (receipts).
const RECEIPTS_PRESETS: [ComponentSelection; 5] = [
ComponentSelection::Distance(64),
ComponentSelection::Distance(10_064),
ComponentSelection::Distance(100_000),
ComponentSelection::Distance(1_000_000),
ComponentSelection::All,
];
/// Presets for components that require at least 10064 blocks (account/storage history).
const HISTORY_PRESETS: [ComponentSelection; 4] = [
ComponentSelection::Distance(10_064),
ComponentSelection::Distance(100_000),
ComponentSelection::Distance(1_000_000),
ComponentSelection::All,
];
/// A display group bundles one or more component types into a single TUI row.
struct DisplayGroup {
/// Display name shown in the TUI.
name: &'static str,
/// Underlying component types this group controls.
types: Vec<SnapshotComponentType>,
/// Whether this group is required and locked to All.
required: bool,
/// Valid presets for this group. Components with minimum distance requirements
/// exclude presets that would produce invalid prune configs.
presets: &'static [ComponentSelection],
}
/// Build the display groups from available components in the manifest.
fn build_groups(manifest: &SnapshotManifest) -> Vec<DisplayGroup> {
let has = |ty: SnapshotComponentType| manifest.component(ty).is_some();
let mut groups = Vec::new();
if has(SnapshotComponentType::State) {
groups.push(DisplayGroup {
name: "State (mdbx)",
types: vec![SnapshotComponentType::State],
required: true,
presets: &DISTANCE_PRESETS,
});
}
if has(SnapshotComponentType::Headers) {
groups.push(DisplayGroup {
name: "Headers",
types: vec![SnapshotComponentType::Headers],
required: true,
presets: &DISTANCE_PRESETS,
});
}
if has(SnapshotComponentType::Transactions) {
groups.push(DisplayGroup {
name: "Transactions",
types: vec![SnapshotComponentType::Transactions],
required: false,
presets: &HISTORY_PRESETS,
});
}
if has(SnapshotComponentType::Receipts) {
groups.push(DisplayGroup {
name: "Receipts",
types: vec![SnapshotComponentType::Receipts],
required: false,
presets: &RECEIPTS_PRESETS,
});
}
// Bundle account + storage changesets as "State History"
let has_acc = has(SnapshotComponentType::AccountChangesets);
let has_stor = has(SnapshotComponentType::StorageChangesets);
if has_acc || has_stor {
let mut types = Vec::new();
if has_acc {
types.push(SnapshotComponentType::AccountChangesets);
}
if has_stor {
types.push(SnapshotComponentType::StorageChangesets);
}
groups.push(DisplayGroup {
name: "State History",
types,
required: false,
presets: &HISTORY_PRESETS,
});
}
groups
}
struct SelectorApp {
manifest: SnapshotManifest,
full_preset: BTreeMap<SnapshotComponentType, ComponentSelection>,
/// Display groups shown in the TUI.
groups: Vec<DisplayGroup>,
/// Current selection for each group.
selections: Vec<ComponentSelection>,
/// Last preset action invoked by user.
preset: Option<SelectionPreset>,
/// Current cursor position.
cursor: usize,
/// List state for ratatui.
list_state: ListState,
}
impl SelectorApp {
fn new(
manifest: SnapshotManifest,
full_preset: BTreeMap<SnapshotComponentType, ComponentSelection>,
) -> Self {
let groups = build_groups(&manifest);
// Default to the minimal preset (matches --minimal prune config)
let selections = groups.iter().map(|g| g.types[0].minimal_selection()).collect();
let mut list_state = ListState::default();
list_state.select(Some(0));
Self {
manifest,
full_preset,
groups,
selections,
preset: Some(SelectionPreset::Minimal),
cursor: 0,
list_state,
}
}
fn cycle_right(&mut self) {
if let Some(group) = self.groups.get(self.cursor) {
if group.required {
return;
}
let presets = group.presets;
let current = self.selections[self.cursor];
let idx = presets.iter().position(|p| *p == current).unwrap_or(0);
self.selections[self.cursor] = presets[(idx + 1) % presets.len()];
self.preset = None;
}
}
fn cycle_left(&mut self) {
if let Some(group) = self.groups.get(self.cursor) {
if group.required {
return;
}
let presets = group.presets;
let current = self.selections[self.cursor];
let idx = presets.iter().position(|p| *p == current).unwrap_or(0);
self.selections[self.cursor] = presets[(idx + presets.len() - 1) % presets.len()];
self.preset = None;
}
}
fn select_all(&mut self) {
for sel in &mut self.selections {
*sel = ComponentSelection::All;
}
self.preset = Some(SelectionPreset::Archive);
}
fn select_minimal(&mut self) {
for (i, group) in self.groups.iter().enumerate() {
self.selections[i] = group.types[0].minimal_selection();
}
self.preset = Some(SelectionPreset::Minimal);
}
fn select_full(&mut self) {
for (i, group) in self.groups.iter().enumerate() {
let mut selection = group.types[0].minimal_selection();
for ty in &group.types {
if let Some(sel) = self.full_preset.get(ty).copied() {
selection = sel;
break;
}
}
self.selections[i] = selection;
}
self.preset = Some(SelectionPreset::Full);
}
fn move_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
} else {
self.cursor = self.groups.len().saturating_sub(1);
}
self.list_state.select(Some(self.cursor));
}
fn move_down(&mut self) {
if self.cursor < self.groups.len() - 1 {
self.cursor += 1;
} else {
self.cursor = 0;
}
self.list_state.select(Some(self.cursor));
}
/// Build the flat component→selection map from grouped selections.
fn selection_map(&self) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
let mut map = BTreeMap::new();
for (group, sel) in self.groups.iter().zip(&self.selections) {
for ty in &group.types {
map.insert(*ty, *sel);
}
}
map
}
/// Size for a single group, summing all component types in the group.
fn group_size(&self, group_idx: usize) -> u64 {
let sel = self.selections[group_idx];
let distance = match sel {
ComponentSelection::None => return 0,
ComponentSelection::All => None,
ComponentSelection::Distance(d) => Some(d),
};
self.groups[group_idx]
.types
.iter()
.map(|ty| self.manifest.size_for_distance(*ty, distance))
.sum()
}
fn total_selected_size(&self) -> u64 {
(0..self.groups.len()).map(|i| self.group_size(i)).sum()
}
}
/// Runs the interactive component selector TUI.
pub fn run_selector(
manifest: SnapshotManifest,
full_preset: &BTreeMap<SnapshotComponentType, ComponentSelection>,
) -> eyre::Result<SelectorOutput> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = SelectorApp::new(manifest, full_preset.clone());
let result = event_loop(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut SelectorApp,
) -> eyre::Result<SelectorOutput> {
let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| render(f, app))?;
let timeout =
tick_rate.checked_sub(last_tick.elapsed()).unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? &&
let Event::Key(key) = event::read()? &&
key.kind == event::KeyEventKind::Press
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
eyre::bail!("Download cancelled by user");
}
KeyCode::Enter => {
return Ok(SelectorOutput {
selections: app.selection_map(),
preset: app.preset,
});
}
KeyCode::Right | KeyCode::Char('l') | KeyCode::Char(' ') => app.cycle_right(),
KeyCode::Left | KeyCode::Char('h') => app.cycle_left(),
KeyCode::Char('a') => app.select_all(),
KeyCode::Char('f') => app.select_full(),
KeyCode::Char('m') => app.select_minimal(),
KeyCode::Up | KeyCode::Char('k') => app.move_up(),
KeyCode::Down | KeyCode::Char('j') => app.move_down(),
_ => {}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
}
}
fn format_selection(sel: &ComponentSelection) -> String {
match sel {
ComponentSelection::All => "All".to_string(),
ComponentSelection::Distance(d) => format!("Last {d} blocks"),
ComponentSelection::None => "None".to_string(),
}
}
fn render(f: &mut Frame<'_>, app: &mut SelectorApp) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(8), // Component list
Constraint::Length(3), // Footer
])
.split(f.area());
// Header
let block_info = if app.manifest.block > 0 {
format!(" (block {})", app.manifest.block)
} else {
String::new()
};
let header = Paragraph::new(format!(" Select snapshot components to download{}", block_info))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::ALL).title("reth download"));
f.render_widget(header, chunks[0]);
// Component list
let items: Vec<ListItem<'_>> = app
.groups
.iter()
.enumerate()
.map(|(i, group)| {
let sel = &app.selections[i];
let sel_str = format_selection(sel);
let size = app.group_size(i);
let size_str =
if size > 0 { DownloadProgress::format_size(size) } else { String::new() };
let required = if group.required { " (required)" } else { "" };
let at_max = *sel == *group.presets.last().unwrap_or(&ComponentSelection::All);
let at_min = *sel == group.presets[0];
let arrows = if group.required {
" "
} else if at_max {
""
} else if at_min {
""
} else {
"◂ ▸"
};
let style = if group.required {
Style::default().fg(Color::DarkGray)
} else if matches!(sel, ComponentSelection::None) {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::Green)
};
ListItem::new(Line::from(vec![
Span::styled(format!(" {:<22}", group.name), style),
Span::styled(
format!("{arrows} {:<12}", sel_str),
style.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:>10}", size_str), style.add_modifier(Modifier::DIM)),
Span::styled(required.to_string(), Style::default().fg(Color::DarkGray)),
]))
})
.collect();
let total_str = DownloadProgress::format_size(app.total_selected_size());
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("Components — Total: {total_str}")),
)
.highlight_style(Style::default().add_modifier(Modifier::BOLD).bg(Color::DarkGray))
.highlight_symbol("");
f.render_stateful_widget(list, chunks[1], &mut app.list_state);
// Footer
let footer = Paragraph::new(
" [←/→] adjust [m] minimal [f] full [a] archive [Enter] confirm [Esc] cancel",
)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::ALL));
f.render_widget(footer, chunks[2]);
}

View File

@@ -19,12 +19,11 @@ use reth_node_api::BlockTy;
use reth_node_events::node::NodeEvent;
use reth_provider::{
providers::ProviderNodeTypes, BlockNumReader, HeaderProvider, ProviderError, ProviderFactory,
RocksDBProviderFactory, StageCheckpointReader,
StageCheckpointReader,
};
use reth_prune::PruneModes;
use reth_stages::{prelude::*, ControlFlow, Pipeline, StageId, StageSet};
use reth_static_file::StaticFileProducer;
use reth_storage_api::StorageSettingsCache;
use std::{path::Path, sync::Arc};
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
@@ -109,11 +108,7 @@ where
let provider = provider_factory.provider()?;
let init_blocks = provider.tx_ref().entries::<tables::HeaderNumbers>()?;
let init_txns = if provider_factory.cached_storage_settings().storage_v2 {
provider_factory.rocksdb_provider().iter::<tables::TransactionHashNumbers>()?.count()
} else {
provider.tx_ref().entries::<tables::TransactionHashNumbers>()?
};
let init_txns = provider.tx_ref().entries::<tables::TransactionHashNumbers>()?;
drop(provider);
let mut total_decoded_blocks = 0;
@@ -220,12 +215,8 @@ where
let provider = provider_factory.provider()?;
let total_imported_blocks = provider.tx_ref().entries::<tables::HeaderNumbers>()? - init_blocks;
let current_txns = if provider_factory.cached_storage_settings().storage_v2 {
provider_factory.rocksdb_provider().iter::<tables::TransactionHashNumbers>()?.count()
} else {
provider.tx_ref().entries::<tables::TransactionHashNumbers>()?
};
let total_imported_txns = current_txns - init_txns;
let total_imported_txns =
provider.tx_ref().entries::<tables::TransactionHashNumbers>()? - init_txns;
let result = ImportResult {
total_decoded_blocks,

View File

@@ -47,7 +47,7 @@ pub struct InitStateCommand<C: ChainSpecParser> {
/// Specifies whether to initialize the state without relying on EVM historical data.
///
/// When enabled, and before inserting the state, it creates a dummy chain up to the last EVM
/// block specified. It then appends the first provided block.
/// block specified. It then, appends the first block provided block.
///
/// - **Note**: **Do not** import receipts and blocks beforehand, or this will fail or be
/// ignored.

View File

@@ -125,12 +125,12 @@ pub struct NodeCommand<C: ChainSpecParser, Ext: clap::Args + fmt::Debug = NoArgs
}
impl<C: ChainSpecParser> NodeCommand<C> {
/// Parses only the default CLI arguments
/// Parsers only the default CLI arguments
pub fn parse_args() -> Self {
Self::parse()
}
/// Parses only the default [`NodeCommand`] arguments from the given iterator
/// Parsers only the default [`NodeCommand`] arguments from the given iterator
pub fn try_parse_args_from<I, T>(itr: I) -> Result<Self, clap::error::Error>
where
I: IntoIterator<Item = T>,

View File

@@ -193,10 +193,7 @@ impl<C: ChainSpecParser> DownloadArgs<C> {
let default_secret_key_path = data_dir.p2p_secret();
let p2p_secret_key = self.network.secret_key(default_secret_key_path)?;
let rlpx_socket = (self.network.addr, self.network.port).into();
let boot_nodes = self
.network
.resolved_bootnodes()
.unwrap_or_else(|| self.chain.bootnodes().unwrap_or_default());
let boot_nodes = self.chain.bootnodes().unwrap_or_default();
let net =
NetworkConfigBuilder::<N::NetworkPrimitives>::new(p2p_secret_key, Runtime::test())

View File

@@ -12,6 +12,7 @@ use reth_node_metrics::{
server::{MetricServer, MetricServerConfig},
version::VersionInfo,
};
#[cfg(all(unix, feature = "rocksdb"))]
use reth_provider::RocksDBProviderFactory;
use reth_prune::PrunerBuilder;
use reth_static_file::StaticFileProducer;
@@ -121,6 +122,7 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> PruneComma
}
// Flush and compact RocksDB to reclaim disk space after pruning
#[cfg(all(unix, feature = "rocksdb"))]
{
info!(target: "reth::cli", "Flushing and compacting RocksDB...");
provider_factory.rocksdb_provider().flush_and_compact()?;

View File

@@ -95,10 +95,6 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
}
};
if min_block > max_block {
eyre::bail!("--from ({min_block}) is beyond --to ({max_block}), nothing to re-execute");
}
let num_tasks = self.num_tasks.unwrap_or_else(|| {
std::thread::available_parallelism().map(|n| n.get() as u64).unwrap_or(10)
});

View File

@@ -127,14 +127,12 @@ fn unwind_and_copy<N: ProviderNodeTypes>(
AccountHashingStage {
clean_threshold: u64::MAX,
commit_threshold: u64::MAX,
commit_entries: u64::MAX,
etl_config: EtlConfig::default(),
}
.execute(&provider, execute_input)?;
StorageHashingStage {
clean_threshold: u64::MAX,
commit_threshold: u64::MAX,
commit_entries: u64::MAX,
etl_config: EtlConfig::default(),
}
.execute(&provider, execute_input)?;

View File

@@ -107,7 +107,7 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
Comp: CliNodeComponents<N>,
F: FnOnce(Arc<C::ChainSpec>) -> Comp,
{
// Quit early if the stage requires a commit and `--commit` is not provided.
// Quit early if the stages requires a commit and `--commit` is not provided.
if self.requires_commit() && !self.commit {
return Err(eyre::eyre!(
"The stage {} requires overwriting existing static files and must commit, but `--commit` was not provided. Please pass `--commit` and try again.",
@@ -282,22 +282,14 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
),
StageEnum::AccountHashing => (
Box::new(AccountHashingStage::new(
HashingConfig {
clean_threshold: 1,
commit_threshold: batch_size,
commit_entries: u64::MAX,
},
HashingConfig { clean_threshold: 1, commit_threshold: batch_size },
etl_config,
)),
None,
),
StageEnum::StorageHashing => (
Box::new(StorageHashingStage::new(
HashingConfig {
clean_threshold: 1,
commit_threshold: batch_size,
commit_entries: u64::MAX,
},
HashingConfig { clean_threshold: 1, commit_threshold: batch_size },
etl_config,
)),
None,

View File

@@ -71,12 +71,7 @@ impl CliRunner {
) -> Result<(), E>
where
F: Future<Output = Result<(), E>>,
E: Send
+ Sync
+ std::fmt::Display
+ From<std::io::Error>
+ From<reth_tasks::PanickedTaskError>
+ 'static,
E: Send + Sync + From<std::io::Error> + From<reth_tasks::PanickedTaskError> + 'static,
{
let (context, task_manager_handle) = cli_context(&self.runtime);
@@ -86,8 +81,8 @@ impl CliRunner {
run_until_ctrl_c(command(context)),
));
if let Err(err) = &command_res {
error!(target: "reth::cli", %err, "shutting down due to error");
if command_res.is_err() {
error!(target: "reth::cli", "shutting down due to error");
} else {
debug!(target: "reth::cli", "shutting down gracefully");
// after the command has finished or exit signal was received we shutdown the
@@ -110,12 +105,7 @@ impl CliRunner {
) -> Result<(), E>
where
F: Future<Output = Result<(), E>> + Send + 'static,
E: Send
+ Sync
+ std::fmt::Display
+ From<std::io::Error>
+ From<reth_tasks::PanickedTaskError>
+ 'static,
E: Send + Sync + From<std::io::Error> + From<reth_tasks::PanickedTaskError> + 'static,
{
let (context, task_manager_handle) = cli_context(&self.runtime);
@@ -132,8 +122,8 @@ impl CliRunner {
),
));
if let Err(err) = &command_res {
error!(target: "reth::cli", %err, "shutting down due to error");
if command_res.is_err() {
error!(target: "reth::cli", "shutting down due to error");
} else {
debug!(target: "reth::cli", "shutting down gracefully");
self.runtime.graceful_shutdown_with_timeout(self.config.graceful_shutdown_timeout);

View File

@@ -332,15 +332,11 @@ pub struct HashingConfig {
pub clean_threshold: u64,
/// The maximum number of entities to process before committing progress to the database.
pub commit_threshold: u64,
/// The maximum number of changeset entries to process before committing progress. The stage
/// commits after either `commit_threshold` blocks or `commit_entries` entries, whichever
/// comes first. This bounds memory usage when blocks contain many state changes.
pub commit_entries: u64,
}
impl Default for HashingConfig {
fn default() -> Self {
Self { clean_threshold: 500_000, commit_threshold: 100_000, commit_entries: 30_000_000 }
Self { clean_threshold: 500_000, commit_threshold: 100_000 }
}
}

View File

@@ -75,3 +75,8 @@ path = "tests/e2e-testsuite/main.rs"
[[test]]
name = "rocksdb"
path = "tests/rocksdb/main.rs"
required-features = ["rocksdb"]
[features]
rocksdb = ["reth-node-core/rocksdb", "reth-provider/rocksdb", "reth-cli-commands/rocksdb"]
edge = ["rocksdb"]

View File

@@ -1,5 +1,7 @@
//! E2E tests for `RocksDB` provider functionality.
#![cfg(all(feature = "rocksdb", unix))]
use alloy_consensus::BlockHeader;
use alloy_primitives::B256;
use alloy_rpc_types_eth::{Transaction, TransactionReceipt};

View File

@@ -39,7 +39,6 @@ thiserror.workspace = true
[features]
default = ["std"]
trie-debug = []
std = [
"reth-execution-types/std",
"reth-ethereum-primitives/std",

View File

@@ -26,15 +26,10 @@ pub const DEFAULT_RESERVED_CPU_CORES: usize = 1;
/// Depth 4 means we keep roughly 16^4 = 65536 potential branch paths at most.
pub const DEFAULT_SPARSE_TRIE_PRUNE_DEPTH: usize = 4;
/// Default LFU hot-slot capacity for sparse trie pruning.
/// Default maximum number of storage tries to keep after pruning.
///
/// Limits the number of `(address, slot)` pairs retained across prune cycles.
pub const DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS: usize = 1500;
/// Default LFU hot-account capacity for sparse trie pruning.
///
/// Limits the number of account addresses retained across prune cycles.
pub const DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS: usize = 1000;
/// Storage tries beyond this limit are cleared (but allocations preserved).
pub const DEFAULT_SPARSE_TRIE_MAX_STORAGE_TRIES: usize = 100;
/// Default timeout for the state root task before spawning a sequential fallback.
pub const DEFAULT_STATE_ROOT_TASK_TIMEOUT: Duration = Duration::from_secs(1);
@@ -136,28 +131,15 @@ pub struct TreeConfig {
disable_cache_metrics: bool,
/// Depth for sparse trie pruning after state root computation.
sparse_trie_prune_depth: usize,
/// LFU hot-slot capacity: max `(address, slot)` pairs retained across prune cycles.
sparse_trie_max_hot_slots: usize,
/// LFU hot-account capacity: max account addresses retained across prune cycles.
sparse_trie_max_hot_accounts: usize,
/// When set, blocks whose total processing time (execution + state reads + state root +
/// DB commit) exceeds this duration trigger a structured `warn!` log with detailed timing,
/// state-operation counts, and cache hit-rate metrics. `Duration::ZERO` logs every block.
slow_block_threshold: Option<Duration>,
/// Maximum number of storage tries to retain after pruning.
sparse_trie_max_storage_tries: usize,
/// Whether to fully disable sparse trie cache pruning between blocks.
disable_sparse_trie_cache_pruning: bool,
/// Whether to use the arena-based sparse trie implementation.
enable_arena_sparse_trie: bool,
/// Timeout for the state root task before spawning a sequential fallback computation.
/// If `Some`, after waiting this duration for the state root task, a sequential state root
/// computation is spawned in parallel and whichever finishes first is used.
/// If `None`, the timeout fallback is disabled.
state_root_task_timeout: Option<Duration>,
/// Maximum random jitter applied before each proof computation (trie-debug only).
/// When set, each proof worker sleeps for a random duration up to this value
/// before starting a proof calculation.
#[cfg(feature = "trie-debug")]
proof_jitter: Option<Duration>,
}
impl Default for TreeConfig {
@@ -183,14 +165,9 @@ impl Default for TreeConfig {
allow_unwind_canonical_header: false,
disable_cache_metrics: false,
sparse_trie_prune_depth: DEFAULT_SPARSE_TRIE_PRUNE_DEPTH,
sparse_trie_max_hot_slots: DEFAULT_SPARSE_TRIE_MAX_HOT_SLOTS,
sparse_trie_max_hot_accounts: DEFAULT_SPARSE_TRIE_MAX_HOT_ACCOUNTS,
slow_block_threshold: None,
sparse_trie_max_storage_tries: DEFAULT_SPARSE_TRIE_MAX_STORAGE_TRIES,
disable_sparse_trie_cache_pruning: false,
enable_arena_sparse_trie: false,
state_root_task_timeout: Some(DEFAULT_STATE_ROOT_TASK_TIMEOUT),
#[cfg(feature = "trie-debug")]
proof_jitter: None,
}
}
}
@@ -219,9 +196,7 @@ impl TreeConfig {
allow_unwind_canonical_header: bool,
disable_cache_metrics: bool,
sparse_trie_prune_depth: usize,
sparse_trie_max_hot_slots: usize,
sparse_trie_max_hot_accounts: usize,
slow_block_threshold: Option<Duration>,
sparse_trie_max_storage_tries: usize,
state_root_task_timeout: Option<Duration>,
) -> Self {
Self {
@@ -245,14 +220,9 @@ impl TreeConfig {
allow_unwind_canonical_header,
disable_cache_metrics,
sparse_trie_prune_depth,
sparse_trie_max_hot_slots,
sparse_trie_max_hot_accounts,
slow_block_threshold,
sparse_trie_max_storage_tries,
disable_sparse_trie_cache_pruning: false,
enable_arena_sparse_trie: false,
state_root_task_timeout,
#[cfg(feature = "trie-debug")]
proof_jitter: None,
}
}
@@ -501,43 +471,14 @@ impl TreeConfig {
self
}
/// Returns the LFU hot-slot capacity for sparse trie pruning.
pub const fn sparse_trie_max_hot_slots(&self) -> usize {
self.sparse_trie_max_hot_slots
/// Returns the maximum number of storage tries to retain after pruning.
pub const fn sparse_trie_max_storage_tries(&self) -> usize {
self.sparse_trie_max_storage_tries
}
/// Setter for LFU hot-slot capacity.
pub const fn with_sparse_trie_max_hot_slots(mut self, max_hot_slots: usize) -> Self {
self.sparse_trie_max_hot_slots = max_hot_slots;
self
}
/// Returns the LFU hot-account capacity for sparse trie pruning.
pub const fn sparse_trie_max_hot_accounts(&self) -> usize {
self.sparse_trie_max_hot_accounts
}
/// Setter for LFU hot-account capacity.
pub const fn with_sparse_trie_max_hot_accounts(mut self, max_hot_accounts: usize) -> Self {
self.sparse_trie_max_hot_accounts = max_hot_accounts;
self
}
/// Returns the slow block threshold, if configured.
///
/// When `Some`, blocks whose total processing time exceeds this duration emit a structured
/// warning with timing, state-operation, and cache-hit-rate details. `Duration::ZERO` logs
/// every block.
pub const fn slow_block_threshold(&self) -> Option<Duration> {
self.slow_block_threshold
}
/// Setter for slow block threshold.
pub const fn with_slow_block_threshold(
mut self,
slow_block_threshold: Option<Duration>,
) -> Self {
self.slow_block_threshold = slow_block_threshold;
/// Setter for maximum storage tries to retain.
pub const fn with_sparse_trie_max_storage_tries(mut self, max_tries: usize) -> Self {
self.sparse_trie_max_storage_tries = max_tries;
self
}
@@ -552,17 +493,6 @@ impl TreeConfig {
self
}
/// Returns whether the arena-based sparse trie is enabled.
pub const fn enable_arena_sparse_trie(&self) -> bool {
self.enable_arena_sparse_trie
}
/// Setter for whether to enable the arena-based sparse trie.
pub const fn with_enable_arena_sparse_trie(mut self, value: bool) -> Self {
self.enable_arena_sparse_trie = value;
self
}
/// Returns the state root task timeout.
pub const fn state_root_task_timeout(&self) -> Option<Duration> {
self.state_root_task_timeout
@@ -573,17 +503,4 @@ impl TreeConfig {
self.state_root_task_timeout = timeout;
self
}
/// Returns the proof jitter duration, if configured (trie-debug only).
#[cfg(feature = "trie-debug")]
pub const fn proof_jitter(&self) -> Option<Duration> {
self.proof_jitter
}
/// Setter for proof jitter (trie-debug only).
#[cfg(feature = "trie-debug")]
pub const fn with_proof_jitter(mut self, proof_jitter: Option<Duration>) -> Self {
self.proof_jitter = proof_jitter;
self
}
}

View File

@@ -9,7 +9,7 @@ use core::{
fmt::{Display, Formatter, Result},
time::Duration,
};
use reth_chain_state::{ExecutedBlock, ExecutionTimingStats};
use reth_chain_state::ExecutedBlock;
use reth_ethereum_primitives::EthPrimitives;
use reth_primitives_traits::{NodePrimitives, SealedBlock, SealedHeader};
@@ -32,8 +32,6 @@ pub enum ConsensusEngineEvent<N: NodePrimitives = EthPrimitives> {
CanonicalChainCommitted(Box<SealedHeader<N::BlockHeader>>, Duration),
/// The consensus engine processed an invalid block.
InvalidBlock(Box<SealedBlock<N::Block>>),
/// A slow block was detected after persistence, with its timing statistics.
SlowBlock(SlowBlockInfo),
}
impl<N: NodePrimitives> ConsensusEngineEvent<N> {
@@ -75,25 +73,6 @@ where
Self::BlockReceived(num_hash) => {
write!(f, "BlockReceived({num_hash:?})")
}
Self::SlowBlock(info) => {
write!(
f,
"SlowBlock(block={}, total={:?})",
info.stats.block_number, info.total_duration
)
}
}
}
}
/// Information about a slow block detected after persistence.
#[derive(Clone, Debug)]
pub struct SlowBlockInfo {
/// The timing statistics for the slow block.
pub stats: Box<ExecutionTimingStats>,
/// The commit duration for the batch containing this block.
pub commit_duration: Duration,
/// The total duration (execution + `state_root` + commit).
/// Note: `state_read` is a subset of execution and is not added separately.
pub total_duration: Duration,
}

View File

@@ -100,6 +100,7 @@ revm-state.workspace = true
assert_matches.workspace = true
eyre.workspace = true
serde_json.workspace = true
crossbeam-channel.workspace = true
proptest.workspace = true
rand.workspace = true
rand_08.workspace = true
@@ -133,12 +134,14 @@ test-utils = [
"reth-evm-ethereum/test-utils",
"reth-tasks/test-utils",
]
trie-debug = [
"reth-trie-sparse/trie-debug",
"reth-trie-parallel/trie-debug",
"reth-engine-primitives/trie-debug",
"dep:serde_json",
trie-debug = ["reth-trie-sparse/trie-debug", "dep:serde_json"]
rocksdb = [
"reth-provider/rocksdb",
"reth-prune/rocksdb",
"reth-stages?/rocksdb",
"reth-e2e-test-utils/rocksdb",
]
edge = ["rocksdb"]
[[test]]
name = "e2e_testsuite"

View File

@@ -64,7 +64,6 @@ pub fn build_engine_orchestrator<N, Client, S, V, C>(
sync_metrics_tx: MetricEventsSender,
evm_config: C,
changeset_cache: ChangesetCache,
runtime: Runtime,
) -> ChainOrchestrator<
EngineHandler<
EngineApiRequestHandler<EngineApiRequest<N::Payload, N::Primitives>, N::Primitives>,
@@ -100,7 +99,6 @@ where
evm_config,
changeset_cache,
use_hashed_state,
runtime,
);
let engine_handler = EngineApiRequestHandler::new(to_tree_tx, from_tree);

View File

@@ -18,20 +18,10 @@ use std::{
Arc,
},
thread::JoinHandle,
time::Duration,
};
use thiserror::Error;
use tracing::{debug, error, instrument};
/// Unified result of any persistence operation.
#[derive(Debug)]
pub struct PersistenceResult {
/// The last block that was persisted, if any.
pub last_block: Option<BlockNumHash>,
/// The commit duration, only available for save-blocks operations.
pub commit_duration: Option<Duration>,
}
/// Writes parts of reth's in memory tree state to the database and static files.
///
/// This is meant to be a spawned service that listens for various incoming persistence operations,
@@ -96,16 +86,18 @@ where
while let Ok(action) = self.incoming.recv() {
match action {
PersistenceAction::RemoveBlocksAbove(new_tip_num, sender) => {
let last_block = self.on_remove_blocks_above(new_tip_num)?;
let result = self.on_remove_blocks_above(new_tip_num)?;
// send new sync metrics based on removed blocks
let _ =
self.sync_metrics_tx.send(MetricEvent::SyncHeight { height: new_tip_num });
let _ = sender.send(PersistenceResult { last_block, commit_duration: None });
// we ignore the error because the caller may or may not care about the result
let _ = sender.send(result);
}
PersistenceAction::SaveBlocks(blocks, sender) => {
let result = self.on_save_blocks(blocks)?;
let result_number = result.last_block.map(|b| b.number);
let result_number = result.map(|r| r.number);
// we ignore the error because the caller may or may not care about the result
let _ = sender.send(result);
if let Some(block_number) = result_number {
@@ -148,7 +140,7 @@ where
fn on_save_blocks(
&mut self,
blocks: Vec<ExecutedBlock<N::Primitives>>,
) -> Result<PersistenceResult, PersistenceError> {
) -> Result<Option<BlockNumHash>, PersistenceError> {
let first_block = blocks.first().map(|b| b.recovered_block.num_hash());
let last_block = blocks.last().map(|b| b.recovered_block.num_hash());
let block_count = blocks.len();
@@ -165,16 +157,10 @@ where
provider_rw.save_blocks(blocks, SaveBlocksMode::Full)?;
if let Some(finalized) = pending_finalized {
provider_rw.save_finalized_block_number(finalized.min(last.number))?;
if finalized > last.number {
self.pending_finalized_block = Some(finalized);
}
provider_rw.save_finalized_block_number(finalized)?;
}
if let Some(safe) = pending_safe {
provider_rw.save_safe_block_number(safe.min(last.number))?;
if safe > last.number {
self.pending_safe_block = Some(safe);
}
provider_rw.save_safe_block_number(safe)?;
}
if self.pruner.is_pruning_needed(last.number) {
@@ -189,11 +175,10 @@ where
debug!(target: "engine::persistence", first=?first_block, last=?last_block, "Saved range of blocks");
let elapsed = start_time.elapsed();
self.metrics.save_blocks_batch_size.record(block_count as f64);
self.metrics.save_blocks_duration_seconds.record(elapsed);
self.metrics.save_blocks_duration_seconds.record(start_time.elapsed());
Ok(PersistenceResult { last_block, commit_duration: Some(elapsed) })
Ok(last_block)
}
}
@@ -217,13 +202,13 @@ pub enum PersistenceAction<N: NodePrimitives = EthPrimitives> {
///
/// First, header, transaction, and receipt-related data should be written to static files.
/// Then the execution history-related data will be written to the database.
SaveBlocks(Vec<ExecutedBlock<N>>, CrossbeamSender<PersistenceResult>),
SaveBlocks(Vec<ExecutedBlock<N>>, CrossbeamSender<Option<BlockNumHash>>),
/// Removes block data above the given block number from the database.
///
/// This will first update checkpoints from the database, then remove actual block data from
/// static files.
RemoveBlocksAbove(u64, CrossbeamSender<PersistenceResult>),
RemoveBlocksAbove(u64, CrossbeamSender<Option<BlockNumHash>>),
/// Update the persisted finalized block on disk
SaveFinalizedBlock(u64),
@@ -302,7 +287,7 @@ impl<T: NodePrimitives> PersistenceHandle<T> {
pub fn save_blocks(
&self,
blocks: Vec<ExecutedBlock<T>>,
tx: CrossbeamSender<PersistenceResult>,
tx: CrossbeamSender<Option<BlockNumHash>>,
) -> Result<(), SendError<PersistenceAction<T>>> {
self.send_action(PersistenceAction::SaveBlocks(blocks, tx))
}
@@ -337,7 +322,7 @@ impl<T: NodePrimitives> PersistenceHandle<T> {
pub fn remove_blocks_above(
&self,
block_num: u64,
tx: CrossbeamSender<PersistenceResult>,
tx: CrossbeamSender<Option<BlockNumHash>>,
) -> Result<(), SendError<PersistenceAction<T>>> {
self.send_action(PersistenceAction::RemoveBlocksAbove(block_num, tx))
}
@@ -397,8 +382,8 @@ mod tests {
handle.save_blocks(blocks, tx).unwrap();
let result = rx.recv().unwrap();
assert!(result.last_block.is_none());
let hash = rx.recv().unwrap();
assert_eq!(hash, None);
}
#[test]
@@ -416,9 +401,12 @@ mod tests {
handle.save_blocks(blocks, tx).unwrap();
let result = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("test timed out");
let BlockNumHash { hash: actual_hash, number: _ } = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("test timed out")
.expect("no hash returned");
assert_eq!(block_hash, result.last_block.unwrap().hash);
assert_eq!(block_hash, actual_hash);
}
#[test]
@@ -432,8 +420,8 @@ mod tests {
let (tx, rx) = crossbeam_channel::bounded(1);
handle.save_blocks(blocks, tx).unwrap();
let result = rx.recv().unwrap();
assert_eq!(last_hash, result.last_block.unwrap().hash);
let BlockNumHash { hash: actual_hash, number: _ } = rx.recv().unwrap().unwrap();
assert_eq!(last_hash, actual_hash);
}
#[test]
@@ -450,8 +438,8 @@ mod tests {
handle.save_blocks(blocks, tx).unwrap();
let result = rx.recv().unwrap();
assert_eq!(last_hash, result.last_block.unwrap().hash);
let BlockNumHash { hash: actual_hash, number: _ } = rx.recv().unwrap().unwrap();
assert_eq!(last_hash, actual_hash);
}
}
}

View File

@@ -18,7 +18,9 @@ use reth_trie::{
updates::TrieUpdates, AccountProof, HashedPostState, HashedStorage, MultiProof,
MultiProofTargets, StorageMultiProof, StorageProof, TrieInput,
};
use revm_primitives::eip7907::MAX_CODE_SIZE;
use std::{
mem::size_of,
sync::{
atomic::{AtomicU64, AtomicUsize, Ordering},
Arc,
@@ -54,17 +56,8 @@ const fn fixed_cache_key_size_with_value<K>(value: usize) -> usize {
raw_size.div_ceil(FIXED_CACHE_ALIGNMENT) * FIXED_CACHE_ALIGNMENT
}
/// Estimated average bytecode size for cache budget calculation.
///
/// The fixed-cache stores `Option<Bytecode>` inline (pointer-sized), but each cached contract
/// also holds bytecode on the heap. For budget estimation we use 8 KiB, which is close to the
/// observed mainnet average (~7 KiB). Using `MAX_CODE_SIZE` (48 KiB) overestimates by ~7x,
/// yielding only 4096 entries for a 228 MB code-cache budget when 16384 fit comfortably.
const ESTIMATED_AVG_CODE_SIZE: usize = 8 * 1024;
/// Size in bytes of a single code cache entry (inline metadata + estimated heap).
const CODE_CACHE_ENTRY_SIZE: usize =
fixed_cache_key_size_with_value::<Address>(ESTIMATED_AVG_CODE_SIZE);
/// Size in bytes of a single code cache entry.
const CODE_CACHE_ENTRY_SIZE: usize = fixed_cache_key_size_with_value::<Address>(MAX_CODE_SIZE);
/// Size in bytes of a single storage cache entry.
const STORAGE_CACHE_ENTRY_SIZE: usize =
@@ -101,10 +94,6 @@ pub struct CachedStateProvider<S, const PREWARM: bool = false> {
/// Metrics for the cached state provider
metrics: CachedStateMetrics,
/// Optional cache statistics for detailed block logging. Only tracked when slow block
/// threshold is configured.
cache_stats: Option<Arc<CacheStats>>,
}
impl<S> CachedStateProvider<S> {
@@ -115,7 +104,7 @@ impl<S> CachedStateProvider<S> {
caches: ExecutionCache,
metrics: CachedStateMetrics,
) -> Self {
Self { state_provider, caches, metrics, cache_stats: None }
Self { state_provider, caches, metrics }
}
}
@@ -126,28 +115,14 @@ impl<S> CachedStateProvider<S, true> {
caches: ExecutionCache,
metrics: CachedStateMetrics,
) -> Self {
Self { state_provider, caches, metrics, cache_stats: None }
Self { state_provider, caches, metrics }
}
}
impl<S, const PREWARM: bool> CachedStateProvider<S, PREWARM> {
/// Enables cache statistics tracking for detailed block logging.
pub fn with_cache_stats(mut self, stats: Option<Arc<CacheStats>>) -> Self {
self.cache_stats = stats;
self
}
}
/// Represents the status of a key in the cache.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CachedStatus<T> {
/// The key is not in the cache (or was invalidated). The value was recalculated.
NotCached(T),
/// The key exists in cache and has a specific value.
Cached(T),
}
/// Metrics for the cached state provider, showing hits / misses for each cache
/// Metrics for the cached state provider, showing hits / misses / size for each cache.
///
/// This struct combines both the provider-level metrics (hits/misses tracked by the provider)
/// and the fixed-cache internal stats (collisions, size, capacity).
#[derive(Metrics, Clone)]
#[metrics(scope = "sync.caching")]
pub struct CachedStateMetrics {
@@ -236,73 +211,6 @@ impl CachedStateMetrics {
}
}
/// Cache hit/miss statistics for detailed block logging.
#[derive(Debug, Default)]
pub struct CacheStats {
/// Account cache hits
account_hits: AtomicUsize,
/// Account cache misses
account_misses: AtomicUsize,
/// Storage cache hits
storage_hits: AtomicUsize,
/// Storage cache misses
storage_misses: AtomicUsize,
/// Code cache hits
code_hits: AtomicUsize,
/// Code cache misses
code_misses: AtomicUsize,
}
impl CacheStats {
pub(crate) fn record_account_hit(&self) {
self.account_hits.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_account_miss(&self) {
self.account_misses.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn account_hits(&self) -> usize {
self.account_hits.load(Ordering::Relaxed)
}
pub(crate) fn account_misses(&self) -> usize {
self.account_misses.load(Ordering::Relaxed)
}
pub(crate) fn record_storage_hit(&self) {
self.storage_hits.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_storage_miss(&self) {
self.storage_misses.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn storage_hits(&self) -> usize {
self.storage_hits.load(Ordering::Relaxed)
}
pub(crate) fn storage_misses(&self) -> usize {
self.storage_misses.load(Ordering::Relaxed)
}
pub(crate) fn record_code_hit(&self) {
self.code_hits.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn record_code_miss(&self) {
self.code_misses.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn code_hits(&self) -> usize {
self.code_hits.load(Ordering::Relaxed)
}
pub(crate) fn code_misses(&self) -> usize {
self.code_misses.load(Ordering::Relaxed)
}
}
/// A stats handler for fixed-cache that tracks collisions and size.
///
/// Note: Hits and misses are tracked directly by the [`CachedStateProvider`] via
@@ -398,36 +306,27 @@ impl<S: AccountReader, const PREWARM: bool> AccountReader for CachedStateProvide
match self.caches.get_or_try_insert_account_with(*address, || {
self.state_provider.basic_account(address)
})? {
// During prewarm we only record stats (not prometheus metrics)
CachedStatus::NotCached(value) => {
if let Some(stats) = &self.cache_stats {
stats.record_account_miss();
}
Ok(value)
}
CachedStatus::Cached(value) => {
if let Some(stats) = &self.cache_stats {
stats.record_account_hit();
}
Ok(value)
}
CachedStatus::NotCached(value) | CachedStatus::Cached(value) => Ok(value),
}
} else if let Some(account) = self.caches.0.account_cache.get(address) {
self.metrics.account_cache_hits.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_account_hit();
}
Ok(account)
} else {
self.metrics.account_cache_misses.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_account_miss();
}
self.state_provider.basic_account(address)
}
}
}
/// Represents the status of a key in the cache.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CachedStatus<T> {
/// The key is not in the cache (or was invalidated). The value was recalculated.
NotCached(T),
/// The key exists in cache and has a specific value.
Cached(T),
}
impl<S: StateProvider, const PREWARM: bool> StateProvider for CachedStateProvider<S, PREWARM> {
fn storage(
&self,
@@ -438,31 +337,17 @@ impl<S: StateProvider, const PREWARM: bool> StateProvider for CachedStateProvide
match self.caches.get_or_try_insert_storage_with(account, storage_key, || {
self.state_provider.storage(account, storage_key).map(Option::unwrap_or_default)
})? {
// During prewarm we only record stats (not prometheus metrics)
CachedStatus::NotCached(value) => {
if let Some(stats) = &self.cache_stats {
stats.record_storage_miss();
}
Ok(Some(value).filter(|v| !v.is_zero()))
}
CachedStatus::Cached(value) => {
if let Some(stats) = &self.cache_stats {
stats.record_storage_hit();
}
CachedStatus::NotCached(value) | CachedStatus::Cached(value) => {
// The slot that was never written to is indistinguishable from a slot
// explicitly set to zero. We return `None` in both cases.
Ok(Some(value).filter(|v| !v.is_zero()))
}
}
} else if let Some(value) = self.caches.0.storage_cache.get(&(account, storage_key)) {
self.metrics.storage_cache_hits.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_storage_hit();
}
Ok(Some(value).filter(|v| !v.is_zero()))
} else {
self.metrics.storage_cache_misses.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_storage_miss();
}
self.state_provider.storage(account, storage_key)
}
}
@@ -474,31 +359,13 @@ impl<S: BytecodeReader, const PREWARM: bool> BytecodeReader for CachedStateProvi
match self.caches.get_or_try_insert_code_with(*code_hash, || {
self.state_provider.bytecode_by_hash(code_hash)
})? {
// During prewarm we only record stats (not prometheus metrics)
CachedStatus::NotCached(code) => {
if let Some(stats) = &self.cache_stats {
stats.record_code_miss();
}
Ok(code)
}
CachedStatus::Cached(code) => {
if let Some(stats) = &self.cache_stats {
stats.record_code_hit();
}
Ok(code)
}
CachedStatus::NotCached(code) | CachedStatus::Cached(code) => Ok(code),
}
} else if let Some(code) = self.caches.0.code_cache.get(code_hash) {
self.metrics.code_cache_hits.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_code_hit();
}
Ok(code)
} else {
self.metrics.code_cache_misses.increment(1);
if let Some(stats) = &self.cache_stats {
stats.record_code_miss();
}
self.state_provider.bytecode_by_hash(code_hash)
}
}
@@ -840,8 +707,7 @@ impl ExecutionCache {
}
self.0.account_cache.remove(addr);
self.0.account_stats.decrement_size();
continue;
continue
}
// If we have an account that was modified, but it has a `None` account info, some wild
@@ -971,10 +837,8 @@ impl SavedCache {
self.caches.update_metrics(&self.metrics);
}
/// Clears all caches, resetting them to empty state,
/// and updates the hash of the block this cache belongs to.
pub(crate) fn clear_with_hash(&mut self, hash: B256) {
self.hash = hash;
/// Clears all caches, resetting them to empty state.
pub(crate) fn clear(&self) {
self.caches.clear();
}
}
@@ -1221,20 +1085,4 @@ mod tests {
assert!(caches.0.account_cache.get(&addr1).is_none());
assert!(caches.0.account_cache.get(&addr2).is_some());
}
#[test]
fn test_code_cache_capacity_with_default_budget() {
// Default cross-block cache is 4 GB; code gets 5.56% = ~228 MB.
let total_cache_size = 4 * 1024 * 1024 * 1024; // 4 GB
let code_budget = (total_cache_size * 556) / 10000; // 228 MB
let capacity = ExecutionCache::bytes_to_entries(code_budget, CODE_CACHE_ENTRY_SIZE);
// With ESTIMATED_AVG_CODE_SIZE (8 KiB) we expect 16384 entries.
// If someone accidentally reverts to MAX_CODE_SIZE (48 KiB), this would drop to 4096.
assert_eq!(
capacity, 16384,
"code cache should have 16384 entries with default 4 GB budget"
);
}
}

View File

@@ -13,10 +13,7 @@ use reth_trie::{
MultiProofTargets, StorageMultiProof, StorageProof, TrieInput,
};
use std::{
sync::{
atomic::{AtomicU64, AtomicUsize, Ordering},
Arc,
},
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
@@ -36,6 +33,11 @@ pub(crate) struct AtomicDuration {
}
impl AtomicDuration {
/// Returns a zero duration.
pub(crate) const fn zero() -> Self {
Self { nanos: AtomicU64::new(0) }
}
/// Returns the duration as a [`Duration`]
pub(crate) fn duration(&self) -> Duration {
let nanos = self.nanos.load(Ordering::Relaxed);
@@ -61,10 +63,18 @@ impl AtomicDuration {
pub struct InstrumentedStateProvider<S> {
/// The state provider
state_provider: S,
/// Prometheus metrics for the instrumented state provider
/// Metrics for the instrumented state provider
metrics: StateProviderMetrics,
/// Shared fetch statistics, readable after the provider is consumed.
stats: Arc<StateProviderStats>,
/// The total time we spend fetching storage over the lifetime of this state provider
total_storage_fetch_latency: AtomicDuration,
/// The total time we spend fetching code over the lifetime of this state provider
total_code_fetch_latency: AtomicDuration,
/// The total time we spend fetching accounts over the lifetime of this state provider
total_account_fetch_latency: AtomicDuration,
}
impl<S> InstrumentedStateProvider<S>
@@ -77,29 +87,48 @@ where
Self {
state_provider,
metrics: StateProviderMetrics::new_with_labels(&[("source", source)]),
stats: Arc::new(StateProviderStats::default()),
total_storage_fetch_latency: AtomicDuration::zero(),
total_code_fetch_latency: AtomicDuration::zero(),
total_account_fetch_latency: AtomicDuration::zero(),
}
}
/// Returns a shared reference to the accumulated fetch statistics.
pub fn stats(&self) -> Arc<StateProviderStats> {
Arc::clone(&self.stats)
}
}
impl<S> Drop for InstrumentedStateProvider<S> {
fn drop(&mut self) {
let total_storage_fetch_latency = self.stats.total_storage_fetch_latency.duration();
impl<S> InstrumentedStateProvider<S> {
/// Records the latency for a storage fetch, and increments the duration counter for the storage
/// fetch.
fn record_storage_fetch(&self, latency: Duration) {
self.metrics.storage_fetch_latency.record(latency);
self.total_storage_fetch_latency.add_duration(latency);
}
/// Records the latency for a code fetch, and increments the duration counter for the code
/// fetch.
fn record_code_fetch(&self, latency: Duration) {
self.metrics.code_fetch_latency.record(latency);
self.total_code_fetch_latency.add_duration(latency);
}
/// Records the latency for an account fetch, and increments the duration counter for the
/// account fetch.
fn record_account_fetch(&self, latency: Duration) {
self.metrics.account_fetch_latency.record(latency);
self.total_account_fetch_latency.add_duration(latency);
}
/// Records the total latencies into their respective gauges and histograms.
pub(crate) fn record_total_latency(&self) {
let total_storage_fetch_latency = self.total_storage_fetch_latency.duration();
self.metrics.total_storage_fetch_latency.record(total_storage_fetch_latency);
self.metrics
.total_storage_fetch_latency_gauge
.set(total_storage_fetch_latency.as_secs_f64());
let total_code_fetch_latency = self.stats.total_code_fetch_latency.duration();
let total_code_fetch_latency = self.total_code_fetch_latency.duration();
self.metrics.total_code_fetch_latency.record(total_code_fetch_latency);
self.metrics.total_code_fetch_latency_gauge.set(total_code_fetch_latency.as_secs_f64());
let total_account_fetch_latency = self.stats.total_account_fetch_latency.duration();
let total_account_fetch_latency = self.total_account_fetch_latency.duration();
self.metrics.total_account_fetch_latency.record(total_account_fetch_latency);
self.metrics
.total_account_fetch_latency_gauge
@@ -107,6 +136,12 @@ impl<S> Drop for InstrumentedStateProvider<S> {
}
}
impl<S> Drop for InstrumentedStateProvider<S> {
fn drop(&mut self) {
self.record_total_latency();
}
}
/// Metrics for the instrumented state provider
#[derive(Metrics, Clone)]
#[metrics(scope = "sync.state_provider")]
@@ -148,10 +183,7 @@ impl<S: AccountReader> AccountReader for InstrumentedStateProvider<S> {
fn basic_account(&self, address: &Address) -> ProviderResult<Option<Account>> {
let start = Instant::now();
let res = self.state_provider.basic_account(address);
let elapsed = start.elapsed();
self.metrics.account_fetch_latency.record(elapsed);
self.stats.total_account_fetches.fetch_add(1, Ordering::Relaxed);
self.stats.total_account_fetch_latency.add_duration(elapsed);
self.record_account_fetch(start.elapsed());
res
}
}
@@ -164,10 +196,7 @@ impl<S: StateProvider> StateProvider for InstrumentedStateProvider<S> {
) -> ProviderResult<Option<StorageValue>> {
let start = Instant::now();
let res = self.state_provider.storage(account, storage_key);
let elapsed = start.elapsed();
self.metrics.storage_fetch_latency.record(elapsed);
self.stats.total_storage_fetches.fetch_add(1, Ordering::Relaxed);
self.stats.total_storage_fetch_latency.add_duration(elapsed);
self.record_storage_fetch(start.elapsed());
res
}
}
@@ -176,17 +205,7 @@ impl<S: BytecodeReader> BytecodeReader for InstrumentedStateProvider<S> {
fn bytecode_by_hash(&self, code_hash: &B256) -> ProviderResult<Option<Bytecode>> {
let start = Instant::now();
let res = self.state_provider.bytecode_by_hash(code_hash);
let elapsed = start.elapsed();
self.metrics.code_fetch_latency.record(elapsed);
self.stats.total_code_fetches.fetch_add(1, Ordering::Relaxed);
self.stats.total_code_fetch_latency.add_duration(elapsed);
self.stats.total_code_fetched_bytes.fetch_add(
res.as_ref()
.ok()
.and_then(|code| code.as_ref().map(|code| code.len()))
.unwrap_or_default(),
Ordering::Relaxed,
);
self.record_code_fetch(start.elapsed());
res
}
}
@@ -289,56 +308,3 @@ impl<S: HashedPostStateProvider> HashedPostStateProvider for InstrumentedStatePr
self.state_provider.hashed_post_state(bundle_state)
}
}
/// Accumulated fetch statistics from an [`InstrumentedStateProvider`].
///
/// Shared via `Arc` so statistics can be read after the provider is consumed.
#[derive(Debug, Default)]
pub struct StateProviderStats {
total_storage_fetches: AtomicUsize,
total_storage_fetch_latency: AtomicDuration,
total_code_fetches: AtomicUsize,
total_code_fetch_latency: AtomicDuration,
total_code_fetched_bytes: AtomicUsize,
total_account_fetches: AtomicUsize,
total_account_fetch_latency: AtomicDuration,
}
impl StateProviderStats {
/// Returns total number of storage fetches.
pub fn total_storage_fetches(&self) -> usize {
self.total_storage_fetches.load(Ordering::Relaxed)
}
/// Returns total time spent on storage fetches.
pub fn total_storage_fetch_latency(&self) -> Duration {
self.total_storage_fetch_latency.duration()
}
/// Returns total number of code fetches.
pub fn total_code_fetches(&self) -> usize {
self.total_code_fetches.load(Ordering::Relaxed)
}
/// Returns total time spent on code fetches.
pub fn total_code_fetch_latency(&self) -> Duration {
self.total_code_fetch_latency.duration()
}
/// Returns total amount of code fetched, in bytes.
pub fn total_code_fetched_bytes(&self) -> usize {
self.total_code_fetched_bytes.load(Ordering::Relaxed)
}
/// Returns total number of account fetches.
pub fn total_account_fetches(&self) -> usize {
self.total_account_fetches.load(Ordering::Relaxed)
}
/// Returns total time spent on account fetches.
pub fn total_account_fetch_latency(&self) -> Duration {
self.total_account_fetch_latency.duration()
}
}

View File

@@ -13,13 +13,13 @@ use alloy_rpc_types_engine::{
};
use error::{InsertBlockError, InsertBlockFatalError};
use reth_chain_state::{
CanonicalInMemoryState, ComputedTrieData, ExecutedBlock, ExecutionTimingStats,
MemoryOverlayStateProvider, NewCanonicalChain,
CanonicalInMemoryState, ComputedTrieData, ExecutedBlock, MemoryOverlayStateProvider,
NewCanonicalChain,
};
use reth_consensus::{Consensus, FullConsensus};
use reth_engine_primitives::{
BeaconEngineMessage, BeaconOnNewPayloadError, ConsensusEngineEvent, ExecutionPayload,
ForkchoiceStateTracker, NewPayloadTimings, OnForkChoiceUpdated, SlowBlockInfo,
ForkchoiceStateTracker, NewPayloadTimings, OnForkChoiceUpdated,
};
use reth_errors::{ConsensusError, ProviderResult};
use reth_evm::ConfigureEvm;
@@ -42,7 +42,7 @@ use reth_tasks::{spawn_os_thread, utils::increase_thread_priority};
use reth_trie_db::ChangesetCache;
use revm::interpreter::debug_unreachable;
use state::TreeState;
use std::{collections::HashMap, fmt::Debug, ops, sync::Arc, time::Duration};
use std::{fmt::Debug, ops, sync::Arc, time::Duration};
use crossbeam_channel::{Receiver, Sender};
use tokio::sync::{
@@ -65,7 +65,7 @@ pub mod precompile_cache;
mod tests;
mod trie_updates;
use crate::{persistence::PersistenceResult, tree::error::AdvancePersistenceError};
use crate::tree::error::AdvancePersistenceError;
pub use block_buffer::BlockBuffer;
pub use cached_state::{CachedStateMetrics, CachedStateProvider, ExecutionCache, SavedCache};
pub use invalid_headers::InvalidHeaderCache;
@@ -273,15 +273,9 @@ where
evm_config: C,
/// Changeset cache for in-memory trie changesets
changeset_cache: ChangesetCache,
/// Timing statistics for executed blocks, keyed by block hash.
/// Stored here (not in `ExecutedBlock`) to avoid leaking observability concerns into the block
/// type. Entries are removed when blocks are persisted or invalidated.
execution_timing_stats: HashMap<B256, Box<ExecutionTimingStats>>,
/// Whether the node uses hashed state as canonical storage (v2 mode).
/// Cached at construction to avoid threading `StorageSettingsCache` bounds everywhere.
use_hashed_state: bool,
/// Task runtime for spawning blocking work on named, reusable threads.
runtime: reth_tasks::Runtime,
}
impl<N, P: Debug, T: PayloadTypes + Debug, V: Debug, C> std::fmt::Debug
@@ -307,9 +301,7 @@ where
.field("engine_kind", &self.engine_kind)
.field("evm_config", &self.evm_config)
.field("changeset_cache", &self.changeset_cache)
.field("execution_timing_stats", &self.execution_timing_stats.len())
.field("use_hashed_state", &self.use_hashed_state)
.field("runtime", &self.runtime)
.finish()
}
}
@@ -350,7 +342,6 @@ where
evm_config: C,
changeset_cache: ChangesetCache,
use_hashed_state: bool,
runtime: reth_tasks::Runtime,
) -> Self {
let (incoming_tx, incoming) = crossbeam_channel::unbounded();
@@ -372,9 +363,7 @@ where
engine_kind,
evm_config,
changeset_cache,
execution_timing_stats: HashMap::new(),
use_hashed_state,
runtime,
}
}
@@ -396,7 +385,6 @@ where
evm_config: C,
changeset_cache: ChangesetCache,
use_hashed_state: bool,
runtime: reth_tasks::Runtime,
) -> (Sender<FromEngine<EngineApiRequest<T, N>, N::Block>>, UnboundedReceiver<EngineApiEvent<N>>)
{
let best_block_number = provider.best_block_number().unwrap_or(0);
@@ -430,7 +418,6 @@ where
evm_config,
changeset_cache,
use_hashed_state,
runtime,
);
let incoming = task.incoming_tx.clone();
spawn_os_thread("engine", || {
@@ -440,14 +427,6 @@ where
(incoming, outgoing)
}
/// Returns a [`TreeOutcome`] indicating the forkchoice head is valid and canonical.
fn valid_outcome(state: ForkchoiceState) -> TreeOutcome<OnForkChoiceUpdated> {
TreeOutcome::new(OnForkChoiceUpdated::valid(PayloadStatus::new(
PayloadStatusEnum::Valid,
Some(state.head_block_hash),
)))
}
/// Returns a new [`Sender`] to send messages to this type.
pub fn sender(&self) -> Sender<FromEngine<EngineApiRequest<T, N>, N::Block>> {
self.incoming_tx.clone()
@@ -509,8 +488,8 @@ where
recv(persistence_rx) -> result => {
// Don't put it back - consumed (oneshot-like behavior)
match result {
Ok(result) => LoopEvent::PersistenceComplete {
result,
Ok(value) => LoopEvent::PersistenceComplete {
result: value,
start_time,
},
Err(_) => LoopEvent::Disconnected,
@@ -1139,7 +1118,11 @@ where
}
// The head block is already canonical
Ok(Some(Self::valid_outcome(state)))
let outcome = TreeOutcome::new(OnForkChoiceUpdated::valid(PayloadStatus::new(
PayloadStatusEnum::Valid,
Some(state.head_block_hash),
)));
Ok(Some(outcome))
}
/// Applies chain update for the new head block and processes payload attributes.
@@ -1200,7 +1183,12 @@ where
// The head block is already canonical and we're not processing payload attributes,
// so we're not triggering a payload job and can return right away
return Ok(Some(Self::valid_outcome(state)));
let outcome = TreeOutcome::new(OnForkChoiceUpdated::valid(PayloadStatus::new(
PayloadStatusEnum::Valid,
Some(state.head_block_hash),
)));
return Ok(Some(outcome));
}
// Ensure we can apply a new chain update for the head block
@@ -1220,7 +1208,11 @@ where
return Ok(Some(TreeOutcome::new(updated)));
}
return Ok(Some(Self::valid_outcome(state)));
let outcome = TreeOutcome::new(OnForkChoiceUpdated::valid(PayloadStatus::new(
PayloadStatusEnum::Valid,
Some(state.head_block_hash),
)));
return Ok(Some(outcome));
}
Ok(None)
@@ -1375,16 +1367,15 @@ where
/// Handles a completed persistence task.
fn on_persistence_complete(
&mut self,
result: PersistenceResult,
last_persisted_hash_num: Option<BlockNumHash>,
start_time: Instant,
) -> Result<(), AdvancePersistenceError> {
self.metrics.engine.persistence_duration.record(start_time.elapsed());
let commit_duration = result.commit_duration;
let Some(BlockNumHash {
hash: last_persisted_block_hash,
number: last_persisted_block_number,
}) = result.last_block
}) = last_persisted_hash_num
else {
// if this happened, then we persisted no blocks because we sent an empty vec of blocks
warn!(target: "engine::tree", "Persistence task completed but did not persist any blocks");
@@ -1425,13 +1416,11 @@ where
// Spawn a background task to trigger computation so it's ready when the next payload
// arrives.
if let Some(overlay) = self.state.tree_state.prepare_canonical_overlay() {
self.runtime.spawn_blocking_named("prepare-overlay", move || {
tokio::task::spawn_blocking(move || {
let _ = overlay.get();
});
}
self.purge_timing_stats(last_persisted_block_number, commit_duration);
Ok(())
}
@@ -1586,7 +1575,7 @@ where
{
let (persistence_tx, persistence_rx) =
std::sync::mpsc::channel();
self.runtime.spawn_blocking_named("wait-persist", move || {
tokio::task::spawn_blocking(move || {
let start = Instant::now();
let result =
rx.recv().expect("persistence state channel closed");
@@ -1737,7 +1726,6 @@ where
// remove all buffered blocks below the backfill height
self.state.buffer.remove_old_blocks(backfill_height);
self.purge_timing_stats(backfill_height, None);
// we remove all entries because now we're synced to the backfill target and consider this
// the canonical chain
self.canonical_in_memory_state.clear_state();
@@ -1871,43 +1859,6 @@ where
Ok(())
}
/// Removes timing stats for blocks at or below `below_number`.
///
/// No-op when detailed block logging is disabled (no stats are recorded in that case).
/// When `commit_duration` is provided and a slow block threshold is configured, checks
/// each removed block against the threshold and emits a [`ConsensusEngineEvent::SlowBlock`]
/// event for blocks that exceed it.
fn purge_timing_stats(&mut self, below_number: u64, commit_duration: Option<Duration>) {
let threshold = self.config.slow_block_threshold();
let check_slow = commit_duration.is_some() && threshold.is_some();
// Two-pass: collect keys first because emit_event borrows &mut self.
let keys_to_remove: Vec<B256> = self
.execution_timing_stats
.iter()
.filter(|(_, stats)| stats.block_number <= below_number)
.map(|(k, _)| *k)
.collect();
for key in keys_to_remove {
let stats = self.execution_timing_stats.remove(&key).expect("key just found");
if check_slow {
let commit_dur = commit_duration.expect("checked above");
// state_read_duration is already included in execution_duration
let total_duration =
stats.execution_duration + stats.state_hash_duration + commit_dur;
if total_duration > threshold.expect("checked above") {
self.emit_event(ConsensusEngineEvent::SlowBlock(SlowBlockInfo {
stats,
commit_duration: commit_dur,
total_duration,
}));
}
}
}
}
/// Emits an outgoing event to the engine.
fn emit_event(&mut self, event: impl Into<EngineApiEvent<N>>) {
let event = event.into();
@@ -2150,16 +2101,16 @@ where
/// Prepares the invalid payload response for the given hash, checking the
/// database for the parent hash and populating the payload status with the latest valid hash
/// according to the engine api spec.
fn prepare_invalid_response(&mut self, parent_hash: B256) -> ProviderResult<PayloadStatus> {
let valid_parent_hash = match self.sealed_header_by_hash(parent_hash)? {
// Edge case: the `latestValid` field is the zero hash if the parent block is the
// terminal PoW block, which we need to identify by looking at the parent's block
// difficulty
Some(parent) if !parent.difficulty().is_zero() => Some(B256::ZERO),
Some(_) => Some(parent_hash),
None => self.latest_valid_hash_for_invalid_payload(parent_hash)?,
};
fn prepare_invalid_response(&mut self, mut parent_hash: B256) -> ProviderResult<PayloadStatus> {
// Edge case: the `latestValid` field is the zero hash if the parent block is the terminal
// PoW block, which we need to identify by looking at the parent's block difficulty
if let Some(parent) = self.sealed_header_by_hash(parent_hash)? &&
!parent.difficulty().is_zero()
{
parent_hash = B256::ZERO;
}
let valid_parent_hash = self.latest_valid_hash_for_invalid_payload(parent_hash)?;
Ok(PayloadStatus::from_status(PayloadStatusEnum::Invalid {
validation_error: PayloadValidationError::LinksToRejectedPayload.to_string(),
})
@@ -2220,26 +2171,18 @@ where
/// Finds any invalid ancestor for the given payload.
///
/// This function first checks if the block itself is in the invalid headers cache (to
/// avoid re-executing a known-invalid block). Then it walks up the chain of buffered
/// ancestors and checks if any ancestor is marked as invalid.
/// This function walks up the chain of buffered ancestors from the payload's block
/// hash and checks if any ancestor is marked as invalid in the tree state.
///
/// The check works by:
/// 1. Checking if the block hash itself is in the `invalid_headers` map
/// 2. Finding the lowest buffered ancestor for the given block hash
/// 3. If the ancestor is the same as the block hash itself, using the parent hash instead
/// 4. Checking if this ancestor is in the `invalid_headers` map
/// 1. Finding the lowest buffered ancestor for the given block hash
/// 2. If the ancestor is the same as the block hash itself, using the parent hash instead
/// 3. Checking if this ancestor is in the `invalid_headers` map
///
/// Returns the invalid ancestor block info if found, or None if no invalid ancestor exists.
fn find_invalid_ancestor(&mut self, payload: &T::ExecutionData) -> Option<BlockWithParent> {
let parent_hash = payload.parent_hash();
let block_hash = payload.block_hash();
// Check if the block itself is already known to be invalid, avoiding re-execution
if let Some(entry) = self.state.invalid_headers.get(&block_hash) {
return Some(entry);
}
let mut lowest_buffered_ancestor = self.lowest_buffered_ancestor_or(block_hash);
if lowest_buffered_ancestor == block_hash {
lowest_buffered_ancestor = parent_hash;
@@ -2791,12 +2734,7 @@ where
&mut self,
block_id: BlockWithParent,
input: Input,
execute: impl FnOnce(
&mut V,
Input,
TreeCtx<'_, N>,
)
-> Result<(ExecutedBlock<N>, Option<Box<ExecutionTimingStats>>), Err>,
execute: impl FnOnce(&mut V, Input, TreeCtx<'_, N>) -> Result<ExecutedBlock<N>, Err>,
convert_to_block: impl FnOnce(&mut Self, Input) -> Result<SealedBlock<N::Block>, Err>,
) -> Result<InsertPayloadOk, Err>
where
@@ -2866,12 +2804,7 @@ where
let start = Instant::now();
let (executed, timing_stats) = execute(&mut self.payload_validator, input, ctx)?;
// Store timing stats for detailed block logging after persistence
if let Some(stats) = timing_stats {
self.execution_timing_stats.insert(executed.recovered_block().hash(), stats);
}
let executed = execute(&mut self.payload_validator, input, ctx)?;
// if the parent is the canonical head, we can insert the block as the pending block
if self.state.tree_state.canonical_block_hash() == executed.recovered_block().parent_hash()
@@ -3193,8 +3126,8 @@ where
EngineMessage(FromEngine<EngineApiRequest<T, N>, N::Block>),
/// A persistence task completed.
PersistenceComplete {
/// The unified result of the persistence operation.
result: PersistenceResult,
/// The result of the persistence operation.
result: Option<BlockNumHash>,
/// When the persistence operation started.
start_time: Instant,
},

View File

@@ -38,7 +38,9 @@ use reth_trie_parallel::{
proof_task::{ProofTaskCtx, ProofWorkerHandle},
root::ParallelStateRootError,
};
use reth_trie_sparse::ParallelismThresholds;
use reth_trie_sparse::{
ParallelSparseTrie, ParallelismThresholds, RevealableSparseTrie, SparseStateTrie,
};
use std::{
ops::Not,
sync::{
@@ -57,17 +59,14 @@ pub mod prewarm;
pub mod receipt_root_task;
pub mod sparse_trie;
pub use preserved_sparse_trie::{
PayloadSparseTrieCache, PayloadSparseTrieKind, PayloadSparseTrieStoreOutcome,
SparseTrieCheckout,
};
use preserved_sparse_trie::{PreservedSparseTrie, SharedPreservedSparseTrie};
/// Default parallelism thresholds to use with the [`ParallelSparseTrie`].
///
/// These values were determined by performing benchmarks using gradually increasing values to judge
/// the effects. Below 100 throughput would generally be equal or slightly less, while above 150 it
/// the affects. Below 100 throughput would generally be equal or slightly less, while above 150 it
/// would deteriorate to the point where PST might as well not be used.
const PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS: ParallelismThresholds =
pub const PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS: ParallelismThresholds =
ParallelismThresholds { min_revealed_nodes: 100, min_updated_nodes: 100 };
/// Default node capacity for shrinking the sparse trie. This is used to limit the number of trie
@@ -104,52 +103,6 @@ type IteratorPayloadHandle<Evm, I, N> = PayloadHandle<
<N as NodePrimitives>::Receipt,
>;
/// Shared cache handles that can be exported to engine consumers and downstream payload builders.
#[derive(Debug, Clone)]
pub struct EngineSharedCaches<Evm: ConfigureEvm> {
execution_cache: PayloadExecutionCache,
sparse_trie_cache: PayloadSparseTrieCache,
precompile_cache_map: PrecompileCacheMap<SpecFor<Evm>>,
}
impl<Evm> Default for EngineSharedCaches<Evm>
where
Evm: ConfigureEvm,
{
fn default() -> Self {
Self::with_sparse_trie_kind(PayloadSparseTrieKind::default())
}
}
impl<Evm> EngineSharedCaches<Evm>
where
Evm: ConfigureEvm,
{
/// Creates shared caches backed by the requested sparse trie implementation.
pub fn with_sparse_trie_kind(sparse_trie_kind: PayloadSparseTrieKind) -> Self {
Self {
execution_cache: Default::default(),
sparse_trie_cache: PayloadSparseTrieCache::new(sparse_trie_kind),
precompile_cache_map: Default::default(),
}
}
/// Returns the shared execution cache handle for engine-internal use.
pub(crate) fn execution_cache(&self) -> PayloadExecutionCache {
self.execution_cache.clone()
}
/// Returns the shared sparse trie cache handle.
pub fn sparse_trie_cache(&self) -> PayloadSparseTrieCache {
self.sparse_trie_cache.clone()
}
/// Returns the shared precompile cache map.
pub fn precompile_cache_map(&self) -> PrecompileCacheMap<SpecFor<Evm>> {
self.precompile_cache_map.clone()
}
}
/// Entrypoint for executing the payload.
#[derive(Debug)]
pub struct PayloadProcessor<Evm>
@@ -158,8 +111,8 @@ where
{
/// The executor used by to spawn tasks.
executor: Runtime,
/// Shared caches reused across payload processing.
shared_caches: EngineSharedCaches<Evm>,
/// The most recent cache used for execution.
execution_cache: PayloadExecutionCache,
/// Metrics for trie operations
trie_metrics: MultiProofTaskMetrics,
/// Cross-block cache size in bytes.
@@ -172,10 +125,16 @@ where
evm_config: Evm,
/// Whether precompile cache should be disabled.
precompile_cache_disabled: bool,
/// LFU hot-slot capacity: max storage slots retained across prune cycles.
sparse_trie_max_hot_slots: usize,
/// LFU hot-account capacity: max account addresses retained across prune cycles.
sparse_trie_max_hot_accounts: usize,
/// Precompile cache map.
precompile_cache_map: PrecompileCacheMap<SpecFor<Evm>>,
/// A pruned `SparseStateTrie`, kept around as a cache of already revealed trie nodes and to
/// re-use allocated memory. Stored with the block hash it was computed for to enable trie
/// preservation across sequential payload validations.
sparse_state_trie: SharedPreservedSparseTrie,
/// Sparse trie prune depth.
sparse_trie_prune_depth: usize,
/// Maximum storage tries to retain after pruning.
sparse_trie_max_storage_tries: usize,
/// Whether sparse trie cache pruning is fully disabled.
disable_sparse_trie_cache_pruning: bool,
/// Whether to disable cache metrics recording.
@@ -197,19 +156,21 @@ where
executor: Runtime,
evm_config: Evm,
config: &TreeConfig,
shared_caches: EngineSharedCaches<Evm>,
precompile_cache_map: PrecompileCacheMap<SpecFor<Evm>>,
) -> Self {
Self {
executor,
shared_caches,
execution_cache: Default::default(),
trie_metrics: Default::default(),
cross_block_cache_size: config.cross_block_cache_size(),
disable_transaction_prewarming: config.disable_prewarming(),
evm_config,
disable_state_cache: config.disable_state_cache(),
precompile_cache_disabled: config.precompile_cache_disabled(),
sparse_trie_max_hot_slots: config.sparse_trie_max_hot_slots(),
sparse_trie_max_hot_accounts: config.sparse_trie_max_hot_accounts(),
precompile_cache_map,
sparse_state_trie: SharedPreservedSparseTrie::default(),
sparse_trie_prune_depth: config.sparse_trie_prune_depth(),
sparse_trie_max_storage_tries: config.sparse_trie_max_storage_tries(),
disable_sparse_trie_cache_pruning: config.disable_sparse_trie_cache_pruning(),
disable_cache_metrics: config.disable_cache_metrics(),
}
@@ -224,17 +185,17 @@ where
debug!(target: "engine::tree::payload_processor", "Waiting for execution cache and sparse trie locks");
// Wait for both caches in parallel using std threads
let execution_cache = self.shared_caches.execution_cache();
let sparse_trie = self.shared_caches.sparse_trie_cache();
let execution_cache = self.execution_cache.clone();
let sparse_trie = self.sparse_state_trie.clone();
// Use channels and spawn_blocking instead of std::thread::spawn
let (execution_tx, execution_rx) = std::sync::mpsc::channel();
let (sparse_trie_tx, sparse_trie_rx) = std::sync::mpsc::channel();
self.executor.spawn_blocking_named("wait-exec-cache", move || {
self.executor.spawn_blocking(move || {
let _ = execution_tx.send(execution_cache.wait_for_availability());
});
self.executor.spawn_blocking_named("wait-sparse-tri", move || {
self.executor.spawn_blocking(move || {
let _ = sparse_trie_tx.send(sparse_trie.wait_for_availability());
});
@@ -390,8 +351,6 @@ where
let (to_multi_proof, from_multi_proof) = crossbeam_channel::unbounded();
let task_ctx = ProofTaskCtx::new(multiproof_provider_factory);
#[cfg(feature = "trie-debug")]
let task_ctx = task_ctx.with_proof_jitter(config.proof_jitter());
let halve_workers = env.transaction_count <= Self::SMALL_BLOCK_PROOF_WORKER_TX_THRESHOLD;
let proof_handle = ProofWorkerHandle::new(&self.executor, task_ctx, halve_workers);
@@ -448,6 +407,8 @@ where
let (prewarm_tx, prewarm_rx) = mpsc::sync_channel(transaction_count);
let (execute_tx, execute_rx) = mpsc::sync_channel(transaction_count);
let span = debug_span!(target: "engine::tree::payload_processor", "recover_transactions", transaction_count).entered();
if transaction_count == 0 {
// Empty block — nothing to do.
} else if transaction_count < Self::SMALL_BLOCK_TX_THRESHOLD {
@@ -459,6 +420,7 @@ where
"using sequential sig recovery for small block"
);
self.executor.spawn_blocking_named("tx-iterator", move || {
let _ = span;
let (transactions, convert) = transactions.into_parts();
convert_serial(transactions.into_iter(), &convert, &prewarm_tx, &execute_tx);
});
@@ -470,8 +432,8 @@ where
// few transactions are recovered sequentially and sent immediately before
// entering the parallel iterator for the remainder.
let prefetch = Self::PARALLEL_PREFETCH_COUNT.min(transaction_count);
let executor = self.executor.clone();
self.executor.spawn_blocking_named("tx-iterator", move || {
let _ = span;
let (transactions, convert) = transactions.into_parts();
let mut all: Vec<_> = transactions.into_iter().collect();
let rest = all.split_off(prefetch.min(all.len()));
@@ -488,7 +450,7 @@ where
let tx = convert.convert(tx);
(idx, tx)
})
.for_each_ordered_in(executor.cpu_pool(), |(idx, tx)| {
.for_each_ordered(|(idx, tx)| {
let tx = tx.map(|tx| {
let (tx_env, tx) = tx.into_parts();
let tx = WithTxEnv { tx_env, tx: Arc::new(tx) };
@@ -496,7 +458,6 @@ where
tx
});
let _ = execute_tx.send(tx);
debug!(target: "engine::tree::payload_processor", idx, "yielded transaction");
});
});
}
@@ -539,12 +500,12 @@ where
terminate_execution: Arc::new(AtomicBool::new(false)),
executed_tx_index: Arc::clone(&executed_tx_index),
precompile_cache_disabled: self.precompile_cache_disabled,
precompile_cache_map: self.shared_caches.precompile_cache_map(),
precompile_cache_map: self.precompile_cache_map.clone(),
};
let (prewarm_task, to_prewarm_task) = PrewarmCacheTask::new(
self.executor.clone(),
self.shared_caches.execution_cache(),
self.execution_cache.clone(),
prewarm_ctx,
to_multi_proof,
);
@@ -572,7 +533,7 @@ where
/// instance.
#[instrument(level = "debug", target = "engine::caching", skip(self))]
fn cache_for(&self, parent_hash: B256) -> SavedCache {
if let Some(cache) = self.shared_caches.execution_cache().get_cache_for(parent_hash) {
if let Some(cache) = self.execution_cache.get_cache_for(parent_hash) {
debug!("reusing execution cache");
cache
} else {
@@ -597,33 +558,54 @@ where
parent_state_root: B256,
chunk_size: usize,
) {
let sparse_trie_cache = self.shared_caches.sparse_trie_cache();
let preserved_sparse_trie = self.sparse_state_trie.clone();
let trie_metrics = self.trie_metrics.clone();
let max_hot_slots = self.sparse_trie_max_hot_slots;
let max_hot_accounts = self.sparse_trie_max_hot_accounts;
let prune_depth = self.sparse_trie_prune_depth;
let max_storage_tries = self.sparse_trie_max_storage_tries;
let disable_cache_pruning = self.disable_sparse_trie_cache_pruning;
let executor = self.executor.clone();
let parent_span = Span::current();
self.executor.spawn_blocking_named("sparse-trie", move || {
reth_tasks::once!(increase_thread_priority);
increase_thread_priority();
let _enter = debug_span!(target: "engine::tree::payload_processor", parent: parent_span, "sparse_trie_task")
.entered();
// Reuse a stored SparseStateTrie if available, applying continuation logic.
// If this payload's parent state root matches the preserved trie's anchor,
// we can reuse the pruned trie structure. Otherwise, we clear the trie but
// keep allocations.
let start = Instant::now();
let mut checkout = sparse_trie_cache.take_or_create_for(parent_state_root);
let preserved = preserved_sparse_trie.take();
trie_metrics
.sparse_trie_cache_wait_duration_histogram
.record(start.elapsed().as_secs_f64());
checkout.set_hot_cache_capacities(max_hot_slots, max_hot_accounts);
let mut task = SparseTrieCacheTask::new_with_checkout(
let sparse_state_trie = preserved
.map(|preserved| preserved.into_trie_for(parent_state_root))
.unwrap_or_else(|| {
debug!(
target: "engine::tree::payload_processor",
"Creating new sparse trie - no preserved trie available"
);
let default_trie = RevealableSparseTrie::blind_from(
ParallelSparseTrie::default().with_parallelism_thresholds(
PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS,
),
);
SparseStateTrie::new()
.with_accounts_trie(default_trie.clone())
.with_default_storage_trie(default_trie)
.with_updates(true)
});
let mut task = SparseTrieCacheTask::new_with_trie(
&executor,
from_multi_proof,
proof_worker_handle,
trie_metrics.clone(),
checkout,
sparse_state_trie.with_skip_proof_node_filtering(true),
chunk_size,
);
@@ -634,7 +616,7 @@ where
// causing take() to return None and forcing it to create a new empty trie
// instead of reusing the preserved one. Holding the guard ensures the next
// block's take() blocks until we've stored the trie for reuse.
let mut guard = sparse_trie_cache.lock();
let mut guard = preserved_sparse_trie.lock();
let task_result = result.as_ref().ok().cloned();
// Send state root computation result - next block may start but will block on take()
@@ -649,9 +631,10 @@ where
SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY,
SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY,
);
trie.store_prepared_cleared_with_guard(&mut guard);
guard.store(PreservedSparseTrie::cleared(trie));
// Drop guard before deferred to release lock before expensive deallocations
drop(guard);
executor.spawn_drop(deferred);
drop(deferred);
return;
}
@@ -662,8 +645,8 @@ where
let deferred = if let Some(result) = task_result {
let start = Instant::now();
let (trie, deferred) = task.into_trie_for_reuse(
max_hot_slots,
max_hot_accounts,
prune_depth,
max_storage_tries,
SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY,
SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY,
disable_cache_pruning,
@@ -672,13 +655,7 @@ where
trie_metrics
.into_trie_for_reuse_duration_histogram
.record(start.elapsed().as_secs_f64());
trie_metrics
.sparse_trie_retained_memory_bytes
.set(trie.memory_size() as f64);
trie_metrics
.sparse_trie_retained_storage_tries
.set(trie.retained_storage_tries_count() as f64);
trie.store_anchored_with_guard(&mut guard, result.state_root);
guard.store(PreservedSparseTrie::anchored(trie, result.state_root));
deferred
} else {
debug!(
@@ -689,11 +666,12 @@ where
SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY,
SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY,
);
trie.store_prepared_cleared_with_guard(&mut guard);
guard.store(PreservedSparseTrie::cleared(trie));
deferred
};
// Drop guard before deferred to release lock before expensive deallocations
drop(guard);
executor.spawn_drop(deferred);
drop(deferred);
});
}
@@ -710,7 +688,7 @@ where
bundle_state: &BundleState,
) {
let disable_cache_metrics = self.disable_cache_metrics;
self.shared_caches.execution_cache().update_with_guard(|cached| {
self.execution_cache.update_with_guard(|cached| {
if cached.as_ref().is_some_and(|c| c.executed_block_hash() != block_with_parent.parent) {
debug!(
target: "engine::caching",
@@ -769,7 +747,6 @@ fn convert_serial<RawTx, Tx, TxEnv, InnerTx, Recovered, Err, C>(
let _ = prewarm_tx.send((idx, tx.clone()));
}
let _ = execute_tx.send(tx);
debug!(target: "engine::tree::payload_processor", idx, "yielded transaction");
}
}
@@ -1014,7 +991,7 @@ impl<R> Drop for CacheTaskHandle<R> {
/// - Prepares data for state root proof computation
/// - Runs concurrently but must not interfere with cache saves
#[derive(Clone, Debug, Default)]
pub(crate) struct PayloadExecutionCache {
pub struct PayloadExecutionCache {
/// Guarded cloneable cache identified by a block hash.
inner: Arc<RwLock<Option<SavedCache>>>,
/// Metrics for cache operations.
@@ -1022,15 +999,15 @@ pub(crate) struct PayloadExecutionCache {
}
impl PayloadExecutionCache {
/// Returns the cache backing store for `parent_hash` if it's available for reuse.
/// Returns the cache for `parent_hash` if it's available for use.
///
/// If the tracked cache is available but keyed to a different parent hash, the cache is
/// cleared and returned so callers can reuse the underlying allocations without carrying over
/// stale state.
/// A cache is considered available when:
/// - It exists and matches the requested parent hash
/// - No other tasks are currently using it (checked via Arc reference count)
#[instrument(level = "debug", target = "engine::tree::payload_processor", skip(self))]
pub(crate) fn get_cache_for(&self, parent_hash: B256) -> Option<SavedCache> {
let start = Instant::now();
let mut cache = self.inner.write();
let cache = self.inner.read();
let elapsed = start.elapsed();
self.metrics.execution_cache_wait_duration.record(elapsed.as_secs_f64());
@@ -1038,7 +1015,7 @@ impl PayloadExecutionCache {
warn!(blocked_for=?elapsed, "Blocked waiting for execution cache mutex");
}
if let Some(c) = cache.as_mut() {
if let Some(c) = cache.as_ref() {
let cached_hash = c.executed_block_hash();
// Check that the cache hash matches the parent hash of the current block. It won't
// match in case it's a fork block.
@@ -1059,13 +1036,13 @@ impl PayloadExecutionCache {
);
if available {
// If the has is available (no other threads are using it), but has a mismatching
// parent hash, we can just clear it and keep using without re-creating from
// scratch.
if !hash_matches {
// Fork block: clear and update the hash on the ORIGINAL before cloning.
// This prevents the canonical chain from matching on the stale hash
// and picking up polluted data if the fork block fails.
c.clear_with_hash(parent_hash);
c.clear();
}
return Some(c.clone());
return Some(c.clone())
} else if hash_matches {
self.metrics.execution_cache_in_use.increment(1);
}
@@ -1076,13 +1053,19 @@ impl PayloadExecutionCache {
None
}
/// Clears the tracked cache
#[expect(unused)]
pub(crate) fn clear(&self) {
self.inner.write().take();
}
/// Waits until the execution cache becomes available for use.
///
/// This acquires a write lock to ensure exclusive access, then immediately releases it.
/// This is useful for synchronization before starting payload processing.
///
/// Returns the time spent waiting for the lock.
pub(crate) fn wait_for_availability(&self) -> Duration {
pub fn wait_for_availability(&self) -> Duration {
let start = Instant::now();
// Acquire write lock to wait for any current holders to finish
let _guard = self.inner.write();
@@ -1110,7 +1093,7 @@ impl PayloadExecutionCache {
///
/// Violating this requirement can result in cache corruption, incorrect state data,
/// and potential consensus failures.
pub(crate) fn update_with_guard<F>(&self, update_fn: F)
pub fn update_with_guard<F>(&self, update_fn: F)
where
F: FnOnce(&mut Option<SavedCache>),
{
@@ -1179,9 +1162,8 @@ mod tests {
use super::PayloadExecutionCache;
use crate::tree::{
cached_state::{CachedStateMetrics, ExecutionCache, SavedCache},
payload_processor::{
evm_state_to_hashed_post_state, EngineSharedCaches, ExecutionEnv, PayloadProcessor,
},
payload_processor::{evm_state_to_hashed_post_state, ExecutionEnv, PayloadProcessor},
precompile_cache::PrecompileCacheMap,
StateProviderBuilder, TreeConfig,
};
use alloy_eips::eip1898::{BlockNumHash, BlockWithParent};
@@ -1254,18 +1236,10 @@ mod tests {
execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(hash)));
// When the parent hash doesn't match (fork block), the cache is cleared,
// hash updated on the original, and clone returned for reuse
// When the parent hash doesn't match, the cache is cleared and returned for reuse
let different_hash = B256::from([4u8; 32]);
let cache = execution_cache.get_cache_for(different_hash);
assert!(cache.is_some(), "cache should be returned for reuse after clearing");
drop(cache);
// The stored cache now has the fork block's parent hash.
// Canonical chain looking for original hash sees a mismatch → clears and reuses.
let original = execution_cache.get_cache_for(hash);
assert!(original.is_some(), "canonical chain gets cache back via mismatch+clear");
assert!(cache.is_some(), "cache should be returned for reuse after clearing")
}
#[test]
@@ -1293,7 +1267,7 @@ mod tests {
reth_tasks::Runtime::test(),
EthEvmConfig::new(Arc::new(ChainSpec::default())),
&TreeConfig::default(),
EngineSharedCaches::default(),
PrecompileCacheMap::default(),
);
let parent_hash = B256::from([1u8; 32]);
@@ -1305,17 +1279,13 @@ mod tests {
let bundle_state = BundleState::default();
// Cache should be empty initially
assert!(payload_processor
.shared_caches
.execution_cache()
.get_cache_for(block_hash)
.is_none());
assert!(payload_processor.execution_cache.get_cache_for(block_hash).is_none());
// Update cache with inserted block
payload_processor.on_inserted_executed_block(block_with_parent, &bundle_state);
// Cache should now exist for the block hash
let cached = payload_processor.shared_caches.execution_cache().get_cache_for(block_hash);
let cached = payload_processor.execution_cache.get_cache_for(block_hash);
assert!(cached.is_some());
assert_eq!(cached.unwrap().executed_block_hash(), block_hash);
}
@@ -1326,14 +1296,13 @@ mod tests {
reth_tasks::Runtime::test(),
EthEvmConfig::new(Arc::new(ChainSpec::default())),
&TreeConfig::default(),
EngineSharedCaches::default(),
PrecompileCacheMap::default(),
);
// Setup: populate cache with block 1
let block1_hash = B256::from([1u8; 32]);
payload_processor
.shared_caches
.execution_cache()
.execution_cache
.update_with_guard(|slot| *slot = Some(make_saved_cache(block1_hash)));
// Try to insert block 3 with wrong parent (should skip and keep block 1's cache)
@@ -1348,11 +1317,11 @@ mod tests {
payload_processor.on_inserted_executed_block(block_with_parent, &bundle_state);
// Cache should still be for block 1 (unchanged)
let cached = payload_processor.shared_caches.execution_cache().get_cache_for(block1_hash);
let cached = payload_processor.execution_cache.get_cache_for(block1_hash);
assert!(cached.is_some(), "Original cache should be preserved");
// Cache for block 3 should not exist
let cached3 = payload_processor.shared_caches.execution_cache().get_cache_for(block3_hash);
let cached3 = payload_processor.execution_cache.get_cache_for(block3_hash);
assert!(cached3.is_none(), "New block cache should not be created on mismatch");
}
@@ -1462,7 +1431,7 @@ mod tests {
reth_tasks::Runtime::test(),
EthEvmConfig::new(factory.chain_spec()),
&TreeConfig::default(),
EngineSharedCaches::default(),
PrecompileCacheMap::default(),
);
let provider_factory = BlockchainProvider::new(factory).unwrap();
@@ -1494,61 +1463,4 @@ mod tests {
"State root mismatch: task={root_from_task}, base={root_from_regular}"
);
}
/// Tests the full prewarm lifecycle for a fork block:
///
/// 1. Cache is at canonical block 4.
/// 2. Fork block (parent = block 2) checks out the cache via `get_cache_for`, simulating what
/// `PrewarmCacheTask` does when it receives a `SavedCache`.
/// 3. Prewarm populates the shared cache with fork-specific state.
/// 4. While the prewarm clone is alive, the cache is unavailable (`usage_guard` > 1).
/// 5. Prewarm drops without calling `save_cache` (fork block was invalid).
/// 6. Canonical block 5 (parent = block 4) must get a cache with correct hash and no stale fork
/// data.
#[test]
fn fork_prewarm_dropped_without_save_does_not_corrupt_cache() {
let execution_cache = PayloadExecutionCache::default();
// Canonical chain at block 4.
let block4_hash = B256::from([4u8; 32]);
execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(block4_hash)));
// Fork block arrives with parent = block 2. Prewarm task checks out the cache.
// This simulates PrewarmCacheTask receiving a SavedCache clone from get_cache_for.
let fork_parent = B256::from([2u8; 32]);
let prewarm_cache = execution_cache.get_cache_for(fork_parent);
assert!(prewarm_cache.is_some(), "prewarm should obtain cache for fork block");
let prewarm_cache = prewarm_cache.unwrap();
assert_eq!(prewarm_cache.executed_block_hash(), fork_parent);
// Prewarm populates cache with fork-specific state (ancestor data for block 2).
// Since ExecutionCache uses Arc<Inner>, this data is shared with the stored original.
let fork_addr = Address::from([0xBB; 20]);
let fork_key = B256::from([0xCC; 32]);
prewarm_cache.cache().insert_storage(fork_addr, fork_key, Some(U256::from(999)));
// While prewarm holds the clone, the usage_guard count > 1 → cache is in use.
let during_prewarm = execution_cache.get_cache_for(block4_hash);
assert!(
during_prewarm.is_none(),
"cache must be unavailable while prewarm holds a reference"
);
// Fork block fails — prewarm task drops without calling save_cache/update_with_guard.
drop(prewarm_cache);
// Canonical block 5 arrives (parent = block 4).
// Stored hash = fork_parent (our fix), so get_cache_for sees a mismatch,
// clears the stale fork data, and returns a cache with hash = block4_hash.
let block5_cache = execution_cache.get_cache_for(block4_hash);
assert!(
block5_cache.is_some(),
"canonical chain must get cache after fork prewarm is dropped"
);
assert_eq!(
block5_cache.as_ref().unwrap().executed_block_hash(),
block4_hash,
"cache must carry the canonical parent hash, not the fork parent"
);
}
}

View File

@@ -47,6 +47,16 @@ pub enum MultiProofMessage {
PrefetchProofs(MultiProofTargetsV2),
/// New state update from transaction execution with its source
StateUpdate(Source, EvmState),
/// State update that can be applied to the sparse trie without any new proofs.
///
/// It can be the case when all accounts and storage slots from the state update were already
/// fetched and revealed.
EmptyProof {
/// The index of this proof in the sequence of state updates
sequence_number: u64,
/// The state update that was used to calculate the proof
state: HashedPostState,
},
/// Pre-hashed state update from BAL conversion that can be applied directly without proofs.
HashedStateUpdate(HashedPostState),
/// Block Access List (EIP-7928; BAL) containing complete state changes for the block.
@@ -118,6 +128,41 @@ pub(crate) fn evm_state_to_hashed_post_state(update: EvmState) -> HashedPostStat
#[derive(Metrics, Clone)]
#[metrics(scope = "tree.root")]
pub(crate) struct MultiProofTaskMetrics {
/// Histogram of active storage workers processing proofs.
pub active_storage_workers_histogram: Histogram,
/// Histogram of active account workers processing proofs.
pub active_account_workers_histogram: Histogram,
/// Gauge for the maximum number of storage workers in the pool.
pub max_storage_workers: Gauge,
/// Gauge for the maximum number of account workers in the pool.
pub max_account_workers: Gauge,
/// Histogram of pending storage multiproofs in the queue.
pub pending_storage_multiproofs_histogram: Histogram,
/// Histogram of pending account multiproofs in the queue.
pub pending_account_multiproofs_histogram: Histogram,
/// Histogram of the number of prefetch proof target accounts.
pub prefetch_proof_targets_accounts_histogram: Histogram,
/// Histogram of the number of prefetch proof target storages.
pub prefetch_proof_targets_storages_histogram: Histogram,
/// Histogram of the number of prefetch proof target chunks.
pub prefetch_proof_chunks_histogram: Histogram,
/// Histogram of the number of state update proof target accounts.
pub state_update_proof_targets_accounts_histogram: Histogram,
/// Histogram of the number of state update proof target storages.
pub state_update_proof_targets_storages_histogram: Histogram,
/// Histogram of the number of state update proof target chunks.
pub state_update_proof_chunks_histogram: Histogram,
/// Histogram of prefetch proof batch sizes (number of messages merged).
pub prefetch_batch_size_histogram: Histogram,
/// Histogram of proof calculation durations.
pub proof_calculation_duration_histogram: Histogram,
/// Histogram of sparse trie update durations.
pub sparse_trie_update_duration_histogram: Histogram,
/// Histogram of durations spent revealing multiproof results into the sparse trie.
pub sparse_trie_reveal_multiproof_duration_histogram: Histogram,
/// Histogram of durations spent coalescing multiple proof results from the channel.
@@ -130,24 +175,21 @@ pub(crate) struct MultiProofTaskMetrics {
pub sparse_trie_final_update_duration_histogram: Histogram,
/// Histogram of sparse trie total durations.
pub sparse_trie_total_duration_histogram: Histogram,
/// Histogram of state updates received.
pub state_updates_received_histogram: Histogram,
/// Histogram of proofs processed.
pub proofs_processed_histogram: Histogram,
/// Histogram of total time spent in the multiproof task.
pub multiproof_task_total_duration_histogram: Histogram,
/// Total time spent waiting for the first state update or prefetch request.
pub first_update_wait_time_histogram: Histogram,
/// Total time spent waiting for the last proof result.
pub last_proof_wait_time_histogram: Histogram,
/// Time spent preparing the sparse trie for reuse after state root computation.
pub into_trie_for_reuse_duration_histogram: Histogram,
/// Time spent waiting for preserved sparse trie cache to become available.
pub sparse_trie_cache_wait_duration_histogram: Histogram,
/// Number of account leaf updates applied without needing a new proof (cache hits).
pub sparse_trie_account_cache_hits: Histogram,
/// Number of account leaf updates that required a new proof (cache misses).
pub sparse_trie_account_cache_misses: Histogram,
/// Number of storage leaf updates applied without needing a new proof (cache hits).
pub sparse_trie_storage_cache_hits: Histogram,
/// Number of storage leaf updates that required a new proof (cache misses).
pub sparse_trie_storage_cache_misses: Histogram,
/// Retained memory of the preserved sparse trie cache in bytes.
pub sparse_trie_retained_memory_bytes: Gauge,
/// Number of storage tries retained in the preserved sparse trie cache.
pub sparse_trie_retained_storage_tries: Gauge,
}
/// Dispatches work items as a single unit or in chunks based on target size and worker

View File

@@ -1,128 +1,44 @@
//! Preserved sparse trie for reuse across payload validations.
use super::{
PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS, SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY,
SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY,
};
use alloy_primitives::B256;
use parking_lot::Mutex;
use reth_trie_sparse::{
ArenaParallelSparseTrie, ConfigurableSparseTrie, ParallelSparseTrie, RevealableSparseTrie,
SparseStateTrie,
};
use std::{
ops::{Deref, DerefMut},
sync::Arc,
time::{Duration, Instant},
};
use reth_trie_sparse::SparseStateTrie;
use std::{sync::Arc, time::Instant};
use tracing::debug;
/// Type alias for the sparse trie type used in preservation.
type SparseTrie = SparseStateTrie<ConfigurableSparseTrie, ConfigurableSparseTrie>;
pub(super) type SparseTrie = SparseStateTrie;
/// Sparse trie implementation used by [`PayloadSparseTrieCache`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum PayloadSparseTrieKind {
/// Back sparse trie storage with hash maps.
#[default]
HashMap,
/// Back sparse trie storage with arena allocations.
Arena,
}
impl From<bool> for PayloadSparseTrieKind {
fn from(enable_arena_sparse_trie: bool) -> Self {
if enable_arena_sparse_trie {
Self::Arena
} else {
Self::HashMap
}
}
}
#[derive(Debug, Default)]
struct PayloadSparseTrieState {
latest_checkout_id: u64,
preserved: Option<PreservedSparseTrie>,
}
/// Outcome of storing a checked-out sparse trie back into the shared cache.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayloadSparseTrieStoreOutcome {
/// The checkout was the most recent lease and the trie was stored.
Stored,
/// A newer checkout had already been issued, so this stale lease was ignored.
IgnoredStaleCheckout,
}
/// Shared sparse trie cache that can be reused across payload validations.
/// Shared handle to a preserved sparse trie that can be reused across payload validations.
///
/// This is the public sparse-trie SDK surface exposed through
/// [`EngineSharedCaches`](super::EngineSharedCaches). Callers take or create a trie, use it for
/// payload work, then store it back either anchored to the resulting state root or cleared for
/// allocation reuse.
#[derive(Debug, Clone)]
pub struct PayloadSparseTrieCache {
kind: PayloadSparseTrieKind,
state: Arc<Mutex<PayloadSparseTrieState>>,
}
/// This is stored in [`PayloadProcessor`](super::PayloadProcessor) and cloned to pass to
/// [`SparseTrieCacheTask`](super::sparse_trie::SparseTrieCacheTask) for trie reuse.
#[derive(Debug, Default, Clone)]
pub(super) struct SharedPreservedSparseTrie(Arc<Mutex<Option<PreservedSparseTrie>>>);
impl Default for PayloadSparseTrieCache {
fn default() -> Self {
Self::new(PayloadSparseTrieKind::default())
}
}
impl PayloadSparseTrieCache {
/// Creates a sparse trie cache backed by the requested trie implementation.
pub fn new(kind: PayloadSparseTrieKind) -> Self {
Self { kind, state: Arc::new(Mutex::new(PayloadSparseTrieState::default())) }
impl SharedPreservedSparseTrie {
/// Takes the preserved trie if present, leaving `None` in its place.
pub(super) fn take(&self) -> Option<PreservedSparseTrie> {
self.0.lock().take()
}
/// Returns the sparse trie implementation used when the cache needs to create a new trie.
pub const fn kind(&self) -> PayloadSparseTrieKind {
self.kind
}
/// Takes a preserved trie for `parent_state_root` or creates a new trie if the cache is empty.
pub fn take_or_create_for(&self, parent_state_root: B256) -> SparseTrieCheckout {
let start = Instant::now();
let mut state = self.state.lock();
state.latest_checkout_id += 1;
let checkout_id = state.latest_checkout_id;
let trie = state
.preserved
.take()
.map(|preserved| preserved.into_trie_for(parent_state_root))
.unwrap_or_else(|| {
debug!(
target: "engine::tree::payload_processor",
%parent_state_root,
kind = ?self.kind,
"Creating new sparse trie - no preserved trie available"
);
new_sparse_trie(self.kind)
});
drop(state);
let elapsed = start.elapsed();
if elapsed.as_millis() > 5 {
debug!(
target: "engine::tree::payload_processor",
blocked_for=?elapsed,
"Waited for preserved sparse trie checkout"
);
}
SparseTrieCheckout { trie: Some(trie), cache: self.clone(), checkout_id }
/// Acquires a guard that blocks `take()` until dropped.
/// Use this before sending the state root result to ensure the next block
/// waits for the trie to be stored.
pub(super) fn lock(&self) -> PreservedTrieGuard<'_> {
PreservedTrieGuard(self.0.lock())
}
/// Waits until the sparse trie lock becomes available.
///
/// This acquires and immediately releases the lock, ensuring that any
/// ongoing operations complete before returning. Useful for synchronization
/// before starting payload processing.
///
/// Returns the time spent waiting for the lock.
pub fn wait_for_availability(&self) -> Duration {
pub(super) fn wait_for_availability(&self) -> std::time::Duration {
let start = Instant::now();
let _guard = self.state.lock();
let _guard = self.0.lock();
let elapsed = start.elapsed();
if elapsed.as_millis() > 5 {
debug!(
@@ -133,142 +49,27 @@ impl PayloadSparseTrieCache {
}
elapsed
}
/// Acquires a guard that blocks cache mutation until dropped.
///
/// Engine-internal code uses this before making the state-root result visible so the next
/// payload cannot observe an empty cache between send and store.
pub(super) fn lock(&self) -> PreservedTrieGuard<'_> {
PreservedTrieGuard { state: self.state.lock() }
}
}
/// A checked-out sparse trie lease.
///
/// This dereferences to [`SparseStateTrie`] so callers can reuse the trie directly. If the lease is
/// dropped without being stored back, a cleared trie is returned to the shared cache unless a newer
/// checkout has already superseded it.
#[derive(Debug)]
pub struct SparseTrieCheckout {
trie: Option<SparseTrie>,
cache: PayloadSparseTrieCache,
checkout_id: u64,
}
impl SparseTrieCheckout {
/// Stores the trie back into the shared cache anchored to the given state root.
pub fn store_anchored(self, state_root: B256) -> PayloadSparseTrieStoreOutcome {
let cache = self.cache.clone();
let mut guard = cache.lock();
self.store_anchored_with_guard(&mut guard, state_root)
}
/// Stores the trie back into the shared cache in a cleared state.
pub fn store_cleared(mut self) -> PayloadSparseTrieStoreOutcome {
let cache = self.cache.clone();
let mut trie = self.take_trie();
prepare_cleared_trie(&mut trie);
let deferred = trie.take_deferred_drops();
let mut guard = cache.lock();
let outcome = guard.store(self.checkout_id, PreservedSparseTrie::cleared(trie));
drop(guard);
drop(deferred);
outcome
}
/// Stores the trie back into the shared cache anchored to the given state root while the
/// caller is already holding the preservation lock.
pub(super) fn store_anchored_with_guard(
mut self,
guard: &mut PreservedTrieGuard<'_>,
state_root: B256,
) -> PayloadSparseTrieStoreOutcome {
guard.store(self.checkout_id, PreservedSparseTrie::anchored(self.take_trie(), state_root))
}
/// Stores an already-cleared trie back into the shared cache while the caller is already
/// holding the preservation lock.
pub(super) fn store_prepared_cleared_with_guard(
mut self,
guard: &mut PreservedTrieGuard<'_>,
) -> PayloadSparseTrieStoreOutcome {
guard.store(self.checkout_id, PreservedSparseTrie::cleared(self.take_trie()))
}
fn take_trie(&mut self) -> SparseTrie {
self.trie.take().expect("sparse trie checkout must hold a trie until it is stored")
}
}
impl Deref for SparseTrieCheckout {
type Target = SparseTrie;
fn deref(&self) -> &Self::Target {
self.trie.as_ref().expect("sparse trie checkout must hold a trie until it is stored")
}
}
impl DerefMut for SparseTrieCheckout {
fn deref_mut(&mut self) -> &mut Self::Target {
self.trie.as_mut().expect("sparse trie checkout must hold a trie until it is stored")
}
}
impl Drop for SparseTrieCheckout {
fn drop(&mut self) {
let Some(mut trie) = self.trie.take() else { return };
debug!(
target: "engine::tree::payload_processor",
checkout_id = self.checkout_id,
"Sparse trie checkout dropped before store, returning cleared trie to cache"
);
prepare_cleared_trie(&mut trie);
let deferred = trie.take_deferred_drops();
let mut guard = self.cache.lock();
let _ = guard.store(self.checkout_id, PreservedSparseTrie::cleared(trie));
drop(guard);
drop(deferred);
}
}
/// Guard that holds the lock on the preserved trie.
/// While held, take-or-create calls will block. Call `store()` to save the trie before dropping.
pub(super) struct PreservedTrieGuard<'a> {
state: parking_lot::MutexGuard<'a, PayloadSparseTrieState>,
}
/// While held, `take()` will block. Call `store()` to save the trie before dropping.
pub(super) struct PreservedTrieGuard<'a>(parking_lot::MutexGuard<'a, Option<PreservedSparseTrie>>);
impl PreservedTrieGuard<'_> {
/// Stores a preserved trie for later reuse if the checkout is still current.
fn store(
&mut self,
checkout_id: u64,
trie: PreservedSparseTrie,
) -> PayloadSparseTrieStoreOutcome {
if checkout_id != self.state.latest_checkout_id {
debug!(
target: "engine::tree::payload_processor",
checkout_id,
latest_checkout_id = self.state.latest_checkout_id,
"Ignoring stale sparse trie checkout"
);
return PayloadSparseTrieStoreOutcome::IgnoredStaleCheckout;
}
self.state.preserved.replace(trie);
PayloadSparseTrieStoreOutcome::Stored
/// Stores a preserved trie for later reuse.
pub(super) fn store(&mut self, trie: PreservedSparseTrie) {
self.0.replace(trie);
}
}
/// A preserved sparse trie that can be reused across payload validations.
///
/// The trie exists in one of two states:
/// - **Anchored**: Has a computed state root and can be reused for payloads whose parent state
/// root matches the anchor.
/// - **Anchored**: Has a computed state root and can be reused for payloads whose parent state root
/// matches the anchor.
/// - **Cleared**: Trie data has been cleared but allocations are preserved for reuse.
#[derive(Debug)]
enum PreservedSparseTrie {
pub(super) enum PreservedSparseTrie {
/// Trie with a computed state root that can be reused for continuation payloads.
Anchored {
/// The sparse state trie (pruned after root computation).
@@ -286,17 +87,24 @@ enum PreservedSparseTrie {
impl PreservedSparseTrie {
/// Creates a new anchored preserved trie.
const fn anchored(trie: SparseTrie, state_root: B256) -> Self {
///
/// The `state_root` is the computed state root from the trie, which becomes the
/// anchor for determining if subsequent payloads can reuse this trie.
pub(super) const fn anchored(trie: SparseTrie, state_root: B256) -> Self {
Self::Anchored { trie, state_root }
}
/// Creates a cleared preserved trie (allocations preserved, data cleared).
const fn cleared(trie: SparseTrie) -> Self {
pub(super) const fn cleared(trie: SparseTrie) -> Self {
Self::Cleared { trie }
}
/// Consumes self and returns the trie for reuse.
fn into_trie_for(self, parent_state_root: B256) -> SparseTrie {
///
/// If the preserved trie is anchored and the parent state root matches, the pruned
/// trie structure is reused directly. Otherwise, the trie is cleared but allocations
/// are preserved to reduce memory overhead.
pub(super) fn into_trie_for(self, parent_state_root: B256) -> SparseTrie {
match self {
Self::Anchored { trie, state_root } if state_root == parent_state_root => {
debug!(
@@ -327,111 +135,3 @@ impl PreservedSparseTrie {
}
}
}
fn new_sparse_trie(kind: PayloadSparseTrieKind) -> SparseTrie {
let default_trie = match kind {
PayloadSparseTrieKind::HashMap => {
RevealableSparseTrie::blind_from(ConfigurableSparseTrie::HashMap(
ParallelSparseTrie::default()
.with_parallelism_thresholds(PARALLEL_SPARSE_TRIE_PARALLELISM_THRESHOLDS),
))
}
PayloadSparseTrieKind::Arena => RevealableSparseTrie::blind_from(
ConfigurableSparseTrie::Arena(ArenaParallelSparseTrie::default()),
),
};
SparseStateTrie::default()
.with_accounts_trie(default_trie.clone())
.with_default_storage_trie(default_trie)
.with_updates(true)
}
fn prepare_cleared_trie(trie: &mut SparseTrie) {
trie.clear();
trie.shrink_to(SPARSE_TRIE_MAX_NODES_SHRINK_CAPACITY, SPARSE_TRIE_MAX_VALUES_SHRINK_CAPACITY);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn take_or_create_reuses_matching_anchor() {
let cache = PayloadSparseTrieCache::default();
let state_root = B256::with_last_byte(1);
assert_eq!(
cache.take_or_create_for(state_root).store_anchored(state_root),
PayloadSparseTrieStoreOutcome::Stored
);
match cache.state.lock().preserved.as_ref() {
Some(PreservedSparseTrie::Anchored { state_root: anchored, .. }) => {
assert_eq!(*anchored, state_root);
}
other => panic!("expected anchored trie, got {other:?}"),
}
}
#[test]
fn drop_restores_cleared_trie() {
let cache = PayloadSparseTrieCache::default();
let state_root = B256::with_last_byte(2);
let mut checkout = cache.take_or_create_for(state_root);
checkout.set_updates(true);
drop(checkout);
match cache.state.lock().preserved.as_ref() {
Some(PreservedSparseTrie::Cleared { .. }) => {}
other => panic!("expected cleared trie, got {other:?}"),
}
}
#[test]
fn stale_checkout_does_not_overwrite_newer_store() {
let cache = PayloadSparseTrieCache::default();
let parent_state_root = B256::with_last_byte(3);
let anchored_state_root = B256::with_last_byte(4);
let stale = cache.take_or_create_for(parent_state_root);
let fresh = cache.take_or_create_for(parent_state_root);
assert_eq!(
fresh.store_anchored(anchored_state_root),
PayloadSparseTrieStoreOutcome::Stored
);
assert_eq!(stale.store_cleared(), PayloadSparseTrieStoreOutcome::IgnoredStaleCheckout);
match cache.state.lock().preserved.as_ref() {
Some(PreservedSparseTrie::Anchored { state_root, .. }) => {
assert_eq!(*state_root, anchored_state_root);
}
other => panic!("expected anchored trie to survive stale checkout, got {other:?}"),
}
}
#[test]
fn stale_checkout_drop_does_not_overwrite_newer_store() {
let cache = PayloadSparseTrieCache::default();
let parent_state_root = B256::with_last_byte(5);
let anchored_state_root = B256::with_last_byte(6);
let stale = cache.take_or_create_for(parent_state_root);
let fresh = cache.take_or_create_for(parent_state_root);
assert_eq!(
fresh.store_anchored(anchored_state_root),
PayloadSparseTrieStoreOutcome::Stored
);
drop(stale);
match cache.state.lock().preserved.as_ref() {
Some(PreservedSparseTrie::Anchored { state_root, .. }) => {
assert_eq!(*state_root, anchored_state_root);
}
other => panic!("expected anchored trie to survive stale checkout drop, got {other:?}"),
}
}
}

View File

@@ -84,7 +84,7 @@ where
Evm: ConfigureEvm<Primitives = N> + 'static,
{
/// Initializes the task with the given transactions pending execution
pub(crate) fn new(
pub fn new(
executor: Runtime,
execution_cache: PayloadExecutionCache,
ctx: PrewarmContext<N, P, Evm>,
@@ -149,7 +149,7 @@ where
});
while let Ok((index, tx)) = pending.recv() {
if ctx.should_stop() {
if ctx.terminate_execution.load(Ordering::Relaxed) {
trace!(
target: "engine::tree::payload_processor::prewarm",
"Termination requested, stopping transaction distribution"
@@ -207,13 +207,13 @@ where
Tx: ExecutableTxFor<Evm>,
{
WorkerPool::with_worker_mut(|worker| {
let Some(evm) =
let Some((evm, metrics, terminate_execution)) =
worker.get_or_init::<PrewarmEvmState<Evm>>(|| ctx.evm_for_ctx()).as_mut()
else {
return;
};
if ctx.should_stop() {
if terminate_execution.load(Ordering::Relaxed) {
return;
}
@@ -235,25 +235,25 @@ where
sender=%tx.signer(),
"Error when executing prewarm transaction",
);
ctx.metrics.transaction_errors.increment(1);
metrics.transaction_errors.increment(1);
return;
}
};
ctx.metrics.execution_duration.record(start.elapsed());
metrics.execution_duration.record(start.elapsed());
if ctx.should_stop() {
if terminate_execution.load(Ordering::Relaxed) {
return;
}
if index > 0 {
let (targets, storage_targets) = multiproof_targets_from_state(res.state);
ctx.metrics.prefetch_storage_targets.record(storage_targets as f64);
metrics.prefetch_storage_targets.record(storage_targets as f64);
if let Some(to_multi_proof) = to_multi_proof {
let _ = to_multi_proof.send(MultiProofMessage::PrefetchProofs(targets));
}
}
ctx.metrics.total_runtime.record(start.elapsed());
metrics.total_runtime.record(start.elapsed());
});
}
@@ -358,7 +358,7 @@ where
bal.par_iter().for_each_init(
|| (ctx.clone(), None::<CachedStateProvider<reth_provider::StateProviderBox>>),
|(ctx, provider), account| {
if ctx.should_stop() {
if ctx.terminate_execution.load(Ordering::Relaxed) {
return;
}
ctx.prefetch_bal_account(provider, account);
@@ -452,7 +452,7 @@ where
PrewarmTaskEvent::TerminateTransactionExecution => {
// stop tx processing
debug!(target: "engine::tree::prewarm", "Terminating prewarm execution");
self.ctx.stop();
self.ctx.terminate_execution.store(true, Ordering::Relaxed);
}
PrewarmTaskEvent::Terminate { execution_outcome, valid_block_rx } => {
trace!(target: "engine::tree::payload_processor::prewarm", "Received termination signal");
@@ -519,8 +519,11 @@ where
/// Per-thread EVM state initialised by [`PrewarmContext::evm_for_ctx`] and stored in
/// [`WorkerPool`] workers via [`Worker::get_or_init`](reth_tasks::pool::Worker::get_or_init).
type PrewarmEvmState<Evm> =
Option<EvmFor<Evm, StateProviderDatabase<reth_provider::StateProviderBox>>>;
type PrewarmEvmState<Evm> = Option<(
EvmFor<Evm, StateProviderDatabase<reth_provider::StateProviderBox>>,
PrewarmMetrics,
Arc<AtomicBool>,
)>;
impl<N, P, Evm> PrewarmContext<N, P, Evm>
where
@@ -528,7 +531,7 @@ where
P: BlockReader + StateProviderFactory + StateReader + Clone + 'static,
Evm: ConfigureEvm<Primitives = N> + 'static,
{
/// Creates a per-thread EVM for prewarming.
/// Creates a per-thread EVM, metrics handle, and termination flag for prewarming.
#[instrument(level = "debug", target = "engine::tree::payload_processor::prewarm", skip_all)]
fn evm_for_ctx(&self) -> PrewarmEvmState<Evm> {
let mut state_provider = match self.provider.build() {
@@ -579,19 +582,7 @@ where
});
}
Some(evm)
}
/// Returns `true` if prewarming should stop.
#[inline]
pub fn should_stop(&self) -> bool {
self.terminate_execution.load(Ordering::Relaxed)
}
/// Signals all prewarm tasks to stop execution.
#[inline]
pub fn stop(&self) {
self.terminate_execution.store(true, Ordering::Relaxed);
Some((evm, self.metrics.clone(), self.terminate_execution.clone()))
}
/// Prefetches a single account and all its storage slots from the BAL into the cache.

View File

@@ -7,13 +7,13 @@ use crate::tree::{
dispatch_with_chunking, evm_state_to_hashed_post_state, MultiProofMessage,
DEFAULT_MAX_TARGETS_FOR_CHUNKING,
},
payload_processor::{multiproof::MultiProofTaskMetrics, SparseTrieCheckout},
payload_processor::multiproof::MultiProofTaskMetrics,
};
use alloy_primitives::B256;
use alloy_rlp::{Decodable, Encodable};
use crossbeam_channel::{Receiver as CrossbeamReceiver, Sender as CrossbeamSender};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use reth_primitives_traits::{Account, FastInstant as Instant};
use rayon::iter::ParallelIterator;
use reth_primitives_traits::{Account, FastInstant as Instant, ParallelBridgeBuffered};
use reth_tasks::Runtime;
use reth_trie::{
updates::TrieUpdates, DecodedMultiProofV2, HashedPostState, TrieAccount, EMPTY_ROOT_HASH,
@@ -29,8 +29,8 @@ use reth_trie_parallel::{
#[cfg(feature = "trie-debug")]
use reth_trie_sparse::debug_recorder::TrieDebugRecorder;
use reth_trie_sparse::{
errors::SparseTrieResult, ConfigurableSparseTrie, DeferredDrops, LeafUpdate,
RevealableSparseTrie,
errors::SparseTrieResult, DeferredDrops, LeafUpdate, ParallelSparseTrie, SparseStateTrie,
SparseTrie,
};
use revm_primitives::{hash_map::Entry, B256Map};
use tracing::{debug, debug_span, error, instrument, trace_span};
@@ -39,7 +39,7 @@ use tracing::{debug, debug_span, error, instrument, trace_span};
const MAX_PENDING_UPDATES: usize = 100;
/// Sparse trie task implementation that uses in-memory sparse trie data to schedule proof fetching.
pub(super) struct SparseTrieCacheTask {
pub(super) struct SparseTrieCacheTask<A = ParallelSparseTrie, S = ParallelSparseTrie> {
/// Sender for proof results.
proof_result_tx: CrossbeamSender<ProofResultMessage>,
/// Receiver for proof results directly from workers.
@@ -47,7 +47,7 @@ pub(super) struct SparseTrieCacheTask {
/// Receives updates from execution and prewarming.
updates: CrossbeamReceiver<SparseTrieTaskMessage>,
/// `SparseStateTrie` used for computing the state root.
trie: SparseTrieCheckout,
trie: SparseStateTrie<A, S>,
/// Handle to the proof worker pools (storage and account).
proof_worker_handle: ProofWorkerHandle,
@@ -92,14 +92,6 @@ pub(super) struct SparseTrieCacheTask {
account_rlp_buf: Vec<u8>,
/// Whether the last state update has been received.
finished_state_updates: bool,
/// Accumulated account leaf update cache hits.
account_cache_hits: u64,
/// Accumulated account leaf update cache misses.
account_cache_misses: u64,
/// Accumulated storage leaf update cache hits.
storage_cache_hits: u64,
/// Accumulated storage leaf update cache misses.
storage_cache_misses: u64,
/// Pending proof targets queued for dispatch to proof workers.
pending_targets: PendingTargets,
/// Number of pending execution/prewarming updates received but not yet passed to
@@ -110,14 +102,18 @@ pub(super) struct SparseTrieCacheTask {
metrics: MultiProofTaskMetrics,
}
impl SparseTrieCacheTask {
impl<A, S> SparseTrieCacheTask<A, S>
where
A: SparseTrie + Default,
S: SparseTrie + Default + Clone,
{
/// Creates a new sparse trie, pre-populating with an existing [`SparseStateTrie`].
pub(super) fn new_with_checkout(
pub(super) fn new_with_trie(
executor: &Runtime,
updates: CrossbeamReceiver<MultiProofMessage>,
proof_worker_handle: ProofWorkerHandle,
metrics: MultiProofTaskMetrics,
trie: SparseTrieCheckout,
trie: SparseStateTrie<A, S>,
chunk_size: usize,
) -> Self {
let (proof_result_tx, proof_result_rx) = crossbeam_channel::unbounded();
@@ -146,10 +142,6 @@ impl SparseTrieCacheTask {
fetched_storage_targets: Default::default(),
account_rlp_buf: Vec::with_capacity(TRIE_ACCOUNT_RLP_MAX_SIZE),
finished_state_updates: Default::default(),
account_cache_hits: 0,
account_cache_misses: 0,
storage_cache_hits: 0,
storage_cache_misses: 0,
pending_targets: Default::default(),
pending_updates: Default::default(),
metrics,
@@ -175,7 +167,9 @@ impl SparseTrieCacheTask {
MultiProofMessage::FinishedStateUpdates => {
SparseTrieTaskMessage::FinishedStateUpdates
}
MultiProofMessage::BlockAccessList(_) => continue,
MultiProofMessage::EmptyProof { .. } | MultiProofMessage::BlockAccessList(_) => {
continue
}
MultiProofMessage::HashedStateUpdate(state) => {
SparseTrieTaskMessage::HashedState(state)
}
@@ -195,17 +189,17 @@ impl SparseTrieCacheTask {
/// benchmarking purposes.
pub(super) fn into_trie_for_reuse(
self,
max_hot_slots: usize,
max_hot_accounts: usize,
prune_depth: usize,
max_storage_tries: usize,
max_nodes_capacity: usize,
max_values_capacity: usize,
disable_pruning: bool,
updates: &TrieUpdates,
) -> (SparseTrieCheckout, DeferredDrops) {
) -> (SparseStateTrie<A, S>, DeferredDrops) {
let Self { mut trie, .. } = self;
trie.commit_updates(updates);
if !disable_pruning {
trie.prune(max_hot_slots, max_hot_accounts);
trie.prune(prune_depth, max_storage_tries);
trie.shrink_to(max_nodes_capacity, max_values_capacity);
}
let deferred = trie.take_deferred_drops();
@@ -220,7 +214,7 @@ impl SparseTrieCacheTask {
self,
max_nodes_capacity: usize,
max_values_capacity: usize,
) -> (SparseTrieCheckout, DeferredDrops) {
) -> (SparseStateTrie<A, S>, DeferredDrops) {
let Self { mut trie, .. } = self;
trie.clear();
trie.shrink_to(max_nodes_capacity, max_values_capacity);
@@ -302,9 +296,9 @@ impl SparseTrieCacheTask {
self.promote_pending_account_updates()?;
self.metrics.sparse_trie_process_updates_duration_histogram.record(t.elapsed());
if self.finished_state_updates
&& self.account_updates.is_empty()
&& self.storage_updates.iter().all(|(_, updates)| updates.is_empty())
if self.finished_state_updates &&
self.account_updates.is_empty() &&
self.storage_updates.iter().all(|(_, updates)| updates.is_empty())
{
break;
}
@@ -338,15 +332,6 @@ impl SparseTrieCacheTask {
self.metrics.sparse_trie_final_update_duration_histogram.record(end.duration_since(start));
self.metrics.sparse_trie_total_duration_histogram.record(end.duration_since(now));
self.metrics.sparse_trie_account_cache_hits.record(self.account_cache_hits as f64);
self.metrics.sparse_trie_account_cache_misses.record(self.account_cache_misses as f64);
self.metrics.sparse_trie_storage_cache_hits.record(self.storage_cache_hits as f64);
self.metrics.sparse_trie_storage_cache_misses.record(self.storage_cache_misses as f64);
self.account_cache_hits = 0;
self.account_cache_misses = 0;
self.storage_cache_hits = 0;
self.storage_cache_misses = 0;
Ok(StateRootComputeOutcome {
state_root,
trie_updates: Arc::new(trie_updates),
@@ -378,13 +363,13 @@ impl SparseTrieCacheTask {
}
for (address, slots) in targets.storage_targets {
if !slots.is_empty() {
// Look up outer map once per address instead of once per slot.
let new_updates = self.new_storage_updates.entry(address).or_default();
for slot in slots {
// Only touch storages that are not yet present in the updates set.
new_updates.entry(slot.key()).or_insert(LeafUpdate::Touched);
}
for slot in slots {
// Only touch storages that are not yet present in the updates set.
self.new_storage_updates
.entry(address)
.or_default()
.entry(slot.key())
.or_insert(LeafUpdate::Touched);
}
// Touch corresponding account leaf to make sure its revealed in accounts trie for
@@ -401,26 +386,19 @@ impl SparseTrieCacheTask {
)]
fn on_hashed_state_update(&mut self, hashed_state_update: HashedPostState) {
for (address, storage) in hashed_state_update.storages {
if !storage.storage.is_empty() {
// Look up outer maps once per address instead of once per slot.
let new_updates = self.new_storage_updates.entry(address).or_default();
let mut existing_updates = self.storage_updates.get_mut(&address);
for (slot, value) in storage.storage {
let encoded = if value.is_zero() {
Vec::new()
} else {
alloy_rlp::encode_fixed_size(&value).to_vec()
};
self.new_storage_updates
.entry(address)
.or_default()
.insert(slot, LeafUpdate::Changed(encoded));
for (slot, value) in storage.storage {
self.trie.record_slot_touch(address, slot);
let encoded = if value.is_zero() {
Vec::new()
} else {
alloy_rlp::encode_fixed_size(&value).to_vec()
};
new_updates.insert(slot, LeafUpdate::Changed(encoded));
// Remove an existing storage update if it exists.
if let Some(ref mut existing) = existing_updates {
existing.remove(&slot);
}
}
// Remove an existing storage update if it exists.
self.storage_updates.get_mut(&address).and_then(|updates| updates.remove(&slot));
}
// Make sure account is tracked in `account_updates` so that it is revealed in accounts
@@ -433,8 +411,6 @@ impl SparseTrieCacheTask {
}
for (address, account) in hashed_state_update.accounts {
self.trie.record_account_touch(address);
// Track account as touched.
//
// This might overwrite an existing update, which is fine, because storage root from it
@@ -529,7 +505,6 @@ impl SparseTrieCacheTask {
let fetched = self.fetched_storage_targets.entry(*address).or_default();
let mut targets = Vec::new();
let updates_len_before = updates.len();
trie.update_leaves(updates, |path, min_len| match fetched.entry(path) {
Entry::Occupied(mut entry) => {
if min_len < *entry.get() {
@@ -542,9 +517,6 @@ impl SparseTrieCacheTask {
targets.push(ProofV2Target::new(path).with_min_len(min_len));
}
})?;
let updates_len_after = updates.len();
self.storage_cache_hits += (updates_len_before - updates_len_after) as u64;
self.storage_cache_misses += updates_len_after as u64;
if !targets.is_empty() {
self.pending_targets.extend_storage_targets(address, targets);
@@ -590,64 +562,7 @@ impl SparseTrieCacheTask {
}
})?;
let updates_len_after = account_updates.len();
self.account_cache_hits += (updates_len_before - updates_len_after) as u64;
self.account_cache_misses += updates_len_after as u64;
Ok(updates_len_after < updates_len_before)
}
/// Computes storage roots for accounts whose storage updates are fully drained.
///
/// For each storage trie T that:
/// 1. was modified in the current block,
/// 2. all the storage updates are fully drained,
/// 3. but the storage root hasn't been updated yet,
///
/// we trigger state root computation on a rayon pool.
#[instrument(
level = "debug",
target = "engine::tree::payload_processor::sparse_trie",
skip_all
)]
fn compute_drained_storage_roots(&mut self) {
let addresses_to_compute_roots: Vec<_> = self
.storage_updates
.iter()
.filter_map(|(address, updates)| updates.is_empty().then_some(*address))
.collect();
struct SendStorageTriePtr(*mut RevealableSparseTrie<ConfigurableSparseTrie>);
// SAFETY: this wrapper only forwards the pointer across rayon; deref invariants are
// documented at the use site below.
unsafe impl Send for SendStorageTriePtr {}
let mut tries_to_compute_roots: Vec<(B256, SendStorageTriePtr)> =
Vec::with_capacity(addresses_to_compute_roots.len());
for address in addresses_to_compute_roots {
if let Some(trie) = self.trie.storage_tries_mut().get_mut(&address)
&& !trie.is_root_cached()
{
tries_to_compute_roots.push((address, SendStorageTriePtr(trie)));
}
}
let parent_span = tracing::Span::current();
tries_to_compute_roots.into_par_iter().for_each(|(address, SendStorageTriePtr(trie))| {
let _enter = debug_span!(
target: "engine::tree::payload_processor::sparse_trie",
parent: &parent_span,
"storage_root",
?address
)
.entered();
// SAFETY:
// - pointers are created from `storage_tries_mut().get_mut(address)` above;
// - `addresses_to_compute_roots` comes from map iteration, so addresses are unique;
// - we do not insert/remove entries between pointer collection and use, so pointers
// stay valid and map reallocation cannot occur;
// - each pointer is consumed by at most one rayon task, so no aliasing mutable access.
unsafe { (*trie).root().expect("updates are drained, trie should be revealed by now") };
});
Ok(account_updates.len() < updates_len_before)
}
/// Iterates through all storage tries for which all updates were processed, computes their
@@ -665,7 +580,21 @@ impl SparseTrieCacheTask {
return Ok(());
}
self.compute_drained_storage_roots();
let span = debug_span!("compute_storage_roots").entered();
self
.trie
.storage_tries_mut()
.iter_mut()
.filter(|(address, trie)| {
self.storage_updates.get(*address).is_some_and(|updates| updates.is_empty()) &&
!trie.is_root_cached()
})
.par_bridge_buffered()
.for_each(|(address, trie)| {
let _enter = debug_span!(target: "engine::tree::payload_processor::sparse_trie", parent: &span, "storage_root", ?address).entered();
trie.root().expect("updates are drained, trie should be revealed by now");
});
drop(span);
loop {
let span = debug_span!("promote_updates", promoted = tracing::field::Empty).entered();
@@ -724,7 +653,7 @@ impl SparseTrieCacheTask {
// We need to keep iterating if any updates are being drained because that might
// indicate that more pending account updates can be promoted.
if num_promoted == 0 || !self.process_account_leaf_updates(false)? {
break;
break
}
}
@@ -845,6 +774,7 @@ pub struct StateRootComputeOutcome {
mod tests {
use super::*;
use alloy_primitives::{keccak256, Address, B256, U256};
use reth_trie_sparse::ParallelSparseTrie;
#[test]
fn test_run_hashing_task_hashed_state_update_forwards() {
@@ -867,7 +797,10 @@ mod tests {
let expected_state = hashed_state.clone();
let handle = std::thread::spawn(move || {
SparseTrieCacheTask::run_hashing_task(updates_rx, hashed_state_tx);
SparseTrieCacheTask::<ParallelSparseTrie, ParallelSparseTrie>::run_hashing_task(
updates_rx,
hashed_state_tx,
);
});
updates_tx.send(MultiProofMessage::HashedStateUpdate(hashed_state)).unwrap();

View File

@@ -1,10 +1,10 @@
//! Types and traits for validating blocks and payloads.
use crate::tree::{
cached_state::{CacheStats, CachedStateProvider},
cached_state::CachedStateProvider,
error::{InsertBlockError, InsertBlockErrorKind, InsertPayloadError},
instrumented_state::{InstrumentedStateProvider, StateProviderStats},
payload_processor::{EngineSharedCaches, PayloadProcessor},
instrumented_state::InstrumentedStateProvider,
payload_processor::PayloadProcessor,
precompile_cache::{CachedPrecompile, CachedPrecompileMetrics, PrecompileCacheMap},
sparse_trie::StateRootComputeOutcome,
CacheWaitDurations, EngineApiMetrics, EngineApiTreeState, ExecutionEnv, PayloadHandle,
@@ -14,14 +14,12 @@ use alloy_consensus::transaction::{Either, TxHashRef};
use alloy_eip7928::BlockAccessList;
use alloy_eips::{eip1898::BlockWithParent, eip4895::Withdrawal, NumHash};
use alloy_evm::Evm;
use alloy_primitives::{map::B256Set, B256};
use alloy_primitives::B256;
#[cfg(feature = "trie-debug")]
use reth_trie_sparse::debug_recorder::TrieDebugRecorder;
use crate::tree::payload_processor::receipt_root_task::{IndexedReceipt, ReceiptRootTaskHandle};
use reth_chain_state::{
CanonicalInMemoryState, DeferredTrieData, ExecutedBlock, ExecutionTimingStats, LazyOverlay,
};
use reth_chain_state::{CanonicalInMemoryState, DeferredTrieData, ExecutedBlock, LazyOverlay};
use reth_consensus::{ConsensusError, FullConsensus, ReceiptRootBloom};
use reth_engine_primitives::{
ConfigureEngineEvm, ExecutableTxIterator, ExecutionPayload, InvalidBlockHook, PayloadValidator,
@@ -44,11 +42,11 @@ use reth_provider::{
ProviderError, PruneCheckpointReader, StageCheckpointReader, StateProvider,
StateProviderFactory, StateReader, StorageChangeSetReader, StorageSettingsCache,
};
use reth_revm::db::{states::bundle_state::BundleRetention, BundleAccount, State};
use reth_trie::{trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState, StateRoot};
use reth_revm::db::{states::bundle_state::BundleRetention, State};
use reth_trie::{updates::TrieUpdates, HashedPostState, StateRoot};
use reth_trie_db::ChangesetCache;
use reth_trie_parallel::root::{ParallelStateRoot, ParallelStateRootError};
use revm_primitives::{Address, KECCAK_EMPTY};
use revm_primitives::Address;
use std::{
collections::HashMap,
panic::{self, AssertUnwindSafe},
@@ -57,23 +55,12 @@ use std::{
mpsc::RecvTimeoutError,
Arc,
},
time::Duration,
};
use tracing::{debug, debug_span, error, info, instrument, trace, warn, Span};
/// Output of block or payload validation.
pub type ValidationOutcome<N, E = InsertPayloadError<BlockTy<N>>> =
Result<(ExecutedBlock<N>, Option<Box<ExecutionTimingStats>>), E>;
/// Handle to a [`HashedPostState`] computed on a background thread.
type LazyHashedPostState = reth_tasks::LazyHandle<HashedPostState>;
/// Result type for block validation with optional timing stats.
type InsertPayloadResult<N> = Result<
(ExecutedBlock<N>, Option<Box<ExecutionTimingStats>>),
InsertPayloadError<<N as NodePrimitives>::Block>,
>;
/// Context providing access to tree state during validation.
///
/// This context is provided to the [`EngineValidator`] and includes the state of the tree's
@@ -102,9 +89,7 @@ impl<'a, N: NodePrimitives> TreeCtx<'a, N> {
) -> Self {
Self { state, canonical_in_memory_state }
}
}
impl<'a, N: NodePrimitives> TreeCtx<'a, N> {
/// Returns a reference to the engine tree state
pub const fn state(&self) -> &EngineApiTreeState<N> {
&*self.state
@@ -190,13 +175,16 @@ where
validator: V,
config: TreeConfig,
invalid_block_hook: Box<dyn InvalidBlockHook<N>>,
shared_caches: EngineSharedCaches<Evm>,
changeset_cache: ChangesetCache,
runtime: reth_tasks::Runtime,
) -> Self {
let precompile_cache_map = shared_caches.precompile_cache_map();
let payload_processor =
PayloadProcessor::new(runtime.clone(), evm_config.clone(), &config, shared_caches);
let precompile_cache_map = PrecompileCacheMap::default();
let payload_processor = PayloadProcessor::new(
runtime.clone(),
evm_config.clone(),
&config,
precompile_cache_map.clone(),
);
Self {
provider,
consensus,
@@ -292,7 +280,7 @@ where
input: BlockOrPayload<T>,
execution_err: InsertBlockErrorKind,
parent_block: &SealedHeader<N::BlockHeader>,
) -> InsertPayloadResult<N>
) -> Result<ExecutedBlock<N>, InsertPayloadError<N::Block>>
where
V: PayloadValidator<T, Block = N::Block>,
{
@@ -310,7 +298,7 @@ where
// Validate block consensus rules which includes header validation
if let Err(consensus_err) = self.validate_block_inner(&block, None) {
// Header validation error takes precedence over execution error
return Err(InsertBlockError::new(block, consensus_err.into()).into());
return Err(InsertBlockError::new(block, consensus_err.into()).into())
}
// Also validate against the parent
@@ -318,7 +306,7 @@ where
self.consensus.validate_header_against_parent(block.sealed_header(), parent_block)
{
// Parent validation error takes precedence over execution error
return Err(InsertBlockError::new(block, consensus_err.into()).into());
return Err(InsertBlockError::new(block, consensus_err.into()).into())
}
// No header validation errors, return the original execution error
@@ -345,7 +333,7 @@ where
&mut self,
input: BlockOrPayload<T>,
mut ctx: TreeCtx<'_, N>,
) -> InsertPayloadResult<N>
) -> ValidationOutcome<N, InsertPayloadError<N::Block>>
where
V: PayloadValidator<T, Block = N::Block> + Clone,
Evm: ConfigureEngineEvm<T::ExecutionData, Primitives = N>,
@@ -393,7 +381,7 @@ where
Ok(val) => val,
Err(e) => {
let block = convert_to_block(input)?;
return Err(InsertBlockError::new(block, e.into()).into());
return Err(InsertBlockError::new(block, e.into()).into())
}
}
};
@@ -426,7 +414,7 @@ where
convert_to_block(input)?,
ProviderError::HeaderNotFound(parent_hash.into()).into(),
)
.into());
.into())
};
let mut state_provider = ensure_ok!(provider_builder.build());
drop(_enter);
@@ -439,7 +427,7 @@ where
convert_to_block(input)?,
ProviderError::HeaderNotFound(parent_hash.into()).into(),
)
.into());
.into())
};
let evm_env = debug_span!(target: "engine::tree::payload_validator", "evm_env")
@@ -497,39 +485,25 @@ where
block_access_list,
));
// Create optional cache stats for detailed block logging
let slow_block_enabled = self.config.slow_block_threshold().is_some();
let cache_stats = slow_block_enabled.then(|| Arc::new(CacheStats::default()));
// Use cached state provider before executing, used in execution after prewarming threads
// complete
if let Some((caches, cache_metrics)) = handle.caches().zip(handle.cache_metrics()) {
state_provider = Box::new(
CachedStateProvider::new(state_provider, caches, cache_metrics)
.with_cache_stats(cache_stats.clone()),
);
state_provider =
Box::new(CachedStateProvider::new(state_provider, caches, cache_metrics));
};
let state_provider_stats = if slow_block_enabled || self.config.state_provider_metrics() {
let instrumented_state_provider =
InstrumentedStateProvider::new(state_provider, "engine");
let stats = slow_block_enabled.then(|| instrumented_state_provider.stats());
state_provider = Box::new(instrumented_state_provider);
stats
} else {
None
};
if self.config.state_provider_metrics() {
state_provider = Box::new(InstrumentedStateProvider::new(state_provider, "engine"));
}
// Execute the block and handle any execution errors.
// The receipt root task is spawned before execution and receives receipts incrementally
// as transactions complete, allowing parallel computation during execution.
let execute_block_start = Instant::now();
let (output, senders, receipt_root_rx) =
match self.execute_block(state_provider, env, &input, &mut handle) {
Ok(output) => output,
Err(err) => return self.handle_execution_error(input, err, &parent_block),
};
let execution_duration = execute_block_start.elapsed();
// After executing the block we can stop prewarming transactions
handle.stop_prewarming_execution();
@@ -759,40 +733,21 @@ where
)
.into(),
)
.into());
.into())
}
let timing_stats = state_provider_stats.map(|stats| {
self.calculate_timing_stats(
&block,
stats,
cache_stats,
&output,
execution_duration,
root_elapsed,
)
});
if let Some(valid_block_tx) = valid_block_tx {
let _ = valid_block_tx.send(());
}
// Create the overlay provider NOW, while we're on the engine loop thread and trie changeset
// eviction cannot race with us. If we deferred this to the background task, persistence
// could advance and evict changeset cache entries between factory creation and the task
// actually running, causing expensive DB fallback computations when building the overlay.
let changeset_provider =
ensure_ok_post_block!(overlay_factory.database_provider_ro(), block);
let executed_block = self.spawn_deferred_trie_task(
Ok(self.spawn_deferred_trie_task(
block,
output,
&ctx,
hashed_state,
trie_output,
changeset_provider,
);
Ok((executed_block, timing_stats))
overlay_factory,
))
}
/// Return sealed block header from database or in-memory state by hash.
@@ -821,14 +776,14 @@ where
) -> Result<(), ConsensusError> {
if let Err(e) = self.consensus.validate_header(block.sealed_header()) {
error!(target: "engine::tree::payload_validator", ?block, "Failed to validate header {}: {e}", block.hash());
return Err(e);
return Err(e)
}
if let Err(e) =
self.consensus.validate_block_pre_execution_with_tx_root(block, transaction_root)
{
error!(target: "engine::tree::payload_validator", ?block, "Failed to validate block {}: {e}", block.hash());
return Err(e);
return Err(e)
}
Ok(())
@@ -870,6 +825,7 @@ where
State::builder()
.with_database(StateProviderDatabase::new(state_provider))
.with_bundle_update()
.without_state_clear()
.build()
});
@@ -947,8 +903,8 @@ where
let execution_duration = execution_start.elapsed();
self.metrics.record_block_execution(&output, execution_duration);
self.metrics.record_block_execution_gas_bucket(output.result.gas_used, execution_duration);
debug!(target: "engine::tree::payload_validator", elapsed = ?execution_duration, "Executed block");
debug!(target: "engine::tree::payload_validator", elapsed = ?execution_duration, "Executed block");
Ok((output, senders, result_rx))
}
@@ -1006,7 +962,6 @@ where
let _enter = debug_span!(
target: "engine::tree",
"execute tx",
tx_index = senders.len() - 1,
)
.entered();
trace!(target: "engine::tree", "Executing transaction");
@@ -1320,7 +1275,7 @@ where
trace!(target: "engine::tree::payload_validator", block=?block.num_hash(), "Validating block consensus");
// validate block consensus rules
if let Err(e) = self.validate_block_inner(block, transaction_root) {
return Err(e.into());
return Err(e.into())
}
// now validate against the parent
@@ -1329,7 +1284,7 @@ where
self.consensus.validate_header_against_parent(block.sealed_header(), parent_block)
{
warn!(target: "engine::tree::payload_validator", ?block, "Failed to validate header {} against parent: {e}", block.hash());
return Err(e.into());
return Err(e.into())
}
drop(_enter);
@@ -1342,7 +1297,7 @@ where
{
// call post-block hook
self.on_invalid_block(parent_block, block, output, None, ctx.state_mut());
return Err(err.into());
return Err(err.into())
}
drop(_enter);
@@ -1358,7 +1313,7 @@ where
{
// call post-block hook
self.on_invalid_block(parent_block, block, output, None, ctx.state_mut());
return Err(err.into());
return Err(err.into())
}
// record post-execution validation duration
@@ -1466,7 +1421,7 @@ where
self.provider.clone(),
historical,
Some(blocks),
)));
)))
}
// Check if the block is persisted
@@ -1474,7 +1429,7 @@ where
debug!(target: "engine::tree::payload_validator", %hash, number = %header.number(), "found canonical state for block in database, creating provider builder");
// For persisted blocks, we create a builder that will fetch state directly from the
// database
return Ok(Some(StateProviderBuilder::new(self.provider.clone(), hash, None)));
return Ok(Some(StateProviderBuilder::new(self.provider.clone(), hash, None)))
}
debug!(target: "engine::tree::payload_validator", %hash, "no canonical state found for block");
@@ -1506,7 +1461,7 @@ where
) {
if state.invalid_headers.get(&block.hash()).is_some() {
// we already marked this block as invalid
return;
return
}
self.invalid_block_hook.on_invalid_block(parent_header, block, output, trie_updates);
}
@@ -1580,7 +1535,7 @@ where
ctx: &TreeCtx<'_, N>,
hashed_state: LazyHashedPostState,
trie_output: Arc<TrieUpdates>,
changeset_provider: impl TrieCursorFactory + Send + 'static,
overlay_factory: OverlayStateProviderFactory<P>,
) -> ExecutedBlock<N> {
// Capture parent hash and ancestor overlays for deferred trie input construction.
let (anchor_hash, overlay_blocks) = ctx
@@ -1609,11 +1564,7 @@ where
// Capture block info and cache handle for changeset computation
let block_hash = block.hash();
let block_number = block.number();
// Register a pending changeset entry so that concurrent readers will wait for
// this computation to finish rather than falling back to the expensive DB path.
// The guard ensures the pending entry is cancelled if the task panics.
let pending_changeset_guard = self.changeset_cache.register_pending(block_hash);
let changeset_cache = self.changeset_cache.clone();
// Spawn background task to compute trie data. Calling `wait_cloned` will compute from
// the stored inputs and cache the result, so subsequent calls return immediately.
@@ -1648,15 +1599,20 @@ where
.record(anchored.trie_input.state.total_len() as f64);
}
// Compute and cache changesets using the computed trie_updates.
// Use the pre-created provider to avoid races with changeset cache
// eviction that can happen between task spawn and execution.
// Compute and cache changesets using the computed trie_updates
let changeset_start = Instant::now();
match reth_trie::changesets::compute_trie_changesets(
&changeset_provider,
&computed.trie_updates,
) {
// Get a provider from the overlay factory for trie cursor access
let changeset_result =
overlay_factory.database_provider_ro().and_then(|provider| {
reth_trie::changesets::compute_trie_changesets(
&provider,
&computed.trie_updates,
)
.map_err(ProviderError::Database)
});
match changeset_result {
Ok(changesets) => {
debug!(
target: "engine::tree::changeset",
@@ -1665,7 +1621,7 @@ where
"Computed and caching changesets"
);
pending_changeset_guard.resolve(block_number, Arc::new(changesets));
changeset_cache.insert(block_hash, block_number, Arc::new(changesets));
}
Err(e) => {
warn!(
@@ -1697,143 +1653,11 @@ where
deferred_trie_data,
)
}
fn calculate_timing_stats(
&self,
block: &RecoveredBlock<N::Block>,
provider_stats: Arc<StateProviderStats>,
cache_stats: Option<Arc<CacheStats>>,
output: &BlockExecutionOutput<N::Receipt>,
execution_duration: Duration,
state_hash_duration: Duration,
) -> Box<ExecutionTimingStats> {
let accounts_read = provider_stats.total_account_fetches();
let storage_read = provider_stats.total_storage_fetches();
let code_read = provider_stats.total_code_fetches();
let code_bytes_read = provider_stats.total_code_fetched_bytes();
// Write stats from BundleState (final state changes)
let accounts_changed = output.state.state.len();
let accounts_deleted =
output.state.state.values().filter(|acc| acc.was_destroyed()).count();
let storage_slots_changed =
output.state.state.values().map(|account| account.storage.len()).sum::<usize>();
let storage_slots_deleted = output
.state
.state
.values()
.flat_map(|account| account.storage.values())
.filter(|slot| {
slot.present_value.is_zero() && !slot.previous_or_original_value.is_zero()
})
.count();
// Helper: check if account represents a new contract deployment
let is_new_deployment = |acc: &BundleAccount| -> bool {
let has_code_now = acc.info.as_ref().is_some_and(|info| info.code_hash != KECCAK_EMPTY);
let had_no_code_before = acc
.original_info
.as_ref()
.map(|info| info.code_hash == KECCAK_EMPTY)
.unwrap_or(true);
has_code_now && had_no_code_before
};
let bytecodes_changed =
output.state.state.values().filter(|acc| is_new_deployment(acc)).count();
// Unique new code hashes to count actual bytes persisted (deduplicated)
let unique_new_code_hashes: B256Set = output
.state
.state
.values()
.filter(|acc| is_new_deployment(acc))
.filter_map(|acc| acc.info.as_ref().map(|info| info.code_hash))
.collect();
let code_bytes_written: usize = unique_new_code_hashes
.iter()
.filter_map(|hash| {
output.state.contracts.get(hash).map(|bytecode| bytecode.original_bytes().len())
})
.sum();
// Total time spent fetching state during execution
let state_read_duration = provider_stats.total_account_fetch_latency() +
provider_stats.total_storage_fetch_latency() +
provider_stats.total_code_fetch_latency();
// EIP-7702 delegation tracking from bytecode changes
// Count new EIP-7702 bytecodes as delegations set
let eip7702_delegations_set =
output.state.contracts.values().filter(|bytecode| bytecode.is_eip7702()).count();
// Delegations cleared: accounts where bytecode changed FROM EIP-7702 TO empty
// This detects when an EIP-7702 delegation is removed by setting code to empty
// Note: Clearing a delegation does NOT destroy the account - it just empties the
// bytecode
let eip7702_delegations_cleared = output
.state
.state
.values()
.filter(|acc| {
// Check if original bytecode was EIP-7702
let original_was_eip7702 = acc
.original_info
.as_ref()
.and_then(|info| info.code.as_ref())
.map(|bytecode| bytecode.is_eip7702())
.unwrap_or(false);
// Check if current code is empty (delegation cleared)
let code_now_empty =
acc.info.as_ref().map(|info| info.code_hash == KECCAK_EMPTY).unwrap_or(false);
original_was_eip7702 && code_now_empty
})
.count();
// Get cache statistics for detailed block logging
let (account_cache_hits, account_cache_misses) = cache_stats
.as_ref()
.map(|s| (s.account_hits(), s.account_misses()))
.unwrap_or_default();
let (storage_cache_hits, storage_cache_misses) = cache_stats
.as_ref()
.map(|s| (s.storage_hits(), s.storage_misses()))
.unwrap_or_default();
let (code_cache_hits, code_cache_misses) =
cache_stats.as_ref().map(|s| (s.code_hits(), s.code_misses())).unwrap_or_default();
// Build execution timing stats for detailed block logging
Box::new(ExecutionTimingStats {
block_number: block.number(),
block_hash: block.hash(),
gas_used: output.result.gas_used,
tx_count: block.transaction_count(),
execution_duration,
state_read_duration,
state_hash_duration,
accounts_read,
storage_read,
code_read,
code_bytes_read,
accounts_changed,
accounts_deleted,
storage_slots_changed,
storage_slots_deleted,
bytecodes_changed,
code_bytes_written,
eip7702_delegations_set,
eip7702_delegations_cleared,
account_cache_hits,
account_cache_misses,
storage_cache_hits,
storage_cache_misses,
code_cache_hits,
code_cache_misses,
})
}
}
/// Output of block or payload validation.
pub type ValidationOutcome<N, E = InsertPayloadError<BlockTy<N>>> = Result<ExecutedBlock<N>, E>;
/// Strategy describing how to compute the state root.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StateRootStrategy {

View File

@@ -20,7 +20,6 @@
//! The [`PersistenceState`] tracks ongoing persistence operations and coordinates
//! between the main execution thread and background persistence workers.
use crate::persistence::PersistenceResult;
use alloy_eips::BlockNumHash;
use alloy_primitives::B256;
use crossbeam_channel::Receiver as CrossbeamReceiver;
@@ -37,7 +36,7 @@ pub struct PersistenceState {
/// Receiver end of channel where the result of the persistence task will be
/// sent when done. A None value means there's no persistence task in progress.
pub(crate) rx:
Option<(CrossbeamReceiver<PersistenceResult>, Instant, CurrentPersistenceAction)>,
Option<(CrossbeamReceiver<Option<BlockNumHash>>, Instant, CurrentPersistenceAction)>,
}
impl PersistenceState {
@@ -51,7 +50,7 @@ impl PersistenceState {
pub(crate) fn start_remove(
&mut self,
new_tip_num: u64,
rx: CrossbeamReceiver<PersistenceResult>,
rx: CrossbeamReceiver<Option<BlockNumHash>>,
) {
self.rx =
Some((rx, Instant::now(), CurrentPersistenceAction::RemovingBlocks { new_tip_num }));
@@ -61,7 +60,7 @@ impl PersistenceState {
pub(crate) fn start_save(
&mut self,
highest: BlockNumHash,
rx: CrossbeamReceiver<PersistenceResult>,
rx: CrossbeamReceiver<Option<BlockNumHash>>,
) {
self.rx = Some((rx, Instant::now(), CurrentPersistenceAction::SavingBlocks { highest }));
}

View File

@@ -169,11 +169,11 @@ where
}
fn call(&self, input: PrecompileInput<'_>) -> PrecompileResult {
if let Some(entry) = &self.cache.get(input.data, self.spec_id.clone()) &&
input.gas >= entry.gas_used()
{
if let Some(entry) = &self.cache.get(input.data, self.spec_id.clone()) {
self.increment_by_one_precompile_cache_hits();
return entry.to_precompile_result()
if input.gas >= entry.gas_used() {
return entry.to_precompile_result()
}
}
let calldata = input.data;

View File

@@ -36,7 +36,6 @@ use std::{
mpsc::{Receiver, Sender},
Arc,
},
time::Duration,
};
use tokio::sync::oneshot;
@@ -203,7 +202,6 @@ impl TestHarness {
payload_validator,
TreeConfig::default(),
Box::new(NoopInvalidBlockHook::default()),
EngineSharedCaches::default(),
changeset_cache.clone(),
reth_tasks::Runtime::test(),
);
@@ -224,7 +222,6 @@ impl TestHarness {
evm_config,
changeset_cache,
provider.cached_storage_settings().use_hashed_state(),
reth_tasks::Runtime::test(),
);
let block_builder = TestBlockBuilder::default().with_chain_spec((*chain_spec).clone());
@@ -408,7 +405,6 @@ impl ValidatorTestHarness {
payload_validator,
TreeConfig::default(),
Box::new(NoopInvalidBlockHook::default()),
EngineSharedCaches::default(),
changeset_cache,
reth_tasks::Runtime::test(),
);
@@ -418,13 +414,14 @@ impl ValidatorTestHarness {
/// Configure `PersistenceState` for specific persistence scenarios
fn start_persistence_operation(&mut self, action: CurrentPersistenceAction) {
// Create a dummy receiver for testing - it will never receive a value
let (_tx, rx) = crossbeam_channel::bounded(1);
match action {
CurrentPersistenceAction::SavingBlocks { highest } => {
let (_tx, rx) = crossbeam_channel::bounded(1);
self.harness.tree.persistence_state.start_save(highest, rx);
}
CurrentPersistenceAction::RemovingBlocks { new_tip_num } => {
let (_tx, rx) = crossbeam_channel::bounded(1);
self.harness.tree.persistence_state.start_remove(new_tip_num, rx);
}
}
@@ -761,12 +758,7 @@ async fn test_tree_state_on_new_head_reorg() {
assert_eq!(saved_blocks, vec![blocks[0].clone(), blocks[1].clone()]);
// send the response so we can advance again
sender
.send(PersistenceResult {
last_block: Some(blocks[1].recovered_block().num_hash()),
commit_duration: Some(Duration::ZERO),
})
.unwrap();
sender.send(Some(blocks[1].recovered_block().num_hash())).unwrap();
// we should be persisting blocks[1] because we threw out the prev action
let current_action = test_harness.tree.persistence_state.current_action().cloned();
@@ -1589,39 +1581,6 @@ mod check_invalid_ancestors_tests {
}
}
/// Test that `find_invalid_ancestor` detects the block itself in the invalid cache
#[test]
fn test_find_invalid_ancestor_detects_block_itself() {
reth_tracing::init_test_tracing();
let mut test_harness = TestHarness::new(HOLESKY.clone());
// Read block 1
let s1 = include_str!("../../test-data/holesky/1.rlp");
let data1 = Bytes::from_str(s1).unwrap();
let block1 = Block::decode(&mut data1.as_ref()).unwrap();
let sealed1 = block1.seal_slow();
let hash1 = sealed1.hash();
let parent1 = sealed1.parent_hash();
// Mark block 1 itself as invalid (simulates a block that failed execution)
test_harness
.tree
.state
.invalid_headers
.insert(BlockWithParent { block: sealed1.num_hash(), parent: parent1 });
// Create payload for block 1 (same block, sent again by CL)
let payload1 = ExecutionData {
payload: ExecutionPayloadV1::from_block_unchecked(hash1, &sealed1.into_block()).into(),
sidecar: ExecutionPayloadSidecar::none(),
};
// find_invalid_ancestor should detect the block itself without re-execution
let result = test_harness.tree.find_invalid_ancestor(&payload1);
assert!(result.is_some(), "Should detect block itself in invalid headers cache");
}
/// Helper function to create a malformed payload that descends from a given parent
fn create_malformed_payload_descending_from(parent_hash: B256) -> ExecutionData {
// Create a block with invalid hash (mismatch between computed and provided hash)
@@ -2074,12 +2033,7 @@ mod forkchoice_updated_tests {
if let Some(last) = saved_blocks.last() {
last_persisted_number = last.recovered_block().number;
}
sender
.send(PersistenceResult {
last_block: saved_blocks.last().map(|b| b.recovered_block().num_hash()),
commit_duration: Some(Duration::ZERO),
})
.unwrap();
sender.send(saved_blocks.last().map(|b| b.recovered_block().num_hash())).unwrap();
}
}

Some files were not shown because too many files have changed in this diff Show More