Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor and test cache #2220

Merged
merged 8 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-clocks-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/hydrogen': patch
---

Fix a small rounding issue when checking stale-while-revalidate timing.
11 changes: 3 additions & 8 deletions packages/hydrogen/src/cache/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,7 @@ async function setItem(
*
* Store the following information in the response header:
*
* cache-put-date - UTC time string of when this request is PUT into cache
*
* Note on `cache-put-date`: The `response.headers.get('date')` isn't static. I am
* not positive what date this is returning but it is never over 500 ms
* after subtracting from the current timestamp.
* cache-put-date - Timestamp string of when this request is PUT into cache
*
* `isStale` function will use the above information to test for stale-ness of a cached response
*/
Expand All @@ -135,7 +131,7 @@ async function setItem(
// cache-control is still necessary for mini-oxygen
response.headers.set('cache-control', paddedCacheControlString);
response.headers.set('real-cache-control', cacheControlString);
response.headers.set('cache-put-date', new Date().toUTCString());
response.headers.set('cache-put-date', String(Date.now()));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@wizardlyhel Do you remember why this was using toUTCString? It introduces a rounding error (~500ms as mentioned in the note above. Instead, .toISOString() could fix the error but we can also just use the timestamp directly I think?

Copy link
Contributor

Choose a reason for hiding this comment

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

urk ... rounding error of ~500ms .. that sucks and how the hell you figure that out?!

I think I was thinking to make the SWR cache calculation to not have timezone differences and this is heavily depends on how the workers and cache data centres are distributed. So .. if we have

  • worker 1 in UTC + 7
  • worker 2 in UTC + 6

and data centre is at UTC + 7 and both workers are accessing the same data centre, then setting a cache entry on the same key would produce different cache entry duration calculation if they use their local server timestamp

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I understand the reasoning about the different timezone with toUTCString but that function does not return milliseconds so it's impossible to parse it into something accurate (half second rounding error). On the other hand, toISOString contains milliseconds:

  • toUTCString: Mon, 10 Jun 2024 02:03:08 GMT
  • toISOString: 2024-06-10T02:03:08.920Z

They are both always based on UTC.

In any case, I think a simple timestamp based on UTC is also fine and even simpler, right?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh! I didn't know that UTCString doesn't supply the milliseconds. Yea, let's use ISOString

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But Date.now() also considers UTC and is simpler conceptually to store milliseconds than a date. Let's use that better, no?

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed, I think Date.now() is simpler


logCacheApiStatus('PUT', request, response);
await cache.put(request, response);
Expand All @@ -159,8 +155,7 @@ function calculateAge(response: Response, responseDate: string) {
}
}

const ageInMs =
new Date().valueOf() - new Date(responseDate as string).valueOf();
const ageInMs = Date.now() - Number(responseDate as string);
return [ageInMs / 1000, responseMaxAge];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ const data: ReferenceEntityTemplateSchema = {
subCategory: 'caching',
isVisualComponent: false,
related: [],
description: `Creates a utility function that executes an asynchronous operation \n like \`fetch\` and caches the result according to the strategy provided.\nUse this to call any third-party APIs from loaders or actions.\nBy default, it uses the \`CacheShort\` strategy.`,
description: `Creates a utility function that executes an asynchronous operation \n like \`fetch\` and caches the result according to the strategy provided.\nUse this to call any third-party APIs from loaders or actions.`,
type: 'utility',
defaultExample: {
description: 'I am the default example',
codeblock: {
tabs: [
{
title: 'JavaScript',
code: './with-cache.example.js',
code: './create-with-cache.example.js',
language: 'js',
},
{
title: 'TypeScript',
code: './with-cache.example.ts',
code: './create-with-cache.example.ts',
language: 'ts',
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,35 @@ export default {
// Create a custom utility to query a third-party API:
const fetchMyCMS = (query) => {
// Prefix the cache key and make it unique based on arguments.
return withCache(['my-cms', query], CacheLong(), async () => {
return await (
await fetch('my-cms.com/api', {
method: 'POST',
body: query,
})
).json();
return withCache(['my-cms', query], CacheLong(), async (params) => {
const response = await fetch('my-cms.com/api', {
method: 'POST',
body: query,
});

// Throw if the response is unsuccessful
if (!response.ok) throw new Error(response.statusText);

// Assuming the API returns errors in the body:
const {data, error} = await response.json();

// Validate data and throw to avoid caching errors.
if (error || !data) throw new Error(error ?? 'Missing data');

// Optionally, add extra information to show
// in the Subrequest Profiler utility.
params.addDebugData({displayName: 'My CMS query', response});

return data;
});
};

const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
getLoadContext: () => ({
/* ... */
// If you have one, update your env.d.ts to
// include `fetchMyCMS` in `AppLoadContext`.
fetchMyCMS,
}),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,37 @@ export default {
// Create a custom utility to query a third-party API:
const fetchMyCMS = (query: string) => {
// Prefix the cache key and make it unique based on arguments.
return withCache(['my-cms', query], CacheLong(), async () => {
return await (
await fetch('my-cms.com/api', {
method: 'POST',
body: query,
})
).json();
return withCache(['my-cms', query], CacheLong(), async (params) => {
const response = await fetch('my-cms.com/api', {
method: 'POST',
body: query,
});

// Throw if the response is unsuccessful
if (!response.ok) throw new Error(response.statusText);

const {data, error} = (await response.json()) as {
data: unknown;
error?: string;
};

// Validate data and throw to avoid caching errors.
if (error || !data) throw new Error(error ?? 'Missing data');

// Optionally, add extra information to show
// in the Subrequest Profiler utility.
params.addDebugData({displayName: 'My CMS query', response});

return data;
});
};

const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
getLoadContext: () => ({
// Make sure to update remix.env.d.ts to include `fetchMyCMS`
// Make sure to update env.d.ts to
// include `fetchMyCMS` in `AppLoadContext`.
fetchMyCMS,
}),
});
Expand Down
116 changes: 116 additions & 0 deletions packages/hydrogen/src/cache/create-with-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {describe, beforeEach, it, expect, vi} from 'vitest';
import {type WithCache, createWithCache} from './create-with-cache';
import {InMemoryCache} from './in-memory';
import {getItemFromCache} from './sub-request';
import {CacheNone, CacheShort} from './strategies';

describe('createWithCache', () => {
const KEY = 'my-key';
const VALUE = 'my-value';
const actionFn = vi.fn(() => VALUE);
const waitUntil = vi.fn(() => {});
let cache: InMemoryCache;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

For now this test uses our InMemoryCache implementation. We could change this to eventually use real MiniOxygen cache directly but I think it's good for now.

Copy link
Contributor

Choose a reason for hiding this comment

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

This is awesome! Thank you for doing this. One nitpicky thing, but maybe an extra test that just validates cache expiration?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's already one in this file, inside the SWR test. It first checks SWR, then it checks what happens when the whole thing expires 👍

let withCache: WithCache;

beforeEach(() => {
vi.useFakeTimers();
cache = new InMemoryCache();
withCache = createWithCache({cache, waitUntil});
actionFn.mockClear();
waitUntil.mockClear();
return () => vi.useRealTimers();
});

it('creates a valid withCache function', () => {
expect(withCache).toBeInstanceOf(Function);
});

it('skips cache for no-cache policy', async () => {
await expect(withCache(KEY, CacheNone(), actionFn)).resolves.toEqual(VALUE);

expect(waitUntil).toHaveBeenCalledTimes(0);
expect(actionFn).toHaveBeenCalledTimes(1);
await expect(getItemFromCache(cache, KEY)).resolves.toEqual(undefined);

await expect(withCache(KEY, CacheNone(), actionFn)).resolves.toEqual(VALUE);

// No cache, always calls the action function:
expect(waitUntil).toHaveBeenCalledTimes(0);
expect(actionFn).toHaveBeenCalledTimes(2);
await expect(getItemFromCache(cache, KEY)).resolves.toEqual(undefined);
});

it('skips cache when throwing', async () => {
actionFn.mockImplementationOnce(() => {
return Promise.resolve().then(() => {
throw new Error('test');
});
});

await expect(withCache(KEY, CacheShort(), actionFn)).rejects.toThrowError(
'test',
);

expect(waitUntil).toHaveBeenCalledTimes(0);
expect(actionFn).toHaveBeenCalledTimes(1);
await expect(getItemFromCache(cache, KEY)).resolves.toEqual(undefined);
});

it('stores results in the cache', async () => {
const strategy = CacheShort({maxAge: 1, staleWhileRevalidate: 9});
await expect(withCache(KEY, strategy, actionFn)).resolves.toEqual(VALUE);

expect(waitUntil).toHaveBeenCalledTimes(1);
expect(actionFn).toHaveBeenCalledTimes(1);
await expect(getItemFromCache(cache, KEY)).resolves.toContainEqual({
value: VALUE,
});

// Less than 1 sec of the cache duration:
vi.advanceTimersByTime(999);

await expect(withCache(KEY, strategy, actionFn)).resolves.toEqual(VALUE);

// Cache hit, nothing to update:
expect(waitUntil).toHaveBeenCalledTimes(1);
expect(actionFn).toHaveBeenCalledTimes(1);
await expect(getItemFromCache(cache, KEY)).resolves.toContainEqual({
value: VALUE,
});
});

it('applies stale-while-revalidate', async () => {
const strategy = CacheShort({maxAge: 1, staleWhileRevalidate: 9});
await expect(withCache(KEY, strategy, actionFn)).resolves.toEqual(VALUE);

expect(waitUntil).toHaveBeenCalledTimes(1);
expect(actionFn).toHaveBeenCalledTimes(1);
await expect(getItemFromCache(cache, KEY)).resolves.toContainEqual({
value: VALUE,
});

// More than 1 sec of the cache duration:
vi.advanceTimersByTime(3000);

await expect(withCache(KEY, strategy, actionFn)).resolves.toEqual(VALUE);

// Cache stale, call the action function again for SWR:
expect(waitUntil).toHaveBeenCalledTimes(2);
expect(actionFn).toHaveBeenCalledTimes(2);
await expect(getItemFromCache(cache, KEY)).resolves.toContainEqual({
value: VALUE,
});

// Make the cache expire. Note: We add a padded maxAge to the cache control
// header to support SWR in Oxygen/CFW. Our InMemoryCache doesn't understand
// this padded maxAge, so we need to advance timers considering the padded
// value: maxAge + (2 * SWR) => 19 sec.
vi.advanceTimersByTime(19001);
await expect(getItemFromCache(cache, KEY)).resolves.toEqual(undefined);

// Cache is expired, call the action function again:
await expect(withCache(KEY, strategy, actionFn)).resolves.toEqual(VALUE);
expect(waitUntil).toHaveBeenCalledTimes(3);
expect(actionFn).toHaveBeenCalledTimes(3);
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {type CachingStrategy} from './strategies';
import {type CrossRuntimeRequest, getDebugHeaders} from '../utils/request';
import {getCallerStackLine} from '../utils/callsites';
import {
type CacheKey,
CacheActionFunctionParam,
CacheKey,
runWithCache,
type CacheActionFunctionParam,
} from './cache/fetch';
import type {CachingStrategy} from './cache/strategies';
import {type CrossRuntimeRequest, getDebugHeaders} from './utils/request';
import {getCallerStackLine} from './utils/callsites';
} from './run-with-cache';

type CreateWithCacheOptions = {
/** An instance that implements the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache) */
Expand All @@ -20,7 +20,6 @@ type CreateWithCacheOptions = {
* Creates a utility function that executes an asynchronous operation
* like `fetch` and caches the result according to the strategy provided.
* Use this to call any third-party APIs from loaders or actions.
* By default, it uses the `CacheShort` strategy.
*
*/
export function createWithCache<T = unknown>({
Expand Down
Loading
Loading