Skip to content

Commit e33bd93

Browse files
authored
perf: reuse lastModified captured in cache handler to calculate date header (#260)
* perf: reuse lastModified captured in cache handler to calculate date header * chore: record exception and set warning and severity attributes * test: spy on blobs.get usage and assert we only do it once, compare produced date headers * test: add integration test for request-context not leaking for concurrent requests * chore: remove some commented out code * fix: there is no requestContext on CacheHandler anymore
1 parent 3bd9893 commit e33bd93

10 files changed

+639
-52
lines changed

package-lock.json

+59
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"get-port": "^7.0.0",
7070
"lambda-local": "^2.1.2",
7171
"memfs": "^4.6.0",
72+
"mock-require": "^3.0.3",
7273
"msw": "^2.0.7",
7374
"next": "^14.0.4",
7475
"os": "^0.1.2",

src/run/handlers/cache.cts

+53-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Buffer } from 'node:buffer'
55

66
import { getDeployStore, Store } from '@netlify/blobs'
77
import { purgeCache } from '@netlify/functions'
8-
import { trace } from '@opentelemetry/api'
8+
import { trace, type Span } from '@opentelemetry/api'
99
import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js'
1010
import type {
1111
CacheHandler,
@@ -14,6 +14,8 @@ import type {
1414
IncrementalCache,
1515
} from 'next/dist/server/lib/incremental-cache/index.js'
1616

17+
import { getRequestContext } from './request-context.cjs'
18+
1719
type TagManifest = { revalidatedAt: number }
1820

1921
const fetchBeforeNextPatchedIt = globalThis.fetch
@@ -35,6 +37,54 @@ export class NetlifyCacheHandler implements CacheHandler {
3537
return await encodeBlobKey(key)
3638
}
3739

40+
private captureResponseCacheLastModified(
41+
cacheValue: CacheHandlerValue,
42+
key: string,
43+
getCacheKeySpan: Span,
44+
) {
45+
if (cacheValue.value?.kind === 'FETCH') {
46+
return
47+
}
48+
49+
const requestContext = getRequestContext()
50+
51+
if (!requestContext) {
52+
// we will not be able to use request context for date header calculation
53+
// we will fallback to using blobs
54+
getCacheKeySpan.recordException(
55+
new Error('CacheHandler was called without a request context'),
56+
)
57+
getCacheKeySpan.setAttributes({
58+
severity: 'alert',
59+
warning: true,
60+
})
61+
return
62+
}
63+
64+
if (requestContext.responseCacheKey && requestContext.responseCacheKey !== key) {
65+
// if there are multiple response-cache keys, we don't know which one we should use
66+
// so as a safety measure we will not use any of them and let blobs be used
67+
// to calculate the date header
68+
requestContext.responseCacheGetLastModified = undefined
69+
getCacheKeySpan.recordException(
70+
new Error(
71+
`Multiple response cache keys used in single request: ["${requestContext.responseCacheKey}, "${key}"]`,
72+
),
73+
)
74+
getCacheKeySpan.setAttributes({
75+
severity: 'alert',
76+
warning: true,
77+
})
78+
return
79+
}
80+
81+
requestContext.responseCacheKey = key
82+
if (cacheValue.lastModified) {
83+
// we store it to use it later when calculating date header
84+
requestContext.responseCacheGetLastModified = cacheValue.lastModified
85+
}
86+
}
87+
3888
async get(...args: Parameters<CacheHandler['get']>): ReturnType<CacheHandler['get']> {
3989
return this.tracer.startActiveSpan('get cache key', async (span) => {
4090
const [key, ctx = {}] = args
@@ -61,6 +111,8 @@ export class NetlifyCacheHandler implements CacheHandler {
61111
return null
62112
}
63113

114+
this.captureResponseCacheLastModified(blob, key, span)
115+
64116
switch (blob.value?.kind) {
65117
case 'FETCH':
66118
span.addEvent('FETCH', { lastModified: blob.lastModified, revalidate: ctx.revalidate })

src/run/handlers/request-context.cts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { AsyncLocalStorage } from 'node:async_hooks'
2+
3+
export type RequestContext = {
4+
responseCacheGetLastModified?: number
5+
responseCacheKey?: string
6+
}
7+
8+
type RequestContextAsyncLocalStorage = AsyncLocalStorage<RequestContext>
9+
10+
export function createRequestContext(): RequestContext {
11+
return {}
12+
}
13+
14+
const REQUEST_CONTEXT_GLOBAL_KEY = Symbol.for('nf-request-context-async-local-storage')
15+
let requestContextAsyncLocalStorage: RequestContextAsyncLocalStorage | undefined
16+
function getRequestContextAsyncLocalStorage() {
17+
if (requestContextAsyncLocalStorage) {
18+
return requestContextAsyncLocalStorage
19+
}
20+
// for cases when there is multiple "copies" of this module, we can't just init
21+
// AsyncLocalStorage in the module scope, because it will be different for each
22+
// copy - so first time an instance of this module is used, we store AsyncLocalStorage
23+
// in global scope and reuse it for all subsequent calls
24+
const extendedGlobalThis = globalThis as typeof globalThis & {
25+
[REQUEST_CONTEXT_GLOBAL_KEY]?: RequestContextAsyncLocalStorage
26+
}
27+
if (extendedGlobalThis[REQUEST_CONTEXT_GLOBAL_KEY]) {
28+
return extendedGlobalThis[REQUEST_CONTEXT_GLOBAL_KEY]
29+
}
30+
31+
const storage = new AsyncLocalStorage<RequestContext>()
32+
// store for future use of this instance of module
33+
requestContextAsyncLocalStorage = storage
34+
// store for future use of copy of this module
35+
extendedGlobalThis[REQUEST_CONTEXT_GLOBAL_KEY] = storage
36+
return storage
37+
}
38+
39+
export const getRequestContext = () => getRequestContextAsyncLocalStorage().getStore()
40+
41+
export function runWithRequestContext<T>(requestContext: RequestContext, fn: () => T): T {
42+
return getRequestContextAsyncLocalStorage().run(requestContext, fn)
43+
}

src/run/handlers/server.ts

+24-14
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
import { nextResponseProxy } from '../revalidate.js'
1515
import { logger } from '../systemlog.js'
1616

17+
import { createRequestContext, runWithRequestContext } from './request-context.cjs'
18+
1719
let nextHandler: WorkerRequestHandler, nextConfig: NextConfigComplete, tagsManifest: TagsManifest
1820

1921
export default async (request: Request) => {
@@ -51,26 +53,34 @@ export default async (request: Request) => {
5153

5254
const resProxy = nextResponseProxy(res)
5355

56+
const requestContext = createRequestContext()
57+
5458
// We don't await this here, because it won't resolve until the response is finished.
55-
const nextHandlerPromise = nextHandler(req, resProxy).catch((error) => {
56-
logger.withError(error).error('next handler error')
57-
console.error(error)
58-
resProxy.statusCode = 500
59-
span.recordException(error)
60-
span.setAttribute('http.status_code', 500)
61-
span.setStatus({
62-
code: SpanStatusCode.ERROR,
63-
message: error instanceof Error ? error.message : String(error),
64-
})
65-
span.end()
66-
resProxy.end('Internal Server Error')
67-
})
59+
const nextHandlerPromise = runWithRequestContext(requestContext, () =>
60+
nextHandler(req, resProxy).catch((error) => {
61+
logger.withError(error).error('next handler error')
62+
console.error(error)
63+
resProxy.statusCode = 500
64+
span.recordException(error)
65+
span.setAttribute('http.status_code', 500)
66+
span.setStatus({
67+
code: SpanStatusCode.ERROR,
68+
message: error instanceof Error ? error.message : String(error),
69+
})
70+
span.end()
71+
resProxy.end('Internal Server Error')
72+
}),
73+
)
6874

6975
// Contrary to the docs, this resolves when the headers are available, not when the stream closes.
7076
// See https://github.com/fastly/http-compute-js/blob/main/src/http-compute-js/http-server.ts#L168-L173
7177
const response = await toComputeResponse(resProxy)
7278

73-
await adjustDateHeader(response.headers, request, span, tracer)
79+
if (requestContext.responseCacheKey) {
80+
span.setAttribute('responseCacheKey', requestContext.responseCacheKey)
81+
}
82+
83+
await adjustDateHeader({ headers: response.headers, request, span, tracer, requestContext })
7484

7585
setCacheControlHeaders(response.headers, request)
7686
setCacheTagsHeaders(response.headers, request, tagsManifest)

src/run/headers.ts

+76-24
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
55
import { encodeBlobKey } from '../shared/blobkey.js'
66

77
import type { TagsManifest } from './config.js'
8+
import type { RequestContext } from './handlers/request-context.cjs'
89

910
interface NetlifyVaryValues {
1011
headers: string[]
@@ -84,12 +85,19 @@ const fetchBeforeNextPatchedIt = globalThis.fetch
8485
* By default, Next.js sets the date header to the current time, even if it's served
8586
* from the cache, meaning that the CDN will cache it for 10 seconds, which is incorrect.
8687
*/
87-
export const adjustDateHeader = async (
88-
headers: Headers,
89-
request: Request,
90-
span: Span,
91-
tracer: Tracer,
92-
) => {
88+
export const adjustDateHeader = async ({
89+
headers,
90+
request,
91+
span,
92+
tracer,
93+
requestContext,
94+
}: {
95+
headers: Headers
96+
request: Request
97+
span: Span
98+
tracer: Tracer
99+
requestContext: RequestContext
100+
}) => {
93101
const cacheState = headers.get('x-nextjs-cache')
94102
const isServedFromCache = cacheState === 'HIT' || cacheState === 'STALE'
95103

@@ -102,28 +110,72 @@ export const adjustDateHeader = async (
102110
return
103111
}
104112
const key = new URL(request.url).pathname
105-
const blobKey = await encodeBlobKey(key)
106-
const blobStore = getDeployStore({ fetch: fetchBeforeNextPatchedIt, consistency: 'strong' })
107-
108-
// TODO: use metadata for this
109-
const { lastModified } = await tracer.startActiveSpan(
110-
'get cache to calculate date header',
111-
async (getBlobForDateSpan) => {
112-
getBlobForDateSpan.setAttributes({
113-
key,
114-
blobKey,
115-
})
116-
const blob = (await blobStore.get(blobKey, { type: 'json' })) ?? {}
117-
118-
getBlobForDateSpan.addEvent(blob ? 'Cache hit' : 'Cache miss')
119-
getBlobForDateSpan.end()
120-
return blob
121-
},
122-
)
113+
114+
let lastModified: number | undefined
115+
if (requestContext.responseCacheGetLastModified) {
116+
lastModified = requestContext.responseCacheGetLastModified
117+
} else {
118+
// this is fallback in case requestContext doesn't contain lastModified
119+
// using fallback indicate problem in the setup as assumption is that for cached responses
120+
// request context would contain lastModified value
121+
// this is not fatal as we have fallback,
122+
// but we want to know about it happening
123+
span.recordException(
124+
new Error('lastModified not found in requestContext, falling back to trying blobs'),
125+
)
126+
span.setAttributes({
127+
severity: 'alert',
128+
warning: true,
129+
})
130+
131+
const blobKey = await encodeBlobKey(key)
132+
const blobStore = getDeployStore({ fetch: fetchBeforeNextPatchedIt, consistency: 'strong' })
133+
134+
// TODO: use metadata for this
135+
lastModified = await tracer.startActiveSpan(
136+
'get cache to calculate date header',
137+
async (getBlobForDateSpan) => {
138+
getBlobForDateSpan.setAttributes({
139+
key,
140+
blobKey,
141+
})
142+
const blob = (await blobStore.get(blobKey, { type: 'json' })) ?? {}
143+
144+
getBlobForDateSpan.addEvent(blob ? 'Cache hit' : 'Cache miss')
145+
getBlobForDateSpan.end()
146+
return blob.lastModified
147+
},
148+
)
149+
}
123150

124151
if (!lastModified) {
152+
// this should never happen as we only execute this code path for cached responses
153+
// and those should always have lastModified value
154+
span.recordException(
155+
new Error(
156+
'lastModified not found in either requestContext or blobs, date header for cached response is not set',
157+
),
158+
)
159+
span.setAttributes({
160+
severity: 'alert',
161+
warning: true,
162+
})
125163
return
126164
}
165+
166+
if (lastModified === 1) {
167+
// epoch 1 time seems to not work well with the CDN
168+
// it causes to produce "Thu, 01 Jan 1970 00:00:00 GMT" date
169+
// header correctly but CDN calculates then AGE to be 0
170+
// causing response to be cached for entire max-age period
171+
// since the first hit
172+
// so instead of using 1 we use 1 year ago
173+
// this is done here instead at build time as it's unclear
174+
// what exactly is edge case in CDN and setting 1 year ago
175+
// from request time seems to work consistently
176+
lastModified = Date.now() - 31536000000
177+
}
178+
127179
const lastModifiedDate = new Date(lastModified)
128180
// Show actual date of the function call in the date header
129181
headers.set('x-nextjs-date', headers.get('date') ?? lastModifiedDate.toUTCString())

0 commit comments

Comments
 (0)