stats: measure the span between the first and last block, not the oldest and newest timestamp

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]>
This commit is contained in:
2026-08-08 08:19:05 -07:00
co-authored by Hanzo Dev
parent 16a96ed0a3
commit ae873d5974
2 changed files with 51 additions and 27 deletions
+17 -9
View File
@@ -145,17 +145,25 @@ func (s *StatsService) count(db *sql.DB, table string) int64 {
// derived from the full timestamp span so it stays meaningful on a sparse,
// demand-driven chain (per-window deltas collapse to zero when idle).
//
// Only blocks that carry a time are counted. A single row whose timestamp
// parses to epoch 0 anchors the span at 1970 and swallows the whole index:
// Hanzo has one, and it turned a chain producing a block every 11 seconds into
// a reported 58,370 — 30,602 blocks over an apparent 56 years. Zoo has one
// too, reading 2,389 for the same 11-second cadence. Excluding them is not a
// threshold, it is a definition: a block with no time cannot bound a span, and
// counting it invents the interval it appears to measure.
// The span is measured between the FIRST and LAST block by number, not by
// MIN and MAX of the timestamp column. Timestamps are monotonic in block order
// by consensus rule, so the two agree on a sound index — and disagree loudly
// on a damaged one, where MIN/MAX hands a single stray row the whole history.
// Hanzo carries a block that parses to epoch 0: MIN/MAX anchored the span at
// 1970 and reported 58,370 seconds per block on a chain that builds one every
// ten. Zoo carries one dated 1.76 years before its own first block, and read
// 2,387. Both are one row out of tens of thousands, and neither is the
// interval between blocks.
//
// Blocks with no time at all are skipped: a block that does not say when it
// happened cannot bound how far apart blocks are.
func (s *StatsService) avgBlockTimeSec(db *sql.DB) float64 {
var count, minTS, maxTS int64
err := db.QueryRow(`SELECT COUNT(*), COALESCE(MIN(`+blockEpoch+`),0), COALESCE(MAX(`+blockEpoch+`),0)
FROM evm_blocks b WHERE `+blockEpoch+` > 0`).Scan(&count, &minTS, &maxTS)
err := db.QueryRow(`SELECT
(SELECT COUNT(*) FROM evm_blocks b WHERE `+blockEpoch+` > 0),
COALESCE((SELECT `+blockEpoch+` FROM evm_blocks b WHERE `+blockEpoch+` > 0 ORDER BY number ASC LIMIT 1), 0),
COALESCE((SELECT `+blockEpoch+` FROM evm_blocks b WHERE `+blockEpoch+` > 0 ORDER BY number DESC LIMIT 1), 0)`).
Scan(&count, &minTS, &maxTS)
if err != nil {
// Say WHY. Swallowing this is how the tile read "0.0 s" on a chain with
// 1.1M blocks: the scan failed on every request and the zero value went
+34 -18
View File
@@ -287,24 +287,40 @@ func TestPagesTransactions24h(t *testing.T) {
}
}
// One block with no time must not become the whole chain's history. Hanzo has
// exactly this row: it anchored the span at 1970 and reported 58,370 seconds
// per block on a chain building one every 11.
func TestAverageBlockTimeIgnoresATimelessBlock(t *testing.T) {
dir := t.TempDir()
writeIndexerDB(t, dir, "cchain", 5) // five blocks, one minute apart
db, err := sql.Open("sqlite3", filepath.Join(dir, "cchain", "query", "indexer.db"))
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO evm_blocks(id,number,timestamp,tx_count) VALUES('0xz',999,?,0)`,
time.Unix(0, 0).UTC()); err != nil {
t.Fatal(err)
}
db.Close()
// One stray row must not become the whole chain's history. Both of these are
// real: Hanzo carries a block that parses to epoch 0 and reported 58,370
// seconds per block; Zoo carries one dated well before its own first block and
// reported 2,387. The chain builds one every ten seconds in both cases.
func TestAverageBlockTimeSurvivesAStrayRow(t *testing.T) {
for _, tc := range []struct {
name string
stray time.Time
}{
{"a block with no time at all", time.Unix(0, 0).UTC()},
{"a block dated years before the first", time.Now().AddDate(-2, 0, 0)},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
writeIndexerDB(t, dir, "cchain", 5) // five blocks, one minute apart
db, err := sql.Open("sqlite3", filepath.Join(dir, "cchain", "query", "indexer.db"))
if err != nil {
t.Fatal(err)
}
// Numbered in the middle of the run, the way a damaged row sits in a
// real index — not at either end, where it would be the boundary.
if _, err := db.Exec(`INSERT INTO evm_blocks(id,number,timestamp,tx_count) VALUES('0xz',3,?,0)`,
tc.stray); err != nil {
t.Fatal(err)
}
db.Close()
body := get(t, NewStatsService(dir, statsChains).handleStats, "api-explore.lux.network", "/v1/stats")
if got := body["average_block_time"].(float64); got != 60 {
t.Errorf("average_block_time = %v, want 60 — the 1970 row is not a block time", got)
body := get(t, NewStatsService(dir, statsChains).handleStats, "api-explore.lux.network", "/v1/stats")
got := body["average_block_time"].(float64)
// Five blocks a minute apart span four minutes; the stray row adds one
// more block to the count without extending the span.
if got < 40 || got > 60 {
t.Errorf("average_block_time = %v, want the real ~48-60s cadence", got)
}
})
}
}