perf: bulk DB writes and reads during indexing - #382
Draft
bonustrack wants to merge 6 commits into
Draft
Conversation
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
marked this pull request as draft
June 29, 2026 13:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_blockfor a block accumulate in a newEntityWriteBufferand are flushed inside oneknex.transactionat the end of the block. Per touched table: one bulkUPDATE ... WHERE id IN (...)to close previous ranges + one bulkINSERT. Replaces the per-entity transaction thatModel.save()used to open.c7c3327— Preload phase. Each event entry in the source config can declarepreload_fnpointing to aPreloaderfunction. Before handlers run for a block, all matched preloaders run in parallel and return{ table, ids }[]. The framework issues oneSELECT ... WHERE id IN (...)per table to warm the buffer cache, so subsequentModel.loadEntitycalls in handlers are free. Preloaders are registered alongside writers on the indexer (second constructor arg, oroptions.preloadersforHyperSyncEvmIndexer).1be74e7— Opt-in cross-block batching. Newbatch_sizeconfig field (default1, i.e. today's exact semantics). When >1 during historical sync,buffer.flushis called every N blocks instead of every block; within a batch, an entity's intermediate states collapse (the new row'sblock_rangelower 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:
~2N + 3DB round-trips, ~N independent transactions.batch_size=1, no preload):~5round-trips, 1 transaction per block.batch_size=1, preload declared):~5writes + 1 bulk SELECT per touched table per block.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
blockNumberto the batch's first block so no committed work is skipped.handleReorgwalks back from the DB'slast_indexed_blockrather than the in-flight block number, so uncommitted batch blocks can't be mistakenly treated as the last good block.Container.getBlockHashconsults pending block hashes in the buffer first, so reorg detection works mid-batch.batch_size=1(default) produces byte-identical DB state to today.EvmProviderwithout changes.Test plan
yarn buildpassesyarn test— all 68 existing tests passyarn lintpassesexamples/indexer withbatch_size=1on this branch and master; diff final DB state (rows,block_range,_checkpoints,_blocks,_metadatas) — expect identical output.batch_size=50+ preload.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.🤖 Generated with Claude Code