Evict upstream connections whose RPC requests go unanswered (Sapphire zombie-connection watchdog) - #30
Merged
Conversation
During the Oasis Sapphire incident the primary provider
(wss://sapphire.oasis.io/ws) kept streaming newHeads but stopped
answering JSON-RPC requests entirely, while the fallback provider had
died at the same time. Since connection health was only judged by block
flow, NodeProxy kept routing requests into the zombie connection for
minutes: every sapphire:getblockheader caller burned its 25s
GenServer.call timeout, and peers requesting the same block queued
behind the per-block :global.trans lock in RPCCache.rpc_direct,
cascading into mass EdgeV2 disconnects until a manual restart.
Add a watchdog that scans in-flight requests every 25s and evicts any
WSConn (primary or fallback) with a request unanswered for 5x the
caller timeout (125s). Eviction closes the connection, clears its
leaked request entries, replies {:error, :disconnect} to orphaned
callers, and schedules an ensure_connections refill so ChainList
re-tests the URL.
Also bump profiler to v0.4.7: warn_if_stuck custom callbacks now
receive the monitored pid, so the EdgeV2 'stuck for 10000' warnings
report the blocked peer's stacktrace instead of the monitor process's
empty trace.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
During the Oasis Sapphire incident the primary provider (
wss://sapphire.oasis.io/ws) turned into a zombie connection: it kept pushingnewHeadsframes but stopped answering JSON-RPC requests, while the fallback (spectrum-02.simplystaking.xyz) had died at the same moment (block flow stopped, reconnects timed out).Because connection health is judged only by block flow, the primary passed every staleness check and stayed in rotation. Evidence from the incident (23:59\u201300:08 local):
Timeout calling {:global, {RemoteChain.NodeProxy, Chains.OasisSapphire}} {:rpc, "eth_getBlockByNumber", \u2026}errors in that window; each killed the EdgeV2 connection, so devices reconnected and retried \u2014 a self-perpetuating storm.Peer \u2026 handle_async_msg(["sapphire:getblockheader", \u2026]) stuck for 10000warnings: peers requesting the same block serialize behind the per-block:global.translock inRPCCache.rpc_direct, each taking a turn at a doomed 25s upstream call.gen_server:loopstack at snapshot times, sends succeeding) \u2014 nothing anywhere times out an unanswered upstream request.The incident only ended with a manual node restart; fresh connections to the same provider worked immediately.
Changes
lib/remote_chain/node_proxy.ex\u2014 unresponsive-request watchdog:watchdogtick every 25s (armed ininit/1, self re-arming; interval runtime-overridable viaset_watchdog_interval_ms/1for tests, same pattern asRPCCache.set_refresh_debounce_ms).prune_unresponsive_connections/1evicts any WSConn (primary or fallback) that has an in-flight request unanswered for 5\u00d7 the caller timeout (125s) \u2014 by then five generations of callers have already died on their own 25s timeout, so no useful answer can still arrive.requestsentries, replies{:error, :disconnect}to orphaned callers, logsEvicting unresponsive WSConn \u2026 (request unanswered for > 125000ms)and schedulesensure_connectionsso ChainList re-tests/refills the pool.Profiler bump
dfc953f \u2192 a9fedba(v0.4.7, dominicletz/profiler)Profiler.warn_if_stuckcustom callbacks previously ran in the spawned monitor process, soformat_stacktrace(self())captured the monitor\u2019s trace \u2014 which is why every \u201cstuck for 10000\u201d warning in the incident showed a useless single frame. Callbacks now receive the monitored pid (arity-1; arity-0 still supported; covered by new tests in the profiler repo, taggedv0.4.7).lib/network/edge_v2.exnow uses the pid argument so future stuck warnings are actually diagnosable.Tests
New
describe "prune_unresponsive_connections/1 watchdog"intest/remote_chain/node_proxy_test.exs(named after the failure they prevent):{:error, :disconnect}fallback/fallback_urlreset)handle_info(:watchdog)prunes and re-arms itselfVerification