Skip to content

Commit

Permalink
feat: make CDN SWR background revalidation discard stale cache conten…
Browse files Browse the repository at this point in the history
…t in order to produce fresh responses
  • Loading branch information
pieh committed Feb 19, 2025
1 parent f4b59b6 commit aca7cdd
Show file tree
Hide file tree
Showing 7 changed files with 110 additions and 34 deletions.
3 changes: 2 additions & 1 deletion src/build/templates/handler-monorepo.tmpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ export default async function (req, context) {
'site.id': context.site.id,
'http.method': req.method,
'http.target': req.url,
isBackgroundRevalidation: requestContext.isBackgroundRevalidation,
monorepo: true,
cwd: '{{cwd}}',
})
if (!cachedHandler) {
const { default: handler } = await import('{{nextServerHandler}}')
cachedHandler = handler
}
const response = await cachedHandler(req, context)
const response = await cachedHandler(req, context, span, requestContext)
span.setAttributes({
'http.status_code': response.status,
})
Expand Down
3 changes: 2 additions & 1 deletion src/build/templates/handler.tmpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ export default async function handler(req, context) {
'site.id': context.site.id,
'http.method': req.method,
'http.target': req.url,
isBackgroundRevalidation: requestContext.isBackgroundRevalidation,
monorepo: false,
cwd: process.cwd(),
})
const response = await serverHandler(req, context)
const response = await serverHandler(req, context, span, requestContext)
span.setAttributes({
'http.status_code': response.status,
})
Expand Down
60 changes: 54 additions & 6 deletions src/run/handlers/cache.cts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
return await encodeBlobKey(key)
}

private getTTL(blob: NetlifyCacheHandlerValue) {
if (
blob.value?.kind === 'FETCH' ||
blob.value?.kind === 'ROUTE' ||
blob.value?.kind === 'APP_ROUTE' ||
blob.value?.kind === 'PAGE' ||
blob.value?.kind === 'PAGES' ||
blob.value?.kind === 'APP_PAGE'
) {
const { revalidate } = blob.value

if (typeof revalidate === 'number') {
const revalidateAfter = revalidate * 1_000 + blob.lastModified
return (revalidateAfter - Date.now()) / 1_000
}
return revalidate === false ? 'PERMANENT' : 'NOT SET'
}

return 'NOT SET'
}

private captureResponseCacheLastModified(
cacheValue: NetlifyCacheHandlerValue,
key: string,
Expand Down Expand Up @@ -219,10 +240,31 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
return null
}

const ttl = this.getTTL(blob)

if (getRequestContext()?.isBackgroundRevalidation && typeof ttl === 'number' && ttl < 0) {
// background revalidation request should allow data that is not yet stale,
// but opt to discard STALE data, so that Next.js generate fresh response
span.addEvent('Discarding stale entry due to SWR background revalidation request', {
key,
blobKey,
ttl,
})
getLogger()
.withFields({
ttl,
key,
})
.debug(
`[NetlifyCacheHandler.get] Discarding stale entry due to SWR background revalidation request: ${key}`,
)
return null
}

const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.tags, ctx.softTags)

if (staleByTags) {
span.addEvent('Stale', { staleByTags })
span.addEvent('Stale', { staleByTags, key, blobKey, ttl })
return null
}

Expand All @@ -231,7 +273,11 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {

switch (blob.value?.kind) {
case 'FETCH':
span.addEvent('FETCH', { lastModified: blob.lastModified, revalidate: ctx.revalidate })
span.addEvent('FETCH', {
lastModified: blob.lastModified,
revalidate: ctx.revalidate,
ttl,
})
return {
lastModified: blob.lastModified,
value: blob.value,
Expand All @@ -242,6 +288,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
span.addEvent(blob.value?.kind, {
lastModified: blob.lastModified,
status: blob.value.status,
revalidate: blob.value.revalidate,
ttl,
})

const valueWithoutRevalidate = this.captureRouteRevalidateAndRemoveFromObject(blob.value)
Expand All @@ -256,10 +304,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
}
case 'PAGE':
case 'PAGES': {
span.addEvent(blob.value?.kind, { lastModified: blob.lastModified })

const { revalidate, ...restOfPageValue } = blob.value

span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl })

await this.injectEntryToPrerenderManifest(key, revalidate)

return {
Expand All @@ -268,10 +316,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
}
}
case 'APP_PAGE': {
span.addEvent(blob.value?.kind, { lastModified: blob.lastModified })

const { revalidate, rscData, ...restOfPageValue } = blob.value

span.addEvent(blob.value?.kind, { lastModified: blob.lastModified, revalidate, ttl })

await this.injectEntryToPrerenderManifest(key, revalidate)

return {
Expand Down
25 changes: 20 additions & 5 deletions src/run/handlers/request-context.cts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export interface FutureContext extends Context {
}

export type RequestContext = {
/**
* Determine if this request is for CDN SWR background revalidation
*/
isBackgroundRevalidation: boolean
captureServerTiming: boolean
responseCacheGetLastModified?: number
responseCacheKey?: string
Expand All @@ -36,12 +40,27 @@ export type RequestContext = {
logger: SystemLogger
}

// this is theoretical header that doesn't yet exist
export const BACKGROUND_REVALIDATION_HEADER = 'x-background-revalidation'

type RequestContextAsyncLocalStorage = AsyncLocalStorage<RequestContext>

export function createRequestContext(request?: Request, context?: FutureContext): RequestContext {
const backgroundWorkPromises: Promise<unknown>[] = []

const isDebugRequest =
request?.headers.has('x-nf-debug-logging') || request?.headers.has('x-next-debug-logging')

const logger = systemLogger.withLogLevel(isDebugRequest ? LogLevel.Debug : LogLevel.Log)

const isBackgroundRevalidation = request?.headers.has(BACKGROUND_REVALIDATION_HEADER) ?? false

if (isBackgroundRevalidation) {
logger.debug('[NetlifyNextRuntime] Background revalidation request')
}

return {
isBackgroundRevalidation,
captureServerTiming: request?.headers.has('x-next-debug-logging') ?? false,
trackBackgroundWork: (promise) => {
if (context?.waitUntil) {
Expand All @@ -53,11 +72,7 @@ export function createRequestContext(request?: Request, context?: FutureContext)
get backgroundWorkPromise() {
return Promise.allSettled(backgroundWorkPromises)
},
logger: systemLogger.withLogLevel(
request?.headers.has('x-nf-debug-logging') || request?.headers.has('x-next-debug-logging')
? LogLevel.Debug
: LogLevel.Log,
),
logger,
}
}

Expand Down
35 changes: 28 additions & 7 deletions src/run/handlers/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { OutgoingHttpHeaders } from 'http'

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

Expand All @@ -13,7 +15,7 @@ import {
} from '../headers.js'
import { nextResponseProxy } from '../revalidate.js'

import { createRequestContext, getLogger, getRequestContext } from './request-context.cjs'
import { getLogger, type RequestContext } from './request-context.cjs'
import { getTracer } from './tracer.cjs'
import { setupWaitUntil } from './wait-until.cjs'

Expand Down Expand Up @@ -46,7 +48,12 @@ const disableFaultyTransferEncodingHandling = (res: ComputeJsOutgoingMessage) =>
}
}

export default async (request: Request) => {
export default async (
request: Request,
_context: Context,
topLevelSpan: Span,
requestContext: RequestContext,
) => {
const tracer = getTracer()

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

disableFaultyTransferEncodingHandling(res as unknown as ComputeJsOutgoingMessage)

const requestContext = getRequestContext() ?? createRequestContext()

const resProxy = nextResponseProxy(res, requestContext)

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

if (requestContext.responseCacheKey) {
span.setAttribute('responseCacheKey', requestContext.responseCacheKey)
topLevelSpan.setAttribute('responseCacheKey', requestContext.responseCacheKey)
}

await adjustDateHeader({ headers: response.headers, request, span, tracer, requestContext })
const nextCache = response.headers.get('x-nextjs-cache')
const isServedFromCache = nextCache === 'HIT' || nextCache === 'STALE'

topLevelSpan.setAttributes({
'x-nextjs-cache': nextCache ?? undefined,
isServedFromCache,
})

if (isServedFromCache) {
await adjustDateHeader({
headers: response.headers,
request,
span,
tracer,
requestContext,
})
}

setCacheControlHeaders(response, request, requestContext, nextConfig)
setCacheTagsHeaders(response.headers, requestContext)
setVaryHeaders(response.headers, request, nextConfig)
setCacheStatusHeader(response.headers)
setCacheStatusHeader(response.headers, nextCache)

async function waitForBackgroundWork() {
// it's important to keep the stream open until the next handler has finished
Expand Down
14 changes: 1 addition & 13 deletions src/run/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,6 @@ export const adjustDateHeader = async ({
tracer: RuntimeTracer
requestContext: RequestContext
}) => {
const cacheState = headers.get('x-nextjs-cache')
const isServedFromCache = cacheState === 'HIT' || cacheState === 'STALE'

span.setAttributes({
'x-nextjs-cache': cacheState ?? undefined,
isServedFromCache,
})

if (!isServedFromCache) {
return
}
const key = new URL(request.url).pathname

let lastModified: number | undefined
Expand Down Expand Up @@ -316,8 +305,7 @@ const NEXT_CACHE_TO_CACHE_STATUS: Record<string, string> = {
* a Cache-Status header for Next cache so users inspect that together with CDN cache status
* and not on its own.
*/
export const setCacheStatusHeader = (headers: Headers) => {
const nextCache = headers.get('x-nextjs-cache')
export const setCacheStatusHeader = (headers: Headers, nextCache: string | null) => {
if (typeof nextCache === 'string') {
if (nextCache in NEXT_CACHE_TO_CACHE_STATUS) {
const cacheStatus = NEXT_CACHE_TO_CACHE_STATUS[nextCache]
Expand Down
4 changes: 3 additions & 1 deletion src/shared/cache-types.cts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ type CachedRouteValueToNetlify<T> = T extends CachedRouteValue
? NetlifyCachedAppPageValue
: T

type MapCachedRouteValueToNetlify<T> = { [K in keyof T]: CachedRouteValueToNetlify<T[K]> }
type MapCachedRouteValueToNetlify<T> = { [K in keyof T]: CachedRouteValueToNetlify<T[K]> } & {
lastModified: number
}

/**
* Used for storing in blobs and reading from blobs
Expand Down

0 comments on commit aca7cdd

Please # to comment.