Skip to content

Commit 0588c2d

Browse files
authored
fix(node): keep slot_index canonical across side-branch imports and reorgs (#1202)
The storage interface documents get_block_root_by_slot as returning the canonical block root at a slot, but _persist_block wrote the index for every imported block: a side-branch import silently overwrote the canonical entry at its slot, and nothing repaired the index when the head moved. Track the head the database last committed and diff it against the store's head inside the persistence batch: slots on the new canonical branch are (re)written, slots only the old branch filled are deleted, and a block that did not move the head no longer touches the index. Closes #1201
1 parent cce7955 commit 0588c2d

6 files changed

Lines changed: 221 additions & 5 deletions

File tree

packages/testing/src/consensus_testing/mocks.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ class MockForkchoiceStore:
181181
advance_finalized_on_block: bool = False
182182
"""Whether processing a block advances the finalized checkpoint to it."""
183183

184+
advance_head_on_block: bool = True
185+
"""Whether processing a block moves the head to it; False imports a side branch."""
186+
184187
received_attestations: list[SignedAttestation] = field(default_factory=list)
185188
"""Attestations accepted so far, in arrival order."""
186189

@@ -197,10 +200,11 @@ def on_block(self, _store: Store, signed_block: SignedBlock) -> MockForkchoiceSt
197200
"""Record a block as the new head and apply the configured side effects."""
198201
root = hash_tree_root(signed_block.block)
199202
self.blocks[root] = signed_block.block
200-
self.head = root
201-
# No real safe-target rule here, so the head doubles as it.
202-
self.safe_target = root
203-
self.head_slot = signed_block.block.slot
203+
if self.advance_head_on_block:
204+
self.head = root
205+
# No real safe-target rule here, so the head doubles as it.
206+
self.safe_target = root
207+
self.head_slot = signed_block.block.slot
204208
if self.on_block_post_state is not None:
205209
self.states[root] = self.on_block_post_state
206210
if self.advance_justified_on_block:
@@ -255,6 +259,7 @@ class RecordingSyncDatabase:
255259
def __init__(self) -> None:
256260
"""Start with an empty call log."""
257261
self.calls: list[RecordedCall] = []
262+
self.head_root: Bytes32 | None = None
258263

259264
def _record(self, name: str, *args: object, **kwargs: object) -> None:
260265
self.calls.append(RecordedCall(name=name, args=args, kwargs=MappingProxyType(dict(kwargs))))
@@ -298,6 +303,14 @@ def put_block_root_by_slot(self, slot: object, root: object) -> None:
298303
"""Record a slot to block-root index write."""
299304
self._record("put_block_root_by_slot", slot, root)
300305

306+
def delete_block_root_by_slot(self, slot: object) -> None:
307+
"""Record a slot-index deletion."""
308+
self._record("delete_block_root_by_slot", slot)
309+
310+
def get_head_root(self) -> Bytes32 | None:
311+
"""Return the seeded head root; reads are not part of the recorded write contract."""
312+
return self.head_root
313+
301314
def put_head_root(self, root: object) -> None:
302315
"""Record a head-root write."""
303316
self._record("put_head_root", root)

src/lean_spec/node/storage/database.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ def put_block_root_by_slot(self, slot: Slot, root: Bytes32) -> None:
8383
"""Index a block root by its slot."""
8484
...
8585

86+
def delete_block_root_by_slot(self, slot: Slot) -> None:
87+
"""
88+
Remove the slot-index entry at a slot, if present.
89+
90+
Needed when a reorg leaves a formerly canonical slot empty.
91+
"""
92+
...
93+
8694
# State Root Index Operations
8795

8896
def get_block_root_by_state_root(self, state_root: Bytes32) -> Bytes32 | None:

src/lean_spec/node/storage/sqlite.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,19 @@ def put_block_root_by_slot(self, slot: Slot, root: Bytes32) -> None:
327327
f"Failed to write slot index for slot {slot}: {exception}"
328328
) from exception
329329

330+
def delete_block_root_by_slot(self, slot: Slot) -> None:
331+
"""Remove the slot-index entry at a slot, if present."""
332+
try:
333+
cursor = self._connection.cursor()
334+
cursor.execute(
335+
f"DELETE FROM {SLOT_INDEX_TABLE_NAME} WHERE slot = ?",
336+
(int(slot),),
337+
)
338+
except sqlite3.Error as exception:
339+
raise StorageWriteError(
340+
f"Failed to delete slot index for slot {slot}: {exception}"
341+
) from exception
342+
330343
# State Root Index Operations
331344

332345
def get_block_root_by_state_root(self, state_root: Bytes32) -> Bytes32 | None:

src/lean_spec/node/sync/service.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,20 @@ class SyncService:
149149
until new blocks arrive.
150150
"""
151151

152+
_persisted_head_root: Bytes32 | None = field(default=None)
153+
"""
154+
Head root the database last committed, or None when untracked.
155+
156+
The slot-index update diffs the store's head against this to find the
157+
slots whose canonical block changed.
158+
"""
159+
152160
def __post_init__(self) -> None:
153161
"""Wire sub-components and apply the genesis-start state hint."""
162+
# Seed the persisted-head tracker from disk so a restarted node
163+
# diffs its slot index against the chain the database last saw.
164+
if self.database is not None:
165+
self._persisted_head_root = self.database.get_head_root()
154166
# Backfill reads the store through self, so it sees each post-block reassignment.
155167
self._backfill = BackfillSync(
156168
peer_manager=self.peer_manager,
@@ -293,7 +305,12 @@ def _persist_block(self, store: Store, block: Block) -> None:
293305
# On restart these tell us where the chain ended last session.
294306
#
295307
# The node can resume forkchoice without re-deriving from scratch.
296-
self.database.put_block_root_by_slot(block.slot, block_root)
308+
#
309+
# The slot index tracks the canonical chain, not the import stream:
310+
# a block that did not move the head sits on a side branch (its
311+
# parent was already known, so it cannot be a head ancestor) and
312+
# must not displace the canonical entry at its slot.
313+
self._reindex_canonical_slots(store)
297314
self.database.put_head_root(store.head)
298315
self.database.put_justified_checkpoint(store.latest_justified)
299316
self.database.put_finalized_checkpoint(store.latest_finalized)
@@ -307,6 +324,53 @@ def _persist_block(self, store: Store, block: Block) -> None:
307324
keep_roots=frozenset({store.latest_finalized.root}),
308325
)
309326

327+
# The tracker mirrors the database, so it moves only after a commit.
328+
self._persisted_head_root = store.head
329+
330+
def _reindex_canonical_slots(self, store: Store) -> None:
331+
"""
332+
Align the persisted slot index with the store's canonical chain.
333+
334+
Walks the old and new head branches down to their fork point: slots
335+
on the new branch are (re)written, slots only the old branch filled
336+
are deleted. A no-op when the head did not move.
337+
338+
Runs inside the caller's batch, so the index and the head pointer
339+
commit together.
340+
"""
341+
# Bytes32 rejects comparison against None, so the None case is explicit.
342+
if self.database is None or (
343+
self._persisted_head_root is not None and store.head == self._persisted_head_root
344+
):
345+
return
346+
347+
new_entries: dict[Slot, Bytes32] = {}
348+
stale_slots: set[Slot] = set()
349+
new_root = store.head
350+
old_root = self._persisted_head_root
351+
352+
# Lower the higher tip one parent link at a time until the branches
353+
# meet (or leave the store, e.g. an untracked previous head).
354+
while old_root is None or new_root != old_root:
355+
new_block = store.blocks.get(new_root)
356+
old_block = None if old_root is None else store.blocks.get(old_root)
357+
if new_block is not None and (old_block is None or new_block.slot >= old_block.slot):
358+
new_entries[new_block.slot] = new_root
359+
new_root = new_block.parent_root
360+
elif old_block is not None:
361+
stale_slots.add(old_block.slot)
362+
old_root = old_block.parent_root
363+
else:
364+
break
365+
366+
for slot in sorted(new_entries):
367+
self.database.put_block_root_by_slot(slot, new_entries[slot])
368+
369+
# A slot the new branch refilled keeps its fresh entry; only slots
370+
# left empty by the reorg lose theirs.
371+
for slot in sorted(stale_slots - new_entries.keys()):
372+
self.database.delete_block_root_by_slot(slot)
373+
310374
def _prune_signed_blocks_below_serving_window(self) -> None:
311375
"""Drop retained signed blocks that fell out of the serving history window."""
312376
# The responder refuses range requests below the sliding window floor.

tests/node/storage/test_sqlite.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,27 @@ def test_slot_index_reorg(self, db: SQLiteDatabase) -> None:
169169
db.put_block_root_by_slot(slot, root_b)
170170
assert db.get_block_root_by_slot(slot) == root_b
171171

172+
def test_delete_block_root_by_slot(self, db: SQLiteDatabase) -> None:
173+
"""Deleting a slot entry removes it while other slots survive."""
174+
root_a = Bytes32(b"\x0b" * 32)
175+
root_b = Bytes32(b"\x0c" * 32)
176+
with db.batch_write():
177+
db.put_block_root_by_slot(Slot(1), root_a)
178+
db.put_block_root_by_slot(Slot(2), root_b)
179+
180+
with db.batch_write():
181+
db.delete_block_root_by_slot(Slot(1))
182+
183+
assert db.get_block_root_by_slot(Slot(1)) is None
184+
assert db.get_block_root_by_slot(Slot(2)) == root_b
185+
186+
def test_delete_nonexistent_slot_is_noop(self, db: SQLiteDatabase) -> None:
187+
"""Deleting an absent slot entry succeeds without effect."""
188+
with db.batch_write():
189+
db.delete_block_root_by_slot(Slot(999))
190+
191+
assert db.get_block_root_by_slot(Slot(999)) is None
192+
172193

173194
class TestStateRootIndex:
174195
"""Tests for state root to block root index."""

tests/node/sync/test_service.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,8 @@ def test_persist_skips_state_when_post_state_missing(
568568
) -> None:
569569
"""No put_state when the store has no post-state for the block root."""
570570
db = RecordingSyncDatabase()
571+
# The persisted head matches the mock genesis, as after a real genesis write.
572+
db.head_root = Bytes32.zero()
571573
service = create_mock_sync_service(
572574
peer_id,
573575
database=cast(Database, db),
@@ -612,6 +614,8 @@ def test_persist_writes_state_and_prunes_when_finalized_advanced(
612614
) -> None:
613615
"""Post-state indexing and pruning run when finalization is past genesis."""
614616
db = RecordingSyncDatabase()
617+
# The persisted head matches the mock genesis, as after a real genesis write.
618+
db.head_root = Bytes32.zero()
615619
service = create_mock_sync_service(
616620
peer_id,
617621
database=cast(Database, db),
@@ -667,6 +671,99 @@ def test_persist_writes_state_and_prunes_when_finalized_advanced(
667671
),
668672
]
669673

674+
def test_persist_skips_slot_index_for_side_branch_block(
675+
self,
676+
peer_id: PeerId,
677+
) -> None:
678+
"""A block that does not move the head leaves the slot index untouched."""
679+
db = RecordingSyncDatabase()
680+
db.head_root = Bytes32.zero()
681+
service = create_mock_sync_service(
682+
peer_id,
683+
database=cast(Database, db),
684+
)
685+
mock_store = cast(MockForkchoiceStore, service.store)
686+
mock_store.advance_head_on_block = False
687+
service.state = SyncState.SYNCING
688+
genesis_root = service.store.head
689+
block = make_signed_block(
690+
slot=Slot(1),
691+
proposer_index=ValidatorIndex(0),
692+
parent_root=genesis_root,
693+
state_root=Bytes32.zero(),
694+
)
695+
service.store = service.process_block(service.store, block)
696+
697+
inner = db.calls_inside_batch()
698+
call_names = [call.name for call in inner]
699+
assert "put_block_root_by_slot" not in call_names
700+
assert "delete_block_root_by_slot" not in call_names
701+
# The unchanged head pointer is still persisted with the block.
702+
empty: MappingProxyType[str, object] = MappingProxyType({})
703+
assert RecordedCall(name="put_head_root", args=(Bytes32.zero(),), kwargs=empty) in inner
704+
705+
def test_persist_reindexes_slot_index_on_reorg(
706+
self,
707+
peer_id: PeerId,
708+
) -> None:
709+
"""A head switch rewrites differing slots and deletes vacated ones."""
710+
db = RecordingSyncDatabase()
711+
genesis_root = Bytes32.zero()
712+
713+
# Old branch genesis <- B1 (slot 1) <- B2 (slot 2) is the persisted canonical chain.
714+
b1 = make_signed_block(
715+
slot=Slot(1),
716+
proposer_index=ValidatorIndex(0),
717+
parent_root=genesis_root,
718+
state_root=Bytes32.zero(),
719+
)
720+
b1_root = hash_tree_root(b1.block)
721+
b2 = make_signed_block(
722+
slot=Slot(2),
723+
proposer_index=ValidatorIndex(0),
724+
parent_root=b1_root,
725+
state_root=Bytes32.zero(),
726+
)
727+
b2_root = hash_tree_root(b2.block)
728+
# Seed before service creation: the tracker reads the head at wiring time.
729+
db.head_root = b2_root
730+
731+
service = create_mock_sync_service(
732+
peer_id,
733+
database=cast(Database, db),
734+
)
735+
mock_store = cast(MockForkchoiceStore, service.store)
736+
service.state = SyncState.SYNCING
737+
mock_store.blocks[b1_root] = b1.block
738+
mock_store.blocks[b2_root] = b2.block
739+
mock_store.head = b2_root
740+
741+
# Importing C2 (slot 2, child of genesis) reorgs the head onto the new branch.
742+
c2 = make_signed_block(
743+
slot=Slot(2),
744+
proposer_index=ValidatorIndex(1),
745+
parent_root=genesis_root,
746+
state_root=Bytes32.zero(),
747+
)
748+
service.store = service.process_block(service.store, c2)
749+
c2_root = hash_tree_root(c2.block)
750+
751+
inner = db.calls_inside_batch()
752+
empty: MappingProxyType[str, object] = MappingProxyType({})
753+
# Slot 2 is rewritten to the new branch; slot 1 has no canonical block anymore.
754+
assert (
755+
RecordedCall(name="put_block_root_by_slot", args=(Slot(2), c2_root), kwargs=empty)
756+
in inner
757+
)
758+
assert (
759+
RecordedCall(name="delete_block_root_by_slot", args=(Slot(1),), kwargs=empty) in inner
760+
)
761+
# The refilled slot is never deleted.
762+
assert (
763+
RecordedCall(name="delete_block_root_by_slot", args=(Slot(2),), kwargs=empty)
764+
not in inner
765+
)
766+
670767

671768
class TestSignedBlockServing:
672769
"""Tests for signed-block retention and the inbound serving lookups."""

0 commit comments

Comments
 (0)