T25 · Blockchain Data Infrastructure
How do you turn full-chain data into a queryable warehouse?
- Capabilities practiced
- BuilderOnchain Literacy
- Hands on
- Export all historical protocol events to columnar files and run an aggregation query across months.
- AI Lab
- Have AI write an analysis query. Check whether its metric definition matches the raw on-chain events.
A Real-World Problem
Your indexer has run reliably since T17: no missing blocks, rollback support, and repeatable runs. The application database holds tens of millions of transfer events.
Operations asks its first question: “How many active addresses and how much fee revenue did our protocol have in each of the past twelve months?”
You write a SQL query and press Enter. Forty minutes later it finishes, just as production alerts fire: the query saturated database I/O and frontend requests began timing out.
You add indexes; the second run is faster. Then comes “Can you separate new and returning users?” Another half hour.
The third request is “Can we see twenty-four months?”
The database has only the most recent six months. Disk grew too fast, so three months ago you scheduled deletion of old data.
All three questions seem so routine that operations expects a couple of clicks. Your system answers none well.
The indexer is not poorly written; it does its own job well. A system optimized to retrieve one address's current balance and one optimized to scan a billion rows for an aggregate differ in storage format and rebuild strategy.
This chapter builds the second.
Thought Experiment
Start with a paper ledger.
Each page holds one transaction with thirty fields: date, counterparty, amount, purpose, handler, invoice number, department, project code, and so on.
Question one: “How much was Zhang San's payment yesterday?” Find the page, read it, and answer. One-page access is cheap.
Question two: “Total monthly spending last year.” You must turn every page for a year. Only date and amount matter, but pages bind the fields together. You cannot turn just those two columns; all thirty fields pass through your hands.
Now rebind the ledger: copy every date onto one roll, every amount onto another, one roll per field. For question two, read only two rolls rather than thirty. Values of the same type compress well, so even moving the data gets cheaper.
Question one becomes harder: to reconstruct Zhang San's complete record, pull one entry from each of thirty rolls.
That is row versus column storage. Neither is universally better; each fits a different question.
But a second thought experiment is harder.
The ledger is not written all at once. It grows every day, and occasionally you must replace all entries for a day last month because of a chain reorg, a misparsed field, or an upgrade changing an event's meaning.
The problem is no longer merely fast lookup:
How can you rebuild one day so that its result exactly matches rebuilding the entire history from day one?
Only then can a warehouse support reconciliation, reporting, and settlement. Otherwise it is just a faster cache.
Your Decision
You must answer operations' three questions. Choose an approach.
What Happens
Compare the choices:
| Delivery speed | Aggregate performance | Historical depth | Definition control | Long-term cost | |
|---|---|---|---|---|---|
| Application indexes and views | Fastest | Low | Disk-limited | Fully controlled | Medium, also burdens production |
| Read-only replica | Fast | Low | Disk-limited | Fully controlled | Medium, full extra copy |
| Columnar files and analytics | Slow | High | Almost unlimited | Fully controlled | Low, but pipeline maintenance |
| Third-party platform | Fastest | High | Deep | Not controlled | Usage-based, external dependency |
The fourth column dominates during an incident, outweighing the others together.
People ask only two fundamental questions of analytics:
How was this number calculated?
If I calculate it again, do I get the same number?
The first is the metric definition; the second is replayability. Neither is primarily a query problem. Both are ETL problems.
That is the surprise: SQL itself can be learned quickly. The difficult work is explaining each row's origin, rebuilding partitions independently, and obtaining the same result each time.
You have already solved half of it. T17's checkpoints, idempotency, range table, and rollback reappear here in another form.
Build a Model
Three layers: Raw, Decoded, Curated
Do not combine “fetch data from the chain” and “compute business metrics” in one step. Use at least three layers:
- RPC / node
- Raw layer
- Decoded layer
- Curated business layer
- Reports and APIs
| Layer | What it stores | Cost to rebuild |
|---|---|---|
| Raw | Original block headers, transactions, receipts, and logs as bytes, not decoded | RPC again: most expensive, perhaps requiring an archive node |
| Decoded | ABI-decoded structured events: who, to whom, how much | Decode local Raw again: cheap |
| Curated | Business meaning: daily active users, fees, positions, LP returns | Aggregate again: cheapest |
The last column explains layering. Pay the costliest step only once.
Common corrections include “we parsed a field wrong” and “an upgrade changed an event's meaning.” If RPC feeds metrics directly, either correction requires refetching chain history, perhaps for days and subject to provider limits. With original Raw bytes retained, you rerun decoding locally in minutes.
Raw is insurance against the certainty that you will someday discover a decoding mistake. Its storage cost is small beside a full refetch.
Partitioning: the choice that shapes everything
Partitions physically divide data into files so queries read only relevant pieces, a feature called partition pruning.
Blockchain data offers two natural keys: block height and time. Which should you use?
Both, with different roles:
Physical partition key → date derived from block time (UTC)
Sort within partition → block height, then log_indexBusiness questions are by time (“last month,” “Q3”), so dates make pruning useful. Writes, deduplication, and rebuilds use block heights, so ordering by height lets the engine skip ranges and gives each partition an exact recorded height span.
A directory layout:
warehouse/
raw_logs/
dt=2024-03-01/part-0000.parquet
dt=2024-03-02/part-0000.parquet
decoded_transfers/
dt=2024-03-01/part-0000.parquet
curated_daily_metrics/
dt=2024-03-01/part-0000.parquetTwo easy mistakes:
First, derive the partition key from block time, never now(). An ingestion-time key puts the same chain data into different partitions today and tomorrow, breaking replay. T16 stated the rule for fact tables: time comes from the block header, not your server.
Second, do not partition too finely. A daily file of tens or hundreds of megabytes is comfortable. Hourly partitioning can create tens of thousands of tiny files whose metadata cost erases the columnar benefit: the small-file problem.
Idempotency: a partition is the atomic write unit
T17 used database transactions and keys. Filesystems lack database transactions but offer a workable substitute: atomic rename.
1. Write the entire partition into a temporary directory
warehouse/decoded_transfers/_tmp_2024-03-01_run42/
2. After writing and validation, atomically rename it to the published path
→ warehouse/decoded_transfers/dt=2024-03-01/
3. Update the manifest: height span, row count, checksum, decoder versionThe rule from T17 appears in a new form:
A partition either exists completely or does not exist at all. Never publish half a partition.
A crash during step 1 leaves a temporary directory for the next run to clear; the published path stays intact. A crash after step 2 but before manifest update causes a rebuild. That is harmless because a repeat produces identical bytes.
Rename first, update the manifest second. Reverse them and you get “manifest says it exists, but files do not,” the same error as advancing T17's checkpoint before storing data.
Manifest: the pipeline's source of truth
Without a manifest, a warehouse is merely a pile of files and nobody can say whether a month is complete.
create table warehouse_manifest (
dataset text not null, -- raw_logs / decoded_transfers / ...
chain_id integer not null,
dt date not null, -- Partition key
from_block bigint not null, -- Height span actually covered
to_block bigint not null,
row_count bigint not null,
checksum text not null, -- Content fingerprint to compare reruns
decoder_version text not null, -- Decoder code version; crucial for lineage
status text not null, -- pending / running / done / failed
attempts integer not null default 0,
built_at timestamptz not null default now(),
primary key (dataset, chain_id, dt)
);It answers four questions nobody asks in calm periods and everyone asks during an incident:
-- 1. Are any dates missing?
select dt from generate_series($from::date, $to::date, '1 day') g(dt)
where not exists (
select 1 from warehouse_manifest m
where m.dataset = 'decoded_transfers' and m.chain_id = $chain
and m.dt = g.dt and m.status = 'done'
);
-- 2. Are height spans continuous between adjacent dates?
select a.dt, a.to_block, b.dt, b.from_block
from warehouse_manifest a
join warehouse_manifest b
on b.dataset = a.dataset and b.chain_id = a.chain_id
and b.dt = a.dt + 1
where b.from_block <> a.to_block + 1;
-- 3. Which partitions use an old decoder version and need rebuilding?
select dt from warehouse_manifest
where dataset = 'decoded_transfers' and decoder_version <> $current;
-- 4. Did content change on replay? Compare checksums by partitionQuery 1 is T17's range table in another form: “complete” must be answerable with SQL, not inferred from a progress bar. Query 3 realizes the value of layers.
Reorgs: the warehouse should lag
Columnar files are immutable. How do you handle the chain-head reorgs from T17?
Keep the chain head out of the warehouse.
Application database (OLTP) ← follows the head, serves real time, handles rollback
│
│ Move only rows with block_number <= safeHeight
↓
Data warehouse (OLAP) ← trails by blocks; published files are immutablesafeHeight is the same safe boundary used by the T17 outbox. Lagging by minutes or hours is a design choice, not a flaw.
Reorg handling becomes simple: the warehouse contains no data that needs rollback. It cannot answer “what happened one second ago”; the application database and T18's real-time channel serve that question.
Two systems offer two freshness levels and two consistency guarantees. Do not force both into one system.
If you must rewrite a published partition, perhaps after finding a decoder error from three months ago, regenerate it, atomically replace it, update the manifest, and mark dependent downstream partitions pending. That is why manifest records decoder_version.
Curated layer: definitions belong next to code
At this layer, technical questions give way to definitions.
“Monthly active addresses” has at least six interpretations:
- Count only transaction initiators, or both sides of a transfer?
- Count failed transactions?
- Count contract addresses?
- For aggregator routes, count the aggregator or the end user?
- One person with five addresses: one or five?
- Use UTC or a local timezone?Two answers to each generate dozens of legitimate metrics with the same label.
Keep a definition comment for every curated table in the same file as its SQL. A separate document will drift away from the code within months.
-- daily_active_addresses
-- Definition (finalized 2024-03):
-- 1. Count logs from successful transactions only
-- 2. Count and deduplicate both transfer from and to
-- 3. Exclude the zero address used for mint and burn counterparties
-- 4. Include known contract addresses, but flag them separately
-- 5. Use UTC calendar days
create table curated_daily_active (
chain_id integer not null,
dt date not null,
contract bytea not null,
addresses bigint not null,
tx_count bigint not null,
primary key (chain_id, dt, contract)
);A useful observation: when two people report different numbers for one metric, the usual cause is different definitions, not bad arithmetic. Put the definition beside the SQL and a long argument becomes a short comparison.
A complete example
Aggregate monthly metrics from decoded data:
-- Monthly active addresses and fees over 12 months
-- Partition pruning uses dt, so only these months' files are scanned
with e as (
select dt, from_addr, to_addr, amount, gas_used, gas_price
from decoded_transfers
where chain_id = 1
and contract = $contract
and dt >= date '2024-01-01'
and dt < date '2025-01-01'
),
addrs as (
select date_trunc('month', dt) as m, from_addr as addr from e
union all
select date_trunc('month', dt) as m, to_addr as addr from e
)
select m,
count(distinct addr) as active_addresses,
sum(gas_used * gas_price) / 1e18 as fees_native
from addrs
join e on date_trunc('month', e.dt) = addrs.m
group by m
order by m;Watch where dt >= ... and dt < .... It determines whether the engine scans twelve months of files or the full archive, which creates the order-of-magnitude difference.
If you instead write where date_trunc('month', dt) = ... or wrap dt in any function, partition pruning may fail and the engine scans everything. Fast queries often depend on leaving the partition key bare in the filter.
What It Is Called
A pipeline that takes source data, reshapes it, and stores it elsewhere. Onchain that means fetching blocks, decoding events, and calculating business metrics.
Its opposite is not another technique but “one SQL query against production.” If that query runs for over a minute, you are already doing ETL without treating it as engineering.
Storage optimized for analytics rather than transactions. It accepts slower ingestion and weaker real-time freshness in return for fast aggregates and deep history.
The test is simple: a system asked both for one address's balance and a sum across a billion rows will struggle at both.
Data organized by column instead of row. Queries read only needed columns, and same-type values compress well.
It is a default for analytics. Two implementation types appear in the concepts: embedded single-machine engines that query files directly within your process and distributed columnar databases for larger data and concurrency. Data volume and team size guide the choice; this chapter's model applies to either.
Physically dividing data by a key so queries read only relevant pieces: partition pruning.
For onchain data, partition by date and sort inside by block height. Date serves queries; height serves ingestion and replay. A partition is also the atomic unit of idempotent loading.
Loading the same batch repeatedly without changing the final result.
For files, write a temporary directory, validate, atomically rename, then update the manifest. This is T17's data-and-checkpoint transaction in another medium: repeat work if needed, but never publish half of it.
The boundary below which blocks may enter the warehouse, preventing rollback-requiring data from entering it.
The cost is predictable lag. Document that lag as a contract with data users, rather than hiding it as a performance problem.
The ability to trace a metric to upstream partition, block span, decoder version, and aggregation logic.
The minimum implementation is those columns in the manifest. Without lineage, “why did this number change?” becomes guesswork.
Hands-on
Everything is read-only. No signing, broadcasting, or spending in this chapter.
Three acceptance checks can each be automated:
Check 1: Rebuild any one daily partition; its checksum stays the same
Check 2: No holes in the manifest; adjacent height spans are continuous
Check 3: Aggregates match direct calculations from raw logs row by rowChoose a small protocol and a bounded range. Do not start with the whole chain. Pick a contract with a moderate event count over three or four continuous months. The goal is a working pipeline, not a volume record.
Before coding, answer the start and end block heights of the range. Write them down as the basis for every validation.
Build Raw: store without decoding.
Partition by day and store original log fields: block_number, log_index, block_time, tx_hash, address, topics, and data. Keep data as raw bytes here.
raw_logs/dt=YYYY-MM-DD/part-0000.parquetWrite in four steps: temporary directory, validation, atomic rename, manifest update. You will test that order in this Lab.
Create the manifest using the DDL above.
After each partition, record height range, row count, checksum, decoder version, and status.
Run the missing-date query immediately. It should report many dates not yet processed, demonstrating that it detects holes before you rely on it in production.
Build Decoded from local Raw only, with no RPC requests.
Decode topics and data with the ABI into structured fields. If this step still calls RPC, Raw did not store enough; fix Raw.
Keep amounts as integers or high-precision decimal, never floating point. A floating-point sum may look fine in a report but fail reconciliation.
Check 1: rebuild individual days.
Select three dates, rerun each, and compare its checksum in the manifest:
Three reruns, three checksums; each must exactly match its originalIf not, inspect partition keys for now(), nondeterministic sorting, and run-time fields such as updated_at written into files. The first two are common replay failures.
Check 2: create a hole deliberately and verify detection.
Delete one middle day's partition directory without changing the manifest. Run an aggregate: it silently omits the day with no error.
Delete that manifest row too, and run the missing-date query. It must identify that day.
This comparison justifies the manifest: files can be silently absent; a checked manifest exposes the gap.
Build Curated monthly metrics with a written definition.
At the top of the SQL file, document at least the six definitional choices listed earlier. Then run the chapter's twelve-month aggregate.
Check 3: reconcile to raw logs.
Pick one day and calculate the metric directly from Raw, without any Curated table. The two numbers must match.
A mismatch is usually a definition issue: perhaps Curated excludes zero addresses while the reconciliation script includes them. The value of this step is forcing a precise definition, not merely finding a bug.
Measure the benefit. Run the same monthly aggregate against the application database and columnar storage. Record elapsed time, bytes scanned, and storage size in both.
Make a table from your own measurements. The course provides no universal numbers; they depend on your data. You should see the scale of the difference yourself.
AI Lab
Give the model your schema, definition comments, and sample rows in three steps:
Step one:
Here is my decoded_transfers schema and partition layout (partition by dt,
sort by block_number within each). Write a query for monthly active addresses
and total fees over the last 12 months. Use partition pruning and state how
many partitions it should scan.
Step two:
List every metric-definition assumption you made. Write each as:
"I assumed X; if the actual rule is Y, the result changes in this way."
Give at least six.
Step three:
The contract actually upgraded last June. After the upgrade the same event
has an extra field and a different topic0. What happens to your query around
June, and how should it change?Models often handle step one because this SQL pattern has been written countless times. Watch whether a function wraps the partition key. It can leave results identical while raising scan costs by orders of magnitude, an error hidden by a correct answer.
Step two is the real test. Models silently decide definitions for you: whether zero addresses count, which side of a transfer counts, and so on, then report an authoritative-looking number. Listing assumptions gives you a chance to correct them.
Step three checks a blockchain-specific hazard: a contract address is not a permanent type marker; event layout can change by height. T16 introduced this. Decode by block-height range, and use decoder_version in the manifest to identify partitions to rebuild. A model may say “add a union” without asking whether fields before and after the upgrade mean the same thing.
Finally, calculate one day from raw logs, bypassing Curated. Only then is the query suitable for reporting.
Verify these points yourself after using AI
- Does its where clause leave partition key dt bare? A function around dt can disable pruning; check the plan and count scanned partitions
- Check active-address deduplication: initiator only or both sides, zero address excluded or not, against your written definition
- Were failed transactions included? Logs occur only in successful transactions, but a transaction table requires filtering status
- Did it use floating point in any amount step or sum tokens with different decimals directly?
- Does date_trunc use UTC or session time? They can differ at month boundaries
- Did a join multiply rows? Calculate one day manually to detect double counting
- Most important: calculate one day directly from raw logs without its query; the results must match
Real-World Case
A team discovers that an event's amount should be parsed as a signed rather than unsigned integer. They change the decoder for new data only.
Three months later, annual reconciliation shows several inflated months. Investigation takes weeks because no row records which decoder version produced it.
That is why the manifest has decoder_version: one SQL query lists partitions needing rebuild after a decoder change. New checksums show which outputs actually changed. Without it, you either rebuild everything or rely on memory.
A backfill script uses the current system date as partition key. The first run writes data into “today”; a rerun writes the same data into “tomorrow.”
Both copies remain and aggregates double, even though each partition looks correct alone.
Rule: partition keys must be properties of data, not the execution environment. Onchain time comes from block headers. T16 emphasized it; warehouses magnify the cost of ignoring it.
A scheduler kills an export job mid-write, leaving a half-filled file at the published path.
Queries return normally, but that day's metric is 60% too low. Someone notices only when reconciling a quarterly report a month later.
A half-written file is more dangerous than a missing file because it is accepted as complete. Temporary directories and atomic rename are the general defense, not unnecessary neatness.
A batch action emits an enormous number of logs in one transaction. A pipeline batching “one transaction at a time” runs out of memory. On restart it reads the same transaction and loops through crashes.
Two lessons: bound batches by bytes or rows, not business units such as a transaction or block, and give retrying pipelines attempt counts and backoff so one poison record does not stop the line forever. The manifest's attempts column supports that.
Change One Variable
A single-machine embedded engine first hits query memory and partition-file size limits.
The response has a sequence, and the first two steps barely change architecture: partition more appropriately, perhaps by chain and contract; precompute common Curated aggregates; only then consider a distributed engine.
Inspect query plans before buying more machines. Many apparent scale problems are failed partition pruning.
Do not merely lower safe height. That lets reorg-prone data enter immutable storage.
Run two systems together: the warehouse remains final and lagged, while the application database and T18's channel serve live metrics. The query layer joins the two ranges and marks the boundary clearly.
The live range may be corrected. Show users which portion is final and which can change, an analytics version of T17's risk tiers.
chain_id becomes part of keys and partition paths throughout, a mostly mechanical change.
The harder problem is comparability. Chains have different block times and numbers of blocks per day, fee currencies differ, and the same token has distinct addresses. Adding raw values across chains often produces wrong reports.
Calculate each chain separately, then merge through a separately documented definition. Asset equivalence across chains raises a deeper question addressed next chapter.
If you use it only for exploration, the change costs an afternoon. If production reporting depends on it, it is an incident.
The difference is whether you hold replayable raw data of your own. With Raw, the worst case is hours of rebuilding; without it, you wait for the provider.
That is why the unglamorous Raw layer is the last to remove.
Takeaway Questions
What problem does it solve? This chapter makes a metric recalculable. The hardest part is not querying but partitioning, idempotency, and lineage, which become visible when someone questions a number.
Who pays? Warehouse costs have three parts: archive RPC or node access, object storage, and bytes scanned by queries. The first is often largest, and layering makes sure it is paid once. Any correction requiring a full refetch repeats the most expensive stage.
Who bears the loss if AI is wrong? Here a model may silently choose a metric definition: whether zero addresses or both transfer sides count. The result looks authoritative and raises no error; it becomes a distributed report. The person whose name is on that report is responsible.
Chapter Check
Two reasons are resources and format.
Analytical queries scan large ranges and compete with production point reads for I/O and memory. One full scan can stall the product. A read-only replica addresses that half.
It does not change row storage: to read two fields, the database still accesses rows containing thirty. Indexes cannot entirely erase a difference rooted in storage format.
A third reason is retention. Application databases delete old data to manage size; analysis needs deep history.
For replay. If the key is now(), the same data processed today and tomorrow lands in different partitions. A rerun adds data rather than replacing it, doubling results.
Each partition may look correct, so only the aggregate exposes the error, with no exception raised.
A partition key must be a property of the data. Onchain time comes from the block header, a rule unchanged since T16.
Write to a temporary directory, validate, atomically rename into the published path, and update the manifest.
The last two must be ordered: rename before manifest update. Reversing them creates a record claiming a file exists when it does not.
As in T17, advancing a checkpoint must follow the data. A crash should cause extra work, not silent missing data.
Avoid the need. Move only blocks below safe height into the warehouse, leaving no data there that needs rollback.
This creates deliberate lag. Document the cutoff height as a data contract for users.
Live data belongs to the application database and real-time channel. Two systems offer two freshness levels and consistency guarantees. Trying to make one do both weakens each.
There is no single answer. Check:
- Does Raw store original bytes? If already decoded, you cannot rerun only the decoder.
- Does the partition key come from block time, and will granularity avoid tiny files?
- Does the manifest hold height spans, checksums, and decoder versions? Can one SQL query find holes?
- Are partition writes atomic? Would a kill leave a half-file published?
- What is safe height? Have data users been told the lag?
- Is each Curated table's definition in its SQL file rather than a separate document?
Point 6 is easily missed and often causes a future argument about whose metric is “wrong.”
Key idea
On-chain data engineering centers on idempotent, replayable ETL, not clever queries.