NethermindEth / NethermindEth/pluto
Cache lifetime-constant Beacon Node API responses
@varex83 is already working on this.
Since Sep 3, 2026.
- Dominant language
- Rust
- Stars
- 8
- Forks
- 5
- Avg merge
- 4d 16h
- Merged PRs (30d)
- 37
Description
Summary
Several Beacon Node API responses are constant for the lifetime of the client (genesis data, the chain spec, slots-per-epoch / slot duration, the deposit contract, …) yet Pluto re-fetches them over HTTP on every call. We should cache these once and reuse them, while being conservative: only cache what is genuinely immutable, and when in doubt make the extra call rather than risk serving stale data.
This came out of the core/scheduler review (PR #466). SchedulerActor::get_duty_definition issues a live fetch_slots_config() round-trip on every query just to derive slots_per_epoch:
slots_per_epoch never changes, so this is a per-call beacon round-trip on a hot path that blocks the actor. A general cache for lifetime-constant responses fixes this class of problem rather than just the one call site.
Where caching should live
Almost all the constant-data helpers funnel through two private base fetchers in extensions.rs, which makes them the natural memoization points:
fetch_spec_data(backs spec / slots config / fork config / domains): https://github.com/NethermindEth/pluto/blob/6a25109aaa6341f551754d0a02728dd75d8917eb/crates/eth2api/src/extensions.rs#L231fetch_genesis_data(backs genesis time / validators root / fork version / genesis domain): https://github.com/NethermindEth/pluto/blob/6a25109aaa6341f551754d0a02728dd75d8917eb/crates/eth2api/src/extensions.rs#L238
Memoizing just these two makes every downstream helper (fetch_genesis_time, fetch_slots_config, fetch_fork_config, fetch_domain*, …) cheap.
Note that EthBeaconNodeApiClient itself is an OpenAPI-generated struct, so a cache field can't simply be added to it without changing the generator:
A caching layer (or a small wrapper type) is therefore preferable. There is already precedent for this pattern in the crate — the epoch-aware ValidatorCache:
Reference: what Charon caches
Charon delegates this caching to go-eth2-client. Its eth2wrap code generator marks each endpoint with a Latency flag; Latency: false means "this endpoint is cached in go-eth2-client" (the generator literally emits that comment):
- interfaces map: https://github.com/ObolNetwork/charon/blob/749d2d7ab0b8ace34f2e686a5af5910c8adb4dc0/app/eth2wrap/genwrap/genwrap.go#L99
- "cached" comment template: https://github.com/ObolNetwork/charon/blob/749d2d7ab0b8ace34f2e686a5af5910c8adb4dc0/app/eth2wrap/genwrap/genwrap.go#L62
The endpoints Charon/go-eth2-client cache (Latency: false) are: BeaconBlockRoot, DepositContract, Domain, Genesis, NodePeerCount, NodeVersion, SlotDuration, SlotsPerEpoch, Spec. Notably ForkSchedule and Fork are NOT cached by Charon (Latency: true).
Category 1 — cache these (lifetime-constant; Charon caches them too)
| Data | Pluto method | Backing endpoint |
|---|---|---|
| Genesis (time, validators root, genesis fork version) | fetch_genesis_data → fetch_genesis_time, fetch_genesis_validators_root, fetch_genesis_fork_version |
get_genesis |
| Chain spec | fetch_spec_data / fetch_spec |
get_spec |
| Slot duration & slots-per-epoch | fetch_slots_config |
derived from spec |
| Signing domains | fetch_domain_type, fetch_genesis_domain, fetch_domain |
derived from spec + genesis |
| Deposit contract (address, chain id) | — (not yet wrapped) | get_deposit_contract |
| Beacon node version | — (not yet wrapped) | get_node_version |
Caching fetch_spec_data and fetch_genesis_data covers the whole first four rows at once.
Category 2 — semantically constant-ish, but do NOT cache (err on the side of caution)
| Data | Pluto method | Why not |
|---|---|---|
| Fork schedule | fetch_fork_config → get_fork_schedule |
Future forks can be (re)scheduled. Charon explicitly does not cache this (Latency: true). In Pluto it is derived from the spec, so it rides along with the spec cache only as far as the spec itself; treat new forks as a reason to re-read. |
| State fork | get_state_fork |
Per-state / per-fork-epoch, not lifetime-constant. Charon does not cache it. |
| Peer count | get_peer_count |
go-eth2-client marks it Latency: false, but it is semantically dynamic — do not cache in Pluto. |
| Block root | get_block_root |
Cached by go-eth2-client keyed by block_id; a single value cache is wrong here. Out of scope. |
Everything else (duties, validator state, syncing status, head/finality, pools, block production/submission, rewards, …) is dynamic and must not be cached.
Proposed scope
- Introduce a memoization layer for the two base fetchers (
fetch_spec_data,fetch_genesis_data) so all constant-data helpers become effectively free after the first call. - Use it to make
slots_per_epochavailable synchronously to the scheduler, removing the per-callfetch_slots_config()round-trip atscheduler.rs#L341. - Optionally extend the same mechanism to
get_deposit_contractandget_node_versionif/when they get high-level wrappers. - Leave Category 2 endpoints uncached.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.