Files
graph/indexer/blocks.go
T
zeekayandHanzo Dev c6daeaaa34 Stop when the store is whole, not when a window is
v1.2.42 gave the heal a loop so a long history takes as many passes as it needs,
and then read the wrong number to decide it was done: the count of rows in the
current window it had FAILED to date. A window that heals completely has no
failures in it, so the very first success looked like completion. On the Lux
C-Chain that dated 50,000 of 912,492 rows and stopped, leaving 862,492 trades
still sorting to 1970 — better than before by one window, invisible by the same
mechanism.

A pass now reports what it found and what it dated. Nothing found is the only
way out; progress goes straight on to the next window; found-but-none-dated
waits and retries, which is the RPC being unreachable rather than the work being
over.

The test seeds more undated rows than one window holds and drains to zero.
Against v1.2.42 it stops with rows still holding a block number.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 20:28:24 -07:00

217 lines
7.5 KiB
Go

package indexer
// When a swap happened.
//
// eth_getLogs answers WHICH block a log was in and never WHEN it was mined, so
// an indexer that writes only what the log carries has no clock. This one wrote
// the block NUMBER into the swap's `timestamp` — a field the subgraph wire
// defines as a unix second and every consumer reads as one. Trade history dated
// every fill to January 1970, and a day series was not merely empty but
// impossible: there is no day to put a block number in.
//
// So the poll reads the header of each block it saw an event in and carries the
// time onto the log, and healSwapTimes gives the rows an older build already
// wrote the same time from the same source. One notion of when: the chain's.
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// blockTimeBatch is how many headers one JSON-RPC batch asks for. Headers are
// small and the node answers them from its index; the bound is here so a first
// run over a long history is many bounded requests rather than one unbounded
// one.
const blockTimeBatch = 256
// healRetry is how long to wait before re-attempting the one-shot heal. It only
// ever runs again if the RPC was unreachable, so this is a reconnect delay, not
// a poll interval.
const healRetry = 30 * time.Second
// blockTimes reads the mining time of each block, in unix seconds.
//
// Blocks the node did not answer for are ABSENT from the result. A missing time
// must not read as zero: zero is 1970, and a swap dated 1970 would silently
// land in a day bucket half a century away from every other one.
func (idx *Indexer) blockTimes(ctx context.Context, blocks []uint64) map[uint64]int64 {
out := make(map[uint64]int64, len(blocks))
for start := 0; start < len(blocks); start += blockTimeBatch {
end := start + blockTimeBatch
if end > len(blocks) {
end = len(blocks)
}
batch := blocks[start:end]
reqs := make([]rpcBatchReq, 0, len(batch))
for i, b := range batch {
reqs = append(reqs, rpcBatchReq{
JSONRPC: "2.0", ID: i + 1, Method: "eth_getBlockByNumber",
Params: []interface{}{fmt.Sprintf("0x%x", b), false},
})
}
res, err := idx.rpcBatch(ctx, reqs)
if err != nil {
idx.logf("[indexer] block times %d..%d: %v", batch[0], batch[len(batch)-1], err)
continue
}
for i, b := range batch {
raw, ok := res[i+1]
if !ok {
continue
}
var head struct {
Timestamp string `json:"timestamp"`
}
if json.Unmarshal(raw, &head) != nil || head.Timestamp == "" {
continue
}
ts, err := parseHexUint64(head.Timestamp)
if err != nil {
continue
}
out[b] = int64(ts)
}
}
return out
}
// stampTimes gives each log the time of its block, one batch of headers for the
// whole poll.
//
// A block whose header does not answer is reported as an error and the poll
// does not advance past it, exactly as it already declines to advance past a
// failed eth_getLogs. A block is indexed when it has been read WHOLE; half of
// it is not progress, and the alternative — admitting a trade with no time, or
// with a made-up one — puts a wrong candle on a chart that no later pass would
// know to correct.
func (idx *Indexer) stampTimes(ctx context.Context, logs []logEntry) error {
var blocks []uint64
seen := map[uint64]bool{}
for i := range logs {
b, err := parseHexUint64(logs[i].BlockNumber)
if err != nil || seen[b] {
continue
}
seen[b] = true
blocks = append(blocks, b)
}
if len(blocks) == 0 {
return nil
}
times := idx.blockTimes(ctx, blocks)
for i := range logs {
b, err := parseHexUint64(logs[i].BlockNumber)
if err != nil {
continue // a log we cannot place in a block; processLog reads it as block 0
}
ts, ok := times[b]
if !ok {
return fmt.Errorf("no header for block %d", b)
}
logs[i].time = ts
}
return nil
}
// healSwapTimes gives stored swaps the real time of their block, and reports
// how many rows it found needing one and how many it dated.
//
// A row written by an older build holds its block NUMBER in Timestamp and has
// no Block at all. That is not a guess: no swap is mined in block 0, so an
// unset Block identifies exactly those rows, and the number it left behind is
// the very thing needed to look the time up. A row whose supposed block the
// chain does not answer for is left alone — this heals what it can prove.
func (idx *Indexer) healSwapTimes(ctx context.Context) (found, healed int, err error) {
// Asked for by the property that identifies them. This read used to be
// RecentSwapsRaw — the newest rows by timestamp — which is the one window an
// undated row is never in: it holds a block number, block numbers are small
// beside unix seconds, so every row needing repair sorts behind every row
// already correct. Past a full window of dated rows the heal saw nothing
// stale, reported success, and stopped. Trades from a million blocks of
// history stayed dated to 1970 and sorted below everything on every
// newest-first list, which is to say they were indexed and invisible.
stale := idx.store.UndatedSwapsRaw(maxValuedSwaps)
var blocks []uint64
seen := map[uint64]bool{}
for _, sw := range stale {
b := uint64(sw.Timestamp)
if !seen[b] {
seen[b] = true
blocks = append(blocks, b)
}
}
found = len(stale)
if found == 0 {
return 0, 0, nil
}
times := idx.blockTimes(ctx, blocks)
if len(times) == 0 {
return found, 0, fmt.Errorf("no block header answered for %d blocks", len(blocks))
}
for id, sw := range stale {
block := uint64(sw.Timestamp)
ts, ok := times[block]
if !ok {
continue
}
next := *sw
next.Block, next.Timestamp = block, ts
idx.store.SeedSwap(id, &next)
healed++
}
idx.logf("[indexer] swap times healed — %d of %d rows dated from their block header", healed, found)
return found, healed, nil
}
// keepSwapTimes runs the heal until no row is left holding a block number where
// a time belongs, then stops. New swaps are dated as they arrive, so this only
// has work on a store an older build wrote.
//
// One pass covers a bounded window, so a long history takes several. It repeats
// immediately while a pass is still dating rows and waits only when one made no
// progress — a heal that stopped after its first window would leave everything
// past it undated, which is the same invisibility by a smaller margin.
func (idx *Indexer) keepSwapTimes(ctx context.Context) {
for {
found, healed, err := idx.healSwapTimes(ctx)
// Nothing left holding a block number: the store is whole, and new
// swaps arrive already dated. Done — and this is the ONLY way out.
if found == 0 && err == nil {
return
}
if err != nil {
idx.logf("[indexer] swap times: %v (%d rows found undated)", err, found)
}
// A pass covers one window. Progress means there is very likely another
// window behind it, so go straight on. Stopping on "this window has no
// failures left" is stopping when the window is DONE rather than when
// the store is — one window of 50,000 dated and 862,492 left behind.
if healed > 0 && err == nil {
continue
}
select {
case <-ctx.Done():
return
case <-time.After(healRetry):
}
}
}
// dayStart is the UTC midnight at or before ts, in unix seconds — the `date` of
// the day series, matching the subgraph convention that a day is identified by
// its own first second.
func dayStart(ts int64) int64 { return ts - ts%dayLength }
// dayLength is a day in seconds.
const dayLength = 86400
// dayID is the subgraph's identity for one subject's day: the subject's address
// and the day's index since the epoch.
func dayID(subject string, date int64) string {
return fmt.Sprintf("%s-%d", strings.ToLower(subject), date/dayLength)
}