Every chain this process serves shares one disk. Warming them together made
each finish late and made a caller arriving during startup wait behind the
contention rather than behind its own chain's scan. In turn, each finishes as
fast as the disk allows, and the chain the front page asks about is ready
first.
Every number the stats surfaces report is a whole-table aggregate: SQLite
keeps no row count, and both the day bucket and the epoch bound are
expressions over a TEXT datetime, so no index answers them. Computing them
per request put five full scans on the request path, and the handlers share
a two-connection pool, so concurrent callers queued behind each other and
waited long enough for a browser to abandon the fetch and leave the
homepage's headline cards empty.
A chain is append-only, so within a few seconds every caller wants the same
numbers. They are now counted once per chain, held, and refreshed in the
background when they age, so a caller reads them from memory instead of
starting its own scan. The trailing year is computed once and shorter
windows are cut from it. Warm at startup, so the first caller after a start
reads a value rather than waiting for one.
1.5.12 stopped a block with no time from anchoring the span at 1970, which
fixed Hanzo — 58,370 seconds per block became 10.3, its real cadence. It did
nothing for Zoo, which still read 2,387 for a chain building one every twelve:
Zoo's stray row is not timeless, it carries a plausible timestamp dated 1.76
years before Zoo's own first block, and no filter on the VALUE can tell that
from a legitimately old chain.
Nothing about the value distinguishes them. The ORDER does. Timestamps are
monotonic in block order by consensus rule, so the chain's elapsed time is the
last block's time minus the first block's — by number. On a sound index that
is identical to MIN and MAX of the column; on a damaged one it is the answer
and MIN/MAX is not. Zoo's first block is three days old, so three days is what
its span should have been all along.
This subsumes 1.5.12: a stray row only moves the answer now if it is an
endpoint, and a timeless one is still skipped because a block that does not
say when it happened cannot bound how far apart blocks are.
The suite covers both shapes at once, numbered mid-run the way a damaged row
actually sits: epoch 0, and two years early. The old query reports 1.26e7
seconds per block on the second.
Co-authored-by: Hanzo Dev <[email protected]>
1.5.11 made the average block time readable, and it read 58,370 seconds on
Hanzo — 30,602 blocks over an apparent 56 years, on a chain that builds one
every 11 seconds. Zoo read 2,389 for the same 11-second cadence. Lux read
50.3, which is right, so nothing looked systematically broken.
Both have a single row whose timestamp parses to epoch 0. The span is
MAX-MIN, so that one row anchors it at 1970 and the other thirty thousand
contribute nothing: the answer stops being about the chain and becomes a
restatement of the current unix time divided by the block count. 1.786e9 over
30,601 intervals is exactly the number that came out.
The span now counts only blocks that carry a time. That is not a threshold to
tune — a block with no timestamp holds no information about how far apart
blocks are, and including it invents the interval it appears to measure.
Reproduced in the suite: five blocks a minute apart plus one row at
1970-01-01 reports 3.57e8 seconds per block on the old query, 60 on this one.
Co-authored-by: Hanzo Dev <[email protected]>
One binary answers explore.lux.network, explore.hanzo.network and
explore.zoo.network. Only /envs.js and the slug-prefixed /v1/indexer/{slug}
routes knew that. Everything else read whichever chain the config called
default, so Hanzo's home page printed 1,107,383 total blocks above its own
Latest blocks list topping out at 29,805 — Lux's number, on Hanzo's page,
overstating Hanzo's chain 37x and Zoo's 49x. Measured on all three brands.
Config.Serves(host) is now the one thing that answers "which chain is this
request about", and the three surfaces that were guessing ask it:
* StatsService keeps a read handle per chain instead of one pinned at
startup, so /v1/counters, /v1/lines, /v1/pages/* and /v1/stats report the
host's chain: 1107259 / 29805 / 22728 on the three brand hosts, where all
three read 1107259 before.
* The realtime stream carries the host's chain. It carried all of them: a
45s read of /v1/base/realtime returned the same 6 cchain + 14 hanzo + 5 zoo
events on every brand host, and the SPA appends what it is sent, so a
50-block page rendered 63 rows with 2 Lux on top and 8 Zoo at the bottom.
?chain= still overrides; an empty filter no longer means "all chains",
because no brand host wants another brand's blocks.
* A slug-less /v1/indexer/* request follows the host, falling back to the
default only when the host names no chain (api-explore.lux.network) or
names one that is not up.
Also here, because they were all the same mistake about the same column: the
timestamp in evm_blocks is a Go time.Time bound into a column declared
TIMESTAMP, so SQLite stores a datetime STRING, and everything that read it as
a unix epoch got nothing and said nothing.
* /stats' daily charts were empty on all three brands. strftime with the
'unixepoch' modifier returns NULL on a datetime string, the scan failed,
and handlePagesMain discarded the error into `daily, _ :=` — a nil slice
marshals to `null`, which the page renders as "No data". Now 22 points.
* Average block time read 0.0s on a chain producing a block every 10s: the
three-value Scan failed and its error was never checked, so the zero went
out as the answer. Now 60.0 on a one-block-per-minute fixture.
* /v1/blocks reported `"timestamp": 2026` for every block — CAST of a
datetime string takes its leading digits, and 2026 looks like a number
rather than a failure. Now a real epoch.
* Transactions (24h) was the lifetime total. SQLite orders every INTEGER
below every TEXT, so `timestamp > <epoch>` was true for all 1.1M rows.
blockEpoch and blockDay are the only two spellings allowed to touch that
column now. The fixtures were the reason none of this failed a test: they
declared the column TIMESTAMP and then inserted integers into it, so the suite
exercised a database the indexer never writes. They bind a time.Time now, and
on the old queries they fail with exactly what production showed —
average_block_time "0.0", empty charts, timestamp 2026.
Deleted: the eight per-channel SSE routes at bare /blocks, /transactions,
/tokens, /gas-tracker, /stats, /validators, /token-transfers and /internal-txs.
Every one of those is also an SPA page path, and one mux cannot serve both; the
SPA opens only the multiplexed stream, so they had no subscriber to lose. With
them goes the second envelope, the per-channel framing built on every
broadcast, and the client's channel field — SSE has one endpoint and one shape.
indexer v1.5.3 for the block transaction-count name.
Co-authored-by: Hanzo Dev <[email protected]>
envs.js was emitting ChainConfig.RPC verbatim, so every browser loading
explore.lux.network was told the default chain lives at
http://luxd-headless.lux-mainnet.svc.cluster.local:9630/v1/bc/C/rpc
which no browser can resolve. One field was serving two consumers with
opposite requirements: the indexer must dial the service-mesh name, the
SPA must get a public URL. Split them — RPC stays the dial address,
public_rpc is what the SPA is told, and browserRPC() falls back to RPC
only when it is actually reachable from outside the cluster. Emitting ""
beats emitting a URL that always fails: the SPA can hide the control
instead of offering one that silently errors.
Also adds the canonical /v1/stats and /v1/blocks. Everything under /v1,
nothing under /api — TestStatsRoutesAreV1Only pins that /api/v2 is not
mounted and never becomes so by accident.
/v1/blocks CASTs timestamp to INTEGER because the real schema declares it
TIMESTAMP and go-sqlite3 turns declared TIMESTAMP columns into time.Time,
which will not Scan into int64 — without the cast every row is skipped and
a fully populated index answers an empty list. That failure was invisible
because the handler swallowed the error; it now logs and reports it, and
the bound test asserts rows came back rather than just `<= 200`, which
passes trivially on an empty result.
Co-authored-by: Hanzo Dev <[email protected]>