Skip to content

feat(indexer): one loop per configured network (#161, #160) - #171

Merged
Miracle656 merged 2 commits into
Miracle656:mainfrom
Salmatcre8:feat/multi-network-indexer
Aug 30, 2026
Merged

feat(indexer): one loop per configured network (#161, #160)#171
Miracle656 merged 2 commits into
Miracle656:mainfrom
Salmatcre8:feat/multi-network-indexer

Conversation

@Salmatcre8

Copy link
Copy Markdown
Contributor

Closes #161. Also closes #160 — see below.

Why this closes two issues

#161 says "use getRpc(network) (W071)". That factory doesn't exist yet — #160 is still open, and getRpc() is a global singleton. Building #161 on top of the singleton would have produced a loop that cannot index two networks and cannot be tested, so the factory is here too. It's ~40 lines and the rest of the PR depends on every line of it.

Per-network RPC (#160)

getRpc(network) caches one client per network. Resolution: SOROBAN_RPC_URL_TESTNET / _MAINNET → unsuffixed SOROBAN_RPC_URL → public testnet default. Mainnet has no free public RPC, so it throws rather than guessing an endpoint that would fail on every call.

One deliberate subtlety. The unsuffixed SOROBAN_RPC_URL applies only to the network named by STELLAR_NETWORK:

if (network === currentNetwork()) {
  const legacy = process.env.SOROBAN_RPC_URL || process.env.STELLAR_RPC_URL;
  if (legacy) return legacy;
}

Without that guard, a deployment that sets SOROBAN_RPC_URL for testnet and then enables mainnet would have its mainnet loop connect to the testnet endpoint — connect fine, index fine, and write testnet ledgers tagged network='mainnet'. Silent corruption, no error anywhere. Scoping it keeps every existing single-network deployment byte-identical while making that impossible. There's a test for it; deleting the guard fails that test and nothing else.

Per-network loops (#161)

Each loop owns a LoopState — counters, watch lists, source switcher, cursor. Nothing shared, and that's a correctness requirement rather than tidiness:

  • two loops incrementing one totalIndexed make /status unable to say how far either chain got;
  • the source switcher has a mutable preferred field for failover, so one shared instance means a testnet RPC outage silently repoints the mainnet loop at testnet Horizon.

Everything that names a chain is per-network with a shared fallback: SAC_CONTRACT_IDS_*, NFT_CONTRACT_IDS_*, HORIZON_URL_*, START_LEDGER_*. START_LEDGER especially — the same sequence number is a completely different point in history on each chain.

NETWORKS=testnet,mainnet opts in. Unset means one loop on STELLAR_NETWORK, exactly as today. Unrecognised entries are dropped rather than throwing (a typo shouldn't take the indexer down), and duplicates collapse so NETWORKS=testnet,testnet can't start two loops fighting over one cursor.

Loops are fault-isolated: a crash restarts that loop after 10s instead of process.exit(1). An expiring mainnet RPC key must not stop testnet indexing — or take the read API down with it, which is what the old handler did.

Three writes that were silently untagged

network carries DEFAULT 'testnet', so a missing tag compiles, typechecks, and runs while filing mainnet rows as testnet. Found and fixed:

  1. upsertHostFnLogs() never set network at all.
  2. pollParallel() / runPartitionWorker() called upsertTransfers and setLastIndexedLedger unscoped — so the INGEST_WORKERS > 1 path wrote every row to the default network.
  3. fetchNftMetadata() read process.env.STELLAR_NETWORK directly for its passphrase, so a mainnet metadata simulation would have been built with the testnet passphrase.

Also: sac-detect's cache was keyed by contract id alone. Contract ids are network-derived so a real collision is unlikely, but it is shared mutable state between loops, which the acceptance criteria rule out. Now keyed ${network}:${contractId}.

/status

Adds a networks object with per-network lastIndexedLedger, latestLedger, lagLedgers and loop stats. Top-level fields are unchanged, so existing consumers keep working — including tests/chaos/db-restart.test.ts, whose StatusResponse interface I checked against the new shape. A network with no running loop reports running: false rather than zeroes that read as progress.

Verification

Acceptance criteria

  • Two loops run concurrently without shared mutable state
  • Each writes rows tagged with its own network
  • /status reports per-network progress
  • Single-network mode still works when only one is enabled — it is the default, and unchanged

Not in scope

#163 (network selector on REST/GraphQL/WS) — reads still answer for the configured network. This PR makes the data correct per network; #163 makes it selectable.

Joined the contributor Telegram.

…le656#160)

startIndexer() ran a single loop over module-global state: totalIndexed,
pollCycleCount and startedAt were module-level `let`s, the SAC/NFT watch
lists were resolved once at import, and one source switcher was shared by
everything. Indexing two networks in one process was impossible.

Closes Miracle656#161. Also closes Miracle656#160, because Miracle656#161 cannot be built without it:
the loop needs getRpc(network), and shipping the loop against the old
global singleton would have produced code that could not be tested.

## Per-network RPC (Miracle656#160)

getRpc(network) caches one client per network. Endpoints resolve from
SOROBAN_RPC_URL_TESTNET / _MAINNET, then the unsuffixed SOROBAN_RPC_URL,
then the public testnet default; mainnet has no free public RPC so it
throws rather than guessing.

The unsuffixed variable deliberately applies **only** to the network named
by STELLAR_NETWORK. Honouring it for both would let a mainnet loop connect
to a testnet endpoint, index happily, and write testnet ledgers tagged
network='mainnet' — a corruption with no error anywhere. Scoping it keeps
every existing single-network deployment byte-identical while making that
mix-up impossible. Covered by a test; removing the scope fails it.

## Per-network loops (Miracle656#161)

Each loop owns a LoopState: its own counters, watch lists, source switcher
and cursor. Nothing is shared, which matters beyond tidiness — two loops
incrementing one counter make /status unable to say how far either chain
got, and a shared source switcher has a mutable `preferred` field, so a
testnet RPC outage would silently repoint the mainnet loop at testnet
Horizon.

Everything that names a chain is now per-network with a shared fallback:
SAC_CONTRACT_IDS_*, NFT_CONTRACT_IDS_*, HORIZON_URL_*, START_LEDGER_*.
START_LEDGER especially — the same sequence number is a completely
different point in history on each chain.

NETWORKS=testnet,mainnet opts in; unset means one loop on STELLAR_NETWORK,
exactly as before. Unrecognised entries are dropped rather than throwing,
and duplicates collapse so two loops never fight over one cursor.

Loops are fault-isolated: a crash restarts that loop after 10s instead of
exiting the process, because an expiring mainnet RPC key must not stop
testnet indexing or take the API down with it.

## Writes that were silently untagged

Two paths wrote rows without a network. The column defaults to 'testnet',
so both compiled, typechecked and ran while filing mainnet rows as
testnet:

  - upsertHostFnLogs() never set it. Now stamped.
  - pollParallel() / runPartitionWorker() called upsertTransfers and
    setLastIndexedLedger unscoped. Now threaded.

Also fixed: fetchNftMetadata picked its passphrase from process.env
directly, so a mainnet metadata simulation would have been built with the
testnet passphrase; and sac-detect's cache was keyed by contract id alone,
which is shared mutable state between loops.

## /status

Adds a `networks` object with per-network lastIndexedLedger, latestLedger,
lag and loop stats. Existing top-level fields are untouched, so current
consumers — including tests/chaos/db-restart.test.ts's StatusResponse —
keep working. A network with no running loop reports running:false rather
than zeroes that look like progress.

Verified: tsc --noEmit clean, 290 tests pass (was 270), 20 of them new.
Integration tests checked for the shape they assert; the change is
additive.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@Salmatcre8 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Self-review catch: `networks` was attached only to the healthy branch of
/status. The degraded branch computed it and threw it away.

That is backwards. With two loops, "RPC is down" is normally true of one
chain and not the other — mainnet runs on a paid provider endpoint whose
key can expire while the public testnet endpoint is fine. Degraded is
precisely when you need to know *which* chain is stuck, and the top-level
fields go null in that branch, so it was the one case the whole field
exists for.

Also calls runningNetworks() once instead of twice.

Both branches now assert `networks` in staleReads.test.ts. Mutation-
checked: removing it from the degraded branch fails that test alone.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@Miracle656
Miracle656 merged commit fe64fe5 into Miracle656:main Aug 30, 2026
4 checks passed
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.

Run one indexer loop per configured network Replace the global RPC singleton with a per-network getRpc(network) factory

2 participants