perf(indexer): bound the swap history one valuation pass reads

The previous commit stopped the pass WRITING a row per swap. It still READ
every swap row in the table, every 30 seconds, into a map.

That is the same shape of mistake one layer down. A live chain's swap table
grows without limit — the Lux C-Chain's already spans blocks 53..1,096,496 in
a 732 MB store — so the cost of a pass grows with chain age forever, and the
allocation churn lands on a process sharing 4Gi with six other chains.

storage.SwapsRaw is replaced by RecentSwapsRaw(limit), which uses the indexed
timestamp column to take the newest `limit` rows in SQL rather than filtering
in Go. The pass takes the newest 50,000.

Where that bound binds, the volume figure is the volume of those 50,000 trades
and the log line says so outright. A partial sum silently presented as a total
is worse than a smaller number that is honest about its window.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-26 18:42:27 -07:00
co-authored by Hanzo Dev
parent 01aa717953
commit 84a4bdf91e
4 changed files with 49 additions and 18 deletions
+22 -8
View File
@@ -55,6 +55,15 @@ const valuationInterval = 30 * time.Second
// maxValuedPools caps one pass. Far above any real book; a guard, not a policy.
const maxValuedPools = 5000
// maxValuedSwaps bounds the swap history one pass summarises, newest first.
//
// This one IS a policy, so it is stated rather than buried: a chain's swap
// table grows forever, and a pass that rescans all of it does more work every
// day the chain lives. Beyond this many trades the reported volume is the
// volume of the most recent maxValuedSwaps of them, and the pass says so in its
// log rather than presenting a partial sum as a total.
const maxValuedSwaps = 50000
// selBalanceOf is `balanceOf(address)`.
const selBalanceOf = "0x70a08231"
@@ -150,7 +159,7 @@ func (idx *Indexer) revalue(parent context.Context) {
// ── Pools: TVL, spot prices, volume ────────────────────────────────
tokenTVL := map[string]float64{}
tokenVol := map[string]float64{}
poolVol := idx.valueSwaps(vps, tokens, prices, tokenVol)
poolVol, swapsSeen := idx.valueSwaps(vps, tokens, prices, tokenVol)
var totalTVL, totalVol float64
for _, vp := range vps {
@@ -225,9 +234,13 @@ func (idx *Indexer) revalue(parent context.Context) {
TotalVolumeUSD: fmtUSD(totalVol),
})
idx.logf("[valuation] %d/%d pools valued, %d tokens priced — TVL $%s, volume $%s (%s)",
len(vps), len(pools), len(prices), fmtUSD(totalTVL), fmtUSD(totalVol),
time.Since(started).Round(time.Millisecond))
window := ""
if swapsSeen >= maxValuedSwaps {
window = fmt.Sprintf(" [volume covers the most recent %d swaps only]", maxValuedSwaps)
}
idx.logf("[valuation] %d/%d pools valued, %d tokens priced, %d swaps — TVL $%s, volume $%s (%s)%s",
len(vps), len(pools), len(prices), swapsSeen, fmtUSD(totalTVL), fmtUSD(totalVol),
time.Since(started).Round(time.Millisecond), window)
}
// readBalances asks each pool's two tokens what the pool holds. One eth_call
@@ -344,14 +357,14 @@ func priceTokens(vps []valuedPool, tokens map[string]*storage.SeedTokenData) map
// valueSwaps prices the stored swap history and returns per-pool volume,
// accumulating per-token volume into tokenVol.
//
// It READS the swap rows and writes nothing. An earlier cut also wrote each
// It READS a bounded window of the newest swap rows and writes nothing. An earlier cut also wrote each
// swap's own amountUSD back, which turned one pass into O(swaps) serialized
// INSERT OR REPLACEs every interval — on a chain with a real trade history the
// pass never finished, so its chain silently produced no aggregates at all
// while a quiet chain beside it looked fine. Volume is a per-pool rollup; it
// belongs on the pool. (Per-swap amountUSD remains "0" — a separate gap, and
// one that wants the price at the swap's own block, not today's.)
func (idx *Indexer) valueSwaps(vps []valuedPool, tokens map[string]*storage.SeedTokenData, prices map[string]float64, tokenVol map[string]float64) map[string]float64 {
func (idx *Indexer) valueSwaps(vps []valuedPool, tokens map[string]*storage.SeedTokenData, prices map[string]float64, tokenVol map[string]float64) (map[string]float64, int) {
byPool := map[string]*valuedPool{}
for i := range vps {
byPool[vps[i].id] = &vps[i]
@@ -360,7 +373,8 @@ func (idx *Indexer) valueSwaps(vps []valuedPool, tokens map[string]*storage.Seed
for _, vp := range vps {
out[vp.id] = 0
}
for _, sw := range idx.store.SwapsRaw() {
swaps := idx.store.RecentSwapsRaw(maxValuedSwaps)
for _, sw := range swaps {
vp := byPool[strings.ToLower(sw.Pool)]
if vp == nil {
continue
@@ -384,7 +398,7 @@ func (idx *Indexer) valueSwaps(vps []valuedPool, tokens map[string]*storage.Seed
tokenVol[vp.t1] += v1
}
}
return out
return out, len(swaps)
}
// twoPow255 is the sign boundary of a 256-bit two's-complement word.
+2 -2
View File
@@ -260,7 +260,7 @@ func TestHandleSwapV3_DecodesSignedAmounts(t *testing.T) {
// amount0 = +1000 in, amount1 = -2500 out.
idx.processLog(context.Background(), swapV3Log(pool, token0, "0xreal", 1000, -2500))
raw := s.SwapsRaw()
raw := s.RecentSwapsRaw(1000)
if len(raw) != 1 {
t.Fatalf("expected exactly one swap, got %d", len(raw))
}
@@ -304,7 +304,7 @@ func TestRevalue_DoesNotRewriteSwapRows(t *testing.T) {
t.Errorf("pool volumeUSD = %q, want 12.51", got)
}
// The swap row itself is untouched — valuing must not write O(swaps) rows.
for _, sw := range s.SwapsRaw() {
for _, sw := range s.RecentSwapsRaw(1000) {
if sw.AmountUSD != "0" {
t.Errorf("valuation must not rewrite swap rows; amountUSD became %q", sw.AmountUSD)
}
+15 -5
View File
@@ -4,6 +4,7 @@ package storage
import (
"context"
"sort"
"sync"
)
@@ -390,13 +391,22 @@ func (s *Store) PoolsRaw() map[string]*SeedPoolData {
return out
}
// SwapsRaw returns every stored swap keyed by id.
func (s *Store) SwapsRaw() map[string]*SeedSwapData {
// RecentSwapsRaw returns the `limit` most recent stored swaps keyed by id.
// See the sqlite implementation for why the bound exists.
func (s *Store) RecentSwapsRaw(limit int) map[string]*SeedSwapData {
s.mu.RLock()
defer s.mu.RUnlock()
out := make(map[string]*SeedSwapData, len(s.swaps))
for id, sw := range s.swaps {
c := *sw
ids := make([]string, 0, len(s.swaps))
for id := range s.swaps {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return s.swaps[ids[i]].Timestamp > s.swaps[ids[j]].Timestamp })
if len(ids) > limit {
ids = ids[:limit]
}
out := make(map[string]*SeedSwapData, len(ids))
for _, id := range ids {
c := *s.swaps[id]
out[id] = &c
}
return out
+10 -3
View File
@@ -592,12 +592,19 @@ func (s *Store) PoolsRaw() map[string]*SeedPoolData {
return out
}
// SwapsRaw returns every stored swap keyed by id.
func (s *Store) SwapsRaw() map[string]*SeedSwapData {
// RecentSwapsRaw returns the `limit` most recent stored swaps keyed by id,
// newest first by the indexed timestamp column.
//
// The bound is not an optimisation. A live chain's swap table grows without
// limit — the Lux C-Chain's already spans a million blocks — so an unbounded
// read would make every caller's cost grow forever with chain age. Callers that
// summarise recent activity take a window; nothing needs the whole table in
// memory at once.
func (s *Store) RecentSwapsRaw(limit int) map[string]*SeedSwapData {
s.mu.RLock()
defer s.mu.RUnlock()
out := map[string]*SeedSwapData{}
rows, err := s.db.Query("SELECT id, data FROM swaps")
rows, err := s.db.Query("SELECT id, data FROM swaps ORDER BY timestamp DESC LIMIT ?", limit)
if err != nil {
return out
}