Skip to content
This repository was archived by the owner on Sep 8, 2025. It is now read-only.

Commit 4487819

Browse files
committed
Can re-open gaps, and note right-side gap shrink
Added hypothesis test to make sure gap invariants hold (non-overlapping, and consecutive)
1 parent 73cc2fb commit 4487819

File tree

3 files changed

+213
-19
lines changed

3 files changed

+213
-19
lines changed

eth/db/chain_gaps.py

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import enum
2-
from typing import Tuple
2+
from typing import Iterable, Tuple
33

44
from eth_typing import BlockNumber
5+
from eth_utils import (
6+
ValidationError,
7+
to_tuple,
8+
)
59

610
from eth.exceptions import GapTrackingCorrupted
711
from eth.typing import BlockRange, ChainGaps
@@ -12,17 +16,87 @@ class GapChange(enum.Enum):
1216
NewGap = enum.auto()
1317
GapFill = enum.auto()
1418
GapSplit = enum.auto()
15-
GapShrink = enum.auto()
19+
GapLeftShrink = enum.auto()
20+
GapRightShrink = enum.auto()
1621
TailWrite = enum.auto()
1722

1823

19-
GAP_WRITES = (GapChange.GapFill, GapChange.GapSplit, GapChange.GapShrink)
24+
GAP_WRITES = (
25+
GapChange.GapFill,
26+
GapChange.GapSplit,
27+
GapChange.GapLeftShrink,
28+
GapChange.GapRightShrink,
29+
)
2030
GENESIS_CHAIN_GAPS = ((), BlockNumber(1))
2131

2232
GapInfo = Tuple[GapChange, ChainGaps]
2333

2434

25-
def calculate_gaps(newly_persisted: BlockNumber, base_gaps: ChainGaps) -> GapInfo:
35+
@to_tuple
36+
def _join_overlapping_gaps(unjoined_gaps: Tuple[BlockRange, ...]) -> Iterable[BlockRange]:
37+
"""
38+
After introducing a new gap, join any that overlap.
39+
Input must already be sorted.
40+
"""
41+
unyielded_low = None
42+
unyielded_high = None
43+
for low, high in unjoined_gaps:
44+
if unyielded_high is not None:
45+
if low < unyielded_low:
46+
raise ValidationError(f"Unsorted input! {unjoined_gaps!r}")
47+
elif unyielded_low <= low <= unyielded_high + 1:
48+
unyielded_high = max(high, unyielded_high)
49+
continue
50+
else:
51+
yield unyielded_low, unyielded_high
52+
53+
unyielded_low = low
54+
unyielded_high = high
55+
56+
if unyielded_high is not None:
57+
yield unyielded_low, unyielded_high
58+
59+
60+
def reopen_gap(decanonicalized: BlockNumber, base_gaps: ChainGaps) -> ChainGaps:
61+
"""
62+
Add a new gap, for a header that was decanonicalized.
63+
"""
64+
current_gaps, tip_child = base_gaps
65+
66+
if tip_child <= decanonicalized:
67+
return base_gaps
68+
69+
new_raw_gaps = current_gaps + ((decanonicalized, decanonicalized), )
70+
71+
# join overlapping gaps
72+
joined_gaps = _join_overlapping_gaps(sorted(new_raw_gaps))
73+
74+
# is the last gap overlapping with the tip child? if so, merge it
75+
if joined_gaps[-1][1] + 1 >= tip_child:
76+
return joined_gaps[:-1], joined_gaps[-1][0]
77+
else:
78+
return joined_gaps, tip_child
79+
80+
81+
def is_block_number_in_gap(block_number: BlockNumber, gaps: ChainGaps) -> bool:
82+
"""
83+
Check if a block number is found in the given gaps
84+
"""
85+
gap_ranges, tip_child = gaps
86+
for low, high in gap_ranges:
87+
if low > block_number:
88+
return False
89+
elif high >= block_number:
90+
return True
91+
# this range was below the block number, continue looking at the next range
92+
93+
return block_number >= tip_child
94+
95+
96+
def fill_gap(newly_persisted: BlockNumber, base_gaps: ChainGaps) -> GapInfo:
97+
"""
98+
Remove a gap, for a new header that was canonicalized.
99+
"""
26100

27101
current_gaps, tip_child = base_gaps
28102

@@ -45,11 +119,12 @@ def calculate_gaps(newly_persisted: BlockNumber, base_gaps: ChainGaps) -> GapInf
45119
]
46120

47121
if len(matching_gaps) > 1:
122+
first_match, second_match, *_ = matching_gaps
48123
raise GapTrackingCorrupted(
49124
"Corrupted chain gap tracking",
50125
f"No. {newly_persisted} appears to be missing in multiple gaps",
51-
f"1st gap goes from {matching_gaps[0][1][0]} to {matching_gaps[0][1][1]}"
52-
f"2nd gap goes from {matching_gaps[1][1][0]} to {matching_gaps[1][1][1]}"
126+
f"1st gap is {first_match[1]}, 2nd gap is {second_match[1]}",
127+
f"all matching gaps: {matching_gaps}",
53128
)
54129
elif len(matching_gaps) == 0:
55130
# Looks like we are just overwriting an existing header.
@@ -62,11 +137,11 @@ def calculate_gaps(newly_persisted: BlockNumber, base_gaps: ChainGaps) -> GapInf
62137
elif newly_persisted == gap[0]:
63138
# we are shrinking the gap at the start
64139
updated_center = ((BlockNumber(gap[0] + 1), gap[1],),)
65-
gap_change = GapChange.GapShrink
140+
gap_change = GapChange.GapLeftShrink
66141
elif newly_persisted == gap[1]:
67142
# we are shrinking the gap at the tail
68143
updated_center = ((gap[0], BlockNumber(gap[1] - 1),),)
69-
gap_change = GapChange.GapShrink
144+
gap_change = GapChange.GapRightShrink
70145
elif gap[0] < newly_persisted < gap[1]:
71146
# we are dividing the gap
72147
first_new_gap = (gap[0], BlockNumber(newly_persisted - 1))

eth/db/header.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@
3030
GENESIS_PARENT_HASH,
3131
)
3232
from eth.db.chain_gaps import (
33-
calculate_gaps,
3433
GapChange,
3534
GapInfo,
3635
GAP_WRITES,
3736
GENESIS_CHAIN_GAPS,
37+
fill_gap,
3838
)
3939
from eth.exceptions import (
4040
CanonicalHeadNotFound,
@@ -79,7 +79,7 @@ def _update_header_chain_gaps(
7979
if base_gaps is None:
8080
base_gaps = cls._get_header_chain_gaps(db)
8181

82-
gap_change, gaps = calculate_gaps(persisted_header.block_number, base_gaps)
82+
gap_change, gaps = fill_gap(persisted_header.block_number, base_gaps)
8383

8484
if gap_change is not GapChange.NoChange:
8585
db.set(
@@ -297,7 +297,8 @@ def _handle_gap_change(cls,
297297
if gap_change not in GAP_WRITES:
298298
return
299299

300-
if gap_change is GapChange.GapFill:
300+
# Check if this change will link up the chain to the right
301+
if gap_change in (GapChange.GapFill, GapChange.GapRightShrink):
301302
next_child_number = BlockNumber(header.block_number + 1)
302303
expected_child = cls._get_canonical_block_header_by_number(db, next_child_number)
303304
if header.hash != expected_child.parent_hash:

tests/database/test_header_db.py

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import enum
2+
from functools import partial
23
import operator
34
import random
45

6+
from hypothesis import (
7+
example,
8+
given,
9+
strategies as st,
10+
)
511
import pytest
612

7-
from eth_utils.toolz import accumulate
13+
from eth_utils.toolz import (
14+
accumulate,
15+
compose,
16+
sliding_window,
17+
)
818

919
from eth_utils import (
20+
to_set,
1021
to_tuple,
1122
keccak,
1223
ValidationError,
@@ -17,9 +28,16 @@
1728
GENESIS_DIFFICULTY,
1829
GENESIS_GAS_LIMIT,
1930
)
20-
from eth.db.chain_gaps import GapChange, GENESIS_CHAIN_GAPS
31+
from eth.db.chain_gaps import (
32+
GapChange,
33+
GENESIS_CHAIN_GAPS,
34+
fill_gap,
35+
reopen_gap,
36+
is_block_number_in_gap,
37+
)
2138
from eth.exceptions import (
2239
CanonicalHeadNotFound,
40+
GapTrackingCorrupted,
2341
HeaderNotFound,
2442
ParentNotFound,
2543
)
@@ -37,7 +55,7 @@ def headerdb(base_db):
3755
return HeaderDB(base_db)
3856

3957

40-
@pytest.fixture
58+
@pytest.fixture(scope="module")
4159
def genesis_header():
4260
return BlockHeader(
4361
difficulty=GENESIS_DIFFICULTY,
@@ -123,6 +141,13 @@ def test_headerdb_persist_disconnected_headers(headerdb, genesis_header):
123141
headerdb.get_block_header_by_hash(headers[2].hash)
124142

125143

144+
def test_corrupt_gaps():
145+
with pytest.raises(GapTrackingCorrupted, match="(1, 3)"):
146+
fill_gap(2, (((1, 3), (2, 4)), 6))
147+
with pytest.raises(GapTrackingCorrupted, match="(2, 4)"):
148+
fill_gap(2, (((1, 3), (2, 4)), 6))
149+
150+
126151
class StepAction(enum.Enum):
127152
PERSIST_CHECKPOINT = enum.auto()
128153
PERSIST_HEADERS = enum.auto()
@@ -132,6 +157,30 @@ class StepAction(enum.Enum):
132157
VERIFY_PERSIST_RAISES = enum.auto()
133158

134159

160+
def _all_gap_numbers(chain_gaps, highest_block_number):
161+
"""List all the missing headers, the block numbers in gaps"""
162+
gap_ranges, tail = chain_gaps
163+
for low, high in gap_ranges:
164+
yield from range(low, high + 1)
165+
yield from range(tail, highest_block_number + 1)
166+
167+
168+
def _validate_gap_invariants(gaps):
169+
# 1. gaps are sorted
170+
for low, high in gaps[0]:
171+
assert high >= low, gaps
172+
173+
# 2. gaps are not overrlapping
174+
for low_range, high_range in sliding_window(2, gaps[0]):
175+
# the top of the low range must not be sequential with the bottom of the high range
176+
assert low_range[1] + 1 < high_range[0], gaps
177+
178+
# 3. final gap does not overlap with the tail
179+
if len(gaps[0]):
180+
final_gap_range = gaps[0][-1]
181+
assert final_gap_range[1] + 1 < gaps[1], gaps
182+
183+
135184
@pytest.mark.parametrize(
136185
'steps',
137186
(
@@ -296,12 +345,12 @@ def _get_chain(id):
296345
(GapChange.TailWrite, ((), 2)),
297346
(GapChange.NewGap, (((2, 9),), 11)),
298347
(GapChange.GapSplit, (((2, 4), (6, 9),), 11)),
299-
(GapChange.GapShrink, (((3, 4), (6, 9),), 11)),
300-
(GapChange.GapShrink, (((3, 3), (6, 9),), 11)),
348+
(GapChange.GapLeftShrink, (((3, 4), (6, 9),), 11)),
349+
(GapChange.GapRightShrink, (((3, 3), (6, 9),), 11)),
301350
(GapChange.GapFill, (((6, 9),), 11)),
302-
(GapChange.GapShrink, (((6, 8),), 11)),
303-
(GapChange.GapShrink, (((6, 7),), 11)),
304-
(GapChange.GapShrink, (((6, 6),), 11)),
351+
(GapChange.GapRightShrink, (((6, 8),), 11)),
352+
(GapChange.GapRightShrink, (((6, 7),), 11)),
353+
(GapChange.GapRightShrink, (((6, 6),), 11)),
305354
(GapChange.GapFill, ((), 11)),
306355
)
307356
),
@@ -325,6 +374,75 @@ def test_gap_tracking(headerdb, genesis_header, written_headers, evolving_gaps):
325374
assert change == evolving_gaps[idx][0]
326375

327376

377+
@given(st.lists(
378+
st.tuples(
379+
# True to insert a header (ie~ remove a gap), False to remove a header (ie~ add a gap)
380+
st.booleans(),
381+
st.integers(min_value=1, max_value=19), # constrain to try to cause collisions
382+
)
383+
))
384+
@example([(True, 2), (True, 4), (False, 4)])
385+
def test_gap_continuity(changes):
386+
MAX_BLOCK_NUM = 21
387+
388+
# method to get all the block numbers that are in a gap right now
389+
_all_missing = compose(set, partial(_all_gap_numbers, highest_block_number=MAX_BLOCK_NUM))
390+
391+
@to_set
392+
def _all_inserted(chain_gaps):
393+
"""List all the inserted headers, the block numbers not in gaps"""
394+
missing = _all_missing(chain_gaps)
395+
for block_num in range(MAX_BLOCK_NUM + 1):
396+
if block_num not in missing:
397+
yield block_num
398+
399+
chain_gaps = GENESIS_CHAIN_GAPS
400+
401+
for do_insert, block_num in changes:
402+
starts_inserted = _all_inserted(chain_gaps)
403+
starts_missing = _all_missing(chain_gaps)
404+
405+
if do_insert:
406+
to_insert = block_num
407+
_, chain_gaps = fill_gap(to_insert, chain_gaps)
408+
assert not is_block_number_in_gap(to_insert, chain_gaps)
409+
410+
# Make sure that at most this one block number was filled
411+
finished_inserted = _all_inserted(chain_gaps)
412+
assert to_insert in finished_inserted
413+
new_inserts = finished_inserted - starts_inserted
414+
if block_num in starts_inserted:
415+
assert new_inserts == set()
416+
else:
417+
assert new_inserts == {block_num}
418+
419+
# Make sure that no new gaps were created
420+
finished_missing = _all_missing(chain_gaps)
421+
assert to_insert not in finished_missing
422+
new_missing = finished_missing - starts_missing
423+
assert new_missing == set()
424+
else:
425+
to_remove = block_num
426+
# Note that removing a header is inserting a gap
427+
chain_gaps = reopen_gap(to_remove, chain_gaps)
428+
assert is_block_number_in_gap(to_remove, chain_gaps)
429+
430+
# Make sure that no gaps were filled
431+
finished_inserted = _all_inserted(chain_gaps)
432+
new_inserts = finished_inserted - starts_inserted
433+
assert new_inserts == set()
434+
435+
# Make sure that at most this one block number gap was reopened
436+
finished_missing = _all_missing(chain_gaps)
437+
new_missing = finished_missing - starts_missing
438+
if block_num in starts_missing:
439+
assert new_missing == set()
440+
else:
441+
assert new_missing == {block_num}
442+
443+
_validate_gap_invariants(chain_gaps)
444+
445+
328446
@pytest.mark.parametrize(
329447
'chain_length',
330448
(0, 1, 2, 3),

0 commit comments

Comments
 (0)