From b933c862233d416b1d526fbc9dad62311dfe675e Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 9 Jun 2026 11:58:47 +0000 Subject: [PATCH] fix(beacon-node): protect just-inserted entry from pruneToMaxSize eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `pruneToMaxSize()` fires from inside `add()` / `addFromBid()` and the cache is at cap, the lowest-slot entry is evicted. If the just-inserted entry has a lower slot than every existing entry (out-of-order range-sync response, reorg-recovery insert, etc.), the prune evicts the entry the caller just added — defeating the insertion. Pass `props.blockRootHex` as `excludeRoot` so the just-inserted entry is filtered out of eviction candidates. Adds a fast O(N) min-finding path for the per-insertion `itemsToDelete === 1` case to avoid an O(N log N) sort of the whole cache on the hot path; the multi-item path keeps the existing sort-based approach. Includes a regression test that fills the cache with high-slot entries and inserts a lower-slot entry to verify the new entry survives and the previously-lowest entry is evicted instead. 🤖 Generated with AI assistance --- .../seenCache/seenPayloadEnvelopeInput.ts | 43 ++++++++++++++----- .../seenPayloadEnvelopeInput.test.ts | 24 +++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index e8c2b467d2a7..0ead741feb52 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -124,7 +124,7 @@ export class SeenPayloadEnvelopeInput { root: props.blockRootHex, daOutOfRange, }); - this.pruneToMaxSize(); + this.pruneToMaxSize(props.blockRootHex); return input; } @@ -146,7 +146,7 @@ export class SeenPayloadEnvelopeInput { root: props.blockRootHex, daOutOfRange, }); - this.pruneToMaxSize(); + this.pruneToMaxSize(props.blockRootHex); return input; } @@ -177,20 +177,43 @@ export class SeenPayloadEnvelopeInput { } } - /** Evict the lowest-slot entries (oldest / furthest behind the head) once over the max size. */ - private pruneToMaxSize(): void { + /** + * Evict the lowest-slot entries (oldest / furthest behind the head) once over the max size. + * `excludeRoot` protects the just-inserted entry from being evicted by its own triggering call, + * which matters when a reorg or out-of-order range-sync response inserts an entry whose slot is + * lower than every existing entry's slot. + */ + private pruneToMaxSize(excludeRoot?: RootHex): void { let itemsToDelete = this.payloadInputs.size - MAX_PAYLOAD_ENVELOPE_INPUT_CACHE_SIZE; if (itemsToDelete <= 0) { return; } - const sorted = [...this.payloadInputs.values()].sort((a, b) => a.slot - b.slot); let deletedCount = 0; - for (const input of sorted) { - this.evictPayloadInput(input); - deletedCount++; - if (--itemsToDelete <= 0) { - break; + if (itemsToDelete === 1) { + // Fast path for the per-insertion call: find the lowest-slot non-excluded entry in O(N) instead of + // paying for an O(N log N) sort of the whole cache. + let minInput: PayloadEnvelopeInput | undefined; + for (const input of this.payloadInputs.values()) { + if (input.blockRootHex === excludeRoot) continue; + if (minInput === undefined || input.slot < minInput.slot) { + minInput = input; + } + } + if (minInput !== undefined) { + this.evictPayloadInput(minInput); + deletedCount = 1; + } + } else { + const sorted = [...this.payloadInputs.values()] + .filter((input) => input.blockRootHex !== excludeRoot) + .sort((a, b) => a.slot - b.slot); + for (const input of sorted) { + this.evictPayloadInput(input); + deletedCount++; + if (--itemsToDelete <= 0) { + break; + } } } 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 2a4122971d73..7a9759b2ac45 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts @@ -120,6 +120,30 @@ describe("SeenPayloadEnvelopeInput", () => { } }); + it("pruneToMaxSize does not evict the just-inserted entry even if it has the lowest slot", () => { + // Fill the cache with high-slot entries (range-sync look-ahead state) + const maxSize = (MAX_LOOK_AHEAD_EPOCHS + 1) * SLOTS_PER_EPOCH; + const highSlotBase = 1000; + const rootHexBySlot = new Map(); + for (let i = 0; i < maxSize; i++) { + const slot = highSlotBase + i; + rootHexBySlot.set(slot, addPayloadInput(slot)); + } + expect(cache.size()).toBe(maxSize); + + // Insert an entry whose slot is lower than every existing entry's slot — out-of-order range-sync + // response, reorg-recovery insert, etc. The triggering prune must NOT evict the just-inserted + // entry; it should evict the previously-lowest existing entry instead. + const lowSlot = 1; + const lowRootHex = addPayloadInput(lowSlot); + + expect(cache.size()).toBe(maxSize); + // just-inserted entry survives + expect(cache.get(lowRootHex)).toBeDefined(); + // previously-lowest entry is the one evicted + expect(cache.get(rootHexBySlot.get(highSlotBase) as string)).toBeUndefined(); + }); + it("add returns the existing entry on duplicate root", () => { const {block, rootHex} = generateBlock({forkName: ForkName.gloas, slot: 1}); const props = {