Extractor anatomy
An extractor is the unit of modular extraction. Each one owns a set of
PostgreSQL tables and defines a single per-block processing function
that runs in both
IngestChainHistory and
FollowingVolatileTail. The same
body works in both phases because the env it consumes is
polymorphic — the phase decides what IdResolver and Writer
implementations the env carries.
The contract
The definition lives in
DbSync.Extractor:
data ExtractorDef = ExtractorDef
{ pdName :: !Text
, pdTables :: ![TableDef]
, pdProcess :: ProcessBlockFn
}
type ProcessBlockFn =
forall env m.
( HasResolver env, HasWriter env, HasNetwork env
, MonadReader env m, MonadIO m
)
=> BlockContext -> m ()
Three fields, no surprises. pdName is the profile key that enables
the extractor; pdTables is the canonical schema for the tables it
owns; pdProcess is the per-block body.
Why the polymorphism
The rank-N quantification over env is what makes one extractor
body work in both Ingest and Follow. The body never picks a phase
— it asks the env for a resolver and a writer and calls methods on
the interface. The phase decides which implementations the env
carries.
In Ingest, the env carries the COPY-backed
Phase.Ingest.Resolver
and
Phase.Ingest.Writer
implementations. In Follow it carries the hasql-backed
Phase.Following.Resolver
and
Phase.Following.Writer.
The body never branches on phase — and never can, because it doesn't
have the env type to dispatch on.
The exception is the one place a body genuinely needs to know which
path it's on: bcSyncPhase on the context, used by core to write
phase-aware fee/deposit values and by utxo to decide whether to
fill address_id synchronously or queue it for the TxOut worker.
BlockContext
data BlockContext = BlockContext
{ bcBlockId :: !BlockId
, bcSlotLeaderId :: !SlotLeaderId
, bcSlotLeaderNew :: !Bool
, bcSlotLeaderPoolHashId :: !(Maybe PoolHashId)
, bcPrevBlockId :: !(Maybe BlockId)
, bcGenBlock :: !GenericBlock
, bcTxs :: ![TxContext]
, bcNetwork :: !Network
, bcLedgerData :: !BlockLedgerData
, bcSyncPhase :: !SyncPhase
}
Every shared ID an extractor might need is pre-assigned and packaged
here before the first extractor runs. BlockId, SlotLeaderId, the
per-tx TxId and per-output TxOutId are all populated by
processBlock
in a single sweep that walks the block once before dispatching to
extractors.
That's what makes extractors textually independent. None of them have to look at each other's output to discover the parent row's ID — those IDs are already known. The dispatcher then iterates through each enabled extractor in registration order.
Ledger data
bcLedgerData is either:
data BlockLedgerData
= LedgerDataOff
| LedgerDataOn !LedgerOutputs
The two-constructor shape rules out the impossible "ledger off but
populated" state at compile time. Extractors that care about
deposits, protocol-param deposits, or other ledger-derived data
pattern-match this; extractors that don't (utxo, metadata,
cbor, etc.) ignore it entirely.
Tables and dependencies
pdTables is a list of TableDefs the extractor owns. The schema
layer (dbsync-db) generates CREATE TABLE DDL
from each one at boot. Tables an extractor doesn't claim never get
created — disabling an extractor at the profile level skips both the
work and the schema.
Dependencies between extractors aren't declared on ExtractorDef.
They're enforced by the config validator in
DbSync.App.Config.Validation,
which collects every violation in one pass and aborts boot with an
operator-readable message. The rules:
multi_assetrequiresutxo(its rows FK intotx_out).off_chain_poolsrequirespool;off_chain_votesrequiresgovernance(each fetches metadata for rows the other writes).stake_delegation_ledger,pool_stats,epoch_boundary, andcurrent_staterequireledger.enabled = true— their rows are derived from ledger state.
Schema versioning is global rather than per-extractor: a fingerprint over every declared table detects drift at boot. See Schema versioning.
Shared dedup helpers
A handful of tables — pool_hash, stake_address, multi_asset —
can be touched by multiple extractors. The first sighting must insert,
subsequent sightings must reuse the same ID. The dedup helpers in
DbSync.Extractor.SharedDedup
wrap that pattern: look up via the resolver, write the row if it's
new, return the ID.
The same helper works in both phases because the underlying
resolvePoolHash / resolveStakeAddress / resolveMultiAsset
methods on IdResolver are phase-implemented (LSM dedup map in
Ingest, SELECT … WHERE hash = ? in Follow).
Registration
Extractors are wired up in
DbSync.App.Setup.buildExtractors
by name. The core extractor is unconditional and leads the list;
the rest are resolved from a (name, enabled?) table built from the
config's db_profile against the registry in
DbSync.Extractor.Registry.
A name with no implementation (today only current_state) resolves to
a no-op stub, so enabling it is accepted but writes nothing.
The list order is the fixed declaration order in allKnownExtractors,
arranged so shared-dedup producers (e.g. stake_delegation) precede
their consumers (pool). Dependency validation happens separately, in
the config validator.
Non-block-driven extractors
Two extractors have a no-op pdProcess:
epoch_boundaryownsada_pots,epoch_param,epoch_state,cost_model. These are populated byrunEpochBoundary(DbSync.Extractor.EpochBoundary) which the consumer calls at each epoch boundary with the matching ledger output. Per-block work is none.epochownsepoch_finalizedplus theepoch/epoch_currentviews. The table is filled by SQL hooks at three points (Ingest backfill, Follow boundary, Follow rollback) rather than from any per-block path. The extractor exists so the schema gets created when the option is on.
Registering them as extractors keeps schema creation uniform — there's no second registration path to maintain. See Existing extractors for the catalogue.