Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/beacon-node/src/chain/blocks/importBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid walking all ancestors on every import

When syncing through a long unfinalized Gloas range, this new per-import prune runs once for every imported block. pruneBelowParent() calls getAllAncestorBlocks(...), which walks from the parent back toward the finalized boundary, so importing N unfinalized blocks does O(N²) ancestor scans in the same non-finality syncing case this change targets and can stall sync on CPU. Please throttle this or track the last pruned point so each import only scans the newly eligible portion.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid concern, and correct in direction. During non-finality the finalized boundary is frozen, so getAllAncestorBlocks walks parent→finalized with depth growing 1→N across imports → Σk = O(N²) node visits + a fresh ProtoNode[] allocation each import. In the empty-branch case this PR targets, those ancestors fail the FULL && hasComputedAllData() guard, so the walk evicts nothing — it's pure re-scan overhead over the same prefix.

Magnitude: negligible for a few epochs (N≈100–300 → tens of thousands of cheap ops). It only bites under prolonged non-finality (thousands of unfinalized blocks) — which is exactly the scenario class this change targets, so worth bounding eventually.

Concrete bounded fix: swap getAllAncestorBlocks for the lazy iterateAncestorBlocks generator and keep a lastPrunedSlot cursor, breaking early once the walk reaches already-pruned depth → aggregate O(N).

One correctness caveat with a pure cursor: an ancestor that was EMPTY when first walked but turns FULL later (payload revealed mid-sync) sits below the cursor and would never be re-visited by pruneBelowParent. pruneFinalized is the backstop (it evicts everything below the finalized slot on the next finalization), so the retained set stays bounded by the non-finality window even with the cursor. If you want precise eviction without leaning on finalization, an evict-on-FULL-transition hook is the alternative, but that's a larger change.

Non-blocking from my side for the immediate devnet fix — fine as a follow-up. Your call on whether to fold it in here.

}
});
}

if (newHead.blockRoot !== oldHead.blockRoot) {
// Set head state as strong reference
this.regen.updateHeadState(newHead, postState);
Expand Down
10 changes: 0 additions & 10 deletions packages/beacon-node/src/chain/prepareNextSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading