diff --git a/src/lib/cache/cache.ts b/src/lib/cache/cache.ts index 389c320f1..878294174 100644 --- a/src/lib/cache/cache.ts +++ b/src/lib/cache/cache.ts @@ -76,7 +76,6 @@ export function cache( expiresAt: Date.now() + (result.ttl ?? options.defaultTtl ?? 60 * 60 * 24) * 1000, args, - hits: 1, }, }; diff --git a/src/lib/cache/cloudflare-cache.ts b/src/lib/cache/cloudflare-cache.ts index 9f630583e..25c5a228e 100644 --- a/src/lib/cache/cloudflare-cache.ts +++ b/src/lib/cache/cloudflare-cache.ts @@ -21,10 +21,10 @@ export const cloudflareCache: CacheBackend = { if (!cache) { return null; } - return trace(`cloudflareCache.get(${key})`, async (trace) => { + return trace(`cloudflareCache.get(${key})`, async (span) => { const cacheKey = await serializeKey(key); const response = await cache.match(cacheKey); - trace.setAttribute('hit', !!response); + span.setAttribute('hit', !!response); options?.signal?.throwIfAborted(); if (!response) { diff --git a/src/lib/cache/cloudflare-kv.ts b/src/lib/cache/cloudflare-kv.ts index 692436c77..15fb6f09d 100644 --- a/src/lib/cache/cloudflare-kv.ts +++ b/src/lib/cache/cloudflare-kv.ts @@ -24,7 +24,7 @@ export const cloudflareKVCache: CacheBackend = { return null; } - return trace(`cloudflareKV.get(${key})`, async (trace) => { + return trace(`cloudflareKV.get(${key})`, async (span) => { const kvKey = getValueKey(key); const entry = await kv.get(kvKey, { @@ -32,7 +32,7 @@ export const cloudflareKVCache: CacheBackend = { cacheTtl: 2 * 60, }); - trace.setAttribute('hit', !!entry); + span.setAttribute('hit', !!entry); return entry; }); diff --git a/src/lib/cache/memory.ts b/src/lib/cache/memory.ts index 1b251df3e..3bff4ac02 100644 --- a/src/lib/cache/memory.ts +++ b/src/lib/cache/memory.ts @@ -16,7 +16,6 @@ export const memoryCache: CacheBackend = { } if (memoryEntry.meta.expiresAt > Date.now()) { - memoryEntry.meta.hits + 1; return memoryEntry; } else { memoryCache.delete(key); diff --git a/src/lib/cache/redis.ts b/src/lib/cache/redis.ts index 2e7301eca..bf5fb9abd 100644 --- a/src/lib/cache/redis.ts +++ b/src/lib/cache/redis.ts @@ -1,13 +1,12 @@ import { Redis } from '@upstash/redis/cloudflare'; -import { CacheBackend, CacheEntry, CacheEntryMeta } from './types'; +import { CacheBackend, CacheEntryMeta } from './types'; import { getCacheMaxAge } from './utils'; import { trace } from '../tracing'; import { filterOutNullable } from '../typescript'; -import { waitUntil } from '../waitUntil'; const cacheNamespace = process.env.UPSTASH_REDIS_NAMESPACE ?? 'gitbook'; -const cacheVersion = 1; +const cacheVersion = 2; export const redisCache: CacheBackend = { name: 'redis', @@ -17,23 +16,17 @@ export const redisCache: CacheBackend = { if (!redis) { return null; } - return trace(`redis.get(${key})`, async (trace) => { - const redisKey = getRedisKey(key); - const redisEntry = await redis.json.get(redisKey); + return trace(`redis.get(${key})`, async (span) => { + const valueKey = getCacheEntryKey(key, 'value'); + const redisEntry = await redis.get(valueKey); - trace.setAttribute('hit', !!redisEntry); + span.setAttribute('hit', !!redisEntry); if (!redisEntry) { return null; } - await waitUntil( - redis.json.numincrby(redisKey, '$.meta.hits', 1).catch((error) => { - // Ignore errors - }), - ); - - return redisEntry as CacheEntry; + return JSON.parse(redisEntry); }); }, @@ -43,30 +36,32 @@ export const redisCache: CacheBackend = { return; } - return trace(`redis.set(${key})`, async (trace) => { - const multi = redis.multi(); - - const redisKey = getRedisKey(key); + return trace(`redis.set(${key})`, async () => { const expire = getCacheMaxAge(entry.meta); - // Don't cache for less than 1min, as it's not worth it + // Don't cache for less than 10min, as it's not worth it if (expire <= 10 * 60) { return; } + const multi = redis.multi(); + const valueKey = getCacheEntryKey(key, 'value'); + const metaKey = getCacheEntryKey(key, 'meta'); + entry.meta.tags.forEach((tag) => { const redisTagKey = getCacheTagKey(tag); - multi.sadd(redisTagKey, redisKey); + multi.sadd(redisTagKey, key); // Set am expiration on the tag to be the maximum of the expiration of all keys multi.expire(redisTagKey, expire, 'GT'); multi.expire(redisTagKey, expire, 'NX'); }); - // @ts-ignore - multi.json.set(redisKey, '$', entry); - multi.expire(redisKey, expire); + multi.set(valueKey, JSON.stringify(entry)); + multi.set(metaKey, JSON.stringify(entry.meta)); + multi.expire(valueKey, expire); + multi.expire(metaKey, expire); await multi.exec(); }); @@ -80,7 +75,8 @@ export const redisCache: CacheBackend = { const multi = redis.multi(); keys.forEach((key) => { - multi.del(getRedisKey(key)); + multi.del(getCacheEntryKey(key, 'value')); + multi.del(getCacheEntryKey(key, 'meta')); }); await multi.exec(); @@ -100,25 +96,35 @@ export const redisCache: CacheBackend = { let metas: Array = []; if (keys.size > 0) { + // Read the meta if (!purge) { metas = ( - await redis.json.mget( + await redis.mget>( // 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, 10), - '$.meta', + .slice(0, 50) + .map((key) => getCacheEntryKey(key, 'meta')), ) - ).flat() as Array; + ) + .flat() + .map((rawMeta) => { + if (!rawMeta) { + return null; + } + return JSON.parse(rawMeta); + }) + .filter(filterOutNullable); } - // Finally, delete all keys + // Delete all keys keys.forEach((key) => { - pipeline.del(key); + pipeline.del(getCacheEntryKey(key, 'value')); }); } + // And delete the tags tags.forEach((tag) => { pipeline.del(getCacheTagKey(tag)); }); @@ -148,6 +154,13 @@ function getCacheTagKey(tag: string) { return getRedisKey(`tags.${tag}`); } +/** + * Get the key for an entry. + */ +function getCacheEntryKey(key: string, type: 'meta' | 'value') { + return getRedisKey(`entry.${key}.${type}`); +} + /** * Create a redis key for a cache entry. */ diff --git a/src/lib/cache/revalidateTags.ts b/src/lib/cache/revalidateTags.ts index 2f7daa5aa..b2d400b87 100644 --- a/src/lib/cache/revalidateTags.ts +++ b/src/lib/cache/revalidateTags.ts @@ -62,13 +62,9 @@ export async function revalidateTags( if (metas && !purge) { await waitUntil( pMap( - // Sort to process the entries with the most hits first - metas.sort((a, b) => b.hits - a.hits), + metas, async (meta) => { - console.log( - `revalidating ${meta.cache} (${meta.hits} hits) with args`, - meta.args, - ); + console.log(`revalidating ${meta.cache} with args`, meta.args); const cache = getCache(meta.cache); if (cache) { await cache.revalidate(...meta.args); diff --git a/src/lib/cache/types.ts b/src/lib/cache/types.ts index d68c83f47..06d3a89bb 100644 --- a/src/lib/cache/types.ts +++ b/src/lib/cache/types.ts @@ -18,11 +18,6 @@ export interface CacheEntryMeta { * Arguments that were passed to the function. */ args: any[]; - - /** - * Number of hits on this entry. - */ - hits: number; } export interface CacheEntry { diff --git a/src/lib/tracing.ts b/src/lib/tracing.ts index c7ebae953..6e57eb05e 100644 --- a/src/lib/tracing.ts +++ b/src/lib/tracing.ts @@ -1,13 +1,13 @@ -export interface Trace { +export interface TraceSpan { setAttribute: (label: string, value: boolean | string | number) => void; } /** * Record a performance trace for the given function. */ -export async function trace(name: string, fn: (trace: Trace) => Promise): Promise { +export async function trace(name: string, fn: (span: TraceSpan) => Promise): Promise { const attributes: Record = {}; - const trace: Trace = { + const span: TraceSpan = { setAttribute(label, value) { attributes[label] = value; }, @@ -15,9 +15,9 @@ export async function trace(name: string, fn: (trace: Trace) => Promise): let start = now(); try { - return await fn(trace); + return await fn(span); } catch (error) { - trace.setAttribute('error', true); + span.setAttribute('error', true); throw error; } finally { let end = now();