Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 32b550d

Browse files
committedApr 25, 2025
feat: initial implementation
1 parent 9a4dda7 commit 32b550d

File tree

3 files changed

+314
-57
lines changed

3 files changed

+314
-57
lines changed
 

‎src/run/handlers/server.ts

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { setupWaitUntil } from './wait-until.cjs'
2525
setFetchBeforeNextPatchedIt(globalThis.fetch)
2626
// configure some globals that Next.js make use of before we start importing any Next.js code
2727
// as some globals are consumed at import time
28+
// TODO: only call this if Next.js version is using CacheHandlerV2 as we don't have V1 compatible implementation
2829
configureUseCacheHandlers()
2930
setupWaitUntil()
3031

‎src/run/handlers/tags-handler.cts

+21
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@ async function lastTagRevalidationTimestamp(
2525
return tagManifest.revalidatedAt
2626
}
2727

28+
/**
29+
* Get the most recent revalidation timestamp for a list of tags
30+
*/
31+
export async function getMostRecentTagRevalidationTimestamp(tags: string[]) {
32+
if (tags.length === 0) {
33+
return 0
34+
}
35+
36+
const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })
37+
38+
const timestampsOrNulls = await Promise.all(
39+
tags.map((tag) => lastTagRevalidationTimestamp(tag, cacheStore)),
40+
)
41+
42+
const timestamps = timestampsOrNulls.filter((timestamp) => timestamp !== null)
43+
if (timestamps.length === 0) {
44+
return 0
45+
}
46+
return Math.max(...timestamps)
47+
}
48+
2849
/**
2950
* Check if any of the tags were invalidated since the given timestamp
3051
*/

‎src/run/handlers/use-cache-handler.ts

+292-57
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,324 @@
1-
import defaultCacheHandlerImport from 'next-next/dist/server/lib/cache-handlers/default.js'
1+
import { Buffer } from 'node:buffer'
2+
3+
import { LRUCache } from 'lru-cache'
24
import type {
35
CacheEntry,
46
CacheHandlerV2 as CacheHandler,
57
Timestamp,
68
} from 'next-next/dist/server/lib/cache-handlers/types.js'
79

8-
import { getRequestContext } from './request-context.cjs'
10+
import { getLogger, getRequestContext } from './request-context.cjs'
11+
import {
12+
getMostRecentTagRevalidationTimestamp,
13+
isAnyTagStale,
14+
markTagsAsStaleAndPurgeEdgeCache,
15+
} from './tags-handler.cjs'
16+
import { getTracer } from './tracer.cjs'
917

10-
const defaultCacheHandler = defaultCacheHandlerImport.default
18+
// copied from default implementation
19+
// packages/next/src/server/lib/cache-handlers/default.ts
20+
type PrivateCacheEntry = {
21+
entry: CacheEntry
1122

12-
export const NetlifyDefaultUseCacheHandler = {
13-
get: function (cacheKey: string): Promise<undefined | CacheEntry> {
14-
console.log('NetlifyDefaultUseCacheHandler::get start', { cacheKey })
15-
return defaultCacheHandler.get(cacheKey).then(async (entry) => {
16-
if (!entry) {
17-
console.log('NetlifyDefaultUseCacheHandler::get MISS', { cacheKey })
18-
return entry
19-
}
23+
// For the default cache we store errored cache
24+
// entries and allow them to be used up to 3 times
25+
// after that we want to dispose it and try for fresh
2026

21-
const [cloneStream, newSaved] = entry.value.tee()
22-
entry.value = newSaved
27+
// If an entry is errored we return no entry
28+
// three times so that we retry hitting origin (MISS)
29+
// and then if it still fails to set after the third we
30+
// return the errored content and use expiration of
31+
// Math.min(30, entry.expiration)
2332

24-
const [returnStream, debugStream] = cloneStream.tee()
33+
isErrored: boolean // pieh: this doesn't seem to be actually used in the default implementation
34+
errorRetryCount: number // pieh: this doesn't seem to be actually used in the default implementation
2535

26-
const text = await new Response(debugStream).text()
27-
console.log('NetlifyDefaultUseCacheHandler::get HIT', { cacheKey, entry, text })
36+
// compute size on set since we need to read size
37+
// of the ReadableStream for LRU evicting
38+
size: number
39+
}
2840

29-
return {
30-
...entry,
31-
value: returnStream,
32-
}
33-
})
41+
type CacheHandleLRUCache = LRUCache<string, PrivateCacheEntry>
42+
type PendingSets = Map<string, Promise<void>>
43+
44+
const LRU_CACHE_GLOBAL_KEY = Symbol.for('nf-use-cache-handler-lru-cache')
45+
const PENDING_SETS_GLOBAL_KEY = Symbol.for('nf-use-cache-handler-pending-sets')
46+
const cacheHandlersSymbol = Symbol.for('@next/cache-handlers')
47+
const extendedGlobalThis = globalThis as typeof globalThis & {
48+
// Used by Next Runtime to ensure we have single instance of
49+
// - LRUCache
50+
// - pending sets
51+
// even if this module gets copied multiple times
52+
[LRU_CACHE_GLOBAL_KEY]?: CacheHandleLRUCache
53+
[PENDING_SETS_GLOBAL_KEY]?: PendingSets
54+
55+
// Used by Next.js to provide implementation of cache handlers
56+
[cacheHandlersSymbol]?: {
57+
RemoteCache?: CacheHandler
58+
DefaultCache?: CacheHandler
59+
}
60+
}
61+
62+
function getLRUCache(): CacheHandleLRUCache {
63+
if (extendedGlobalThis[LRU_CACHE_GLOBAL_KEY]) {
64+
return extendedGlobalThis[LRU_CACHE_GLOBAL_KEY]
65+
}
66+
67+
const lruCache = new LRUCache<string, PrivateCacheEntry>({
68+
max: 1000,
69+
maxSize: 50 * 1024 * 1024, // same as hardcoded default in Next.js
70+
sizeCalculation: (value) => value.size,
71+
})
72+
73+
extendedGlobalThis[LRU_CACHE_GLOBAL_KEY] = lruCache
74+
75+
return lruCache
76+
}
77+
78+
function getPendingSets(): PendingSets {
79+
if (extendedGlobalThis[PENDING_SETS_GLOBAL_KEY]) {
80+
return extendedGlobalThis[PENDING_SETS_GLOBAL_KEY]
81+
}
82+
83+
const pendingSets = new Map()
84+
extendedGlobalThis[PENDING_SETS_GLOBAL_KEY] = pendingSets
85+
return pendingSets
86+
}
87+
88+
const debugLog = (...args: unknown[]) => console.log(new Date().toISOString(), ...args)
89+
90+
// eslint-disable-next-line @typescript-eslint/no-empty-function
91+
const tmpResolvePendingBeforeCreatingAPromise = () => {}
92+
93+
const WIPNetlifyDefaultUseCacheHandler = {
94+
get(cacheKey: string): ReturnType<CacheHandler['get']> {
95+
return getTracer().withActiveSpan(
96+
'DefaultUseCacheHandler.get',
97+
async (span): ReturnType<CacheHandler['get']> => {
98+
span.setAttributes({
99+
cacheKey,
100+
})
101+
102+
const pendingPromise = getPendingSets().get(cacheKey)
103+
if (pendingPromise) {
104+
await pendingPromise
105+
}
106+
107+
const privateEntry = getLRUCache().get(cacheKey)
108+
if (!privateEntry) {
109+
span.setAttributes({
110+
cacheStatus: 'miss',
111+
})
112+
return undefined
113+
}
114+
115+
const { entry } = privateEntry
116+
const ttl = (entry.timestamp + entry.revalidate * 1000 - Date.now()) / 1000
117+
if (ttl < 0) {
118+
// In-memory caches should expire after revalidate time because it is
119+
// unlikely that a new entry will be able to be used before it is dropped
120+
// from the cache.
121+
span.setAttributes({
122+
cacheStatus: 'expired, discarded',
123+
ttl,
124+
})
125+
return undefined
126+
}
127+
128+
if (await isAnyTagStale(entry.tags, entry.timestamp)) {
129+
span.setAttributes({
130+
cacheStatus: 'stale tag, discarded',
131+
ttl,
132+
})
133+
return undefined
134+
}
135+
136+
// returning entry will cause stream to be consumed
137+
// so we need to clone it first, so in-memory cache can
138+
// be used again
139+
const [returnStream, newSaved] = entry.value.tee()
140+
entry.value = newSaved
141+
142+
span.setAttributes({
143+
cacheStatus: 'hit',
144+
ttl,
145+
})
146+
147+
return {
148+
...entry,
149+
value: returnStream,
150+
}
151+
},
152+
)
34153
},
35-
set: async function (cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void> {
36-
console.log('NetlifyDefaultUseCacheHandler::set start', { cacheKey })
37-
const entry = await pendingEntry
38-
const [storeStream, debugStream] = entry.value.tee()
154+
set(cacheKey: string, pendingEntry: Promise<CacheEntry>): ReturnType<CacheHandler['set']> {
155+
return getTracer().withActiveSpan(
156+
'DefaultUseCacheHandler.set',
157+
async (span): ReturnType<CacheHandler['set']> => {
158+
span.setAttributes({
159+
cacheKey,
160+
})
161+
162+
let resolvePending: () => void = tmpResolvePendingBeforeCreatingAPromise
163+
const pendingPromise = new Promise<void>((resolve) => {
164+
resolvePending = resolve
165+
})
166+
167+
const pendingSets = getPendingSets()
168+
169+
pendingSets.set(cacheKey, pendingPromise)
39170

40-
const toSetEntry = Promise.resolve({
41-
...entry,
42-
value: storeStream,
43-
})
171+
const entry = await pendingEntry
44172

45-
const text = await new Response(debugStream).text()
173+
span.setAttributes({
174+
cacheKey,
175+
})
46176

47-
console.log('NetlifyDefaultUseCacheHandler::set awaited pending entry', {
48-
cacheKey,
49-
entry,
50-
text,
51-
})
177+
let size = 0
178+
try {
179+
const [value, clonedValue] = entry.value.tee()
180+
entry.value = value
181+
const reader = clonedValue.getReader()
52182

53-
const setPromise = defaultCacheHandler.set(cacheKey, toSetEntry).then(() => {
54-
console.log('NetlifyDefaultUseCacheHandler::set finish', { cacheKey })
55-
})
183+
for (let chunk; !(chunk = await reader.read()).done; ) {
184+
size += Buffer.from(chunk.value).byteLength
185+
}
56186

57-
getRequestContext()?.trackBackgroundWork(setPromise)
187+
span.setAttributes({
188+
tags: entry.tags,
189+
timestamp: entry.timestamp,
190+
revalidate: entry.revalidate,
191+
expire: entry.expire,
192+
})
58193

59-
return setPromise
194+
getLRUCache().set(cacheKey, {
195+
entry,
196+
isErrored: false,
197+
errorRetryCount: 0,
198+
size,
199+
})
200+
} catch (error) {
201+
getLogger().withError(error).error('UseCacheHandler.set error')
202+
} finally {
203+
resolvePending()
204+
pendingSets.delete(cacheKey)
205+
}
206+
},
207+
)
60208
},
61-
refreshTags: function (): Promise<void> {
62-
console.log('NetlifyDefaultUseCacheHandler::refreshTags')
63-
return defaultCacheHandler.refreshTags()
209+
async refreshTags(): Promise<void> {
210+
// we check tags on demand, so we don't need to do anything here
211+
// additionally this is blocking and we do need to check tags in
212+
// persisted storage, so if we would maintain in-memory tags manifests
213+
// we would need to check more tags than current request needs
214+
// while blocking pipeline
64215
},
65-
getExpiration: function (...tags: string[]): Promise<Timestamp> {
66-
console.log('NetlifyDefaultUseCacheHandler::getExpiration start', { tags })
67-
return defaultCacheHandler.getExpiration(...tags).then((expiration) => {
68-
console.log('NetlifyDefaultUseCacheHandler::getExpiration finish', { tags, expiration })
69-
return expiration
70-
})
216+
getExpiration: function (...tags: string[]): ReturnType<CacheHandler['getExpiration']> {
217+
return getTracer().withActiveSpan(
218+
'DefaultUseCacheHandler.getExpiration',
219+
async (span): ReturnType<CacheHandler['getExpiration']> => {
220+
span.setAttributes({
221+
tags,
222+
})
223+
224+
const expiration = await getMostRecentTagRevalidationTimestamp(tags)
225+
226+
span.setAttributes({
227+
expiration,
228+
})
229+
230+
return expiration
231+
},
232+
)
71233
},
72-
expireTags: function (...tags: string[]): Promise<void> {
73-
console.log('NetlifyDefaultUseCacheHandler::expireTags', { tags })
74-
return defaultCacheHandler.expireTags(...tags)
234+
expireTags(...tags: string[]): ReturnType<CacheHandler['expireTags']> {
235+
return getTracer().withActiveSpan(
236+
'DefaultUseCacheHandler.expireTags',
237+
async (span): ReturnType<CacheHandler['expireTags']> => {
238+
span.setAttributes({
239+
tags,
240+
})
241+
242+
await markTagsAsStaleAndPurgeEdgeCache(tags)
243+
},
244+
)
75245
},
76246
} satisfies CacheHandler
77247

78-
const cacheHandlersSymbol = Symbol.for('@next/cache-handlers')
248+
function createDebugUseCacheHandler(cacheHandlerToDebug: CacheHandler): CacheHandler {
249+
return {
250+
get: function (cacheKey: string): Promise<undefined | CacheEntry> {
251+
debugLog('NetlifyDefaultUseCacheHandler::get start', { cacheKey })
252+
return cacheHandlerToDebug.get(cacheKey).then(async (entry) => {
253+
if (!entry) {
254+
debugLog('NetlifyDefaultUseCacheHandler::get MISS', { cacheKey })
255+
return entry
256+
}
79257

80-
const extendedGlobalThis = globalThis as typeof globalThis & {
81-
[cacheHandlersSymbol]?: {
82-
RemoteCache?: CacheHandler
83-
DefaultCache?: CacheHandler
258+
const [cloneStream, newSaved] = entry.value.tee()
259+
entry.value = newSaved
260+
261+
const [returnStream, debugStream] = cloneStream.tee()
262+
263+
const text = await new Response(debugStream).text()
264+
debugLog('NetlifyDefaultUseCacheHandler::get HIT', { cacheKey, entry, text })
265+
266+
return {
267+
...entry,
268+
value: returnStream,
269+
}
270+
})
271+
},
272+
set: async function (cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void> {
273+
debugLog('NetlifyDefaultUseCacheHandler::set start', { cacheKey })
274+
const entry = await pendingEntry
275+
const [storeStream, debugStream] = entry.value.tee()
276+
277+
const toSetEntry = Promise.resolve({
278+
...entry,
279+
value: storeStream,
280+
})
281+
282+
const text = await new Response(debugStream).text()
283+
284+
debugLog('NetlifyDefaultUseCacheHandler::set awaited pending entry', {
285+
cacheKey,
286+
entry,
287+
text,
288+
})
289+
290+
const setPromise = cacheHandlerToDebug.set(cacheKey, toSetEntry).then(() => {
291+
debugLog('NetlifyDefaultUseCacheHandler::set finish', { cacheKey })
292+
})
293+
294+
getRequestContext()?.trackBackgroundWork(setPromise)
295+
296+
return setPromise
297+
},
298+
refreshTags: function (): Promise<void> {
299+
debugLog('NetlifyDefaultUseCacheHandler::refreshTags start')
300+
return cacheHandlerToDebug.refreshTags().then(() => {
301+
debugLog('NetlifyDefaultUseCacheHandler::refreshTags end')
302+
})
303+
},
304+
getExpiration: function (...tags: string[]): Promise<Timestamp> {
305+
debugLog('NetlifyDefaultUseCacheHandler::getExpiration start', { tags })
306+
return cacheHandlerToDebug.getExpiration(...tags).then((expiration) => {
307+
debugLog('NetlifyDefaultUseCacheHandler::getExpiration finish', { tags, expiration })
308+
return expiration
309+
})
310+
},
311+
expireTags: function (...tags: string[]): Promise<void> {
312+
debugLog('NetlifyDefaultUseCacheHandler::expireTags', { tags })
313+
return cacheHandlerToDebug.expireTags(...tags)
314+
},
84315
}
85316
}
86317

318+
export const NetlifyDefaultUseCacheHandler = createDebugUseCacheHandler(
319+
WIPNetlifyDefaultUseCacheHandler,
320+
)
321+
87322
export function configureUseCacheHandlers() {
88323
extendedGlobalThis[cacheHandlersSymbol] = {
89324
DefaultCache: NetlifyDefaultUseCacheHandler,

0 commit comments

Comments
 (0)
Please sign in to comment.