Skip to content

Commit 39ab537

Browse files
authoredFeb 16, 2024
perf: check any revalidatedTags passed from next first before checking blobs, memoize tag manifest blob gets for duration of request (#229)
* test: add assertions ensuring we only get tag manifest at most once per tag per request * perf: check any revalidatedTags passed from next first before checking blobs, memoize tag manifest blob gets for duration of request
1 parent ede6277 commit 39ab537

File tree

4 files changed

+265
-97
lines changed

4 files changed

+265
-97
lines changed
 

‎src/run/handlers/cache.cts

+137-82
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Buffer } from 'node:buffer'
55

66
import { getDeployStore, Store } from '@netlify/blobs'
77
import { purgeCache } from '@netlify/functions'
8-
import { trace, type Span } from '@opentelemetry/api'
8+
import { trace, type Span, SpanStatusCode } from '@opentelemetry/api'
99
import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js'
1010
import type {
1111
CacheHandler,
@@ -18,18 +18,22 @@ import { getRequestContext } from './request-context.cjs'
1818

1919
type TagManifest = { revalidatedAt: number }
2020

21+
type TagManifestBlobCache = Record<string, Promise<TagManifest>>
22+
2123
const fetchBeforeNextPatchedIt = globalThis.fetch
2224

2325
export class NetlifyCacheHandler implements CacheHandler {
2426
options: CacheHandlerContext
2527
revalidatedTags: string[]
2628
blobStore: Store
2729
tracer = trace.getTracer('Netlify Cache Handler')
30+
tagManifestsFetchedFromBlobStoreInCurrentRequest: TagManifestBlobCache
2831

2932
constructor(options: CacheHandlerContext) {
3033
this.options = options
3134
this.revalidatedTags = options.revalidatedTags
3235
this.blobStore = getDeployStore({ fetch: fetchBeforeNextPatchedIt, consistency: 'strong' })
36+
this.tagManifestsFetchedFromBlobStoreInCurrentRequest = {}
3337
}
3438

3539
private async encodeBlobKey(key: string) {
@@ -87,64 +91,71 @@ export class NetlifyCacheHandler implements CacheHandler {
8791

8892
async get(...args: Parameters<CacheHandler['get']>): ReturnType<CacheHandler['get']> {
8993
return this.tracer.startActiveSpan('get cache key', async (span) => {
90-
const [key, ctx = {}] = args
91-
console.debug(`[NetlifyCacheHandler.get]: ${key}`)
92-
93-
const blobKey = await this.encodeBlobKey(key)
94-
span.setAttributes({ key, blobKey })
95-
const blob = (await this.blobStore.get(blobKey, {
96-
type: 'json',
97-
})) as CacheHandlerValue | null
98-
99-
// if blob is null then we don't have a cache entry
100-
if (!blob) {
101-
span.addEvent('Cache miss', { key, blobKey })
102-
span.end()
94+
try {
95+
const [key, ctx = {}] = args
96+
console.debug(`[NetlifyCacheHandler.get]: ${key}`)
97+
98+
const blobKey = await this.encodeBlobKey(key)
99+
span.setAttributes({ key, blobKey })
100+
const blob = (await this.blobStore.get(blobKey, {
101+
type: 'json',
102+
})) as CacheHandlerValue | null
103+
104+
// if blob is null then we don't have a cache entry
105+
if (!blob) {
106+
span.addEvent('Cache miss', { key, blobKey })
107+
return null
108+
}
109+
110+
const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.tags, ctx.softTags)
111+
112+
if (staleByTags) {
113+
span.addEvent('Stale', { staleByTags })
114+
return null
115+
}
116+
117+
this.captureResponseCacheLastModified(blob, key, span)
118+
119+
switch (blob.value?.kind) {
120+
case 'FETCH':
121+
span.addEvent('FETCH', { lastModified: blob.lastModified, revalidate: ctx.revalidate })
122+
return {
123+
lastModified: blob.lastModified,
124+
value: blob.value,
125+
}
126+
127+
case 'ROUTE':
128+
span.addEvent('ROUTE', { lastModified: blob.lastModified, status: blob.value.status })
129+
return {
130+
lastModified: blob.lastModified,
131+
value: {
132+
...blob.value,
133+
body: Buffer.from(blob.value.body as unknown as string, 'base64'),
134+
},
135+
}
136+
case 'PAGE':
137+
span.addEvent('PAGE', { lastModified: blob.lastModified })
138+
return {
139+
lastModified: blob.lastModified,
140+
value: blob.value,
141+
}
142+
default:
143+
span.recordException(new Error(`Unknown cache entry kind: ${blob.value?.kind}`))
144+
// TODO: system level logging not implemented
145+
}
103146
return null
104-
}
105-
106-
const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.tags, ctx.softTags)
107-
108-
if (staleByTags) {
109-
span.addEvent('Stale', { staleByTags })
147+
} catch (error) {
148+
if (error instanceof Error) {
149+
span.recordException(error)
150+
}
151+
span.setStatus({
152+
code: SpanStatusCode.ERROR,
153+
message: error instanceof Error ? error.message : String(error),
154+
})
155+
throw error
156+
} finally {
110157
span.end()
111-
return null
112158
}
113-
114-
this.captureResponseCacheLastModified(blob, key, span)
115-
116-
switch (blob.value?.kind) {
117-
case 'FETCH':
118-
span.addEvent('FETCH', { lastModified: blob.lastModified, revalidate: ctx.revalidate })
119-
span.end()
120-
return {
121-
lastModified: blob.lastModified,
122-
value: blob.value,
123-
}
124-
125-
case 'ROUTE':
126-
span.addEvent('ROUTE', { lastModified: blob.lastModified, status: blob.value.status })
127-
span.end()
128-
return {
129-
lastModified: blob.lastModified,
130-
value: {
131-
...blob.value,
132-
body: Buffer.from(blob.value.body as unknown as string, 'base64'),
133-
},
134-
}
135-
case 'PAGE':
136-
span.addEvent('PAGE', { lastModified: blob.lastModified })
137-
span.end()
138-
return {
139-
lastModified: blob.lastModified,
140-
value: blob.value,
141-
}
142-
default:
143-
span.recordException(new Error(`Unknown cache entry kind: ${blob.value?.kind}`))
144-
// TODO: system level logging not implemented
145-
}
146-
span.end()
147-
return null
148159
})
149160
}
150161

@@ -190,12 +201,12 @@ export class NetlifyCacheHandler implements CacheHandler {
190201
})
191202
}
192203

193-
/* Not used, but required by the interface */
194-
// eslint-disable-next-line @typescript-eslint/no-empty-function
195-
resetRequestCache() {}
204+
resetRequestCache() {
205+
this.tagManifestsFetchedFromBlobStoreInCurrentRequest = {}
206+
}
196207

197208
/**
198-
* Checks if a page is stale through on demand revalidated tags
209+
* Checks if a cache entry is stale through on demand revalidated tags
199210
*/
200211
private async checkCacheEntryStaleByTags(
201212
cacheEntry: CacheHandlerValue,
@@ -212,32 +223,76 @@ export class NetlifyCacheHandler implements CacheHandler {
212223
return false
213224
}
214225

215-
const allManifests = await Promise.all(
216-
cacheTags.map(async (tag) => {
217-
const res = await this.blobStore
218-
.get(await this.encodeBlobKey(tag), { type: 'json' })
219-
.then((value: TagManifest) => ({ [tag]: value }))
220-
.catch(console.error)
221-
return res || { [tag]: null }
222-
}),
223-
)
224-
225-
const tagsManifest = Object.assign({}, ...allManifests) as Record<
226-
string,
227-
null | { revalidatedAt: number }
228-
>
229-
230-
const isStale = cacheTags.some((tag) => {
226+
// 1. Check if revalidateTags array passed from Next.js contains any of cacheEntry tags
227+
if (this.revalidatedTags && this.revalidatedTags.length !== 0) {
231228
// TODO: test for this case
232-
if (this.revalidatedTags?.includes(tag)) {
233-
return true
229+
for (const tag of this.revalidatedTags) {
230+
if (cacheTags.includes(tag)) {
231+
return true
232+
}
234233
}
234+
}
235235

236-
const { revalidatedAt } = tagsManifest[tag] || {}
237-
return revalidatedAt && revalidatedAt >= (cacheEntry.lastModified || Date.now())
238-
})
236+
// 2. If any in-memory tags don't indicate that any of tags was invalidated
237+
// we will check blob store, but memoize results for duration of current request
238+
// so that we only check blob store once per tag within a single request
239+
// full-route cache and fetch caches share a lot of tags so this might save
240+
// some roundtrips to the blob store.
241+
// Additionally, we will resolve the promise as soon as we find first
242+
// stale tag, so that we don't wait for all of them to resolve (but keep all
243+
// running in case future `CacheHandler.get` calls would be able to use results).
244+
// "Worst case" scenario is none of tag was invalidated in which case we need to wait
245+
// for all blob store checks to finish before we can be certain that no tag is stale.
246+
return new Promise<boolean>((resolve, reject) => {
247+
const tagManifestPromises: Promise<boolean>[] = []
248+
249+
for (const tag of cacheTags) {
250+
let tagManifestPromise: Promise<TagManifest> =
251+
this.tagManifestsFetchedFromBlobStoreInCurrentRequest[tag]
252+
253+
if (!tagManifestPromise) {
254+
tagManifestPromise = this.encodeBlobKey(tag).then((blobKey) => {
255+
return this.tracer.startActiveSpan(`get tag manifest`, async (span) => {
256+
span.setAttributes({ tag, blobKey })
257+
try {
258+
return await this.blobStore.get(blobKey, { type: 'json' })
259+
} catch (error) {
260+
if (error instanceof Error) {
261+
span.recordException(error)
262+
}
263+
span.setStatus({
264+
code: SpanStatusCode.ERROR,
265+
message: error instanceof Error ? error.message : String(error),
266+
})
267+
throw error
268+
} finally {
269+
span.end()
270+
}
271+
})
272+
})
273+
274+
this.tagManifestsFetchedFromBlobStoreInCurrentRequest[tag] = tagManifestPromise
275+
}
276+
277+
tagManifestPromises.push(
278+
tagManifestPromise.then((tagManifest) => {
279+
const isStale = tagManifest?.revalidatedAt >= (cacheEntry.lastModified || Date.now())
280+
if (isStale) {
281+
resolve(true)
282+
return true
283+
}
284+
return false
285+
}),
286+
)
287+
}
239288

240-
return isStale
289+
// make sure we resolve promise after all blobs are checked (if we didn't resolve as stale yet)
290+
Promise.all(tagManifestPromises)
291+
.then((tagManifestAreStale) => {
292+
resolve(tagManifestAreStale.some((tagIsStale) => tagIsStale))
293+
})
294+
.catch(reject)
295+
})
241296
}
242297
}
243298

‎tests/integration/revalidate-path.test.ts

+67-13
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,35 @@ import {
88
runPlugin,
99
type FixtureTestContext,
1010
} from '../utils/fixture.js'
11-
import { encodeBlobKey, generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js'
11+
import {
12+
encodeBlobKey,
13+
generateRandomObjectID,
14+
getBlobServerGets,
15+
startMockBlobStore,
16+
} from '../utils/helpers.js'
17+
18+
function isTagManifest(key: string) {
19+
return key.startsWith('_N_T_')
20+
}
21+
22+
expect.extend({
23+
toBeDistinct(received: string[]) {
24+
const { isNot } = this
25+
const pass = new Set(received).size === received.length
26+
return {
27+
pass,
28+
message: () => `${received} is${isNot ? ' not' : ''} array with distinct values`,
29+
}
30+
},
31+
})
32+
33+
interface CustomMatchers<R = unknown> {
34+
toBeDistinct(): R
35+
}
36+
37+
declare module 'vitest' {
38+
interface Assertion<T = any> extends CustomMatchers<T> {}
39+
}
1240

1341
// Disable the verbose logging of the lambda-local runtime
1442
getLogger().level = 'alert'
@@ -33,29 +61,41 @@ test<FixtureTestContext>('should revalidate a route by path', async (ctx) => {
3361
expect(await ctx.blobStore.get(encodeBlobKey('/static-fetch/1'))).not.toBeNull()
3462
expect(await ctx.blobStore.get(encodeBlobKey('_N_T_/static-fetch/[id]/page'))).toBeNull()
3563

64+
ctx.blobServerGetSpy.mockClear()
65+
3666
// test the function call
37-
const [post1, post1Route2] = await Promise.all([
38-
invokeFunction(ctx, { url: '/static-fetch/1' }),
39-
invokeFunction(ctx, { url: '/static-fetch/2' }),
40-
])
67+
const post1 = await invokeFunction(ctx, { url: '/static-fetch/1' })
4168
const post1Date = load(post1.body)('[data-testid="date-now"]').text()
4269
expect(post1.statusCode).toBe(200)
43-
expect(post1Route2.statusCode).toBe(200)
4470
expect(load(post1.body)('h1').text()).toBe('Hello, Statically fetched show 1')
45-
4671
expect(post1.headers, 'a cache hit on the first invocation of a prerendered page').toEqual(
4772
expect.objectContaining({
4873
'cache-status': expect.stringMatching(/"Next.js"; hit/),
4974
'netlify-cdn-cache-control': 's-maxage=31536000, stale-while-revalidate=31536000',
5075
}),
5176
)
77+
78+
expect(
79+
getBlobServerGets(ctx, isTagManifest),
80+
`expected tag manifests to be retrieved at most once per tag`,
81+
).toBeDistinct()
82+
ctx.blobServerGetSpy.mockClear()
83+
84+
const post1Route2 = await invokeFunction(ctx, { url: '/static-fetch/2' })
85+
expect(post1Route2.statusCode).toBe(200)
5286
expect(post1Route2.headers, 'a cache hit on the first invocation of a prerendered page').toEqual(
5387
expect.objectContaining({
5488
'cache-status': expect.stringMatching(/"Next.js"; hit/),
5589
'netlify-cdn-cache-control': 's-maxage=31536000, stale-while-revalidate=31536000',
5690
}),
5791
)
5892

93+
expect(
94+
getBlobServerGets(ctx, isTagManifest),
95+
`expected tag manifests to be retrieved at most once per tag`,
96+
).toBeDistinct()
97+
ctx.blobServerGetSpy.mockClear()
98+
5999
const revalidate = await invokeFunction(ctx, { url: '/api/on-demand-revalidate/path' })
60100
expect(revalidate.statusCode).toBe(200)
61101
expect(JSON.parse(revalidate.body)).toEqual({ revalidated: true, now: expect.any(String) })
@@ -65,25 +105,39 @@ test<FixtureTestContext>('should revalidate a route by path', async (ctx) => {
65105

66106
expect(await ctx.blobStore.get(encodeBlobKey('_N_T_/static-fetch/[id]/page'))).not.toBeNull()
67107

68-
const [post2, post2Route2] = await Promise.all([
69-
invokeFunction(ctx, { url: '/static-fetch/1' }),
70-
invokeFunction(ctx, { url: '/static-fetch/2' }),
71-
])
108+
ctx.blobServerGetSpy.mockClear()
109+
110+
const post2 = await invokeFunction(ctx, { url: '/static-fetch/1' })
72111
const post2Date = load(post2.body)('[data-testid="date-now"]').text()
73112
expect(post2.statusCode).toBe(200)
74-
expect(post2Route2.statusCode).toBe(200)
75113
expect(load(post2.body)('h1').text()).toBe('Hello, Statically fetched show 1')
76114
expect(post2.headers, 'a cache miss on the on demand revalidated path /1').toEqual(
77115
expect.objectContaining({
78116
'cache-status': '"Next.js"; fwd=miss',
79117
'netlify-cdn-cache-control': 's-maxage=31536000, stale-while-revalidate=31536000',
80118
}),
81119
)
120+
expect(post2Date).not.toBe(post1Date)
121+
122+
expect(
123+
getBlobServerGets(ctx, isTagManifest),
124+
`expected tag manifests to be retrieved at most once per tag`,
125+
).toBeDistinct()
126+
ctx.blobServerGetSpy.mockClear()
127+
128+
const post2Route2 = await invokeFunction(ctx, { url: '/static-fetch/2' })
129+
130+
expect(post2Route2.statusCode).toBe(200)
82131
expect(post2Route2.headers, 'a cache miss on the on demand revalidated path /2').toEqual(
83132
expect.objectContaining({
84133
'cache-status': '"Next.js"; fwd=miss',
85134
'netlify-cdn-cache-control': 's-maxage=31536000, stale-while-revalidate=31536000',
86135
}),
87136
)
88-
expect(post2Date).not.toBe(post1Date)
137+
138+
expect(
139+
getBlobServerGets(ctx, isTagManifest),
140+
`expected tag manifests to be retrieved at most once per tag`,
141+
).toBeDistinct()
142+
ctx.blobServerGetSpy.mockClear()
89143
})

‎tests/integration/revalidate-tags.test.ts

+59-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,35 @@ import {
88
runPlugin,
99
type FixtureTestContext,
1010
} from '../utils/fixture.js'
11-
import { encodeBlobKey, generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js'
11+
import {
12+
encodeBlobKey,
13+
generateRandomObjectID,
14+
getBlobServerGets,
15+
startMockBlobStore,
16+
} from '../utils/helpers.js'
17+
18+
function isTagManifest(key: string) {
19+
return key.startsWith('_N_T_')
20+
}
21+
22+
expect.extend({
23+
toBeDistinct(received: string[]) {
24+
const { isNot } = this
25+
const pass = new Set(received).size === received.length
26+
return {
27+
pass,
28+
message: () => `${received} is${isNot ? ' not' : ''} array with distinct values`,
29+
}
30+
},
31+
})
32+
33+
interface CustomMatchers<R = unknown> {
34+
toBeDistinct(): R
35+
}
36+
37+
declare module 'vitest' {
38+
interface Assertion<T = any> extends CustomMatchers<T> {}
39+
}
1240

1341
// Disable the verbose logging of the lambda-local runtime
1442
getLogger().level = 'alert'
@@ -32,6 +60,8 @@ test<FixtureTestContext>('should revalidate a route by tag', async (ctx) => {
3260

3361
expect(await ctx.blobStore.get(encodeBlobKey('/static-fetch-1'))).not.toBeNull()
3462

63+
ctx.blobServerGetSpy.mockClear()
64+
3565
// test the function call
3666
const post1 = await invokeFunction(ctx, { url: '/static-fetch-1' })
3767
const post1Date = load(post1.body)('[data-testid="date-now"]').text()
@@ -45,13 +75,21 @@ test<FixtureTestContext>('should revalidate a route by tag', async (ctx) => {
4575
}),
4676
)
4777

78+
expect(
79+
getBlobServerGets(ctx, isTagManifest),
80+
`expected tag manifests to be retrieved at most once per tag`,
81+
).toBeDistinct()
82+
ctx.blobServerGetSpy.mockClear()
83+
4884
const revalidate = await invokeFunction(ctx, { url: '/api/on-demand-revalidate/tag' })
4985
expect(revalidate.statusCode).toBe(200)
5086
expect(JSON.parse(revalidate.body)).toEqual({ revalidated: true, now: expect.any(String) })
5187

5288
// it does not wait for the revalidation
5389
await new Promise<void>((resolve) => setTimeout(resolve, 100))
5490

91+
ctx.blobServerGetSpy.mockClear()
92+
5593
const post2 = await invokeFunction(ctx, { url: '/static-fetch-1' })
5694
const post2Date = load(post2.body)('[data-testid="date-now"]').text()
5795
const post2Quote = load(post2.body)('[data-testid="quote"]').text()
@@ -66,6 +104,12 @@ test<FixtureTestContext>('should revalidate a route by tag', async (ctx) => {
66104
expect(post2Date).not.toBe(post1Date)
67105
expect(post2Quote).not.toBe(post1Quote)
68106

107+
expect(
108+
getBlobServerGets(ctx, isTagManifest),
109+
`expected tag manifests to be retrieved at most once per tag`,
110+
).toBeDistinct()
111+
ctx.blobServerGetSpy.mockClear()
112+
69113
// it does not wait for the cache.set so we have to manually wait here until the blob storage got populated
70114
await new Promise<void>((resolve) => setTimeout(resolve, 100))
71115

@@ -83,13 +127,21 @@ test<FixtureTestContext>('should revalidate a route by tag', async (ctx) => {
83127
expect(post3Date).toBe(post2Date)
84128
expect(post3Quote).toBe(post2Quote)
85129

130+
expect(
131+
getBlobServerGets(ctx, isTagManifest),
132+
`expected tag manifests to be retrieved at most once per tag`,
133+
).toBeDistinct()
134+
ctx.blobServerGetSpy.mockClear()
135+
86136
const revalidate2 = await invokeFunction(ctx, { url: '/api/on-demand-revalidate/tag' })
87137
expect(revalidate2.statusCode).toBe(200)
88138
expect(JSON.parse(revalidate2.body)).toEqual({ revalidated: true, now: expect.any(String) })
89139

90140
// it does not wait for the revalidation
91141
await new Promise<void>((resolve) => setTimeout(resolve, 100))
92142

143+
ctx.blobServerGetSpy.mockClear()
144+
93145
const post4 = await invokeFunction(ctx, { url: '/static-fetch-1' })
94146
const post4Date = load(post4.body)('[data-testid="date-now"]').text()
95147
const post4Quote = load(post4.body)('[data-testid="quote"]').text()
@@ -103,4 +155,10 @@ test<FixtureTestContext>('should revalidate a route by tag', async (ctx) => {
103155
)
104156
expect(post4Date).not.toBe(post3Date)
105157
expect(post4Quote).not.toBe(post3Quote)
158+
159+
expect(
160+
getBlobServerGets(ctx, isTagManifest),
161+
`expected tag manifests to be retrieved at most once per tag`,
162+
).toBeDistinct()
163+
ctx.blobServerGetSpy.mockClear()
106164
})

‎tests/utils/helpers.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export const getBlobEntries = async (ctx: FixtureTestContext) => {
9393
return blobs
9494
}
9595

96-
function getBlobServerGets(ctx: FixtureTestContext) {
96+
export function getBlobServerGets(ctx: FixtureTestContext, predicate?: (key: string) => boolean) {
9797
const isString = (arg: unknown): arg is string => typeof arg === 'string'
9898
return ctx.blobServerGetSpy.mock.calls
9999
.map(([request]) => {
@@ -110,6 +110,7 @@ function getBlobServerGets(ctx: FixtureTestContext) {
110110
return undefined
111111
})
112112
.filter(isString)
113+
.filter((key) => !predicate || predicate(key))
113114
}
114115

115116
export function countOfBlobServerGetsForKey(ctx: FixtureTestContext, key: string) {

0 commit comments

Comments
 (0)
Please sign in to comment.