-
Notifications
You must be signed in to change notification settings - Fork 281
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
Refactor and test cache #2220
Changes from all commits
2c11f1f
6813484
9547126
0cfe51d
906600d
f880fb2
8c0e8a4
2d89fab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
}); | ||
}); |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 useISOString
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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