From a16c162be7a6de537efb0f89526dd460e3c6c9fb Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 22 Apr 2025 12:22:06 +0200 Subject: [PATCH 1/7] refactor: move tags handling from cache-handler module to dedicated tags-handler to allow for reuse --- src/run/handlers/cache.cts | 110 ++-------------------- src/run/handlers/tags-handler.cts | 146 ++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 102 deletions(-) create mode 100644 src/run/handlers/tags-handler.cts diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 37ade76962..f89c8144d2 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -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, @@ -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[] @@ -427,70 +423,17 @@ 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`) - }), - ) + + 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() { @@ -501,7 +444,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[] = [], @@ -534,45 +477,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((resolve, reject) => { - const tagManifestPromises: Promise[] = [] - - for (const tag of cacheTags) { - const tagManifestPromise: Promise = this.cacheStore.get( - 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) } } diff --git a/src/run/handlers/tags-handler.cts b/src/run/handlers/tags-handler.cts new file mode 100644 index 0000000000..88bfac8699 --- /dev/null +++ b/src/run/handlers/tags-handler.cts @@ -0,0 +1,146 @@ +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 lastTagRevalidationTimestamp( + tag: string, + cacheStore: MemoizedKeyValueStoreBackedByRegionalBlobStore, +): Promise { + const tagManifest = await cacheStore.get(tag, 'tagManifest.get') + if (!tagManifest) { + return null + } + return tagManifest.revalidatedAt +} + +/** + * Check if any of the tags were invalidated since the given timestamp + */ +export function isAnyTagStale(tags: string[], timestamp: number): Promise { + 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((resolve, reject) => { + const tagManifestPromises: Promise[] = [] + + for (const tag of tags) { + const lastRevalidationTimestampPromise = lastTagRevalidationTimestamp(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[]): void { + const tags = getCacheTagsFromTagOrTags(tagOrTags) + + if (tags.length === 0) { + return + } + + const purgeCachePromise = purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => { + // TODO: add reporting here + getLogger() + .withError(error) + .error(`[NetlifyCacheHandler]: Purging the cache for tags [${tags.join(',')}] failed`) + }) + + getRequestContext()?.trackBackgroundWork(purgeCachePromise) +} + +async function doRevalidateTag(tags: string[]): Promise { + getLogger().withFields({ tags }).debug('NetlifyCacheHandler.revalidateTag') + + if (tags.length === 0) { + return + } + + const data: TagManifest = { + revalidatedAt: Date.now(), + } + + const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' }) + + await Promise.all( + tags.map(async (tag) => { + try { + await 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`) + }) +} + +export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) { + const tags = getCacheTagsFromTagOrTags(tagOrTags) + + const revalidateTagPromise = doRevalidateTag(tags) + + const requestContext = getRequestContext() + if (requestContext) { + requestContext.trackBackgroundWork(revalidateTagPromise) + } + + return revalidateTagPromise +} From b9bec2ebe5d2782f05aa5f75f61f65453bb9534e Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 25 Apr 2025 18:37:59 +0200 Subject: [PATCH 2/7] chore: rename lastTagRevalidationTimestamp to getTagRevalidatedAt --- src/run/handlers/tags-handler.cts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/run/handlers/tags-handler.cts b/src/run/handlers/tags-handler.cts index 88bfac8699..9326ac1814 100644 --- a/src/run/handlers/tags-handler.cts +++ b/src/run/handlers/tags-handler.cts @@ -14,7 +14,7 @@ const purgeCacheUserAgent = `${nextRuntimePkgName}@${nextRuntimePkgVersion}` /** * Get timestamp of the last revalidation for a tag */ -async function lastTagRevalidationTimestamp( +async function getTagRevalidatedAt( tag: string, cacheStore: MemoizedKeyValueStoreBackedByRegionalBlobStore, ): Promise { @@ -47,7 +47,7 @@ export function isAnyTagStale(tags: string[], timestamp: number): Promise[] = [] for (const tag of tags) { - const lastRevalidationTimestampPromise = lastTagRevalidationTimestamp(tag, cacheStore) + const lastRevalidationTimestampPromise = getTagRevalidatedAt(tag, cacheStore) tagManifestPromises.push( lastRevalidationTimestampPromise.then((lastRevalidationTimestamp) => { From 6dece23552c97b1b07e5ab2c9da34607a1eed0ac Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 25 Apr 2025 18:41:50 +0200 Subject: [PATCH 3/7] chore: rename data to tagManifest when upserting revalidation time for less ambiguity --- src/run/handlers/tags-handler.cts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/run/handlers/tags-handler.cts b/src/run/handlers/tags-handler.cts index 9326ac1814..5c81838e20 100644 --- a/src/run/handlers/tags-handler.cts +++ b/src/run/handlers/tags-handler.cts @@ -108,7 +108,7 @@ async function doRevalidateTag(tags: string[]): Promise { return } - const data: TagManifest = { + const tagManifest: TagManifest = { revalidatedAt: Date.now(), } @@ -117,7 +117,7 @@ async function doRevalidateTag(tags: string[]): Promise { await Promise.all( tags.map(async (tag) => { try { - await cacheStore.set(tag, data, 'tagManifest.set') + await cacheStore.set(tag, tagManifest, 'tagManifest.set') } catch (error) { getLogger().withError(error).log(`Failed to update tag manifest for ${tag}`) } From 46e5e5bb2d3fbb1d616a4e4c504767b899ef22fe Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 25 Apr 2025 18:50:03 +0200 Subject: [PATCH 4/7] chore: move cache purging debug log to avoid logging when no tags are to be purged --- src/run/handlers/cache.cts | 2 -- src/run/handlers/tags-handler.cts | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index f89c8144d2..6d68bb050b 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -424,8 +424,6 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { // encode here to deal with non ASCII characters in the key const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}` - getLogger().debug(`Purging CDN cache for: [${tag}]`) - purgeEdgeCache(tag) } } diff --git a/src/run/handlers/tags-handler.cts b/src/run/handlers/tags-handler.cts index 5c81838e20..1edfec3a3d 100644 --- a/src/run/handlers/tags-handler.cts +++ b/src/run/handlers/tags-handler.cts @@ -91,6 +91,8 @@ export function purgeEdgeCache(tagOrTags: string | string[]): void { return } + getLogger().debug(`[NextRuntime] Purging CDN cache for: [${tags}.join(', ')]`) + const purgeCachePromise = purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => { // TODO: add reporting here getLogger() From 9fb72e15536c53d1f86ee562bbe880ee52ebe920 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 25 Apr 2025 18:52:26 +0200 Subject: [PATCH 5/7] chore: adjust debug log prefix as this tag manifest handling will not be specific to response cache handler soon --- src/run/handlers/tags-handler.cts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/run/handlers/tags-handler.cts b/src/run/handlers/tags-handler.cts index 1edfec3a3d..f76231dcfb 100644 --- a/src/run/handlers/tags-handler.cts +++ b/src/run/handlers/tags-handler.cts @@ -97,14 +97,14 @@ export function purgeEdgeCache(tagOrTags: string | string[]): void { // TODO: add reporting here getLogger() .withError(error) - .error(`[NetlifyCacheHandler]: Purging the cache for tags [${tags.join(',')}] failed`) + .error(`[NextRuntime] Purging the cache for tags [${tags.join(',')}] failed`) }) getRequestContext()?.trackBackgroundWork(purgeCachePromise) } -async function doRevalidateTag(tags: string[]): Promise { - getLogger().withFields({ tags }).debug('NetlifyCacheHandler.revalidateTag') +async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise { + getLogger().withFields({ tags }).debug('doRevalidateTagAndPurgeEdgeCache') if (tags.length === 0) { return @@ -121,7 +121,7 @@ async function doRevalidateTag(tags: string[]): Promise { try { await cacheStore.set(tag, tagManifest, 'tagManifest.set') } catch (error) { - getLogger().withError(error).log(`Failed to update tag manifest for ${tag}`) + getLogger().withError(error).log(`[NextRuntime] Failed to update tag manifest for ${tag}`) } }), ) @@ -130,14 +130,14 @@ async function doRevalidateTag(tags: string[]): Promise { // TODO: add reporting here getLogger() .withError(error) - .error(`[NetlifyCacheHandler]: Purging the cache for tags ${tags.join(', ')} failed`) + .error(`[NextRuntime]: Purging the cache for tags ${tags.join(', ')} failed`) }) } export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) { const tags = getCacheTagsFromTagOrTags(tagOrTags) - const revalidateTagPromise = doRevalidateTag(tags) + const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags) const requestContext = getRequestContext() if (requestContext) { From 4ce9230034162242d7fa595d3fecda38998c9442 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 25 Apr 2025 18:53:28 +0200 Subject: [PATCH 6/7] chore: use existing helper purgeEdgeCache instead of calling purgeCache directly to ensure uniform handling and logging --- src/run/handlers/tags-handler.cts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/run/handlers/tags-handler.cts b/src/run/handlers/tags-handler.cts index f76231dcfb..c975962282 100644 --- a/src/run/handlers/tags-handler.cts +++ b/src/run/handlers/tags-handler.cts @@ -126,12 +126,7 @@ async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise { }), ) - await purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => { - // TODO: add reporting here - getLogger() - .withError(error) - .error(`[NextRuntime]: Purging the cache for tags ${tags.join(', ')} failed`) - }) + purgeEdgeCache(tags) } export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) { From b6995a5c0ff2e244c8b7a418f49832b9f40128ca Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 25 Apr 2025 19:35:06 +0200 Subject: [PATCH 7/7] fix: using context.waitUntil after response was already returned is no-op, so need to track it more carefully --- src/run/handlers/cache.cts | 2 +- src/run/handlers/tags-handler.cts | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 6d68bb050b..512c62c994 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -424,7 +424,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { // encode here to deal with non ASCII characters in the key const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}` - purgeEdgeCache(tag) + requestContext?.trackBackgroundWork(purgeEdgeCache(tag)) } } }) diff --git a/src/run/handlers/tags-handler.cts b/src/run/handlers/tags-handler.cts index c975962282..38b350bbb3 100644 --- a/src/run/handlers/tags-handler.cts +++ b/src/run/handlers/tags-handler.cts @@ -84,23 +84,21 @@ function getCacheTagsFromTagOrTags(tagOrTags: string | string[]): string[] { .filter(Boolean) } -export function purgeEdgeCache(tagOrTags: string | string[]): void { +export function purgeEdgeCache(tagOrTags: string | string[]): Promise { const tags = getCacheTagsFromTagOrTags(tagOrTags) if (tags.length === 0) { - return + return Promise.resolve() } getLogger().debug(`[NextRuntime] Purging CDN cache for: [${tags}.join(', ')]`) - const purgeCachePromise = purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => { + return purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => { // TODO: add reporting here getLogger() .withError(error) .error(`[NextRuntime] Purging the cache for tags [${tags.join(',')}] failed`) }) - - getRequestContext()?.trackBackgroundWork(purgeCachePromise) } async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise { @@ -126,7 +124,7 @@ async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise { }), ) - purgeEdgeCache(tags) + await purgeEdgeCache(tags) } export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) {