Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions docs/runbooks/live-backfill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Backfilling a Running Indexer

This runbook covers closing a raw-fact coverage hole on a deployment that is
already serving, without stopping the live tailer and without dropping the
database. Backfill mechanics live in [`../chain-intake.md`](../chain-intake.md);
deployment shape in [`../deployment.md`](../deployment.md).

Wiping and re-bootstrapping is not a substitute. A cold start reproduces the same
hole (see [Why one pass is not enough](#why-one-pass-is-not-enough)), and it is
never available on mainnet.

## When to use this

A watched target has on-chain logs that the deployment never ingested. The
common cause is a target admitted by discovery *after* the backfill job that
would have covered its history was created.

`GET /v1/status` does not detect this. `projection_lag_blocks` tracks head
reconciliation, not log coverage: `chain_lineage` can be gapless to the chain
head while `raw_logs` holds nothing for a watched target. Trust the per-target
comparison in [Verification](#verification) instead.

## Preconditions

Check each before creating a job. Two are hard gates.

1. **Backfill code observations must be activity-scoped.** Before that change,
backfill wrote one `raw_code_hashes` row per selected target per block,
unconditionally. A whole-chain job over a large target set writes
`O(selected targets x blocks)` rows and can exhaust the volume. Confirm the
deployed image scopes observations to a block's selected log emitters.
2. **The live tailer must watch the active watched chain.** Backfill does not
observe a watched target that emits nothing in the backfilled range; the
tailer's missing-baseline pass is the only source of a baseline observation
for a silent target, and it only covers targets in the live watch plan
(`apps/indexer/src/main/reconciliation/persistence.rs:544-581`).
3. **Volume headroom.** A running deployment cannot reclaim `raw_code_hashes`
rows written under the old policy — raw facts are immutable. Size the job
against free space before starting.
4. **No leaked backfill lease.** A `backfill_jobs` row stuck in `running` holds a
lease. Inspect `status`, `updated_at`, and `failure_reason` before adding

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Inspect range leases instead of job rows

In a deployment with an interrupted worker, the lease lives on backfill_ranges (lease_token, lease_owner, and lease_expires_at), not on backfill_jobs; a job in running may have an expired or cleared range lease, and a stuck range lease is what blocks reservation. This precondition sends operators to inspect only job-level status, updated_at, and failure_reason, which can miss the actual leaked lease or make them avoid a safely reclaimable job.

Useful? React with 👍 / 👎.

work.

## Why one pass is not enough

A backfill job freezes its selected target set at creation
([`../chain-intake.md`](../chain-intake.md), § Selector modes:
`whole_active_watched_chain` selects "every active watched target ... at job
creation"). Discovery admits targets from normalized events, which are derived
from raw facts, which is what backfill produces. So a target discovered *by* a
pass was not in that pass's own filter.

The consequence: a single whole-chain pass over a deployment whose discovery
graph is incomplete will admit new targets and leave their history unindexed.
Iterate until a pass admits no new targets and the coverage check is clean.

For source families with a topic-signature scan the circularity does not apply,
because ingest identity is the topic plan rather than a moving address list. The
ENSv1 generic resolver scan (`apps/indexer/src/main/ens_v1_resolver.rs:4`) and
the Basenames registry scan ([`../chain-intake.md`](../chain-intake.md) § Backfill
contract) already work this way.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope the Basenames topic-scan exception

The procedure's command uses hash-pinned backfill unless the operator explicitly chooses Coinbase SQL, but the hash-pinned topic-first path only scans all emitters for the ENSv1 generic resolver; the Basenames registry topic-signature scan is the Coinbase SQL source-family special case. If this exception is read as applying to a hash-pinned whole-chain Base run, operators can treat one pass as non-circular even though newly discovered registry emitters are still absent from that pass's address filter.

Useful? React with 👍 / 👎.


## Procedure

Run from an operator shell against the serving database. The live tailer keeps
running throughout; raw upserts are widening and idempotent, so a range may be
re-covered safely under a fresh idempotency key
([`../chain-intake.md`](../chain-intake.md) § Selected-target intake).

**1. Record the starting state.** Capture the per-target coverage table from
[Verification](#verification) and the current `raw_code_hashes` row count.

**2. Create the job.** Omit a selector to take `whole_active_watched_chain`.

```sh
bigname-indexer backfill \
--chain ethereum-sepolia \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pin the manifest profile in the Sepolia example

When this example is copied for an ENSv2 Sepolia deployment from a normal operator shell, bigname-indexer backfill defaults --manifests-root to manifests/mainnet, so --chain ethereum-sepolia resolves against the wrong profile unless BIGNAME_INDEXER_MANIFESTS_ROOT=manifests/sepolia happens to be set. Include the profile flag (or make the chain/profile placeholders match) so the runbook command actually selects the serving Sepolia watch plan.

Useful? React with 👍 / 👎.

--from-block <lowest admitted start across active targets> \
--to-block <finalized head> \
--idempotency-key recovery-<iso8601>-<n>
```

Bound `--to-block` at the finalized head, not the canonical head, so the range
cannot be reorged out from under the job.

**3. Let normalization catch up.** Discovery edges are written from normalized
events, so new targets only appear after replay drains. Watch
`normalized_replay_cursors`: `next_block_number > target_block_number` and
`last_failure_at IS NULL`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't wait on replay cursors for inline backfill

With the command shown above, --hash-pinned-adapter-sync defaults to auto, and manual hash-pinned backfill maps that to inline adapter sync rather than raw-only replay; inline adapter sync writes normalized events directly and does not advance normalized_replay_cursors. On that default path this wait condition can remain stale or absent even after discovery has caught up, so the runbook should either switch the command to raw-only before using cursor progress or tell operators what to check for the inline path.

Useful? React with 👍 / 👎.


**4. Check for newly admitted targets.** If the active watched set grew, return
to step 2 with a fresh idempotency key. The new pass covers the targets the
previous pass discovered.

**5. Stop when a pass admits nothing new and the coverage check is clean.**

## Cost model

Hash-pinned backfill resolves one block hash per block
(`apps/indexer/src/provider/block_transaction.rs:173-192`, `eth_getBlockByNumber`).
That dominates provider cost, and it does not depend on how many targets the job
selects:

```
provider cost ~= (to_block - from_block + 1) x CU(eth_getBlockByNumber)
wall time ~= provider cost / throughput ceiling (CU/s)
```

Batch size (`BIGNAME_INDEXER_JSON_RPC_BATCH_ITEM_LIMIT`, default 32, max 256)
does not change the total; it removes per-request latency stalls up to the
throughput ceiling. Below the ceiling the job is latency-bound and a larger batch
helps; at the ceiling only a higher throughput limit helps.

Note the block-hash resolution loop is sequential and does not consume
`BIGNAME_INDEXER_JSON_RPC_BATCH_CONCURRENCY`, which is applied only to receipt
fetches (`apps/indexer/src/provider/transaction_receipts.rs:532`).

Budget the provider's monthly quota as well as its rate: a full-range pass over a
long history can consume a large fraction of a month's allowance, and this
procedure runs the range more than once.

## Verification

Coverage is per target, not per deployment. For each active watched target
compare the highest block it has an ingested non-orphaned log for against what
the chain holds above that block.

The floor for a target with **no** ingested logs is its admission block minus
one, not the chain head. Treating "no rows" as "nothing missing" is the failure
this runbook exists to correct: a target that was never watched has no rows, and
a naive `onchain_max > ingested_max` comparison silently skips it.

```sql
-- Per-target ingested frontier. Targets with no rows come back NULL and must be
-- scanned from their admission block, not skipped.
SELECT cia.address,
MAX(rl.block_number) AS ingested_max_block

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify from the active-range start

When a newly admitted target has any later log ingested by the live tailer, this MAX becomes that recent live block, and the next step only checks the provider above it. That misses the historical window this runbook is meant to repair (from the target's active/admitted start up to the first live-tail log), so operators can stop after a "clean" check while old raw facts are still absent. The coverage check needs to scan from each target's active range start or otherwise prove there are no gaps below the current max.

Useful? React with 👍 / 👎.

FROM contract_instance_addresses cia
LEFT JOIN raw_logs rl
ON LOWER(rl.emitting_address) = cia.address

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope the raw-log join to the selected chain

For any serving database with more than one watched chain, this joins and groups by bare address, so a log from the same address on another chain can produce ingested_max_block and mask a missing backfill on the chain passed to bigname-indexer backfill. The verification query should filter cia.chain_id for the job's chain and join raw_logs on that same chain_id before using the result as coverage evidence.

Useful? React with 👍 / 👎.

AND rl.canonicality_state <> 'orphaned'::canonicality_state
WHERE cia.deactivated_at IS NULL

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Query the watched plan, not all active addresses

For databases that retain active contract_instance_addresses rows not currently selected by an active manifest or discovery edge, this query treats every active address row as a watched target even though the backfill selector only uses load_watched_contracts/the watched plan. That can send operators to scan and backfill unrelated active instance addresses while the runbook claims the table is per active watched target; the verification query should derive the same active watched set used by whole_active_watched_chain.

Useful? React with 👍 / 👎.

GROUP BY cia.address
ORDER BY ingested_max_block NULLS FIRST;
```

Then, for each target, ask the provider for the first log strictly above that
floor within the target's active range. Use a single bounded `eth_getLogs`; do
not use the reorg-safe range helpers, which resolve every block hash in the range
and are unaffordable across a never-ingested target's full history.

A useful whole-deployment smoke check: no raw log should exist only because it
shared a transaction with a manifest-declared emitter. If every row in `raw_logs`
sits in a transaction containing a manifest-declared contract's log, no
discovery-admitted target has ever been ingested on its own.

Confirm the code-observation policy held during the run:

```sql
SELECT COUNT(*) AS rows,
COUNT(DISTINCT code_hash) AS distinct_code_hashes
FROM raw_code_hashes
WHERE chain_id = 'ethereum-sepolia';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parameterize the code-hash check by job chain

When this runbook is used for any non-Sepolia repair (mainnet/Base are explicitly in scope), this final check always reads ethereum-sepolia, so it can report stable counts even while the job's actual chain is still writing per-block raw_code_hashes. That undermines the policy verification; use the same :'job_chain' variable as the coverage query.

Useful? React with 👍 / 👎.

```

Row count growing far faster than distinct code hashes means observations are
being written per block rather than per emission.

## Hazards

- **The tailer runs throughout.** It writes raw facts for new head blocks while
the job writes them for historical ranges. Upserts are widening and idempotent,
so this is safe, but a coverage check taken mid-run is a moving target. Take
the check after the job completes.
- **A pass can widen the watch set.** The tailer's discovery refresh picks up
newly admitted targets without a restart, so live coverage self-corrects going
forward; only history needs another pass.
- **Never-ingested targets are the expensive case.** Their scan window is their
entire active range. Bound the look-ahead and stop at the first hit; presence
of a hole is a boolean, and its size does not change what you do next.
Loading