Repository navigation
fix(stats): coalesce concurrent ttlCache misses onto a single compute - #69
Open
detail-app[bot] wants to merge 1 commit into
Open
detail-app[bot] wants to merge 1 commit into
detail-app[bot] wants to merge 1 commit into
Conversation
Contributor
🤖 Augment PR SummarySummary: This PR prevents cache stampedes in the public stats queries. Changes:
Technical Notes:
🤖 Was this summary useful? React with 👍 or 👎 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Detail bug report: View on Detail
Bug
ttlCache(src/utils/ttlCache.ts, added ina18bd13) memoizes the expensive Prisma aggregations behind the publicstatOutcomesandstatOutcomesByYearqueries, refreshing at most once per hour. It tracked only the cached value — not an in-flight refresh — so when the cache was empty/expired andKcallers arrived concurrently, each caller independently invokedcompute(). At every TTL boundary (and on cold start) the 3–6-query aggregation batch firedKtimes instead of once (a cache-stampede). Warm-cache behavior was unaffected.Fix
Track an in-flight
pendingpromise inttlCacheand return it to concurrent callers, so only onecompute()runs per miss window:compute(), stores its promise inpending, and shares it.pendingpromise instead of starting anothercompute().cachedis written in.thenbeforependingis cleared in.finally— this ordering matters: clearingpendingfirst would open a microtask window where an interleaved caller seespending === nullbutcachedstill empty and starts a duplicatecompute().pendingis 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.tscall sites compile and behave without modification.Testing
src/utils/ttlCache.test.ts(uses the repo's hand-rolled harness convention): concurrent cold-miss coalesces to onecompute()(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 clearspendingfor retry, and a caller arriving mid-refresh joins the in-flight compute (guards the.then/.finallyordering). All pass under both--transpile-onlyand fullts-nodetype-checking.tsc --skipLibCheck --noEmit) and build (npm run build) pass; the existing offline tests forgithubandsyncAlumniInteractionsstill pass.End-to-end verification against a live Postgres + the running GraphQL server (I provisioned a
postgres:16container with the schema and seeded data, and stubbed the unrelated Elasticsearch dependency that otherwise crashes server boot):StatsResolverwith a Prisma per-execute query counter: 20 concurrentstatOutcomescalls issued exactly 6 DB queries (not 120) and 20 concurrentstatOutcomesByYearissued 3 (not 60); all callers received identical results./graphqlendpoint usingpg_stat_statementsper-executecalls: 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:
STAT_OUTCOMES_CACHE_TTL_MSis hardcoded with no config seam, so exercising it requires either a 1-hour wait or an out-of-scope code change. The samettlCachecode path is covered by the unit tests with a short configurable TTL.ttlCachewith a rejectingcompute().eslint— the repo-wide@typescript-eslint/parseris incompatible with the installed TypeScript (DeprecationError: 'originalKeywordKind'), failing identically on untouched files;tscis the authoritative type gate.Automatic Fixes PRs can be configured here.