Repository navigation
Cache live chain reads in Redis with ledger-aware invalidation and single-flight #98
Description
Overview
Three public endpoints read contract state live on every request by simulating a Soroban call through Stellar RPC: GET /markets/on-chain/:onChainMarketId/state (market_core.get_market), GET /oracle/markets/:marketId/resolution (resolver.state and market_core.get_market), and GET /portfolio (treasury.available_capital). Each simulation builds a fresh binding client, round-trips to RPC and costs tens to hundreds of milliseconds. The frontend polls these, and a market page shared on social media can produce bursts of identical requests.
Today traffic translates one-to-one into RPC load. Public RPC providers rate-limit aggressively, so a traffic spike degrades every chain-dependent feature at once, including the indexer and agent writes that share the same rpcServer and the same provider quota. Identical concurrent requests each perform their own simulation even though they would return the same value for the same ledger.
This issue adds a read-through cache for chain views in Redis, keyed by contract, method and arguments, storing each value together with the ledger it was read at. Entries are invalidated by indexer events that affect them (trades, resolutions, resolver events) and bounded by a short TTL, with in-process and cross-instance single-flight so concurrent misses trigger one RPC call. Responses expose the ledger they reflect, so clients can reason about freshness. The contracts stay the source of truth: the cache only ever serves values that were read from the chain, never values derived in the backend.
Background & current behavior
src/services/chain.service.ts:builds the transaction viaasync function read<T>(contract: ContractKey, method: string, call: () => Promise<AssembledTransaction<unknown>>): Promise<T>
call()(which constructs a new bindingClientfromreadClients.*insrc/services/stellar/clients.tsand simulates), and unwraps withsimulatedValue(), throwingChainError(502CHAIN_ERRORwithcontractError). The simulation response'slatestLedger(available ontx.simulation) is discarded. Exported reads:marketCount,getOnChainMarket,getPrice,quoteBuy,getPosition,getOnChainTrace,resolverState,treasuryAvailableCapital.src/controllers/market.controller.tsgetOnChainMarketState:parseU64,getOnChainMarket(id), returnsprintable({...market})plusstatus: marketStatusView(...),yesBpsfrompriceYesBps(reserve_yes, reserve_no),source: 'chain'.src/services/oracle.service.tsgetResolutionStatus(marketId): DB lookup, thenPromise.all([resolverState(id), getOnChainMarket(id)]).src/controllers/portfolio.controller.tsgetPortfolio:treasuryAvailableCapital()with a catch that returnsnull.- Values contain
bigint(reserves,close_time,fees_accrued,sets_minted,available_capital),Buffer(question_hash,resolution_hash) and tagged unions (status: { tag, values },category).printable()converts bigint → string and Buffer → hex for output but is lossy for round-tripping. src/services/indexer.service.tsapplySideEffects()already reacts to market-coreTradeandMarketResolvedand reasoning-registryTracePublished; treasury and resolver events are stored inchain_eventswithout side effects.- Backend writes (
agentCreateMarket,agentBuy,agentSell,publishTrace) change the same state the cache would hold. src/lib/redis.tsprovidesgetRedis(); Redis is already required (SEP-10 challenges).
Goals
src/lib/chain-cache.tswith a typed, lossless codec for binding values (bigint, Buffer, tagged enums, nested structs).cachedRead(key, ttl, loader)that stores{ value, ledger, readAt }, with in-process and Redis single-flight.- Event-driven invalidation per market and per contract, plus write-through invalidation after the backend's own writes.
- Cache applied to
getOnChainMarket,resolverStateandtreasuryAvailableCapital(and available to other reads). - Responses include
ledgerandcached: boolean. - Never return a value older than an invalidation the process has seen.
- Tests with a counting fake RPC.
Non-goals
- Batching reads via
getLedgerEntries(separate issue); this caches whateverread()does. - RPC failover pools (separate issue).
- Caching user-specific reads (
getPosition) in a shared cache unless keyed by holder; out of scope for the first iteration. - HTTP-level caching headers (separate ETag issue).
Proposed implementation
- Capture the ledger: change
read()to return{ value: T; ledger: number }internally (latestLedgerfromtx.simulationwhen it is a success response;0if missing) and addreadWithLedger()exports alongside the existing value-only exports so current callers keep compiling. - Codec in new
src/lib/chain-cache.ts:Round-trip tests over real binding shapes (export function encode(value: unknown): string; // JSON with tagged wrappers: {"$bigint":"123"}, {"$buf":"hex"} export function decode<T>(text: string): T;
Marketfromsrc/generated/market-core.ts,ResolutionState,i128). Do not reuseprintable(). - Cache API:
Keys:
export interface CacheEntry<T> { value: T; ledger: number; readAt: number; gen: number } export interface ChainCache { get<T>(key: string, opts: { ttlMs: number; tags: string[] }, load: () => Promise<{ value: T; ledger: number }>): Promise<CacheEntry<T> & { cached: boolean }>; invalidateTag(tag: string, atLedger?: number): Promise<void>; } export function redisChainCache(redis: Redis): ChainCache; export function memoryChainCache(): ChainCache;
chain:v1:<network-hash>:<contractId>:<method>:<sha256(encode(args))>; include a hash ofSTELLAR_NETWORK_PASSPHRASEand the contract id (not the logical name) so overrides or redeployments never serve stale entries. - Invalidation via tag generations (avoids scanning keys): each tag (e.g.
market:12,resolver:12,treasury:capital) has a generation counterchain:gen:<tag>in Redis. An entry stores the generations of its tags at load time; on read,MGETthe current generations and treat the entry as a miss if any differs.invalidateTagdoesINCR. Also recordchain:minLedger:<tag> = atLedgerso a load that started before the invalidation but returns a value read at an older ledger is not written back (compareledgerwithminLedger). - Single-flight: in-process, a
Map<string, Promise<...>>coalescing identical concurrent misses. Cross-instance, a Redis lockSET chain:lock:<key> <token> NX PX 5000; non-holders poll the cache key for up toCHAIN_CACHE_WAIT_MS(default1500) and then load themselves (never block indefinitely on a dead holder). Errors are not cached;ChainErrorwith a contract error (e.g.MarketNotFound) may be negatively cached for a short TTL (CHAIN_CACHE_NEG_TTL_MS, default2000) to blunt enumeration of unknown ids. - Wire the reads in
src/services/chain.service.ts:Similarlyexport const getOnChainMarketCached = (id: bigint) => cache.get(key('marketCore','get_market',{id}), { ttlMs: CHAIN_CACHE_TTL_MS, tags: [`market:${id}`] }, () => readWithLedger(...));
resolverStateCached(tagsresolver:<id>,market:<id>) andtreasuryAvailableCapitalCached(tagtreasury:capital). Default TTLCHAIN_CACHE_TTL_MS=5000(about one ledger) insrc/config/index.tsand.env.example;CHAIN_CACHE_ENABLEDdefaults totrueand tofalseunderNODE_ENV=testunless the test opts in. - Indexer invalidation in
applySideEffects()(before emitting, so clients that refetch on the socket event see fresh data): market-coreTrade,MarketResolved,Redeemed,FeesSwept,MarketCreated→market:<id>; resolverResolutionProposed,ResolutionFinalized,PriceResolution→resolver:<id>andmarket:<id>; treasuryTradeAuthorized,PositionClosed,FeesCollectedand market-coreTradewith the treasury as trader →treasury:capital. Passevent.ledgerasatLedger. Note: invalidation for events that currently have no side-effect branch must be added as a generic pre-step, not inside the existing branches. - Write-through invalidation: in
write()after a successful live submission, invalidate the tags the method affects (agent_create_market→treasury:capital;agent_buy/agent_sell→market:<id>,treasury:capital), so the backend never serves its own pre-write state right after writing. - Controllers:
getOnChainMarketStatereturnsledgerandcached;getResolutionStatusreturnsledger(the minimum of the two reads) andcached;getPortfolioaddsavailableCapitalLedger. Updatedocs/api.mdfor each response. - Observability: count hits, misses, coalesced waits and stale-rejects in a small exported
chainCacheStats()for the metrics issue.
Edge cases & pitfalls
- RPC nodes behind a load balancer can return different
latestLedgers; never write back an entry whose ledger is lower than an already cached entry for the same key. - Indexer lag means invalidation arrives seconds after the chain changed; the short TTL bounds staleness. Document the worst case (
TTL + indexer poll interval). - Buffer values in bindings are sometimes
Uint8Array; the codec must normalise both. - Redis down: bypass the cache entirely (direct reads) rather than failing requests; log once per minute.
- Key explosion: only cache whitelisted methods;
quoteBuywith arbitrary amounts must not be cached. - Tests set
STELLAR_RPC_URLtohttp://127.0.0.1:1; inject the loader so no real RPC is touched.
Testing plan
New tests/chain-cache.test.ts using memoryChainCache() and, when REDIS_URL is reachable, redisChainCache(), with a counting fake loader:
codec round-trips Market, ResolutionState and i128 values losslesslysecond read within TTL is served from cache with cached true and the original ledger100 concurrent misses trigger exactly one loader callinvalidateTag forces a reload on the next reada load that started before invalidation and returns an older ledger is not cachedcontract errors are negatively cached only for the negative TTLRedis unavailable falls back to direct readstwo cache instances sharing Redis coalesce a miss across instances(Redis-gated)- Indexer:
Trade event invalidates market tag before TRADE_EXECUTED is emitted(intests/indexer.test.ts) - HTTP:
GET /markets/on-chain/:id/state includes ledger and cached
Commands: npm test, npm run build.
Acceptance criteria
-
read()captures the simulation'slatestLedger; existing read exports keep their signatures. -
src/lib/chain-cache.tsprovides a lossless codec, tag-generation invalidation, and in-process plus Redis single-flight. -
getOnChainMarket,resolverStateandtreasuryAvailableCapitalare served through the cache in the three endpoints. - The indexer invalidates affected tags for market-core, resolver and treasury events before emitting socket events.
- Live backend writes invalidate the tags they affect.
- Older-ledger values never overwrite newer entries or survive a newer invalidation (tested).
- Responses expose
ledgerandcached;docs/api.mdis updated. -
CHAIN_CACHE_*env vars are validated and documented in.env.example. -
npm run build,npm testand CI pass.
Resources
src/services/chain.service.ts,src/services/stellar/clients.ts,src/services/indexer.service.ts,src/services/oracle.service.tssrc/controllers/market.controller.ts,src/controllers/portfolio.controller.ts,src/lib/redis.ts,src/generated/market-core.ts,docs/api.md- Stellar RPC
simulateTransaction(response includeslatestLedger): https://developers.stellar.org/docs/data/apis/rpc/api-reference/methods/simulateTransaction - Redis
SETwithNX/PXfor locks: https://redis.io/docs/latest/commands/set/ - Stellar JS SDK
contract.AssembledTransaction: https://stellar.github.io/js-stellar-sdk/
Important
Raise your pull request against the develop branch, not main.
All contributor work for this issue must be submitted as a PR into develop. Pull requests opened against main will not be merged and are closed automatically. Create your branch from develop, and before opening the PR double-check that the base branch is set to develop.
Generated by Claude Code
Activity
rubbathabrahband commentedon Oct 17, 2026
@rubbathabrahband has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @rubbathabrahband to this issue.
smithd2423-beep commentedon Oct 17, 2026
@smithd2423-beep has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @smithd2423-beep to this issue.
drips-wave commentedon Oct 17, 2026
Congratulations, @rubbathabrahband! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.
🧑💻 @rubbathabrahband: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.
Warning
When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.
🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.
🌊 Happy Wave 🌊
rubbathabrahband commentedon Oct 17, 2026
Resolved in PR #174: #174