Fix LFS restore stalls caused by genesis placeholders - #443
Conversation
82e095f to
4b682a1
Compare
There was a problem hiding this comment.
Reviewed at 4b682a1 against dev. Suggestions below.
High
-
Worth fixing the finalizer hold here rather than after. On a shard carrying both idle shapes — validator5 bonded with
--heartbeat-disabledfrom genesis, validator4 paused after proposing — dev's placeholder drags the window to-1, the joiner downloads to genesis and finalizes normally (119 → 147, 0 holds). At this head the placeholder correctly stops lowering the window, and the same joiner reaches Running in 30s and then never finalizes: LFB frozen at 118 while the shard reached 133, 50× "the floor walk needs a block this node does not hold", missing block at height 6. The defect is dev's, but the lowering was hiding it, so this branch turns a visible stall into a silent one.The absence is raised inside
derive_floor's frontier resolution, on the warm path. Capturing a backtrace inheld_metaand printing it wherefloor_of_viewcatchesBlockNotHeldgives the same frames on all 50 holds:CliqueOracle←derive_floor←floor_of_view←compute_last_finalized_block. A horizon guard incold_parent_frontiernever fires, so the cold descent is not the path. Fits:floor_of_blockon the stale tip succeeds and persists a frontier pivot, and the next derivation walks the warm path from that pivot into history the restore never fetched. The exact line is open —held_numberon the cached pivot, or on a spine candidate aroundft_witnessed_exact.Two things it is not. Not the seeding path: the joiner logs "Seeded the anchor's finalized floor and frontier from the approved block" (floor_number 115) with neither warning present, and
seed_floor_cachescaches a floor for the anchor alone, so a tip ~110 below the anchor has nothing in its ancestry passing through it. Not per-slot abstention either:floor_of_viewabstaining a slot whose floor does not derive, the wayfork_choice_floordoes, was built and run and never fires — per-tipfloor_of_blocksucceeds for all four tips, so the hold is unchanged. -
Worth clamping coverage to
max(contiguous_coverage_start, min_height - 1). Rows below the window are fetched only as parents of accepted blocks, so a deep secondary parent makes a lower row contiguous without it being complete; a run above loggedcoverage_from: 5againstmin_height: 8. In the differential harness, with carrier c26 absent and sibling Z26 held, a repeat at height 29 givesValidat W=26 andUndecidableat W=27 — same deploy, two verdicts, againstInvalidRepeatDeployon a full node.
Medium
- Worth applying
own_testimonyat the remaining consumers, or saying why not.mergeable_channels_gc.rs:311passes every latest-message hash todeepest_common_strict_ancestor, whosemain_parentcollect short-circuits on genesis (probe:with_seed=Noneagainstwithout_seed=Some(4)), and:225then refuses every deletion — GC collects nothing while any bonded validator has never proposed.
Low
- Worth carrying
justification.validatorpastlfs_block_requester.rs:893-896so the LFS check issender == validatorrather thansendernon-empty, and matching themissingformatting across the two new absence log sites (PrettyPrinterin one, raw hex in the other).
Co-Authored-By: Claude noreply@anthropic.com
`MissingBlockContext` force-captures a backtrace at every missing-block error and both log sites discarded it, printing only the accessor. The accessor names the read; only the stack names the walk that reached it. Emitted at debug under `f1r3.trace.absence`, so warn volume is unchanged.
A validator bonded but never proposing has its latest-message slot seeded with the genesis hash, network-uniform by design. Genesis is held, so it survives the unheld-slot abstention and becomes a parent candidate. Every ancestry walk bounded by a height-0 candidate then runs to the bottom of the DAG, which on a restored node is below the restore horizon: the propose check demanded a block the node can never hold and no proposal was made for 31 hours on the RGB testnet shard. `own_testimony` states the rule once — held, and signed by that validator — and `floor_of_view`'s inline copy is removed in favour of it. A silent validator's slot no longer adds genesis as a declared parent; the merge and parent-depth specs now expect it abstained. Parent collapsing is an optimisation, so `prune_dag_covered_parents` now treats a coverage walk that leaves held history as unproven rather than failing the whole snapshot.
A joiner seeds `latest` from the approved block's justifications, so a bonded validator that never proposed puts genesis there. Genesis arrives at height 0 and the window lowers to `height - 1` = -1, after which the acceptance gate admits every block in the chain: joins that should take 75 blocks walked thousands toward genesis and never reached Running. The lowering itself is not the defect and still applies to a validator's own latest message, whose parents the restore needs. The seed is still consumed from `latest`, so the stream terminates.
…is rooted
The watermark was written at startup, where an empty database cannot tell
a node about to run a genesis ceremony from one about to restore. It wrote
0, claiming the carrier index is complete over every height — so a restored
node, holding nothing below its anchor, read an index miss as proof a
deploy had no carrier and admitted a repeat the full node rejects.
It is now written where the answer is known: 0 at genesis-ceremony
completion, and at restore completion the lowest height above which the
node holds every height. Not the DAG minimum — a shipped genesis sits at 0
while the node holds nothing between it and the restore band.
`carrier_watermark_initializes_once_per_database` asserted the old
behaviour ("an empty database is complete from the first insert"), which
is the defect stated as a contract; it now pins the corrected one.
An entry is a pointer, and one aimed below the restore horizon turns every walk that reads it into a demand for a block nothing fetches. The anchor's seed is solicited like any other restored block, so unchecked entries also overwrote the seed verified moments earlier.
A tip at or below the current floor cannot raise it, and deriving one walks a lagging validator's history below the horizon. A tip whose floor or frontier will not derive now abstains, as fork_choice_floor already does, costing that validator's testimony for the cycle rather than the cycle.
Below the bound a block is fetched only as a parent of an accepted one, so contiguity is not completeness there: a deep secondary parent fills a row whose other blocks were never requested. Observed as coverage_from 5 against min_height 8.
Genesis has no main parent, so counting a slot its validator never signed collapses the anchor search and the pass refuses every deletion for as long as a bonded validator stays silent.
Carrying the justification's validator makes the check sender == validator rather than sender non-empty. Genesis is the only unsigned block today, so the weaker test was an accident of what the requester kept.
4b682a1 to
7f9e5de
Compare
|
All four addressed. Every fix has a test that was red first, and each was red-checked by reverting the fix. 1. The finalizer hold — neither candidate line was the one.
The abstention patch counted 0 for a reason that turned out to matter: Three parts, each its own red test:
Contract change worth noting: Found while verifying that, and fixed here: 2. Coverage clamp — The case raised against it was a block at 3. GC consumer. 4. The requester carries Co-Authored-By: Claude noreply@anthropic.com |
spreston8
left a comment
There was a problem hiding this comment.
Re-reviewed at 7f9e5de against dev. All four addressed. Three smaller suggestions.
Medium
- Worth deriving before probing in
floor_of_view. Each tip now resolves twice a cycle: the probe runsfloor_of_block+parent_frontier, thenfinalized_floorruns both again with the same arguments.floor_of_blockis a warm cache read, butparent_frontiercaches nothing —incremental_frontierre-collects the band[tip..pivot], reads a weight map per band block and repeats everyft_witnessed_exactcall. That band is the tip-to-frontier distance, so the doubling is largest exactly when finality is already lagging. Callingfinalized_floorfirst and probing only onBlockNotHeldgives the identical answer — it errors precisely when some tip is undecidable — with nothing added to the healthy path. On the shard that produced the original hold the probe abstained nothing across 120s: the stale tip sits near height 11 against a floor of 116, so the height filter removes it and the abstention never fires.
Low
-
Worth applying
own_testimonyinestimatortoo, or saying why not.snapshot.rs:539-548filters only on invalid blocks, so the placeholder stays in the tip set, andblock_api.rs:1402takestip_hashes.first()off aHashSet:show_main_chaincan return a chain rooted at genesis. Pre-existing, not introduced here. -
Worth refreshing the description. It opens "Three defects on the LFS restore path" and its before/after table predates the finalizer fix, the floor-entry validation and the GC fix.
Co-Authored-By: Claude noreply@anthropic.com
…s absence Probing first resolved every tip twice a cycle, and parent_frontier caches nothing: it re-collects the band and repeats each oracle call, so the cost doubled exactly when finality was already lagging. The derivation errors precisely when a tip is undecidable, so probing after it gives the same answer for nothing on the healthy path.
The read API takes the first tip off a set, so a placeholder there roots show_main_chain at height 0.
The two helpers landed between the comment and the function it describes.
Six defects on the LFS restore path, all reproduced on the RGB testnet shard and most of them the same failure mode: state written when the truth was not yet knowable, then trusted later.
A validator bonded but never proposing gets the genesis hash in its latest-message slot, network-uniform by design so every node agrees on a validator with no blocks. Four consumers mistake that seed for testimony:
own_testimonystates the rule once (held, and signed by that validator);floor_of_view's inline copy is removed in favour of it.latestfrom the approved block's justifications, so genesis arrives at height 0 and the window lowers to-1, after which the gate admits the whole chain. A join that should take 75 blocks walked 3,098 heights and never reached Running. The lowering still applies to a validator's own latest message, whose parents the restore needs.show_main_chainreturn a chain rooted at height 0.Two more on the same path, found while verifying the first:
CliqueOracle::get_corresponding_weight_mapreads the target's MAIN PARENT's weight map, so holding a block is not enough to judge it, and a lagging validator's tip one hop above the horizon held the LFB for every validator. A tip at or below the current floor is no longer a candidate — its own floor and its frontier are both bounded by its height, so it cannot raise a maximum — and a tip that still will not derive abstains, asfork_choice_flooralready does. Confined tofloor_of_view: the consensus derivation used by propose and validate is untouched, since a restored node quietly deriving a different floor than a full node is a fork, not a stall.seed_floor_cachesapplies to the anchor's seed and documents. The solicited set is every restored block, the anchor included, andput_cached_flooroverwrites, so a peer's unvalidated entry landed on top of the seed verified on the line above.Separately, the repeat-deploy carrier watermark was written at startup, where an empty database cannot tell a node about to run a genesis ceremony from one about to restore. It wrote 0 — claiming the index is complete over every height — so a restored node read an index miss as proof a deploy had no carrier and admitted a repeat the full node rejects. It is now written where the answer is known: 0 at ceremony completion, and at restore completion the lowest height above which the node holds every block it will need. Not the DAG minimum: a shipped genesis sits at 0 while the node holds nothing between it and the restore band. Coverage also stops at the acceptance bound, because below it a block is fetched only as a parent of an accepted one, so contiguity there is not completeness.
Also here: parent collapsing is an optimisation, so a coverage walk that leaves held history is now unproven rather than fatal to the snapshot; and both missing-block log sites emit the backtrace they already capture, at debug under
f1r3.trace.absence. That backtrace is what identified the parent-selection walk after code reading had failed to.Verified on the shard, fixed build against unfixed controls on the same shard:
Minimum block height is -1, 3,098 heights, never readyapproved - 75, 77 heights, ready in ~2 mincoverage_from: 0over 157,000 unheld heightscoverage_from: 157520while the DAG minimum is 0Reviewer notes:
multi_parent_casper_merge_specandlimited_parent_depth_specnow expect 2 parents where they asserted 3. Genesis is an ancestor of every block, so it adds nothing to a merge. Parent selection is proposer-local and validators replay declared parents, so blocks from unfixed proposers remain valid.a_walk_crossing_the_retention_edge_holds_the_cycleassertedAbsenceHold; a lone undecidable tip now abstains and the answer isNoAdvance, so it is renamed..._abstains_the_tip. The abstention logs the tip and the missing block.carrier_watermark_initializes_once_per_databasehad its expectations changed — the old assertion ("an empty database is complete from the first insert") is the defect stated as a contract.ensure_carrier_watermarknow returnsOption<i64>, meaning "the watermark if known". The M1 defect is silent in production, with no error or stall, and was caught only by reading the log line the fix added.Co-Authored-By: Claude noreply@anthropic.com