feat: evmStream facade with multi-source RPC fallback and a pluggable switching strategy - #156
feat: evmStream facade with multi-source RPC fallback and a pluggable switching strategy#156abernatskiy wants to merge 43 commits into
Conversation
Port of the feat/rpc-fallback-source branch (PR #109) across the packages/subsquid-pipes -> packages/pipes rename, adapted to the renamed public API: Target.resolveFork, ForkException.canonicalBlocks, MissingForkAncestorError, defaultLogger, and the StreamInfo.state.ranges contract. Behavior is unchanged from the reviewed branch; all ported unit tests pass. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
…luggable strategy
The fallback moves under PortalStream as a BlockStreamClient — the exact
client surface PortalStream consumes — so outputs, decoders, multi-range
plans, progress, metrics and fork handling work identically for portal,
RPC and fallback sources. evmStream({portal: [...]}) accepts an ordered
source list (portal URLs/options, plain {type:'rpc', url} specs, custom
clients); evmPortalStream stays as a deprecated alias. Switching
decisions route through a FallbackStrategy function (code as config):
the stock algorithm is now defaultFallbackStrategy(policy), and a custom
strategy can override any decision per event while the engine keeps the
machinery and safety invariants (fork propagation, cursor-continuous
switches, the PortalStream-owned finalized watermark). The RPC source
now serves portal-wire-shaped blocks, deleting the projection re-decode
and the per-source batch-context assembly.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
…ields in raw-client e2e
The gated e2e suite now also drives evmStream over a pure {type:'rpc'}
source list — lazy peer load, wire-shape normalization, and the facade's
cast verified against a real endpoint. All three live tests pass against
rpc.subsquid.io/eth + the ethereum-mainnet portal, as does the two-test
wire-shape parity suite.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
…policy vs strategy Custom strategies now receive ctx.defaultCommand — the decision the stock algorithm would take for the event — so they can inspect, veto, or amend it instead of re-deriving it; returning undefined still lets it stand. defaultFallbackStrategy now accepts plain FallbackPolicy options, so a strategy can also delegate to its own differently-tuned instance. Docs now state the split: policy is measurement data plus the stock decision thresholds; strategy is decision code consulted with both the measurements and the stock decision. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
…urface Extract the raw-RPC → portal-wire mapping into createWireBlockMapper (rpc/wire.ts) so the filtering/projection pipeline is unit-testable on a hand-built block without a network; cover the fallback source-spec parsing (naming, lazy RPC construction, URL redaction, finality uniformity) and the RPC client's network-free surface. The remaining uncovered stream orchestration is exercised by the gated live e2e. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
…ents The sensing half of the fallback is now named for what it does: `fallback.detection` configures how failure and recovery are detected (capability probes — folded in from the stray top-level knob — head polls, liveness thresholds, cooldowns) and defines the freshness conditions, whose verdicts now ride on the strategy events (stall.stale, batch.lagging). The two genuine decision knobs (preferPrimary, allDownTimeoutMs) move to the deciding side: `fallback.strategy` accepts either stock-strategy options or a custom function, so each half owns exactly its own vocabulary. The stock strategy is now threshold-free — it acts on verdicts — which also removes the dual-use of maxLagBlocks/maxStalenessMs and the dead Selector class. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
…guards, typed boundaries Reuse cursorFromHeader and the shared sleep helper instead of local duplicates; move the cancellable delay timer to fallback-async next to its siblings; type the fallback's resume query as Query instead of any; replace the hand-rolled EvmQueryShape with the canonical evm Query type; split the boundary step into observe (verdict) and decide (strategy); report FallbackMetrics health via the FallbackHealth type. The portal cache guard now fails at PortalStream construction instead of first read; evmStream narrows its source without a cast; a dead BlockCursor re-export and an unneeded stream cast are gone; factory.runPreindex accepts any BlockStreamClient. Mock BlockStreamClients move into the internal testing framework (mockBlockStreamClient) per repo convention. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
54ecfb9 to
ae6ea4d
Compare
The new pipes peer range (^4.15.1) resolved next to pipes-cli's existing ^4.14.0 as two store entries, and the nominally-different RpcClient types broke pipes-cli's dts build in CI. Both ranges accept 4.16.0. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
There was a problem hiding this comment.
Pull request overview
This PR introduces a unified evmStream facade that can stream EVM data from a single Portal source, a JSON-RPC source, or an ordered multi-source list driven by a health-based fallback supervisor, while keeping Portal-wire compatibility so the existing decoding/transform pipeline remains unchanged.
Changes:
- Added a generic
BlockStreamClientcontract, aFallbackClientsupervisor (detection + strategy), and Prometheus-style fallback metrics. - Added an RPC-backed EVM block client plus RPC→Portal-wire mapping/filtering utilities, with optional/lazy loading of the RPC stack.
- Refactored EVM entrypoints/tests to use
evmStreamand keepevmPortalStreamas a deprecated alias.
Reviewed changes
Copilot reviewed 55 out of 56 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Locks newly added deps for RPC + fallback support. |
| packages/pipes/package.json | Adds optional peer deps for the RPC stack + internal utilities used by the new modules. |
| packages/pipes/src/testing/test-metrics-server.ts | Extends the mock Gauge with reset() to support stale-series pruning in metrics collect. |
| packages/pipes/src/testing/test-block-stream-client.ts | New in-memory BlockStreamClient mock used by fallback/probe tests. |
| packages/pipes/src/testing/index.ts | Re-exports the new mockBlockStreamClient helper. |
| packages/pipes/src/testing/evm/evm-portal-mock-stream.test.ts | Updates imports to use evmPortalStream from evm-stream. |
| packages/pipes/src/targets/pubsub/pubsub-target.test.ts | Updates EVM source import to the new facade module. |
| packages/pipes/src/targets/pubsub/pubsub-target-fork.test.ts | Updates EVM source import to the new facade module. |
| packages/pipes/src/targets/pubsub/pubsub-signal.test.ts | Updates EVM source import to the new facade module. |
| packages/pipes/src/targets/memory/memory-target.test.ts | Updates EVM source import to the new facade module. |
| packages/pipes/src/targets/bigquery/bigquery-target-fork.integration.test.ts | Updates EVM source import to the new facade module. |
| packages/pipes/src/portal-client/client.ts | Introduces BlockStreamClient + isBlockStreamClient for plugging non-Portal sources into PortalStream. |
| packages/pipes/src/evm/rpc/wire.ts | Adds RPC block → Portal-wire mapping with client-side filtering integration. |
| packages/pipes/src/evm/rpc/wire.test.ts | Unit tests for wire mapping + downstream cast/prune parity expectations. |
| packages/pipes/src/evm/rpc/shim.ts | Shim for trace-tag/action-field discrepancies between normalization output and Portal schema. |
| packages/pipes/src/evm/rpc/shim.test.ts | Tests for shim behavior and malformed input tolerance. |
| packages/pipes/src/evm/rpc/request.ts | Derives coarse RPC fetch toggles (logs vs receipts, traces, stateDiffs). |
| packages/pipes/src/evm/rpc/project.ts | Field augmentation for where-only keys + empty-block dropping parity logic. |
| packages/pipes/src/evm/rpc/project.test.ts | Tests for field augmentation and empty-block dropping behavior. |
| packages/pipes/src/evm/rpc/filter.ts | Client-side filtering + relation expansion to mirror Portal semantics for RPC blocks. |
| packages/pipes/src/evm/rpc/filter.test.ts | Tests for filter semantics, relations, and required-data derivation scenarios. |
| packages/pipes/src/evm/rpc/decode.ts | Required-field forcing and Portal decoder reuse for RPC-origin blocks. |
| packages/pipes/src/evm/rpc/decode.test.ts | Tests for required-field forcing behavior (cursor/filter discriminators). |
| packages/pipes/src/evm/factory.ts | Broadens preindex input type to BlockStreamClient and switches to new EVM stream import. |
| packages/pipes/src/evm/factory.test.ts | Updates EVM stream import to evm-stream. |
| packages/pipes/src/evm/evm-stream.ts | New evmStream facade + fallback-source-list support; keeps deprecated aliases. |
| packages/pipes/src/evm/evm-stream.test.ts | New unit tests covering facade defaults, fallback list behavior, strategy routing, and config validation. |
| packages/pipes/src/evm/evm-rpc-block-client.ts | RPC-backed BlockStreamClient implementation that yields Portal-wire-shaped blocks. |
| packages/pipes/src/evm/evm-rpc-block-client.test.ts | Network-free surface tests for RPC client construction/metadata/errors. |
| packages/pipes/src/evm/evm-rpc-block-client.parity.e2e.test.ts | Network-gated live parity tests (Portal vs RPC) using the same downstream cast. |
| packages/pipes/src/evm/evm-portal-source.test.ts | Removes the old portal-only stream test (replaced by evm-stream.test.ts). |
| packages/pipes/src/evm/evm-fallback.ts | Builds a fallback client from Portal/RPC/custom specs + lazy RPC loading & peer diagnostics. |
| packages/pipes/src/evm/evm-fallback.test.ts | Tests for barrel reachability and missing-peer translation + spec parsing. |
| packages/pipes/src/evm/evm-fallback.e2e.test.ts | Network-gated e2e tests for mixed Portal/RPC fallback and RPC-only facade streaming. |
| packages/pipes/src/evm/evm-decoder.test.ts | Updates EVM stream import to evm-stream. |
| packages/pipes/src/evm/browser.ts | Updates public EVM barrel exports to include fallback + new stream facade. |
| packages/pipes/src/evm/abi/define-abi.test.ts | Updates EVM stream import to evm-stream. |
| packages/pipes/src/core/transformer.ts | Generalizes transformer hook context from PortalClient to BlockStreamClient. |
| packages/pipes/src/core/portal-source.ts | Allows PortalStream to consume any BlockStreamClient; validates cache compatibility. |
| packages/pipes/src/core/metrics-server.ts | Extends Gauge interface with optional reset?() for collect-time stale-series pruning. |
| packages/pipes/src/core/index.ts | Exposes new fallback modules from the core barrel. |
| packages/pipes/src/core/fallback-strategy.ts | Adds fallback strategy types + default strategy implementation. |
| packages/pipes/src/core/fallback-strategy.test.ts | Unit tests for default strategy decisions and composition patterns. |
| packages/pipes/src/core/fallback-metrics.ts | Registers pull-based gauges exporting fallback state. |
| packages/pipes/src/core/fallback-metrics.test.ts | Tests for fallback metrics collection behavior and label handling. |
| packages/pipes/src/core/fallback-health.ts | Implements detection options + the per-source trinary health state machine. |
| packages/pipes/src/core/fallback-health.test.ts | Tests for health transitions, thresholds, cooldowns, and probe gating. |
| packages/pipes/src/core/fallback-diagnostics.ts | Error classification + URL redaction for logs/metrics-safe reporting. |
| packages/pipes/src/core/fallback-diagnostics.test.ts | Tests for URL/text redaction and error classification behavior. |
| packages/pipes/src/core/fallback-client.ts | Implements the multi-source FallbackClient supervisor (switching, probes, freshness ticks). |
| packages/pipes/src/core/fallback-capability.ts | Generic capability probe builder for BlockStreamClient sources. |
| packages/pipes/src/core/fallback-capability.test.ts | Tests for probe anchoring, fork tolerance, classification, and timeouts. |
| packages/pipes/src/core/fallback-async.ts | Async utilities (withTimeout, safeReturn, delay) used by fallback engine. |
| packages/pipes/src/core/fallback-async.test.ts | Tests promise hygiene (timeouts and iterator return). |
| packages/pipes/src/core/errors.ts | Updates example error message text to reference evmStream. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| export function isBlockStreamClient(value: unknown): value is BlockStreamClient { | ||
| return ( | ||
| typeof value === 'object' && | ||
| value != null && | ||
| typeof (value as BlockStreamClient).getStream === 'function' && | ||
| typeof (value as BlockStreamClient).getHead === 'function' | ||
| ) | ||
| } |
There was a problem hiding this comment.
(With Claude): Agreed — tightened in 9302a46. isBlockStreamClient now checks all six members (finalized, getUrl, getMetadata, getHead, resolveTimestamp, getStream), so a partial object is rejected where it is classified rather than failing deep inside a stream. Covered by a new test that deletes each member in turn.
| async *#stream(query: EvmQuery, options?: PortalBlockStreamOptions): AsyncGenerator<StreamData<any>> { | ||
| const { type: _type, fields = {}, fromBlock = 0, toBlock, parentBlockHash, ...request } = query | ||
| const mapper = createWireBlockMapper(fields, request) |
There was a problem hiding this comment.
(With Claude): Added in 9302a46 — #stream now throws EvmRpcBlockClient can only serve EVM queries, got type "<type>" before destructuring, with a test. The client implements the chain-generic contract but only speaks EVM, so naming that explicitly beats a downstream mapping error.
| stream?: (query: any) => AsyncGenerator<StreamData<any>> | ||
| /** The independent head poll; defaults to "no head known". */ | ||
| getHead?: () => Promise<BlockCursor | undefined> | ||
| } |
There was a problem hiding this comment.
(With Claude): Good catch — fixed in 9302a46. The mock now returns BlockRef (hash required) and forwards the { finalized } option, matching the contract; the test helpers were retyped accordingly. BlockCursor.hash being optional was exactly the looseness that could have hidden a missing head hash.
| meta: { | ||
| bytes: JSON.stringify(data).length, | ||
| requestedFromBlock: fromBlock, | ||
| lastBlockReceivedAt: new Date(), | ||
| requests: {}, |
There was a problem hiding this comment.
(With Claude): Considered and kept, with the tradeoff now documented in the code. The serialize costs a few ms per batch while the RPC fetch it accompanies takes seconds, so it is noise against the work it measures — and setting it to 0 would make the progress/throughput display read as broken, which is what this field feeds. On the UTF-16 point: the portal wire JSON here is ASCII (hex strings and numbers), so length equals byte length; the comment now says so. If profiling ever shows it mattering, swapping in a cheaper estimate is a one-line change.
…eview) isBlockStreamClient now checks every member of the contract, so a partial object is rejected at classification instead of failing deep inside a stream; EvmRpcBlockClient rejects a non-EVM query with a named error; the test mock's head poll matches the contract (BlockRef, and it forwards the finalized option) so tests can't hide a missing head hash. Both new guards are covered by tests. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 56 out of 57 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
packages/pipes/src/evm/evm-rpc-block-client.ts:161
PortalHead.latestis intended to represent the chain “latest” head number (PortalClient populates it fromX-Sqd-Head-Number), but here it is derived fromfinalizedHead(a finalized head). That can make progress/lag calculations treat the finalized head as the latest head for RPC sources, which is semantically incorrect and may under-report head/lag.
If the RPC stream cannot provide the latest head cheaply, it’s better to omit latest than to populate it with a finalized value.
const head = finalizedHead ? { number: finalizedHead.number, hash: finalizedHead.hash } : undefined
yield {
blocks: data,
head: { finalized: head, latest: head ? { number: head.number } : undefined },
meta: {
…view) `head.latest` is the chain head and feeds the progress denominator, so filling it from the finalized head reported an unbounded run complete a finality window early. The RPC stream has no cheap latest head, so the field is now absent — what a portal response without a head header does; freshness still comes from the independently polled getHead(). Scoping that fix uncovered a second issue: the pre-filter cast decoded the user's OUTPUT selection, so a valid query selecting receipt-backed transaction fields (gasUsed/status) while requesting only logs threw, because receipts are fetched only when transactions are requested. The internal cast now decodes just what filtering reads. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
|
(With Claude): Picking up the suppressed finding from the latest review — it was right, and fixing it surfaced a second bug. Both are in 7809529. 1. 2. (found while fixing 1) A valid query could throw. Receipts are fetched only when transactions are requested, but the pre-filter cast decoded the user's output selection — so Verified: 1069 unit tests green, plus the network-gated e2e suites — including a new assertion that a finalized-head batch never reports |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 56 out of 57 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
packages/pipes/src/core/fallback-client.ts:393
#observeBoundaryusescursor?.number ?? -1as a sentinel when no blocks have been delivered yet. But aBlockStreamClientcan legitimately yield empty batches (e.g.PortalClienton HTTP 204 yieldsblocks: []), socursorcan beundefinedat a batch boundary. In that case this code reportschainHead = -1(and can report a large positivelagwhen other sources have heads), which is a bogus value for observability and can confuse strategies that readctx.lagBlocks/metrics before the first real block arrives.
const lastNumber = cursor?.number ?? -1
this.chainHead = others != null ? Math.max(others, lastNumber) : lastNumber
if (others == null) {
this.lag = 0 // no independent reference ⇒ lag is not computable; don't report a stale value
} else {
…iew) A boundary can be reached with nothing delivered yet — a source may yield an empty batch, which is how the portal answers 204 — and measuring from a -1 sentinel published a chain-height-sized lag on the metrics gauge and in a strategy's ctx.lagBlocks. Lag now stays unset until there is a position to measure from, and chainHead reports only what is actually known. Failover was never affected: such a lag cannot arm the tip latch, and the lagging verdict requires it. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
|
(With Claude): The suppressed finding in the latest review was right too — fixed in 489a656.
Scope worth stating precisely: failover was never affected. Arming the tip latch requires Now both gauges stay unset until there is a position to measure from, and 1070 unit tests green. |
Enables the 'cheap bulk, then follow the tip' topology: a finalized-only portal backfills, its request then sits outstanding at the finality frontier, and staleness detection hands off to a hot RPC that is genuinely ahead. The uniformity check is replaced by two narrower rules. The pipe reports itself finalized only when EVERY source is — one hot source makes a fork reachable, and the flag gates whether a target keeps its rollback machinery and whether a finalized-requiring target forces the finalized stream. And each source's head is polled at its own commitment, so an exhausted finalized source reports its finalized head rather than the chain tip and never looks 'fresher' than a hot source that is ahead of it. Capability probes now mirror the stream's finality, and the head cache is cleared per stream since heads are commitment-specific. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd
|
(With Claude): Real race, fixed in 2acd29f. The lazy loader guarded on the resolved client, so every caller arriving before the first load finished started its own — and the two callers that race here are not exotic: a head poll and a capability probe overlap routinely, since probes are fire-and-forget and head polls run at every batch boundary. The result was several Rather than inline the guard, I moved the sharing into a small internal helper so the mechanism itself is testable, and pinned two properties:
Worth noting for the missing-peer path specifically: that failure is deterministic, so not caching it just means the actionable "install these peers" error is raised again on the next attempt rather than a stale rejection being replayed. 1105 unit tests, live e2e and the full build green. |
The option accepts RPC endpoints and fallback source lists, not just portals, so the old name no longer described it. The `portal` spelling is still accepted as a deprecated legacy option. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
…wide Replace the deprecated evmPortalStream / `portal` spelling in the CLI init templates, docs, examples, READMEs, migration notes and tests. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
| The EVM entry point went one step further: it is now `evmStream`, and its `portal` option is | ||
| renamed to `source` — the stream also accepts an ordered fallback list of sources there, so the | ||
| old name no longer described it. `evmPortalStream` remains as a deprecated alias of `evmStream`, | ||
| and the `portal` option spelling is still accepted but deprecated. |
There was a problem hiding this comment.
(With Claude): Reworded — the blanket claim is now scoped to the removed *PortalSource names, and the next paragraph already spells out the EVM exception (evmPortalStream deprecated alias, portal option spelling deprecated in favor of source). Fixed in 18aab60.
| @@ -674,7 +674,7 @@ There are **no deprecated aliases** in this release — every rename in breaking | |||
|
|
|||
| - `CompositeTransformer` / `compositeTransformer` / `composite-transformer.ts` removed — use named `outputs` | |||
| - `.pipeComposite()` removed — use named `outputs` | |||
| - `query` option removed from `evmPortalStream` and `solanaPortalStream` | |||
| - `query` option removed from `evmStream` and `solanaPortalStream` | |||
There was a problem hiding this comment.
(With Claude): Agreed — the Removals section now states the exception explicitly: evmPortalStream remains as a deprecated alias of evmStream, and the portal option spelling stays accepted (deprecated in favor of source), since that rename landed after the 1.0 betas shipped. Fixed in 18aab60.
…PortalSource alias (review) The barrel test compared evmStream to itself; it now pins the deprecated evmPortalStream alias to evmStream. evmPortalSource was already removed in 1.0 and is not reintroduced. Docs no longer contradict the alias. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 112 out of 115 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
packages/pipes/src/core/fallback-metrics.ts:127
- The
sqd_fallback_staleness_mshelp text says it measures how long a request has been outstanding, butFallbackClient.stalenessis defined/updated as unproductive wait (accumulated time answering without delivering a block, excluding consumer time). The metric description should match the implemented semantics so dashboards/alerts interpret it correctly.
…iew) The help text claimed a per-request outstanding clock; the gauge is fed by accumulated unproductive wait. Dashboards and alerts read the HELP line, so it must match what is measured. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
|
(With Claude): Addressed the suppressed finding from the latest Copilot round: the |
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
…ocs links
Generated projects pin @subsquid/pipes 1.0.0-alpha.22 exactly — the
emitted evmStream({ source }) code needs the alpha line, and any range
would resolve to a beta without it. README template links point at the
current docs tree and the UI runs off @beta.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
New `rpcFallback` config option (EVM only) and an interactive prompt. Generated pipes then read from a portal-primary source list with an RPC standby, require RPC_URL in the env, ship the optional RPC peers, and document the setup in README and docker-compose. The endpoint URL is asked for interactively but lands only in the generated .env — never in the committed pipes.config.json. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
…kNumber A healthy pipe at tip pace issued one eth_getBlockByNumber per block per standby: batch cadence outruns the 5s head cache, and the boundary poll ran even when nothing consumed it. The boundary now polls only when the heads have a consumer (lag detection, a stale verdict, a custom strategy, or a reclaimable more-preferred source), and head polls use a new optional number-only client method — eth_blockNumber on RPC sources — instead of a full block lookup. Reported by a beta tester. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
|
(With Claude): d452dea addresses a beta-tester report: at tip pace a healthy pipe issued one |
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
The wrapper enumerates the client contract explicitly, so the new optional number-only head poll was invisible through it and every poll silently stayed on the full eth_getBlockByNumber lookup — caught by a live smoke test, not by the unit tests, which hit the client directly. Added an end-to-end regression test through createEvmFallbackClient against a method-counting local RPC stub. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
Probing atCursor+1 parks the one-block slice on a block the chain has not produced yet; an RPC source spends that wait re-fetching its head every ~100ms, turning one probe into dozens of block lookups per block interval at the tip. The slice now reads the last delivered block, which exists and answers immediately. Ports the tip-safe anchoring the Squid SDK already had (via its capabilityTipMargin clamp). DEF-65 updated. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01W2HQnYVvTkKkVPJCB6pjn6
Summary
evmStream({source})now also accepts an ordered source list — portal URLs/options, plain{type: 'rpc', url}JSON-RPC endpoints, or custom clients — and drives them with health-based failover and switch-back. A single source compiles to exactly the previous code path, andevmPortalStreamremains a deprecated alias, so nothing breaks.portaloption renamed tosource: the option takes RPC endpoints and fallback lists, not just portals, so the old name no longer described it. Theportalspelling is still accepted (deprecated), and the repo — CLI init templates, docs, examples, tests — now usesevmStream+sourcethroughout.BlockStreamClientinterface (the exact client surfacePortalStreamconsumes).FallbackClientmultiplexes any such clients (chain-agnostic), andEvmRpcBlockClientserves portal-wire-shaped blocks off a JSON-RPC endpoint — so outputs/decoders, multi-range plans, cache guards, progress, metrics, the finalized watermark and fork handling work identically for every source kind. The RPC stack (@subsquid/evm-rpc+ peers) stays an optional, lazily-loaded dependency.detection.maxStalenessMs— the Portal's request simply sits outstanding there). A set containing any hot source reports itself as hot, so the target keeps fork handling, and each source's head is polled at its own commitment so an exhausted source never looks "fresher" than one that is genuinely ahead.fallback.detectionconfigures how failure/recovery is sensed — capability probes, head polls, liveness thresholds, cooldowns — and defines the freshness conditions, whose verdicts ride on the strategy events (stall.stale,batch.lagging).fallback.strategyis the deciding half: plain options tune the stock strategy ({ preferPrimary, allDownTimeoutMs }), or a function replaces its decisions perselect/batch/stallevent — it sees the measurements and the stock decision (ctx.defaultCommand), and returningundefinedkeeps stock behavior. Safety invariants (fork propagation, cursor-continuous boundary-only switches) are not delegated.Replaces #109 (same engine — health model, probes, staleness/lag detection, diagnostics, metrics — rebased across the package rename and restructured onto the facade).
Breaking changes (type-level)
StartContext.portal(transformerstarthooks) andEvmFactory.runPreindex'sportalare typedBlockStreamClientinstead ofPortalClient, because a pipe's source may now be an RPC-backed orfallback client rather than a portal. Runtime behaviour is unchanged — a single portal URL still
passes a real
PortalClient. A hook needing portal-specific request options should narrow withinstanceof PortalClient.Coverage
evm-rpc-block-client.tsitself sits at ~52% statements: the uncovered remainder is theEvmRpcDataSourcestream orchestration + fork translation, which requires a live endpoint and is covered by the network-gated e2e suites below.Test plan
FallbackClientsupervisor tests (failover, cursor-continuous resume, fork propagation across switches, staleness/lag/chain-stall, probe gating, all-down, gauges)defaultFallbackStrategytests + custom-strategy tests (pinning, voluntary jumps, stall holds,defaultCommandinspect/veto, per-event fallthrough, invalid index)fallbackconfig errors)RPC_E2E=1): RPC-vs-Portal wire parity at a historical block (equal decode, projection of where-only fields), portal→portal failover, the full facade streaming typed blocks from an RPC-only source list, and head semantics (a finalized-head batch never reportslatest)Review-round fixes (each with a regression test)
isBlockStreamClientnow verifies the whole contract, so a partial object is rejected at classification instead of failing mid-streamEvmRpcBlockClientrejects a non-EVM query by namehead.latestis no longer derived from the finalized head — that reported an unbounded run complete a finality window earlyctx.lagBlocks)Specification
spec/16-fallback.mdspecifies this machinery in the style of the existing suite — model, driving rules, safety invariants, liveness, failure model and observability — with four decisions recorded as ADRs (detection/strategy split, conservative finality, staleness as unproductive wait, reclaim gating) and every threshold registered as a parameter.node spec/check-spec.mjsgates it in CI.🤖 Generated with Claude Code
https://claude.ai/code/session_017Vfwn6cqjZpV4pF4J27sRd