fix: the backfill was re-announcing blocks the console had already shown
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user