From 337dfa0f8621bed825948a1302f601914174bdde Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 9 Sep 2026 14:12:30 +0300 Subject: [PATCH] fix(attribution): retry the telemetry join until the feed catches up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names were still absent after fixing the confidence denominator, because no vote was being cast at all. The cause is that `ATTRIBUTION_SETTLE` was doing duty for two unrelated quantities. It was written for one: how long node reports take to spread across the network once telemetry has the block. Eight seconds is right for that. But the lookup also has to wait out a second quantity nobody had measured — how far behind the chain the feed itself runs. On Planck the two coincide, because ~240 nodes at a 13 s block time is ~18 reports a second and the feed keeps up. Mainnet is ~190 nodes at ~1 s, an order of magnitude more traffic, and the feed sits about 57 blocks back. Measured against the node: chain tip 9539, highest height the feed had reported in the preceding 110 s was 9482. So every lookup asked who reported a block roughly fifty seconds before telemetry had heard of it. `first_import` missed every time, every observation became an abstention, and no author was ever named. The join now retries every 5 s until the feed reaches the block, giving up at three minutes and recording an honest abstention then. It adapts to whatever the lag is rather than assuming it is zero. Confirmed live: the top miner resolved to `QUANPOOL - quanpool-com` at confidence 1.00 within about two minutes of restart, from 3 votes cast over 21 blocks. That 3-of-21 is also why the previous commit was necessary and not sufficient — under the old denominator it would have scored 0.15 against a 0.6 bar and stayed nameless. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 --- crates/blackbeard-api/src/ingest.rs | 59 +++++++++++++++++++++++++---- crates/blackbeard-api/src/state.rs | 9 ++++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index 4f2a576..b09e54a 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -36,14 +36,30 @@ use crate::state::{ChainRuntime, PendingAttribution}; /// dropped head while an ever-growing queue would only hide the problem. const HEAD_BUFFER: usize = 64; -/// How long to wait for the telemetry feed to report a block before deciding -/// who reported it first. +/// How long to wait before first asking the feed who reported a block. /// /// Reports arrive from nodes all over the network over a couple of seconds; /// judging at the moment the block is seen would count only the fastest-peered /// nodes and systematically favour them. const ATTRIBUTION_SETTLE: Duration = Duration::from_secs(8); +/// How often to ask again while the feed has not yet reported the block. +const ATTRIBUTION_RETRY: Duration = Duration::from_secs(5); + +/// How long to keep asking before recording an abstention. +/// +/// **This is not the same quantity as [`ATTRIBUTION_SETTLE`], and conflating +/// them cost an afternoon.** Settle is how long reports take to spread across +/// the network once the feed has the block; this is how far behind the chain +/// the feed itself runs. On Planck the second is near zero and one look at 8 s +/// worked. On mainnet the feed sits ~57 blocks back — roughly a minute at a +/// one-second block time, because ~190 nodes reporting every second is an order +/// of magnitude more traffic than Planck ever produced — so every lookup asked +/// before the answer existed, every observation became an abstention, and no +/// author was ever named. Three minutes is comfortably past the observed lag +/// without holding blocks whose reports are never coming. +const ATTRIBUTION_GIVE_UP: Duration = Duration::from_secs(180); + /// Interval between difficulty and sync-state polls. const POLL_INTERVAL: Duration = Duration::from_secs(4); @@ -321,10 +337,12 @@ async fn record( .pending_attributions .lock() .unwrap_or_else(|e| e.into_inner()); + let now = tokio::time::Instant::now(); pending.push_back(PendingAttribution { hash, miner, - due: tokio::time::Instant::now() + ATTRIBUTION_SETTLE, + due: now + ATTRIBUTION_SETTLE, + give_up: now + ATTRIBUTION_GIVE_UP, }); } } @@ -513,11 +531,36 @@ fn resolve_attributions(chain: &Arc) { if due.is_empty() { return; } - let mut inner = chain.write(); - for p in due { - inner - .attributor - .observe(&p.miner, chain.telemetry.first_import(&p.hash)); + + // Resolve what the feed can answer for; put the rest back to be asked again. + // Re-queued entries keep the queue sorted by `due` because every retry adds + // the same interval, which is what lets the drain above stop at the first + // entry that is not yet due. + let mut retry = Vec::new(); + { + let mut inner = chain.write(); + for p in due { + match chain.telemetry.first_import(&p.hash) { + Some(report) => inner.attributor.observe(&p.miner, Some(report)), + // Nothing yet. An abstention recorded now would be a claim the + // feed never made — it has simply not reached this block. + None if now < p.give_up => retry.push(PendingAttribution { + due: now + ATTRIBUTION_RETRY, + ..p + }), + None => inner.attributor.observe(&p.miner, None), + } + } + } + + if !retry.is_empty() { + let mut pending = chain + .pending_attributions + .lock() + .unwrap_or_else(|e| e.into_inner()); + for p in retry { + pending.push_back(p); + } } } diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index 0efa505..2923c4e 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -114,8 +114,15 @@ pub struct PendingAttribution { pub hash: String, /// Its author. pub miner: blackbeard_entities::MinerId, - /// When the feed has had long enough to report. + /// When to next ask the feed who reported this block first. pub due: tokio::time::Instant, + /// When to stop asking and record an abstention. + /// + /// The feed runs behind the chain by however much it is behind — measured + /// on mainnet at ~57 blocks, about a minute — so a single look at a fixed + /// offset asks before the answer exists and misses every time. This is + /// retried until the feed catches up or the block is too old to matter. + pub give_up: tokio::time::Instant, } /// One chain, live.