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
403from __future__ import annotations
414
4912from lean_spec .spec .ssz import Bytes32
5013
5114
52- @dataclass (slots = True )
15+ @dataclass (frozen = True , slots = True )
5316class 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 )
12543class 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