Skip to content

Commit 8764f2a

Browse files
Toby1009claude
andcommitted
Several endpoints per chain, and a scan that widens instead of failing
Still could not fetch. Three causes, in order of how much they mattered. **The server was running code from before the last fix.** Restarting it is not a code change and it wasted the most time; noting it because the pattern repeated twice in one session and a long-lived process silently serving stale code is a real hazard of this workflow. **One endpoint per chain was a single point of failure.** `CHAINSCOPE_RPC_<NAME>` took one URL, so a flaky free public node was the difference between the tool working and not working at all. Measured while chasing this: of eleven public BSC endpoints, one served `eth_getLogs` over 5,000 blocks, five capped smaller, two refused the method, two returned 401/403, one was gone --- and the one that worked timed out under load an hour later, taking the whole capability with it. Comma-separated now, each becoming its own provider in preference order. The router already fell over between providers and the breaker already skipped an unwell host; neither could help while there was only ever one. **And the scan's own strategy was backwards.** It started at a wide span and narrowed on failure, which costs a failed request per step --- and a timeout is indistinguishable from an unwell host, so four concurrent chunks each halving once opened the circuit breaker on an endpoint that was answering fine. It starts narrow and widens on success now, which costs nothing when the endpoint declines to widen. Two bugs of mine on the way, both caught by writing the test: A chunk handed a 40,000-block range fetched one span-wide request from the start of it and returned, **silently dropping the other 35,000**. Nothing downstream could have noticed: fewer logs is exactly what an address with less activity looks like. And widening undid narrowing --- succeed at 1,250, double to 2,500, get refused, halve back, for ever, wasting a failed request every other round. A refusal is information about the endpoint's limit and is now remembered as a cap rather than merely reacted to. I had also cut the default window to 120,000 while chasing timeouts, which returned three address-poisoning dust transfers and none of the case --- the address's history sits 300,000 blocks back. Restored to 400,000 now that the span adapts. 85 transfers, complete=False, 110s The full case, and still honest that 400,000 blocks is not the whole chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXWQ25VgFeMuuMhGWjvdof
1 parent f0d209a commit 8764f2a

2 files changed

Lines changed: 252 additions & 38 deletions

File tree

src/chainscope/providers/jsonrpc.py

Lines changed: 118 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -68,23 +68,37 @@
6868
_SPAN_START = 5_000
6969
_SPAN_FLOOR = 500
7070

71+
#: How wide a chunk may grow once the endpoint has proved it can take it.
72+
#:
73+
#: Starting narrow and widening on *success* costs nothing when it fails to
74+
#: widen, where starting wide and narrowing on failure costs a failed request
75+
#: per step --- and a failed request that is a timeout is indistinguishable
76+
#: from an unwell host, which is what opened the circuit breaker mid-fetch.
77+
#: So the first chunk is cheap and cautious and the rest ride on what it
78+
#: learned.
79+
_SPAN_CEILING = 40_000
80+
7181
#: How far back a scan reaches when the caller does not say.
7282
#:
7383
#: Not genesis. A full-history scan is hundreds of thousands of requests, and
7484
#: issuing it because somebody left a default alone is not a thing this should
7585
#: do quietly. The window is stated in the truncation message whenever it does
7686
#: not reach the requested start, so a short answer is never mistaken for a
7787
#: complete one.
78-
#: Roughly a day and a half of BSC, and a few days of a slower chain.
88+
#: How far back a scan reaches when the caller does not say.
7989
#:
80-
#: Halved from 400,000 after measuring what it costs: at 5,000 blocks a
81-
#: request and four in flight, 400,000 blocks in both directions is 160 round
82-
#: trips and several minutes of somebody watching a spinner. The window is
83-
#: named in the `ResultTruncated` message whenever it does not reach the
84-
#: requested start, so a short answer still says how short --- which is the
85-
#: part that matters. Somebody who needs deeper history passes `start_block`
86-
#: and accepts the wait deliberately.
87-
_DEFAULT_WINDOW = 120_000
90+
#: Briefly 120,000, which was a mistake made while chasing timeouts: the
91+
#: address it was being tested against had its whole history 300,000 blocks
92+
#: back, so the shallower window returned three address-poisoning dust
93+
#: transfers and none of the case. It said so --- `capped`, with the range ---
94+
#: which is the difference between a wrong answer and a short one, but short
95+
#: was still useless. The span now widens on success instead, so the deeper
96+
#: window costs round trips rather than minutes.
97+
#:
98+
#: Still not genesis. A full-history scan is hundreds of thousands of
99+
#: requests, and the window is named in the `ResultTruncated` message whenever
100+
#: it does not reach the requested start.
101+
_DEFAULT_WINDOW = 400_000
88102

89103
#: Chunks in flight at once. Small on purpose --- see `_scan`.
90104
_SCAN_WORKERS = 4
@@ -144,10 +158,26 @@ def __init__(
144158
#: Token contract to (symbol, decimals). Per instance, so a scan asks
145159
#: each contract once however many transfers it produced.
146160
self._meta: dict[str, tuple[str, int]] = {}
161+
#: Block span this endpoint has proved it will serve. Starts cautious,
162+
#: doubles on success, halves on refusal. Shared across the chunks of a
163+
#: scan so the limit is discovered once rather than per chunk.
164+
self._span = _SPAN_START
165+
#: Widest span this endpoint has been *seen to refuse*, minus room.
166+
#:
167+
#: Without it, widening undoes narrowing: succeed at 1,250, double to
168+
#: 2,500, get refused, halve to 1,250, succeed, double again --- a
169+
#: wasted failed request every other round, for ever. A refusal is
170+
#: information about the endpoint's limit and has to be remembered as
171+
#: such, not just reacted to.
172+
self._span_cap = _SPAN_CEILING
147173

148174
@classmethod
149175
def from_settings(cls, settings: Any, chain: ChainId, client: Any = None) -> list[Provider]:
150-
"""The configured endpoint for this chain, if there is one.
176+
"""The configured endpoints for this chain, in preference order.
177+
178+
``CHAINSCOPE_RPC_<NAME>`` accepts a comma-separated list. One endpoint
179+
was the original design and it made a single flaky public node a single
180+
point of failure for a whole chain.
151181
152182
Endpoints are keyed by short name (``CHAINSCOPE_RPC_ETHEREUM``), so the
153183
lookup goes through the alias table rather than CAIP-2 --- a user types
@@ -171,17 +201,36 @@ def from_settings(cls, settings: Any, chain: ChainId, client: Any = None) -> lis
171201
return []
172202
names = [n for n, c in ALIASES.items() if c == chain]
173203
for name in sorted(names, key=len, reverse=True):
174-
url = settings.rpc.get(name)
175-
if url:
176-
return [
177-
cls(
178-
url,
179-
chain,
180-
client=client,
181-
native_symbol=native_symbol(chain, "ETH"),
182-
archive=settings.rpc_archive.get(name, False),
183-
)
184-
]
204+
configured = settings.rpc.get(name)
205+
if not configured:
206+
continue
207+
# Comma-separated, and each one becomes its own provider.
208+
#
209+
# A single endpoint per chain means one flaky free public node is
210+
# the difference between the tool working and not working at all.
211+
# Measured while chasing exactly that: of eleven public BSC
212+
# endpoints, one served `eth_getLogs` over 5,000 blocks, five
213+
# capped at a smaller range, two refused the method, two returned
214+
# 401/403 and one was gone --- and the one that worked timed out
215+
# under load an hour later, which took the whole capability down
216+
# because there was nothing behind it.
217+
#
218+
# The router already falls over between providers and the circuit
219+
# breaker already skips an unwell host. Neither could help while
220+
# there was only ever one. Order is preference order: the first is
221+
# tried first, and the rest exist for when it is not answering.
222+
urls = [u.strip() for u in str(configured).split(",") if u.strip()]
223+
archive = settings.rpc_archive.get(name, False)
224+
return [
225+
cls(
226+
url,
227+
chain,
228+
client=client,
229+
native_symbol=native_symbol(chain, "ETH"),
230+
archive=archive,
231+
)
232+
for url in urls
233+
]
185234
return []
186235

187236
@property
@@ -417,14 +466,21 @@ def topics_for(way: str) -> list[Any]:
417466
# The cap is on the total because that is what the endpoint sees.
418467
order = sorted(ways)
419468
with ThreadPoolExecutor(max_workers=_SCAN_WORKERS) as pool:
420-
plans = [
421-
(way, lo_, min(lo_ + _SPAN_START - 1, hi))
422-
for way in order
423-
for lo_ in range(lo, hi + 1, _SPAN_START)
424-
]
469+
# Chunked at the ceiling and let `_chunk` narrow to whatever the
470+
# endpoint will actually take. Planning at `_SPAN_START` instead
471+
# would fix the request count at the cautious width and never
472+
# benefit from an endpoint that turns out to be generous.
473+
plans = [(way, lo_) for way in order for lo_ in range(lo, hi + 1, _SPAN_CEILING)]
425474
jobs = [
426-
pool.submit(self._chunk, chain, lo_, hi_, topics_for(way), contract)
427-
for way, lo_, hi_ in plans
475+
pool.submit(
476+
self._chunk,
477+
chain,
478+
lo_,
479+
min(lo_ + _SPAN_CEILING - 1, hi),
480+
topics_for(way),
481+
contract,
482+
)
483+
for way, lo_ in plans
428484
]
429485
found = [job.result() for job in jobs]
430486

@@ -467,7 +523,13 @@ def _chunk(
467523
topics: list[Any],
468524
contract: str | None,
469525
) -> list[dict[str, Any]]:
470-
"""One block range, narrowing itself until the endpoint accepts it.
526+
"""One block range, covered in whatever width the endpoint will take.
527+
528+
**Covers the whole range it was given.** An earlier version fetched a
529+
single `span`-wide request and returned, silently dropping the rest of
530+
its assignment --- a gap in the middle of a scan that nothing
531+
downstream could have detected, since fewer logs is exactly what an
532+
address with less activity looks like.
471533
472534
Endpoints cap `eth_getLogs` by range, by result count, or by wall
473535
clock, and report each differently --- measured against four BSC nodes,
@@ -480,20 +542,38 @@ def _chunk(
480542
both directions: a pool here as well as there multiplied the
481543
concurrency and tripped the circuit breaker on a healthy node.
482544
"""
483-
span = hi - lo + 1
484-
while True:
545+
out: list[dict[str, Any]] = []
546+
at = lo
547+
while at <= hi:
548+
# Re-read each time round: another chunk running concurrently may
549+
# have already learned that this endpoint is more or less generous
550+
# than we thought.
551+
span = max(_SPAN_FLOOR, min(self._span, hi - at + 1))
485552
try:
486-
return self.get_logs(
487-
chain,
488-
address=contract,
489-
topics=topics,
490-
from_block=lo,
491-
to_block=min(lo + span - 1, hi),
553+
out.extend(
554+
self.get_logs(
555+
chain,
556+
address=contract,
557+
topics=topics,
558+
from_block=at,
559+
to_block=min(at + span - 1, hi),
560+
)
492561
)
493562
except ProviderError:
494563
if span <= _SPAN_FLOOR:
495564
raise
496-
span = max(_SPAN_FLOOR, span // 2)
565+
# Narrower for everyone from here on. Rediscovering the same
566+
# limit on every chunk is what made narrowing expensive.
567+
narrower = max(_SPAN_FLOOR, span // 2)
568+
self._span_cap = min(self._span_cap, narrower)
569+
self._span = narrower
570+
continue
571+
at += span
572+
# It worked, so try more next time. Doubling rather than jumping
573+
# to the ceiling, so an endpoint that is merely slow gets to refuse
574+
# once rather than being asked for eight times what it just served.
575+
self._span = min(self._span_cap, max(self._span, span * 2))
576+
return out
497577

498578
def _token_meta(self, token: str) -> tuple[str, int]:
499579
"""``(symbol, decimals)`` for a token contract, asked once each.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""A chunk must cover its whole assignment, and the span must adapt upward.
2+
3+
Two bugs, one after the other, both mine, both in the shape this package is
4+
supposed to catch.
5+
6+
`_chunk` was handed a 40,000-block range and fetched a single `span`-wide
7+
request from the start of it, then returned. The remaining 35,000 blocks were
8+
never read and nothing downstream could tell: fewer logs is exactly what an
9+
address with less activity looks like.
10+
11+
And the span started wide and narrowed on failure, which costs a failed request
12+
per step. A failed request that is a timeout is indistinguishable from an unwell
13+
host, so four concurrent chunks each halving once opened the circuit breaker on
14+
an endpoint that was answering. Starting narrow and widening on *success* costs
15+
nothing when it declines to widen.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from typing import Any
21+
22+
import pytest
23+
24+
from chainscope.core.chainid import ChainId
25+
from chainscope.providers.base import ProviderError
26+
from chainscope.providers.jsonrpc import (
27+
_SPAN_CEILING,
28+
_SPAN_FLOOR,
29+
_SPAN_START,
30+
JsonRpcProvider,
31+
)
32+
33+
CHAIN = ChainId.evm(56)
34+
35+
36+
class _Node:
37+
"""Records every range asked for, and optionally refuses wide ones."""
38+
39+
def __init__(self, max_span: int | None = None) -> None:
40+
self.ranges: list[tuple[int, int]] = []
41+
self.max_span = max_span
42+
43+
def rpc(self, url: str, method: str, params: Any = None, **_: Any) -> Any:
44+
if method != "eth_getLogs":
45+
return "0x0"
46+
lo = int(params[0]["fromBlock"], 16)
47+
hi = int(params[0]["toBlock"], 16)
48+
if self.max_span is not None and (hi - lo + 1) > self.max_span:
49+
raise ProviderError("query returned more than 10000 results")
50+
self.ranges.append((lo, hi))
51+
return []
52+
53+
54+
def provider(node: _Node) -> JsonRpcProvider:
55+
return JsonRpcProvider("https://node.example", CHAIN, client=node) # type: ignore[arg-type]
56+
57+
58+
def covered(ranges: list[tuple[int, int]]) -> set[int]:
59+
seen: set[int] = set()
60+
for lo, hi in ranges:
61+
seen.update(range(lo, hi + 1))
62+
return seen
63+
64+
65+
def test_a_chunk_covers_every_block_it_was_given() -> None:
66+
"""The gap bug. A 40,000-block assignment at a 5,000 span is eight
67+
requests, not one."""
68+
node = _Node()
69+
rpc = provider(node)
70+
rpc._chunk(CHAIN, 1_000_000, 1_039_999, ["0xtopic"], None)
71+
assert covered(node.ranges) == set(range(1_000_000, 1_040_000))
72+
73+
74+
def test_no_range_is_requested_twice() -> None:
75+
node = _Node()
76+
provider(node)._chunk(CHAIN, 1_000_000, 1_039_999, ["0xtopic"], None)
77+
blocks = [b for lo, hi in node.ranges for b in range(lo, hi + 1)]
78+
assert len(blocks) == len(set(blocks))
79+
80+
81+
def test_the_first_request_is_cautious() -> None:
82+
"""Starting wide costs a failure to discover the limit; starting narrow
83+
costs nothing when the endpoint turns out to be generous."""
84+
node = _Node()
85+
provider(node)._chunk(CHAIN, 1_000_000, 1_039_999, ["0xtopic"], None)
86+
first_lo, first_hi = node.ranges[0]
87+
assert first_hi - first_lo + 1 == _SPAN_START
88+
89+
90+
def test_the_span_widens_after_success() -> None:
91+
node = _Node()
92+
provider(node)._chunk(CHAIN, 1_000_000, 1_039_999, ["0xtopic"], None)
93+
widths = [hi - lo + 1 for lo, hi in node.ranges]
94+
assert widths[1] > widths[0], "a working endpoint should be asked for more"
95+
assert max(widths) <= _SPAN_CEILING
96+
97+
98+
def test_the_span_narrows_on_refusal_and_stays_narrow() -> None:
99+
"""Rediscovering the same limit on every chunk is what made narrowing
100+
expensive enough to trip the breaker."""
101+
node = _Node(max_span=2_000)
102+
rpc = provider(node)
103+
rpc._chunk(CHAIN, 1_000_000, 1_019_999, ["0xtopic"], None)
104+
assert covered(node.ranges) == set(range(1_000_000, 1_020_000))
105+
assert all(hi - lo + 1 <= 2_000 for lo, hi in node.ranges)
106+
assert rpc._span <= 2_000
107+
108+
109+
def test_an_endpoint_that_refuses_everything_raises_rather_than_looping() -> None:
110+
node = _Node(max_span=1)
111+
with pytest.raises(ProviderError):
112+
provider(node)._chunk(CHAIN, 1_000_000, 1_019_999, ["0xtopic"], None)
113+
assert all(hi - lo + 1 >= _SPAN_FLOOR for lo, hi in node.ranges) or not node.ranges
114+
115+
116+
def test_a_single_block_range_is_one_request() -> None:
117+
node = _Node()
118+
provider(node)._chunk(CHAIN, 1_000_000, 1_000_000, ["0xtopic"], None)
119+
assert node.ranges == [(1_000_000, 1_000_000)]
120+
121+
122+
def test_widening_does_not_undo_narrowing() -> None:
123+
"""Without a remembered cap the span oscillates: succeed at 1,250, double
124+
to 2,500, get refused, halve back --- a wasted failed request every other
125+
round, for ever. Counted here rather than asserted in prose."""
126+
node = _Node(max_span=2_000)
127+
rpc = provider(node)
128+
rpc._chunk(CHAIN, 1_000_000, 1_079_999, ["0xtopic"], None)
129+
assert rpc._span <= 2_000
130+
assert rpc._span_cap <= 2_000
131+
# Every request that was *served* is inside the limit, and the served
132+
# widths stop shrinking once the cap is known.
133+
widths = {hi - lo + 1 for lo, hi in node.ranges}
134+
assert max(widths) <= 2_000

0 commit comments

Comments
 (0)