fix: the backfill was re-announcing blocks the console had already shown
deploy / build (push) Successful in 8m18s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 14:12:41 +03:00
co-authored by Claude Opus 5
parent ad22866d3c
commit 578a54ef87
3 changed files with 53 additions and 6 deletions
+15
View File
@@ -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
+25 -3
View File
@@ -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<ChainRuntime>,
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<blackbeard_data::store::EventRecord> = 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<ChainRuntime>, 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.
+13 -3
View File
@@ -103,8 +103,16 @@ const KINDS: Record<string, { glyph: string; label: string }> = {
}
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<number | null>(null)
/** Newest first, the way the block ticker reads. Oldest falls off the end. */
const [log, setLog] = useState<ConsoleLine[]>([])
/** 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,