Skip to content

refactor: move tags handling from cache-handler module to dedicated tags-handler to allow for reuse #2872

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

Merged
merged 8 commits into from
Apr 25, 2025
110 changes: 7 additions & 103 deletions src/run/handlers/cache.cts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@ import { Buffer } from 'node:buffer'
import { join } from 'node:path'
import { join as posixJoin } from 'node:path/posix'

import { purgeCache } from '@netlify/functions'
import { type Span } from '@opentelemetry/api'
import type { PrerenderManifest } from 'next/dist/build/index.js'
import { NEXT_CACHE_TAGS_HEADER } from 'next/dist/lib/constants.js'

import { name as nextRuntimePkgName, version as nextRuntimePkgVersion } from '../../../package.json'
import { type TagManifest } from '../../shared/blob-types.cjs'
import {
type CacheHandlerContext,
type CacheHandlerForMultipleVersions,
Expand All @@ -28,10 +25,9 @@ import {
} from '../storage/storage.cjs'

import { getLogger, getRequestContext } from './request-context.cjs'
import { isAnyTagStale, markTagsAsStaleAndPurgeEdgeCache, purgeEdgeCache } from './tags-handler.cjs'
import { getTracer, recordWarning } from './tracer.cjs'

const purgeCacheUserAgent = `${nextRuntimePkgName}@${nextRuntimePkgVersion}`

export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
options: CacheHandlerContext
revalidatedTags: string[]
Expand Down Expand Up @@ -427,70 +423,15 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
if (requestContext?.didPagesRouterOnDemandRevalidate) {
// encode here to deal with non ASCII characters in the key
const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}`
const tags = tag.split(/,|%2c/gi).filter(Boolean)

if (tags.length === 0) {
return
}

getLogger().debug(`Purging CDN cache for: [${tag}]`)
requestContext.trackBackgroundWork(
purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => {
// TODO: add reporting here
getLogger()
.withError(error)
.error(`[NetlifyCacheHandler]: Purging the cache for tag ${tag} failed`)
}),
)
requestContext?.trackBackgroundWork(purgeEdgeCache(tag))
}
}
})
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async revalidateTag(tagOrTags: string | string[], ...args: any) {
const revalidateTagPromise = this.doRevalidateTag(tagOrTags, ...args)

const requestContext = getRequestContext()
if (requestContext) {
requestContext.trackBackgroundWork(revalidateTagPromise)
}

return revalidateTagPromise
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async doRevalidateTag(tagOrTags: string | string[], ...args: any) {
getLogger().withFields({ tagOrTags, args }).debug('NetlifyCacheHandler.revalidateTag')

const tags = (Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags])
.flatMap((tag) => tag.split(/,|%2c/gi))
.filter(Boolean)

if (tags.length === 0) {
return
}

const data: TagManifest = {
revalidatedAt: Date.now(),
}

await Promise.all(
tags.map(async (tag) => {
try {
await this.cacheStore.set(tag, data, 'tagManifest.set')
} catch (error) {
getLogger().withError(error).log(`Failed to update tag manifest for ${tag}`)
}
}),
)

await purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => {
// TODO: add reporting here
getLogger()
.withError(error)
.error(`[NetlifyCacheHandler]: Purging the cache for tags ${tags.join(', ')} failed`)
})
async revalidateTag(tagOrTags: string | string[]) {
return markTagsAsStaleAndPurgeEdgeCache(tagOrTags)
}

resetRequestCache() {
Expand All @@ -501,7 +442,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
/**
* Checks if a cache entry is stale through on demand revalidated tags
*/
private async checkCacheEntryStaleByTags(
private checkCacheEntryStaleByTags(
cacheEntry: NetlifyCacheHandlerValue,
tags: string[] = [],
softTags: string[] = [],
Expand Down Expand Up @@ -534,45 +475,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
}

// 2. If any in-memory tags don't indicate that any of tags was invalidated
// we will check blob store. Full-route cache and fetch caches share a lot of tags
// but we will only do actual blob read once withing a single request due to cacheStore
// memoization.
// Additionally, we will resolve the promise as soon as we find first
// stale tag, so that we don't wait for all of them to resolve (but keep all
// running in case future `CacheHandler.get` calls would be able to use results).
// "Worst case" scenario is none of tag was invalidated in which case we need to wait
// for all blob store checks to finish before we can be certain that no tag is stale.
return new Promise<boolean>((resolve, reject) => {
const tagManifestPromises: Promise<boolean>[] = []

for (const tag of cacheTags) {
const tagManifestPromise: Promise<TagManifest | null> = this.cacheStore.get<TagManifest>(
tag,
'tagManifest.get',
)

tagManifestPromises.push(
tagManifestPromise.then((tagManifest) => {
if (!tagManifest) {
return false
}
const isStale = tagManifest.revalidatedAt >= (cacheEntry.lastModified || Date.now())
if (isStale) {
resolve(true)
return true
}
return false
}),
)
}

// make sure we resolve promise after all blobs are checked (if we didn't resolve as stale yet)
Promise.all(tagManifestPromises)
.then((tagManifestAreStale) => {
resolve(tagManifestAreStale.some((tagIsStale) => tagIsStale))
})
.catch(reject)
})
// we will check blob store.
return isAnyTagStale(cacheTags, cacheEntry.lastModified)
}
}

Expand Down
141 changes: 141 additions & 0 deletions src/run/handlers/tags-handler.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { purgeCache } from '@netlify/functions'

import { name as nextRuntimePkgName, version as nextRuntimePkgVersion } from '../../../package.json'
import { TagManifest } from '../../shared/blob-types.cjs'
import {
getMemoizedKeyValueStoreBackedByRegionalBlobStore,
MemoizedKeyValueStoreBackedByRegionalBlobStore,
} from '../storage/storage.cjs'

import { getLogger, getRequestContext } from './request-context.cjs'

const purgeCacheUserAgent = `${nextRuntimePkgName}@${nextRuntimePkgVersion}`

/**
* Get timestamp of the last revalidation for a tag
*/
async function getTagRevalidatedAt(
tag: string,
cacheStore: MemoizedKeyValueStoreBackedByRegionalBlobStore,
): Promise<number | null> {
const tagManifest = await cacheStore.get<TagManifest>(tag, 'tagManifest.get')
if (!tagManifest) {
return null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we wanted, we could return a number here as well and simplify the return type of this function to just Promise<number>. Perhaps -1?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pros & cons

returning a number may simplify types and possibly remove the need to do specific null checking, but I think it's actually better to return non-number here exactly to force consumer of this function to consider what should be done when there is no value so it forces to take a second how to handle scenario if there is no tag manifest as this should prevent bugs that are result of oversight if someone would assume there is always a value here

Next.js use cache handler wants to return 0 in cases like that - https://github.com/vercel/next.js/blob/071e31705f4451bdeb8ada4126a0da9c49d11761/packages/next/src/server/lib/cache-handlers/types.ts#L102-L108 so maybe we should follow this, but because this is used not just for new cache handler I'd prefer more verbose handling to ensure that consumers handling don't have potentially incompatible oversights

}
return tagManifest.revalidatedAt
}

/**
* Check if any of the tags were invalidated since the given timestamp
*/
export function isAnyTagStale(tags: string[], timestamp: number): Promise<boolean> {
if (tags.length === 0 || !timestamp) {
return Promise.resolve(false)
}

const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })

// Full-route cache and fetch caches share a lot of tags
// but we will only do actual blob read once withing a single request due to cacheStore
// memoization.
// Additionally, we will resolve the promise as soon as we find first
// stale tag, so that we don't wait for all of them to resolve (but keep all
// running in case future `CacheHandler.get` calls would be able to use results).
// "Worst case" scenario is none of tag was invalidated in which case we need to wait
// for all blob store checks to finish before we can be certain that no tag is stale.
return new Promise<boolean>((resolve, reject) => {
const tagManifestPromises: Promise<boolean>[] = []

for (const tag of tags) {
const lastRevalidationTimestampPromise = getTagRevalidatedAt(tag, cacheStore)

tagManifestPromises.push(
lastRevalidationTimestampPromise.then((lastRevalidationTimestamp) => {
if (!lastRevalidationTimestamp) {
// tag was never revalidated
return false
}
const isStale = lastRevalidationTimestamp >= timestamp
if (isStale) {
// resolve outer promise immediately if any of the tags is stale
resolve(true)
return true
}
return false
}),
)
}

// make sure we resolve promise after all blobs are checked (if we didn't resolve as stale yet)
Promise.all(tagManifestPromises)
.then((tagManifestAreStale) => {
resolve(tagManifestAreStale.some((tagIsStale) => tagIsStale))
})
.catch(reject)
})
}

/**
* Transform a tag or tags into an array of tags and handle white space splitting and encoding
*/
function getCacheTagsFromTagOrTags(tagOrTags: string | string[]): string[] {
return (Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags])
.flatMap((tag) => tag.split(/,|%2c/gi))
.filter(Boolean)
}

export function purgeEdgeCache(tagOrTags: string | string[]): Promise<void> {
const tags = getCacheTagsFromTagOrTags(tagOrTags)

if (tags.length === 0) {
return Promise.resolve()
}

getLogger().debug(`[NextRuntime] Purging CDN cache for: [${tags}.join(', ')]`)

return purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => {
// TODO: add reporting here
getLogger()
.withError(error)
.error(`[NextRuntime] Purging the cache for tags [${tags.join(',')}] failed`)
})
}

async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise<void> {
getLogger().withFields({ tags }).debug('doRevalidateTagAndPurgeEdgeCache')

if (tags.length === 0) {
return
}

const tagManifest: TagManifest = {
revalidatedAt: Date.now(),
}

const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })

await Promise.all(
tags.map(async (tag) => {
try {
await cacheStore.set(tag, tagManifest, 'tagManifest.set')
} catch (error) {
getLogger().withError(error).log(`[NextRuntime] Failed to update tag manifest for ${tag}`)
}
}),
)

await purgeEdgeCache(tags)
}

export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) {
const tags = getCacheTagsFromTagOrTags(tagOrTags)

const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags)

const requestContext = getRequestContext()
if (requestContext) {
requestContext.trackBackgroundWork(revalidateTagPromise)
}

return revalidateTagPromise
}
Loading