Skip to content

Commit d24f15e

Browse files
piehserhalp
andauthored
feat: support revalidateTag with SWR behavior (#3173)
Co-authored-by: Philippe Serhal <[email protected]>
1 parent 4672364 commit d24f15e

File tree

21 files changed

+863
-60
lines changed

21 files changed

+863
-60
lines changed

src/run/handlers/cache.cts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ import {
2525
} from '../storage/storage.cjs'
2626

2727
import { getLogger, getRequestContext } from './request-context.cjs'
28-
import { isAnyTagStale, markTagsAsStaleAndPurgeEdgeCache, purgeEdgeCache } from './tags-handler.cjs'
28+
import {
29+
isAnyTagStaleOrExpired,
30+
markTagsAsStaleAndPurgeEdgeCache,
31+
purgeEdgeCache,
32+
type RevalidateTagDurations,
33+
type TagStaleOrExpiredStatus,
34+
} from './tags-handler.cjs'
2935
import { getTracer, recordWarning } from './tracer.cjs'
3036

3137
let memoizedPrerenderManifest: PrerenderManifest
@@ -290,19 +296,26 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
290296
return null
291297
}
292298

293-
const staleByTags = await this.checkCacheEntryStaleByTags(
299+
const { stale: staleByTags, expired: expiredByTags } = await this.checkCacheEntryStaleByTags(
294300
blob,
295301
context.tags,
296302
context.softTags,
297303
)
298304

299-
if (staleByTags) {
300-
span.addEvent('Stale', { staleByTags, key, ttl })
305+
if (expiredByTags) {
306+
span.addEvent('Expired', { expiredByTags, key, ttl })
301307
return null
302308
}
303309

304310
this.captureResponseCacheLastModified(blob, key, span)
305311

312+
if (staleByTags) {
313+
span.addEvent('Stale', { staleByTags, key, ttl })
314+
// note that we modify this after we capture last modified to ensure that Age is correct
315+
// but we still let Next.js know that entry is stale
316+
blob.lastModified = -1 // indicate that the entry is stale
317+
}
318+
306319
// Next sets a kind/kindHint and fetchUrl for data requests, however fetchUrl was found to be most reliable across versions
307320
const isDataRequest = Boolean(context.fetchUrl)
308321
if (!isDataRequest) {
@@ -477,8 +490,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
477490
})
478491
}
479492

480-
async revalidateTag(tagOrTags: string | string[]) {
481-
return markTagsAsStaleAndPurgeEdgeCache(tagOrTags)
493+
async revalidateTag(tagOrTags: string | string[], durations?: RevalidateTagDurations) {
494+
return markTagsAsStaleAndPurgeEdgeCache(tagOrTags, durations)
482495
}
483496

484497
resetRequestCache() {
@@ -493,7 +506,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
493506
cacheEntry: NetlifyCacheHandlerValue,
494507
tags: string[] = [],
495508
softTags: string[] = [],
496-
) {
509+
): TagStaleOrExpiredStatus | Promise<TagStaleOrExpiredStatus> {
497510
let cacheTags: string[] = []
498511

499512
if (cacheEntry.value?.kind === 'FETCH') {
@@ -508,22 +521,28 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
508521
cacheTags =
509522
(cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER] as string)?.split(/,|%2c/gi) || []
510523
} else {
511-
return false
524+
return {
525+
stale: false,
526+
expired: false,
527+
}
512528
}
513529

514530
// 1. Check if revalidateTags array passed from Next.js contains any of cacheEntry tags
515531
if (this.revalidatedTags && this.revalidatedTags.length !== 0) {
516532
// TODO: test for this case
517533
for (const tag of this.revalidatedTags) {
518534
if (cacheTags.includes(tag)) {
519-
return true
535+
return {
536+
stale: true,
537+
expired: true,
538+
}
520539
}
521540
}
522541
}
523542

524543
// 2. If any in-memory tags don't indicate that any of tags was invalidated
525544
// we will check blob store.
526-
return isAnyTagStale(cacheTags, cacheEntry.lastModified)
545+
return isAnyTagStaleOrExpired(cacheTags, cacheEntry.lastModified)
527546
}
528547
}
529548

src/run/handlers/tags-handler.cts

Lines changed: 102 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,47 +11,55 @@ import { getLogger, getRequestContext } from './request-context.cjs'
1111

1212
const purgeCacheUserAgent = `${nextRuntimePkgName}@${nextRuntimePkgVersion}`
1313

14-
/**
15-
* Get timestamp of the last revalidation for a tag
16-
*/
17-
async function getTagRevalidatedAt(
14+
async function getTagManifest(
1815
tag: string,
1916
cacheStore: MemoizedKeyValueStoreBackedByRegionalBlobStore,
20-
): Promise<number | null> {
17+
): Promise<TagManifest | null> {
2118
const tagManifest = await cacheStore.get<TagManifest>(tag, 'tagManifest.get')
2219
if (!tagManifest) {
2320
return null
2421
}
25-
return tagManifest.revalidatedAt
22+
return tagManifest
2623
}
2724

2825
/**
2926
* Get the most recent revalidation timestamp for a list of tags
3027
*/
31-
export async function getMostRecentTagRevalidationTimestamp(tags: string[]) {
28+
export async function getMostRecentTagExpirationTimestamp(tags: string[]) {
3229
if (tags.length === 0) {
3330
return 0
3431
}
3532

3633
const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })
3734

38-
const timestampsOrNulls = await Promise.all(
39-
tags.map((tag) => getTagRevalidatedAt(tag, cacheStore)),
40-
)
35+
const manifestsOrNulls = await Promise.all(tags.map((tag) => getTagManifest(tag, cacheStore)))
4136

42-
const timestamps = timestampsOrNulls.filter((timestamp) => timestamp !== null)
43-
if (timestamps.length === 0) {
37+
const expirationTimestamps = manifestsOrNulls
38+
.filter((manifest) => manifest !== null)
39+
.map((manifest) => manifest.expireAt)
40+
if (expirationTimestamps.length === 0) {
4441
return 0
4542
}
46-
return Math.max(...timestamps)
43+
return Math.max(...expirationTimestamps)
4744
}
4845

46+
export type TagStaleOrExpiredStatus =
47+
// FRESH
48+
| { stale: false; expired: false }
49+
// STALE
50+
| { stale: true; expired: false; expireAt: number }
51+
// EXPIRED (should be treated similarly to MISS)
52+
| { stale: true; expired: true }
53+
4954
/**
50-
* Check if any of the tags were invalidated since the given timestamp
55+
* Check if any of the tags expired since the given timestamp
5156
*/
52-
export function isAnyTagStale(tags: string[], timestamp: number): Promise<boolean> {
57+
export function isAnyTagStaleOrExpired(
58+
tags: string[],
59+
timestamp: number,
60+
): Promise<TagStaleOrExpiredStatus> {
5361
if (tags.length === 0 || !timestamp) {
54-
return Promise.resolve(false)
62+
return Promise.resolve({ stale: false, expired: false })
5563
}
5664

5765
const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })
@@ -60,37 +68,74 @@ export function isAnyTagStale(tags: string[], timestamp: number): Promise<boolea
6068
// but we will only do actual blob read once withing a single request due to cacheStore
6169
// memoization.
6270
// Additionally, we will resolve the promise as soon as we find first
63-
// stale tag, so that we don't wait for all of them to resolve (but keep all
71+
// expired tag, so that we don't wait for all of them to resolve (but keep all
6472
// running in case future `CacheHandler.get` calls would be able to use results).
65-
// "Worst case" scenario is none of tag was invalidated in which case we need to wait
66-
// for all blob store checks to finish before we can be certain that no tag is stale.
67-
return new Promise<boolean>((resolve, reject) => {
68-
const tagManifestPromises: Promise<boolean>[] = []
73+
// "Worst case" scenario is none of tag was expired in which case we need to wait
74+
// for all blob store checks to finish before we can be certain that no tag is expired.
75+
return new Promise<TagStaleOrExpiredStatus>((resolve, reject) => {
76+
const tagManifestPromises: Promise<TagStaleOrExpiredStatus>[] = []
6977

7078
for (const tag of tags) {
71-
const lastRevalidationTimestampPromise = getTagRevalidatedAt(tag, cacheStore)
79+
const tagManifestPromise = getTagManifest(tag, cacheStore)
7280

7381
tagManifestPromises.push(
74-
lastRevalidationTimestampPromise.then((lastRevalidationTimestamp) => {
75-
if (!lastRevalidationTimestamp) {
82+
tagManifestPromise.then((tagManifest) => {
83+
if (!tagManifest) {
7684
// tag was never revalidated
77-
return false
85+
return { stale: false, expired: false }
7886
}
79-
const isStale = lastRevalidationTimestamp >= timestamp
80-
if (isStale) {
81-
// resolve outer promise immediately if any of the tags is stale
82-
resolve(true)
83-
return true
87+
const stale = tagManifest.staleAt >= timestamp
88+
const expired = tagManifest.expireAt >= timestamp && tagManifest.expireAt <= Date.now()
89+
90+
if (expired && stale) {
91+
const expiredResult: TagStaleOrExpiredStatus = {
92+
stale,
93+
expired,
94+
}
95+
// resolve outer promise immediately if any of the tags is expired
96+
resolve(expiredResult)
97+
return expiredResult
8498
}
85-
return false
99+
100+
if (stale) {
101+
const staleResult: TagStaleOrExpiredStatus = {
102+
stale,
103+
expired,
104+
expireAt: tagManifest.expireAt,
105+
}
106+
return staleResult
107+
}
108+
return { stale: false, expired: false }
86109
}),
87110
)
88111
}
89112

90-
// make sure we resolve promise after all blobs are checked (if we didn't resolve as stale yet)
113+
// make sure we resolve promise after all blobs are checked (if we didn't resolve as expired yet)
91114
Promise.all(tagManifestPromises)
92-
.then((tagManifestAreStale) => {
93-
resolve(tagManifestAreStale.some((tagIsStale) => tagIsStale))
115+
.then((tagManifestsAreStaleOrExpired) => {
116+
let result: TagStaleOrExpiredStatus = { stale: false, expired: false }
117+
118+
for (const tagResult of tagManifestsAreStaleOrExpired) {
119+
if (tagResult.expired) {
120+
// if any of the tags is expired, the whole thing is expired
121+
result = tagResult
122+
break
123+
}
124+
125+
if (tagResult.stale) {
126+
result = {
127+
stale: true,
128+
expired: false,
129+
expireAt:
130+
// make sure to use expireAt that is lowest of all tags
131+
result.stale && !result.expired && typeof result.expireAt === 'number'
132+
? Math.min(result.expireAt, tagResult.expireAt)
133+
: tagResult.expireAt,
134+
}
135+
}
136+
}
137+
138+
resolve(result)
94139
})
95140
.catch(reject)
96141
})
@@ -122,15 +167,30 @@ export function purgeEdgeCache(tagOrTags: string | string[]): Promise<void> {
122167
})
123168
}
124169

125-
async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise<void> {
126-
getLogger().withFields({ tags }).debug('doRevalidateTagAndPurgeEdgeCache')
170+
// shape of this type comes from Next.js https://github.com/vercel/next.js/blob/fffa2831b61fa74852736eeaad2f17fbdd553bce/packages/next/src/server/lib/incremental-cache/index.ts#L78
171+
// and we use it internally
172+
export type RevalidateTagDurations = {
173+
/**
174+
* Number of seconds after which tagged cache entries should no longer serve stale content.
175+
*/
176+
expire?: number
177+
}
178+
179+
async function doRevalidateTagAndPurgeEdgeCache(
180+
tags: string[],
181+
durations?: RevalidateTagDurations,
182+
): Promise<void> {
183+
getLogger().withFields({ tags, durations }).debug('doRevalidateTagAndPurgeEdgeCache')
127184

128185
if (tags.length === 0) {
129186
return
130187
}
131188

189+
const now = Date.now()
190+
132191
const tagManifest: TagManifest = {
133-
revalidatedAt: Date.now(),
192+
staleAt: now,
193+
expireAt: now + (durations?.expire ? durations.expire * 1000 : 0),
134194
}
135195

136196
const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })
@@ -148,10 +208,13 @@ async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise<void> {
148208
await purgeEdgeCache(tags)
149209
}
150210

151-
export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) {
211+
export function markTagsAsStaleAndPurgeEdgeCache(
212+
tagOrTags: string | string[],
213+
durations?: RevalidateTagDurations,
214+
) {
152215
const tags = getCacheTagsFromTagOrTags(tagOrTags)
153216

154-
const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags)
217+
const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags, durations)
155218

156219
const requestContext = getRequestContext()
157220
if (requestContext) {

src/run/handlers/use-cache-handler.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import type {
1010

1111
import { getLogger } from './request-context.cjs'
1212
import {
13-
getMostRecentTagRevalidationTimestamp,
14-
isAnyTagStale,
13+
getMostRecentTagExpirationTimestamp,
14+
isAnyTagStaleOrExpired,
1515
markTagsAsStaleAndPurgeEdgeCache,
1616
} from './tags-handler.cjs'
1717
import { getTracer } from './tracer.cjs'
@@ -127,7 +127,9 @@ export const NetlifyDefaultUseCacheHandler = {
127127
return undefined
128128
}
129129

130-
if (await isAnyTagStale(entry.tags, entry.timestamp)) {
130+
const { stale } = await isAnyTagStaleOrExpired(entry.tags, entry.timestamp)
131+
132+
if (stale) {
131133
getLogger()
132134
.withFields({ cacheKey, ttl, status: 'STALE BY TAG' })
133135
.debug(`[NetlifyDefaultUseCacheHandler] get result`)
@@ -229,7 +231,7 @@ export const NetlifyDefaultUseCacheHandler = {
229231
tags,
230232
})
231233

232-
const expiration = await getMostRecentTagRevalidationTimestamp(tags)
234+
const expiration = await getMostRecentTagExpirationTimestamp(tags)
233235

234236
getLogger()
235237
.withFields({ tags, expiration })

src/shared/blob-types.cts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { type NetlifyCacheHandlerValue } from './cache-types.cjs'
22

3-
export type TagManifest = { revalidatedAt: number }
3+
export type TagManifest = {
4+
/**
5+
* Timestamp when tag was revalidated. Used to determine if a tag is stale.
6+
*/
7+
staleAt: number
8+
/**
9+
* Timestamp when tagged cache entry should no longer serve stale content.
10+
*/
11+
expireAt: number
12+
}
413

514
export type HtmlBlob = {
615
html: string
@@ -13,9 +22,11 @@ export const isTagManifest = (value: BlobType): value is TagManifest => {
1322
return (
1423
typeof value === 'object' &&
1524
value !== null &&
16-
'revalidatedAt' in value &&
17-
typeof value.revalidatedAt === 'number' &&
18-
Object.keys(value).length === 1
25+
'staleAt' in value &&
26+
typeof value.staleAt === 'number' &&
27+
'expiredAt' in value &&
28+
typeof value.expiredAt === 'number' &&
29+
Object.keys(value).length === 2
1930
)
2031
}
2132

src/shared/blob-types.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { BlobType, HtmlBlob, isHtmlBlob, isTagManifest, TagManifest } from './bl
44

55
describe('isTagManifest', () => {
66
it(`returns true for TagManifest instance`, () => {
7-
const value: TagManifest = { revalidatedAt: 0 }
7+
const value: TagManifest = { staleAt: 0, expiredAt: 0 }
88
expect(isTagManifest(value)).toBe(true)
99
})
1010

@@ -21,7 +21,7 @@ describe('isHtmlBlob', () => {
2121
})
2222

2323
it(`returns false for non-HtmlBlob instance`, () => {
24-
const value: BlobType = { revalidatedAt: 0 }
24+
const value: BlobType = { staleAt: 0, expiredAt: 0 }
2525
expect(isHtmlBlob(value)).toBe(false)
2626
})
2727
})

0 commit comments

Comments
 (0)