Skip to content

perf(indexer): batch sync.ts + stats_daily_mv + RPC fallback transport#46

Merged
github-actions[bot] merged 2 commits into
mainfrom
chore/indexer-tier2-perf
May 10, 2026
Merged

perf(indexer): batch sync.ts + stats_daily_mv + RPC fallback transport#46
github-actions[bot] merged 2 commits into
mainfrom
chore/indexer-tier2-perf

Conversation

@satyakwok
Copy link
Copy Markdown
Member

Summary

Tier 2 of the indexer audit pass. Three changes that reshape the hot loop. Builds on top of #45 (Tier 1).

1. Batch `indexBlock`

`apps/indexer/src/sync.ts` — pre-batch sync was N HTTP round-trips + N INSERTs per tx, sequential, inside one long-held DB transaction. On a 5000-tx block that meant ~10 s of held write lock and chain RPC saturation from one indexer instance alone.

Refactor to four phases:

  • PHASE 1: `getNativeTransaction()` fan-out via `mapWithConcurrency` with a 25-task cap (env `INDEXER_TX_FETCH_CONCURRENCY`). HTTP happens outside the DB transaction.
  • PHASE 2: build `TxRow[]` / `AddrRow[]` arrays in memory; dedupe addresses by primary key (Postgres rejects multi-row `ON CONFLICT` on the same target with `cardinality_violation`).
  • PHASE 3: `getLogsRange` + decode → `LogRow[]` / `TransferRow[]`.
  • PHASE 4: `db.transaction` wraps four batch `INSERT`s (txs, addresses, logs, transfers). Lock window now ms not seconds.

Expected: ~50–100x throughput on log-heavy blocks; backfill burst no longer monopolises the chain LB.

2. `stats_daily_mv` materialised view

Replaces the per-process 5-min in-memory cache that was lost on every restart and not shared across api processes.

  • Migration `0005_stats_daily_mv.sql` creates the view with a UNIQUE INDEX on date so `REFRESH MATERIALIZED VIEW CONCURRENTLY` works.
  • Indexer worker (`apps/indexer/src/index.ts`) fires REFRESH every 5 min (env `INDEXER_STATS_REFRESH_INTERVAL_MS`); initial seed is non-CONCURRENT since the unique index isn't valid until the first populate.
  • API (`apps/api/src/routes/native.ts`) drops the in-memory cache and just `SELECT`s from the view (~700 rows for 2 yrs of chain history).

Combined with Tier 1's 60 s edge cache: worst-case freshness gap is ~6 min.

3. RPC HTTP fallback transport

`packages/chain/src/index.ts` — operator can now set `INDEXER_RPC_HTTP_URLS=primary,backup1,backup2` and viem's `fallback()` transport rolls over on connection error / 5xx / timeout. Health-checks each transport every 60 s and demotes bad ones automatically.

Legacy single-URL env (`INDEXER_RPC_HTTP_URL`) continues to work — it just becomes the only entry in the URL list. Single-URL deployments fall back to plain `http()` (no fallback wrapper overhead) so behaviour is byte-identical.

Out of scope (Tier 3)

Reorg buffer, declarative event handlers, GraphQL, partitioning.

Test plan

  • `pnpm turbo build` passes
  • `pnpm db:migrate` applies 0005 cleanly on staging DB
  • `SELECT * FROM stats_daily_mv LIMIT 5` returns rows after worker first refresh
  • Index a single block locally; verify all txs land via batch INSERT (one statement per table per block in PG `pg_stat_statements`)
  • Set `INDEXER_RPC_HTTP_URLS=https://bad-url,https://rpc.sentrixchain.com\` and verify worker falls back successfully (chain logs report rank-demote of the bad endpoint)

@github-actions github-actions Bot enabled auto-merge (squash) May 10, 2026 20:11
satyakwok added 2 commits May 10, 2026 22:13
Tier 2 of the audit pass. Three changes that reshape the hot loop:

1. **Batch indexBlock** (apps/indexer/src/sync.ts)
   Pre-batch sync was N HTTP round-trips + N INSERTs per tx, sequential,
   inside one long-held DB transaction. On a 5000-tx block that meant
   ~10 s of held write lock and chain RPC saturation from one indexer
   instance alone. Refactor moves to four phases:

     - PHASE 1: getNativeTransaction() fan-out via mapWithConcurrency
       with a 25-task cap (env INDEXER_TX_FETCH_CONCURRENCY). HTTP
       happens entirely outside the DB transaction.
     - PHASE 2: build TxRow[] / AddrRow[] arrays in memory; dedupe
       addresses by primary key (Postgres rejects multi-row ON CONFLICT
       on the same target with cardinality_violation).
     - PHASE 3: getLogsRange + decode → LogRow[] / TransferRow[].
     - PHASE 4: db.transaction wraps four batch INSERTs (txs, addresses,
       logs, transfers). Lock window now ms not seconds.

   ~50–100x throughput on log-heavy blocks; backfill burst no longer
   monopolises the chain LB.

2. **stats_daily_mv materialised view** (migration 0005, indexer +
   API wiring)
   Replaces the per-process 5-min in-memory cache that was lost on
   every restart and not shared across api processes. View has a
   UNIQUE INDEX on date so REFRESH MATERIALIZED VIEW CONCURRENTLY can
   run without blocking SELECTs. Indexer worker fires REFRESH every
   5 min (env INDEXER_STATS_REFRESH_INTERVAL_MS); the API just SELECTs
   from the view (~700 rows for 2 yrs of chain history). Combined with
   Tier 1's 60 s edge cache: worst-case freshness gap is ~6 min.

3. **RPC HTTP fallback transport** (packages/chain/src/index.ts)
   Operator can now set INDEXER_RPC_HTTP_URLS=primary,backup1,backup2
   and viem's fallback() transport rolls over on connection error /
   5xx / timeout. Health-checks each transport every 60 s and demotes
   bad ones automatically. Legacy single-URL env (INDEXER_RPC_HTTP_URL)
   continues to work — it just becomes the only entry in the URL list.
   Single-URL deployments fall back to plain http() (no fallback
   wrapper overhead) so behaviour is byte-identical for them.
@satyakwok satyakwok force-pushed the chore/indexer-tier2-perf branch from d0c1af4 to 25b3b15 Compare May 10, 2026 20:14
@github-actions github-actions Bot merged commit ec52c81 into main May 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant