Skip to content

fix: expose IBD header height correctly in health metrics - #1204

Merged
reardencode merged 5 commits into
reardencode:masterfrom
w3irdrobot:fix/ibd-header-metrics
Oct 25, 2026
Merged

reardencode merged 5 commits into
reardencode:masterfrom
w3irdrobot:fix/ibd-header-metrics

Conversation

@w3irdrobot

Copy link
Copy Markdown
Contributor

IBD tracks headers separately from the state read by health metrics and getblockchaininfo. This can make headers equal blocks and verification progress show 1 while catch-up is still running.

Share IBD’s accepted header tip with those readers during fresh sync and resume, and update it on rewind or invalid-header retirement. Peer-advertised heights do not count. The progress formula, validation, and readiness rules stay unchanged.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

REQUEST_CHANGES

1 bug, 1 suggestion, 0 nits

docs/rpc.md still says getblockchaininfo.headers is the best height from submitheader and P2P header tips. That file owns the field notes, and this change makes that sentence false.

Comment thread crates/rbitcoin-net/src/chain.rs Outdated
let mut tips: Vec<(BlockHash, u32)> = self.header_tips.read().unwrap().entries().collect();
tips.extend(*self.ibd_header_tip.read().unwrap());
for (hash, h) in tips {
if !self.header_ancestry_invalid(hash) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

best_header_height runs header_ancestry_invalid on the IBD walk tip. The walk stops after 10,000 prev_of store reads and then returns false, so a tip more than 10,000 blocks above the active chain is still counted. invalidateblock on the connected tip does not call publish_ibd_header_tip, and retire_invalid returns immediately when that block is no longer in height_to_hash, so headers stays on a walk that extends the invalidated block. Every getblockchaininfo and metrics scrape pays for those 10,000 lookups through the header-ahead phase, and past the cap the result cannot change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 00ce4aa. The IBD report now uses a generation check instead of an ancestry walk. Invalidations clear it, and old walks or delayed origin loads cannot publish into the new generation. The regression checks an unstored look-ahead tip after its connected ancestor is invalidated, then checks stale publications and recovery after reconsider/resume.

@w3irdrobot
w3irdrobot force-pushed the fix/ibd-header-metrics branch from 1768708 to 00ce4aa Compare October 25, 2026 13:24

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

REQUEST_CHANGES

2 bugs, 0 suggestions, 0 nits

The docs/rpc.md field note is fixed. An epoch bump clears the IBD report, and a later publish that still holds the old epoch does not put that report back.

best_header_height still fail-opens the stored header queue past 10,000 prev_of reads. publish_header_tip never recaptures the generation, so the live walk goes silent after the next invalidation that is not an on-path retire.

Comment thread crates/rbitcoin-net/src/chain.rs Outdated
.unwrap_or(0)
.max(self.ibd_header_report.read().unwrap().height.unwrap_or(0));
let tips: Vec<(BlockHash, u32)> = self.header_tips.read().unwrap().entries().collect();
for (hash, h) in tips {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

best_header_height still runs header_ancestry_invalid on every header_tips entry. That walk stops after 10,000 prev_of reads and returns not invalid, so the tip is counted. IBD stores the download queue up to ORDERED_HEADERS_SOFT_CAP (64,000) and ensure_headers_batch keeps that frontier in header_tips. Invalidating the connected tip clears the IBD report only; headers stays on this stored chain, and every getblockchaininfo and metrics scrape still pays for the capped walk. The new journey stops at height 6, under the cap. Count a tip only when the prev walk reaches the active chain, and do not repeat that walk on the scrape path.

@w3irdrobot w3irdrobot Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 42a1a0a (rebased onto current master). best_header_height now reads one cached maximum for both reporting sources. Stored candidates are proved on mutation without a depth cutoff, not on each scrape. The regression reaches header height 10,003 and invalidates its connected ancestor; the header-only stretch is necessary to cross the old fixed limit, without building a tall block fixture.

}

fn publish_header_tip(st: &IbdWorkState, hub: &ChainHub) {
if let Some(epoch) = st.header_walk.report_epoch {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

publish_header_tip keeps the epoch captured when the origin was loaded. invalidateblock, submitblock, and any note_invalid_block bump the generation and clear the report, but extend_tip and rewind_to publish that same old epoch, so the update is dropped for the rest of the process and headers falls back to the block tip while catch-up continues. retire_invalid stores the new epoch only after the hash is on height_to_hash and header_of returns a parent; the earlier returns leave the walk stale. Recapture the current generation at publish when the base and checkpoints are not in the invalid set.

@w3irdrobot w3irdrobot Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 42a1a0a (rebased onto current master). A live producer can refresh its generation from a revalidated proof, and the journey now continues after an unrelated invalidation. It also invalidates an ancestor between checkpoints rather than checking endpoint hashes alone. Unprovable look-ahead retains only its proved prefix. IBD queues these checks on the existing header thread, outside the coordination lock.

@reardencode

Copy link
Copy Markdown
Owner

rebase plz!

Publish the accepted IBD header walk into ChainHub on fresh sync, resume, and rewind so health metrics and RPC do not fall back to the validated block tip. Keep peer horizons separate and retain ancestry filtering without holding tip locks during the walk.

Add an HTTP metrics and RPC regression through a real store reopen, plus assertions for peer horizons, look-ahead headers, and invalid candidate retirement.
Replace per-scrape ancestry checks for the IBD report with a reporting generation. Invalidating a block clears the report and prevents delayed origin loads or an old walk from publishing it again. Rebased and restored walks capture the current generation; reporting generations are not persisted.

Extend the metrics and RPC journey with an unstored look-ahead tip, invalidation of its connected ancestor, stale origin publications, reconsideration, and resume. Keep the fixture small rather than building a 10,000-header chain to hit the old ancestry cap.
Cache the maximum from stored header tips and the accepted IBD path so scrapes never walk ancestry. Revalidate stored candidates to the active chain on invalidation and reconsideration without a depth cutoff. Reuse proved parents for header intake and retain only a proved prefix when a look-ahead tail cannot be checked.

Refresh live IBD reporting after unrelated invalidations. Bound extensions by known invalid descendants and check intermediate ancestry rather than checkpoint hashes alone. Queue IBD proof checks on the existing header role so they run outside the coordination lock.

Extend the journeys with a stored chain beyond the old 10,000-step cap, unrelated invalidation followed by live extension, an invalid ancestor between checkpoints, reconsideration, and the coordinator IO guard.
@w3irdrobot
w3irdrobot force-pushed the fix/ibd-header-metrics branch from d1cd040 to 42a1a0a Compare October 25, 2026 16:03
@w3irdrobot

Copy link
Copy Markdown
Contributor Author

rebase plz!

done 🫡

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

REQUEST_CHANGES

2 bugs, 0 suggestions, 0 nits

The cached read fixes the scrape walk and the 10,000-step fail-open. Refresh rewrites a live proof at the current generation, so an unrelated stored invalid block no longer leaves the walk on a stale epoch.

proved_stored_header_height returns at the first invalid hash, and check_ibd_header_proof then keeps only the walk base, so every valid header below that block disappears. A hash with no header row clamps look-ahead to the stored anchor; submitblock refreshes before ensure_header, and nothing proves that look-ahead again while header intake is idle.

loop {
let hash = BlockHash::from_byte_array(rec.hash);
if invalid.contains(&hash) {
return None;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

proved_stored_header_height returns None at the first invalid hash, so a stored frontier whose ancestor was invalidated contributes no height. check_ibd_header_proof then sees the highest stored anchor fail and returns only anchors[0] (the walk base), skipping every later anchor that still proves. Valid headers between the active chain and that invalid block disappear, and headers falls back to the block tip. The 10,003-header journey invalidates the connected ancestor, where the valid prefix is only the tip, and the interior-checkpoint case sits next to that base, so both stay green. Walk to the parent of the invalid block and report that height; for the IBD proof, keep the highest anchor that still proves.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ba01b4a. Each frontier candidate now retains its highest proved valid prefix, walking below invalid ancestors rather than discarding the candidate. The stored-header regression checks 5,000 invalid → 4,999, a second invalid ancestor at 7,000, and staged reconsideration back through 6,999 to 10,003. The IBD journey also checks a valid prefix above the walk base.

let Some(invalid_height) =
self.proved_stored_header_height(hash, &none, &context.proved)
else {
safe_until = safe_until.min(stored_height);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

An invalid hash that is not an anchor and has no header row sets safe_until to the stored anchor, which drops the whole unstored look-ahead. That hash cannot lie between checkpoints. submitblock calls note_invalid_block (and therefore refresh_header_report) before ensure_header, so the refresh runs while the row is missing and commits the clamp. Header intake then goes idle for the block download and nothing refreshes the proof after the row exists. Ignore an invalid hash that is not an anchor and not stored, and refresh again once its header row is written.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ba01b4a by refreshing when the marked header is ensured, before intake can return early. The actual rejected-block RPC path now checks that look-ahead recovers after header storage without further intake. I kept unknown hashes conservative: an unstored invalid hash can be between checkpoints, and a control case verifies that storing such a header does not restore its invalid descendants. A revision guard rejects refresh results superseded by newer registration evidence.

Keep each stored frontier candidate's highest proved valid prefix, including when multiple ancestors are invalid. Refresh reporting when a marked header is registered so rejected-block intake can restore unrelated look-ahead without waiting for more headers.

Cover intermediate invalidation and staged reconsideration on a 10,003-header chain, the rejected-block RPC path, and an unstored invalid ancestor inside look-ahead. Reject refresh results superseded by newer registration evidence.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROVE

0 bugs, 0 suggestions, 0 nits

A stored frontier now keeps the parent of its lowest invalid ancestor, including a second invalid block above that cut, and the IBD proof keeps that prefix when the old tip no longer proves. A rejected submitblock still marks the hash before the header row exists, then note_header_tip refreshes once the row is written, so an unrelated reject no longer leaves look-ahead clamped for the rest of block download. A hash that is still unstored stays a hard cut: it can sit between checkpoints, and storing it must not bring its descendants back.

@reardencode
reardencode merged commit 533eeee into reardencode:master Oct 25, 2026
18 checks passed
@w3irdrobot
w3irdrobot deleted the fix/ibd-header-metrics branch October 25, 2026 21:39
phlip9-github-agent Bot pushed a commit to phlip9/rbitcoin that referenced this pull request Oct 25, 2026
reardencode#1204 built an RpcContext literal in the health metrics test. reardencode#1211
added package_dialect to RpcContext and updated the other test
literals to false. The two pulls merged cleanly and left this
initializer missing the field, so the node test crate does not
compile.

package_dialect is false here, the off default those other literals
use. The rest of reardencode#1204 already matches the NodeEnv, NodeConfig, and
RpcContext fields reardencode#1211 added, and it does not call the helpers that
pull removed.

Co-authored-by: Brandon Black <reardencode@users.noreply.github.com>
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