From 032222fac9a582ea4578c9b6233dba7c0f4cb907 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 6 Mar 2026 18:56:48 +0000 Subject: [PATCH 1/3] fix: Issue #447 remains unresolved: no caching path exists for POST GraphQL requests (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add server-side response cache + in-flight coalescing to Ponder's Hono API layer (services/ponder/src/api/index.ts). Previously every polling client generated an independent DB query, giving O(users × 1/poll_interval) load. With a 5 s in-process cache keyed on the raw request body (POST) or query string (GET), the effective DB hit rate is capped at O(1/5s) regardless of how many clients are polling. In-flight coalescing ensures that N concurrent identical queries that arrive before the first response is ready all share a single DB hit instead of each issuing their own. Expired entries are evicted every 30 s to keep memory use bounded. The 5 s TTL deliberately matches the existing Caddy `Cache-Control: public, max-age=5` header so that if a caching proxy/CDN is layered in front later, both layers stay in sync. Co-Authored-By: Claude Sonnet 4.6 --- services/ponder/src/api/index.ts | 88 +++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/services/ponder/src/api/index.ts b/services/ponder/src/api/index.ts index 362c323..bb0c19e 100644 --- a/services/ponder/src/api/index.ts +++ b/services/ponder/src/api/index.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import type { Context, Next } from 'hono'; import { cors } from 'hono/cors'; import { client, graphql } from 'ponder'; import { db } from 'ponder:api'; @@ -19,11 +20,96 @@ app.use( }) ); +// Server-side GraphQL response cache. +// +// Without this, every polling client (O(users × 1/poll_interval)) generates a +// separate DB query. With this 5 s cache + in-flight coalescing, the effective +// DB hit rate is capped at O(1/5s) regardless of how many clients poll. +// +// The TTL matches the Caddy `Cache-Control: public, max-age=5` header so that +// if a caching CDN or proxy is added later the two layers stay in sync. + +const GRAPHQL_CACHE_TTL_MS = 5_000; // 5 seconds – matches Caddy max-age=5 + +const responseCache = new Map(); + +// In-flight map: when concurrent requests arrive for the same query before the +// first response is ready, they all await the same DB hit instead of each +// issuing their own. +const inFlight = new Map>(); + +// Evict expired entries periodically so the cache stays bounded in memory. +const evictInterval = setInterval(() => { + const now = Date.now(); + for (const [k, v] of responseCache) { + if (v.expiresAt <= now) responseCache.delete(k); + } +}, 30_000); +// Don't keep the process alive just for eviction. +(evictInterval as unknown as { unref?: () => void }).unref?.(); + +async function graphqlCache(c: Context, next: Next): Promise { + if (c.req.method !== 'GET' && c.req.method !== 'POST') return next(); + + // Build a stable cache key without consuming the original request body so + // the downstream graphql() handler can still read it. + const cacheKey = + c.req.method === 'POST' + ? await c.req.raw.clone().text() + : new URL(c.req.url).search; + + const now = Date.now(); + + // 1. Cache hit – serve immediately, no DB involved. + const hit = responseCache.get(cacheKey); + if (hit && hit.expiresAt > now) { + return c.body(hit.body, 200, { 'Content-Type': 'application/json' }); + } + + // 2. In-flight coalescing – N concurrent identical queries share one DB hit. + const flying = inFlight.get(cacheKey); + if (flying) { + const body = await flying; + if (body !== null) { + return c.body(body, 200, { 'Content-Type': 'application/json' }); + } + // The in-flight request errored; fall through and try again fresh. + } + + // 3. Cache miss – run the real graphql() handler and cache a successful result. + const promise = (async (): Promise => { + await next(); + if (c.res.status !== 200) return null; + const body = await c.res.clone().text(); + try { + const parsed = JSON.parse(body) as { errors?: unknown }; + if (!parsed.errors) { + responseCache.set(cacheKey, { body, expiresAt: now + GRAPHQL_CACHE_TTL_MS }); + return body; + } + } catch { + // Non-JSON response; don't cache. + } + return null; + })(); + + inFlight.set(cacheKey, promise); + promise.finally(() => inFlight.delete(cacheKey)); + + const body = await promise; + if (body !== null) { + return c.body(body, 200, { 'Content-Type': 'application/json' }); + } + // Error path: graphql() already populated c.res; let Hono send it as-is. +} + // SQL endpoint app.use('/sql/*', client({ db, schema })); -// GraphQL endpoints +// GraphQL endpoints with server-side caching + in-flight coalescing +app.use('/graphql', graphqlCache); app.use('/graphql', graphql({ db, schema })); +app.use('/', graphqlCache); app.use('/', graphql({ db, schema })); export default app; From ad19d8fc00deb4bf17d07dcca9e65366e733ce42 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 6 Mar 2026 19:11:21 +0000 Subject: [PATCH 2/3] fix: Issue #447 remains unresolved: no caching path exists for POST GraphQL requests (#478) Replace setInterval-based eviction with lazy eviction to avoid the no-undef ESLint error (setInterval is not in the allowed globals list). Expired cache entries are now deleted on access rather than via a background timer. Co-Authored-By: Claude Sonnet 4.6 --- services/ponder/src/api/index.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/services/ponder/src/api/index.ts b/services/ponder/src/api/index.ts index bb0c19e..a4d98eb 100644 --- a/services/ponder/src/api/index.ts +++ b/services/ponder/src/api/index.ts @@ -38,16 +38,6 @@ const responseCache = new Map(); // issuing their own. const inFlight = new Map>(); -// Evict expired entries periodically so the cache stays bounded in memory. -const evictInterval = setInterval(() => { - const now = Date.now(); - for (const [k, v] of responseCache) { - if (v.expiresAt <= now) responseCache.delete(k); - } -}, 30_000); -// Don't keep the process alive just for eviction. -(evictInterval as unknown as { unref?: () => void }).unref?.(); - async function graphqlCache(c: Context, next: Next): Promise { if (c.req.method !== 'GET' && c.req.method !== 'POST') return next(); @@ -61,9 +51,13 @@ async function graphqlCache(c: Context, next: Next): Promise { const now = Date.now(); // 1. Cache hit – serve immediately, no DB involved. + // Evict lazily: delete the entry if it has already expired. const hit = responseCache.get(cacheKey); - if (hit && hit.expiresAt > now) { - return c.body(hit.body, 200, { 'Content-Type': 'application/json' }); + if (hit) { + if (hit.expiresAt > now) { + return c.body(hit.body, 200, { 'Content-Type': 'application/json' }); + } + responseCache.delete(cacheKey); } // 2. In-flight coalescing – N concurrent identical queries share one DB hit. From 694a68ad276cb05659954b19dc00008dc4228b6b Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 6 Mar 2026 19:49:13 +0000 Subject: [PATCH 3/3] fix: Issue #447 remains unresolved: no caching path exists for POST GraphQL requests (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address AI review findings: - Bug: restore 30 s periodic eviction via setInterval so queries that are never repeated don't accumulate forever (add setInterval/ clearInterval to ESLint globals to allow it) - Bug: fix .finally() race – use identity check before deleting the in-flight key so a waiting request's replacement promise is never evicted by the original promise's cleanup handler - Warning: replace `new URL(c.req.url).search` with a string-split approach that cannot throw on relative URLs - Warning: add MAX_CACHE_ENTRIES (500) cap with LRU-oldest eviction to bound memory growth from callers with many unique variable sets - Warning: prefix cache key with c.req.path so /graphql and / can never produce cross-route cache collisions Co-Authored-By: Claude Sonnet 4.6 --- services/ponder/eslint.config.js | 2 ++ services/ponder/src/api/index.ts | 36 +++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/services/ponder/eslint.config.js b/services/ponder/eslint.config.js index 77fb894..e6d4f05 100644 --- a/services/ponder/eslint.config.js +++ b/services/ponder/eslint.config.js @@ -17,6 +17,8 @@ export default [ process: 'readonly', console: 'readonly', Context: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', }, }, plugins: { diff --git a/services/ponder/src/api/index.ts b/services/ponder/src/api/index.ts index a4d98eb..725ed5e 100644 --- a/services/ponder/src/api/index.ts +++ b/services/ponder/src/api/index.ts @@ -30,6 +30,7 @@ app.use( // if a caching CDN or proxy is added later the two layers stay in sync. const GRAPHQL_CACHE_TTL_MS = 5_000; // 5 seconds – matches Caddy max-age=5 +const MAX_CACHE_ENTRIES = 500; // guard against unbounded growth from unique variable sets const responseCache = new Map(); @@ -38,15 +39,31 @@ const responseCache = new Map(); // issuing their own. const inFlight = new Map>(); +// Evict expired entries every 30 s so queries that are never repeated don't +// accumulate in memory indefinitely. +const evictInterval = setInterval(() => { + const now = Date.now(); + for (const [k, v] of responseCache) { + if (v.expiresAt <= now) responseCache.delete(k); + } +}, 30_000); +// Don't keep the process alive just for eviction. +(evictInterval as unknown as { unref?: () => void }).unref?.(); + async function graphqlCache(c: Context, next: Next): Promise { if (c.req.method !== 'GET' && c.req.method !== 'POST') return next(); // Build a stable cache key without consuming the original request body so // the downstream graphql() handler can still read it. - const cacheKey = + // For GET: extract the query string via string operations to avoid `new URL()` + // which can throw a TypeError if the URL is relative (no scheme/host). + const rawKey = c.req.method === 'POST' ? await c.req.raw.clone().text() - : new URL(c.req.url).search; + : (c.req.url.includes('?') ? c.req.url.slice(c.req.url.indexOf('?')) : ''); + // Prefix with the route path so /graphql and / never share cache entries, + // even though both currently serve identical content. + const cacheKey = `${c.req.path}:${rawKey}`; const now = Date.now(); @@ -78,6 +95,12 @@ async function graphqlCache(c: Context, next: Next): Promise { try { const parsed = JSON.parse(body) as { errors?: unknown }; if (!parsed.errors) { + // Evict the oldest entry when the cache is at capacity to prevent + // unbounded growth from callers with many unique variable sets. + if (responseCache.size >= MAX_CACHE_ENTRIES) { + const oldestKey = responseCache.keys().next().value; + if (oldestKey !== undefined) responseCache.delete(oldestKey); + } responseCache.set(cacheKey, { body, expiresAt: now + GRAPHQL_CACHE_TTL_MS }); return body; } @@ -88,7 +111,14 @@ async function graphqlCache(c: Context, next: Next): Promise { })(); inFlight.set(cacheKey, promise); - promise.finally(() => inFlight.delete(cacheKey)); + // Only delete the key if our promise is still the one registered. A waiting + // request may have replaced it with its own promise before our .finally() + // fires (microtask ordering), and we must not evict that replacement. + promise.finally(() => { + if (inFlight.get(cacheKey) === promise) { + inFlight.delete(cacheKey); + } + }); const body = await promise; if (body !== null) {