Skip to content

Commit f8004d7

Browse files
authoredMar 3, 2025
feat: make CDN SWR background revalidation discard stale cache content in order to produce fresh responses (#2765)
* feat: make CDN SWR background revalidation discard stale cache content in order to produce fresh responses * test: add e2e case testing storing fresh responses in cdn when handling background SWR requests * fix: apply code review comments/suggestions
1 parent f3e24b1 commit f8004d7

File tree

11 files changed

+243
-59
lines changed

11 files changed

+243
-59
lines changed
 

‎src/build/templates/handler-monorepo.tmpl.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,15 @@ export default async function (req, context) {
2828
'site.id': context.site.id,
2929
'http.method': req.method,
3030
'http.target': req.url,
31+
isBackgroundRevalidation: requestContext.isBackgroundRevalidation,
3132
monorepo: true,
3233
cwd: '{{cwd}}',
3334
})
3435
if (!cachedHandler) {
3536
const { default: handler } = await import('{{nextServerHandler}}')
3637
cachedHandler = handler
3738
}
38-
const response = await cachedHandler(req, context)
39+
const response = await cachedHandler(req, context, span, requestContext)
3940
span.setAttributes({
4041
'http.status_code': response.status,
4142
})

‎src/build/templates/handler.tmpl.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ export default async function handler(req, context) {
2525
'site.id': context.site.id,
2626
'http.method': req.method,
2727
'http.target': req.url,
28+
isBackgroundRevalidation: requestContext.isBackgroundRevalidation,
2829
monorepo: false,
2930
cwd: process.cwd(),
3031
})
31-
const response = await serverHandler(req, context)
32+
const response = await serverHandler(req, context, span, requestContext)
3233
span.setAttributes({
3334
'http.status_code': response.status,
3435
})

‎src/run/handlers/cache.cts

+56-6
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,29 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
5252
return await encodeBlobKey(key)
5353
}
5454

55+
private getTTL(blob: NetlifyCacheHandlerValue) {
56+
if (
57+
blob.value?.kind === 'FETCH' ||
58+
blob.value?.kind === 'ROUTE' ||
59+
blob.value?.kind === 'APP_ROUTE' ||
60+
blob.value?.kind === 'PAGE' ||
61+
blob.value?.kind === 'PAGES' ||
62+
blob.value?.kind === 'APP_PAGE'
63+
) {
64+
const { revalidate } = blob.value
65+
66+
if (typeof revalidate === 'number') {
67+
const revalidateAfter = revalidate * 1_000 + blob.lastModified
68+
return (revalidateAfter - Date.now()) / 1_000
69+
}
70+
if (revalidate === false) {
71+
return 'PERMANENT'
72+
}
73+
}
74+
75+
return 'NOT SET'
76+
}
77+
5578
private captureResponseCacheLastModified(
5679
cacheValue: NetlifyCacheHandlerValue,
5780
key: string,
@@ -219,10 +242,31 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
219242
return null
220243
}
221244

245+
const ttl = this.getTTL(blob)
246+
247+
if (getRequestContext()?.isBackgroundRevalidation && typeof ttl === 'number' && ttl < 0) {
248+
// background revalidation request should allow data that is not yet stale,
249+
// but opt to discard STALE data, so that Next.js generate fresh response
250+
span.addEvent('Discarding stale entry due to SWR background revalidation request', {
251+
key,
252+
blobKey,
253+
ttl,
254+
})
255+
getLogger()
256+
.withFields({
257+
ttl,
258+
key,
259+
})
260+
.debug(
261+
`[NetlifyCacheHandler.get] Discarding stale entry due to SWR background revalidation request: ${key}`,
262+
)
263+
return null
264+
}
265+
222266
const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.tags, ctx.softTags)
223267

224268
if (staleByTags) {
225-
span.addEvent('Stale', { staleByTags })
269+
span.addEvent('Stale', { staleByTags, key, blobKey, ttl })
226270
return null
227271
}
228272

@@ -231,7 +275,11 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
231275

232276
switch (blob.value?.kind) {
233277
case 'FETCH':
234-
span.addEvent('FETCH', { lastModified: blob.lastModified, revalidate: ctx.revalidate })
278+
span.addEvent('FETCH', {
279+
lastModified: blob.lastModified,
280+
revalidate: ctx.revalidate,
281+
ttl,
282+
})
235283
return {
236284
lastModified: blob.lastModified,
237285
value: blob.value,
@@ -242,6 +290,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
242290
span.addEvent(blob.value?.kind, {
243291
lastModified: blob.lastModified,
244292
status: blob.value.status,
293+
revalidate: blob.value.revalidate,
294+
ttl,
245295
})
246296

247297
const valueWithoutRevalidate = this.captureRouteRevalidateAndRemoveFromObject(blob.value)
@@ -256,10 +306,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
256306
}
257307
case 'PAGE':
258308
case 'PAGES': {
259-
span.addEvent(blob.value?.kind, { lastModified: blob.lastModified })
260-
261309
const { revalidate, ...restOfPageValue } = blob.value
262310

311+
span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl })
312+
263313
await this.injectEntryToPrerenderManifest(key, revalidate)
264314

265315
return {
@@ -268,10 +318,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
268318
}
269319
}
270320
case 'APP_PAGE': {
271-
span.addEvent(blob.value?.kind, { lastModified: blob.lastModified })
272-
273321
const { revalidate, rscData, ...restOfPageValue } = blob.value
274322

323+
span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl })
324+
275325
await this.injectEntryToPrerenderManifest(key, revalidate)
276326

277327
return {

‎src/run/handlers/request-context.cts

+18-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export interface FutureContext extends Context {
1313
}
1414

1515
export type RequestContext = {
16+
/**
17+
* Determine if this request is for CDN SWR background revalidation
18+
*/
19+
isBackgroundRevalidation: boolean
1620
captureServerTiming: boolean
1721
responseCacheGetLastModified?: number
1822
responseCacheKey?: string
@@ -41,7 +45,20 @@ type RequestContextAsyncLocalStorage = AsyncLocalStorage<RequestContext>
4145
export function createRequestContext(request?: Request, context?: FutureContext): RequestContext {
4246
const backgroundWorkPromises: Promise<unknown>[] = []
4347

48+
const isDebugRequest =
49+
request?.headers.has('x-nf-debug-logging') || request?.headers.has('x-next-debug-logging')
50+
51+
const logger = systemLogger.withLogLevel(isDebugRequest ? LogLevel.Debug : LogLevel.Log)
52+
53+
const isBackgroundRevalidation =
54+
request?.headers.get('netlify-invocation-source') === 'background-revalidation'
55+
56+
if (isBackgroundRevalidation) {
57+
logger.debug('[NetlifyNextRuntime] Background revalidation request')
58+
}
59+
4460
return {
61+
isBackgroundRevalidation,
4562
captureServerTiming: request?.headers.has('x-next-debug-logging') ?? false,
4663
trackBackgroundWork: (promise) => {
4764
if (context?.waitUntil) {
@@ -53,11 +70,7 @@ export function createRequestContext(request?: Request, context?: FutureContext)
5370
get backgroundWorkPromise() {
5471
return Promise.allSettled(backgroundWorkPromises)
5572
},
56-
logger: systemLogger.withLogLevel(
57-
request?.headers.has('x-nf-debug-logging') || request?.headers.has('x-next-debug-logging')
58-
? LogLevel.Debug
59-
: LogLevel.Log,
60-
),
73+
logger,
6174
}
6275
}
6376

‎src/run/handlers/server.ts

+28-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { OutgoingHttpHeaders } from 'http'
22

33
import { ComputeJsOutgoingMessage, toComputeResponse, toReqRes } from '@fastly/http-compute-js'
4+
import type { Context } from '@netlify/functions'
5+
import { Span } from '@opentelemetry/api'
46
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
57
import type { WorkerRequestHandler } from 'next/dist/server/lib/types.js'
68

@@ -13,7 +15,7 @@ import {
1315
} from '../headers.js'
1416
import { nextResponseProxy } from '../revalidate.js'
1517

16-
import { createRequestContext, getLogger, getRequestContext } from './request-context.cjs'
18+
import { getLogger, type RequestContext } from './request-context.cjs'
1719
import { getTracer } from './tracer.cjs'
1820
import { setupWaitUntil } from './wait-until.cjs'
1921

@@ -46,7 +48,12 @@ const disableFaultyTransferEncodingHandling = (res: ComputeJsOutgoingMessage) =>
4648
}
4749
}
4850

49-
export default async (request: Request) => {
51+
export default async (
52+
request: Request,
53+
_context: Context,
54+
topLevelSpan: Span,
55+
requestContext: RequestContext,
56+
) => {
5057
const tracer = getTracer()
5158

5259
if (!nextHandler) {
@@ -85,8 +92,6 @@ export default async (request: Request) => {
8592

8693
disableFaultyTransferEncodingHandling(res as unknown as ComputeJsOutgoingMessage)
8794

88-
const requestContext = getRequestContext() ?? createRequestContext()
89-
9095
const resProxy = nextResponseProxy(res, requestContext)
9196

9297
// We don't await this here, because it won't resolve until the response is finished.
@@ -103,15 +108,31 @@ export default async (request: Request) => {
103108
const response = await toComputeResponse(resProxy)
104109

105110
if (requestContext.responseCacheKey) {
106-
span.setAttribute('responseCacheKey', requestContext.responseCacheKey)
111+
topLevelSpan.setAttribute('responseCacheKey', requestContext.responseCacheKey)
107112
}
108113

109-
await adjustDateHeader({ headers: response.headers, request, span, tracer, requestContext })
114+
const nextCache = response.headers.get('x-nextjs-cache')
115+
const isServedFromNextCache = nextCache === 'HIT' || nextCache === 'STALE'
116+
117+
topLevelSpan.setAttributes({
118+
'x-nextjs-cache': nextCache ?? undefined,
119+
isServedFromNextCache,
120+
})
121+
122+
if (isServedFromNextCache) {
123+
await adjustDateHeader({
124+
headers: response.headers,
125+
request,
126+
span,
127+
tracer,
128+
requestContext,
129+
})
130+
}
110131

111132
setCacheControlHeaders(response, request, requestContext, nextConfig)
112133
setCacheTagsHeaders(response.headers, requestContext)
113134
setVaryHeaders(response.headers, request, nextConfig)
114-
setCacheStatusHeader(response.headers)
135+
setCacheStatusHeader(response.headers, nextCache)
115136

116137
async function waitForBackgroundWork() {
117138
// it's important to keep the stream open until the next handler has finished

‎src/run/headers.ts

+1-13
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,6 @@ export const adjustDateHeader = async ({
137137
tracer: RuntimeTracer
138138
requestContext: RequestContext
139139
}) => {
140-
const cacheState = headers.get('x-nextjs-cache')
141-
const isServedFromCache = cacheState === 'HIT' || cacheState === 'STALE'
142-
143-
span.setAttributes({
144-
'x-nextjs-cache': cacheState ?? undefined,
145-
isServedFromCache,
146-
})
147-
148-
if (!isServedFromCache) {
149-
return
150-
}
151140
const key = new URL(request.url).pathname
152141

153142
let lastModified: number | undefined
@@ -317,8 +306,7 @@ const NEXT_CACHE_TO_CACHE_STATUS: Record<string, string> = {
317306
* a Cache-Status header for Next cache so users inspect that together with CDN cache status
318307
* and not on its own.
319308
*/
320-
export const setCacheStatusHeader = (headers: Headers) => {
321-
const nextCache = headers.get('x-nextjs-cache')
309+
export const setCacheStatusHeader = (headers: Headers, nextCache: string | null) => {
322310
if (typeof nextCache === 'string') {
323311
if (nextCache in NEXT_CACHE_TO_CACHE_STATUS) {
324312
const cacheStatus = NEXT_CACHE_TO_CACHE_STATUS[nextCache]

‎src/shared/cache-types.cts

+3-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ type CachedRouteValueToNetlify<T> = T extends CachedRouteValue
7878
? NetlifyCachedAppPageValue
7979
: T
8080

81-
type MapCachedRouteValueToNetlify<T> = { [K in keyof T]: CachedRouteValueToNetlify<T[K]> }
81+
type MapCachedRouteValueToNetlify<T> = { [K in keyof T]: CachedRouteValueToNetlify<T[K]> } & {
82+
lastModified: number
83+
}
8284

8385
/**
8486
* Used for storing in blobs and reading from blobs

‎tests/e2e/page-router.test.ts

+57
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,63 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => {
390390
expect(beforeFetch.localeCompare(date2)).toBeLessThan(0)
391391
})
392392

393+
test('Background SWR invocations can store fresh responses in CDN cache', async ({
394+
page,
395+
pageRouter,
396+
}) => {
397+
const slug = Date.now()
398+
const pathname = `/revalidate-60/${slug}`
399+
400+
const beforeFirstFetch = new Date().toISOString()
401+
402+
const response1 = await page.goto(new URL(pathname, pageRouter.url).href)
403+
expect(response1?.status()).toBe(200)
404+
expect(response1?.headers()['cache-status']).toMatch(
405+
/"Netlify (Edge|Durable)"; fwd=(uri-miss(; stored)?|miss)/m,
406+
)
407+
expect(response1?.headers()['netlify-cdn-cache-control']).toMatch(
408+
/s-maxage=60, stale-while-revalidate=[0-9]+, durable/,
409+
)
410+
411+
// ensure response was NOT produced before invocation
412+
const date1 = (await page.textContent('[data-testid="date-now"]')) ?? ''
413+
expect(date1.localeCompare(beforeFirstFetch)).toBeGreaterThan(0)
414+
415+
// allow page to get stale
416+
await page.waitForTimeout(60_000)
417+
418+
const response2 = await page.goto(new URL(pathname, pageRouter.url).href)
419+
expect(response2?.status()).toBe(200)
420+
expect(response2?.headers()['cache-status']).toMatch(
421+
/"Netlify (Edge|Durable)"; hit; fwd=stale/m,
422+
)
423+
expect(response2?.headers()['netlify-cdn-cache-control']).toMatch(
424+
/s-maxage=60, stale-while-revalidate=[0-9]+, durable/,
425+
)
426+
427+
const date2 = (await page.textContent('[data-testid="date-now"]')) ?? ''
428+
expect(date2).toBe(date1)
429+
430+
// wait a bit to ensure background work has a chance to finish
431+
// (it should take at least 5 seconds to regenerate, so we should wait at least that much to get fresh response)
432+
await page.waitForTimeout(10_000)
433+
434+
// subsequent request should be served with fresh response from cdn cache, as previous request
435+
// should result in background SWR invocation that serves fresh response that was stored in CDN cache
436+
const response3 = await page.goto(new URL(pathname, pageRouter.url).href)
437+
expect(response3?.status()).toBe(200)
438+
expect(response3?.headers()['cache-status']).toMatch(
439+
// hit, without being followed by ';fwd=stale'
440+
/"Netlify (Edge|Durable)"; hit(?!; fwd=stale)/m,
441+
)
442+
expect(response3?.headers()['netlify-cdn-cache-control']).toMatch(
443+
/s-maxage=60, stale-while-revalidate=[0-9]+, durable/,
444+
)
445+
446+
const date3 = (await page.textContent('[data-testid="date-now"]')) ?? ''
447+
expect(date3.localeCompare(date2)).toBeGreaterThan(0)
448+
})
449+
393450
test('should serve 404 page when requesting non existing page (no matching route)', async ({
394451
page,
395452
pageRouter,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { purgeCache, Config } from '@netlify/functions'
2+
3+
export default async function handler(request: Request) {
4+
const url = new URL(request.url)
5+
const pathToPurge = url.searchParams.get('path')
6+
7+
if (!pathToPurge) {
8+
return Response.json(
9+
{
10+
status: 'error',
11+
error: 'missing "path" query parameter',
12+
},
13+
{ status: 400 },
14+
)
15+
}
16+
try {
17+
await purgeCache({ tags: [`_N_T_${encodeURI(pathToPurge)}`] })
18+
return Response.json(
19+
{
20+
status: 'ok',
21+
},
22+
{
23+
status: 200,
24+
},
25+
)
26+
} catch (error) {
27+
return Response.json(
28+
{
29+
status: 'error',
30+
error: error.toString(),
31+
},
32+
{
33+
status: 500,
34+
},
35+
)
36+
}
37+
}
38+
39+
export const config: Config = {
40+
path: '/api/purge-cdn',
41+
}

‎tests/fixtures/page-router/pages/api/purge-cdn.js

-25
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const Show = ({ time, easyTimeToCompare, slug }) => (
2+
<div>
3+
<p>
4+
This page uses getStaticProps() at
5+
<span data-testid="date-now">{time}</span>
6+
</p>
7+
<p>
8+
Time string: <span data-testid="date-easy-time">{easyTimeToCompare}</span>
9+
</p>
10+
<p>Slug {slug}</p>
11+
</div>
12+
)
13+
14+
/** @type {import('next').getStaticPaths} */
15+
export const getStaticPaths = () => {
16+
return {
17+
paths: [],
18+
fallback: 'blocking',
19+
}
20+
}
21+
22+
/** @type {import('next').GetStaticProps} */
23+
export async function getStaticProps({ params }) {
24+
const date = new Date()
25+
return {
26+
props: {
27+
slug: params.slug,
28+
time: date.toISOString(),
29+
easyTimeToCompare: date.toTimeString(),
30+
},
31+
revalidate: 60,
32+
}
33+
}
34+
35+
export default Show

0 commit comments

Comments
 (0)
Please sign in to comment.