Skip to content

perf(solana): faster getBlock + smarter RPC failover - #109

Open
vietddude wants to merge 11 commits into
mainfrom
perf/solana-getblock-json
Open

vietddude wants to merge 11 commits into
mainfrom
perf/solana-getblock-json

Conversation

@vietddude

Copy link
Copy Markdown
Collaborator

Base of the stack. Solana getBlock performance/reliability and RPC failover improvements.

  • perf(solana): use json encoding for getBlock instead of jsonParsed (cheaper)
  • fix(solana): treat skipped slots as advanced-past in the regular worker
  • feat(rpc): rotate away from slow-but-successful providers
  • refactor(config): move failover config out of yaml into code
  • feat(solana): adaptive getBlock concurrency (AIMD congestion control)
  • feat(rpc): blacklist nodes that do not serve the chain
  • feat(rpc): back off emergency recovery when the whole pool is blacklisted
  • chore(solana): trim verbose comments
  • docs: add CLAUDE.md guide for AI agents
  • fix(worker): drop duplicate chain field in worker logs
  • fix(bloom): tolerate address types missing from the DB enum

Stack: this PR → refactor/kvstore-redis → fix/catchup-redis-hash

jsonParsed is the heaviest getBlock encoding (~35-40% larger payload and
higher server-side cost). Benchmarks against public mainnet RPC show json
cuts both latency and bandwidth substantially, improving real-time indexing
throughput so Solana keeps up with slot production instead of falling behind
into catchup.

Switching encoding requires two adjustments the RPC used to do for us:
- AccountKey now unmarshals from both a bare pubkey string (json) and the
  { pubkey, signer, writable } object (jsonParsed).
- Versioned (v0) transactions deliver Address Lookup Table accounts in
  meta.loadedAddresses rather than merged into message.accountKeys. Index
  resolution now appends them as static keys + loaded writable + loaded
  readonly, matching the order the RPC produces under jsonParsed.
Solana produces skipped slots that will never have a block. The catchup and
rescanner paths already recognise these, but the real-time regular path ran
them through handleBlockResult, which persisted each skipped slot as a failed
block and pushed it to the rescanner — costing an extra getBlock per skip just
to confirm the skip, exactly when block production is fastest.

Detect ErrorTypeBlockNotFound on Solana in the non-reorg batch loop, advance
currentBlock past the slot, and notify the observer as not-found instead of
failed.
The failover only penalised latency on the error path (analyzeError's >3s
check). A provider that returns successfully but slowly — an overloaded free
RPC responding in seconds without ever erroring — was never rotated away, so
it kept dragging down real-time throughput.

Add a success-path latency check in executeCore: a call slower than
SlowResponseThreshold blacklists the provider for SlowResponseCooldown so the
next call prefers a faster one. It never drops the available pool below
MinActiveProviders, so when every provider is slow we keep using them. Both
thresholds are config fields (defaulting to the previous hard-coded 3s / 2m),
and analyzeError now reads the same values.
The defaults.failover yaml block was dead configuration: the Failover field
lived only on the Defaults struct, was never merged into ChainConfig, and every
NewFailover call passed nil — so rpc.DefaultFailoverConfig() was always the
effective source. Remove the unused field and the yaml block so failover tuning
lives in one place (code) instead of misleading knobs operators can set with no
effect.
Replace the static per-batch semaphore in GetBlocksByNumbers with a shared
AIMD concurrency limiter. The configured throttle.concurrency becomes a ceiling:
the limiter multiplicatively backs off when getBlock calls get slow (>2.5s) or
fail, and additively recovers toward the ceiling once calls settle (<1.2s). It
never exceeds the ceiling, so it can only match or beat the previous static
behaviour.

Because the Solana indexer is built once and shared across all worker modes,
the limiter is a single global congestion controller for the chain's getBlock
load — replacing the previous uncoordinated per-call semaphores (regular +
catchup + rescanner each had their own). This adapts call pressure to real RPC
capacity instead of a fixed number, avoiding the rate-limit death spiral on
overloaded free nodes while still using full concurrency when they are healthy.

New pkg/adaptive holds the reusable limiter with full unit + race coverage.
Some free RPCs (e.g. drpc) reject a chain outright with 'not available on free
plan' / code 35. That was classified as a generic error, so the provider only
degraded after ForceRotateThreshold wasted attempts every pass. Classify it as
chain_unavailable and blacklist for 24h, since the condition is permanent for
that node.
…sted

When every provider is rate-limited/blacklisted, many concurrent callers each
triggered emergency recovery, un-blacklisting a provider only to have it 429
again immediately — a hot recover->fail->recover loop that spammed logs and
hammered dead nodes. Space emergency recoveries by EmergencyRecoveryInterval
(default 2s): within the window, callers get errAllProvidersBackoff and retry
instead of churning.

Also refresh the example Solana config: drop drpc (does not serve Solana free),
add keyed-node placeholders (Helius/Ankr/Chainstack) since free pools cannot
sustain getBlock at slot rate.
The worker logger already binds chain (and mode) via logger.With, so passing
"chain" again in each call printed it twice (chain=X chain=X). Remove the
redundant explicit chain args; the value now comes only from logger context.
Bloom init/sync iterate all network types, but the DB's address_type enum may
lack some (e.g. 'apt'), so filtering wallet_addresses by that type fails with
22P02 and aborts init. Treat 22P02 (invalid enum value) as no matching rows in
both the repository (WrapError) and the raw loader query, so unknown types are
simply skipped.
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