Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 54 additions & 23 deletions src/routes/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ const CACHE_TTL_MS = 60 * 1_000; // 60 seconds
const CACHE_MAX_SIZE = 100;
const cache = new Map<string, CacheEntry>();

// 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<string, Promise<{ candles: CandleData[]; poolAddress: string | null }>>();

// ── GeckoTerminal helpers ──────────────────────────────────────────────────
const GECKO_BASE = "https://api.geckoterminal.com/api/v2";
const GECKO_HEADERS = { Accept: "application/json;version=20230302" };
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 },
Expand Down
44 changes: 44 additions & 0 deletions tests/routes/chart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((resolve) => {
resolveOhlcvFetch = resolve;
});
}
return new Promise<Response>((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))
Expand Down