From f8a0866da7d638c73b727d55d07c00461801a023 Mon Sep 17 00:00:00 2001 From: MAC Date: Fri, 26 Jun 2026 05:46:26 +0100 Subject: [PATCH] fix(api): in-flight request coalescing for /chart/:mint [BUG-107] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /chart/:mint's own cache Map had no in-flight de-dup, distinct from the already-fixed cache.ts/db-cache-fallback.ts coalescing (different cache entirely) and from issue #213 (which is about the limit param polluting the cache key — a different mechanism). On a miss, this route makes two sequential awaited fetch() calls to GeckoTerminal (getTopPool then fetchOhlcv) with no de-dup — concurrent requests for the same mint/timeframe/aggregate/limit key all miss together and each independently fire both upstream calls. GeckoTerminal is a third-party API with rate limits shared across this whole process, so duplicate concurrent calls risk exhausting that budget and degrading /chart for every mint, not just the one being requested. Extracted the miss-path into fetchAndCache() and added an `inflight` Map mirroring the proven coalescing pattern already in oracle-router.ts: only the first concurrent caller for a key runs fetchAndCache(); the rest await the same promise and the cache write happens once per miss-episode. Added a regression test: two concurrent requests for the same key result in exactly one pool-resolution call and one OHLCV call (not two of each), and both get the same candle data. Verified it fails against the pre-fix code (2 pool calls instead of 1) and passes against the fix. Co-authored-by: Claude Sonnet 4.6 --- src/routes/chart.ts | 77 ++++++++++++++++++++++++++------------ tests/routes/chart.test.ts | 44 ++++++++++++++++++++++ 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/src/routes/chart.ts b/src/routes/chart.ts index f3b9b1e..2a9dcb5 100644 --- a/src/routes/chart.ts +++ b/src/routes/chart.ts @@ -46,6 +46,17 @@ const CACHE_TTL_MS = 60 * 1_000; // 60 seconds const CACHE_MAX_SIZE = 100; const cache = new Map(); +// In-flight request coalescing: cacheKey → pending fetch-and-cache promise. +// Without this, N concurrent misses for the same key (same mint/timeframe/ +// aggregate/limit) each independently call getTopPool + fetchOhlcv against +// GeckoTerminal — a third-party API with its own rate limits shared across +// this whole process, so duplicate concurrent calls risk exhausting that +// budget and degrading /chart for everyone, not just the requester. Mirrors +// the proven pattern in oracle-router.ts: the entry is deleted only after +// settling, so a request arriving immediately after resolution sees a +// populated cache rather than racing to start its own fetch. +const inflight = new Map>(); + // ── GeckoTerminal helpers ────────────────────────────────────────────────── const GECKO_BASE = "https://api.geckoterminal.com/api/v2"; const GECKO_HEADERS = { Accept: "application/json;version=20230302" }; @@ -140,6 +151,39 @@ async function fetchOhlcv( } } +// Resolves the pool and fetches OHLCV for a cache-miss, then caches a +// non-empty result. Shared by all coalesced callers via the `inflight` map +// above, so the cache write happens once per miss-episode regardless of how +// many concurrent requests triggered it. +async function fetchAndCache( + mint: string, + timeframe: Timeframe, + aggregate: number, + limit: number, + cacheKey: string, +): Promise<{ candles: CandleData[]; poolAddress: string | null }> { + const poolAddress = await getTopPool(mint); + if (!poolAddress) { + return { candles: [], poolAddress: null }; + } + + const candles = await fetchOhlcv(poolAddress, timeframe, aggregate, limit); + + // Only cache non-empty results to avoid persisting upstream failures + if (candles.length > 0) { + cache.set(cacheKey, { candles, poolAddress, fetchedAt: Date.now() }); + + // Evict oldest entries when over limit (Map iteration order = insertion order) + while (cache.size > CACHE_MAX_SIZE) { + const oldestKey = cache.keys().next().value; + if (oldestKey) cache.delete(oldestKey); + else break; + } + } + + return { candles, poolAddress }; +} + // ── Route ────────────────────────────────────────────────────────────────── export function chartRoutes(): Hono { const app = new Hono(); @@ -188,30 +232,17 @@ export function chartRoutes(): Hono { ); } - // Step 1: resolve top pool - const poolAddress = await getTopPool(mint); - if (!poolAddress) { - return c.json( - { candles: [], poolAddress: null, cached: false }, - 200, - { "Cache-Control": "public, max-age=60, stale-while-revalidate=120" } - ); - } - - // Step 2: fetch OHLCV - const candles = await fetchOhlcv(poolAddress, timeframe, aggregate, limit); - - // Only cache non-empty results to avoid persisting upstream failures - if (candles.length > 0) { - cache.set(cacheKey, { candles, poolAddress, fetchedAt: Date.now() }); - } - - // Evict oldest entries when over limit (Map iteration order = insertion order) - while (cache.size > CACHE_MAX_SIZE) { - const oldestKey = cache.keys().next().value; - if (oldestKey) cache.delete(oldestKey); - else break; + // Coalesce concurrent misses for this exact key: only the first request + // calls getTopPool/fetchOhlcv; concurrent requests await the same + // promise instead of each independently hitting GeckoTerminal. + let inFlightPromise = inflight.get(cacheKey); + if (!inFlightPromise) { + inFlightPromise = fetchAndCache(mint, timeframe, aggregate, limit, cacheKey).finally(() => { + inflight.delete(cacheKey); + }); + inflight.set(cacheKey, inFlightPromise); } + const { candles, poolAddress } = await inFlightPromise; return c.json( { candles, poolAddress, cached: false }, diff --git a/tests/routes/chart.test.ts b/tests/routes/chart.test.ts index 19a4c80..34e42ef 100644 --- a/tests/routes/chart.test.ts +++ b/tests/routes/chart.test.ts @@ -128,6 +128,50 @@ describe("GET /chart/:mint", () => { expect(c.timestamp).toBe(1700000000 * 1000); }); + it("coalesces concurrent requests for the same key into a single upstream fetch pair (BUG-107)", async () => { + let resolvePoolFetch: ((res: Response) => void) | undefined; + let resolveOhlcvFetch: ((res: Response) => void) | undefined; + let fetchCallCount = 0; + + mockFetch.mockImplementation((url: string) => { + fetchCallCount++; + if (url.includes("/ohlcv/")) { + return new Promise((resolve) => { + resolveOhlcvFetch = resolve; + }); + } + return new Promise((resolve) => { + resolvePoolFetch = resolve; + }); + }); + + const app = makeApp(); + // Distinct params (timeframe=day) so this test's cache key can't collide + // with any other test's cached entry. + const reqA = app.request(`http://localhost/chart/${VALID_MINT}?timeframe=day&limit=10`); + const reqB = app.request(`http://localhost/chart/${VALID_MINT}?timeframe=day&limit=10`); + + // Both requests have already reached the coalescing check (the pool + // fetch is suspended on the unresolved promise below) — a second + // pool-resolution call here would prove de-duplication failed. + expect(fetchCallCount).toBe(1); + + resolvePoolFetch!(makeJsonRes(MOCK_POOL_RES)); + // Let the pool-resolution .then() chain proceed to the OHLCV fetch. + await new Promise((r) => setTimeout(r, 0)); + expect(fetchCallCount).toBe(2); + + resolveOhlcvFetch!(makeJsonRes(MOCK_OHLCV_RES)); + + const [resA, resB] = await Promise.all([reqA, reqB]); + const [bodyA, bodyB] = await Promise.all([resA.json(), resB.json()]); + + expect(bodyA.candles).toHaveLength(3); + expect(bodyB.candles).toEqual(bodyA.candles); + // Still exactly 2 upstream calls total (one pool + one OHLCV) — never 4. + expect(fetchCallCount).toBe(2); + }); + it("sets Cache-Control header on successful response", async () => { mockFetch .mockResolvedValueOnce(makeJsonRes(MOCK_POOL_RES))