From 578a54ef879892e2619b72ff488144a171616d21 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Mon, 14 Sep 2026 14:12:41 +0300 Subject: [PATCH] fix: the backfill was re-announcing blocks the console had already shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wormhole pulse was broadcast from inside `index_events`, which has two callers: `record`, for a block that just arrived, and `backfill`, walking history. So the backfill re-announced every block it re-read — and because it walks towards the tip, the blocks it re-announced were exactly the ones a viewer had just watched. The live console listed #43,446 three times. This is the live/backfill confusion this repository has already been bitten by twice, arriving by a third road, so the guard is a type rather than a comment: `Pass::Live` or `Pass::Backfill`, and a caller cannot forget to say which. The duplicates did more damage than repeating themselves. Console lines were keyed `height-index`, which looked unique and was not, and React given duplicate keys renders duplicates *and* stops honouring the list's own length cap — twelve lines had become eighteen on screen. Lines are keyed on a monotonic counter now, so a reorg re-recording a height cannot reproduce it. Neither half was caught by the build, the lint, or the tests. Both were obvious within a minute of watching the live page. Refs #17 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 15 +++++++++++++++ crates/blackbeard-api/src/ingest.rs | 28 +++++++++++++++++++++++++--- web/src/components/WormholeStage.tsx | 16 +++++++++++++--- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 83cacdc..7e83d09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -785,6 +785,21 @@ One mark stands for many and the caption always says how many, printed whether or not a block has arrived yet: forty-five turning marks with nothing explaining them is worse than no marks at all. +**Only the live pass may broadcast, and `Pass` is what makes a caller say so.** +`index_events` has two callers — `record`, for a block that just arrived, and +`backfill`, walking history. The wormhole pulse was broadcast from inside it, so +the backfill re-announced every block it re-read; and because the backfill walks +*towards* the tip, the blocks it re-announced were the ones a viewer had just +watched. The console listed the same block three times. This is the live/backfill +confusion this file already warns about twice, arriving by a third road. + +It was worse than a repeat, because the duplicates collided on their React keys +(`height-index` looked unique and was not). **React given duplicate keys renders +duplicates and stops honouring the list's own length cap** — twelve lines became +eighteen. The key is a monotonic counter now, so a reorg re-recording a height +cannot do the same thing. Nothing in the build caught either half: it shipped, +and only watching the live page for a minute showed it. + **A mark is not self-explanatory, so every mark gets a line.** A dot travelling inward could be anybody paying anybody; without text beside it the stage is an ornament. `ServerMessage::Wormhole` therefore carries a *sample* of the diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index b4e6f47..5791b3c 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -689,7 +689,7 @@ async fn record( None, ); - let _ = index_events(chain, store, height, &hash).await; + let _ = index_events(chain, store, height, &hash, Pass::Live).await; // Queue the attribution join. The feed needs a few seconds to collect // reports from around the network before "who was first" means anything. @@ -928,11 +928,25 @@ enum Indexed { /// Best effort by design: a block whose events cannot be read costs the site /// that block's rewards and nothing else. It must never stop ingest, because /// the leaderboard is built from headers and does not depend on this at all. +/// Which pass is reading a block's events. +/// +/// An enum rather than a `bool` for the reason `Interval` is one: a caller +/// cannot forget to say which kind it has. Only [`Pass::Live`] may broadcast — +/// see `broadcast_wormhole_pulse`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Pass { + /// A block that just arrived, being seen for the first time. + Live, + /// History being walked. These blocks are old news to anyone watching. + Backfill, +} + async fn index_events( chain: &Arc, store: &Store, height: u64, hash: &str, + pass: Pass, ) -> Indexed { let blob = match chain.rpc.events_at(hash).await { Ok(Some(b)) => b, @@ -1014,7 +1028,9 @@ async fn index_events( std::collections::HashSet::new() }; - broadcast_wormhole_pulse(chain, height, &decoded, &exits); + if pass == Pass::Live { + broadcast_wormhole_pulse(chain, height, &decoded, &exits); + } let keep: Vec = decoded .into_iter() @@ -1047,6 +1063,12 @@ async fn index_events( /// Tell the page what this block did to the wormhole, if it did anything. /// +/// **Only ever called for a block seen live.** The backfill reads events too, +/// and it walks *towards* the tip — so broadcasting from there re-announced +/// blocks a viewer had just watched, and the console listed the same block +/// three times while the duplicate React keys quietly broke its line cap. That +/// shipped. `Pass` exists so the next caller has to say which it is. +/// /// Direction is a fact about the producing extrinsic, exactly as it is in /// `exit_cohorts` and the daily flow query — a settled batch releases value, /// anything else commits it. The recorded sender cannot say: it is the same @@ -1300,7 +1322,7 @@ async fn backfill(chain: Arc, store: Store) { break; } }; - match index_events(&chain, &store, height, &hash).await { + match index_events(&chain, &store, height, &hash, Pass::Backfill).await { Indexed::Ok(n) => kept += n, // Permanently unreadable: claim it, or the scan stops here for // good against a block that will never decode. diff --git a/web/src/components/WormholeStage.tsx b/web/src/components/WormholeStage.tsx index c08e10f..aa493c1 100644 --- a/web/src/components/WormholeStage.tsx +++ b/web/src/components/WormholeStage.tsx @@ -103,8 +103,16 @@ const KINDS: Record = { } interface ConsoleLine { - /** Unique per line, so React is not asked to key on a block that has several. */ - id: string + /** + * Unique per line, from a counter rather than from the block. + * + * Keying on `height-index` looked unique and was not: the same height can + * arrive twice — a reorg re-records it, and for one deploy the backfill + * re-announced it — and React given duplicate keys renders duplicates *and* + * stops honouring the list's own length cap. The console grew past twelve + * lines showing one block three times. A counter cannot collide. + */ + id: number height: number kind: string to: string @@ -186,6 +194,8 @@ export function WormholeStage({ const [outstanding, setOutstanding] = useState(null) /** Newest first, the way the block ticker reads. Oldest falls off the end. */ const [log, setLog] = useState([]) + /** Monotonic line id. See `ConsoleLine.id` for why this is not derived. */ + const nextLineId = useRef(0) useEffect(() => { if (chain === null) return @@ -380,7 +390,7 @@ export function WormholeStage({ setLog((previous) => [ ...shown.map((n, i) => ({ - id: `${pulse.height}-${i}`, + id: nextLineId.current++, height: pulse.height, kind: n.kind, to: n.to,