diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index bfb9cecf9d4b..3cedce0700b2 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -281,6 +281,18 @@ export async function importBlock( const newHead = this.recomputeForkChoiceHead(ForkchoiceCaller.importBlock); const currFinalizedEpoch = this.forkChoice.getFinalizedCheckpoint().epoch; + // Prune the gloas payload-envelope cache below the new head's parent so it stays bounded during + // syncing. On a synced node, cache holds just 2 entries — head (parent for + // next-slot production) and head.parent (proposer-boost-reorg fallback) + if (fork >= ForkSeq.gloas) { + callInNextEventLoop(() => { + const newHeadParent = this.forkChoice.getBlockHexDefaultStatus(newHead.parentRoot); + if (newHeadParent) { + this.seenPayloadEnvelopeInputCache.pruneBelowParent(newHeadParent); + } + }); + } + if (newHead.blockRoot !== oldHead.blockRoot) { // Set head state as strong reference this.regen.updateHeadState(newHead, postState); diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index ec6474c0d046..ced08b44ad97 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -217,16 +217,6 @@ export class PrepareNextSlotScheduler { }); } - if (ForkSeq[fork] >= ForkSeq.gloas) { - // Cutoff = slot of the parent of the block we'll actually build on (post-reorg). - // Steady state: cache holds just 2 entries — head (parent for next-slot production) - // and head.parent (proposer-boost-reorg fallback). Anything older is evicted. - const updatedHeadParent = this.chain.forkChoice.getBlockHexDefaultStatus(updatedHead.parentRoot); - if (updatedHeadParent) { - this.chain.seenPayloadEnvelopeInputCache.pruneBelowParent(updatedHeadParent); - } - } - this.computeStateHashTreeRoot(updatedPrepareState, isEpochTransition); // If emitPayloadAttributes is true emit a SSE payloadAttributes event for diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index 496a76503799..a297c9f6546c 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -1,5 +1,5 @@ import {ChainForkConfig} from "@lodestar/config"; -import {CheckpointWithHex, IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; +import {CheckpointWithHex, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; @@ -154,9 +154,14 @@ export class SeenPayloadEnvelopeInput { pruneBelowParent(parentBlock: ProtoBlock): void { for (const block of this.forkChoice.getAllAncestorBlocks(parentBlock.blockRoot, parentBlock.payloadStatus)) { - if (block.slot < parentBlock.slot) { + // Only evict once the payload is FULL (revealed/imported) — on an EMPTY/PENDING branch we may + // still need to download the FULL envelope (see #9475), and evicting would make payload-by-root + // sync throw "Missing PayloadEnvelopeInput for known block". + if (block.slot < parentBlock.slot && block.payloadStatus === PayloadStatus.FULL) { const input = this.payloadInputs.get(block.blockRoot); - if (input) { + // ...and don't evict while columns are still being gathered: writeDataColumnsToDb awaits the + // same hasComputedAllData() before persisting. Such entries are pruned by a later call. + if (input?.hasComputedAllData()) { this.evictPayloadInput(input); this.logger?.verbose("SeenPayloadEnvelopeInput.pruneBelowParent deleted", { slot: block.slot, diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts index 3623a936ace9..ba83abdfb594 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts @@ -8,7 +8,7 @@ import {ChainEventEmitter} from "../../../../src/chain/emitter.js"; import {SeenPayloadEnvelopeInput} from "../../../../src/chain/seenCache/seenPayloadEnvelopeInput.js"; import {SerializedCache} from "../../../../src/util/serializedCache.js"; import {getMockedClock} from "../../../mocks/clock.js"; -import {config, generateBlock} from "../../../utils/blocksAndData.js"; +import {config, generateBlock, generateBlockWithColumnSidecars} from "../../../utils/blocksAndData.js"; describe("SeenPayloadEnvelopeInput", () => { let cache: SeenPayloadEnvelopeInput; @@ -50,6 +50,21 @@ describe("SeenPayloadEnvelopeInput", () => { return rootHex; } + // Block with blob commitments + non-empty sampledColumns and no columns added, so the input + // reports hasComputedAllData() === false. + function addPayloadInputNotComputed(slot: number): string { + const {block, rootHex} = generateBlockWithColumnSidecars({forkName: ForkName.gloas, slot}); + cache.add({ + blockRootHex: rootHex, + block, + forkName: ForkName.gloas, + sampledColumns: [0, 1], + custodyColumns: [0, 1], + timeCreatedSec: Date.now() / 1000, + }); + return rootHex; + } + function protoBlock(blockRoot: RootHex, slot: number): ProtoBlock { return { slot, @@ -86,6 +101,31 @@ describe("SeenPayloadEnvelopeInput", () => { expect(cache.get(newRootHex)).toBeDefined(); }); + it("pruneBelowParent keeps ancestor payload inputs whose payload is not yet FULL", () => { + const oldRootHex = addPayloadInput(1); + const newRootHex = addPayloadInput(2); + const parentBlock = protoBlock(newRootHex, 2); + const emptyAncestor: ProtoBlock = {...protoBlock(oldRootHex, 1), payloadStatus: PayloadStatus.EMPTY}; + + vi.mocked(forkChoice.getAllAncestorBlocks).mockReturnValue([parentBlock, emptyAncestor]); + cache.pruneBelowParent(parentBlock); + + expect(cache.get(oldRootHex)).toBeDefined(); + }); + + it("pruneBelowParent keeps ancestor payload inputs that have not computed all data", () => { + const oldRootHex = addPayloadInputNotComputed(1); + const newRootHex = addPayloadInput(2); + // precondition: the ancestor input is still gathering columns + expect(cache.get(oldRootHex)?.hasComputedAllData()).toBe(false); + + const parentBlock = protoBlock(newRootHex, 2); + vi.mocked(forkChoice.getAllAncestorBlocks).mockReturnValue([parentBlock, protoBlock(oldRootHex, 1)]); + cache.pruneBelowParent(parentBlock); + + expect(cache.get(oldRootHex)).toBeDefined(); + }); + it("pruneBelowParent keeps payload inputs at the parent slot", () => { const rootHex = addPayloadInput(1); const parentBlock = protoBlock(rootHex, 1);