Skip to content

Commit f25afa6

Browse files
committed
feat: support revaldiateTag with SWR behavior
1 parent 039c9e1 commit f25afa6

File tree

12 files changed

+290
-59
lines changed

12 files changed

+290
-59
lines changed

src/run/handlers/cache.cts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ 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 TagStaleOrExpired,
33+
} from './tags-handler.cjs'
2934
import { getTracer, recordWarning } from './tracer.cjs'
3035

3136
let memoizedPrerenderManifest: PrerenderManifest
@@ -290,19 +295,26 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
290295
return null
291296
}
292297

293-
const staleByTags = await this.checkCacheEntryStaleByTags(
298+
const { stale: staleByTags, expired: expiredByTags } = await this.checkCacheEntryStaleByTags(
294299
blob,
295300
context.tags,
296301
context.softTags,
297302
)
298303

299-
if (staleByTags) {
300-
span.addEvent('Stale', { staleByTags, key, ttl })
304+
if (expiredByTags) {
305+
span.addEvent('Expired', { expiredByTags, key, ttl })
301306
return null
302307
}
303308

304309
this.captureResponseCacheLastModified(blob, key, span)
305310

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

480-
async revalidateTag(tagOrTags: string | string[]) {
481-
return markTagsAsStaleAndPurgeEdgeCache(tagOrTags)
492+
async revalidateTag(tagOrTags: string | string[], durations?: { expire?: number }) {
493+
return markTagsAsStaleAndPurgeEdgeCache(tagOrTags, durations)
482494
}
483495

484496
resetRequestCache() {
@@ -493,7 +505,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
493505
cacheEntry: NetlifyCacheHandlerValue,
494506
tags: string[] = [],
495507
softTags: string[] = [],
496-
) {
508+
): TagStaleOrExpired | Promise<TagStaleOrExpired> {
497509
let cacheTags: string[] = []
498510

499511
if (cacheEntry.value?.kind === 'FETCH') {
@@ -508,22 +520,28 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
508520
cacheTags =
509521
(cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER] as string)?.split(/,|%2c/gi) || []
510522
} else {
511-
return false
523+
return {
524+
stale: false,
525+
expired: false,
526+
}
512527
}
513528

514529
// 1. Check if revalidateTags array passed from Next.js contains any of cacheEntry tags
515530
if (this.revalidatedTags && this.revalidatedTags.length !== 0) {
516531
// TODO: test for this case
517532
for (const tag of this.revalidatedTags) {
518533
if (cacheTags.includes(tag)) {
519-
return true
534+
return {
535+
stale: true,
536+
expired: true,
537+
}
520538
}
521539
}
522540
}
523541

524542
// 2. If any in-memory tags don't indicate that any of tags was invalidated
525543
// we will check blob store.
526-
return isAnyTagStale(cacheTags, cacheEntry.lastModified)
544+
return isAnyTagStaleOrExpired(cacheTags, cacheEntry.lastModified)
527545
}
528546
}
529547

src/run/handlers/tags-handler.cts

Lines changed: 93 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 timestampsOrNulls = 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 = timestampsOrNulls
38+
.filter((timestamp) => timestamp !== null)
39+
.map((manifest) => manifest.expiredAt)
40+
if (expirationTimestamps.length === 0) {
4441
return 0
4542
}
46-
return Math.max(...timestamps)
43+
return Math.max(...expirationTimestamps)
4744
}
4845

46+
export type TagStaleOrExpired =
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<TagStaleOrExpired> {
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<TagStaleOrExpired>((resolve, reject) => {
76+
const tagManifestPromises: Promise<TagStaleOrExpired>[] = []
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 }
86+
}
87+
const stale = tagManifest.staleAt >= timestamp
88+
const expired = tagManifest.expiredAt >= timestamp && tagManifest.expiredAt <= Date.now()
89+
90+
if (expired && stale) {
91+
const expiredResult: TagStaleOrExpired = {
92+
stale,
93+
expired,
94+
}
95+
// resolve outer promise immediately if any of the tags is expired
96+
resolve(expiredResult)
97+
return expiredResult
7898
}
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
99+
100+
if (stale) {
101+
const staleResult: TagStaleOrExpired = {
102+
stale,
103+
expired,
104+
expireAt: tagManifest.expiredAt,
105+
}
106+
return staleResult
84107
}
85-
return false
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: TagStaleOrExpired = { 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,21 @@ 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+
async function doRevalidateTagAndPurgeEdgeCache(
171+
tags: string[],
172+
durations?: { expire?: number },
173+
): Promise<void> {
174+
getLogger().withFields({ tags, durations }).debug('doRevalidateTagAndPurgeEdgeCache')
127175

128176
if (tags.length === 0) {
129177
return
130178
}
131179

180+
const now = Date.now()
181+
132182
const tagManifest: TagManifest = {
133-
revalidatedAt: Date.now(),
183+
staleAt: now,
184+
expiredAt: now + (durations?.expire ? durations.expire * 1000 : 0),
134185
}
135186

136187
const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })
@@ -148,10 +199,13 @@ async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise<void> {
148199
await purgeEdgeCache(tags)
149200
}
150201

151-
export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) {
202+
export function markTagsAsStaleAndPurgeEdgeCache(
203+
tagOrTags: string | string[],
204+
durations?: { expire?: number },
205+
) {
152206
const tags = getCacheTagsFromTagOrTags(tagOrTags)
153207

154-
const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags)
208+
const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags, durations)
155209

156210
const requestContext = getRequestContext()
157211
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+
expiredAt: 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
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { revalidateTag } from 'next/cache'
3+
4+
export async function GET(request: NextRequest) {
5+
const url = new URL(request.url)
6+
const tagToRevalidate = url.searchParams.get('tag') ?? 'collection'
7+
const expire = url.searchParams.has('expire') ? parseInt(url.searchParams.get('expire')) : 0
8+
9+
console.log(`Revalidating tag: ${tagToRevalidate}, expire: ${expire}`)
10+
11+
revalidateTag(tagToRevalidate, { expire })
12+
return NextResponse.json({ revalidated: true, now: new Date().toISOString() })
13+
}
14+
15+
export const dynamic = 'force-dynamic'

0 commit comments

Comments
 (0)