fix(attribution): retry the telemetry join until the feed catches up
All checks were successful
deploy / build (push) Successful in 6m36s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-09 14:12:30 +03:00
parent 057937e9cc
commit 337dfa0f86
2 changed files with 59 additions and 9 deletions

View File

@@ -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<ChainRuntime>) {
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);
}
}
}

View File

@@ -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.