Skip to content

fix(stats): coalesce concurrent ttlCache misses onto a single compute - #69

Open
detail-app[bot] wants to merge 1 commit into
mainfrom
detail/bug-fix/fix-stats-coalesce-concurrent-ttlcache-misses-onto-ed2a1b
Open

detail-app[bot] wants to merge 1 commit into
mainfrom
detail/bug-fix/fix-stats-coalesce-concurrent-ttlcache-misses-onto-ed2a1b

Conversation

@detail-app

@detail-app detail-app Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Detail bug report: View on Detail

Bug

ttlCache (src/utils/ttlCache.ts, added in a18bd13) memoizes the expensive Prisma aggregations behind the public statOutcomes and statOutcomesByYear queries, refreshing at most once per hour. It tracked only the cached value — not an in-flight refresh — so when the cache was empty/expired and K callers arrived concurrently, each caller independently invoked compute(). At every TTL boundary (and on cold start) the 3–6-query aggregation batch fired K times instead of once (a cache-stampede). Warm-cache behavior was unaffected.

Fix

Track an in-flight pending promise in ttlCache and return it to concurrent callers, so only one compute() runs per miss window:

  • First miss starts compute(), stores its promise in pending, and shares it.
  • Concurrent misses return the same pending promise instead of starting another compute().
  • On success, cached is written in .then before pending is cleared in .finally — this ordering matters: clearing pending first would open a microtask window where an interleaved caller sees pending === null but cached still empty and starts a duplicate compute().
  • On failure, the rejection propagates to every waiter and pending is cleared, so a later call retries (the cache is not poisoned and not stuck on a dead promise).

The public signature is unchanged; both Stats.ts call sites compile and behave without modification.

Testing

  • Offline regression tests added at src/utils/ttlCache.test.ts (uses the repo's hand-rolled harness convention): concurrent cold-miss coalesces to one compute() (the reported 20-for-20 scenario now 1), warm cache serves with no extra computes, TTL-expiry miss coalesces, a failed refresh rejects all waiters then clears pending for retry, and a caller arriving mid-refresh joins the in-flight compute (guards the .then/.finally ordering). All pass under both --transpile-only and full ts-node type-checking.
  • Typecheck (tsc --skipLibCheck --noEmit) and build (npm run build) pass; the existing offline tests for github and syncAlumniInteractions still pass.

End-to-end verification against a live Postgres + the running GraphQL server (I provisioned a postgres:16 container with the schema and seeded data, and stubbed the unrelated Elasticsearch dependency that otherwise crashes server boot):

  • Directly driving the real StatsResolver with a Prisma per-execute query counter: 20 concurrent statOutcomes calls issued exactly 6 DB queries (not 120) and 20 concurrent statOutcomesByYear issued 3 (not 60); all callers received identical results.
  • Over the live HTTP /graphql endpoint using pg_stat_statements per-execute calls: all 9 distinct stat aggregations executed exactly once across 40 concurrent GraphQL callers, and a follow-up warm burst of 40 callers issued 0 stat queries.

Not verified:

  • Real-path TTL-expiry re-coalescing at the production 1-hour TTL — STAT_OUTCOMES_CACHE_TTL_MS is hardcoded with no config seam, so exercising it requires either a 1-hour wait or an out-of-scope code change. The same ttlCache code path is covered by the unit tests with a short configurable TTL.
  • Real-DB failure recovery — dropping the schema mid-process made the Prisma 3.15.2 binary connection hang rather than reject cleanly. The failure-coalescing guarantees are covered by the unit tests, which drive the real ttlCache with a rejecting compute().
  • eslint — the repo-wide @typescript-eslint/parser is incompatible with the installed TypeScript (DeprecationError: 'originalKeywordKind'), failing identically on untouched files; tsc is the authoritative type gate.

Automatic Fixes PRs can be configured here.

@augmentcode

augmentcode Bot commented Sep 25, 2026

Copy link
Copy Markdown
Contributor
🤖 Augment PR Summary

Summary: This PR prevents cache stampedes in the public stats queries.

Changes:

  • Extends ttlCache with a pending promise for in-flight refreshes.
  • Shares that promise among concurrent cold-cache and expired-cache callers.
  • Writes the successful value and expiry timestamp before clearing the pending state.
  • Clears the pending state after rejected computations so later callers can retry.
  • Leaves the existing ttlCache signature and Stats resolver call sites unchanged.
  • Adds offline regression coverage for cold, warm, expired, failed, and mid-refresh calls.

Technical Notes:

  • The implementation preserves the one-hour stats TTL while limiting each miss window to one aggregation batch.
  • Failure propagation remains shared across waiters and does not populate the cache.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. No suggestions at this time.

Comment augment review to trigger a new review at any time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant