Bound in-memory growth during long non-finalization periods#176
Bound in-memory growth during long non-finalization periods#176pablodeymo wants to merge 4 commits intomainfrom
Conversation
🤖 Kimi Code ReviewSecurity & Correctness Review1. Critical: Pruning logic can delete finalized stateIn Fix: Add check to ensure 2. Race condition in pending block pruningIn Fix: Use 3. Memory leak in pending_blocks cleanupIn Fix: Need to scan all parent entries for stale children, not just the ones being directly pruned. 4. Inconsistent iterator changesThe change from iterator to Vec return types in Recommendation: Revert to iterator types or provide both iterator and collection methods. 5. Potential overflow in cutoff calculationIn Note: This is handled correctly, but worth documenting the edge case behavior. 6. Missing error handling in pruningSeveral pruning functions ( Fix: Propagate errors properly instead of panicking. 7. Inefficient duplicate iterationIn Optimization: Single pass to collect all keys, then batch delete across tables. 8. Missing validation in safety_net_pruneThe Fix: Add bounds checking: Minor Issues
Positive Notes
Automated review by Kimi (Moonshot AI) · custom prompt |
🤖 Codex Code Review
Assumptions:
Brief positive note:
Automated review by OpenAI Codex · custom prompt |
🤖 Claude Code ReviewNow I have enough context to write the full review. PR Review: Bound in-memory growth during long non-finalization periodsOverall the PR is well-motivated and the implementation is thoughtful. The three-part change (DB safety-net, pending-block eviction, Vec return types) is clearly decomposed. Below are the issues I found, ordered by severity. Correctness / Potential Bugs1.
// store.rs:447 — prunes slot < cutoff_slot, keeps slot == cutoff_slot
.take_while(|(k, _)| {
let (slot, _) = decode_live_chain_key(k);
slot < finalized_slot // strict <
})// store.rs:1040 — prunes slot <= cutoff_slot
if header.slot <= cutoff_slot {When 2. Non-atomic pruning — possible cross-table inconsistency on crash
For a safety-net path this may be acceptable, but a single atomic batch would eliminate the window. Worth a comment at minimum explaining the deliberate choice. 3. Magic number
// lib.rs:177
let cutoff = finalized_slot.max(self.store.head_slot().saturating_sub(1024));If 4. Both functions are byte-for-byte identical in their scan/filter logic (lines 1026–1046 and 1062–1081). That's two full fn collect_prunable_block_roots(
&self, cutoff_slot: u64, protected: &HashSet<H256>
) -> Vec<Vec<u8>> { ... }Minor Issues5. Log condition silently drops signature-only pruning // store.rs:1007
if pruned_states > 0 || pruned_blocks > 0 || pruned_chain > 0 {
6. Redundant After changing the return types to for (key, new_proofs) in store.iter_new_aggregated_payloads() { ... }works without What's Good
Automated review by Claude (Anthropic) · custom prompt |
Greptile SummaryImplemented comprehensive memory management safeguards to prevent OOM during extended non-finalization periods. The changes add safety-net pruning to the database layer that automatically prunes data older than 1024 slots behind head when finalization stalls, and introduces age-based eviction for in-memory pending blocks whose parents never arrive. Key improvements:
Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/lib.rs | Adds pending block eviction mechanism with new pending_block_slots map and prune_pending_blocks method called at interval 0 |
| crates/storage/src/store.rs | Implements safety-net pruning for stalled finalization and changes iterator methods to return Vec directly instead of impl Iterator |
| crates/blockchain/src/store.rs | Updates callers of changed iterator methods by removing redundant .collect() calls or adding .into_iter() where needed |
| crates/blockchain/tests/forkchoice_spectests.rs | Updates test code to handle new Vec return types from iterator methods by adding .into_iter() before .map() |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Slot Tick - Interval 0] --> B{Calculate Cutoff}
B --> C[cutoff = max finalized_slot, head_slot - 1024]
C --> D{cutoff > finalized_slot?}
D -->|No - Finalization Healthy| E[Skip Safety-Net Pruning]
D -->|Yes - Finalization Stalled| F[Store.safety_net_prune]
F --> G[Build Protected Roots Set]
G --> H[head, finalized, justified, safe_target]
H --> I[Prune States slot ≤ cutoff]
I --> J[Prune Blocks slot ≤ cutoff]
J --> K[Prune LiveChain slot ≤ cutoff]
K --> L[Prune Gossip Signatures]
L --> M[Prune Attestation Data]
M --> N[Prune Aggregated Payloads]
N --> O[BlockChain.prune_pending_blocks cutoff]
O --> P[Find Stale Pending Blocks slot ≤ cutoff]
P --> Q[Remove from pending_block_slots]
Q --> R[Remove from pending_block_parents]
R --> S[Clean from pending_blocks children]
S --> T[Remove Empty Parent Entries]
T --> U[Log Pruning Stats]
Last reviewed commit: 6d62a2a
When the chain runs without finalization (e.g., insufficient aggregators), all pruning is disabled since every prune function gates on finalized_slot advancing. The States table has no pruning at all, and each state is 100+ MB. After ~12 hours without finalization this can reach terabytes of data. Add a safety-net that computes cutoff_slot = max(finalized_slot, head_slot - 1024) and prunes states, blocks, live chain, signatures, attestation data, and aggregated payloads older than the cutoff. Protected roots (head, finalized, justified, safe_target) are never pruned. When finalization is healthy, cutoff equals finalized_slot and this is a no-op. Runs once per slot at interval 0 in on_tick, after tick processing but before block proposal.
When a block's parent never arrives (network partition, attack), entries in pending_blocks and pending_block_parents accumulate forever. Add a pending_block_slots map to track each pending block's slot, then evict entries older than the safety-net cutoff (max of finalized_slot and head_slot - 1024) at interval 0, right after safety_net_prune().
iter_gossip_signatures, iter_known_aggregated_payloads, and iter_new_aggregated_payloads all .collect() into a Vec internally then return .into_iter(). Return the Vec directly to make the allocation cost visible and let callers avoid double-collecting.
…4 constant safety_net_prune already computes the cutoff using MAX_UNFINALIZED_SLOTS. Have it return the value so prune_pending_blocks can reuse it instead of recomputing with a hardcoded 1024.
6d62a2a to
eddf01c
Compare
|
We'll try another approach |
Summary
pending_blocks/pending_block_parentsmapsVecdirectly from Store iterator methods that were already collecting internally, removing theimpl Iteratorfacade and letting callers avoid double-collectingCloses #103. Partially addresses #126.
Changes in detail
1. Safety-net pruning (
crates/storage/src/store.rs)Cherry-picked from
safety-net-pruningbranch. When finalization is healthy this is a no-op (cutoff equals finalized slot). When finalization stalls for >1024 slots, prunes:StatesBlockHeaders/BlockBodies/BlockSignaturesLiveChainGossipSignaturesLatestKnownAttestations/LatestNewAttestationsLatestKnownAggregatedPayloads/LatestNewAggregatedPayloadsCutoff formula:
max(finalized_slot, head_slot - 1024)Called once per slot at interval 0.
2. Evict stale pending blocks (
crates/blockchain/src/lib.rs)BlockChainServerholds three in-memory maps for orphan blocks waiting on missing parents:Without eviction, if a parent never arrives (network partition, eclipse attack, pruned ancestor), these entries accumulate forever. The new
pending_block_slotsmap tracks each pending block's slot, andprune_pending_blocks(cutoff_slot)evicts entries older than the safety-net cutoff.Wiring:
process_or_pend_block(both direct pend and ancestor-walk loop)collect_pending_childrensafety_net_prune(), using the same cutoff formula3. Honest Vec return types (
crates/storage/src/store.rs,crates/blockchain/src/store.rs,crates/blockchain/tests/forkchoice_spectests.rs)Three Store methods pretended to return lazy iterators but internally
.collect()into aVecfirst, then returned.into_iter():iter_gossip_signatures()→impl Iteratorover collectedVeciter_known_aggregated_payloads()→ sameiter_new_aggregated_payloads()→ sameChanged to return
Vecdirectly. This makes the allocation cost visible and lets callers avoid double-collecting (e.g.,store.iter_gossip_signatures().collect::<Vec<_>>()was collecting a Vec that was already collected).Updated all callers:
aggregate_committee_signatures: removed redundant.collect()produce_block_with_signatures: removed redundant.collect()update_safe_target: added.into_iter()before.collect()into HashMapextract_latest_known_attestations: added.into_iter()before.map().into_iter()before.map()How to test
In a live devnet without finalization, after 1024 slots: