Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: make CDN SWR background revalidation discard stale cache content in order to produce fresh responses #2765

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading