feat: add block root slot index#495
Conversation
Greptile SummaryThis PR introduces a canonical slot-to-block-root index (
Confidence Score: 3/5The index maintenance logic and handler change are correct, but from_db_state now silently assumes KEY_HEAD is present and will panic rather than return None on a DB missing that key. The LCA walk in block_root_index_changes is algorithmically sound and the tests cover reorgs, skipped slots, and restore paths well. However, from_db_state now calls store.head_slot() — which panics via get_metadata if KEY_HEAD is absent — without first probing for the key the way it already probes KEY_LATEST_FINALIZED. Any DB that passes the initial key checks but is missing KEY_HEAD will crash the process instead of failing gracefully. Additionally, rebuild_block_root_index takes &self while writing to the backend, deviating from the &mut self convention used everywhere else in the Store API. crates/storage/src/store.rs — specifically the from_db_state restore guard and block_root_index_changes error handling
|
| Filename | Overview |
|---|---|
| crates/storage/src/store.rs | Core of the PR — adds BlockRoots index with init, LCA-walk update-on-head-change, rebuild-on-restore, and prune cleanup. The from_db_state guard can panic if KEY_HEAD is missing; block_root_index_changes uses expect() on header lookups; rebuild_block_root_index is a mutating method with a &self signature. |
| crates/net/p2p/src/req_resp/handlers.rs | Replaces the backward head-walk in canonical_blocks_by_range with a direct indexed slot lookup using get_block_root_by_slot; removes the HashMap and related imports. Logic is correct and the early-exit guard is preserved. |
| crates/storage/src/api/tables.rs | Adds BlockRoots variant to the Table enum and ALL_TABLES constant; name string is consistent with other tables. |
| crates/storage/src/backend/rocksdb.rs | Registers the new block_roots column family in the RocksDB backend; change is minimal and correct. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant FC as Fork Choice
participant Store
participant BlockRoots as BlockRoots Table
participant BlockHeaders as BlockHeaders Table
Note over FC,BlockHeaders: Head update (normal extension)
FC->>Store: update_checkpoints(new_head)
Store->>Store: block_root_index_changes(old_head, new_head)
Store->>BlockHeaders: get_block_header(new_head) [walk back to LCA]
BlockHeaders-->>Store: headers
Store->>BlockRoots: delete_batch(old canonical slots)
Store->>BlockRoots: put_batch(new canonical slots)
Store-->>FC: committed
Note over FC,BlockHeaders: DB restore
FC->>Store: from_db_state(backend)
Store->>BlockRoots: get(head_slot) check index
alt index stale or missing
Store->>Store: rebuild_block_root_index()
Store->>BlockHeaders: walk from head to anchor
Store->>BlockRoots: delete_batch(old) + put_batch(rebuilt)
end
Store-->>FC: Some(store)
Note over FC,BlockHeaders: BlocksByRange P2P request
FC->>Store: get_block_root_by_slot(slot) x count
Store->>BlockRoots: get(slot_key)
BlockRoots-->>Store: block_root
Store-->>FC: signed blocks
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant FC as Fork Choice
participant Store
participant BlockRoots as BlockRoots Table
participant BlockHeaders as BlockHeaders Table
Note over FC,BlockHeaders: Head update (normal extension)
FC->>Store: update_checkpoints(new_head)
Store->>Store: block_root_index_changes(old_head, new_head)
Store->>BlockHeaders: get_block_header(new_head) [walk back to LCA]
BlockHeaders-->>Store: headers
Store->>BlockRoots: delete_batch(old canonical slots)
Store->>BlockRoots: put_batch(new canonical slots)
Store-->>FC: committed
Note over FC,BlockHeaders: DB restore
FC->>Store: from_db_state(backend)
Store->>BlockRoots: get(head_slot) check index
alt index stale or missing
Store->>Store: rebuild_block_root_index()
Store->>BlockHeaders: walk from head to anchor
Store->>BlockRoots: delete_batch(old) + put_batch(rebuilt)
end
Store-->>FC: Some(store)
Note over FC,BlockHeaders: BlocksByRange P2P request
FC->>Store: get_block_root_by_slot(slot) x count
Store->>BlockRoots: get(slot_key)
BlockRoots-->>Store: block_root
Store-->>FC: signed blocks
Comments Outside Diff (3)
-
crates/storage/src/store.rs, line 577-579 (link)from_db_statecan panic ifKEY_HEADis absentfrom_db_stateis designed to returnNonegracefully when the DB is empty or incompatible — it probesKEY_CONFIGandKEY_LATEST_FINALIZEDwith?to do so. But it now callsstore.head_slot()→store.head()→get_metadata(KEY_HEAD), andget_metadatauses an innerexpect("metadata key exists")that panics on a missing key. IfKEY_HEADis absent (corrupted write, partial migration, or a hand-crafted fixture like the one fixed in the test at line 1905), the process panics instead of returningNone. The probe forKEY_HEADshould be added alongside the existing probe forKEY_LATEST_FINALIZED.Prompt To Fix With AI
This is a comment left during a code review. Path: crates/storage/src/store.rs Line: 577-579 Comment: **`from_db_state` can panic if `KEY_HEAD` is absent** `from_db_state` is designed to return `None` gracefully when the DB is empty or incompatible — it probes `KEY_CONFIG` and `KEY_LATEST_FINALIZED` with `?` to do so. But it now calls `store.head_slot()` → `store.head()` → `get_metadata(KEY_HEAD)`, and `get_metadata` uses an inner `expect("metadata key exists")` that panics on a missing key. If `KEY_HEAD` is absent (corrupted write, partial migration, or a hand-crafted fixture like the one fixed in the test at line 1905), the process panics instead of returning `None`. The probe for `KEY_HEAD` should be added alongside the existing probe for `KEY_LATEST_FINALIZED`. How can I resolve this? If you propose a fix, please make it concise.
-
crates/storage/src/store.rs, line 849-892 (link)block_root_index_changespanics on missing headers during a reorgBoth
expect("old canonical block header exists")andexpect("new canonical block header exists")will panic if a header cannot be fetched. WhileBLOCKS_TO_KEEP = 21_600makes a reorg crossing the prune boundary essentially impossible on mainnet, the same function is exercised during DB restore rebuilds. A more defensive approach would be to break out of the loop on a missing header (similar to thelet Some(header) = ... else { break }pattern used inrebuild_block_root_index), leaving any deeper chain entries unchanged rather than panicking.Prompt To Fix With AI
This is a comment left during a code review. Path: crates/storage/src/store.rs Line: 849-892 Comment: **`block_root_index_changes` panics on missing headers during a reorg** Both `expect("old canonical block header exists")` and `expect("new canonical block header exists")` will panic if a header cannot be fetched. While `BLOCKS_TO_KEEP = 21_600` makes a reorg crossing the prune boundary essentially impossible on mainnet, the same function is exercised during DB restore rebuilds. A more defensive approach would be to break out of the loop on a missing header (similar to the `let Some(header) = ... else { break }` pattern used in `rebuild_block_root_index`), leaving any deeper chain entries unchanged rather than panicking. How can I resolve this? If you propose a fix, please make it concise.
-
crates/storage/src/store.rs, line 895-923 (link)rebuild_block_root_indextakes&selfbut performs a writeEvery other mutating method on
Store(update_checkpoints,prune_old_blocks,insert_signed_block, etc.) takes&mut self.rebuild_block_root_indexwrites to the backend through the innerArcwhile only holding&self, which is inconsistent and sidesteps Rust's normal exclusivity guarantee. This is safe in the narrow call site infrom_db_state(no other holders exist yet), but makes the method's signature silently lie about its mutation semantics. Changing it to&mut selfwould be consistent with the rest of the API.Prompt To Fix With AI
This is a comment left during a code review. Path: crates/storage/src/store.rs Line: 895-923 Comment: **`rebuild_block_root_index` takes `&self` but performs a write** Every other mutating method on `Store` (`update_checkpoints`, `prune_old_blocks`, `insert_signed_block`, etc.) takes `&mut self`. `rebuild_block_root_index` writes to the backend through the inner `Arc` while only holding `&self`, which is inconsistent and sidesteps Rust's normal exclusivity guarantee. This is safe in the narrow call site in `from_db_state` (no other holders exist yet), but makes the method's signature silently lie about its mutation semantics. Changing it to `&mut self` would be consistent with the rest of the API. How can I resolve this? If you propose a fix, please make it concise.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
crates/storage/src/store.rs:577-579
**`from_db_state` can panic if `KEY_HEAD` is absent**
`from_db_state` is designed to return `None` gracefully when the DB is empty or incompatible — it probes `KEY_CONFIG` and `KEY_LATEST_FINALIZED` with `?` to do so. But it now calls `store.head_slot()` → `store.head()` → `get_metadata(KEY_HEAD)`, and `get_metadata` uses an inner `expect("metadata key exists")` that panics on a missing key. If `KEY_HEAD` is absent (corrupted write, partial migration, or a hand-crafted fixture like the one fixed in the test at line 1905), the process panics instead of returning `None`. The probe for `KEY_HEAD` should be added alongside the existing probe for `KEY_LATEST_FINALIZED`.
### Issue 2 of 3
crates/storage/src/store.rs:849-892
**`block_root_index_changes` panics on missing headers during a reorg**
Both `expect("old canonical block header exists")` and `expect("new canonical block header exists")` will panic if a header cannot be fetched. While `BLOCKS_TO_KEEP = 21_600` makes a reorg crossing the prune boundary essentially impossible on mainnet, the same function is exercised during DB restore rebuilds. A more defensive approach would be to break out of the loop on a missing header (similar to the `let Some(header) = ... else { break }` pattern used in `rebuild_block_root_index`), leaving any deeper chain entries unchanged rather than panicking.
### Issue 3 of 3
crates/storage/src/store.rs:895-923
**`rebuild_block_root_index` takes `&self` but performs a write**
Every other mutating method on `Store` (`update_checkpoints`, `prune_old_blocks`, `insert_signed_block`, etc.) takes `&mut self`. `rebuild_block_root_index` writes to the backend through the inner `Arc` while only holding `&self`, which is inconsistent and sidesteps Rust's normal exclusivity guarantee. This is safe in the narrow call site in `from_db_state` (no other holders exist yet), but makes the method's signature silently lie about its mutation semantics. Changing it to `&mut self` would be consistent with the rest of the API.
Reviews (1): Last reviewed commit: "Merge branch 'main' into feat/block-root..." | Re-trigger Greptile
|
can you solve this conflicts? |
You can review again |
The match existed only to log the error and fall back to an empty vec, which is exactly what the codebase's error-handling idiom expresses with inspect_err + unwrap_or_default.
The two-branch walk special-cased zero roots and refetched both headers every iteration, so the same "fetch a header or give up" block appeared four times. Carrying each branch's header across iterations and refetching only the side that stepped drops both special cases: genesis' parent has no header, so it already flows through the missing-header path. Equal slots no longer need an arm of their own either. The old branch steps first, dropping it below the new one, which then steps on the following iteration, yielding the same deletes and entries in the same order. A missing header now returns Error::UnexpectedMissingBlockHeader rather than warning, so update_checkpoints aborts before writing anything instead of committing a partial diff that describes neither branch.
The canonical slot -> root index makes `update_checkpoints` reject a head whose block header is not stored, so this test can no longer set up its fixture: it pointed the head at an unstored root to reach the warn-and-skip path in `diff_and_emit`. That defensive branch stays; only its test goes.
🗒️ Description / Motivation
What Changed
BlockRootsstorage table:Store::get_block_root_by_slotinstead of walking parent headers.Correctness / Behavior Guarantees
Tests Added / Run
Related Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleancargo test --workspace --release— all passing