Skip to content

Cache lifetime-constant Beacon Node API responses #481

@emlautarom1-agent

Description

@emlautarom1-agent

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:

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):

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_datafetch_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_configget_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

  1. 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.
  2. Use it to make slots_per_epoch available synchronously to the scheduler, removing the per-call fetch_slots_config() round-trip at scheduler.rs#L341.
  3. Optionally extend the same mechanism to get_deposit_contract and get_node_version if/when they get high-level wrappers.
  4. Leave Category 2 endpoints uncached.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions