Skip to content

fix(l1): size the RocksDB block cache from available memory - #7093

Open
edg-l wants to merge 2 commits into
mainfrom
fix-ram-aware-block-cache
Open

fix(l1): size the RocksDB block cache from available memory#7093
edg-l wants to merge 2 commits into
mainfrom
fix-ram-aware-block-cache

Conversation

@edg-l

@edg-l edg-l commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Motivation

DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES was a flat 12 GiB regardless of the machine. With cache_index_and_filter_blocks enabled that constant is the effective ceiling on RocksDB's resident memory, so on a 16 GiB host we were telling RocksDB it could claim 71% of the box — leaving nothing for the in-memory trie-layer backlog, block execution, the mempool, peer buffers and allocator slack.

This showed up on ethpandaops' syncoor runners for glamsterdam-devnet-7 (16.8 GB, 8 cores), where ethrex is OOM-killed during sync. The dominant term there is a separate unbounded-trie-layer bug, but even with that fixed a 12 GiB cache ceiling on a 16.8 GB host has no headroom: the one genesis-sync run that survived peaked at 13.4 GB, right at the edge.

The problem is worse in containers than the number suggests: /proc/meminfo reports the host's memory, not the container's limit, so a 4 GiB-limited container also saw "12 GiB is fine".

Description

Derive the default from the memory the process may actually use instead of hardcoding it:

  • MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES (12 GiB) is now documented as the ceiling, not the default. Its value and its tuning rationale are unchanged.
  • default_rocksdb_block_cache_size() returns ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT (40%) of the detected limit, clamped to MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES (512 MiB) ..= 12 GiB.
  • The detected limit is min(physical memory, cgroup memory limit)MemTotal from /proc/meminfo against memory.max (cgroup v2) / memory.limit_in_bytes (v1) — so a container is sized against its own limit rather than the machine it lands on. cgroup v1's "unlimited" sentinel is discarded by the min; v2's literal max fails to parse and is skipped.
  • Detection failure (non-Linux, unreadable /proc) falls back to the 12 GiB ceiling, so no host regresses relative to today.

Effect: ≥30 GiB hosts keep 12 GiB (unchanged). A 16 GiB host gets ~6.4 GiB and keeps >half the machine for the node. That is below the ~8 GiB filter-thrash floor the ceiling's docs describe, which is the deliberate tradeoff — reduced throughput on a small host beats being OOM-killed on it.

No new dependencies; the detection is ~25 lines of std::fs.

CLI

--rocksdb.block-cache-size becomes Option<usize> and resolves at store construction. Explicitly setting it (or ETHREX_ROCKSDB_BLOCK_CACHE_SIZE) still overrides in either direction.

It is deliberately not default_value_t: clap would then print the resolving machine's byte count into --help, and CI diffs --help against docs/CLI.md, so the check would pass or fail depending on the runner's RAM. The verbose long_help is dropped in favor of one sentence; the sweep rationale now lives on the constants.

Testing

test/tests/storage/rocksdb_block_cache_tests.rs covers the pure clamp via rocksdb_block_cache_size_for(Option<usize>), so it is machine-independent:

  • undetectable limit → the ceiling (the no-regression path)
  • 32/64/1024 GiB → clamped to the ceiling
  • 16 GiB → exactly 40%, below the ceiling, >half the host left for the node
  • 1 GiB and 0 → the 512 MiB floor
  • the resolved default on the running machine lands inside the clamp

Also run locally: cargo clippy --workspace --all-targets clean, cargo fmt --all --check clean, storage suite 33 passed, and --help verified byte-identical to docs/CLI.md under CI's own diff -ubB (modulo the $HOME-dependent datadir default line, which CI generates as /home/runner).

Fixes #7092

The default was a flat 12 GiB, which is 71% of a 16 GiB host and leaves no
headroom for the trie-layer backlog, execution, the mempool and allocator
slack. Default to 40% of the memory the process may use -- the smaller of
physical memory and the cgroup limit, so a container is sized against its
own limit rather than the machine it lands on -- clamped to
512 MiB..=12 GiB. The 12 GiB ceiling is unchanged, so hosts with 30 GiB or
more keep today's value, and an undetectable limit also falls back to it.

--rocksdb.block-cache-size becomes optional and still overrides in either
direction; the default resolves at store construction so --help stays
machine-independent.
@edg-l
edg-l requested review from a team as code owners August 3, 2026 12:43
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

Stateless (zkEVM) Amsterdam+ EF tests skipped

Where: tooling/ef_tests/blockchain/test_runner.rsparse_and_execute skips
fixtures with network >= Fork::Amsterdam when running with a stateless backend.
Affects make test-stateless (the vectors_zkevm/ run); make test-levm is
unaffected.

Why: The stateless run uses the tests-zkevm@v0.5.0 bundle, filled against
glamsterdam-devnet v6.1.0, which predeploys the EIP-8282 builder deposit/exit
contracts at the OLD addresses (0x0000884d…d9008282 / 0x000014574a…0f008282).
This client uses the devnet-7 addresses (0x0000bff4…300d8282 /
0x000064d6…800e8282, matching the live tests-glamsterdam-devnet@v7.2.0 bundle
used by make test-levm). Every Amsterdam+ block runs the end-of-block EIP-8282
builder system call; with the new addresses absent from the v0.5.0 bundle, each
stateless Amsterdam+ block fails with
SystemContractCallFailed("System contract: 0x0000…8282 has no code after deployment").
The skip is by fork rather than by test name, since cross-fork directories such as
for_amsterdam/prague/... still execute at the Amsterdam fork.

Removal: Delete the skip_stateless_amsterdam branch in parse_and_execute
once a tests-zkevm bundle filled with the devnet-7 builder predeploy addresses is
released and .fixtures_url_zkevm is bumped to it.

@github-actions github-actions Bot added the L1 Ethereum client label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

The PR correctly implements dynamic sizing for the RocksDB block cache based on available system memory with appropriate safety clamps. No critical issues found.

Minor observations:

  1. Integer precision in store.rs (line ~120):
    The calculation limit / 100 * ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT truncates during division, potentially losing up to 99 bytes of precision before multiplication. For memory sizing this is negligible, but consider documenting the truncation behavior in the comment if sub-percent precision matters for future tuning.

  2. Test coverage:
    The new rocksdb_block_cache_tests.rs comprehensively covers clamping boundaries, fallback behavior, and the "headroom" requirement. Good practice including the 0 bytes edge case.

  3. Documentation consistency:
    The CHANGELOG entry and CLI help text accurately describe the 40% calculation and clamping range (512 MiB–12 GiB).

  4. Migration tooling:
    bench_migration.rs correctly switches from the removed constant to the new function call.

Code quality notes:

  • The use of unwrap_or_else(default_rocksdb_block_cache_size) in initializers (items 2–3 of diff) properly defers system inspection until necessary.
  • host_memory_limit_bytes() correctly takes the minimum of physical and cgroup limits, ensuring containers respect their own constraints rather than host totals.
  • Error handling via Option propagation for missing /proc or cgroup files is appropriate for cross-platform compatibility.

Nit (non-blocking):
In cgroup_memory_limit_bytes(), the v1 sentinel check relies on the value exceeding physical memory and being discarded by min(). This is correct for 64-bit systems where the sentinel is typically PAGE_COUNTER_MAX (~16 EiB), but consider an explicit check for values > usize::MAX / 2 if you want to be defensive against misconfigured v1 limits.

Overall: LGTM – well-structured change with proper bounds checking and test coverage.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. High: crates/storage/store.rs:158 reads cgroup limits only from the cgroup mount root (/sys/fs/cgroup/memory.max or /sys/fs/cgroup/memory/memory.limit_in_bytes). That is not the effective limit for processes running in nested cgroups, which is common under systemd, Kubernetes, and some container setups. In those cases the real limit lives under the current process cgroup path from /proc/self/cgroup; if the root file says max, host_memory_limit_bytes() falls back to physical RAM and can still select a 12 GiB cache inside a much smaller slice. That defeats the purpose of this PR and can still OOM constrained nodes.

  2. Medium: crates/storage/store.rs:120 clamps upward to MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES even when the detected limit is lower, and the new tests codify that behavior at test/tests/storage/rocksdb_block_cache_tests.rs:55. For a container capped below 512 MiB, the computed “default” can exceed the process’s actual memory allowance. Since this change is explicitly marketed as sizing from the memory available to the process, I’d avoid returning a value above the detected hard limit.

No EVM, gas-accounting, consensus, trie, or RLP logic is touched here; the risk is operational correctness around memory-constrained deployments.

I couldn’t run the Rust tests in this environment because rustup failed to create temp files under the read-only home directory.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown

Greptile Summary

The PR replaces the fixed 12 GiB RocksDB block-cache default with a memory-aware default while preserving explicit CLI and environment overrides.

  • Computes 40% of detected physical/cgroup memory, clamped to 512 MiB–12 GiB.
  • Resolves the dynamic default during L1 and L2 store construction.
  • Updates CLI documentation, exports, migration tooling, and storage tests.

Confidence Score: 4/5

The nested-cgroup detection defect should be fixed before merging because it leaves a realistic path to the same oversized-cache OOM behavior this PR intends to prevent.

The percentage and override wiring are consistent, but the detector consults the cgroup mount root rather than the current process's cgroup, so service-level or non-namespaced container limits can be ignored.

Files Needing Attention: crates/storage/store.rs

Important Files Changed

Filename Overview
crates/storage/store.rs Adds memory-aware cache sizing, but fixed cgroup-root paths miss limits applied to nested process cgroups.
cmd/ethrex/cli.rs Makes the cache option optional so runtime detection applies only when no CLI or environment override is supplied.
cmd/ethrex/initializers.rs Correctly resolves the optional cache size before constructing the L1 StoreConfig.
cmd/ethrex/l2/initializers.rs Mirrors the L1 cache-resolution behavior for L2 startup.
test/tests/storage/rocksdb_block_cache_tests.rs Covers percentage and clamp behavior but not process membership in nested cgroups.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  CLI["CLI/env cache override"] --> Resolve{"Explicit value?"}
  Resolve -->|Yes| Config["StoreConfig cache size"]
  Resolve -->|No| Detect["Detect physical and cgroup memory"]
  Detect --> Limit["Select smaller detected limit"]
  Limit --> Clamp["40%, clamped to 512 MiB–12 GiB"]
  Clamp --> Config
  Config --> RocksDB["Open RocksDB shared block cache"]
Loading
Prompt To Fix All With AI
### Issue 1
crates/storage/store.rs:158-164
**Nested cgroup limit is ignored**

If ethrex runs in a nested cgroup with a memory limit, these fixed mount-root paths read the root cgroup rather than the process's cgroup. The limit is therefore ignored and startup can select the 12 GiB cache ceiling inside a smaller allocation, causing the node to be OOM-killed.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(l1): size the RocksDB block cache fr..." | Re-trigger Greptile

Comment thread crates/storage/store.rs Outdated
Comment on lines +158 to +164
fn cgroup_memory_limit_bytes() -> Option<usize> {
[
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
]
.into_iter()
.find_map(|path| std::fs::read_to_string(path).ok()?.trim().parse().ok())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Nested cgroup limit is ignored

If ethrex runs in a nested cgroup with a memory limit, these fixed mount-root paths read the root cgroup rather than the process's cgroup. The limit is therefore ignored and startup can select the 12 GiB cache ceiling inside a smaller allocation, causing the node to be OOM-killed.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/store.rs
Line: 158-164

Comment:
**Nested cgroup limit is ignored**

If ethrex runs in a nested cgroup with a memory limit, these fixed mount-root paths read the root cgroup rather than the process's cgroup. The limit is therefore ignored and startup can select the 12 GiB cache ceiling inside a smaller allocation, causing the node to be OOM-killed.

**Knowledge Base Used:**
- [Storage Layer](https://app.greptile.com/lambdaclass/-/custom-context/knowledge-base/lambdaclass/ethrex/-/docs/storage-layer.md)
- [CLI Entrypoint and Node Startup](https://app.greptile.com/lambdaclass/-/custom-context/knowledge-base/lambdaclass/ethrex/-/docs/cli-entrypoint.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 147
Total lines removed: 16
Total lines changed: 163

Detailed view
+------------------------------------------+-------+------+
| File                                     | Lines | Diff |
+------------------------------------------+-------+------+
| ethrex/cmd/ethrex/cli.rs                 | 1310  | -12  |
+------------------------------------------+-------+------+
| ethrex/cmd/ethrex/initializers.rs        | 968   | +2   |
+------------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/initializers.rs     | 415   | +3   |
+------------------------------------------+-------+------+
| ethrex/crates/storage/backend/rocksdb.rs | 515   | -4   |
+------------------------------------------+-------+------+
| ethrex/crates/storage/lib.rs             | 21    | +2   |
+------------------------------------------+-------+------+
| ethrex/crates/storage/store.rs           | 4747  | +140 |
+------------------------------------------+-------+------+

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Code Review: PR 7093 — size the RocksDB block cache from available memory

Summary

Solid, well-scoped change: replaces the flat 12 GiB DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES with a memory-aware default (40% of min(physical, cgroup limit), clamped to 512 MiB..=12 GiB), and defers CLI default resolution so --help/docs/CLI.md stay machine-independent. The split between the pure clamp function (rocksdb_block_cache_size_for) and the impure detection (default_rocksdb_block_cache_size) is good design — it makes the interesting logic unit-testable without mocking the filesystem.

Findings

1. cgroup detection assumes the process's own limit is visible at the fixed root path (crates/storage/store.rs, cgroup_memory_limit_bytes, ~line 164)
The code reads /sys/fs/cgroup/memory.max (v2) and /sys/fs/cgroup/memory/memory.limit_in_bytes (v1) unconditionally. This only reflects the container's own limit when the container runtime uses cgroup namespaces (the default in modern Docker/Kubernetes on cgroup v2 hosts). In setups without cgroup-namespace isolation (older runtimes, some nested-container/CI configurations), /sys/fs/cgroup inside the process's view can be the host's root cgroup, which typically reports max/unlimited — silently degrading to physical-memory-only sizing exactly on the class of host (constrained containers) this PR targets. This isn't a regression (today's code ignores cgroups entirely), but it means the PR's "sized against its own limit" guarantee is conditional. Worth a one-line doc caveat; not blocking given the documented fallback is safe (falls back toward the ceiling, not toward an under-sized cache).

2. Redundant default computation on the common path (crates/storage/store.rs, StoreConfig::default() ~line 188; cmd/ethrex/initializers.rs:743, cmd/ethrex/l2/initializers.rs:224)
StoreConfig::default() now calls default_rocksdb_block_cache_size(), but both call sites (init_l1, init_l2) always override rocksdb_block_cache_size explicitly via .unwrap_or_else(default_rocksdb_block_cache_size), so the computation inside StoreConfig::default() runs and is discarded. Harmless (a few syscalls at startup), just wasted work — could take Option<usize> in StoreConfig itself, or leave a comment noting the double-call is intentional/inconsequential.

3. Precision-losing division order (crates/storage/store.rs, rocksdb_block_cache_size_for, ~line 130)
(limit / 100 * ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT) divides before multiplying, discarding up to 99 bytes of limit's remainder before scaling. At GiB-scale inputs this is immaterial (< 40 bytes lost), but limit * ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT / 100 would be both more precise and no more overflow-prone (max realistic limit is far below usize::MAX / 40 on 64-bit). Minor nit, not worth blocking on.

Things done well

  • Fallback to the 12 GiB ceiling on detection failure (non-Linux, unreadable /proc) correctly preserves current behavior — no host regresses.
  • Deliberately avoiding default_value_t for the CLI flag is the right call; baking a resolving-machine-dependent byte count into --help would make the CI diff against docs/CLI.md flaky, and the PR explicitly reasons through this.
  • cgroup v1's "unlimited" sentinel and v2's literal "max" are both handled correctly — v2 fails to parse (skipped), v1's huge sentinel is naturally squeezed out by min() against physical memory.
  • Test suite targets the right boundaries (ceiling clamp, floor clamp, the 16 GiB headroom case, undetected-limit fallback) via the pure function, sidestepping the need to mock /proc/cgroup files.
  • Renaming DEFAULT_...MAX_... and updating all call sites (rocksdb.rs tests, read_chain_id_from_db, bench_migration.rs) is consistent and correctly reflects the new semantics (ceiling, not default).

No security or consensus-correctness concerns — this is host resource sizing, not on-chain logic, and error paths degrade safely to the previous behavior.


Automated review by Claude (Anthropic) · sonnet · custom prompt

Walk /proc/self/cgroup up to the mount root and take the smallest limit,
so a limit set on a systemd slice, a pod's parent or an outer container is
not missed by reading the mount root alone.
Comment thread crates/storage/store.rs
/// clamped to [`MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES`] ..=
/// [`MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES`]. Falls back to the ceiling when the limit
/// cannot be detected, preserving the previous behavior.
pub fn default_rocksdb_block_cache_size() -> usize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing logs the detected limit or the size this returns, and that is the one thing the PR'''s own motivation argues for.

The old value was a constant: wrong on small hosts, but knowable from the source. This replaces it with a number derived from /proc/meminfo, cgroup v2 memory.max, or cgroup v1 memory.limit_in_bytes, whichever is smallest — and None from all of them silently restores the old 12 GiB. So the cache size is now environment-dependent and invisible, on a code path that exists because a mis-sized cache was only discovered via an OOM kill.

Concretely, an operator debugging memory today cannot answer: was a cgroup limit detected at all, or did we fall back to physical memory? Did detection fail entirely and hand back 12 GiB? Was the result clamped? The clamp matters because it is silent in both directions — below ~1.3 GiB of detected limit, MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES (512 MiB) wins and the cache is more than 40% of the limit.

One line at startup would close it:

info!(detected_limit_bytes = ?limit, source = %source, cache_bytes = size, "sized RocksDB block cache");

Worth noting the blast radius is small, which is a point in the PR'''s favour and not stated: with a 40% factor clamped to 12 GiB, the default only moves below ~30 GiB of detected memory. docs/getting-started/hardware_requirements.md puts the RAM minimum at 32 GB for every network, so on any spec-compliant host this is a no-op (32 GB -> 12.8 GiB -> clamped to 12 GiB, unchanged). It changes behaviour only for under-spec machines and containers — which is exactly the intent, and worth saying explicitly so reviewers know a spec-compliant deployment sees no change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

OOM while syncing glamsterdam-devnet-7: memory grows to host limit (~14.6GB) within 40 minutes, container killed

2 participants