The ingestion layer (src/services/ingestion.ts) processes on-chain event batches atomically. Either the full batch is committed together with the cursor advance, or nothing is written.
ingestBatch(store, events, batchCursor)
1. Read current cursor from store
2. If batchCursor <= currentCursor → skip (idempotent)
3. Validate every event in the batch
4. store.commitBatch(events, batchCursor) ← atomic
Steps 1–3 happen in application memory. Only step 4 touches durable state, so a crash before step 4 leaves the store unchanged and the batch can be safely retried.
Re-submitting a batch with a cursor that has already been committed returns skipped: true and makes no writes. This means retries after a crash are safe — the second attempt either commits (if the first truly failed) or is a no-op (if the first succeeded but the caller didn't receive the acknowledgement).
The persisted raw-event layer enforces a second idempotency invariant independent of the batch cursor:
- Each on-chain event is uniquely identified by
(tx_hash, event_index). - Migration
v011_raw_events_uniquecreates theraw_eventstable and theraw_events_idempotencyunique index on that pair. - Normal ingestion inserts with
ON CONFLICT(tx_hash, event_index) DO NOTHING, so a re-delivered event in a different batch is ignored instead of duplicated. - Reorg recovery re-ingests with
replaceOnConflict: true, usingON CONFLICT DO UPDATEso canonical rows replace orphaned data for the same key.
Invariant: at most one row exists per (tx_hash, event_index). The scheduled invariant suite (checkRawEventIdempotency) scans for accidental duplicates and alerts if any are found.
SQLite UNIQUE violations surface as RawEventDuplicateError (code RAW_EVENT_DUPLICATE) rather than crashing the process.
All events in a batch are validated before commitBatch is called. If any event is malformed, the entire batch is rejected and the store is not touched. This prevents "half-indexed" states where some events from a batch are persisted but others are not.
IngestionStore has three methods:
| Method | Responsibility |
|---|---|
getCursor() |
Return the last committed cursor (or null). |
commitBatch(events, cursor) |
Persist events and advance cursor atomically. Must throw on failure without partial writes. |
rollbackTo(cursor) |
Delete events above cursor and reset the committed cursor. Used for reorg recovery. |
InMemoryIngestionStore and SqliteIngestionStore satisfy this contract. Production uses the SQLite implementation backed by raw_events.
- The backend persists current indexer freshness in the SQLite table
freshness_state. - On boot,
backend/src/services/freshnessService.tsloads the saved cursor and timestamp fromfreshness_stateand uses them to compute response freshness. - If the table is empty or missing, the service falls back to the existing in-memory default behavior and returns a conservative freshness estimate.
- Updates to freshness state are debounced by 100ms to avoid repeated database writes during rapid cursor advancement.
- Concurrent freshness updates always keep the latest cursor/timestamp pair, and the final debounced write persists the most recent value.
- Cursor can only advance, never regress. The idempotency check enforces this.
- Validation runs before any write, so invalid payloads cannot corrupt the index.
- The store interface is injected, making it straightforward to swap in a real DB-backed implementation with proper ACID transactions.
-
Dashboard indexer queries — operator-ready SQL for freshness, throughput, replay integrity, and materialized views.
-
Attestation Events — Schema and Lifecycle — full topic reference,
RawEventwire format, and attestation lifecycle for downstream integrators.