Fix cache revalidation in KV and output stats on revalidation (#231)

* Store setAt on cache meta and output stats on revalidation

* Delete from KV cache

* test more

* Improve

* Format

* Store the key in metadata

* Avoid storing too much in KV

* Don't put tag on resolve of url
This commit is contained in:
Samy Pessé
2024-03-11 12:30:26 +00:00
committed by GitHub
parent ea7ff33f3a
commit 56a9d5601b
7 changed files with 89 additions and 69 deletions
@@ -25,10 +25,11 @@ export async function POST(req: NextRequest) {
);
}
const result = await revalidateTags(json.tags, !!json.purge);
const result = await revalidateTags(json.tags);
return NextResponse.json({
success: true,
keys: result.keys,
stats: result.stats,
});
}
+3 -10
View File
@@ -44,7 +44,7 @@ export interface ContentTarget {
/**
* Parameter to cache an entry as an immutable one (ex: revisions, documents).
* It'll cache it for 1 week and revalidate it 24h before expiration.
*
*
* We don't cache for more than this to ensure we don't use too much storage and keep the cache small.
*/
const immutableCacheTtl = {
@@ -202,20 +202,13 @@ export const getPublishedContentByUrl = cache(
const parsed = parseCacheResponse(response);
const tags = [
...parsed.tags,
...('space' in response.data
? [getAPICacheTag({ tag: 'space', space: response.data.space })]
: []),
];
const data: PublishedContentWithCache = {
...response.data,
cacheMaxAge: parsed.ttl,
cacheTags: tags,
cacheTags: parsed.tags,
};
return {
tags,
tags: parsed.tags,
ttl: parsed.ttl,
data,
};
+17 -4
View File
@@ -71,14 +71,17 @@ export function cache<Args extends any[], Result>(
const result = await fn(...args, { signal });
signal?.throwIfAborted();
const setAt = Date.now();
const expiresAt =
Date.now() + (result.ttl ?? options.defaultTtl ?? 60 * 60 * 24) * 1000;
setAt + (result.ttl ?? options.defaultTtl ?? 60 * 60 * 24) * 1000;
const cacheEntry: CacheEntry = {
data: result.data,
meta: {
key,
cache: cacheName,
tags: result.tags ?? [],
setAt,
expiresAt,
revalidatesAt: result.revalidateBefore
? expiresAt - result.revalidateBefore * 1000
@@ -242,9 +245,7 @@ export function getCache(name: string): CacheFunction<any[], any> | null {
* Get a cache key for a function and its arguments.
*/
export function getCacheKey(fnName: string, args: any[]) {
let innerKey = args
.map((arg) => (typeof arg === 'object' && !!arg ? hash(arg) : JSON.stringify(arg)))
.join(',');
let innerKey = args.map((arg) => hashValue(arg)).join(',');
// Avoid crazy long keys, by fallbacking to a hash
if (innerKey.length > 400) {
@@ -254,6 +255,18 @@ export function getCacheKey(fnName: string, args: any[]) {
return `${fnName}(${innerKey})`;
}
function hashValue(arg: any): string {
if (arg === undefined) {
return '';
}
if (typeof arg === 'object' && !!arg) {
return hash(arg);
}
return JSON.stringify(arg);
}
async function setCacheEntry(key: string, entry: CacheEntry) {
return await trace(
{
+14 -3
View File
@@ -34,7 +34,7 @@ export const cloudflareKVCache: CacheBackend = {
const entry = await kv.get<CacheEntry>(kvKey, {
type: 'json',
cacheTtl: 2 * 60,
cacheTtl: 60,
});
span.setAttribute('hit', !!entry);
@@ -69,7 +69,7 @@ export const cloudflareKVCache: CacheBackend = {
if (entry.meta.tags.length > 0) {
const metadata: KVTagMetadata = {
key: key,
key, // TODO: Remove this key from the metadata in 1 day
meta: entry.meta,
};
const jsonMetadata = JSON.stringify(metadata);
@@ -113,6 +113,8 @@ export const cloudflareKVCache: CacheBackend = {
return result;
}
const pendingDeletions: Array<Promise<unknown>> = [];
await Promise.all(
tags.map(async (tag) => {
const entries = await kv.list({
@@ -124,13 +126,22 @@ export const cloudflareKVCache: CacheBackend = {
for (const entry of entries.keys) {
if (entry.metadata) {
const metadata = entry.metadata as KVTagMetadata;
const key = metadata.meta.key ?? metadata.key;
result.metas.push(metadata.meta);
result.keys.push(metadata.key);
result.keys.push(key);
// Delete the tag key and the value key
pendingDeletions.push(kv.delete(getValueKey(key)));
pendingDeletions.push(kv.delete(entry.name));
}
}
}),
);
await Promise.all(pendingDeletions);
return result;
},
};
+12 -14
View File
@@ -94,7 +94,7 @@ export const redisCache: CacheBackend = {
await multi.exec();
},
async revalidateTags(tags, purge) {
async revalidateTags(tags) {
const redis = getRedis();
if (!redis) {
return { keys: [], metas: [] };
@@ -109,20 +109,18 @@ export const redisCache: CacheBackend = {
if (keys.size > 0) {
// Read the meta
if (!purge) {
metas = (
await redis.mget<Array<CacheEntryMeta | null>>(
// Hard limit to avoid fetching a massive list of data
// Starts with the smallest keys.
Array.from(keys)
.sort((a, b) => a.length - b.length)
.slice(0, 50)
.map((key) => getCacheEntryKey(key, 'meta')),
)
metas = (
await redis.mget<Array<CacheEntryMeta | null>>(
// Hard limit to avoid fetching a massive list of data
// Starts with the smallest keys.
Array.from(keys)
.sort((a, b) => a.length - b.length)
.slice(0, 50)
.map((key) => getCacheEntryKey(key, 'meta')),
)
.flat()
.filter(filterOutNullable);
}
)
.flat()
.filter(filterOutNullable);
// Delete all keys
keys.forEach((key) => {
+33 -33
View File
@@ -1,24 +1,30 @@
import pMap from 'p-map';
import { cacheBackends } from './backends';
import { getCache, getCacheKey } from './cache';
import { getCacheKey } from './cache';
import { CacheEntryMeta } from './types';
import { waitUntil } from '../waitUntil';
interface RevalidateTagsStats {
[key: string]: {
/**
* Backends that have the key.
*/
[backend: string]: { set: boolean; setAt?: number; expiresAt?: number };
};
}
/**
* Revalidate all values associated with tags.
* It clears the values from the caches, but also start a background task to revalidate them.
*/
export async function revalidateTags(
tags: string[],
purge: boolean,
): Promise<{
export async function revalidateTags(tags: string[]): Promise<{
keys: string[];
stats: RevalidateTagsStats;
}> {
if (tags.length === 0) {
return { keys: [] };
return { keys: [], stats: {} };
}
const stats: RevalidateTagsStats = {};
const processed = new Set<string>();
const keysByBackend = new Map<number, string[]>();
@@ -27,16 +33,24 @@ export async function revalidateTags(
await Promise.all(
cacheBackends.map(async (backend, backendIndex) => {
const { keys: addedKeys, metas: addedMetas } = await backend.revalidateTags(
tags,
purge,
);
const { keys: addedKeys, metas: addedMetas } = await backend.revalidateTags(tags);
console.log('revalidateTags', backend.name, addedKeys);
addedKeys.forEach((key) => {
stats[key] = stats[key] ?? {};
stats[key][backend.name] = { set: true };
keys.add(key);
keysByBackend.set(backendIndex, [...(keysByBackend.get(backendIndex) ?? []), key]);
});
addedMetas.forEach((meta) => {
const key = getCacheKey(meta.cache, meta.args);
const key = meta.key ?? getCacheKey(meta.cache, meta.args);
stats[key] = stats[key] ?? {};
stats[key][backend.name] = { set: true };
stats[key][backend.name].setAt = meta.setAt;
stats[key][backend.name].expiresAt = meta.expiresAt;
if (!processed.has(key)) {
metas.push(meta);
processed.add(key);
@@ -53,31 +67,17 @@ export async function revalidateTags(
);
if (unclearedKeys.length > 0) {
unclearedKeys.forEach((key) => {
stats[key][backend.name] = { set: false };
});
await backend.del(unclearedKeys);
}
}),
);
// Refresh the values in the cache in the background
if (metas && !purge) {
await waitUntil(
pMap(
metas,
async (meta) => {
console.log(`revalidating ${meta.cache} with args`, meta.args);
const cache = getCache(meta.cache);
if (cache) {
await cache.revalidate(...meta.args);
}
},
{
concurrency: 5,
},
),
);
}
return {
keys: Array.from(keys.keys()),
stats,
};
}
+8 -4
View File
@@ -1,4 +1,11 @@
export interface CacheEntryMeta {
key?: string;
/**
* Timestamp when the entry was created.
*/
setAt: number;
/**
* Name of the function that was cached.
*/
@@ -59,8 +66,5 @@ export interface CacheBackend {
* Revalidate all keys associated with tags.
* It should return the meta of all entries that were revalidated.
*/
revalidateTags(
tags: string[],
purge: boolean,
): Promise<{ keys: string[]; metas: CacheEntryMeta[] }>;
revalidateTags(tags: string[]): Promise<{ keys: string[]; metas: CacheEntryMeta[] }>;
}