Skip to content

fix(sql): anchor the best-chain walk on the highest non-orphaned block - #229

Merged
SanabriaRusso merged 1 commit into
mainfrom
fix/best-chain-anchor-after-hard-fork
Sep 3, 2026
Merged

fix(sql): anchor the best-chain walk on the highest non-orphaned block#229
SanabriaRusso merged 1 commit into
mainfrom
fix/best-chain-anchor-after-hard-fork

Conversation

@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Summary

Every "walk back from the tip" recursive CTE anchored on the table's global MAX(height). After a hard fork the abandoned chain's blocks stay in the archive above the new chain's tip, marked orphaned, so that anchor lands on a dead block:

  • blocks-service.ts guards the anchor with chain_status = 'pending', so it matched nothing and inBestChain: true silently degraded to canonical-only rows.
  • events-actions/queries.ts (events, actions, action-state boundary, zkapps-with-pending-events) has no guard, so the walk followed the dead chain.

The anchor is now (SELECT MAX(height) FROM blocks WHERE chain_status <> 'orphaned'), one constant in src/db/sql/best-chain.ts used by both query styles. It agrees with networkState.pendingMaxBlockHeight (which already computes its maximum per chain_status) and is a no-op in normal operation, where the highest block is always the pending tip.

What it looked like on mainnet (Mesa upgrade, 2026-09-03)

The old chain reached height 548187 before stopping at 14:45Z; the fork block is 548147 (18:00Z); the new tip was 548164 an hour in.

networkState                         → canonical 548147, pending 548164   (correct)
blocks(inBestChain: true, ≥ 548148)  → []                                 (55 rows exist unfiltered)

Downstream, mina-explorer-api's indexer discovers blocks only through that filter, so it ingested no Mesa-era block while its cursor read caught-up, and the explorer's recent-blocks list on mainnet showed the abandoned chain (o1-labs/mina-explorer-api companion PR handles that side).

Tests

  • Integration (tests/integration/integration.test.ts, "Hard-fork shape"): plants an orphaned block one height above the synthetic pending tip and asserts inBestChain: true still reaches the tip, agrees with networkState, and reports the orphan under inBestChain: false. Fails on main.
  • Unit (tests/unit/best-chain-anchor.test.ts): renders every events/actions query through a recording stand-in for the postgres client and pins that none anchors on the global maximum. No DB needed.
  • npm run lint, npm run test:unit (15 files, 0 failures), npm run test:integration (36 + 29 + 1 pass, 0 fail) — all green locally against Postgres 13.

Rollout

Mainnet's archive-node-api needs a release and an image bump in gitops-infrastructure for the fix to take effect. Until then the symptom clears on its own only once the new chain outgrows the old one (548187), and only for blocks; the events/actions walk stays on the dead chain until the archive daemon relabels every old-chain block orphaned.

A runbook row was added under Common incidents for the relabelling window.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SHHxXVk9RcCLHj7yM69m8J

@SanabriaRusso SanabriaRusso self-assigned this Sep 3, 2026
@SanabriaRusso SanabriaRusso added the bug Something isn't working label Sep 3, 2026
Every "walk back from the tip" recursive CTE anchored on the table's global
MAX(height). After a hard fork the abandoned chain's blocks stay in the archive
ABOVE the new chain's tip, marked orphaned, so that anchor lands on a dead
block. With the chain_status = 'pending' guard (blocks-service) it matched
nothing and inBestChain: true silently degraded to canonical-only rows; without
the guard (events, actions, action-state resolution, zkapps-with-pending-events)
the walk followed the dead chain.

Seen on mainnet at the Mesa upgrade, 2026-09-03: the old chain reached 548187
before stopping, the fork block was 548147, and for the whole first hour
blocks(inBestChain: true) returned nothing above 548147 while networkState
correctly reported a pending tip of 548164. The two disagreed because only
networkState computes its maximum per chain_status. Downstream, the
mina-explorer-api indexer discovers blocks only through that filter, so the
explorer's recent-blocks list on mainnet showed the abandoned chain.

The anchor is now (SELECT MAX(height) FROM blocks WHERE chain_status <>
'orphaned') — one constant in src/db/sql/best-chain.ts used by both query
styles — which agrees with networkState.pendingMaxBlockHeight and is a no-op in
normal operation, where the highest block is always the pending tip.

Tests: an integration case plants the hard-fork shape (an orphaned block one
height above the synthetic pending tip) and asserts inBestChain still reaches
the tip, agrees with networkState, and reports the orphan under
inBestChain: false; a unit test renders every events/actions query through a
recording client and pins that none anchors on the global maximum.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHHxXVk9RcCLHj7yM69m8J
dkijania
dkijania previously approved these changes Sep 3, 2026
@SanabriaRusso
SanabriaRusso merged commit a30e904 into main Sep 3, 2026
8 checks passed
@SanabriaRusso
SanabriaRusso deleted the fix/best-chain-anchor-after-hard-fork branch September 3, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants