Skip to content

Recover from a 421 on the evidence rather than on the cache's age - #154

Open
mpretty-cyro wants to merge 2 commits into
session-foundation:devfrom
mpretty-cyro:fix/swarm-invalidation-on-421
Open

Recover from a 421 on the evidence rather than on the cache's age#154
mpretty-cyro wants to merge 2 commits into
session-foundation:devfrom
mpretty-cyro:fix/swarm-invalidation-on-421

Conversation

@mpretty-cyro

Copy link
Copy Markdown
Collaborator

The defect

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 for any cache younger than that, then re-read the same _swarm_cache entry. The retry
therefore went to another node in the list that had just rejected us, redirect_retry_count (1) was
exhausted, and every later request for that account did the same until the cache aged out.

User-visible result: a client can be unable to interact with a swarm for up to two hours, with no
recovery except waiting out cache_expiration or a full clear_cache(). The log line even said it —
"refreshing swarm if stale" — staleness measured by the clock while we were holding direct proof.

Why evicting the swarm cache entry doesn't fix it

Worth stating, because it's the obvious-looking fix and it is a no-op:

  • _swarm_cache is only ever written with swarm::get_swarm(swarm_pubkey, _all_swarms) — a pure
    function of _all_swarms.
  • _all_swarms is swarm::generate_swarms(_snode_cache), grouping nodes by the swarm_id each node
    carried in the pool fetch.
  • Both writers of _all_swarms replace _swarm_cache wholesale in the same breath.

So the swarm cache can never disagree with the pool; erasing an entry recomputes the identical answer.
What a 421 disproves is the pool snapshot, and only a refresh replaces that.

The 421 body does carry the correct swarm, and is deliberately not used: it would take one node's
unauthenticated word for swarm membership — the node that just rejected us — where a pool refresh
establishes it from an intersection of cache_num_nodes_to_use_for_refresh (3) nodes. It would also
break the memo relationship above.

The change

SnodePool::invalidate_swarm() — evidence-driven, alongside the existing age-driven
refresh_if_needed. _handle_421_retry calls it instead.

What stops a 421 storm becoming a refresh storm. A rejection arriving after we already refreshed
on one is the refresh telling us it didn't help, so each successive one waits twice as long, capped at
cache_expiration: immediate, 1m, 2m, 4m, 8m, 16m, 32m — about 7 refreshes in the first hour, then
flattening toward today's behaviour. Quiet for twice the interval being held resets to the base delay.
The backoff is global rather than per-swarm on purpose: a refresh replaces the pool every swarm is
derived from, so a rejection for swarm B right after a refresh prompted by swarm A is equally
unhelpful to act on. A separate pool-age floor stops us refreshing a pool something else just fetched.

Worst-case outage goes from 2h to ~1 min.

Tests

New [network][invalidate_swarm] case. It cannot fail against the pre-fix tree (it calls a function
that doesn't exist there), so the old behaviour was reproduced by mutation instead — reducing
invalidate_swarm to refresh_if_needed({}, cb), exactly what _handle_421_retry used to do, fails
REQUIRE(debug_refresh_in_progress()). Deleting the backoff term fails
CHECK_FALSE(debug_refresh_in_progress()).

Full suite green (135 cases). utils/format.sh verify clean.

Notes for review

  • Request::swarm_pubkey is populated only by the client via the C API, and a request without it
    is refused a redirect outright — so this is inert for any caller that leaves it null. iOS sets it
    for .snode destinations; worth confirming as the Session 2.0 client wires up its request paths.
  • Applies cleanly to client, which carries the identical defect.

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.
@mpretty-cyro
mpretty-cyro marked this pull request as ready for review September 11, 2026 03:05
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`.
@mpretty-cyro

Copy link
Copy Markdown
Collaborator Author

Pushed a second commit (329f8b9d) answering the aggregate-bandwidth feedback. It's a fast-forward60c6d799 is unchanged and still the first commit, so nothing already reviewed has been rewritten.

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 cache_num_nodes_to_use_for_refresh nodes), where nothing made them fetch it before — and the backoff in the first commit bounds how often one client repeats that, not the aggregate.

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 get_swarm prefers that over its own calculation until the pool is refreshed. The pool refresh stays as the fallback for a 421 carrying no usable redirect, so both paths are needed.

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:

  • a redirect naming the swarm we already calculated is refused (the node contradicting itself)
  • fewer than cache_min_swarm_size resolved names is refused
  • three in a row without an intervening refresh is refused, then we fall back to refreshing
  • a pool refresh drops every override it was correcting, so a claim can't outlive the ground truth that would overrule it

Overrides are kept out of _swarm_cache, which stays a pure memo of _all_swarms.

One thing worth a second opinion: a redirect reports INVALID_SWARM_ID, because a redirect names swarm members, not an id, and the new id isn't derivable from a stale pool. Harmless today — the C API discards the swarm id and the 421 handler ignores it — but it would matter if a consumer ever started reading it.

New [network][swarm_redirect] test covers the accept path and all four refusals, and asserts no refresh is started against a deliberately-aged pool, so "no refresh happened" means the redirect was taken rather than the pool being too fresh to bother. Full suite green (136 cases).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant