Skip to content

Optimize redis cache miss coalescing#27507

Merged
vershwal merged 4 commits intomainfrom
optimize-redis-cache-miss-coalescing
Apr 23, 2026
Merged

Optimize redis cache miss coalescing#27507
vershwal merged 4 commits intomainfrom
optimize-redis-cache-miss-coalescing

Conversation

@vershwal
Copy link
Copy Markdown
Member

@vershwal vershwal commented Apr 22, 2026

Concurrent requests for the same cache key that all encounter a miss each independently call fetchData(), causing a "thundering herd" — N identical queries to the underlying data source when only 1 is needed. This adds request coalescing so that fetchData() runs exactly once per cache miss, and all concurrent callers share the same result.

What changed

  • Added a currentlyExecutingReads Map to AdapterCacheRedis that tracks in-flight fetch promises per key
  • On a cache miss, the first caller stores its fetchData() promise in the Map; subsequent callers for the same key return the existing promise instead of spawning a new fetch
  • The promise is cleaned up via .finally() so the next request after completion goes through normally
  • Errors from fetchData() are caught and logged within the promise chain (matching the existing error-swallowing behavior), so all concurrent callers receive undefined and the key is freed for retry on the next request

Why coalescing is scoped to cache misses only

An earlier approach (#19627) coalesced the entire get() flow — including the Redis read, TTL check, and refresh-ahead logic. This means even cache hits were serialized: if 10 requests arrived concurrently for a cached key, 9 of them would wait for the first one's full Redis round-trip + TTL check to complete before returning.

Cache hits are the fast path and don't benefit from serialization — Redis handles concurrent reads efficiently. The expensive part is fetchData() (the database or API call behind the cache), and that only runs on a miss. By scoping coalescing to the miss branch:

  • Cache hits stay fast — each request reads from Redis independently, no artificial queuing
  • Cache misses are protectedfetchData() runs once, all waiters share the result
  • No extra private method needed — the change stays within the existing get() method

Why the Map is keyed by internalKey (not the external key)

The cache uses prefix-hash rotation for invalidation — reset() changes the prefix hash so all existing keys become invisible. If the coalescing Map were keyed by the external key (e.g. 'foo'), a reset() during an in-flight fetchData() would cause two bugs:

  1. Incorrect coalescing — a post-reset caller looks up 'foo', gets a new internalKey (prefix:newHash:foo), misses, but finds the pre-reset promise under 'foo' and joins it instead of fetching fresh data
  2. Stale write to new generation — the original this.set(key, data) call re-derives the internal key at write time using the new prefix hash, so stale data from the pre-reset fetch lands in the new cache generation

Keying by internalKey (which includes the prefix hash) fixes both: after a reset, a new caller gets a different internalKey so it won't join the stale promise, and the pre-reset writeback targets the old generation key directly via this.cache.set(internalKey, data) — a harmless orphan that nobody will read.

When internalKey is null (timeout or Redis failure during lookup), coalescing is skipped entirely — the degraded path doesn't need this protection.

Test plan

Unit tests (adapter-cache-redis.test.js)

  • Concurrent cache misses call fetchData only once, all callers receive the value
  • fetchData rejection propagates to all concurrent callers (all get undefined, error logged once)
  • After a coalesced fetch rejection, the Map is cleaned up and the next call retries successfully
  • Does not coalesce fetches across a prefix_hash cycle (reset)
  • Concurrent misses for different keys fetch independently (coalescing is per-key, not global)

Integration test (adapter-cache-redis.test.js)

  • Concurrent cache misses against real Redis call fetchData only once, subsequent cached read also works

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1f27c55f-def2-43c3-9b11-7f7ad59c6704

📥 Commits

Reviewing files that changed from the base of the PR and between c4dcff7 and a33f778.

📒 Files selected for processing (3)
  • ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js
  • ghost/core/test/integration/adapters/redis/adapter-cache-redis.test.js
  • ghost/core/test/unit/server/adapters/lib/redis/adapter-cache-redis.test.js

Walkthrough

The Redis cache adapter now coalesces concurrent cache-miss reads within the same process using a currentlyExecutingReads map: if an internalKey has an in-flight promise, get returns that promise; if internalKey is null it immediately runs fetchData(); otherwise it starts fetchData(), stores the promise, writes the result with this.cache.set(internalKey, data) on success, logs errors, and removes the map entry in finally. Tests were added to verify coalescing, rejection propagation, retry behavior, isolation across prefix_hash resets, and independence per cache key.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Optimize redis cache miss coalescing' directly and clearly describes the main change: adding request coalescing for Redis cache misses to prevent the thundering herd problem.
Description check ✅ Passed The description comprehensively explains the problem being solved, implementation details, design rationale for scoping to misses only, and the test coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch optimize-redis-cache-miss-coalescing

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js`:
- Around line 234-245: The in-flight coalescing currently uses the external key,
causing race where reset() changes the prefix and a pre-reset fetch writes into
the new generation; change the logic in AdapterCacheRedis to key
currentlyExecutingReads by the internal cache key produced by the lookup
(internalKey) instead of the external key: when you perform the lookup/get flow,
obtain the internalKey and use that internalKey as the map key for
currentlyExecutingReads before starting fetchData(), and when the fetchPromise
resolves call this.set(internalKey, data) (or the internal-key-aware writeback)
and delete the entry by internalKey in finally; update any places that join the
in-flight promise to do so by internalKey as well (refer to
currentlyExecutingReads, fetchData(), set(), get()/lookup/internalKey).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e5c1b978-6c76-4f87-bb56-3127d4757a3e

📥 Commits

Reviewing files that changed from the base of the PR and between 5b945df and c8fa863.

📒 Files selected for processing (3)
  • ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js
  • ghost/core/test/integration/adapters/redis/adapter-cache-redis.test.js
  • ghost/core/test/unit/server/adapters/lib/redis/adapter-cache-redis.test.js

Comment thread ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js Outdated
@vershwal vershwal force-pushed the optimize-redis-cache-miss-coalescing branch from a3ac98b to 5425684 Compare April 22, 2026 12:12
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js (1)

234-259: Coalescing is skipped on the lookup-timeout / redis-failure path.

internalKey is null when #lookupWithTimeout times out or the underlying cache.get rejects (lines 187 and 195). In that branch, the miss handler neither consults nor populates currentlyExecutingReads, so concurrent callers each fire their own fetchData() and the fallback this.set(key, data) writeback is not deduplicated either.

That is precisely the scenario coalescing is most valuable for (Redis slow/unhealthy ⇒ origin thundering-herd), so it's worth calling out. The current behavior is safe (no cross-generation pollution, which was the reason internalKey scoping was introduced), so this is an optional improvement, not a blocker.

If you want to close the gap, you can compute an internalKey on demand in the miss branch (independently of the lookup result) and use that as the map key — e.g.:

♻️ Optional: coalesce even when lookup failed/timed out
-            } else {
-                if (internalKey && this.currentlyExecutingReads.has(internalKey)) {
-                    return this.currentlyExecutingReads.get(internalKey);
-                }
-                const fetchPromise = fetchData().then(async (data) => {
-                    if (internalKey) {
-                        try {
-                            debug('set', internalKey);
-                            await this.cache.set(internalKey, data);
-                        } catch (err) {
-                            logging.error(err);
-                        }
-                    } else {
-                        await this.set(key, data);
-                    }
-                    return data;
-                }).catch((err) => {
-                    logging.error(err);
-                }).finally(() => {
-                    if (internalKey) {
-                        this.currentlyExecutingReads.delete(internalKey);
-                    }
-                });
-                if (internalKey) {
-                    this.currentlyExecutingReads.set(internalKey, fetchPromise);
-                }
-                return fetchPromise;
+            } else {
+                // Derive a coalescing key even if the lookup itself failed/timed out,
+                // so concurrent misses don't all stampede the origin.
+                let coalesceKey = internalKey;
+                if (!coalesceKey) {
+                    try {
+                        coalesceKey = await this._buildKey(key);
+                    } catch (e) {
+                        // If we can't build a key, fall through without coalescing.
+                    }
+                }
+                if (coalesceKey && this.currentlyExecutingReads.has(coalesceKey)) {
+                    return this.currentlyExecutingReads.get(coalesceKey);
+                }
+                const fetchPromise = fetchData().then(async (data) => {
+                    if (coalesceKey) {
+                        try {
+                            debug('set', coalesceKey);
+                            await this.cache.set(coalesceKey, data);
+                        } catch (err) {
+                            logging.error(err);
+                        }
+                    } else {
+                        await this.set(key, data);
+                    }
+                    return data;
+                }).catch((err) => {
+                    logging.error(err);
+                }).finally(() => {
+                    if (coalesceKey) {
+                        this.currentlyExecutingReads.delete(coalesceKey);
+                    }
+                });
+                if (coalesceKey) {
+                    this.currentlyExecutingReads.set(coalesceKey, fetchPromise);
+                }
+                return fetchPromise;

One small nit regardless of the above: the .finally deletes by key unconditionally. Since internalKey is generation-scoped this is safe today, but an identity guard (if (this.currentlyExecutingReads.get(internalKey) === fetchPromise) …) would be robust against any future change that re-seeds the entry mid-flight.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js` around lines
234 - 259, The miss path skips coalescing when lookupWithTimeout/cache.get
returns null by not using an internalKey, causing concurrent callers to each
call fetchData() and write back via this.set; to fix, compute or derive an
internalKey for the miss branch (e.g., based on key + generation) and use it
when interacting with currentlyExecutingReads so concurrent misses share the
same fetchPromise, ensure fetchPromise is stored with
this.currentlyExecutingReads.set(internalKey, fetchPromise) for both hit and
miss paths, and make the finally cleanup robust by only deleting if
this.currentlyExecutingReads.get(internalKey) === fetchPromise to avoid
accidentally removing a newer in-flight promise.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js`:
- Around line 234-259: The miss path skips coalescing when
lookupWithTimeout/cache.get returns null by not using an internalKey, causing
concurrent callers to each call fetchData() and write back via this.set; to fix,
compute or derive an internalKey for the miss branch (e.g., based on key +
generation) and use it when interacting with currentlyExecutingReads so
concurrent misses share the same fetchPromise, ensure fetchPromise is stored
with this.currentlyExecutingReads.set(internalKey, fetchPromise) for both hit
and miss paths, and make the finally cleanup robust by only deleting if
this.currentlyExecutingReads.get(internalKey) === fetchPromise to avoid
accidentally removing a newer in-flight promise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d415b0b4-0a35-48a2-bfe0-7714b4553ed1

📥 Commits

Reviewing files that changed from the base of the PR and between c8fa863 and 5425684.

📒 Files selected for processing (3)
  • ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js
  • ghost/core/test/integration/adapters/redis/adapter-cache-redis.test.js
  • ghost/core/test/unit/server/adapters/lib/redis/adapter-cache-redis.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/test/unit/server/adapters/lib/redis/adapter-cache-redis.test.js

@vershwal vershwal requested a review from allouis April 22, 2026 12:20
Comment thread ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js Outdated
Comment thread ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js Outdated
@vershwal vershwal requested a review from allouis April 22, 2026 13:31
Comment thread ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js
Comment thread ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js
- When multiple requests hit the cache simultaneously for the same key and all encounter a miss, each one independently calls fetchData(), hammering the underlying data source with identical queries. This is the classic "thundering herd" problem.
- This adds a currentlyExecutingReads Map that stores the in-flight fetch promise on a cache miss. Subsequent callers for the same key reuse that promise instead of spawning redundant fetches. The promise is cleaned up via .finally() so the next request after completion goes through normally.
- The coalescing is scoped to cache misses only — cache hits continue to read from Redis independently, which is the fast path and doesn't benefit from serialization. The expensive part is fetchData() (the database or API call behind the cache), and that's what we protect.
- Unit tests cover: concurrent misses resolve from a single fetch, fetchData rejection propagates to all waiters, retry succeeds after a coalesced failure, and different keys fetch independently.
- Integration test verifies the same coalescing behavior against a real Redis instance.
The coalescing Map was keyed by the external key (e.g. 'foo'),
and the writeback used this.set(key, data) which calls _buildKey
at write time to resolve the current prefix hash. This created a
race when reset() changed the prefix hash while a fetchData() was
in flight:

1. Caller A looks up 'foo', gets internalKey 'prefix:hashABC:foo',
   misses, starts fetchData(), stores promise under 'foo'
2. reset() changes prefix hash from hashABC to hashXYZ
3. Caller B looks up 'foo', gets internalKey 'prefix:hashXYZ:foo',
   misses, but finds the promise under 'foo' and joins it
4. Caller A's fetch resolves with stale data, this.set('foo', data)
   re-derives the key using the NEW hash, writing stale data into
   the new generation

Two bugs: Caller B incorrectly coalesces with a pre-reset fetch,
and the stale data pollutes the new cache generation.

Fix: key the coalescing Map by internalKey (which includes the
prefix hash) and write back directly to cache.set(internalKey, data)
using the key captured at lookup time. After a reset, a new caller
gets a different internalKey so it won't join the stale promise,
and the pre-reset write targets the old generation key (a harmless
orphan). When internalKey is null (timeout or Redis failure during
lookup), coalescing is skipped entirely — the degraded path doesn't
need this protection.
- Skip coalescing and Redis writeback when internalKey is null,
  just return fetchData() directly
- Return data to the caller immediately instead of waiting for
  the Redis SET to complete
@vershwal vershwal force-pushed the optimize-redis-cache-miss-coalescing branch from c4dcff7 to a33f778 Compare April 23, 2026 05:33
@sonarqubecloud
Copy link
Copy Markdown

@vershwal vershwal enabled auto-merge (squash) April 23, 2026 05:40
@vershwal vershwal merged commit 5e09e0f into main Apr 23, 2026
43 checks passed
@vershwal vershwal deleted the optimize-redis-cache-miss-coalescing branch April 23, 2026 05:57
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.

2 participants