Skip to content

perf: bulk DB writes and reads during indexing - #382

Draft
bonustrack wants to merge 6 commits into
masterfrom
fabien/bulk
Draft

perf: bulk DB writes and reads during indexing#382
bonustrack wants to merge 6 commits into
masterfrom
fabien/bulk

Conversation

@bonustrack

Copy link
Copy Markdown
Member

Summary

Cuts DB round-trips during indexing by batching writes and (opt-in) reads against Postgres. Ported from the approach Envio HyperIndex uses for its historical sync.

Three stacked commits:

  • 84d058c — Per-block write buffer. All entity inserts/updates/deletes, _checkpoints, _blocks, and _metadatas.last_indexed_block for a block accumulate in a new EntityWriteBuffer and are flushed inside one knex.transaction at the end of the block. Per touched table: one bulk UPDATE ... WHERE id IN (...) to close previous ranges + one bulk INSERT. Replaces the per-entity transaction that Model.save() used to open.
  • c7c3327 — Preload phase. Each event entry in the source config can declare preload_fn pointing to a Preloader function. Before handlers run for a block, all matched preloaders run in parallel and return { table, ids }[]. The framework issues one SELECT ... WHERE id IN (...) per table to warm the buffer cache, so subsequent Model.loadEntity calls in handlers are free. Preloaders are registered alongside writers on the indexer (second constructor arg, or options.preloaders for HyperSyncEvmIndexer).
  • 1be74e7 — Opt-in cross-block batching. New batch_size config field (default 1, i.e. today's exact semantics). When >1 during historical sync, buffer.flush is called every N blocks instead of every block; within a batch, an entity's intermediate states collapse (the new row's block_range lower bound and the closed range's upper bound both use the block where the entity was first modified in the batch). Pre-batch history is preserved exactly. Near tip (blockNumber >= preloadEndBlock), batching is forced to 1 so reorgs and live queries stay correct.

Effect on DB traffic

For a block with N entity ops:

  • Before: ~2N + 3 DB round-trips, ~N independent transactions.
  • Phase 1 (batch_size=1, no preload): ~5 round-trips, 1 transaction per block.
  • Phase 2 (batch_size=1, preload declared): ~5 writes + 1 bulk SELECT per touched table per block.
  • Phase 3 (batch_size=K): same per-batch shape amortized over K blocks.

Handler failures now roll the whole block back atomically rather than leaving partial state.

Correctness notes

  • Mid-batch errors discard the buffer and rewind blockNumber to the batch's first block so no committed work is skipped.
  • handleReorg walks back from the DB's last_indexed_block rather than the in-flight block number, so uncommitted batch blocks can't be mistakenly treated as the last good block.
  • Container.getBlockHash consults pending block hashes in the buffer first, so reorg detection works mid-batch.
  • batch_size=1 (default) produces byte-identical DB state to today.
  • HyperSync inherits the preload phase from EvmProvider without changes.

Test plan

  • yarn build passes
  • yarn test — all 68 existing tests pass
  • yarn lint passes
  • Run an existing examples/ indexer with batch_size=1 on this branch and master; diff final DB state (rows, block_range, _checkpoints, _blocks, _metadatas) — expect identical output.
  • Benchmark on an Arbitrum contract (~100k blocks of a busy ERC-20) master vs branch; target ≥5× fewer DB round-trips per block. ≥15× wall-time speedup with batch_size=50 + preload.
  • Manual smoke: run with batch_size=10, assert GraphQL historical queries (entity(block: N)) return the final batch state for any N inside the batch range, and the pre-batch state for N before it.
  • Force a reorg mid-batch and confirm no stray rows remain.

🤖 Generated with Claude Code

bonustrack and others added 6 commits April 15, 2026 10:51
Writes performed during a block's handlers (entity inserts/updates/
deletes, checkpoint records, block hash, last indexed block) are now
accumulated in an in-memory buffer and flushed inside one transaction
at the end of the block. On a block with N entity ops, DB round-trips
drop from ~2N + 3 to ~5, and handler failures now roll back the whole
block atomically.

Repeat Model.loadEntity calls for the same entity within a block also
hit the buffer cache instead of re-querying the DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each event entry in the source config can now declare a `preload_fn`
pointing to a Preloader function passed to the indexer. Before handlers
run for a block, all matched preloaders run in parallel and return
`{ table, ids }[]` targets. The framework then issues one
`SELECT ... WHERE id IN (...)` per table to warm the per-block entity
cache. Handlers' subsequent Model.loadEntity calls hit the cache
instead of doing one round-trip per id.

All three indexers (EvmIndexer, HyperSyncEvmIndexer, StarknetIndexer)
accept preloaders as a second constructor argument (or as an options
field for HyperSync). HyperSync inherits the preload phase from
EvmProvider without further changes.

Config validation rejects configs that reference a preload_fn that
isn't registered on the indexer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A new optional `batch_size` on CheckpointConfig (default 1, preserving
today's exact per-block semantics) lets users flush multiple blocks in
one transaction during historical sync. When an entity is modified in
multiple blocks within a batch, its intermediate states are collapsed:
the closed-range UPDATE's upper bound and the new INSERT's lower bound
both use the block where the entity was FIRST changed in the batch.
Pre-batch historical rows are preserved exactly; queries within the
batch range resolve to the batch's final state.

The buffer now tracks a per-entity firstChangeBlock and a pending map
of block hashes. Container.getBlockHash consults pending hashes first
so reorg detection still works mid-batch. Near tip
(blockNumber >= preloadEndBlock), batching is forced to 1 so live
queries and reorgs stay correct.

On any mid-batch error, the buffer is discarded and blockNumber is
rewound to the batch's first block so no committed work is skipped.
handleReorg walks back from the DB's last_indexed_block rather than
from the in-flight block, so uncommitted batch blocks can never be
mistakenly treated as the last good block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Sekhmet
Sekhmet marked this pull request as draft June 29, 2026 13:35
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