Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions src/run/handlers/cache.cts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import {
} from '../storage/storage.cjs'

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

let memoizedPrerenderManifest: PrerenderManifest
Expand Down Expand Up @@ -290,19 +295,26 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
return null
}

const staleByTags = await this.checkCacheEntryStaleByTags(
const { stale: staleByTags, expired: expiredByTags } = await this.checkCacheEntryStaleByTags(
blob,
context.tags,
context.softTags,
)

if (staleByTags) {
span.addEvent('Stale', { staleByTags, key, ttl })
if (expiredByTags) {
span.addEvent('Expired', { expiredByTags, key, ttl })
return null
}

this.captureResponseCacheLastModified(blob, key, span)

if (staleByTags) {
span.addEvent('Stale', { staleByTags, key, ttl })
// note that we modify this after we capture last modified to ensure that Age is correct
// but we still let Next.js know that entry is stale
blob.lastModified = -1 // indicate that the entry is stale
}

// Next sets a kind/kindHint and fetchUrl for data requests, however fetchUrl was found to be most reliable across versions
const isDataRequest = Boolean(context.fetchUrl)
if (!isDataRequest) {
Expand Down Expand Up @@ -477,8 +489,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
})
}

async revalidateTag(tagOrTags: string | string[]) {
return markTagsAsStaleAndPurgeEdgeCache(tagOrTags)
async revalidateTag(tagOrTags: string | string[], durations?: { expire?: number }) {
return markTagsAsStaleAndPurgeEdgeCache(tagOrTags, durations)
}

resetRequestCache() {
Expand All @@ -493,7 +505,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
cacheEntry: NetlifyCacheHandlerValue,
tags: string[] = [],
softTags: string[] = [],
) {
): TagStaleOrExpired | Promise<TagStaleOrExpired> {
let cacheTags: string[] = []

if (cacheEntry.value?.kind === 'FETCH') {
Expand All @@ -508,22 +520,28 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
cacheTags =
(cacheEntry.value.headers?.[NEXT_CACHE_TAGS_HEADER] as string)?.split(/,|%2c/gi) || []
} else {
return false
return {
stale: false,
expired: false,
}
}

// 1. Check if revalidateTags array passed from Next.js contains any of cacheEntry tags
if (this.revalidatedTags && this.revalidatedTags.length !== 0) {
// TODO: test for this case
for (const tag of this.revalidatedTags) {
if (cacheTags.includes(tag)) {
return true
return {
stale: true,
expired: true,
}
}
}
}

// 2. If any in-memory tags don't indicate that any of tags was invalidated
// we will check blob store.
return isAnyTagStale(cacheTags, cacheEntry.lastModified)
return isAnyTagStaleOrExpired(cacheTags, cacheEntry.lastModified)
}
}

Expand Down
132 changes: 93 additions & 39 deletions src/run/handlers/tags-handler.cts
Original file line number Diff line number Diff line change
Expand Up @@ -11,47 +11,55 @@ import { getLogger, getRequestContext } from './request-context.cjs'

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

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

/**
* Get the most recent revalidation timestamp for a list of tags
*/
export async function getMostRecentTagRevalidationTimestamp(tags: string[]) {
export async function getMostRecentTagExpirationTimestamp(tags: string[]) {
if (tags.length === 0) {
return 0
}

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

const timestampsOrNulls = await Promise.all(
tags.map((tag) => getTagRevalidatedAt(tag, cacheStore)),
)
const timestampsOrNulls = await Promise.all(tags.map((tag) => getTagManifest(tag, cacheStore)))

const timestamps = timestampsOrNulls.filter((timestamp) => timestamp !== null)
if (timestamps.length === 0) {
const expirationTimestamps = timestampsOrNulls
.filter((timestamp) => timestamp !== null)
.map((manifest) => manifest.expiredAt)
if (expirationTimestamps.length === 0) {
return 0
}
return Math.max(...timestamps)
return Math.max(...expirationTimestamps)
}

export type TagStaleOrExpired =
// FRESH
| { stale: false; expired: false }
// STALE
| { stale: true; expired: false; expireAt: number }
// EXPIRED (should be treated similarly to MISS)
| { stale: true; expired: true }

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

const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })
Expand All @@ -60,37 +68,74 @@ export function isAnyTagStale(tags: string[], timestamp: number): Promise<boolea
// 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
// expired 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>[] = []
// "Worst case" scenario is none of tag was expired in which case we need to wait
// for all blob store checks to finish before we can be certain that no tag is expired.
return new Promise<TagStaleOrExpired>((resolve, reject) => {
const tagManifestPromises: Promise<TagStaleOrExpired>[] = []

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

tagManifestPromises.push(
lastRevalidationTimestampPromise.then((lastRevalidationTimestamp) => {
if (!lastRevalidationTimestamp) {
tagManifestPromise.then((tagManifest) => {
if (!tagManifest) {
// tag was never revalidated
return false
return { stale: false, expired: false }
}
const stale = tagManifest.staleAt >= timestamp
const expired = tagManifest.expiredAt >= timestamp && tagManifest.expiredAt <= Date.now()

if (expired && stale) {
const expiredResult: TagStaleOrExpired = {
stale,
expired,
}
// resolve outer promise immediately if any of the tags is expired
resolve(expiredResult)
return expiredResult
}
const isStale = lastRevalidationTimestamp >= timestamp
if (isStale) {
// resolve outer promise immediately if any of the tags is stale
resolve(true)
return true

if (stale) {
const staleResult: TagStaleOrExpired = {
stale,
expired,
expireAt: tagManifest.expiredAt,
}
return staleResult
}
return false
return { stale: false, expired: false }
}),
)
}

// make sure we resolve promise after all blobs are checked (if we didn't resolve as stale yet)
// make sure we resolve promise after all blobs are checked (if we didn't resolve as expired yet)
Promise.all(tagManifestPromises)
.then((tagManifestAreStale) => {
resolve(tagManifestAreStale.some((tagIsStale) => tagIsStale))
.then((tagManifestsAreStaleOrExpired) => {
let result: TagStaleOrExpired = { stale: false, expired: false }

for (const tagResult of tagManifestsAreStaleOrExpired) {
if (tagResult.expired) {
// if any of the tags is expired, the whole thing is expired
result = tagResult
break
}

if (tagResult.stale) {
result = {
stale: true,
expired: false,
expireAt:
// make sure to use expireAt that is lowest of all tags
result.stale && !result.expired && typeof result.expireAt === 'number'
? Math.min(result.expireAt, tagResult.expireAt)
: tagResult.expireAt,
}
}
}

resolve(result)
})
.catch(reject)
})
Expand Down Expand Up @@ -122,15 +167,21 @@ export function purgeEdgeCache(tagOrTags: string | string[]): Promise<void> {
})
}

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

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

const now = Date.now()

const tagManifest: TagManifest = {
revalidatedAt: Date.now(),
staleAt: now,
expiredAt: now + (durations?.expire ? durations.expire * 1000 : 0),
}

const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore({ consistency: 'strong' })
Expand All @@ -148,10 +199,13 @@ async function doRevalidateTagAndPurgeEdgeCache(tags: string[]): Promise<void> {
await purgeEdgeCache(tags)
}

export function markTagsAsStaleAndPurgeEdgeCache(tagOrTags: string | string[]) {
export function markTagsAsStaleAndPurgeEdgeCache(
tagOrTags: string | string[],
durations?: { expire?: number },
) {
const tags = getCacheTagsFromTagOrTags(tagOrTags)

const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags)
const revalidateTagPromise = doRevalidateTagAndPurgeEdgeCache(tags, durations)

const requestContext = getRequestContext()
if (requestContext) {
Expand Down
10 changes: 6 additions & 4 deletions src/run/handlers/use-cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import type {

import { getLogger } from './request-context.cjs'
import {
getMostRecentTagRevalidationTimestamp,
isAnyTagStale,
getMostRecentTagExpirationTimestamp,
isAnyTagStaleOrExpired,
markTagsAsStaleAndPurgeEdgeCache,
} from './tags-handler.cjs'
import { getTracer } from './tracer.cjs'
Expand Down Expand Up @@ -127,7 +127,9 @@ export const NetlifyDefaultUseCacheHandler = {
return undefined
}

if (await isAnyTagStale(entry.tags, entry.timestamp)) {
const { stale } = await isAnyTagStaleOrExpired(entry.tags, entry.timestamp)

if (stale) {
getLogger()
.withFields({ cacheKey, ttl, status: 'STALE BY TAG' })
.debug(`[NetlifyDefaultUseCacheHandler] get result`)
Expand Down Expand Up @@ -229,7 +231,7 @@ export const NetlifyDefaultUseCacheHandler = {
tags,
})

const expiration = await getMostRecentTagRevalidationTimestamp(tags)
const expiration = await getMostRecentTagExpirationTimestamp(tags)

getLogger()
.withFields({ tags, expiration })
Expand Down
19 changes: 15 additions & 4 deletions src/shared/blob-types.cts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { type NetlifyCacheHandlerValue } from './cache-types.cjs'

export type TagManifest = { revalidatedAt: number }
export type TagManifest = {
/**
* Timestamp when tag was revalidated. Used to determine if a tag is stale.
*/
staleAt: number
/**
* Timestamp when tagged cache entry should no longer serve stale content.
*/
expiredAt: number
}

export type HtmlBlob = {
html: string
Expand All @@ -13,9 +22,11 @@ export const isTagManifest = (value: BlobType): value is TagManifest => {
return (
typeof value === 'object' &&
value !== null &&
'revalidatedAt' in value &&
typeof value.revalidatedAt === 'number' &&
Object.keys(value).length === 1
'staleAt' in value &&
typeof value.staleAt === 'number' &&
'expiredAt' in value &&
typeof value.expiredAt === 'number' &&
Object.keys(value).length === 2
)
}

Expand Down
4 changes: 2 additions & 2 deletions src/shared/blob-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { BlobType, HtmlBlob, isHtmlBlob, isTagManifest, TagManifest } from './bl

describe('isTagManifest', () => {
it(`returns true for TagManifest instance`, () => {
const value: TagManifest = { revalidatedAt: 0 }
const value: TagManifest = { staleAt: 0, expiredAt: 0 }
expect(isTagManifest(value)).toBe(true)
})

Expand All @@ -21,7 +21,7 @@ describe('isHtmlBlob', () => {
})

it(`returns false for non-HtmlBlob instance`, () => {
const value: BlobType = { revalidatedAt: 0 }
const value: BlobType = { staleAt: 0, expiredAt: 0 }
expect(isHtmlBlob(value)).toBe(false)
})
})
15 changes: 15 additions & 0 deletions tests/fixtures/update-tag/app/api/revalidate-tag/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'
import { revalidateTag } from 'next/cache'

export async function GET(request: NextRequest) {
const url = new URL(request.url)
const tagToRevalidate = url.searchParams.get('tag') ?? 'collection'
const expire = url.searchParams.has('expire') ? parseInt(url.searchParams.get('expire')) : 0

console.log(`Revalidating tag: ${tagToRevalidate}, expire: ${expire}`)

revalidateTag(tagToRevalidate, { expire })
return NextResponse.json({ revalidated: true, now: new Date().toISOString() })
}

export const dynamic = 'force-dynamic'
Loading
Loading