feat(indexer): one loop per configured network (#161, #160) - #171
Merged
Miracle656 merged 2 commits intoAug 30, 2026
Conversation
…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
|
@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! 🚀 |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, andgetRpc()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→ unsuffixedSOROBAN_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_URLapplies only to the network named bySTELLAR_NETWORK:Without that guard, a deployment that sets
SOROBAN_RPC_URLfor testnet and then enables mainnet would have its mainnet loop connect to the testnet endpoint — connect fine, index fine, and write testnet ledgers taggednetwork='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:totalIndexedmake/statusunable to say how far either chain got;preferredfield 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_LEDGERespecially — the same sequence number is a completely different point in history on each chain.NETWORKS=testnet,mainnetopts in. Unset means one loop onSTELLAR_NETWORK, exactly as today. Unrecognised entries are dropped rather than throwing (a typo shouldn't take the indexer down), and duplicates collapse soNETWORKS=testnet,testnetcan'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
networkcarriesDEFAULT 'testnet', so a missing tag compiles, typechecks, and runs while filing mainnet rows as testnet. Found and fixed:upsertHostFnLogs()never setnetworkat all.pollParallel()/runPartitionWorker()calledupsertTransfersandsetLastIndexedLedgerunscoped — so theINGEST_WORKERS > 1path wrote every row to the default network.fetchNftMetadata()readprocess.env.STELLAR_NETWORKdirectly 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}./statusAdds a
networksobject with per-networklastIndexedLedger,latestLedger,lagLedgersand loop stats. Top-level fields are unchanged, so existing consumers keep working — includingtests/chaos/db-restart.test.ts, whoseStatusResponseinterface I checked against the new shape. A network with no running loop reportsrunning: falserather than zeroes that read as progress.Verification
tsc --noEmitclean/statusfields and useSKIP_INDEXER, so this is additive for them. (I checked because feat(db): add network column across all Prisma models (#159) #169's integration break came fromtsconfig.jsonincluding onlysrc/**/*— tracked in tests/ is not typechecked — tsconfig include is src/**/* only #170.)Acceptance criteria
/statusreports per-network progressNot 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.