Explorer: a schema-gap read answers 503 with the reason, the supply tile names the cap and what is ever minted (reviewer C13), the plan records the live restart

Until an observer has started on new code, a read of a column it adds fails with Postgres 42703; the three public
handlers now answer 503 "the observer has not been restarted on the explorer code yet" instead of 500, and the pages
show it. The Circulating tile reads "cap 4,000,000,000, 3.96 billion ever minted" with the withheld figure on hover;
the homepage tile and the litepaper keep the project lead's words (consequences-decisions D3, D7). The live observer was restarted
on this code at 19:42:50 UTC by another session; its counters are in the plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 20:29:45 +00:00
parent e0fce822b6
commit 894fe08f34
6 changed files with 44 additions and 9 deletions

View file

@ -118,10 +118,29 @@ site. None of this is in the repo; the stats API of this branch is the network s
- Vercel's `cleanUrls` with a rewrite destination of `/block` and `/address` (the clean names of `block.html` and
`address.html`): checked locally through `tools/site-serve.mjs`, not on a Vercel preview, because nothing was pushed.
- `/api/stats` and `/api/supply` on the live tables: the live observer has not been restarted on this code, so the
live `live_blocks` has no `number`, `tx_count` or `detail` columns yet; the handlers answer with nulls there until
the restart (the schema adds itself on start, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`). The examples in
`docs/api/public-stats.md` come from the test observer's tables.
- The live tables until the observer restarts on this code: `live_blocks` has no `number`, `tx_count`, `detail` and
`live_state` no `rpc_load` or `supply_check`, so a read of them fails with Postgres 42703. `/api/stats` and
`/api/explorer` answer 503 with the reason ("the observer has not been restarted on the explorer code yet") and the
pages show it; `/api/supply` reads only old columns and answers in full (circulating is non-zero from the first
deploy). The restart that closes the gap: `tools/observer/autosync.sh` fetches origin/master every 5 min,
fast-forwards the shared checkout and restarts the observer because `observer.mjs` changed; its start runs
`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`. So after the project lead pushes master the window is the Vercel deploy (about a
minute) to the next autosync pass (up to 5 min), and no hand restart is needed, provided the shared checkout is
clean under `tools/observer` and `site/api` (autosync's own rule) and the fast-forward brings `site/lib/emission.mjs`
and `site/lib/eth.mjs`, which the observer now imports. Reviewer C13 (`docs/plans/consequences-2026-10-05.md`)
asked for the footer link to wait for that restart; taken instead as the 503 reason above, so the link is live from
the first deploy and a visitor in the window reads why the table is empty rather than nothing. Overtaken while this
was written: the four commits of this branch were merged to master by another session and the live observer was
restarted on them at 19:42:50 UTC on 5 October 2026 (`live_state.observer_started_at`; a first start at 19:39:50),
with `IGNEUM_EVM_RPC` now at `http://127.0.0.1:26790`. Its own counters since: wRPC 186 to 420 calls per minute
(252 average over five minutes), EVM 2,446 to 2,492 (the proving feed, which was unreachable at 26800 before and
so counted zero), `supply_check` at 19:43:11 UTC over the first explorer rows. The live tables carry the explorer
columns and `detail` from 19:39:51 UTC, so the 503 path above is the cold-start path for the next schema change,
not tonight's.
- The supply tile reads "cap 4,000,000,000, 3.96 billion ever minted" with the withheld figure on hover (C13); the
homepage tile's "4B IGN hard cap, ever" and the litepaper's "approached and never reached" are the project lead's words
(consequences-decisions D3, D7) and are not changed here. The examples in `docs/api/public-stats.md` come from the
test observer's tables.
- Blockscout's licence terms for a hosted instance, and the exact env flag that disables its trace fetcher.
- The database size per Igneum block in Blockscout, and the Hetzner price of the box: approximate above, measure and
price when ordered.

View file

@ -24,3 +24,12 @@ export function chainIdOf(network) {
if (n.includes('simnet')) return null;
return 4461;
}
/**
* The explorer columns (number, tx_count, detail, ...) and live_state.rpc_load exist once the observer has started on the
* explorer code (ALTER TABLE ... ADD COLUMN IF NOT EXISTS at its start). Until then a read of them fails with Postgres
* 42703 "column ... does not exist"; that is a 503 with a plain reason, not a 500, and the pages show the reason.
*/
export function schemaGap(e) {
return /column .* does not exist/i.test(String(e && e.message || e)) ? 'The observer has not been restarted on the explorer code yet (its restart adds the columns this endpoint reads). Try again in a few minutes.' : null;
}

View file

@ -7,7 +7,7 @@
// balance through eth_getBalance when EXPLORER_EVM_RPC names a reachable EVM JSON-RPC
// ?search=<q> routes a hash, a height or an address to its page, or says what it is not
// Everything is read from what tools/observer wrote to Neon; the only live call is the balance. Cached 5 s.
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
import { neon, num, tablePrefix, chainIdOf, schemaGap } from './_neon.mjs';
import { classify } from '../lib/explorer.mjs';
const STALE_AFTER_S = 30;
@ -145,6 +145,8 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) {
return res.status(400).json({ ...base, ok: false, error: 'blocks, block, height, address or search' });
} catch (e) {
res.setHeader('Cache-Control', 'no-store');
const gap = schemaGap(e);
if (gap) return res.status(503).json({ ok: false, error: gap });
return res.status(500).json({ ok: false, error: String(e.message || e) });
}
};

View file

@ -3,7 +3,7 @@
// Read from what tools/observer wrote to Neon (live_state, newest live_blocks row); the reward is computed from the
// emission rule (site/lib/emission.mjs, spec 2.5) at the node's DAA score. Cached 10 s at the edge. Documented with
// example responses in docs/api/public-stats.md; FIELDS below is the contract the CI check asserts.
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
import { neon, num, tablePrefix, chainIdOf, schemaGap } from './_neon.mjs';
import { blockSubsidy, rampFactor, HALVING_INTERVAL_SECONDS, LAUNCH_RAMP_SECONDS, PROVING_POOL_SHARE_PERCENT, sompiToIgn } from '../lib/emission.mjs';
export const STALE_AFTER_S = 30;
@ -83,6 +83,8 @@ export function createHandler({ env = process.env, sql } = {}) {
return res.status(200).json(shape({ now, state: s, last, miners10m: minerRows[0] ? minerRows[0].n : 0 }));
} catch (e) {
res.setHeader('Cache-Control', 'no-store');
const gap = schemaGap(e);
if (gap) return res.status(503).json({ ok: false, error: gap });
return res.status(500).json({ ok: false, error: String(e.message || e) });
}
};

View file

@ -2,7 +2,7 @@
// halving schedule as a table and the 30-day launch ramp (docs/spec/02-consensus.md 2.5, consensus/core/src/igneum.rs),
// plus the observer's hourly check of the chain's coinbase sums against that rule (live_state.supply_check). Cached
// 10 s at the edge. Documented in docs/api/public-stats.md.
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
import { neon, num, tablePrefix, chainIdOf, schemaGap } from './_neon.mjs';
import * as E from '../lib/emission.mjs';
export const FIELDS = ['ok', 'now', 'network', 'chain_id', 'unit', 'daa', 'max_supply_ign', 'circulating_ign', 'circulating_sompi', 'minted_at_end_ign', 'never_minted_ign',
@ -69,6 +69,8 @@ export function createHandler({ env = process.env, sql } = {}) {
return res.status(200).json(shape({ now, state: rows[0].state || null, daa: num(rows[0].daa) }));
} catch (e) {
res.setHeader('Cache-Control', 'no-store');
const gap = schemaGap(e);
if (gap) return res.status(503).json({ ok: false, error: gap });
return res.status(500).json({ ok: false, error: String(e.message || e) });
}
};

View file

@ -214,7 +214,7 @@ main{padding-bottom:var(--sec)}
<div class="cell"><div class="k">Difficulty</div><div class="v" id="st-diff">0</div><div class="s" id="st-bt">target per block</div></div>
<div class="cell"><div class="k">Hash rate</div><div class="v" id="st-hps">0</div><div class="s">network estimate</div></div>
<div class="cell"><div class="k">Block reward</div><div class="v" id="st-reward">0<small>IGN</small></div><div class="s" id="st-split">80% miner, 20% proving pool</div></div>
<div class="cell"><div class="k">Circulating</div><div class="v" id="st-supply">0<small>IGN</small></div><div class="s" id="st-cap">of 4,000,000,000 by the rule</div></div>
<div class="cell"><div class="k">Circulating</div><div class="v" id="st-supply">0<small>IGN</small></div><div class="s" id="st-cap" title="The 30-day launch ramp withholds about 37,000,000 IGN and integer floors a little more; neither is ever minted (spec 2.5).">cap 4,000,000,000, about 3.96 billion ever minted</div></div>
<div class="cell"><div class="k">Next halving</div><div class="v" id="st-halving">0<small>d</small></div><div class="s" id="st-ramp">ramp</div></div>
</div>
<div class="card" aria-label="Latest blocks">
@ -332,7 +332,8 @@ async function loadStats() {
if (s.block_reward) { $('st-reward').innerHTML = esc(Number(s.block_reward.ign).toFixed(4)) + '<small>IGN</small>'; $('st-split').textContent = `${Number(s.block_reward.miner_ign).toFixed(4)} miner, ${Number(s.block_reward.proving_pool_ign).toFixed(4)} proving pool`; }
if (p.ok) {
$('st-supply').innerHTML = esc(int(Number(p.circulating_ign))) + '<small>IGN</small>';
$('st-cap').textContent = `of ${int(Number(p.max_supply_ign))}, ${(Number(p.circulating_ign) / Number(p.max_supply_ign) * 100).toFixed(3)}% by the rule`;
$('st-cap').textContent = `cap ${int(Number(p.max_supply_ign))}, ${(Number(p.minted_at_end_ign) / 1e9).toFixed(2)} billion ever minted, ${(Number(p.circulating_ign) / Number(p.minted_at_end_ign) * 100).toFixed(3)}% so far`;
$('st-cap').title = `The 30-day launch ramp withholds ${int(Number(p.never_minted_ign))} IGN of the cap (about 37,000,000 by the ramp, the rest by integer floors); never minted (spec 2.5).`;
const d = p.halving.next_halving_in_s / 86400;
$('st-halving').innerHTML = esc(d >= 1 ? Math.floor(d).toLocaleString('en-GB') : (p.halving.next_halving_in_s / 3600).toFixed(1)) + '<small>' + (d >= 1 ? 'days' : 'h') + '</small>';
$('st-ramp').textContent = p.ramp.complete ? 'launch ramp complete' : `launch ramp at ${(p.ramp.factor_now * 100).toFixed(1)}%, full at DAA ${int(p.ramp.length_daa_s)}`;