Skip to content

Commit 6c0b2cb

Browse files
committed
Implement gap tracking for blocks
1 parent f98fad5 commit 6c0b2cb

File tree

4 files changed

+226
-0
lines changed

4 files changed

+226
-0
lines changed

eth/db/chain.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535
EMPTY_UNCLE_HASH,
3636
GENESIS_PARENT_HASH,
3737
)
38+
from eth.db.chain_gaps import (
39+
fill_gap,
40+
GapChange,
41+
GapInfo,
42+
GENESIS_CHAIN_GAPS,
43+
is_block_number_in_gap,
44+
reopen_gap,
45+
)
3846
from eth.db.trie import make_trie_root_and_nodes
3947
from eth.exceptions import (
4048
HeaderNotFound,
@@ -49,6 +57,8 @@
4957
from eth.rlp.receipts import (
5058
Receipt
5159
)
60+
from eth.rlp.sedes import chain_gaps
61+
from eth.typing import ChainGaps
5262
from eth.validation import (
5363
validate_word,
5464
)
@@ -75,6 +85,73 @@ class ChainDB(HeaderDB, ChainDatabaseAPI):
7585
def __init__(self, db: AtomicDatabaseAPI) -> None:
7686
self.db = db
7787

88+
def get_chain_gaps(self) -> ChainGaps:
89+
return self._get_chain_gaps(self.db)
90+
91+
@classmethod
92+
def _get_chain_gaps(cls, db: DatabaseAPI) -> ChainGaps:
93+
try:
94+
encoded_gaps = db[SchemaV1.make_chain_gaps_lookup_key()]
95+
except KeyError:
96+
return GENESIS_CHAIN_GAPS
97+
else:
98+
return rlp.decode(encoded_gaps, sedes=chain_gaps)
99+
100+
@classmethod
101+
def _update_chain_gaps(
102+
cls,
103+
db: DatabaseAPI,
104+
persisted_block: BlockAPI,
105+
base_gaps: ChainGaps = None
106+
) -> GapInfo:
107+
108+
# If we make many updates in a row, we can avoid reloading the integrity info by
109+
# continuously caching it and providing it as a parameter to this API
110+
if base_gaps is None:
111+
base_gaps = cls._get_chain_gaps(db)
112+
113+
gap_change, gaps = fill_gap(persisted_block.number, base_gaps)
114+
if gap_change is not GapChange.NoChange:
115+
db.set(
116+
SchemaV1.make_chain_gaps_lookup_key(),
117+
rlp.encode(gaps, sedes=chain_gaps)
118+
)
119+
120+
return gap_change, gaps
121+
122+
@classmethod
123+
def _update_header_chain_gaps(
124+
cls,
125+
db: DatabaseAPI,
126+
persisting_header: BlockHeaderAPI,
127+
base_gaps: ChainGaps = None
128+
) -> GapInfo:
129+
# The only reason we overwrite this here is to be able to detect when the HeaderDB
130+
# de-canonicalizes an uncle that should cause us to re-open a block gap.
131+
gap_change, gaps = super()._update_header_chain_gaps(db, persisting_header, base_gaps)
132+
133+
if gap_change is not GapChange.NoChange or persisting_header.block_number == 0:
134+
return gap_change, gaps
135+
136+
# We have written a header for which block number we've already had a header.
137+
# This might be a sign of a de-canonicalized uncle.
138+
current_gaps = cls._get_chain_gaps(db)
139+
if not is_block_number_in_gap(persisting_header.block_number, current_gaps):
140+
# ChainDB believes we have that block. If the header has changed, we need to re-open
141+
# a gap for the corresponding block.
142+
old_canonical_header = cls._get_canonical_block_header_by_number(
143+
db,
144+
persisting_header.block_number
145+
)
146+
if old_canonical_header != persisting_header:
147+
updated_gaps = reopen_gap(persisting_header.block_number, current_gaps)
148+
db.set(
149+
SchemaV1.make_chain_gaps_lookup_key(),
150+
rlp.encode(updated_gaps, sedes=chain_gaps)
151+
)
152+
153+
return gap_change, gaps
154+
78155
#
79156
# Header API
80157
#
@@ -196,6 +273,7 @@ def _persist_block(
196273
old_canonical_hashes = tuple(
197274
header.hash for header in old_canonical_headers)
198275

276+
cls._update_chain_gaps(db, block)
199277
return new_canonical_hashes, old_canonical_hashes
200278

201279
def persist_uncles(self, uncles: Tuple[BlockHeaderAPI]) -> Hash32:

eth/db/schema.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ def make_block_hash_to_score_lookup_key(block_hash: Hash32) -> bytes:
2424
def make_header_chain_gaps_lookup_key() -> bytes:
2525
return b'v1:header_chain_gaps'
2626

27+
@staticmethod
28+
def make_chain_gaps_lookup_key() -> bytes:
29+
return b'v1:chain_gaps'
30+
2731
@staticmethod
2832
def make_checkpoint_headers_key() -> bytes:
2933
"""

newsfragments/1947.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Expose ``get_chain_gaps()`` on ``ChainDB`` to track gaps in the chain of blocks.

tests/database/test_eth1_chaindb.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from eth.constants import (
1313
BLANK_ROOT_HASH,
14+
ZERO_ADDRESS,
1415
)
1516
from eth.chains.base import (
1617
MiningChain,
@@ -19,16 +20,19 @@
1920
from eth.db.chain import (
2021
ChainDB,
2122
)
23+
from eth.db.chain_gaps import GENESIS_CHAIN_GAPS
2224
from eth.db.schema import SchemaV1
2325
from eth.exceptions import (
2426
BlockNotFound,
2527
HeaderNotFound,
2628
ParentNotFound,
2729
ReceiptNotFound,
30+
CheckpointsMustBeCanonical,
2831
)
2932
from eth.rlp.headers import (
3033
BlockHeader,
3134
)
35+
from eth.tools.builder.chain import api
3236
from eth.tools.rlp import (
3337
assert_headers_eq,
3438
)
@@ -87,10 +91,149 @@ def chain(chain_without_block_validation):
8791
def test_chaindb_add_block_number_to_hash_lookup(chaindb, block):
8892
block_number_to_hash_key = SchemaV1.make_block_number_to_hash_lookup_key(block.number)
8993
assert not chaindb.exists(block_number_to_hash_key)
94+
assert chaindb.get_chain_gaps() == GENESIS_CHAIN_GAPS
9095
chaindb.persist_block(block)
9196
assert chaindb.exists(block_number_to_hash_key)
9297

9398

99+
@pytest.mark.parametrize(
100+
'has_uncle, has_transaction, can_fetch_block',
101+
(
102+
# Uncle block gets de-canonicalized by a header that has another uncle
103+
(True, False, False,),
104+
# Uncle block gets de-canonicalized by a header that has transactions
105+
(False, True, False,),
106+
# Has uncle and transactions
107+
(True, True, False,),
108+
# Uncle block gets de-canonicalized by a header that has no uncles nor transactions which
109+
# means this is a "Header-only" block. Even though we technically do not need to re-open
110+
# a gap here, we don't have a good way of detecting that special case and hence open a gap.
111+
(False, False, True,),
112+
)
113+
)
114+
def test_block_gap_tracking(chain,
115+
funded_address,
116+
funded_address_private_key,
117+
has_uncle,
118+
has_transaction,
119+
can_fetch_block):
120+
121+
# Mine three common blocks
122+
common_chain = api.build(
123+
chain,
124+
api.mine_blocks(3),
125+
)
126+
127+
assert common_chain.get_canonical_head().block_number == 3
128+
assert common_chain.chaindb.get_chain_gaps() == ((), 4)
129+
130+
tx = new_transaction(
131+
common_chain.get_vm(),
132+
from_=funded_address,
133+
to=ZERO_ADDRESS,
134+
private_key=funded_address_private_key,
135+
)
136+
uncle = api.build(common_chain, api.mine_block()).get_canonical_block_header_by_number(4)
137+
uncles = [uncle] if has_uncle else []
138+
transactions = [tx] if has_transaction else []
139+
140+
# Split and have the main chain mine four blocks, the uncle chain two blocks
141+
main_chain, uncle_chain = api.build(
142+
common_chain,
143+
api.chain_split(
144+
(
145+
# We have four different scenarios for our replaced blocks:
146+
# 1. Replaced by a trivial block without uncles or transactions
147+
# 2. Replaced by a block with transactions
148+
# 3. Replaced by a block with uncles
149+
# 4. 2 and 3 combined
150+
api.mine_block(uncles=uncles, transactions=transactions),
151+
api.mine_block(),
152+
api.mine_block(),
153+
api.mine_block(),
154+
),
155+
# This will be the uncle chain
156+
(api.mine_block(extra_data=b'fork-it'), api.mine_block(),),
157+
),
158+
)
159+
160+
main_head = main_chain.get_canonical_head()
161+
assert main_head.block_number == 7
162+
assert uncle_chain.get_canonical_head().block_number == 5
163+
164+
assert main_chain.chaindb.get_chain_gaps() == ((), 8)
165+
assert uncle_chain.chaindb.get_chain_gaps() == ((), 6)
166+
167+
main_header_6 = main_chain.chaindb.get_canonical_block_header_by_number(6)
168+
main_header_6_score = main_chain.chaindb.get_score(main_header_6.hash)
169+
170+
gap_chain = api.copy(common_chain)
171+
assert gap_chain.get_canonical_head() == common_chain.get_canonical_head()
172+
173+
gap_chain.chaindb.persist_checkpoint_header(main_header_6, main_header_6_score)
174+
# We created a gap in the chain of headers
175+
assert gap_chain.chaindb.get_header_chain_gaps() == (((4, 5),), 7)
176+
# ...but not in the chain of blocks (yet!)
177+
assert gap_chain.chaindb.get_chain_gaps() == ((), 4)
178+
block_7 = main_chain.get_canonical_block_by_number(7)
179+
block_7_receipts = block_7.get_receipts(main_chain.chaindb)
180+
# Persist block 7 on top of the checkpoint
181+
gap_chain.chaindb.persist_unexecuted_block(block_7, block_7_receipts)
182+
assert gap_chain.chaindb.get_header_chain_gaps() == (((4, 5),), 8)
183+
# Now we have a gap in the chain of blocks, too
184+
assert gap_chain.chaindb.get_chain_gaps() == (((4, 6),), 8)
185+
186+
# Overwriting header 3 doesn't cause us to re-open a block gap
187+
gap_chain.chaindb.persist_header_chain([
188+
main_chain.chaindb.get_canonical_block_header_by_number(3)
189+
])
190+
assert gap_chain.chaindb.get_chain_gaps() == (((4, 6),), 8)
191+
192+
# Now get the uncle block
193+
uncle_block = uncle_chain.get_canonical_block_by_number(4)
194+
uncle_block_receipts = uncle_block.get_receipts(uncle_chain.chaindb)
195+
196+
# Put the uncle block in the gap
197+
gap_chain.chaindb.persist_unexecuted_block(uncle_block, uncle_block_receipts)
198+
assert gap_chain.chaindb.get_header_chain_gaps() == (((5, 5),), 8)
199+
assert gap_chain.chaindb.get_chain_gaps() == (((5, 6),), 8)
200+
201+
# Trying to save another uncle errors as its header isn't the parent of the checkpoint
202+
second_uncle = uncle_chain.get_canonical_block_by_number(5)
203+
second_uncle_receipts = second_uncle.get_receipts(uncle_chain.chaindb)
204+
with pytest.raises(CheckpointsMustBeCanonical):
205+
gap_chain.chaindb.persist_unexecuted_block(second_uncle, second_uncle_receipts)
206+
207+
# Now close the gap in the header chain with the actual correct headers
208+
actual_headers = [
209+
main_chain.chaindb.get_canonical_block_header_by_number(block_number)
210+
for block_number in range(4, 7)
211+
]
212+
gap_chain.chaindb.persist_header_chain(actual_headers)
213+
# No more gaps in the header chain
214+
assert gap_chain.chaindb.get_header_chain_gaps() == ((), 8)
215+
# We detected the de-canonicalized uncle and re-opened the block gap
216+
assert gap_chain.chaindb.get_chain_gaps() == (((4, 6),), 8)
217+
218+
if can_fetch_block:
219+
# We can fetch the block even if the gap tracking reports it as missing if the block is
220+
# a "trivial" block, meaning one that doesn't have transactions nor uncles and hence
221+
# can be loaded by just the header alone.
222+
block_4 = gap_chain.get_canonical_block_by_number(4)
223+
assert block_4 == main_chain.get_canonical_block_by_number(4)
224+
else:
225+
# The uncle block was implicitly de-canonicalized with its header,
226+
# hence we can not fetch it any longer.
227+
with pytest.raises(BlockNotFound):
228+
gap_chain.get_canonical_block_by_number(4)
229+
# Add the missing block and assert the gap shrinks
230+
assert gap_chain.chaindb.get_chain_gaps() == (((4, 6),), 8)
231+
block_4 = main_chain.get_canonical_block_by_number(4)
232+
block_4_receipts = block_4.get_receipts(main_chain.chaindb)
233+
gap_chain.chaindb.persist_unexecuted_block(block_4, block_4_receipts)
234+
assert gap_chain.chaindb.get_chain_gaps() == (((5, 6),), 8)
235+
236+
94237
def test_chaindb_persist_header(chaindb, header):
95238
with pytest.raises(HeaderNotFound):
96239
chaindb.get_block_header_by_hash(header.hash)

0 commit comments

Comments
 (0)