Failover buys availability, not depth: a pruned node answers null, and null is a success #9
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-up to #8, which gave mainnet two public archive nodes behind our own.
The gap
RpcClient::callmoves to the next endpoint on a transport failure and never on a JSON-RPC error, which is right — an error is the node answering, and asking a second node the same bad question just wastes a round trip.But a node that has pruned the state you asked for does not error. It answers:
That is a success.
state_getStorageat a pruned height,state_getKeysPagedover a pruned prefix,QPoWApi_get_difficultyagainst a pruned parent — all of them come back empty and the client is satisfied. So the fallbacks do nothing for depth: whichever node we happen to be pinned to decides how far back the site can see, and if that is ours, the answer is 256 blocks once it starts pruning.Right now this is invisible because bob runs archive state pruning and is first in the list, so it always has the answer. It becomes real the moment bob's disk gets tight and somebody drops that flag, and it will present as "history quietly went blank" rather than as an error.
Why it is not just "retry on null"
Null is a legitimate answer. Most of the storage keys we read genuinely do not exist — an account with no balance, an item never set, the treasury's
System::Accountentry, which is absent because the treasury was never funded (that is a real finding the site currently reports correctly). Retrying every one of those against three nodes in turn would triple the read volume on the public nodes to re-derive an answer we already had, and would still end in null.The distinction is not in the response. It is in whether the node could have known:
Sketch
Some options, roughly in increasing order of cost:
system_nodeRolesdoes not say, butarchive_v1_*inrpc_methodsis a strong signal, and astate_getStoragefor a key we know exists at genesis is a direct test. Probe each endpoint once at startup, mark it archival or not, and route historical reads only to the archival ones. Cheap — one probe per endpoint — and it makes the pinning explicit rather than incidental.call_deepor similar, used by the reads that are known to be historical (difficulty_for_child_of, the genesis and block-zero state reads,storage_keys_pagedat an old hash). A null from a non-archival endpoint means "ask the next one"; a null from an archival one is the answer. Leaves every tip read on the fast path untouched.(1) and (2) compose: the probe decides which endpoints are eligible, the depth-aware call rotates among them.
Not urgent
Nothing is broken today. Filing it because the failure mode is silent, the cause will be a config change on an unrelated host months from now, and the symptom — old blocks losing their difficulty — is one this codebase has already produced once for a different reason, so it would be easy to misdiagnose.
CLAUDE.mdalready warns that substituting the current difficulty for a pruned one is how a gap fill draws a flat plateau across a stretch of history; this is the same trap approached from the other side.Worth pinning before anyone picks this up:
CLAUDE.mdalready documents null-on-pruned as deliberate, under "A pruned block is a success, not an unhealthy endpoint" — treating it as a failure would walk the whole endpoint list asking a question none of them can answer, and turn a legitimate absence into an outage. The neighbouring note, "Endpoints are stuck to, never round-robined", is the other half: alternating between nodes that prune on their own schedules returns a mixture of answers and absences that reads as sparse data rather than as a configuration problem.So this issue is not "make null fail over". Both of those decisions stand. What is missing is the third thing neither covers: knowing, before the read, which endpoints could possibly hold the state — so a historical read can be routed to an archival node rather than retried across all of them. Option (1) in the description is the load-bearing part; (2) is only useful once (1) exists.
If a fix here ever ends up deleting either of those
CLAUDE.mdparagraphs, it has gone wrong.Implemented in
9def0c8, with one deliberate departure from what this issue proposed.The description said "route historical reads only to the archival ones". That is worse than what landed. It would have taken every historical read off the sticky endpoint — including mainnet's, which is our own loopback node and pays no network hop — and sent it over the wire to a public node, on every read, to defend against a case that is not currently true of any endpoint.
What landed instead:
call_deeptries the sticky endpoint first and only buys a second opinion when a non-archive endpoint returned null. A null from an archive is the answer and stops there. So the common case costs nothing at all, and the extra round trip is paid only in the situation this issue exists for.One thing the description missed. It framed the whole problem as null-that-looks-like-an-answer. But
state_call— which is howdifficulty_for_child_ofreads difficulty — does not return null when it cannot execute against dropped state. It refuses, with a JSON-RPC error.callis right not to fail over on that (an error is the node answering), so the archive would never have been asked, and the most depth-sensitive read on the site would have been the one still broken.call_deeptreats anRpcerror from a non-archive endpoint the same as a null.Probe.
System::Numberat block one — always written, four bytes. The key is derived fromtwox_128in a test rather than pasted, because a mistyped storage key is absent everywhere and would have classified every endpoint as pruned.:codewould also work and is megabytes;state_getRuntimeVersionwould work and takes ~4 s per endpoint.Measured against all seven configured endpoints today, every one returns
0x01000000— block one's number, so all seven are archives:http://127.0.0.1:9944(ours)rpc1-mainnet.quantus.com,rpc2-a1-heisenberg.quantus.cat,a2-a1-planck.quantus.cat,a2-So this changes no current behaviour and is dormant insurance, which is what it was filed as. An endpoint that cannot be probed stays
Unknownand routes as pruned — depth is demonstrated, never assumed, or an unreachable host silently becomes the archive of record.Option (3) from the description — recording and surfacing the pruning boundary — is not implemented.
call_deeplogs when an archive answers what the current endpoint could not, which is the operational signal; showing a boundary on the block route is a separate piece of work and nobody needs it while every endpoint is an archive.Both
CLAUDE.mdparagraphs the earlier comment asked to protect are still there, with the new behaviour written as a third alongside them.