Skip to content

Commit 07875b3

Browse files
twoethsnflaig
andauthored
feat: cache the last 2 PayloadEnvelopeInputs (#9260)
**Motivation** - We should not prune `PayloadEnvelopeInput` from `seenPayloadEnvelopeInputCache` right after we persist to DB, because block production needs it (next-slot `getParentExecutionRequests` and `prepareExecutionPayload` read the cached envelope synchronously). **Description** - Keep the last 2 `PayloadEnvelope`s in memory (head + head.parent) - In the worst case, we fall back to loading from DB. - This PR gets the most part of #9249 targeting #9257 to make it ready for unstable - block production modification is removed due to #9257 **AI Assistance Disclosure** Used Claude Code to assist with implementation and review. --------- Co-authored-by: Nico Flaig <nflaig@protonmail.com> Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com>
1 parent b741495 commit 07875b3

3 files changed

Lines changed: 44 additions & 41 deletions

File tree

packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,14 @@ export async function persistPayloadEnvelopeInput(
3333
this: BeaconChain,
3434
payloadInput: PayloadEnvelopeInput
3535
): Promise<void> {
36-
await writePayloadEnvelopeInputToDb
37-
.call(this, payloadInput)
38-
.catch((e) => {
39-
this.logger.error(
40-
"Error persisting payload envelope in hot db",
41-
{
42-
slot: payloadInput.slot,
43-
root: payloadInput.blockRootHex,
44-
},
45-
e
46-
);
47-
})
48-
.finally(() => {
49-
this.seenPayloadEnvelopeInputCache.prune(payloadInput.blockRootHex);
50-
this.logger.debug("Pruned payload envelope input", {
36+
await writePayloadEnvelopeInputToDb.call(this, payloadInput).catch((e) => {
37+
this.logger.error(
38+
"Error persisting payload envelope in hot db",
39+
{
5140
slot: payloadInput.slot,
5241
root: payloadInput.blockRootHex,
53-
});
54-
});
42+
},
43+
e
44+
);
45+
});
5546
}

packages/beacon-node/src/chain/prepareNextSlot.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class PrepareNextSlotScheduler {
8383
const headBlock = this.chain.recomputeForkChoiceHead(ForkchoiceCaller.prepareNextSlot);
8484
const {slot: headSlot, blockRoot: headRoot} = headBlock;
8585
// may be updated below if we predict a proposer-boost-reorg
86-
let updatedHeadRoot = headRoot;
86+
let updatedHead = headBlock;
8787

8888
// PS: previously this was comparing slots, but that gave no leway on the skipped
8989
// slots on epoch bounday. Making it more fluid.
@@ -148,7 +148,7 @@ export class PrepareNextSlotScheduler {
148148
{dontTransferCache: !isEpochTransition},
149149
RegenCaller.predictProposerHead
150150
);
151-
updatedHeadRoot = proposerHeadRoot;
151+
updatedHead = proposerHead;
152152
}
153153

154154
// Update the builder status, if enabled shoot an api call to check status
@@ -166,7 +166,7 @@ export class PrepareNextSlotScheduler {
166166

167167
let parentBlockHash: Bytes32;
168168
if (isStatePostGloas(updatedPrepareState)) {
169-
parentBlockHash = this.chain.forkChoice.shouldExtendPayload(updatedHeadRoot)
169+
parentBlockHash = this.chain.forkChoice.shouldExtendPayload(updatedHead.blockRoot)
170170
? updatedPrepareState.latestExecutionPayloadBid.blockHash
171171
: updatedPrepareState.latestExecutionPayloadBid.parentBlockHash;
172172
} else {
@@ -189,7 +189,7 @@ export class PrepareNextSlotScheduler {
189189
this.chain,
190190
this.logger,
191191
fork as ForkPostBellatrix, // State is of execution type
192-
fromHex(updatedHeadRoot),
192+
fromHex(updatedHead.blockRoot),
193193
parentBlockHash,
194194
safeBlockHash,
195195
finalizedBlockHash,
@@ -203,6 +203,16 @@ export class PrepareNextSlotScheduler {
203203
});
204204
}
205205

206+
if (ForkSeq[fork] >= ForkSeq.gloas) {
207+
// Cutoff = slot of the parent of the block we'll actually build on (post-reorg).
208+
// Steady state: cache holds just 2 entries — head (parent for next-slot production)
209+
// and head.parent (proposer-boost-reorg fallback). Anything older is evicted.
210+
const updatedHeadParent = this.chain.forkChoice.getBlockHexDefaultStatus(updatedHead.parentRoot);
211+
if (updatedHeadParent) {
212+
this.chain.seenPayloadEnvelopeInputCache.pruneBelow(updatedHeadParent.slot);
213+
}
214+
}
215+
206216
this.computeStateHashTreeRoot(updatedPrepareState, isEpochTransition);
207217

208218
// If emitPayloadAttributes is true emit a SSE payloadAttributes event

packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {CheckpointWithHex} from "@lodestar/fork-choice";
22
import {computeStartSlotAtEpoch} from "@lodestar/state-transition";
3-
import {RootHex} from "@lodestar/types";
3+
import {RootHex, Slot} from "@lodestar/types";
44
import {Logger} from "@lodestar/utils";
55
import {Metrics} from "../../metrics/metrics.js";
66
import {SerializedCache} from "../../util/serializedCache.js";
@@ -21,8 +21,15 @@ export type SeenPayloadEnvelopeInputModules = {
2121
/**
2222
* Cache for tracking PayloadEnvelopeInput instances, keyed by beacon block root.
2323
*
24-
* Created during block import when a block is processed.
25-
* Pruned on finalization and after payload is written to DB.
24+
* Created during block import when a block is processed. Two pruning paths:
25+
* - `prepareNextSlot` calls `pruneBelow(headParentSlot)` every slot once the head we'll build
26+
* on is known.
27+
* - `onFinalized` calls `pruneBelow(finalizedSlot)` on every finalization for bulk cleanup.
28+
*
29+
* Steady state (linear chain, healthy progression): the cache holds ~2 entries — the head
30+
* (parent for next-slot production) and its parent (proposer-boost-reorg fallback). It can
31+
* transiently hold more during forks, range-sync bursts, or when `prepareNextSlot` skips
32+
* ticks; subsequent ticks settle it back.
2633
*/
2734
export class SeenPayloadEnvelopeInput {
2835
private readonly chainEvents: ChainEventEmitter;
@@ -58,16 +65,7 @@ export class SeenPayloadEnvelopeInput {
5865
}
5966

6067
private onFinalized = (checkpoint: CheckpointWithHex): void => {
61-
// Prune all entries with slot < finalized slot
62-
const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch);
63-
let deletedCount = 0;
64-
for (const [, input] of this.payloadInputs) {
65-
if (input.slot < finalizedSlot) {
66-
this.evictPayloadInput(input);
67-
deletedCount++;
68-
}
69-
}
70-
this.logger?.debug("SeenPayloadEnvelopeInput.onFinalized deleted cached entries", {deletedCount});
68+
this.pruneBelow(computeStartSlotAtEpoch(checkpoint.epoch));
7169
};
7270

7371
add(props: CreateFromBlockProps): PayloadEnvelopeInput {
@@ -88,17 +86,21 @@ export class SeenPayloadEnvelopeInput {
8886
return this.payloadInputs.get(blockRootHex)?.hasPayloadEnvelope() ?? false;
8987
}
9088

91-
prune(blockRootHex: RootHex): void {
92-
const payloadInput = this.payloadInputs.get(blockRootHex);
93-
if (payloadInput) {
94-
this.evictPayloadInput(payloadInput);
95-
}
96-
}
97-
9889
size(): number {
9990
return this.payloadInputs.size;
10091
}
10192

93+
pruneBelow(slot: Slot): void {
94+
let deletedCount = 0;
95+
for (const [, input] of this.payloadInputs) {
96+
if (input.slot < slot) {
97+
this.evictPayloadInput(input);
98+
deletedCount++;
99+
}
100+
}
101+
this.logger?.debug("SeenPayloadEnvelopeInput.pruneBelow deleted entries", {slot, deletedCount});
102+
}
103+
102104
private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void {
103105
this.serializedCache.delete(payloadInput.getSerializedCacheKeys());
104106
this.payloadInputs.delete(payloadInput.blockRootHex);

0 commit comments

Comments
 (0)