Snode pool: 421 recovery on evidence, strike expiry, and reporting failed refreshes - #154
mpretty-cyro wants to merge 5 commits into
Conversation
A 421 tells us, authoritatively, that the swarm we resolved for an account is wrong. `_handle_421_retry` answered it with `refresh_if_needed`, which decides on `cache_expiration` (2h) and so declines on any cache younger than that, and then re-read the same `_swarm_cache` entry - so the retry went to another node in the list that had just rejected us, and every later request for that account did the same until the cache aged out. Evicting the swarm cache entry alone cannot fix it: the entry is only ever a memo of `swarm::get_swarm(pubkey, _all_swarms)`, so it recomputes the identical answer. What the rejection disproves is the pool snapshot the swarms were generated from, and only a refresh replaces that. `invalidate_swarm` refreshes on that evidence, with a backoff that doubles each time another rejection arrives after a refresh we already ran for one, so a node rejecting everything costs a handful of refreshes over a couple of hours rather than one every minute for as long as it keeps it up.
Refreshing on a 421 recovers the account, but it means a swarm change has every client of that swarm fetch the full node list - 51 bytes per node from each of `cache_num_nodes_to_use_for_refresh` nodes - where nothing made them fetch it at all before. The backoff added with `invalidate_swarm` bounds how often one client repeats that; it does nothing about the aggregate. A node rejecting a request for an account usually names the swarm it actually belongs to, which corrects the one mapping we know is wrong without fetching anything. `get_swarm` prefers such a redirect over its own calculation until the pool is refreshed, and the refresh stays as the fallback for a 421 that carries no usable redirect. Only the node pubkeys are read from the response, and only ones that resolve against the pool we fetched ourselves, so a redirect reaches registered service nodes we already know about and nothing else - it cannot invent a node or name an address of its own choosing. It can still choose which of those nodes we talk to, so: a redirect naming the swarm we already calculated is refused, fewer than `cache_min_swarm_size` resolved names is refused, three in a row without an intervening refresh is refused, and a pool refresh drops every override it was correcting. The redirects are kept out of `_swarm_cache`, which stays a pure memo of `_all_swarms`.
|
Pushed a second commit ( What changed. The point stands that refreshing the pool on a 421 means a swarm change has every client of that swarm fetch the full node list (51 bytes/node from each of So the redirect is now the primary path: a node rejecting a request for an account usually names the swarm it actually belongs to, and On safety — this was the objection I'd originally raised against using the 421 body, and taking only pubkeys and resolving them against our own pool answers it: a redirect can reach registered service nodes we already know about and nothing else. It can still choose which of those we talk to, so it's bounded:
Overrides are kept out of One thing worth a second opinion: a redirect reports New |
`STRIKE_EXPIRY` was applied in `node_strike_count()` - which nothing in this repo calls - and on the disk load. Every place that actually decides something counted the raw vector instead, and `record_node_failure` only ever appends, so a node struck during a two-minute outage stayed out of path building, swarm answers and refresh candidates for the life of the process, and its timestamp vector grew without bound. Restarting was the only thing that cleared it, because loading is the one path that filtered. Counting is now in one place, used by all four, and a node's expired strikes are dropped when it collects a new one. Two things this uncovered, both only reachable with a strike threshold of 0, which is what `tests/test_snode_pool.cpp` has been running with: - comparing the count against the threshold with a bare `>=` excludes every node in the pool, including ones that have never failed, so a threshold of 0 has to keep meaning "drop a node on its first strike". - a permanent failure looped up to the threshold, recording no strikes at all and leaving a node we know is gone in rotation.
A cached edge node is handed to `_build_path` as a forced first hop, so it is the one node in a path that never passes the strike filter `get_unused_nodes` applies to the rest, and the only thing that dropped it was `edge_node_cache_duration` (10 days). So a node we already had evidence was unreachable got another path built onto it on every launch and every resume, and a path rotation carried it into the replacement path. It loses the cached-edge role rather than its place in the pool: it stays a node like any other and can be picked again once its strikes expire. Keeping the entry and merely skipping it would hand the role back at that expiry, by which point we have been running on a different edge node for two days - a second change of first hop, not a return to a stable one.
`refresh_if_needed` had three ways to do nothing and say nothing: a suspended pool, no candidate nodes, and no fetcher. The callback was simply never invoked, and every caller sets its own state up before calling, so "never" is not a delay - it is permanent: - `_resync_clock` sets `_current_clock_resync_id` first, so a dropped callback makes every later clock resync short-circuit as "already in progress" and strands everything queued behind it. - both routers finish setup from the callback, so they never finish it. - `get_random_nodes` and the path builder retry from the callback, so their callers wait on something that will not arrive. The callback now takes whether a refresh actually happened, which is the smallest thing that lets a caller tell "the pool is fine" from "we could not find out". Nothing needing a refresh reports true - that is the answer the caller wanted, not a failure to get one. Each caller had to be given a failing branch, and two of them would have spun rather than hung if the callback had simply always been invoked: `get_random_nodes` re-enters itself, and the path builder rebuilds into the same too-few-nodes branch. `SnodePool` has no C API surface, so this is internal only.
Five commits against
dev, each independently reviewable and any of them droppable. They're togetherbecause they all sit in
src/network/and touch the same few files, so splitting them into separatePRs just moved the conflict resolution to merge time.
1–2. A 421 couldn't invalidate the mapping it proved was wrong
A 421 says authoritatively that the swarm we resolved for an account is wrong.
_handle_421_retryanswered it with
refresh_if_needed, which decides oncache_expiration(2h) and so declines forany cache younger than that, then re-read the same
_swarm_cacheentry — so the retry went to anothernode in the list that had just rejected us,
redirect_retry_count(1) was exhausted, and every laterrequest for that account did the same until the cache aged out. A client could be unable to interact
with a swarm for up to two hours, recoverable only by waiting or a full
clear_cache().Evicting the swarm cache entry is a no-op, which is worth stating since it's the obvious-looking fix:
_swarm_cacheonly ever holdsswarm::get_swarm(pubkey, _all_swarms),_all_swarmsisgenerate_swarms(_snode_cache), and both writers of_all_swarmsreplace_swarm_cachein the samebreath. It can never disagree with the pool. What a 421 disproves is the pool snapshot.
Commit 1 adds
invalidate_swarm— evidence-driven, alongside the age-drivenrefresh_if_needed—with a backoff that doubles each time another rejection arrives after a refresh we already ran for one
(immediate, 1m, 2m, 4m… capped at
cache_expiration), so a node rejecting everything costs a handfulof refreshes over a couple of hours rather than one a minute.
Commit 2 makes the redirect the primary path, after review feedback that refreshing on every 421
means a swarm change has every client of that swarm fetch the full node list (51 bytes/node from each
of
cache_num_nodes_to_use_for_refreshnodes) where nothing made them fetch it before. A rejectingnode usually names the swarm the account actually belongs to;
get_swarmnow prefers that until thepool is refreshed, with the refresh kept as the fallback when a 421 carries no usable redirect.
Only pubkeys are read from the response, and only ones resolving against the pool we fetched
ourselves, so a redirect reaches registered service nodes we already know and nothing else. It can
still choose which of those, so: a redirect naming the swarm we already calculated is refused, fewer
than
cache_min_swarm_sizeresolved names is refused, three in a row without an intervening refreshis refused, and a pool refresh drops every override it was correcting. Overrides are kept out of
_swarm_cache, which stays a pure memo of_all_swarms.INVALID_SWARM_ID— it names members, not an id, and the new id isn't derivablefrom a stale pool. Harmless today (the C API discards the swarm id,
_handle_421_retryignores it) butit would matter if a consumer started reading it.
3. Strikes never expired where it counted
STRIKE_EXPIRY(48h) was applied innode_strike_count()— which nothing in this repo calls, it'sclient-facing API — and on the disk load. Every place that actually decides something counted the
raw vector:
refresh_if_needed's usable-node count,get_unused_nodes' skip, andget_swarm'sfilter_by_strikes. Sincerecord_node_failureonly appends, a node struck three times during atwo-minute outage stayed out of path building, swarm answers and refresh candidates for the life of
the process, with its vector growing unbounded. Restarting was the only cure, because loading is the
one path that filtered.
Lopsided in a telling way:
_perform_strikes_writealready filtered bySTRIKE_EXPIRYon the way outand
_load_from_diskfiltered again on the way in. Both ends of persistence honoured expiry; only thein-memory decision sites didn't. (So this changes nothing about what lands on disk.)
Two further defects fell out, both only reachable at
cache_node_strike_threshold == 0— which is whattests/test_snode_pool.cpphas been running with — and both caught by the existing suite:>=against the threshold excludes every node in the pool, including ones that neverfailed (
0 >= 0). The old code dodged it only by requiring the map lookup to succeed first.for (i = 0; i < threshold; ++i)is zeroiterations — leaving a node we know is gone in full rotation. Now
max(1, threshold).48h after its last strike instead of never. Intended — it may have recovered, and gets struck out again
on first use — but it is a change.
4. The cached edge node never had its strikes consulted
OnionRequestRouterkeeps sticky edge nodes so a client's first hop is stable across sessions, andhands one to
_build_pathas a forced first hop — so it's the one node in a path that never passesthe strike filter
get_unused_nodesapplies to the rest. Onlyedge_node_cache_duration(10 days)ever dropped it, so a node we had evidence was unreachable got another path built onto it on every
launch and every resume.
It now loses the cached-edge role once struck out — not its place in the pool; nothing here touches
_snode_cache, soget_unused_nodescan pick it again when its strikes expire. Dropped rather thanskipped because
_cached_edge_nodesis written once at load and never again, so a retained entry wouldreclaim the role at expiry, by which point we'd been on a different edge node for two days.
Lowest-value commit of the five — the retry already excludes the failed edge node, so the real cost
is one wasted build plus a retry per launch. Easy to drop.
5.
refresh_if_neededcould silently never call backThree ways to do nothing and say nothing — suspended pool, no candidate nodes, no fetcher. Callers set
their state up before calling, so "never" is permanent, not slow:
_resync_clock_current_clock_resync_idfirst, so every later clock resync short-circuits as "already in progress" and its queue is stranded_finish_setup()never runs — the router never becomes usableget_random_nodes, path build, proxy selectionThe callback now carries whether a refresh happened; "nothing needed refreshing" reports
true, sincethat's the answer the caller wanted. The flag does real work rather than being decoration — simply
always invoking the callback would turn two of these hangs into spins, as
get_random_nodesre-entersitself (and
Loop::callruns inline on the loop thread, so that's stack recursion) and the pathbuilder rebuilds into the same branch.
SnodePoolhas no C API surface, so this is internal only. The judgement calls are the review-worthypart: both routers still finish setup (not finishing leaves them permanently unusable), and
_resync_clockroutes into the existing_on_clock_resync_complete(), which resets the in-progress id,fails the queued requests, and leaves the existing offset alone — an old offset beats none.
Tests
Four new cases —
[invalidate_swarm],[swarm_redirect],[strike_expiry],[refresh_callback_contract],[cached_edge_nodes]— 139 total, all green.utils/format.sh verifyclean.Where a test couldn't fail against the pre-fix tree (it calls a function that didn't exist), the old
behaviour was reproduced by mutation instead and the failing assertion recorded.
[strike_expiry]is agenuine fail-before-fix:
2 == 4and4 == 1against unfixed code.Forward-port
client(b0a523fd) carries all of these. Commits 1–2 and 4 apply cleanly; 3 conflicts only on thesysclock_now_s()→clock_now_s()rename and 5 on the_loop->call→_jq.calljob-queue refactor.