Skip to content

feat(evm): RPC-backed data source + fallback data source#510

Merged
abernatskiy merged 52 commits into
masterfrom
feat/rpc-fallback
Jul 8, 2026
Merged

feat(evm): RPC-backed data source + fallback data source#510
abernatskiy merged 52 commits into
masterfrom
feat/rpc-fallback

Conversation

@abernatskiy

@abernatskiy abernatskiy commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Why

A processor today has a single data source — the Portal. When the Portal is unhealthy, indexing stops. This PR lets a processor fall back to RPC (or, more generally, to any of N preference-ordered sources) so it keeps making progress. The goal for v0 is no downtime, not perfectly flat freshness; EVM only.

Two capabilities make that possible. They ship as subpaths of a new umbrella package, @subsquid/squid-sdk (see Packaging) rather than as standalone packages:

@subsquid/squid-sdk/evm/rpc — an RPC source whose output matches the Portal's

A fallback only works if switching between sources is invisible to everything downstream — same block model, same fields, same fork semantics. The hard part is making RPC produce exactly what the Portal produces.

Rather than re-deriving that mapping (and forever chasing parity), this reuses the Portal's own decoder and the proven filtering logic, so RPC output matches by construction rather than by best effort. A processor can drop this in wherever a Portal source goes; nothing else changes. Per-network presets supply the small amount of chain config that can't be auto-detected, so the output matches the published dataset.

It depends on @subsquid/evm-rpc/evm-normalization only optionally — Portal-only users pull in nothing extra.

@subsquid/squid-sdk/evm/fallback (over the VM-agnostic @subsquid/squid-sdk/fallback) — the fallback itself

A meta-source that drives the lowest-index healthy source and, on failure, resumes the next one from the last committed position. The supervisor itself is VM-agnostic (exposed at @subsquid/squid-sdk/fallback); evm/fallback is a thin EVM binding (createEvmFallbackSource) over it. Two design choices carry the weight:

  • Forks across a switch are not a special case. Because every source identifies position by {number, hash} and the sources are stateless, a fork that straddles a switch is just an ordinary reorg — it flows through the processor's existing rollback path. No new fork machinery.
  • Health has to mean "can still serve this exact data," not just "reachable." A source that's up but can no longer serve the configured fields, or that silently falls behind the chain head, must count as unhealthy — otherwise a switch would quietly change the output or the indexer would stall invisibly. So health is trinary with capability probes, and "falling behind" is a first-class, metered signal.

Health and switch state are exported as metrics so an unhealthy or lagging source is visible.

Does it actually match the Portal?

Yes — verified live against Ethereum mainnet: transactions, logs, and traces decode identically to the Portal's output, and a [Portal, RPC] fallback both streams a range and fails over mid-stream when the primary dies. (State diffs match up to a small, documented node-version difference, not a config one.) Offline suites cover the filtering, health/failover, and per-network presets.

Packaging

The RPC source and the fallback are folded into a new umbrella package, @subsquid/squid-sdk (at meta/squid-sdk), exposed via subpaths (evm/rpc, evm/fallback, fallback). The previously-standalone @subsquid/evm-rpc-stream and @subsquid/evm-fallback-stream packages are removed / not published — their source now lives in the umbrella. The umbrella also re-exports the core *-stream and util packages as subpaths (those stay published standalone). Subpaths ship both an exports map and a typesVersions map, so they type-check under a squid's classic moduleResolution: node as well as node16/nodenext/bundler; Node honors exports at runtime.

@subsquid/evm-rpc and @subsquid/evm-normalization are optional peer dependencies — a Portal-only or non-EVM squid installs neither and never loads the RPC stack (the EVM binding requires evm/rpc lazily, translating a missing peer into an actionable error).

Newly published packages: @subsquid/evm-rpc, @subsquid/evm-normalization, @subsquid/squid-sdk. rush.json moves @subsquid/evm-rpc/evm-normalization from the docker version policy to npm so the release workflow publishes them. This does not affect the ingestion docker images — they bundle these packages via rush deploy's dependency graph, independent of version policy.

Related

The Pipes SDK gets the equivalent capability in two stacked PRs (the finalized-watermark prerequisite, then the RPC source + fallback). The two SDKs share no code — only test scenarios.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds two new additive EVM packages to enable preference-ordered data sources so processors can continue indexing through Portal outages: an RPC-backed source that matches Portal output, and a meta “fallback” source with health/freshness-based switching and exported metrics.

Changes:

  • Register @subsquid/evm-rpc-stream and @subsquid/evm-fallback-stream in rush.json, and move @subsquid/evm-rpc + @subsquid/evm-normalization to the publishable version policy.
  • Implement @subsquid/evm-rpc-stream by normalizing RPC blocks, decoding them via the Portal schema, and applying a ported filter engine; add per-network presets + parity tests.
  • Implement @subsquid/evm-fallback-stream with trinary health, lag/staleness freshness triggers, Prometheus gauges, and an EVM-specific convenience builder; add unit + live e2e tests.

Reviewed changes

Copilot reviewed 41 out of 41 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
rush.json Registers new packages and adjusts version policies so required deps can be published.
evm/evm-rpc-stream/tsconfig.json TypeScript build config for the new RPC-backed stream package.
evm/evm-rpc-stream/package.json Declares package metadata, build/test scripts, and dependencies for RPC stream.
evm/evm-rpc-stream/src/index.ts Public exports for rpc-stream (decode/filter/source/presets/builder).
evm/evm-rpc-stream/src/builder.ts Convenience builder evmRpcStream(...) resolving network settings and wiring Rpc.
evm/evm-rpc-stream/src/source/data-source.ts Core RPC-backed DataSource that normalizes, decodes via Portal schema, filters, and (when needed) projects output.
evm/evm-rpc-stream/src/source/augment.ts Field-selection augmentation for evaluating where clauses on unselected fields.
evm/evm-rpc-stream/src/source/parity.e2e.test.ts Live parity checks comparing RPC source output to Portal output (gated).
evm/evm-rpc-stream/src/networks/types.ts Types for per-network presets (validation + method selection).
evm/evm-rpc-stream/src/networks/_families.ts Reusable preset fragments (standard validation, OP-stack method toggles).
evm/evm-rpc-stream/src/networks/index.ts Preset registry and resolver (preset ⊕ overrides).
evm/evm-rpc-stream/src/networks/ethereum-mainnet.ts Ethereum mainnet preset.
evm/evm-rpc-stream/src/networks/optimism-mainnet.ts Optimism preset (OP-stack).
evm/evm-rpc-stream/src/networks/base-mainnet.ts Base preset (OP-stack).
evm/evm-rpc-stream/src/networks/arbitrum-one.ts Arbitrum preset.
evm/evm-rpc-stream/src/networks/gnosis-mainnet.ts Gnosis preset (trace API, adjusted validation).
evm/evm-rpc-stream/src/networks/polygon-mainnet.ts Polygon preset (Bor-specific validation/finality).
evm/evm-rpc-stream/src/networks/cronos-mainnet.ts Cronos preset (Ethermint-specific validation choices).
evm/evm-rpc-stream/src/networks/networks.test.ts Golden tests asserting presets match documented infra config expectations.
evm/evm-rpc-stream/src/filter/request.ts Request flattening + coarse “required data” derivation for RPC fetch toggles/capability needs.
evm/evm-rpc-stream/src/filter/request.test.ts Unit tests for request flattening and required-data derivation.
evm/evm-rpc-stream/src/filter/filter.ts Ported client-side filter engine and relation expansion on the flat block model.
evm/evm-rpc-stream/src/filter/filter.test.ts Unit tests validating filter semantics and relation expansion.
evm/evm-rpc-stream/src/decode/decode.ts Portal-schema-based decode pipeline for normalized RPC blocks.
evm/evm-rpc-stream/src/decode/portal-map.ts Copied Portal mapping helpers to keep output shape identical.
evm/evm-rpc-stream/src/decode/shim.ts Small wire-shim to satisfy Portal schema tagged-union expectations for traces.
evm/evm-rpc-stream/src/decode/shim.test.ts Unit tests for the wire-shim transformations.
evm/evm-rpc-stream/src/decode/decode.lfs.test.ts LFS-gated offline decode test on a real fixture.
evm/evm-fallback-stream/tsconfig.json TypeScript build config for the fallback package.
evm/evm-fallback-stream/package.json Declares fallback package metadata, scripts, deps, and peers.
evm/evm-fallback-stream/src/index.ts Public exports for fallback, health, policy, metrics, and EVM convenience source.
evm/evm-fallback-stream/src/policy.ts Fallback policy configuration, defaults, and AllSourcesDownError.
evm/evm-fallback-stream/src/health.ts Trinary health state machine used by the fallback selector/driver.
evm/evm-fallback-stream/src/selector.ts Selection logic for failover vs switch-up using trinary health.
evm/evm-fallback-stream/src/fallback.ts Core fallback DataSource implementing switching and freshness checks.
evm/evm-fallback-stream/src/metrics.ts Prometheus gauges exposing fallback state.
evm/evm-fallback-stream/src/evm/source.ts EVM-specific helper building a FallbackDataSource<Block<F>> from Portal/RPC sources.
evm/evm-fallback-stream/src/fallback.test.ts Unit tests covering selection, switching, forks, all-down, freshness, getHead, metrics snapshot.
evm/evm-fallback-stream/src/health.test.ts Unit tests for health transitions and cooldown behavior.
evm/evm-fallback-stream/src/metrics.test.ts Unit test asserting Prometheus exposition output contains expected gauges/labels.
evm/evm-fallback-stream/src/evm/source.e2e.test.ts Live E2E test for Portal+RPC fallback and forced failover (gated).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread evm/evm-fallback-stream/package.json Outdated
Comment thread meta/squid-sdk/src/fallback/fallback.ts
Comment thread evm/evm-rpc-stream/src/source/data-source.ts Outdated
Comment thread evm/evm-rpc-stream/src/source/augment.ts Outdated
abernatskiy added a commit that referenced this pull request Jun 25, 2026
Wire up two half-finished pieces and correct two stale comments flagged in
the Copilot review:

- evm-fallback-stream entrypoint no longer crashes for Portal-only consumers.
  The EVM glue's peer imports are now type-only and @subsquid/evm-rpc-stream
  is loaded with a lazy require reached only on the `rpc` branch, so
  require('@subsquid/evm-fallback-stream') touches neither optional peer.
  Configuring an `rpc` source without them now fails with an actionable
  message. @subsquid/evm-rpc joins the optional peers (the public config type
  references its Rpc).

- Proactive probing is now actually wired. The head poll feeds onLivenessPass
  on success (it is the liveness probe), and probeCapability is invoked once a
  source is reachable, feeding onCapability. Eager switch-up no longer depends
  on the freshness head-polls: each boundary proactively probes the
  higher-preference standbys, so a recovered source reaches `healthy` and is
  reclaimed even with maxLagBlocks/maxStalenessMs disabled. Two integration
  tests cover liveness-driven recovery and capability-gated switch-up.

- Corrected the EvmRpcStreamDataSource / augmentFields comments that still
  described the project-back step as unimplemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@abernatskiy abernatskiy requested a review from Copilot June 25, 2026 23:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated 4 comments.

Comment thread meta/squid-sdk/src/evm/rpc/source/augment.ts Outdated
Comment thread meta/squid-sdk/src/fallback/policy.ts
Comment thread meta/squid-sdk/src/fallback/metrics.ts
Comment thread evm/evm-fallback-stream/src/metrics.test.ts
abernatskiy added a commit that referenced this pull request Jun 25, 2026
- policy doc: AllSourcesDown → AllSourcesDownError (actual exported name).
- metrics: rename the switch-count gauge from `_switches_total` to
  `_switches`; the `_total` suffix is reserved for Counters by Prometheus
  convention and this is a set-on-scrape Gauge. Test updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@abernatskiy abernatskiy requested a review from Copilot June 25, 2026 23:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated 4 comments.

Comment thread evm/evm-rpc-stream/package.json Outdated
Comment thread evm/evm-fallback-stream/src/fallback.ts
Comment thread evm/evm-fallback-stream/src/fallback.ts Outdated
Comment thread rush.json Outdated
abernatskiy added a commit that referenced this pull request Jun 26, 2026
- evm-rpc-stream: add the `test:lfs` script (VITEST_LFS=1) so `rush test:lfs`
  actually runs decode.lfs.test.ts, matching the repo convention.
- fallback: clear `activeIndex` while all sources are down, so metrics
  (`${prefix}_active`) no longer report a source as active when none is
  eligible. Switch-counting now keys off a retained `#lastActive`, so a
  resume on a different source after the gap still registers as a switch and a
  resume on the same one does not. Covers both runStream and delegateHead.
- Add Rush change files for the two new publishable packages so the
  change-management/release flow recognises them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@abernatskiy abernatskiy requested a review from Copilot June 26, 2026 00:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 43 out of 43 changed files in this pull request and generated 4 comments.

Comment thread evm/evm-fallback-stream/src/evm/source.ts Outdated
Comment thread evm/evm-fallback-stream/src/fallback.ts Outdated
Comment thread evm/evm-fallback-stream/src/fallback.ts
Comment thread evm/evm-rpc-stream/package.json Outdated
abernatskiy added a commit that referenced this pull request Jun 26, 2026
Wire up two half-finished pieces and correct two stale comments flagged in
the Copilot review:

- evm-fallback-stream entrypoint no longer crashes for Portal-only consumers.
  The EVM glue's peer imports are now type-only and @subsquid/evm-rpc-stream
  is loaded with a lazy require reached only on the `rpc` branch, so
  require('@subsquid/evm-fallback-stream') touches neither optional peer.
  Configuring an `rpc` source without them now fails with an actionable
  message. @subsquid/evm-rpc joins the optional peers (the public config type
  references its Rpc).

- Proactive probing is now actually wired. The head poll feeds onLivenessPass
  on success (it is the liveness probe), and probeCapability is invoked once a
  source is reachable, feeding onCapability. Eager switch-up no longer depends
  on the freshness head-polls: each boundary proactively probes the
  higher-preference standbys, so a recovered source reaches `healthy` and is
  reclaimed even with maxLagBlocks/maxStalenessMs disabled. Two integration
  tests cover liveness-driven recovery and capability-gated switch-up.

- Corrected the EvmRpcStreamDataSource / augmentFields comments that still
  described the project-back step as unimplemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
abernatskiy added a commit that referenced this pull request Jun 26, 2026
- policy doc: AllSourcesDown → AllSourcesDownError (actual exported name).
- metrics: rename the switch-count gauge from `_switches_total` to
  `_switches`; the `_total` suffix is reserved for Counters by Prometheus
  convention and this is a set-on-scrape Gauge. Test updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
abernatskiy added a commit that referenced this pull request Jun 26, 2026
- evm-rpc-stream: add the `test:lfs` script (VITEST_LFS=1) so `rush test:lfs`
  actually runs decode.lfs.test.ts, matching the repo convention.
- fallback: clear `activeIndex` while all sources are down, so metrics
  (`${prefix}_active`) no longer report a source as active when none is
  eligible. Switch-counting now keys off a retained `#lastActive`, so a
  resume on a different source after the gap still registers as a switch and a
  resume on the same one does not. Covers both runStream and delegateHead.
- Add Rush change files for the two new publishable packages so the
  change-management/release flow recognises them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
abernatskiy added a commit that referenced this pull request Jun 26, 2026
- loadRpcStream: only translate a MODULE_NOT_FOUND (peers not installed) into
  the "install the optional peers" hint; rethrow any real error thrown from
  inside the package so it isn't masked.
- laggingTooFar: reset `lag` to 0 when there's no independent head reference,
  so metrics don't report a stale lag when it's currently not computable.
- nextWithStaleness: clear `chainStalled` once a batch resolves or the source
  errors, so the global-stall flag can't stick after progress resumes or a
  failover. Asserted in the global-stall test.
- evm-rpc-stream: drop unused deps @subsquid/logger and util-internal-hex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@abernatskiy abernatskiy requested a review from Copilot June 26, 2026 01:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 44 out of 45 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • common/config/rush/pnpm-lock.yaml: Generated file

Comment thread evm/evm-rpc-stream/src/source/data-source.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 45 out of 46 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • common/config/rush/pnpm-lock.yaml: Generated file

Comment thread evm/evm-fallback-stream/src/evm/source.ts Outdated
Comment thread evm/evm-fallback-stream/package.json Outdated
abernatskiy added a commit that referenced this pull request Jun 26, 2026
- loadRpcStream: narrow the "peers not installed" translation to a
  MODULE_NOT_FOUND that names @subsquid/evm-rpc-stream itself (matched by its
  quoted name in the message). A missing transitive dep inside the package
  names a different module and now surfaces unchanged instead of being masked
  by a misleading install hint.
- Drop the unused @subsquid/util-internal dependency from evm-fallback-stream
  (only util-internal-data-source / util-internal-range are imported).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@abernatskiy abernatskiy requested a review from Copilot June 26, 2026 01:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 45 out of 46 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • common/config/rush/pnpm-lock.yaml: Generated file

Comment thread meta/squid-sdk/src/evm/rpc/source/augment.ts Outdated
abernatskiy and others added 30 commits July 9, 2026 03:45
…parity)

The RPC source streamed every block in the caller's window and only filtered
those covered by a request range — so a block in a *gap* between non-contiguous
ranges was emitted unfiltered (all logs/txs/traces/stateDiffs), which the Portal
would never return, and which disagreed with getBlocksCountInRange (counts only
request ranges). That broke the drop-in guarantee.

Now getStream/getFinalizedStream iterate the request ranges intersected with the
caller's [from, to] (like the Portal's one-query-per-range), via a standalone
streamBoundedRanges() generator. Gaps are never streamed. parentHash is threaded
through contiguous ranges (so the inner source's continuity/fork detection still
works across a seam) and dropped across a gap; the caller's parentHash is kept
for the first block, preserving fork-on-resume detection for the fallback.

Unit-tested with a mock inner: gap-skipping, parentHash threading across
contiguous ranges + reset across gaps, and window clipping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tream

- setActive: on a real switch, clear lag/staleness/chainStalled/chainHead. They
  describe the active source, and the previous source's values (e.g. a non-zero
  staleness left when nextWithStaleness returns STALE) would otherwise linger in
  the gauges until the new source's next batch/head poll.
- runStream: reset #lagArmed at the start of each stream. It latches on the
  instance once the tip is reached; a reused instance starting a later
  far-behind-head backfill must not inherit it and spuriously fail over.

Tests: freshness reset on switch (empty standby so no boundary recomputes), and
per-stream re-arming (same instance: tip-reaching stream then a backfill).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… frontier

Adds a generic `makeCapabilityProbe(source)` (any DataSource<B>): it fetches a
one-block slice of exactly the data the source is configured to serve — the
query is baked into the source, so a bounded getStream re-exercises the whole
pipeline — and reports whether the source could serve it. Catches the
reachable-but-incapable failures liveness alone misses (trace/debug_ API
disabled, pruned state at depth, missing receipts). Capable iff the slice yields
a batch or ends cleanly; a ForkException counts as capable; any other error or a
timeout is not-capable.

The supervisor anchors the probed block to the indexing frontier, not the chain
tip: `target = clamp(lastCommitted + capabilityLookahead, 0, head - capabilityTipMargin)`.
During a backfill this verifies the source can serve at the depth it is about to
read in bulk; when caught up it lands on a settled block off the reorg-prone tip.
The callback receives the resolved block (`probeCapability(atBlock)`), so it
needs no head of its own — the supervisor reuses the head it already polled.

createEvmFallbackSource attaches the probe to every source by default
(`capabilityProbe?: false | {timeoutMs}` to opt out / tune). lookahead and
tipMargin default to 16, configurable on the policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A source with a capability probe could flap back to `healthy` on liveness
alone after going unhealthy: `#capabilityOk` was sticky-true once confirmed,
so `#toUnhealthy()` left it set and recovery needed only `M` liveness passes.
That is exactly the churn loop a probe is meant to prevent — a node that stays
reachable but keeps failing the real query (e.g. a Portal returning HTTP 400
on a query that passed type-level validation, or an RPC node with traces
disabled) would recover, get re-promoted, fail again.

Store `hasCapabilityProbe` and drop `#capabilityOk` on `#toUnhealthy()` for
probed sources, so health can never return to `healthy` until a fresh probe
succeeds. Probe-less sources are unaffected (capability stays always-confirmed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + metrics

Since the fallback relies on sources *throwing* to detect incapability, the
supervisor never knows the error type up front. New `diagnostics.ts` normalizes
any thrown value into a `SourceErrorInfo` by duck-typing the conventional fields
the SDK already stamps (`addErrorContext`'s `rpcUrl`/`rpcMethod`/`rpcParams`;
`HttpError.response.{status,url}`; `RpcError.code`; transport error names):

  - `check` (stream|liveness|capability) + `reason` (http|rpc|timeout|connection|
    stale|lag|fork|unknown) + `code` — bounded, safe as metric labels.
  - `detail` — full text incl. the request, for logs only; URLs are run through a
    credential redactor (drops userinfo/query, masks `sqd_` tokens) so API keys
    never reach logs.

Wiring:
  - The capability probe now returns `{ok, cause?}` instead of a bare boolean.
  - `SourceHealth` stores the cause; `FallbackDataSource.failSource()` feeds it to
    health and logs (via an injectable `logger`, default `console.warn`) only on a
    real transition to unhealthy.
  - The `source_health` Prometheus gauge gains `check`/`reason`/`code` labels on
    the unhealthy row (empty otherwise); the gauge is reset per scrape so stale
    cause series don't linger. The request body is never a label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ected one

Sibling data packages don't take an injectable logger — `*-stream` packages don't
log at all, and the ones that do (`@subsquid/evm-rpc`, `evm-processor`) create it
statically with `createLogger('sqd:…')`. So drop the `logger?` option (and the
ad-hoc `FallbackLogger`/`consoleLogger` shim) and create the cause logger
statically as `createLogger('sqd:evm-fallback-stream')`, matching the namespace
convention (`sqd:evm-rpc`, `sqd:evm-data-service`). The cause is still also exposed
through `metrics()` for callers preferring a metrics surface.

Tests raise the `sqd:evm-fallback*` level to ERROR via `setLogLevelCallback` so the
expected unhealthy-transition WARNs stay out of the test output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… stream

`delegateHead()` (backing `getHead`/`getFinalizedHead`) classified a failed head
fetch as `check: 'stream'`, marking the source unhealthy on the first error. But a
head query is the liveness signal everywhere else (`getCachedHead`), and head +
stream share one `SourceHealth` — so a single transient head blip would condemn
the source (cooldown) and needlessly switch an otherwise-healthy stream. Classify
it as `'liveness'` so it goes through the K-consecutive-fail threshold and a blip
just retries the same source instead. (Per PR #510 review.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…robing

Remove the `churnOnGlobalStall` policy knob. On a global stall every source sits
at the same head, so there is nowhere fresher to switch to — churning through
equally-stuck sources is pointless. The supervisor now always holds the active
source in that case (the former `false` default).

The hold is not passive: each time staleness re-trips, `chainHeadOthers` re-polls
the other sources, which doubles as their liveness/capability probe — so a held
supervisor keeps watching for recovery and leaves the hold as soon as any of: the
active source delivers the next batch, the active source errors (failover), or
another source advances past us (`others > lastNumber` ⇒ STALE). Added test (d2)
asserting the held source keeps probing (liveness + capability) and recovers by
switching to a source that becomes fresher, without the active ever resolving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per PR #510 review: the EVM glue's types reference the optional peers, so the
package `.d.ts` does too. Spell out that this is type-check-only (runtime and all
transpile-only runners are unaffected), neutralized by `skipLibCheck: true` (our
repo/template default), and only surfaces under `skipLibCheck: false` with the
peers omitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`clearActive()` zeroed `activeIndex` when no source is eligible but left
`lag`/`staleness`/`chainStalled`/`chainHead` reporting the last active
source's freshness, contradicting the "these gauges describe the active
source" contract. Clear them too, and cover it with an all-down test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…510 review)

`#lagArmed` is meant to arm once the active source reaches the tip, so a deep
backfill never trips lag failover. But the arming condition `lag <= maxLagBlocks`
also armed on a *negative* lag — when the independent reference (max head of the
other sources) is itself behind the active source's last committed block. A
stale/lagging standby would then arm the latch mid-backfill, and once that
standby recovered to the real tip its head jump would trip a spurious failover.

Gate arming on `lag >= 0` so it only arms when the active is genuinely at/near
the tip (within threshold and not ahead of the reference). Added a regression
test: a standby first behind us, then far ahead, must not cause a failover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The doc comments referenced an internal planning doc (`CHAIN_SPECIFIC.md`
§2 / §4.1) that was never committed, leaving consumers with broken pointers.
Describe the "C1" per-chain config concept inline instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the trace-relation helper (and its call site) to the standard spelling
so the misspelling doesn't propagate to future uses/searches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ction

Four issues found reviewing my own PR:

1. [availability] A sick standby whose `getHead` hangs (TCP up, no response) could
   stall the healthy active source, because head polls are awaited on the
   batch-critical path (lag check every batch, staleness hold, switch-up). Add
   `headPollTimeoutMs` (default 500ms; null defers to the client's own timeout) and
   time-box each poll — a timeout counts as a liveness failure for that standby and
   never blocks the primary.

2. [observability] The active source's capability probe never runs (it only fires
   for standbys), so a cold-start primary served forever yet stayed `unknown`. A
   delivered batch proves capability (an incapable source throws rather than yields),
   so confirm capability on each batch — the primary now reaches `healthy`.

3. [correctness] Project-back matched items by a synthesized structural key, which
   collides for items that share one (block-reward traces carry no transactionIndex),
   projecting the wrong one. Replace with position/identity matching against the
   pre-filter decode (`keptByPosition`) — the two decodes align 1:1 by index.

4. [cleanup] Drop the dead `transactions` toggle from `RequiredData`/`toRequiredData`
   (and now-unused `TX_FIELDS`): the RPC source always fetches full transactions, so
   it could never be false. Comment left explaining why.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold @subsquid/evm-rpc-stream and @subsquid/evm-fallback-stream into a new
@subsquid/squid-sdk package as the /evm-rpc-stream and /evm-fallback-stream
subpaths, removing the standalone packages. Re-export the core *-stream and
util packages (evm-stream, solana-stream, fuel-stream, starknet-stream, the
util-internal family, logger, portal-client, http-client, rpc-client,
util-timeout) as additional subpaths, kept standalone.

Subpath resolution ships both an `exports` map and a `typesVersions` map so
imports type-check under classic `moduleResolution: node` (the standard squid
template's tsconfig) as well as node16/nodenext/bundler; Node honors `exports`
at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
…nal EVM peers

- Hierarchical subpaths replacing flat old-name slugs: evm, evm/rpc,
  evm/fallback, solana, fuel, starknet, fallback, util, util/<x>, client/<x>,
  logger.
- Extract the VM-agnostic fallback supervisor into its own `fallback` subpath;
  the EVM binding (createEvmFallbackSource) lives at `evm/fallback`.
- Make @subsquid/evm-rpc and @subsquid/evm-normalization optional peer
  dependencies. The fallback core has no EVM in its runtime graph, and
  evm/fallback requires evm/rpc lazily, so a Portal-only/non-EVM squid needs
  neither peer.

exports + typesVersions updated for the new tree; verified type-checking under
classic (template) and nodenext resolution and runtime require() for all 19
subpaths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
The @subsquid/squid-sdk root doc comment still showed the pre-restructure
subpaths (/evm-fallback-stream, /util-internal). Point them at the actual
exports (/evm/fallback, /util).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
Rename the project folder sdk/squid-sdk -> meta/squid-sdk and update its Rush
projectFolder registration. No package contents change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
Making @subsquid/evm-rpc and @subsquid/evm-normalization optional peers means
the lazy require('../rpc') in the EVM fallback binding can now fail with a raw
MODULE_NOT_FOUND when a squid configures an 'rpc' source without installing the
peers. Reinstate the actionable guard (dropped during the fold-in, when the RPC
stack was still a hard dep) — now keyed on the two optional peers — and fix the
stale "always resolves" doc comment. Extracted into a testable helper with unit
coverage for translate-vs-passthrough.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
A capability probe that reports not-capable is not a freshness failure, but
both the supervisor's no-cause path and the probe helper's timeout path built
their SourceErrorInfo via freshnessFailure(..., 'stale', ...), surfacing a
misleading `stale` reason in logs/metrics. Add a capabilityFailure() builder
(reason defaults to `unknown`, or an accurate category) and use it: `unknown`
for a bare {ok:false}, `timeout` for a probe that outran its timeout. Tests
updated/added accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
The supervisor is VM-agnostic (@subsquid/squid-sdk/fallback) but still logged
under the fold-in-era sqd:evm-fallback-stream namespace, which misleads log
filtering/alerting for non-EVM users. Use sqd:fallback (matching the repo's
sqd:<component> convention) and update the test's log-level callback to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
@subsquid/starknet-stream@1.2.0 is uninstallable from npm: it requires
@subsquid/starknet-rpc@^0.0.2, but starknet-rpc is a docker-policy package and
only 0.0.1 is published — so pulling starknet-stream into the umbrella broke
`npm install @subsquid/squid-sdk` with ETARGET. Remove the `starknet` subpath
(dependency, export, typesVersions, barrel, README) and bump to 0.0.2. The
solana and fuel streams install cleanly and are kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
.npmrc was accidentally added in the previous commit. Its content is only the
`${NPM_TOKEN}` placeholder (no secret), but it is a local publishing artifact
and must not be tracked. Untrack it and add it to .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
Test-first refactors from the PR #510 code-quality review (behavior-preserving
except finding 1, which removes redundant RPC calls without changing output):

- (1) evm/rpc: apply the logs↔receipts de-duplication on the aggregate of all
  requests, not per-request. `toRequiredData` now reports the raw `logs` need and
  the new exported `unionRequiredData` applies `logs &&= !receipts` once, so a
  multi-request config (one needs logs, another needs receipts) no longer fires a
  redundant eth_getLogs every stride. Regression test added (verified failing pre-fix).
- (7) evm/rpc: `augmentFields` returns {fields, grew} with an in-place grew flag,
  deleting the second structural-diff pass (`selectionGrew`/`SELECTION_TYPES`).
- (4) fallback: extract the all-down/failover preamble duplicated between
  `runStream` and `delegateHead` into a `selectActive()` async generator.
- (5) fallback: collapse the identical freshness-gauge reset in `setActive`/
  `clearActive` into `resetFreshnessGauges()`.
- (6) fallback: extract the hand-rolled timeout-race and fire-and-forget iterator
  close into shared `withTimeout`/`safeReturn` (fallback/async.ts), reused by
  `headWithTimeout` and the capability probe; `sleep` now delegates to `delay`.
- (2) packaging: in-package guard test asserting exports/typesVersions/barrels
  stay in sync (verified catching a dropped typesVersions entry).
- (3) packaging: add the `./package.json` subpath export (an exports map is a hard
  allowlist). Bump to 0.0.3 + changefile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
…ilot #510)

Two log-hygiene leaks flagged by Copilot review:
- redactUrl left the URL fragment (#...) intact; it can carry a token and rode
  through url.toString() into logs. Clear url.hash.
- redactText matched only http(s)://, so ws:// / wss:// endpoints quoted in
  JSON-RPC provider errors leaked unredacted. Widen the pattern to wss?.

Tests extended: fragment stripping, ws:// credential redaction, and a redactText
block (previously untested).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
… (Copilot #510)

A custom `probeCapability` that throws synchronously (before returning a Promise)
escaped `#maybeProbeCapability` past the `#capabilityProbing[i] = true` line, so the
flag was never cleared — permanently blocking future probes for that source (and it
was mislabeled a liveness blip via getCachedHead's catch). Route the call through
`Promise.resolve().then(() => probe(target))` so a sync throw becomes a rejection
handled by the existing `.then(_, e => failSource('capability', e))` and the
`.finally` always clears the flag. Regression test verified failing pre-fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
…oc (Copilot #510)

- delegateHead marked head-query failures as liveness but never recorded a pass on
  success, so with "K consecutive liveness fails" semantics a transient blip stayed
  counted across intervening successes and a later blip could wrongly trip
  livenessFailThreshold. Call onLivenessPass() on success (mirrors getCachedHead).
  Regression test verified failing pre-fix (a non-consecutive second blip condemned
  the source and forced a failover).
- Qualify the streamBoundedRanges doc: the caller's parentHash is preserved for the
  first block only when the stream starts within the first request range (no leading
  gap); a stream starting inside a gap drops it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
…pilot #510)

metrics.ts re-declared MetricsSink even though @subsquid/util-internal-processor-tools
exports an identical interface (its `addMetricsSink` consumes it). Import and re-export
the upstream type — a single source of truth, no drift. Imported as a *type*, so it's
erased at build and the VM-agnostic fallback module keeps its zero runtime dependency
on processor-tools (verified: no require in the emitted metrics.js).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
…llision (Copilot #510)

`const exports = pkg.exports` shadows the CommonJS module wrapper's `exports` param and
would be a redeclaration SyntaxError if the file ran as CJS. Rename to `exportsMap`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2n1VHrhUa2szzVMW3mrkY
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.

2 participants