Commit 455611c
authored
Defer state/block pruning until after block cascade completes (#240)
## Motivation
During the devnet4 run (2026-03-13), all three ethlambda nodes entered
an **infinite re-processing loop** at slot ~15276, generating ~3.5GB of
logs each and consuming 100% CPU for hours.
This PR fixes the root cause by deferring heavy state/block pruning
until after a block processing cascade completes, so parent states
survive long enough for their children to be processed.
## Root Cause
The infinite loop is caused by **fallback pruning running inside the
block processing cascade**, deleting states that pending children still
need.
### The three interacting mechanisms
**1. Asymmetric retention creates a state-header gap**
When finalization stalls, fallback pruning keeps only
`STATES_TO_KEEP=900` states but `BLOCKS_TO_KEEP=21600` headers. Block
headers exist in DB without their states.
**2. Chain walk reaches protected checkpoints**
When a block arrives with a missing parent, `process_or_pend_block`
walks ancestor headers looking for one whose parent has state. Protected
checkpoints (justified/finalized) always have state, so the walk can
reach blocks thousands of slots behind head.
**3. Mid-cascade pruning deletes just-computed states**
`on_block_core` calls `update_checkpoints` after every block, which runs
`prune_old_states`. States for old slots (far behind head) are
immediately deleted — even if they were just computed milliseconds ago
by the same cascade.
### The loop
```
┌──────────────────────────────────────────────┐
│ │
▼ │
1. Chain walk finds block 15266 (parent=4dda, justified) │
→ parent state exists (protected) → enqueue for processing │
│ │
2. Cascade processes 15266 → 15269 → ... → 15276 │
→ states computed and stored │
│ │
3. Each on_block_core calls update_checkpoints │
→ fallback pruning runs → states for slots 15266-15276 │
are IMMEDIATELY deleted (slot < head - 900) │
│ │
4. collect_pending_children(15276) finds block 15278 │
→ process_or_pend_block(15278) │
→ has_state(parent=15276) → FALSE (just pruned!) │
→ stores as pending │
│ │
5. Chain walk for 15278 re-discovers 15266 │
→ parent 4dda still has state (protected) │
→ enqueue 15266 ─────────────────────────────────────────────→─┘
```
### How it was triggered in devnet4
1. 9 validators, 7 clients. Finalization stalled at slot 15261 due to a
fork at slot 15264 (qlean diverged).
2. At ~10:13:40 UTC, qlean's alternate fork blocks arrived at ethlambda
via gossip.
3. The chain walk for these blocks traversed ~2000 slots back to the
justified checkpoint.
4. The cascade re-processed blocks 15266→15276, but fallback pruning
deleted each state immediately.
5. All three ethlambda nodes (validators 6, 7, 8) entered the loop
simultaneously.
## Solution
**Defer heavy pruning (states + blocks) until after the block cascade
completes.**
### Before (pruning runs per-block, mid-cascade)
```
on_block
└─ while queue:
└─ process_or_pend_block
└─ on_block_core
└─ update_checkpoints
├─ write metadata ← immediate
├─ prune_live_chain ← immediate
├─ prune_gossip_signatures ← immediate
├─ prune_old_states ← DELETES PARENT STATES MID-CASCADE
└─ prune_old_blocks ← DELETES BLOCK DATA MID-CASCADE
```
### After (pruning deferred to end of cascade)
```
on_block
└─ while queue:
│ └─ process_or_pend_block
│ └─ on_block_core
│ └─ update_checkpoints
│ ├─ write metadata ← immediate
│ ├─ prune_live_chain ← immediate (fork choice correctness)
│ ├─ prune_gossip_signatures ← immediate (cheap)
│ └─ (no state/block pruning)
│
└─ store.prune_old_data() ← runs ONCE after cascade
```
### Split of `update_checkpoints`
| Operation | Where it runs | Why |
|-----------|--------------|-----|
| Write head/justified/finalized metadata | `update_checkpoints`
(per-block) | Checkpoints must be current for fork choice |
| `prune_live_chain` | `update_checkpoints` (per-block) | Affects fork
choice traversal |
| `prune_gossip_signatures` | `update_checkpoints` (per-block) | Cheap,
correctness-related |
| `prune_attestation_data_by_root` | `update_checkpoints` (per-block) |
Cheap, correctness-related |
| `prune_old_states` | **`prune_old_data`** (after cascade) | Heavy,
causes infinite loop if mid-cascade |
| `prune_old_blocks` | **`prune_old_data`** (after cascade) | Heavy,
coupled with state pruning |
### Why this fixes the loop
With deferred pruning, the devnet4 scenario plays out safely:
1. Cascade processes 15266 → 15269 → ... → 15276 → **states are KEPT**
(no pruning mid-cascade)
2. `collect_pending_children(15276)` finds 15278 →
`has_state(parent=15276)` → **TRUE** (state still exists)
3. 15278 processes successfully, cascade continues through children
4. Queue empties, `while` loop ends
5. `prune_old_data()` runs once — deletes old states
6. Cascade is already done — no one re-triggers it
### Cross-client validation
We surveyed how other lean consensus clients handle this (Lighthouse,
Zeam, Ream, Qlean, Lantern, Grandine). **None of them prune states
mid-cascade.** Common patterns:
- **Zeam**: Canonicality-based pruning, only after finalization or after
long stalls (14,400 slots). Never during block processing.
- **Ream**: Prunes one state per tick (not during block import).
- **Grandine**: Never prunes states (in-memory forever).
- **Lighthouse**: Background migrator thread, decoupled from block
import.
## Changes
- **`crates/storage/src/store.rs`**: Split `update_checkpoints` —
extract `prune_old_states`/`prune_old_blocks` into new
`prune_old_data()` method. Lightweight pruning (live chain, signatures,
attestation data) stays in `update_checkpoints`.
- **`crates/blockchain/src/lib.rs`**: Call `store.prune_old_data()` once
after the `on_block` while loop completes.
- **Tests**: Updated `fallback_pruning_*` tests to call
`prune_old_data()` explicitly.
## How to Test
1. `make test` — all 125 tests pass including 27 fork choice spec tests
2. Deploy to devnet with a multi-client setup where finalization stalls
and alternate fork blocks arrive
3. Verify ethlambda nodes do not enter re-processing loops (no repeated
"Block imported successfully" for the same slot in logs)
4. Monitor memory during long finalization stalls — temporary state
accumulation during cascades is bounded by cascade size1 parent 50527b2 commit 455611c
2 files changed
+34
-34
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
299 | 299 | | |
300 | 300 | | |
301 | 301 | | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
302 | 307 | | |
303 | 308 | | |
304 | 309 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
470 | 470 | | |
471 | 471 | | |
472 | 472 | | |
473 | | - | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
474 | 477 | | |
475 | 478 | | |
476 | 479 | | |
477 | 480 | | |
478 | | - | |
479 | | - | |
480 | 481 | | |
481 | 482 | | |
482 | | - | |
483 | | - | |
484 | | - | |
485 | | - | |
486 | | - | |
487 | | - | |
488 | | - | |
489 | | - | |
490 | | - | |
491 | | - | |
492 | | - | |
| 483 | + | |
| 484 | + | |
493 | 485 | | |
494 | 486 | | |
495 | | - | |
496 | | - | |
497 | | - | |
498 | | - | |
499 | | - | |
500 | | - | |
501 | | - | |
502 | | - | |
503 | | - | |
504 | | - | |
505 | | - | |
506 | | - | |
507 | | - | |
508 | | - | |
509 | | - | |
510 | | - | |
511 | | - | |
512 | | - | |
513 | | - | |
514 | | - | |
| 487 | + | |
515 | 488 | | |
516 | 489 | | |
517 | 490 | | |
518 | 491 | | |
519 | 492 | | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
520 | 508 | | |
521 | 509 | | |
522 | 510 | | |
| |||
1486 | 1474 | | |
1487 | 1475 | | |
1488 | 1476 | | |
| 1477 | + | |
| 1478 | + | |
| 1479 | + | |
| 1480 | + | |
| 1481 | + | |
| 1482 | + | |
1489 | 1483 | | |
1490 | 1484 | | |
1491 | 1485 | | |
| |||
1530 | 1524 | | |
1531 | 1525 | | |
1532 | 1526 | | |
| 1527 | + | |
1533 | 1528 | | |
1534 | 1529 | | |
1535 | 1530 | | |
| |||
0 commit comments