UNPKG

@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.

299 lines (265 loc) 8.85 kB
// SyncedMap.ts import { Client, getTimeoutRedisCommandOptions } from './RedisStringsHandler'; type CustomizedSync = { withoutRedisHashmap?: boolean; withoutSetSync?: boolean; }; type SyncedMapOptions = { client: Client; keyPrefix: string; redisKey: string; // Redis Hash key database: number; timeoutMs: number; querySize: number; filterKeys: (key: string) => boolean; resyncIntervalMs?: number; customizedSync?: CustomizedSync; }; export type SyncMessage<V> = { type: 'insert' | 'delete'; key?: string; value?: V; keys?: string[]; }; const SYNC_CHANNEL_SUFFIX = ':sync-channel:'; export class SyncedMap<V> { private client: Client; private subscriberClient: Client; private map: Map<string, V>; private keyPrefix: string; private syncChannel: string; private redisKey: string; private database: number; private timeoutMs: number; private querySize: number; private filterKeys: (key: string) => boolean; private resyncIntervalMs?: number; private customizedSync?: CustomizedSync; private setupLock: Promise<void>; private setupLockResolve!: () => void; constructor(options: SyncedMapOptions) { this.client = options.client; this.keyPrefix = options.keyPrefix; this.redisKey = options.redisKey; this.syncChannel = `${options.keyPrefix}${SYNC_CHANNEL_SUFFIX}${options.redisKey}`; this.database = options.database; this.timeoutMs = options.timeoutMs; this.querySize = options.querySize; this.filterKeys = options.filterKeys; this.resyncIntervalMs = options.resyncIntervalMs; this.customizedSync = options.customizedSync; this.map = new Map<string, V>(); this.subscriberClient = this.client.duplicate(); this.setupLock = new Promise<void>((resolve) => { this.setupLockResolve = resolve; }); this.setup().catch((error) => { console.error('Failed to setup SyncedMap:', error); throw error; }); } private async setup() { let setupPromises: Promise<void>[] = []; if (!this.customizedSync?.withoutRedisHashmap) { setupPromises.push(this.initialSync()); this.setupPeriodicResync(); } setupPromises.push(this.setupPubSub()); await Promise.all(setupPromises); this.setupLockResolve(); } private async initialSync() { let cursor = 0; const hScanOptions = { COUNT: this.querySize }; try { do { const remoteItems = await this.client.hScan( getTimeoutRedisCommandOptions(this.timeoutMs), this.keyPrefix + this.redisKey, cursor, hScanOptions, ); for (const { field, value } of remoteItems.tuples) { if (this.filterKeys(field)) { const parsedValue = JSON.parse(value); this.map.set(field, parsedValue); } } cursor = remoteItems.cursor; } while (cursor !== 0); // Clean up keys not in Redis await this.cleanupKeysNotInRedis(); } catch (error) { console.error('Error during initial sync:', error); throw error; } } private async cleanupKeysNotInRedis() { let cursor = 0; const scanOptions = { COUNT: this.querySize, MATCH: `${this.keyPrefix}*` }; let remoteKeys: string[] = []; try { do { const remoteKeysPortion = await this.client.scan( getTimeoutRedisCommandOptions(this.timeoutMs), cursor, scanOptions, ); remoteKeys = remoteKeys.concat(remoteKeysPortion.keys); cursor = remoteKeysPortion.cursor; } while (cursor !== 0); const remoteKeysSet = new Set( remoteKeys.map((key) => key.substring(this.keyPrefix.length)), ); const keysToDelete: string[] = []; for (const key of this.map.keys()) { const keyStr = key as unknown as string; if (!remoteKeysSet.has(keyStr) && this.filterKeys(keyStr)) { keysToDelete.push(keyStr); } } if (keysToDelete.length > 0) { await this.delete(keysToDelete); } } catch (error) { console.error('Error during cleanup of keys not in Redis:', error); throw error; } } private setupPeriodicResync() { if (this.resyncIntervalMs && this.resyncIntervalMs > 0) { setInterval(() => { this.initialSync().catch((error) => { console.error('Error during periodic resync:', error); }); }, this.resyncIntervalMs); } } private async setupPubSub() { const syncHandler = async (message: string) => { const syncMessage: SyncMessage<V> = JSON.parse(message); if (syncMessage.type === 'insert') { if (syncMessage.key !== undefined && syncMessage.value !== undefined) { this.map.set(syncMessage.key, syncMessage.value); } } else if (syncMessage.type === 'delete') { if (syncMessage.keys) { for (const key of syncMessage.keys) { this.map.delete(key); } } } }; const keyEventHandler = async (_channel: string, message: string) => { const key = message; if (key.startsWith(this.keyPrefix)) { const keyInMap = key.substring(this.keyPrefix.length); if (this.filterKeys(keyInMap)) { await this.delete(keyInMap, true); } } }; try { await this.subscriberClient.connect(); await Promise.all([ // We use a custom channel for insert/delete For the following reason: // With custom channel we can delete multiple entries in one message. If we would listen to unlink / del we // could get thousands of messages for one revalidateTag (For example revalidateTag("algolia") would send an enormous amount of network packages) // Also we can send the value in the message for insert this.subscriberClient.subscribe(this.syncChannel, syncHandler), // Subscribe to Redis keyspace notifications for evicted and expired keys this.subscriberClient.subscribe( `__keyevent@${this.database}__:evicted`, keyEventHandler, ), this.subscriberClient.subscribe( `__keyevent@${this.database}__:expired`, keyEventHandler, ), ]); // Error handling for reconnection this.subscriberClient.on('error', async (err) => { console.error('Subscriber client error:', err); try { await this.subscriberClient.quit(); this.subscriberClient = this.client.duplicate(); await this.setupPubSub(); } catch (reconnectError) { console.error( 'Failed to reconnect subscriber client:', reconnectError, ); } }); } catch (error) { console.error('Error setting up pub/sub client:', error); throw error; } } public async waitUntilReady() { await this.setupLock; } public get(key: string): V | undefined { return this.map.get(key); } public async set(key: string, value: V): Promise<void> { this.map.set(key, value); const operations = []; // This is needed if we only want to sync delete commands. This is especially useful for non serializable data like a promise map if (this.customizedSync?.withoutSetSync) { return; } if (!this.customizedSync?.withoutRedisHashmap) { const options = getTimeoutRedisCommandOptions(this.timeoutMs); operations.push( this.client.hSet( options, this.keyPrefix + this.redisKey, key as unknown as string, JSON.stringify(value), ), ); } const insertMessage: SyncMessage<V> = { type: 'insert', key: key as unknown as string, value, }; operations.push( this.client.publish(this.syncChannel, JSON.stringify(insertMessage)), ); await Promise.all(operations); } public async delete( keys: string[] | string, withoutSyncMessage = false, ): Promise<void> { const keysArray = Array.isArray(keys) ? keys : [keys]; const operations = []; for (const key of keysArray) { this.map.delete(key); } if (!this.customizedSync?.withoutRedisHashmap) { const options = getTimeoutRedisCommandOptions(this.timeoutMs); operations.push( this.client.hDel(options, this.keyPrefix + this.redisKey, keysArray), ); } if (!withoutSyncMessage) { const deletionMessage: SyncMessage<V> = { type: 'delete', keys: keysArray, }; operations.push( this.client.publish(this.syncChannel, JSON.stringify(deletionMessage)), ); } await Promise.all(operations); } public has(key: string): boolean { return this.map.has(key); } public entries(): IterableIterator<[string, V]> { return this.map.entries(); } }