indexer-core exposes a small set of runtime primitives:
IndexerProcessor: your persistence boundaryIndexerFactory: builds configured indexersIndexerRunner: starts and coordinates themIndexer: the runtime interface implemented byBlockIndexerandLogsIndexer
In normal usage you implement IndexerProcessor, build indexers with IndexerFactory, and run them through IndexerRunner.launch(...).
IndexerProcessor is the main integration point for application code.
interface IndexerProcessor {
fun getLastSyncedBlock(): BlockIdentifier?
fun rollback(blockNumber: Long)
suspend fun process(entry: IndexingResult)
}Responsibilities:
- return the last successfully persisted block
- roll back persisted state when the library detects a reorg or re-initialises from a safe point
- handle the indexing payload emitted by the runtime
The processor receives one of two result types:
IndexingResult.LogResult- produced by
LogsIndexer - contains
endBlock, decodedevents, and the currentstatus
- produced by
IndexingResult.BlockResult- produced by
BlockIndexer - contains the full
block, decodedevents, optionalcallResults, and the currentstatus
- produced by
Indexer is the runtime contract used internally by the runner. The built-in implementations are:
LogsIndexer: default factory output, optimized for fast log-based syncBlockIndexer: used when full block access or dependency ordering is required
You usually do not implement Indexer directly unless you are extending the library itself.
class TransfersProcessor(
private val repository: TransfersRepository,
) : IndexerProcessor {
override fun getLastSyncedBlock(): BlockIdentifier? = repository.getLastSyncedBlock()
override fun rollback(blockNumber: Long) {
repository.rollbackFrom(blockNumber)
}
override suspend fun process(entry: IndexingResult) {
when (entry) {
is IndexingResult.LogResult -> repository.storeEvents(entry.endBlock, entry.events)
is IndexingResult.BlockResult ->
repository.storeBlock(entry.block, entry.events, entry.callResults)
}
}
}val thorClient = DefaultThorClient("https://mainnet.vechain.org")
val indexer =
IndexerFactory()
.name("transfers")
.thorClient(thorClient)
.processor(TransfersProcessor(repository))
.startBlock(19_000_000)
.build()val job =
IndexerRunner.launch(
scope = scope,
thorClient = thorClient,
indexers = listOf(indexer),
)When an indexer is initialised:
- The processor's
getLastSyncedBlock()is queried. - The runtime rolls back from that block number if needed.
- The current block pointer is restored and the indexer moves to
INITIALISED.
This rollback-on-start behavior is intentional. It lets consumers rebuild the latest processed block from a known safe point.
For each block or log batch, the runtime:
- validates the expected block position
- updates sync state (
SYNCINGorFULLY_SYNCED) - checks for reorgs
- builds an
IndexingResult - calls
IndexerProcessor.process(...)
Reorg handling is built into the runtime:
- block-based indexers compare the previous processed block ID against the next canonical block
- if a mismatch is detected, a
ReorgExceptionis raised IndexerRunnercatches that exception, re-initialises indexers, and resumes from the rolled-back state
Your processor only needs to provide deterministic rollback logic.
The runtime can emit these states:
NOT_INITIALISEDINITIALISEDFAST_SYNCINGSYNCINGFULLY_SYNCEDSHUT_DOWN
Indexers can depend on other indexers through IndexerFactory.dependsOn(...).
val baseIndexer =
IndexerFactory()
.name("base")
.thorClient(thorClient)
.processor(baseProcessor)
.build()
val dependentIndexer =
IndexerFactory()
.name("dependent")
.thorClient(thorClient)
.processor(dependentProcessor)
.dependsOn(baseIndexer)
.build()Important behavior:
- dependencies must be included in the same
IndexerRunner.launch(...)call - circular dependency chains are rejected
- dependent indexers are executed after their parent for the same block
IndexerRunner coordinates all configured indexers:
- fast-syncable indexers are initialised and fast-synced concurrently
- independent non-fast-syncable indexers may run while that fast sync is in progress
- steady-state execution groups are formed from dependency chains
- groups can be reshuffled based on block-number proximity for better throughput
- within a group, indexers run in topological order
- across groups, prepared blocks are distributed concurrently
The public entry point is:
IndexerRunner.launch(
scope = scope,
thorClient = thorClient,
indexers = indexers,
blockBatchSize = 1,
proximityThreshold = 500_000L,
reshuffleInterval = 15.minutes,
)Use a default factory-built LogsIndexer when you only need decoded events or VET transfers and want the fastest catch-up path.
Use includeFullBlock() when you need:
- full block contents
- reverted transaction visibility
- gas metadata
- clause inspection results from
callDataClauses(...)
See LogsIndexerOverview.md for the log-based path and EventsAndABIHandling.md for event decoding.