diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index 496a76503799..8428fa42a8c3 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -1,9 +1,11 @@ import {ChainForkConfig} from "@lodestar/config"; import {CheckpointWithHex, IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; +import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; +import {MAX_LOOK_AHEAD_EPOCHS} from "../../sync/constants.js"; import {IClock} from "../../util/clock.js"; import {SerializedCache} from "../../util/serializedCache.js"; import {isDaOutOfRange} from "../blocks/blockInput/index.js"; @@ -24,6 +26,13 @@ export type SeenPayloadEnvelopeInputModules = { logger?: Logger; }; +// Upper bound on the cache, enforced on every insertion. pruneFinalized / pruneBelowParent are both +// dormant during catch-up (no finality; prepareNextSlot bails once the head is far behind the clock), +// so without an insert-time cap the cache grows unbounded and OOMs the node +// (see https://github.com/ChainSafe/lodestar/issues/9073). Same size as SeenBlockInput's +// MAX_BLOCK_INPUT_CACHE_SIZE. +const MAX_PAYLOAD_ENVELOPE_INPUT_CACHE_SIZE = (MAX_LOOK_AHEAD_EPOCHS + 1) * SLOTS_PER_EPOCH; + /** * Cache for tracking PayloadEnvelopeInput instances, keyed by beacon block root. * @@ -116,6 +125,7 @@ export class SeenPayloadEnvelopeInput { root: props.blockRootHex, daOutOfRange, }); + this.pruneToMaxSize(props.blockRootHex); return input; } @@ -137,6 +147,7 @@ export class SeenPayloadEnvelopeInput { root: props.blockRootHex, daOutOfRange, }); + this.pruneToMaxSize(props.blockRootHex); return input; } @@ -167,6 +178,28 @@ export class SeenPayloadEnvelopeInput { } } + /** + * Enforce the size cap by evicting the lowest-slot entry (oldest / furthest behind the head). + * Called on every insertion so the cache is at most one over the cap. `excludeRoot` is never + * evicted, so an add can't remove itself when it is the lowest slot (e.g. an older block in a reorg). + */ + private pruneToMaxSize(excludeRoot?: RootHex): void { + while (this.payloadInputs.size > MAX_PAYLOAD_ENVELOPE_INPUT_CACHE_SIZE) { + let lowest: PayloadEnvelopeInput | undefined; + for (const input of this.payloadInputs.values()) { + if (input.blockRootHex === excludeRoot) continue; + if (lowest === undefined || input.slot < lowest.slot) lowest = input; + } + if (lowest === undefined) break; + this.evictPayloadInput(lowest); + this.logger?.debug("SeenPayloadEnvelopeInput.pruneToMaxSize evicted entry", { + slot: lowest.slot, + root: lowest.blockRootHex, + maxSize: MAX_PAYLOAD_ENVELOPE_INPUT_CACHE_SIZE, + }); + } + } + private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void { this.serializedCache.delete(payloadInput.getSerializedCacheKeys()); this.payloadInputs.delete(payloadInput.blockRootHex); 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..81c281740daf 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts @@ -1,11 +1,12 @@ import {beforeEach, describe, expect, it, vi} from "vitest"; import {ExecutionStatus, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {testLogger} from "@lodestar/logger/test-utils"; -import {ForkName} from "@lodestar/params"; +import {ForkName, SLOTS_PER_EPOCH} from "@lodestar/params"; import {DataAvailabilityStatus} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; import {ChainEventEmitter} from "../../../../src/chain/emitter.js"; import {SeenPayloadEnvelopeInput} from "../../../../src/chain/seenCache/seenPayloadEnvelopeInput.js"; +import {MAX_LOOK_AHEAD_EPOCHS} from "../../../../src/sync/constants.js"; import {SerializedCache} from "../../../../src/util/serializedCache.js"; import {getMockedClock} from "../../../mocks/clock.js"; import {config, generateBlock} from "../../../utils/blocksAndData.js"; @@ -96,6 +97,43 @@ describe("SeenPayloadEnvelopeInput", () => { expect(cache.get(rootHex)).toBeDefined(); }); + it("pruneToMaxSize bounds the cache on insertion, evicting the lowest-slot entries", () => { + // pruneFinalized / pruneBelowParent never run here, so the insertion-time cap is the only bound + const maxSize = (MAX_LOOK_AHEAD_EPOCHS + 1) * SLOTS_PER_EPOCH; + const overflow = 5; + + const rootHexBySlot = new Map(); + for (let slot = 1; slot <= maxSize + overflow; slot++) { + rootHexBySlot.set(slot, addPayloadInput(slot)); + } + + // never grows past the cap despite no finality-based pruning + expect(cache.size()).toBe(maxSize); + + // the oldest (lowest-slot) entries are evicted ... + for (let slot = 1; slot <= overflow; slot++) { + expect(cache.get(rootHexBySlot.get(slot) as string)).toBeUndefined(); + } + // ... and the most recent window is retained + for (let slot = overflow + 1; slot <= maxSize + overflow; slot++) { + expect(cache.get(rootHexBySlot.get(slot) as string)).toBeDefined(); + } + }); + + it("excludes the just-inserted entry from pruning even when it is the lowest slot", () => { + const maxSize = (MAX_LOOK_AHEAD_EPOCHS + 1) * SLOTS_PER_EPOCH; + const prevLowest = addPayloadInput(101); + for (let slot = 102; slot < 101 + maxSize; slot++) addPayloadInput(slot); + expect(cache.size()).toBe(maxSize); + + // adding an older block (lowest slot) while at cap must not evict itself; the previous lowest + // (slot 101) is evicted instead. Guards the reorg / older-fork case. + const older = addPayloadInput(1); + expect(cache.size()).toBe(maxSize); + expect(cache.get(older)).toBeDefined(); + expect(cache.get(prevLowest)).toBeUndefined(); + }); + it("add returns the existing entry on duplicate root", () => { const {block, rootHex} = generateBlock({forkName: ForkName.gloas, slot: 1}); const props = {