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:
|
if duty.duty_type == types::DutyType::BuilderProposer { |
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):
|
async fn fetch_spec_data(&self) -> Result<serde_json::Value, EthBeaconNodeApiClientError> { |
fetch_genesis_data (backs genesis time / validators root / fork version / genesis domain):
|
async fn fetch_genesis_data( |
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)
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_epoch available synchronously to the scheduler, removing the per-call fetch_slots_config() round-trip at scheduler.rs#L341.
- Optionally extend the same mechanism to
get_deposit_contract and get_node_version if/when they get high-level wrappers.
- Leave Category 2 endpoints uncached.
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/schedulerreview (PR #466).SchedulerActor::get_duty_definitionissues a livefetch_slots_config()round-trip on every query just to deriveslots_per_epoch:pluto/crates/core/src/scheduler.rs
Line 341 in 6a25109
slots_per_epochnever 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):pluto/crates/eth2api/src/extensions.rs
Line 231 in 6a25109
fetch_genesis_data(backs genesis time / validators root / fork version / genesis domain):pluto/crates/eth2api/src/extensions.rs
Line 238 in 6a25109
Memoizing just these two makes every downstream helper (
fetch_genesis_time,fetch_slots_config,fetch_fork_config,fetch_domain*, …) cheap.Note that
EthBeaconNodeApiClientitself 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. Itseth2wrapcode generator marks each endpoint with aLatencyflag;Latency: falsemeans "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. NotablyForkScheduleandForkare NOT cached by Charon (Latency: true).Category 1 — cache these (lifetime-constant; Charon caches them too)
fetch_genesis_data→fetch_genesis_time,fetch_genesis_validators_root,fetch_genesis_fork_versionget_genesisfetch_spec_data/fetch_specget_specfetch_slots_configfetch_domain_type,fetch_genesis_domain,fetch_domainget_deposit_contractget_node_versionCaching
fetch_spec_dataandfetch_genesis_datacovers the whole first four rows at once.Category 2 — semantically constant-ish, but do NOT cache (err on the side of caution)
fetch_fork_config→get_fork_scheduleLatency: 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.get_state_forkget_peer_countLatency: false, but it is semantically dynamic — do not cache in Pluto.get_block_rootblock_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
fetch_spec_data,fetch_genesis_data) so all constant-data helpers become effectively free after the first call.slots_per_epochavailable synchronously to the scheduler, removing the per-callfetch_slots_config()round-trip atscheduler.rs#L341.get_deposit_contractandget_node_versionif/when they get high-level wrappers.