fix(api): in-flight request coalescing for response cache + DB fallback cache [BUG-004] - #197
fix(api): in-flight request coalescing for response cache + DB fallback cache [BUG-004]#197Morenikeoa wants to merge 1 commit into
Conversation
…ck cache [BUG-004]
cacheMiddleware (src/middleware/cache.ts) and withDbCacheFallback
(src/middleware/db-cache-fallback.ts) had no in-flight de-duplication.
N concurrent callers for the same cache key each independently re-ran the
expensive backend call (RPC or Supabase) and raced to overwrite the cache
on completion — whichever resolved last won, regardless of which result
actually reflected more current backend state. This was both wasted
backend load (thundering herd) and a correctness issue.
This codebase already has a proven fix for this exact bug class:
oracle-router.ts's in-flight Map<key, Promise> coalescing for its own
cache. Both fixes mirror that pattern:
- db-cache-fallback.ts: coalesce only queryFn() itself via a new
runCoalesced() helper. The cache write and staleness-header logic stay
per-caller (each coalesced waiter still runs its own post-processing
against its own Hono Context), so every caller's HTTP response gets
correct headers without needing to thread them back out of the shared
promise.
- cache.ts: leader/follower split on cache miss. Only the first ("leader")
request calls next(); concurrent ("follower") requests await the same
promise and replay the leader's cached entry via a new serveEntry()
helper (shared with the existing HIT path) instead of re-running next()
themselves. If the leader's result isn't cacheable (error, non-JSON) or
the leader throws, the follower falls back to calling next() itself —
safe here since every route in this repo is a read-only GET handler with
no side effects (confirmed during the broader audit this fix came out of).
No shared coalesce() helper extracted across the two files — cache.ts's
Context-mutation semantics don't fit the same shape as a plain
promise-memoizing helper would need, and oracle-router.ts's own
unabstracted version is left untouched (no bug-fix value, only refactor
risk).
Added regression tests for both files (concurrent-miss coalescing, and a
test proving a slower concurrent request can no longer overwrite a faster
one's cached result) plus tests for the new fallback-to-next() behavior on
non-cacheable leader responses. Verified all three coalescing-specific
tests fail against the pre-fix code (handler/queryFn called twice instead
of once) and pass against the fix.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@Princessdada is attempting to deploy a commit to the Khubair Nasir's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Warning Review limit reached
More reviews will be available in 24 minutes and 24 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Problem
Neither
cacheMiddleware(src/middleware/cache.ts) norwithDbCacheFallback(src/middleware/db-cache-fallback.ts) de-duplicates concurrent in-flight requests for the same cache key. If N concurrent requests miss the cache for the same key at the same time, all N independently re-run the expensive backend call (Solana RPC or Supabase), and all N independently write to the cache on completion with no ordering guarantee — whichever happens to resolve last wins, regardless of which result actually reflects more current backend state.This is both wasted backend load (thundering herd on every concurrency event, attack or not) and a correctness issue (an earlier-started-but-slower request can clobber a later-started-but-faster request's result).
Fix design process
This went through the full triple-solver-consensus process given the architectural complexity. All three independently proposed the same core approach — mirror the in-flight coalescing pattern this codebase already uses and trusts in
src/routes/oracle-router.ts— then diverged on implementation details forcache.ts, which is harder thandb-cache-fallback.tsbecause the "expensive work" being coalesced isawait next()(a Hono middleware continuation that writes into the current request's ownContext), not a plain value-returning function.Synthesized from all three proposals:
db-cache-fallback.ts: coalesce onlyqueryFn()itself (newrunCoalesced()helper). The cache write and staleness-header logic stay per-caller — every coalesced waiter (leader and followers alike) independently runs its own post-processing against its ownContext. This was deliberately chosen over coalescing the whole write+header block, because one solver's first draft of that approach set staleness headers only on the leader'sContext, silently dropping them from followers' HTTP responses — a real bug another solver caught only on a second pass.cache.ts: leader/follower split on cache miss. Only the first ("leader") request callsnext(); concurrent ("follower") requests await the same promise and replay the leader's now-cached entry via a newserveEntry()helper (shared with the existing cache-HIT path) instead of callingnext()themselves. If the leader's result isn't cacheable (error, non-JSON) or the leader throws, the follower falls back to callingnext()itself for its own accurate response — verified safe because every route in this repo is a read-only GET handler with zero mutation endpoints (confirmed during the broader audit this fix came out of), so re-running a handler twice has no side-effect risk.coalesce()helper extracted across the two files —cache.ts's Context-mutation semantics don't fit the same shape a plain promise-memoizing helper would need without adding indirection for marginal line savings.oracle-router.ts's own unabstracted version is left untouched — refactoring working code for style, with no bug-fix value, was unanimously rejected by all three solvers.Proof of Fix
Added regression tests to both
tests/middleware/db-cache-fallback.test.tsand a newtests/middleware/cache.test.ts:queryFncalled exactly once.next()behavior when the leader's response isn't replayable.I verified these are genuine regression tests, not tautologies: reverted just the two source files and reran — all 3 coalescing-specific tests failed with the handler/
queryFncalled twice instead of once. Restored the fix and they pass.tsc --noEmitclean (no separate lint script in this repo).Test Output
Full suite: 301/302 passed (294 baseline + 7 new). The 1 failure (
tests/sdk-smoke.test.ts) is pre-existing and unrelated — it asserts on an exact@percolatorct/sdkerror-message string that has drifted from the locally-resolved SDK version in this environment.Related
Found during a broader API audit; no existing open issue/PR covers this.