Skip to content

Comments

Optimized requests with identical get helpers#26487

Merged
9larsons merged 12 commits intomainfrom
cache-gets-per-request
Feb 24, 2026
Merged

Optimized requests with identical get helpers#26487
9larsons merged 12 commits intomainfrom
cache-gets-per-request

Conversation

@9larsons
Copy link
Contributor

ref https://linear.app/ghost/issue/ONC-1431

#get helpers from the frontend are processed as individual API requests, although there may be duplicates. This change creates a per-request cache that prevents duplicative queries hitting the database, while allowing theme creators to continue using a simple syntax.

This is configured to be used with a new config setting: optimization:getHelper:deduplication.

ref https://linear.app/ghost/issue/ONC-1431

#get helpers from the frontend are processed as individual API requests, although there may be duplicates. This change creates a per-request cache that prevents duplicative queries hitting the database, while allowing theme creators to continue using a simple syntax.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 18, 2026

No actionable comments were generated in the recent review. 🎉


Walkthrough

Adds per-request query deduplication to the Get helper by introducing generateCacheKey (deterministically serializes API options) and a renderResponse helper, storing in-flight promises in a request-scoped _queryCache on options.data, and exposing generateCacheKey from the module. The theme-engine middleware conditionally initializes _queryCache when config.get('optimization:getHelper:deduplication') is enabled. Unit tests were added covering generateCacheKey, deduplication behavior, concurrency, and error cases.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Optimized requests with identical get helpers' clearly summarizes the main change: a per-request deduplication cache for identical get helper API requests.
Description check ✅ Passed The description accurately explains that the PR adds per-request caching to deduplicate identical frontend get helpers, preventing duplicate database queries while maintaining existing theme syntax.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cache-gets-per-request

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.

@9larsons 9larsons marked this pull request as draft February 18, 2026 18:46
Copy link
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

🧹 Nitpick comments (3)
ghost/core/test/unit/frontend/helpers/get.test.js (2)

835-855: Test title is misleading — it doesn't actually test different property orderings.

Both hashes are {filter: 'featured:true', limit: 5} with identical key order. The comment on line 846 acknowledges this ("hash object properties are in same order in JS"). The test verifies deduplication of literally identical option objects, which is already covered by the earlier test on line 724. Consider either removing this test as a duplicate, or actually varying the scenario (e.g., a numeric vs. string limit).

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

In `@ghost/core/test/unit/frontend/helpers/get.test.js` around lines 835 - 855,
The test titled "should deduplicate queries with same parameters in different
order" is misleading because both calls pass identical hash objects; update the
test in get.test.js to actually vary property order or types so it verifies
deduplication across different representations: modify the second call to pass
the hash with properties in a different insertion order (or change limit from
number to string) when invoking get (same arguments shape used in the first
call) and assert browseStub.callCount === 1; reference the get helper being
invoked and the browseStub assertion so you change only the test inputs and keep
the deduplication config (optimization:getHelper:deduplication) and
locals/_queryCache setup intact.

857-888: Good concurrent deduplication test — consider also asserting on the response content.

The test correctly validates that only one API call is made and both helpers render (fn.callCount === 2). Given that both calls share the same response object (see shared-mutation concern on get.js), asserting that the data passed to fn in both calls is correct (e.g., fn.firstCall.args[0].posts and fn.secondCall.args[0].posts both have the expected content) would catch double-processing regressions.

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

In `@ghost/core/test/unit/frontend/helpers/get.test.js` around lines 857 - 888,
Add assertions to the concurrent deduplication test to verify the actual
response passed to the template function is correct for both invocations: after
the Promise.all and the existing asserts on browseStub.callCount and
fn.callCount, assert that fn.firstCall.args[0].posts (and
fn.secondCall.args[0].posts) contain the expected content (e.g., an item with id
'post1' or match the meta/response shape returned by the browseStub). This
ensures get (the helper under test) and the browseStub slow response are not
being double-processed or mutated between concurrent calls.
ghost/core/core/frontend/helpers/get.js (1)

57-73: Cache key delimiter | could cause collisions if any field value contains it.

The |-joined key is position-based, but if a value itself contains | (e.g., a filter string, slug, or order clause), two distinct queries could produce the same key. Example: filter='a|b', limit='' vs. filter='a', limit='b' would both yield ...a|b|....

In practice this is unlikely with Ghost's current API surface, but a safer approach would be to use a delimiter that cannot appear in any of the option values, or encode each part.

Safer alternative using JSON.stringify per part
 function generateCacheKey(resource, apiOptions) {
     const parts = [
         resource,
         apiOptions.filter || '',
         apiOptions.limit || '',
         apiOptions.include || '',
         apiOptions.fields || '',
         apiOptions.formats || '',
         apiOptions.page || '',
         apiOptions.order || '',
         apiOptions.id || '',
         apiOptions.slug || '',
         apiOptions.context?.member?.uuid || ''
     ];
-    return parts.join('|');
+    return JSON.stringify(parts);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/frontend/helpers/get.js` around lines 57 - 73, The
generateCacheKey function builds a pipe-delimited key which can collide if any
option value contains '|' — fix by encoding or escaping each part before joining
(e.g., JSON.stringify or base64-encoding each element of parts) so the delimiter
cannot be confused with data; update generateCacheKey to map parts through an
encoder and then join with the delimiter, ensuring you still use the same
deterministic field order (resource, apiOptions.filter, apiOptions.limit, etc.)
and preserve empty-string fallbacks.
🤖 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/frontend/helpers/get.js`:
- Around line 341-365: The shared mutable response causes double-wrapping
because renderResponse calls prepareContextResource which mutates
response[resource]; fix it by shallow-cloning the response at the start of
renderResponse and replacing response[resource] with a shallow copy of that
array so prepareContextResource operates on the clone (preserving the
cached/original response for future calls); update renderResponse to work with
the clonedResponse and cloned array before running prepareContextResource,
leaving SafeString-wrapped values in the original untouched.

---

Nitpick comments:
In `@ghost/core/core/frontend/helpers/get.js`:
- Around line 57-73: The generateCacheKey function builds a pipe-delimited key
which can collide if any option value contains '|' — fix by encoding or escaping
each part before joining (e.g., JSON.stringify or base64-encoding each element
of parts) so the delimiter cannot be confused with data; update generateCacheKey
to map parts through an encoder and then join with the delimiter, ensuring you
still use the same deterministic field order (resource, apiOptions.filter,
apiOptions.limit, etc.) and preserve empty-string fallbacks.

In `@ghost/core/test/unit/frontend/helpers/get.test.js`:
- Around line 835-855: The test titled "should deduplicate queries with same
parameters in different order" is misleading because both calls pass identical
hash objects; update the test in get.test.js to actually vary property order or
types so it verifies deduplication across different representations: modify the
second call to pass the hash with properties in a different insertion order (or
change limit from number to string) when invoking get (same arguments shape used
in the first call) and assert browseStub.callCount === 1; reference the get
helper being invoked and the browseStub assertion so you change only the test
inputs and keep the deduplication config (optimization:getHelper:deduplication)
and locals/_queryCache setup intact.
- Around line 857-888: Add assertions to the concurrent deduplication test to
verify the actual response passed to the template function is correct for both
invocations: after the Promise.all and the existing asserts on
browseStub.callCount and fn.callCount, assert that fn.firstCall.args[0].posts
(and fn.secondCall.args[0].posts) contain the expected content (e.g., an item
with id 'post1' or match the meta/response shape returned by the browseStub).
This ensures get (the helper under test) and the browseStub slow response are
not being double-processed or mutated between concurrent calls.

@9larsons 9larsons marked this pull request as ready for review February 18, 2026 20:06
@9larsons 9larsons requested a review from EvanHahn February 18, 2026 20:06
Copy link
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 (2)
ghost/core/core/frontend/helpers/get.js (1)

408-418: Cache-hit catch is overly broad — catches renderResponse failures too.

If the cached promise resolves successfully but renderResponse throws (e.g., template error), the catch on line 414 will delete a valid cache entry and fall through to make a redundant API call. Consider narrowing the try/catch to only the await, or re-throwing non-promise errors:

Suggested narrowing
         if (queryCache.has(cacheKey)) {
+            let cachedResponse;
             try {
                 // Await cached promise (handles both resolved and in-flight)
-                const cachedResponse = await queryCache.get(cacheKey);
-                returnedRowsCount = cachedResponse[resource] && cachedResponse[resource].length;
-                return renderResponse(cachedResponse, resource, options, data);
+                cachedResponse = await queryCache.get(cacheKey);
             } catch (error) {
                 // Cached promise rejected - fall through to make new request
                 queryCache.delete(cacheKey);
             }
+
+            if (cachedResponse) {
+                returnedRowsCount = cachedResponse[resource] && cachedResponse[resource].length;
+                return renderResponse(cachedResponse, resource, options, data);
+            }
         }

Low severity since template errors in production are rare and the per-request cache limits blast radius.

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

In `@ghost/core/core/frontend/helpers/get.js` around lines 408 - 418, The
try/catch around both awaiting the cached promise and calling renderResponse
causes renderResponse failures to be treated as cache-miss errors and delete the
valid cache entry; narrow the scope so only the await of
queryCache.get(cacheKey) is inside the try (or catch and re-throw non-promise
errors), e.g. await the cached promise in a try to handle rejected/in-flight
promises, then after a successful cachedResponse assignment call
renderResponse(resource, options, data) outside that try and only delete
queryCache for errors thrown by the await; keep the returnedRowsCount assignment
tied to the cachedResponse variable and do not delete cache when renderResponse
throws.
ghost/core/test/unit/frontend/helpers/get.test.js (1)

871-902: Good concurrent dedup test — also consider testing timeout+dedup interaction.

The concurrent test correctly validates that in-flight dedup works via Promise.all. One gap: there's no test for when optimization:getHelper:timeout:threshold is configured alongside deduplication. If the first request is aborted via timeout, both concurrent waiters would receive the @@ABORTED_GET_HELPER@@ response. This may be worth a test to verify the aborted marker renders correctly for both callers.

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

In `@ghost/core/test/unit/frontend/helpers/get.test.js` around lines 871 - 902,
Add a new test that verifies dedup + timeout interaction: enable deduplication
via configUtils.set('optimization:getHelper:deduplication', true) and set a low
timeout using configUtils.set('optimization:getHelper:timeout:threshold', <small
ms>) then make browseStub return after a longer delay so the initial in-flight
request times out; invoke get.call twice concurrently (same args as existing
test using locals, fn, inverse) and assert browseStub.callCount is 1,
fn.callCount is 2, and both fn invocations were rendered with the aborted marker
'@@ABORTED_GET_HELPER@@' (or equivalent aborted output) to ensure both waiters
receive the aborted response.Ensure you reference the same symbols used in the
file: browseStub, get.call, locals, fn, inverse, and the aborted marker when
adding assertions.
🤖 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/frontend/helpers/get.js`:
- Around line 408-418: The try/catch around both awaiting the cached promise and
calling renderResponse causes renderResponse failures to be treated as
cache-miss errors and delete the valid cache entry; narrow the scope so only the
await of queryCache.get(cacheKey) is inside the try (or catch and re-throw
non-promise errors), e.g. await the cached promise in a try to handle
rejected/in-flight promises, then after a successful cachedResponse assignment
call renderResponse(resource, options, data) outside that try and only delete
queryCache for errors thrown by the await; keep the returnedRowsCount assignment
tied to the cachedResponse variable and do not delete cache when renderResponse
throws.

In `@ghost/core/test/unit/frontend/helpers/get.test.js`:
- Around line 871-902: Add a new test that verifies dedup + timeout interaction:
enable deduplication via configUtils.set('optimization:getHelper:deduplication',
true) and set a low timeout using
configUtils.set('optimization:getHelper:timeout:threshold', <small ms>) then
make browseStub return after a longer delay so the initial in-flight request
times out; invoke get.call twice concurrently (same args as existing test using
locals, fn, inverse) and assert browseStub.callCount is 1, fn.callCount is 2,
and both fn invocations were rendered with the aborted marker
'@@ABORTED_GET_HELPER@@' (or equivalent aborted output) to ensure both waiters
receive the aborted response.Ensure you reference the same symbols used in the
file: browseStub, get.call, locals, fn, inverse, and the aborted marker when
adding assertions.

Copy link
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: 2

🧹 Nitpick comments (1)
ghost/core/test/unit/frontend/helpers/get.test.js (1)

8-9: Combine the two require calls into one.

The same module is required twice. querySimplePath and generateCacheKey are properties of the same module object already held in get, so the second require is redundant.

♻️ Proposed consolidation
-const get = require('../../../../core/frontend/helpers/get');
-const {querySimplePath, generateCacheKey} = require('../../../../core/frontend/helpers/get');
+const get = require('../../../../core/frontend/helpers/get');
+const {querySimplePath, generateCacheKey} = get;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/frontend/helpers/get.test.js` around lines 8 - 9, You
have two requires for the same module; remove the redundant require and obtain
querySimplePath and generateCacheKey from the already-imported get object (e.g.
keep const get = require('../../../../core/frontend/helpers/get'); then
destructure const {querySimplePath, generateCacheKey} = get) so only one require
is used and the helpers are referenced from get.
🤖 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/test/unit/frontend/helpers/get.test.js`:
- Around line 715-735: The test currently sets locals = {root: {_locals: {}},
_queryCache: {}} which uses a plain object and doesn't exercise the Map-based
cache path; change the test to provide a real Map for _queryCache (e.g., locals
= {root: {_locals: {}}, _queryCache: new Map()}) so that get.call(...) runs with
a Map-backed cache while the deduplication config flag remains absent/disabled,
then assert browseStub.callCount === 2 to verify duplicate API calls occur;
update references to locals and _queryCache in this test case only.
- Around line 684-694: The assertion key.includes('2') is unreliable because it
can match other fields (like id: '123'); update the assertion that verifies the
serialized cache key to specifically assert that the page field is present and
equals 2 (e.g. check for the JSON field '"page":2' or use a regex like
/"page"\s*:\s*2/ or parse the key and assert parsed.page === 2). Change the
assertion around the variable key in get.test.js (the block of includes
assertions) to use a field-scoped check for the page property instead of the
loose '2' substring check.

---

Nitpick comments:
In `@ghost/core/test/unit/frontend/helpers/get.test.js`:
- Around line 8-9: You have two requires for the same module; remove the
redundant require and obtain querySimplePath and generateCacheKey from the
already-imported get object (e.g. keep const get =
require('../../../../core/frontend/helpers/get'); then destructure const
{querySimplePath, generateCacheKey} = get) so only one require is used and the
helpers are referenced from get.

Copy link
Contributor

@EvanHahn EvanHahn left a comment

Choose a reason for hiding this comment

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

LGTM overall, but a number of nits.

try {
// Await cached promise (handles both resolved and in-flight)
const cachedResponse = await queryCache.get(cacheKey);
returnedRowsCount = cachedResponse[resource] && cachedResponse[resource].length;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I believe this line does nothing.

Suggested change
returnedRowsCount = cachedResponse[resource] && cachedResponse[resource].length;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Currently yes, because of the early exit you mentioned below. Though if we still check the timing, we should include the rows.

// Await cached promise (handles both resolved and in-flight)
const cachedResponse = await queryCache.get(cacheKey);
returnedRowsCount = cachedResponse[resource] && cachedResponse[resource].length;
return renderResponse(cachedResponse, resource, options, data);
Copy link
Contributor

Choose a reason for hiding this comment

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

  • issue: This early return prevents all the logging below. Cached requests will probably be below the notify threshold, but I don't think we should assume that.
  • issue: If cachedResponse resolves but renderResponse fails, we will needlessly delete the cache key.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, yep! Good catch. Fixed. I was mostly thinking about skipping the logging in the event of a cached response, but it feels more reasonable to keep these on the same path. Plus, it's entirely possible they take forever for some reason.

Copy link
Contributor

Choose a reason for hiding this comment

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

It still looks like we have an early return preventing the logging. Could you fix that?

@9larsons 9larsons requested a review from EvanHahn February 20, 2026 02:22
Copy link
Contributor

@EvanHahn EvanHahn left a comment

Choose a reason for hiding this comment

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

Two non-blocking comments. LGTM.

};

/**
* @typedef {Object.<string, unknown>} GetHelperAPIOptions
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I think this should be a Record, but I might be wrong.

Suggested change
* @typedef {Object.<string, unknown>} GetHelperAPIOptions
* @typedef {Record<string, unknown>} GetHelperAPIOptions

// Await cached promise (handles both resolved and in-flight)
const cachedResponse = await queryCache.get(cacheKey);
returnedRowsCount = cachedResponse[resource] && cachedResponse[resource].length;
return renderResponse(cachedResponse, resource, options, data);
Copy link
Contributor

Choose a reason for hiding this comment

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

It still looks like we have an early return preventing the logging. Could you fix that?

@9larsons 9larsons merged commit 0960849 into main Feb 24, 2026
33 checks passed
@9larsons 9larsons deleted the cache-gets-per-request branch February 24, 2026 15:56
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