Skip to content

Commit a2991cb

Browse files
tcoratgerclaude
andauthored
refactor(node/sync): tidy block cache and fix internal-node eviction (leanEthereum#1110)
Clarity, modern Python, and one correctness fix in BlockCache. Documentation: - Trim the banner-essay module docstring, the PendingBlock class and field docstrings, and the verbose method Args/Returns to the project rules, keeping the genuine rationale (the >= capacity reason, the empty-set deletion, the slot-order example, FIFO attack-resistance). Structure: - Inline the single-use _evict_oldest into add and drop unmark_orphan, whose only caller was remove (folded its discard inline). mark_orphan stays: it has callers in two other modules plus internal use and guards the orphan set against phantom entries. - Make PendingBlock frozen; nothing mutates it. Eviction correctness fix: - Evicting an internal node left its cached children stranded: still cached, no longer linked to a present parent, never re-marked as orphans, so backfill never refetched the parent and orphan_count under-reported (which could flip the node to synced with stuck blocks). Eviction now re-marks the evicted block's still-cached children as orphans, with a regression test. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 11983a2 commit a2991cb

2 files changed

Lines changed: 82 additions & 248 deletions

File tree

Lines changed: 46 additions & 223 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,4 @@
1-
"""
2-
Block cache for downloaded blocks awaiting parent resolution.
3-
4-
Why Cache Blocks?
5-
6-
In an ideal world, blocks arrive in perfect order: parent before child, always.
7-
Reality differs. Network latency, parallel downloads, and gossip propagation
8-
mean blocks often arrive before their parents are known.
9-
10-
Without caching, we would have two bad choices:
11-
12-
1. **Drop the block**: Wasteful. We will need it later.
13-
2. **Re-request later**: Slow. Network round-trips add latency.
14-
15-
The block cache provides a third option: hold the block until its parent
16-
arrives, then process both.
17-
18-
How It Works
19-
20-
The cache maintains three data structures:
21-
22-
1. **Block storage**: Maps block root to PendingBlock (the block + metadata)
23-
2. **Orphan set**: Roots of blocks whose parents are completely unknown
24-
3. **Parent index**: Maps parent_root to child roots for descendant lookup
25-
26-
When a parent arrives:
27-
28-
1. Look up children via the parent index
29-
2. Check if those children can now be processed
30-
3. Process children in slot order (ensuring parent-before-child)
31-
4. Recursively check if processed children have their own waiting children
32-
33-
Memory Safety
34-
35-
The cache is bounded by MAX_CACHED_BLOCKS (1024). When full, FIFO eviction
36-
removes the oldest blocks. This prevents memory exhaustion from attacks or
37-
prolonged network partitions that could otherwise grow the cache unboundedly.
38-
"""
1+
"""Block cache for downloaded blocks awaiting parent resolution."""
392

403
from __future__ import annotations
414

@@ -49,89 +12,39 @@
4912
from lean_spec.spec.ssz import Bytes32
5013

5114

52-
@dataclass(slots=True)
15+
@dataclass(frozen=True, slots=True)
5316
class PendingBlock:
54-
"""
55-
A block awaiting integration into the Store.
56-
57-
PendingBlock wraps a SignedBlock with metadata needed for
58-
cache management. This metadata answers key questions:
59-
60-
- **Who sent it?** For peer scoring when we determine validity
61-
- **When did it arrive?** For timeout and staleness decisions
62-
- **How deep is the backfill?** To enforce MAX_BACKFILL_DEPTH limit
63-
64-
Blocks remain pending until:
65-
66-
1. Their parent arrives and they can be processed (success)
67-
2. They are evicted due to cache capacity limits (dropped)
68-
3. They are determined to be invalid (rejected)
69-
"""
17+
"""A cached block plus the metadata needed to resolve and score it later."""
7018

7119
block: SignedBlock
7220
"""The complete signed block."""
7321

7422
root: Bytes32
75-
"""
76-
The SSZ hash tree root of the block.
77-
78-
Computed once at cache insertion for efficiency. All subsequent lookups
79-
and comparisons use this cached value rather than recomputing.
80-
"""
23+
"""Hash tree root of the block, computed once at insertion to avoid recomputing."""
8124

8225
parent_root: Bytes32
83-
"""
84-
Root of the parent block.
85-
86-
Stored separately for quick parent relationship lookups without
87-
deserializing the full block. This is the key to efficient descendant
88-
processing.
89-
"""
26+
"""Root of the parent, stored separately so lookups need not deserialize the block."""
9027

9128
slot: Slot
92-
"""
93-
Slot of the block.
94-
95-
Used for ordering during batch processing. We must process blocks in
96-
slot order to ensure parents are processed before children.
97-
"""
29+
"""Slot of the block, used to order batch processing so parents precede children."""
9830

9931
received_from: PeerId | None
100-
"""
101-
Peer that sent this block.
102-
103-
Essential for peer scoring.
104-
- If the block is valid, the peer gets credit.
105-
- If invalid, they get penalized.
106-
107-
This creates incentives for good behavior.
108-
None for self-produced blocks.
109-
"""
32+
"""Peer that sent the block, for later scoring. None for self-produced blocks."""
11033

11134
backfill_depth: int = 0
11235
"""
113-
Depth of backfill chain from original request.
36+
How many ancestors deep this block was fetched while chasing a missing parent.
11437
115-
When fetching missing parents recursively, this tracks how deep we are.
116-
- A block at depth 0 came directly from gossip or an explicit request.
117-
- A block at depth 5 is the 5th ancestor we fetched while backfilling.
118-
119-
Blocks with depth >= MAX_BACKFILL_DEPTH trigger backfill termination
120-
to prevent unbounded recursion during attacks or deep forks.
38+
Bounds recursion so deep forks or attacks cannot backfill without limit.
12139
"""
12240

12341

12442
@dataclass(slots=True)
12543
class BlockCache:
126-
"""
127-
Cache for blocks awaiting parent resolution.
128-
129-
Holds blocks that cannot be immediately processed because their parent
130-
blocks are not yet in the Store.
131-
"""
44+
"""Cache for blocks that cannot be processed yet because their parent is unknown."""
13245

13346
_blocks: OrderedDict[Bytes32, PendingBlock] = field(default_factory=OrderedDict)
134-
"""Block storage ordered by insertion time for FIFO eviction."""
47+
"""Blocks ordered by insertion time, so the oldest can be evicted first."""
13548

13649
_orphans: set[Bytes32] = field(default_factory=set)
13750
"""Roots of blocks whose parents are completely unknown."""
@@ -154,82 +67,66 @@ def add(
15467
backfill_depth: int = 0,
15568
) -> PendingBlock:
15669
"""
157-
Add a block to the cache.
158-
159-
This is the primary entry point for caching blocks. The method handles:
160-
161-
1. Deduplication (same block added twice returns existing entry)
162-
2. Capacity management (evicts oldest if full)
163-
3. Index maintenance (updates parent->children mapping)
70+
Cache a block, evicting the oldest entry first if at capacity.
16471
165-
Args:
166-
block: The signed block to cache.
167-
peer: The peer that sent this block (for later scoring).
168-
backfill_depth: How deep in the backfill chain (0 = direct request).
169-
170-
Returns:
171-
The PendingBlock wrapper, either newly created or existing.
72+
Returns the existing entry if the block is already cached.
17273
"""
17374
block_inner = block.block
17475
root = hash_tree_root(block_inner)
17576

176-
# Deduplication: if we already have this block, return the existing entry.
77+
# The same block can arrive from several peers, or be re-requested while pending.
17778
#
178-
# This can happen when multiple peers send the same block via gossip,
179-
# or when a block is requested while already pending.
79+
# Return the existing entry instead of caching a duplicate.
18080
if root in self._blocks:
18181
return self._blocks[root]
18282

183-
# Capacity management: evict before adding to ensure we stay within bounds.
184-
#
185-
# We check >= rather than > because we are about to add one more.
83+
# Use >= here, not >: we are about to add one entry, so evict at the limit.
18684
if len(self._blocks) >= MAX_CACHED_BLOCKS:
187-
self._evict_oldest()
188-
189-
parent_root = block_inner.parent_root
85+
# Evict the oldest block (FIFO).
86+
#
87+
# Oldest-first means an attacker cannot keep a block cached by re-sending it.
88+
evicted_root, evicted = self._blocks.popitem(last=False)
89+
self._orphans.discard(evicted_root)
90+
91+
# Detach the evicted block from its parent's child set.
92+
siblings = self._by_parent.get(evicted.parent_root)
93+
if siblings:
94+
siblings.discard(evicted_root)
95+
if not siblings:
96+
del self._by_parent[evicted.parent_root]
97+
98+
# Its cached children just lost their parent, so they are orphans again.
99+
for child_root in self._by_parent.get(evicted_root, set()):
100+
self.mark_orphan(child_root)
190101

191102
pending = PendingBlock(
192103
block=block,
193104
root=root,
194-
parent_root=parent_root,
105+
parent_root=block_inner.parent_root,
195106
slot=block_inner.slot,
196107
received_from=peer,
197108
backfill_depth=backfill_depth,
198109
)
199-
200-
# Insert into primary storage.
201110
self._blocks[root] = pending
202111

203-
# Update parent index so we can find this block when its parent arrives.
204-
self._by_parent[parent_root].add(root)
205-
112+
# Index by parent so this block is found when its parent is processed.
113+
self._by_parent[block_inner.parent_root].add(root)
206114
return pending
207115

208116
def remove(self, root: Bytes32) -> PendingBlock | None:
209117
"""
210-
Remove a block from the cache.
118+
Remove a block and clean up its orphan and parent-index entries.
211119
212-
Called after a block has been successfully processed or determined
213-
invalid. Maintains all index consistency automatically.
214-
215-
Args:
216-
root: The block root to remove.
217-
218-
Returns:
219-
The removed PendingBlock if it existed, None otherwise.
120+
Returns the removed block, or None if it was not cached.
220121
"""
221122
pending = self._blocks.pop(root, None)
222123
if pending is None:
223124
return None
224125

225-
# Clean up orphan tracking.
226-
self.unmark_orphan(root)
126+
self._orphans.discard(root)
227127

228-
# Clean up parent index.
229-
#
230-
# We must remove this block from its parent's child set. If that
231-
# leaves the parent with no children, remove the parent entry entirely
232-
# to avoid memory leaks from empty sets accumulating.
128+
# Drop this child from its parent's set.
129+
# Delete the parent entry once empty, so empty sets do not accumulate.
233130
children = self._by_parent.get(pending.parent_root)
234131
if children:
235132
children.discard(root)
@@ -240,95 +137,21 @@ def remove(self, root: Bytes32) -> PendingBlock | None:
240137

241138
def mark_orphan(self, root: Bytes32) -> None:
242139
"""
243-
Mark a block as an orphan (parent not in Store or cache).
244-
245-
An orphan is a block whose parent is completely unknown. This differs
246-
from a block whose parent is simply pending: orphans need backfill,
247-
pending blocks just need to wait.
140+
Mark a block as an orphan: its parent is in neither the store nor the cache.
248141
249-
Typical flow:
250-
1. Block arrives
251-
2. Check if parent in Store -> no
252-
3. Check if parent in cache -> no
253-
4. Mark as orphan, trigger backfill for parent
254-
255-
Args:
256-
root: The block root to mark as orphan.
142+
Unlike a block waiting on a pending parent, an orphan needs its parent backfilled.
257143
"""
258144
if root in self._blocks:
259145
self._orphans.add(root)
260146

261-
def unmark_orphan(self, root: Bytes32) -> None:
262-
"""
263-
Remove orphan status from a block.
264-
265-
Called when a block's parent has been received. The block is no longer
266-
an orphan because its parent now exists (in cache or Store).
267-
268-
Args:
269-
root: The block root to unmark.
270-
"""
271-
self._orphans.discard(root)
272-
273147
def get_children(self, parent_root: Bytes32) -> list[PendingBlock]:
274-
"""
275-
Get all cached children of a given parent root.
276-
277-
This is the core of descendant processing. When a parent block is
278-
successfully processed, call this method to find children that were
279-
waiting for it. Those children can now be processed.
280-
281-
Results are sorted by slot to ensure parent-before-child ordering when
282-
processing a chain of descendants.
283-
284-
Args:
285-
parent_root: The root of the parent block that was just processed.
286-
287-
Returns:
288-
List of pending child blocks, sorted by slot (earliest first).
289-
"""
148+
"""Return the cached children of a parent, sorted by slot so parents process first."""
290149
child_roots = self._by_parent.get(parent_root, set())
291-
children = [self._blocks[r] for r in child_roots if r in self._blocks]
292-
293-
# Sort by slot ensures correct processing order.
294-
#
295-
# If block A at slot 100 and block B at slot 101 both waited for
296-
# parent P, we must process A before B.
297-
return sorted(children, key=lambda p: p.slot)
150+
children = [self._blocks[root] for root in child_roots if root in self._blocks]
151+
# If two blocks at slots 100 and 101 share a parent, 100 must process first.
152+
return sorted(children, key=lambda pending: pending.slot)
298153

299154
@property
300155
def orphan_count(self) -> int:
301156
"""Number of orphan blocks in the cache."""
302157
return len(self._orphans)
303-
304-
def _evict_oldest(self) -> None:
305-
"""
306-
Evict the oldest block to make room for new entries.
307-
308-
FIFO (First-In-First-Out) eviction is used because:
309-
310-
1. **Simplicity**: No scoring or prioritization needed
311-
2. **Fairness**: Old blocks had their chance; new ones deserve theirs
312-
3. **Attack resistance**: Attackers cannot keep malicious blocks cached
313-
by refreshing them
314-
315-
More sophisticated strategies (LRU, priority queues) add complexity
316-
without clear benefits for this use case.
317-
"""
318-
if not self._blocks:
319-
return
320-
321-
# popitem(last=False) removes the first (oldest) entry.
322-
#
323-
# This is O(1) due to OrderedDict's doubly-linked list implementation.
324-
oldest_root, oldest_block = self._blocks.popitem(last=False)
325-
326-
# Clean up orphan tracking.
327-
self._orphans.discard(oldest_root)
328-
329-
# Clean up parent index to prevent memory leaks.
330-
children = self._by_parent.get(oldest_block.parent_root)
331-
if children:
332-
children.discard(oldest_root)
333-
if not children:
334-
del self._by_parent[oldest_block.parent_root]

0 commit comments

Comments
 (0)