Skip to content

feat(evm): explicit factory pre-indexing phase before the main loop - #106

Open
mo4islona wants to merge 1 commit into
mainfrom
feat/factory-preindex
Open

feat(evm): explicit factory pre-indexing phase before the main loop#106
mo4islona wants to merge 1 commit into
mainfrom
feat/factory-preindex

Conversation

@mo4islona

@mo4islona mo4islona commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the removed _experimental_preindex with an explicit, automatically-managed factory pre-indexing phase, enabled declaratively:

contractFactory({
  address: '0x1f98431c8ad98523631ae4a59f267346ea31f984',
  event: factoryAbi.PoolCreated,
  childAddressField: 'pool',
  database: contractFactoryStore({ path: './pools.sqlite' }),
  preindex: true, // or { maxAddressFilterSize: 50_000 }
})

The old option was removed for being vague (a user-picked range silently changing query behavior). This version manages the range itself and runs as a visible, logged phase.

How it works

  1. Check run (every startup). Before the main loop, the pipe reads the persisted scan progress from the factory SQLite store and scans factory-creation events for the gap up to the finalized head, saving discovered child addresses. Progress advances with every batch, so an interrupted scan resumes where it stopped; a completed one only scans the delta since the last run (factory preindex: already up to date at block N).
  2. Main loop with a split query. Child events in the pre-indexed range [from … finalized] are requested with a server-side address filter (fast backfill, less traffic). Above the finalized head the query falls back to the usual wildcard (topic-only) request with client-side filtering, where inline discovery keeps running as before.

Fork safety is structural: the pre-indexed range never exceeds the finalized head, so it never rolls back; children discovered inline in the wildcard tail are handled by the existing Factory.fork() cleanup.

Notable details:

  • Progress is stored as a covered range (not a single watermark block) keyed by a hash of the factory config — lowering range.from or changing addresses/event/params between runs safely triggers a re-scan instead of silently missing children.
  • If the pre-pass finds no children, the historical child request is skipped entirely (no children ≤ watermark ⇒ no child events there).
  • Above maxAddressFilterSize children (default 5 000 — the list is embedded into every stream request body, ~50 bytes per address) the pipe warns and falls back to the wildcard query. The cap is enforced before wasting work: a start with more persisted children than the cap skips the gap scan entirely, and a first-run scan stops mid-way as soon as the discovered set crosses the cap. The per-batch progress stays honest, so raising the cap later resumes the scan from where it stopped.
  • Datasets without a finalized head (404 on /finalized-head) log a warning and keep today's single-pass behavior.
  • FactoryPersistentAdapter gains optional getPreindexedRange/setPreindexedRange — third-party adapters keep working and degrade to a full re-scan per start (idempotent).
  • Core change: the setup-query context now carries portal (SetupQueryCtx), and evmDecoder registers its log requests inside setupQuery instead of at construction time, since the split depends on the pre-pass result. Registration is memoized as a promise: a pre-pass that failed during setup stays failed on a retried stream start instead of silently merging a query without child-event requests.
  • A factory shared between several decoders serializes its pre-passes rather than reusing the first one: each decoder scans against its own range (a range extending below the covered one triggers the re-scan it needs), and overlapping follow-up runs become cheap no-ops thanks to the persisted progress.
  • The pre-pass is fully distinguishable in the logs: factory preindex: scanning blocks X…Y for factory 0x…, progress ticks in the main loop's format under the same prefix (factory preindex: 15,000,000 / 23,000,000 (65%), ETA: 1m 30s + blocks/s, bytes/s), then factory preindex: finished, blocks X…Y covered. The nested scan no longer emits a second Start indexing… line — the only one you see belongs to the main loop.

Coverage diff (base vs head)

File Lines (base → head) Δ Branches (base → head) Δ
src/core/portal-source.ts 93.1% → 93.1% +0.0 82.5% → 83.0% +0.4
src/core/query-builder.ts 94.2% → 97.2% +3.0 93.7% → 95.3% +1.6
src/core/transformer.ts 100.0% → 100.0% +0.0 97.3% → 97.3% +0.0
src/evm/evm-decoder.ts 97.3% → 97.7% +0.4 91.6% → 92.8% +1.2
src/evm/factory-adapters/sqlite.ts 88.6% → 97.5% +8.9 85.0% → 88.9% +3.9
src/evm/factory.ts 70.3% → 95.4% +25.0 95.3% → 87.8% −7.6
src/internal/hash.ts — → 52.9% new — → 60.0% new
src/testing/test-portal.ts 86.4% → 87.4% +1.0 88.1% → 87.5% −0.6
Total (changed files) 90.3% → 94.8% +4.5 90.4% → 89.9% −0.5

Base 378 tests → head 400 tests. The factory.ts branch dip comes from new defensive guards (non-404 error rethrow, missing-adapter-method warnings); hash.ts uncovered lines are environment-guard throws (no TextEncoder/crypto.subtle) unreachable under Node. New tests use the internal testing framework (createMockPortal + mockBlock/encodeEvent); createMockPortal learned a head option to serve /finalized-head deterministically, and createTestLogger learned a capture option to assert log output. The table predates the review follow-ups (5 extra tests slightly raise factory.ts/evm-decoder.ts further; src/core/progress-tracker.ts was touched by a pure extract-and-reuse refactor of the progress-message formatting).

Testing

  • 17 new tests in factory-preindex.test.ts: query split shape, empty-children skip, gap-only re-scan on restart, params-change re-scan under a new progress key, per-batch progress persistence + interrupted-scan resume, fork in the wildcard tail, no-finality fallback, maxAddressFilterSize fallback, unit tests for the range helpers — plus review follow-ups: serialized shared-factory pre-passes (same range → single scan; a range extending below the covered one → re-scan), loud failure on a retried setup after a failed pre-pass, factory preindex:-prefixed pre-pass logging without a duplicate start line, mid-scan abort once the child set crosses the cap (with resume after raising it), and skipping the gap scan when the persisted children already exceed the cap.
  • Full suite: 400 tests pass; pnpm build and Biome clean.
  • Real-portal smoke test (Uniswap V3, blocks 12,369,621–12,400,000): pre-pass discovered 773 pools, main query carried all 773 addresses server-side; restart logged factory preindex: already up to date at block 12400000 and went straight to the main loop.

🤖 Generated with Claude Code

@mo4islona
mo4islona force-pushed the feat/factory-preindex branch 3 times, most recently from 80e3724 to 224fefa Compare July 7, 2026 13:58
Replaces the removed _experimental_preindex with a declarative
preindex: true option on contractFactory. On startup the pipe scans
factory-creation events up to the finalized head, persists children
and scan progress in SQLite, then backfills child events with a
server-side address filter and falls back to a wildcard query above
the finalized head. Restarts and interrupted scans resume from the
persisted progress.

Co-Authored-By: Claude Fable 5 <[email protected]>
@mo4islona
mo4islona force-pushed the feat/factory-preindex branch from 224fefa to 8f87a15 Compare July 7, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant