Skip to content

[WIP] Gracefully handle nodes that now prune historical blocks - #4360

Closed
eranrund wants to merge 95 commits into
aleo-labs-protocol:stagingfrom
eranrund:eran/authority-prune
Closed

eranrund wants to merge 95 commits into
aleo-labs-protocol:stagingfrom
eranrund:eran/authority-prune

Conversation

@eranrund

@eranrund eranrund commented Jul 30, 2026 •

Copy link
Copy Markdown
Collaborator

This is a companion PR to aleo-labs-protocol/snarkVM#3329

The main benefits of the smaller state, is that initial participation and recovery will be much faster, making the network more robust.

It contains two major changes, presented as individual commits that hopefully make it easier to review/reason about.


Background

The companion snarkVM PR prunes authority data (authority_map + certificate_map) for blocks older than AUTHORITY_RETENTION_BLOCKS (100,000 blocks, roughly three days at 2.5s block times). Per review discussion there, peers do not advertise whether they keep full history. Instead the protocol contract becomes implicit and uniform: every peer serves [tip − AUTHORITY_RETENTION_BLOCKS, tip], and anything older is fetched from the trusted CDN (automatic on startup) or from a ledger snapshot (manual).

That design needs two things from snarkOS, which today does neither: a node must be able to decline to serve blocks it no longer has without punishing the requester, and a node must avoid asking peers for blocks they cannot possibly still hold - and know what to do when nobody can serve it.

Commit 1: Don't treat get_blocks as failure and penalize requesting peer

Today, when a node fails to load the blocks in a BlockRequest, the handler returns false/Err and the inbound dispatcher turns that into "peer sent an invalid block request", disconnecting the requester for a protocol violation. The failure is attributed to the wrong side: with pruning, an honest node would disconnect every peer that legitimately asks for a range it has pruned (and even without pruning, it's not the requesters fault that get_blocks failed...).

This commit makes an unavailable range a normal, benign outcome:

  • Serving side: In the client router, validator router, and BFT gateway, a get_blocks failure now logs a warning and replies with an empty BlockResponse rather than failing the message. An empty response is expressible in the existing wire format (DataBlocks serializes a u8 count), so there is no format change and no new message variant.
  • Requesting side: DataBlocks::ensure_response_is_well_formed now accepts an empty response, documented as the peer's explicit "I cannot serve this range" signal. Ordering and range checks still apply to non-empty responses.
  • InsertBlockResponseError::EmptyBlockResponse is now classified as benign. The existing machinery then does the right thing without further changes: the peer's outstanding requests are removed and marked for re-issue to other peers, so failover is immediate rather than waiting out the 60s request timeout.

Compatibility. No wire format change. Against an un-upgraded requester, an empty response is rejected and that peer disconnects the server and retries elsewhere - noisier, but assumed to be better than today, where the server disconnects an innocent requester. Un-upgraded servers never emit empty responses, so their behavior is unchanged.

Commit 2: Don't request blocks that peers have pruned, and shut down when no peer can serve them

With commit 1 alone, a node far behind the network would request pruned ranges, receive empty responses, re-issue to the same peers, and churn indefinitely. This commit "teaches" the requester the retention contract and gives it an exit.

  • Retention-aware peer selection: find_sync_peers_inner now skips peers whose assumed pruning floor lies above the next block we need. The floor is derived from the peer's advertised tip: tip − (AUTHORITY_RETENTION_BLOCKS − RETENTION_SLACK). The slack (1,000 blocks, chosen arbitrarily) exists because an advertised tip is only a snapshot - by the time our request arrives the peer may have advanced and pruned correspondingly, so we cannot assume it serves exactly tip − retention.
  • Stuck detection: BlockSync records when peers ahead of us exist but every one of them has pruned what we need, exposed as sync_stuck_below_peer_floors(). This state cannot resolve itself - peers prune further with every block they produce, so the gap only widens. It is deliberately distinguished from simply having no useful peers (not yet connected, isolated), which is transient and does not count.
  • Fatal shutdown: After the condition persists for five minutes, the node logs how to recover and shuts down gracefully via the new SignalHandler::stop_with_failure(), which causes the process to exit non-zero so a supervisor restarts it - at which point the existing startup CDN sync closes the gap automatically. Restoring a snapshot is the manual alternative; nodes run with --nocdn must use one.

The choice of shutdown over in-process CDN fetching was deliberate. Mid-run CDN sync would require reworking the one-shot CdnBlockSync lifecycle, re-routing its ledger writes through the advancement lock it currently bypasses, and adding a pause/resume surface to BlockSync - and it would be difficult to cover validators, where a mid-run CDN jump would strand BFT/DAG state. Restarting into the existing, well-tested startup path achieves the same outcome. This is a thing we can improve upon if we decide to invest resources in it, but in reality I think we are not going to experience nodes being down for almost 3 days and then suddenly coming back to life and trying to sync. If a node fell that far behind, it might as well restart. This keeps the code and potential edgecases smaller and easier to reason about. Its also worth emphasizing that CDN sync takes place every time a node restarts, so this is standard practice.

Misc notes

  • Devnet validation is still outstanding (.ci/test_devnet.sh). We'll need to make the 100k prune block window configurable (maybe via a build env var), and we'd need a way to trigger a node falling far behind and aborting.
  • The stuck detector is inferred from peer claims. A peer advertising a tip far above the real chain is unverifiable. In a progressing network this is inert: every real block gives an honest peer a servable tip and resets the timer, but during a network halt, when no honest peer is ahead, a lying peer could push idle nodes to exit. I am not convinced this is a problem since the network will (should?) never halt for any meaningful duration of time... if this is a concern, we might need hardening around this such as requiring a majority to choose the next tip, or choosing the minimum out of all peer-reprted tips that are ahead of us instead of choosing the maximum (which is what I think the code currently does).
  • REST still answers misleadingly for pruned blocks (GET /block/{h} 404s; GET /blocks fails the whole range) - a separate follow-up. I'm considering using HTTP code 410 Gone (Gone client error response status code indicates that the target resource is no longer available at the origin server) to differentiate between a block that never existed and a block that used to exist but are no longer available due to pruning.

Another thing worth mentioning is that this shifts the entire network (after an upgrade) to assuming no node can actually provide post-prune-window block, even though some might (due to having the history feature enabled). That was a deliberate decision that favors simplicity over having each peer return different ranges it can fulfill (explicitly suggested in the companion PR).

eranrund added 2 commits July 29, 2026 16:06
Instead, return an empty blocks array which the requesting peer will
interpret as "the peer cannot provide the requested blocks".

@vicsn vicsn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great to see!

The choice of shutdown over in-process CDN fetching was deliberate.

I think I agree, but can you check that the current CDN-fetching is robust enough? It should not fail and switch to P2P sync on the first fetching error.

Some on AI findings:

A single peer can force any client to shut down by advertising a fabricated tip

We should aim for the simplest fix for this. needed_blocks_below_all_peer_floors decides "no peer can serve me, this is unrecoverable" purely from locators.latest_locator_height(), which is an unverified peer claim:

if peer_tip > height {
any_peer_ahead = true;
if assumed_peer_floor(peer_tip) <= height.saturating_add(1) {
return false;
}
}
Block locators are only checked for structural validity — BlockLocators::ensure_is_valid requires checkpoints at multiples of CHECKPOINT_INTERVAL (10,000) and consistency between the last checkpoint and the last recent height. Nothing bounds the claimed height, and update_peer_locators explicitly documents that it does not cross-check against other peers. An attacker copies the honest checkpoint chain up to the real tip and appends ~10 fabricated checkpoints plus 100 fabricated recents.

The consequences chain together:

set_greatest_peer_height takes the max over all peers, and MAX_BLOCKS_BEHIND is 1, so one liar flips the node to Syncing — satisfying the !self.is_block_synced() guard.
The liar's assumed_peer_floor sits ~99,000 blocks above us, so it is excluded from candidate_locators; honest peers at exactly our height fail the peer_tip > current_height test. No candidates, no batches, no outstanding requests.
any_peer_ahead is true and no peer clears the floor test, so unservable_since is armed. After STUCK_SYNC_SHUTDOWN_THRESHOLD (5 min) the node calls stop_with_failure() and exits non-zero.
In a healthy network the timer resets whenever an honest peer advertises a tip strictly above ours, which happens every couple of seconds, so this is hard to sustain. The dangerous case is exactly the one you least want it in: a network halt or stall, when no honest peer is ever ahead. Then one connected peer can walk every client off the network in five minutes, and because the exit code is non-zero, supervisors restart into CDN sync and the attacker repeats it — a sustained crash loop. Clients accept inbound connections from anyone, so this needs no privileges. Validators are exposed too (they call prepare_block_requests through the BFT sync loop), but only to a Byzantine committee member, since gateway locators come from authorized validators.

The PR description acknowledges this ("a lying peer could push idle nodes to exit") and argues the network will never halt meaningfully. I don't think that's a safe assumption for a mechanism whose failure mode is mass shutdown. Concrete hardening options: require the stuck condition to be corroborated by a quorum or a majority of connected peers rather than any single one; derive the floor from the median or minimum of peer tips ahead of us instead of each peer's own claim; and require the node to have made no sync progress at all for the whole window, not merely that the flag stayed armed.

Shutdown-as-recovery has no backoff and no bound on repeats

Indeed instead of trying and waiting for 5 minutes could we fail immediately and loudly when:

  1. The node is far behind, and CDN sync failed.
  2. The node is far behind, and have --nocdn turned on.

Comment thread node/sync/src/block_sync.rs Outdated
@vicsn
vicsn requested a review from raychu86 August 7, 2026 09:44
Pin snarkVM to eranrund/snarkVM agent/authority-prune-v2 (76cc738a7), which is
staging plus authority pruning. That rev pins proptest =1.12, so the lock bumps
proptest and its transitive deps along with the snarkvm entries.
The constant moved from 100_000 to 500_000 in snarkVM; the literals no longer
exercised the boundaries they described.
A peer that advanced past its advertised tip answers with an empty response,
which is re-issued; the stuck detector's threshold covers the rest.
…fallback

With --nocdn, or after a failed startup CDN sync, waiting out the shutdown
threshold changes nothing, so shut down at once and name the recovery. The CDN
worker now reports a sync that stopped short instead of masking it as success;
the callers ignored that result before.
Only with the prune-authority-data feature; otherwise unchanged. Visible on /v2,
since the v1 error middleware still turns every error into a 500.
Block bundles were already retried; the initial latest.json fetch was not, so a
single transient failure skipped the CDN sync entirely.
A single peer advertising a fabricated far-ahead tip is a minority among honest
peers at this node's height, so it no longer arms the detector. The threshold
now measures a window without any ledger progress; progress restarts it.
The snarkVM pin forces proptest 1.12 (rand 0.10), where RngCore no longer
provides next_u64.
Share the CDN wait and stuck shutdown between client and validator, compute the
retention floor once in snarkos-node-sync, derive the stuck rule's preconditions
instead of passing them in, check REST retention inside the blocking task and
after the block cache, shorten the CDN height backoff, and cut the comments.
Evaluate the stuck condition on every query so a window armed earlier cannot
outlive it; require the router's minimum peer count before a majority counts;
treat a CDN behind the node as nothing to fetch rather than a failed sync;
include heights above genesis when a /blocks range starts at 0; count the CDN
height attempts as the constant says.
Check the re-issue path's peers against the first block it re-requests, not the
one after; measure the stuck condition at the sync height that request
generation uses; skip the REST retention check for an empty range; describe the
empty response as it is today; do not rely on clock resolution in a test.
On a pruning node, requests below the retention window are expected.
Comment thread Cargo.toml Outdated
git = "https://github.com/ProvableHQ/snarkVM.git"
rev = "9796eb19dee4fd2c671be9b3a1322eda62586991" #staging
git = "https://github.com/eranrund/snarkVM.git"
rev = "fd64f0bbb76829918bb07cbaa2e004a345dc8cc7" # eran/authority-prune

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember to undo once the snarkVM PR is merged

find_sync_peers reports the network tip to BFT sync and to provers, whose
ledger height is 0; filtering it by retention left provers without puzzle
requests and showed far-behind validators a tip of 0.
Treat a CDN with no blocks past the node as no fallback, so a node stuck below
every peer's floor fails fast with the snapshot instruction; log the re-issue
gap at debug and describe it accurately; name the actual CDN-disabled cause.
Simplify: REST uses snarkVM's oldest_retained_authority_height and carries the
parsed height; one helper answers block requests empty when the ledger cannot
serve them; the stuck tests are table-driven and the lifecycle test checks a
duration that can fail; connect_test_peer moves to tests/common; the mock
ledger returns to staging and the malformed-request test is dropped.
Count only requests still awaiting a response, so answered blocks waiting on an
unservable gap no longer keep the detector from arming; report an empty CDN
range as nothing loaded so it is classified as Behind; note that the 410 shows
only on /v2.
An answer for the next block means sync can still progress, so it keeps the
detector from arming. Skip the hash-to-height lookup on nodes that retain every
block, and keep the test that a malformed block request still disconnects.
… height

prepare_block_requests dropped peers assumed to have pruned retention_height()+1,
but construct_requests starts at the first height not in the ledger and not already
requested, which is far higher during catch-up. Compute that height once and use it
for both.
The failure height is the start height when no block was loaded, for example
when fetching the CDN height failed, so say the sync failed at or before it.
The early failures in load_blocks returned the start height, a block never
fetched, while the others return the last completed height. Make them agree
and log the failure as stopping after that block.
Every failure now carries the last completed height, so the old guard was always true.
The bundle closure owned a copy of current_height, so a failing insert
reported the pre-bundle height, failed the integrity check in the sync
worker, and masked the real error.
The sync worker warned and wait_for_cdn warned the same chain again;
only wait_for_cdn consumes the outcome, so it keeps the one warning.
…ight

The obsolete-pruning loop used the raw sync height while new requests
start from retention_height(), so with the sync height behind the ledger
a failed batch wholly below the ledger was kept and re-issued from above
the ledger every round, bypassing the new-request caps. Both paths now
use retention_height(), the re-issue is sized from its shifted start, a
batch already requested in full is dropped, and the re-request path
filters peers by the same tip height as the new-request path.
…tion height

The new-request start passed retention_height()+1 and the helper maxed
it with ledger+1 again. The helper now only skips outstanding requests;
both call sites in prepare_block_requests pass a height above
current_height (the retention height), which keeps the invariant visible
where the start is chosen.
The integrity-check branches replaced the load error, which the worker
no longer logs itself, so the one warning lost the cause.
The serve filter only checked a peer's floor, so a peer whose tip lies
between the current height and the batch start joined the cohort and
pulled the common ancestor below the start, stalling the batch.
A batch whose construct_requests returned nothing was still pushed as
an empty request list, so a round could return only empty lists and
skip the new-request path with capacity free. Its entries stay queued.
The heights before the re-issue start are already outstanding; leaving
them queued made the peer's later timeout or disconnect re-insert them
and warn "already exists in the failed requests map" once per height.
…batch

The re-request path returned early when no connected peer could serve
a batch, so a batch above every peer's tip blocked new requests for the
blocks those peers do have, and the stuck detector never armed.
@eranrund

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #4596

@eranrund eranrund closed this Oct 28, 2026
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.

2 participants