@trieb.work/nextjs-turbo-redis-cache
Version:
The ultimate Redis caching solution for Next.js. Built for production-ready, large-scale projects, it delivers unparalleled performance and efficiency with features tailored for high-traffic applications.
341 lines (297 loc) • 10.5 kB
text/typescript
import { commandOptions, createClient } from 'redis';
import { SyncedMap } from './SyncedMap';
import { DeduplicatedRequestHandler } from './DeduplicatedRequestHandler';
import {
CacheHandler,
CacheHandlerValue,
IncrementalCache,
} from 'next/dist/server/lib/incremental-cache';
export type CommandOptions = ReturnType<typeof commandOptions>;
type GetParams = Parameters<IncrementalCache['get']>;
type SetParams = Parameters<IncrementalCache['set']>;
type RevalidateParams = Parameters<IncrementalCache['revalidateTag']>;
export type Client = ReturnType<typeof createClient>;
export type CreateRedisStringsHandlerOptions = {
database?: number;
keyPrefix?: string;
timeoutMs?: number;
revalidateTagQuerySize?: number;
sharedTagsKey?: string;
avgResyncIntervalMs?: number;
redisGetDeduplication?: boolean;
inMemoryCachingTime?: number;
defaultStaleAge?: number;
estimateExpireAge?: (staleAge: number) => number;
};
const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_';
const REVALIDATED_TAGS_KEY = '__revalidated_tags__';
function isImplicitTag(tag: string): boolean {
return tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID);
}
export function getTimeoutRedisCommandOptions(
timeoutMs: number,
): CommandOptions {
return commandOptions({ signal: AbortSignal.timeout(timeoutMs) });
}
export default class RedisStringsHandler implements CacheHandler {
private client: Client;
private sharedTagsMap: SyncedMap<string[]>;
private revalidatedTagsMap: SyncedMap<number>;
private inMemoryDeduplicationCache: SyncedMap<
Promise<ReturnType<Client['get']>>
>;
private redisGet: Client['get'];
private redisDeduplicationHandler: DeduplicatedRequestHandler<
Client['get'],
string | Buffer | null
>;
private deduplicatedRedisGet: (key: string) => Client['get'];
private timeoutMs: number;
private keyPrefix: string;
private redisGetDeduplication: boolean;
private inMemoryCachingTime: number;
private defaultStaleAge: number;
private estimateExpireAge: (staleAge: number) => number;
constructor({
database = process.env.VERCEL_ENV === 'production' ? 0 : 1,
keyPrefix = process.env.VERCEL_URL || 'UNDEFINED_URL_',
sharedTagsKey = '__sharedTags__',
timeoutMs = 5000,
revalidateTagQuerySize = 250,
avgResyncIntervalMs = 60 * 60 * 1000,
redisGetDeduplication = true,
inMemoryCachingTime = 10_000,
defaultStaleAge = 60 * 60 * 24 * 14,
estimateExpireAge = (staleAge) =>
process.env.VERCEL_ENV === 'preview' ? staleAge * 1.2 : staleAge * 2,
}: CreateRedisStringsHandlerOptions) {
this.keyPrefix = keyPrefix;
this.timeoutMs = timeoutMs;
this.redisGetDeduplication = redisGetDeduplication;
this.inMemoryCachingTime = inMemoryCachingTime;
this.defaultStaleAge = defaultStaleAge;
this.estimateExpireAge = estimateExpireAge;
try {
this.client = createClient({
...(database !== 0 ? { database } : {}),
url: process.env.REDISHOST
? `redis://${process.env.REDISHOST}:${process.env.REDISPORT}`
: 'redis://localhost:6379',
});
this.client.on('error', (error) => {
console.error('Redis client error', error);
});
this.client
.connect()
.then(() => {
console.info('Redis client connected.');
})
.catch((error) => {
console.error('Failed to connect Redis client:', error);
this.client.disconnect();
});
} catch (error: unknown) {
console.error('Failed to initialize Redis client');
throw error;
}
const filterKeys = (key: string): boolean =>
key !== REVALIDATED_TAGS_KEY && key !== sharedTagsKey;
this.sharedTagsMap = new SyncedMap<string[]>({
client: this.client,
keyPrefix,
redisKey: sharedTagsKey,
database,
timeoutMs,
querySize: revalidateTagQuerySize,
filterKeys,
resyncIntervalMs:
avgResyncIntervalMs -
avgResyncIntervalMs / 10 +
Math.random() * (avgResyncIntervalMs / 10),
});
this.revalidatedTagsMap = new SyncedMap<number>({
client: this.client,
keyPrefix,
redisKey: REVALIDATED_TAGS_KEY,
database,
timeoutMs,
querySize: revalidateTagQuerySize,
filterKeys,
resyncIntervalMs:
avgResyncIntervalMs +
avgResyncIntervalMs / 10 +
Math.random() * (avgResyncIntervalMs / 10),
});
this.inMemoryDeduplicationCache = new SyncedMap({
client: this.client,
keyPrefix,
redisKey: 'inMemoryDeduplicationCache',
database,
timeoutMs,
querySize: revalidateTagQuerySize,
filterKeys,
customizedSync: {
withoutRedisHashmap: true,
withoutSetSync: true,
},
});
const redisGet: Client['get'] = this.client.get.bind(this.client);
this.redisDeduplicationHandler = new DeduplicatedRequestHandler(
redisGet,
inMemoryCachingTime,
this.inMemoryDeduplicationCache,
);
this.redisGet = redisGet;
this.deduplicatedRedisGet =
this.redisDeduplicationHandler.deduplicatedFunction;
}
resetRequestCache(...args: never[]): void {
console.warn('WARNING resetRequestCache() was called', args);
}
private async assertClientIsReady(): Promise<void> {
await Promise.all([
this.sharedTagsMap.waitUntilReady(),
this.revalidatedTagsMap.waitUntilReady(),
]);
if (!this.client.isReady) {
throw new Error('Redis client is not ready yet or connection is lost.');
}
}
public async get(key: GetParams[0], ctx: GetParams[1]) {
await this.assertClientIsReady();
const clientGet = this.redisGetDeduplication
? this.deduplicatedRedisGet(key)
: this.redisGet;
const result = await clientGet(
getTimeoutRedisCommandOptions(this.timeoutMs),
this.keyPrefix + key,
);
if (!result) {
return null;
}
const cacheValue = JSON.parse(result) as
| (CacheHandlerValue & { lastModified: number })
| null;
if (!cacheValue) {
return null;
}
if (cacheValue.value?.kind === 'FETCH') {
cacheValue.value.data.body = Buffer.from(
cacheValue.value.data.body,
).toString('base64');
}
const combinedTags = new Set([
...(ctx?.softTags || []),
...(ctx?.tags || []),
]);
if (combinedTags.size === 0) {
return cacheValue;
}
for (const tag of combinedTags) {
// TODO: check how this revalidatedTagsMap is used or if it can be deleted
const revalidationTime = this.revalidatedTagsMap.get(tag);
if (revalidationTime && revalidationTime > cacheValue.lastModified) {
const redisKey = this.keyPrefix + key;
// Do not await here as this can happen in the background while we can already serve the cacheValue
this.client
.unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey)
.catch((err) => {
console.error(
'Error occurred while unlinking stale data. Retrying now. Error was:',
err,
);
this.client.unlink(
getTimeoutRedisCommandOptions(this.timeoutMs),
redisKey,
);
})
.finally(async () => {
await this.sharedTagsMap.delete(key);
await this.revalidatedTagsMap.delete(tag);
});
return null;
}
}
return cacheValue;
}
public async set(
key: SetParams[0],
data: SetParams[1] & { lastModified: number },
ctx: SetParams[2],
) {
if (data.kind === 'FETCH') {
console.time('encoding' + key);
data.data.body = Buffer.from(data.data.body, 'base64').toString();
console.timeEnd('encoding' + key);
}
await this.assertClientIsReady();
data.lastModified = Date.now();
const value = JSON.stringify(data);
// pre seed data into deduplicated get client. This will reduce redis load by not requesting
// the same value from redis which was just set.
if (this.redisGetDeduplication) {
this.redisDeduplicationHandler.seedRequestReturn(key, value);
}
const expireAt =
ctx.revalidate &&
Number.isSafeInteger(ctx.revalidate) &&
ctx.revalidate > 0
? this.estimateExpireAge(ctx.revalidate)
: this.estimateExpireAge(this.defaultStaleAge);
const options = getTimeoutRedisCommandOptions(this.timeoutMs);
const setOperation: Promise<string | null> = this.client.set(
options,
this.keyPrefix + key,
value,
{
EX: expireAt,
},
);
let setTagsOperation: Promise<void> | undefined;
if (ctx.tags && ctx.tags.length > 0) {
const currentTags = this.sharedTagsMap.get(key);
const currentIsSameAsNew =
currentTags?.length === ctx.tags.length &&
currentTags.every((v) => ctx.tags!.includes(v)) &&
ctx.tags.every((v) => currentTags.includes(v));
if (!currentIsSameAsNew) {
setTagsOperation = this.sharedTagsMap.set(
key,
structuredClone(ctx.tags) as string[],
);
}
}
await Promise.all([setOperation, setTagsOperation]);
}
public async revalidateTag(tagOrTags: RevalidateParams[0]) {
const tags = new Set([tagOrTags || []].flat());
await this.assertClientIsReady();
// TODO: check how this revalidatedTagsMap is used or if it can be deleted
for (const tag of tags) {
if (isImplicitTag(tag)) {
const now = Date.now();
await this.revalidatedTagsMap.set(tag, now);
}
}
const keysToDelete: string[] = [];
for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
if (sharedTags.some((tag) => tags.has(tag))) {
keysToDelete.push(key);
}
}
if (keysToDelete.length === 0) {
return;
}
const fullRedisKeys = keysToDelete.map((key) => this.keyPrefix + key);
const options = getTimeoutRedisCommandOptions(this.timeoutMs);
const deleteKeysOperation = this.client.unlink(options, fullRedisKeys);
// delete entries from in-memory deduplication cache
if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
for (const key of keysToDelete) {
this.inMemoryDeduplicationCache.delete(key);
}
}
const deleteTagsOperation = this.sharedTagsMap.delete(keysToDelete);
await Promise.all([deleteKeysOperation, deleteTagsOperation]);
}
}