Skip to content

Commit 85d4eb9

Browse files
committed
perf: memoize blobs requests in the request scope
1 parent 2f7dee1 commit 85d4eb9

8 files changed

+431
-77
lines changed

package-lock.json

+30-46
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
@@ -76,6 +76,7 @@
7676
"fs-monkey": "^1.0.6",
7777
"get-port": "^7.1.0",
7878
"lambda-local": "^2.2.0",
79+
"lru-cache": "^10.4.3",
7980
"memfs": "^4.9.2",
8081
"mock-require": "^3.0.3",
8182
"msw": "^2.0.7",

src/run/config.ts

+26-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join, resolve } from 'node:path'
55
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
66

77
import { PLUGIN_DIR, RUN_CONFIG } from './constants.js'
8+
import { setInMemoryCacheMaxSizeFromNextConfig } from './regional-blob-store.cjs'
89

910
/**
1011
* Get Next.js config from the build output
@@ -13,10 +14,27 @@ export const getRunConfig = async () => {
1314
return JSON.parse(await readFile(resolve(PLUGIN_DIR, RUN_CONFIG), 'utf-8'))
1415
}
1516

17+
type NextConfigForMultipleVersions = NextConfigComplete & {
18+
experimental: NextConfigComplete['experimental'] & {
19+
// those are pre 14.1.0 options that were moved out of experimental in // https://github.com/vercel/next.js/pull/57953/files#diff-c49c4767e6ed8627e6e1b8f96b141ee13246153f5e9142e1da03450c8e81e96fL311
20+
21+
// https://github.com/vercel/next.js/blob/v14.0.4/packages/next/src/server/config-shared.ts#L182-L183
22+
// custom path to a cache handler to use
23+
incrementalCacheHandlerPath?: string
24+
// https://github.com/vercel/next.js/blob/v14.0.4/packages/next/src/server/config-shared.ts#L207-L212
25+
/**
26+
* In-memory cache size in bytes.
27+
*
28+
* If `isrMemoryCacheSize: 0` disables in-memory caching.
29+
*/
30+
isrMemoryCacheSize?: number
31+
}
32+
}
33+
1634
/**
1735
* Configure the custom cache handler at request time
1836
*/
19-
export const setRunConfig = (config: NextConfigComplete) => {
37+
export const setRunConfig = (config: NextConfigForMultipleVersions) => {
2038
const cacheHandler = join(PLUGIN_DIR, '.netlify/dist/run/handlers/cache.cjs')
2139
if (!existsSync(cacheHandler)) {
2240
throw new Error(`Cache handler not found at ${cacheHandler}`)
@@ -25,15 +43,17 @@ export const setRunConfig = (config: NextConfigComplete) => {
2543
// set the path to the cache handler
2644
config.experimental = {
2745
...config.experimental,
28-
// @ts-expect-error incrementalCacheHandlerPath was removed from config type
29-
// but we still need to set it for older Next.js versions
46+
// Before Next.js 14.1.0 path to the cache handler was in experimental section, see NextConfigForMultipleVersions type
3047
incrementalCacheHandlerPath: cacheHandler,
3148
}
3249

33-
// Next.js 14.1.0 moved the cache handler from experimental to stable
34-
// https://github.com/vercel/next.js/pull/57953/files#diff-c49c4767e6ed8627e6e1b8f96b141ee13246153f5e9142e1da03450c8e81e96fL311
50+
// Next.js 14.1.0 moved the cache handler from experimental to stable, see NextConfigForMultipleVersions type
3551
config.cacheHandler = cacheHandler
36-
config.cacheMaxMemorySize = 0
52+
53+
// honor the in-memory cache size from next.config (either one set by user or Next.js default)
54+
setInMemoryCacheMaxSizeFromNextConfig(
55+
config.cacheMaxMemorySize ?? config.experimental?.isrMemoryCacheSize,
56+
)
3757

3858
// set config
3959
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config)

src/run/handlers/cache.cts

+9-17
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,18 @@ import {
3030
import { getLogger, getRequestContext } from './request-context.cjs'
3131
import { getTracer, recordWarning } from './tracer.cjs'
3232

33-
type TagManifestBlobCache = Record<string, Promise<TagManifest | null>>
34-
3533
const purgeCacheUserAgent = `${nextRuntimePkgName}@${nextRuntimePkgVersion}`
3634

3735
export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
3836
options: CacheHandlerContext
3937
revalidatedTags: string[]
4038
cacheStore: MemoizedKeyValueStoreBackedByRegionalBlobStore
4139
tracer = getTracer()
42-
tagManifestsFetchedFromBlobStoreInCurrentRequest: TagManifestBlobCache
4340

4441
constructor(options: CacheHandlerContext) {
4542
this.options = options
4643
this.revalidatedTags = options.revalidatedTags
4744
this.cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })
48-
this.tagManifestsFetchedFromBlobStoreInCurrentRequest = {}
4945
}
5046

5147
private getTTL(blob: NetlifyCacheHandlerValue) {
@@ -469,7 +465,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
469465
}
470466

471467
resetRequestCache() {
472-
this.tagManifestsFetchedFromBlobStoreInCurrentRequest = {}
468+
// no-op because in-memory cache is scoped to requests and not global
469+
// see getRequestSpecificInMemoryCache
473470
}
474471

475472
/**
@@ -508,10 +505,9 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
508505
}
509506

510507
// 2. If any in-memory tags don't indicate that any of tags was invalidated
511-
// we will check blob store, but memoize results for duration of current request
512-
// so that we only check blob store once per tag within a single request
513-
// full-route cache and fetch caches share a lot of tags so this might save
514-
// some roundtrips to the blob store.
508+
// we will check blob store. Full-route cache and fetch caches share a lot of tags
509+
// but we will only do actual blob read once withing a single request due to cacheStore
510+
// memoization.
515511
// Additionally, we will resolve the promise as soon as we find first
516512
// stale tag, so that we don't wait for all of them to resolve (but keep all
517513
// running in case future `CacheHandler.get` calls would be able to use results).
@@ -521,14 +517,10 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
521517
const tagManifestPromises: Promise<boolean>[] = []
522518

523519
for (const tag of cacheTags) {
524-
let tagManifestPromise: Promise<TagManifest | null> =
525-
this.tagManifestsFetchedFromBlobStoreInCurrentRequest[tag]
526-
527-
if (!tagManifestPromise) {
528-
tagManifestPromise = this.cacheStore.get<TagManifest>(tag, 'tagManifest.get')
529-
530-
this.tagManifestsFetchedFromBlobStoreInCurrentRequest[tag] = tagManifestPromise
531-
}
520+
const tagManifestPromise: Promise<TagManifest | null> = this.cacheStore.get<TagManifest>(
521+
tag,
522+
'tagManifest.get',
523+
)
532524

533525
tagManifestPromises.push(
534526
tagManifestPromise.then((tagManifest) => {

src/run/handlers/request-context.cts

+14-4
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,22 @@ export type RequestContext = {
3939
*/
4040
backgroundWorkPromise: Promise<unknown>
4141
logger: SystemLogger
42+
requestID: string
4243
}
4344

4445
type RequestContextAsyncLocalStorage = AsyncLocalStorage<RequestContext>
46+
const REQUEST_CONTEXT_GLOBAL_KEY = Symbol.for('nf-request-context-async-local-storage')
47+
const REQUEST_COUNTER_KEY = Symbol.for('nf-request-counter')
48+
const extendedGlobalThis = globalThis as typeof globalThis & {
49+
[REQUEST_CONTEXT_GLOBAL_KEY]?: RequestContextAsyncLocalStorage
50+
[REQUEST_COUNTER_KEY]?: number
51+
}
52+
53+
function getFallbackRequestID() {
54+
const requestNumber = extendedGlobalThis[REQUEST_COUNTER_KEY] ?? 0
55+
extendedGlobalThis[REQUEST_COUNTER_KEY] = requestNumber + 1
56+
return `#${requestNumber}`
57+
}
4558

4659
export function createRequestContext(request?: Request, context?: FutureContext): RequestContext {
4760
const backgroundWorkPromises: Promise<unknown>[] = []
@@ -72,10 +85,10 @@ export function createRequestContext(request?: Request, context?: FutureContext)
7285
return Promise.allSettled(backgroundWorkPromises)
7386
},
7487
logger,
88+
requestID: request?.headers.get('x-nf-request-id') ?? getFallbackRequestID(),
7589
}
7690
}
7791

78-
const REQUEST_CONTEXT_GLOBAL_KEY = Symbol.for('nf-request-context-async-local-storage')
7992
let requestContextAsyncLocalStorage: RequestContextAsyncLocalStorage | undefined
8093
function getRequestContextAsyncLocalStorage() {
8194
if (requestContextAsyncLocalStorage) {
@@ -85,9 +98,6 @@ function getRequestContextAsyncLocalStorage() {
8598
// AsyncLocalStorage in the module scope, because it will be different for each
8699
// copy - so first time an instance of this module is used, we store AsyncLocalStorage
87100
// in global scope and reuse it for all subsequent calls
88-
const extendedGlobalThis = globalThis as typeof globalThis & {
89-
[REQUEST_CONTEXT_GLOBAL_KEY]?: RequestContextAsyncLocalStorage
90-
}
91101
if (extendedGlobalThis[REQUEST_CONTEXT_GLOBAL_KEY]) {
92102
return extendedGlobalThis[REQUEST_CONTEXT_GLOBAL_KEY]
93103
}

0 commit comments

Comments
 (0)