Files
zeekayandClaude Opus 5.5 1c2a90f9f0 Header batches fit through a public RPC door
A batch of 256 headers is refused whole by a door that caps batches at
100, and the refusal is one error object where the array was, so every
block-times read failed and the AMM subgraph never advanced. 100 is the
cap such doors enforce, ours included.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016dDw5Lp9sZFctwvZLn4nVz
2026-09-22 23:20:23 -07:00

218 lines
7.7 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. 100 is the batch cap a public JSON-RPC door enforces — ours included — and
// a larger batch is refused whole, with one error object where the array was.
const blockTimeBatch = 100
// 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)
}