@@ -5,7 +5,7 @@ import { Buffer } from 'node:buffer'
5
5
6
6
import { getDeployStore , Store } from '@netlify/blobs'
7
7
import { purgeCache } from '@netlify/functions'
8
- import { trace , type Span } from '@opentelemetry/api'
8
+ import { trace , type Span , SpanStatusCode } from '@opentelemetry/api'
9
9
import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js'
10
10
import type {
11
11
CacheHandler ,
@@ -18,18 +18,22 @@ import { getRequestContext } from './request-context.cjs'
18
18
19
19
type TagManifest = { revalidatedAt : number }
20
20
21
+ type TagManifestBlobCache = Record < string , Promise < TagManifest > >
22
+
21
23
const fetchBeforeNextPatchedIt = globalThis . fetch
22
24
23
25
export class NetlifyCacheHandler implements CacheHandler {
24
26
options : CacheHandlerContext
25
27
revalidatedTags : string [ ]
26
28
blobStore : Store
27
29
tracer = trace . getTracer ( 'Netlify Cache Handler' )
30
+ tagManifestsFetchedFromBlobStoreInCurrentRequest : TagManifestBlobCache
28
31
29
32
constructor ( options : CacheHandlerContext ) {
30
33
this . options = options
31
34
this . revalidatedTags = options . revalidatedTags
32
35
this . blobStore = getDeployStore ( { fetch : fetchBeforeNextPatchedIt , consistency : 'strong' } )
36
+ this . tagManifestsFetchedFromBlobStoreInCurrentRequest = { }
33
37
}
34
38
35
39
private async encodeBlobKey ( key : string ) {
@@ -87,64 +91,71 @@ export class NetlifyCacheHandler implements CacheHandler {
87
91
88
92
async get ( ...args : Parameters < CacheHandler [ 'get' ] > ) : ReturnType < CacheHandler [ 'get' ] > {
89
93
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
+ }
103
146
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 {
110
157
span . end ( )
111
- return null
112
158
}
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
148
159
} )
149
160
}
150
161
@@ -190,12 +201,12 @@ export class NetlifyCacheHandler implements CacheHandler {
190
201
} )
191
202
}
192
203
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
+ }
196
207
197
208
/**
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
199
210
*/
200
211
private async checkCacheEntryStaleByTags (
201
212
cacheEntry : CacheHandlerValue ,
@@ -212,32 +223,76 @@ export class NetlifyCacheHandler implements CacheHandler {
212
223
return false
213
224
}
214
225
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 ) {
231
228
// 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
+ }
234
233
}
234
+ }
235
235
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
+ }
239
288
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
+ } )
241
296
}
242
297
}
243
298
0 commit comments