diff --git a/apps/api/src/bots/index.ts b/apps/api/src/bots/index.ts index eaa62492..12d6b738 100644 --- a/apps/api/src/bots/index.ts +++ b/apps/api/src/bots/index.ts @@ -1,4 +1,4 @@ -import { cacheable } from '@openpanel/redis'; +import { cacheable, cacheableLru } from '@openpanel/redis'; import bots from './bots'; // Pre-compile regex patterns at module load time @@ -15,7 +15,7 @@ const compiledBots = bots.map((bot) => { const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot); const includesBots = compiledBots.filter((bot) => 'includes' in bot); -export const isBot = cacheable( +export const isBot = cacheableLru( 'is-bot', (ua: string) => { // Check simple string patterns first (fast) @@ -40,6 +40,8 @@ export const isBot = cacheable( return null; }, - 60 * 60, // 1 hour - 'lru', + { + maxSize: 1000, + ttl: 60 * 5, + }, ); diff --git a/packages/db/src/services/clients.service.ts b/packages/db/src/services/clients.service.ts index b36bd830..ea5eab17 100644 --- a/packages/db/src/services/clients.service.ts +++ b/packages/db/src/services/clients.service.ts @@ -1,4 +1,4 @@ -import { cacheable } from '@openpanel/redis'; +import { cacheable, cacheableLru } from '@openpanel/redis'; import type { Client, Prisma } from '../prisma-client'; import { db } from '../prisma-client'; @@ -34,8 +34,7 @@ export async function getClientById( }); } -export const getClientByIdCached = cacheable( - getClientById, - 60 * 60 * 24, - 'both', -); +export const getClientByIdCached = cacheableLru(getClientById, { + maxSize: 1000, + ttl: 60 * 5, +}); diff --git a/packages/db/src/services/salt.service.ts b/packages/db/src/services/salt.service.ts index afeab085..e9da7083 100644 --- a/packages/db/src/services/salt.service.ts +++ b/packages/db/src/services/salt.service.ts @@ -1,6 +1,6 @@ import { generateSalt } from '@openpanel/common/server'; -import { cacheable } from '@openpanel/redis'; +import { cacheableLru } from '@openpanel/redis'; import { db } from '../prisma-client'; export async function getCurrentSalt() { @@ -17,7 +17,7 @@ export async function getCurrentSalt() { return salt.salt; } -export const getSalts = cacheable( +export const getSalts = cacheableLru( 'op:salt', async () => { const [curr, prev] = await db.salt.findMany({ @@ -42,8 +42,10 @@ export const getSalts = cacheable( return salts; }, - 60 * 10, - 'both', + { + maxSize: 2, + ttl: 60 * 5, + }, ); export async function createInitialSalts() { diff --git a/packages/redis/cachable.ts b/packages/redis/cachable.ts index 17849d18..3878af65 100644 --- a/packages/redis/cachable.ts +++ b/packages/redis/cachable.ts @@ -128,13 +128,17 @@ function hasResult(result: unknown): boolean { return true; } -type CacheMode = 'lru' | 'redis' | 'both'; +export interface CacheableLruOptions { + /** TTL in seconds for LRU cache */ + ttl: number; + /** Maximum number of entries in LRU cache */ + maxSize?: number; +} -// Overload 1: cacheable(fn, expireInSec, lruCache?) +// Overload 1: cacheable(fn, expireInSec) export function cacheable any>( fn: T, expireInSec: number, - cacheMode?: CacheMode, ): T & { getKey: (...args: Parameters) => string; clear: (...args: Parameters) => Promise; @@ -143,12 +147,11 @@ export function cacheable any>( ) => (payload: Awaited>) => Promise<'OK'>; }; -// Overload 2: cacheable(name, fn, expireInSec, lruCache?) +// Overload 2: cacheable(name, fn, expireInSec) export function cacheable any>( name: string, fn: T, expireInSec: number, - cacheMode?: CacheMode, ): T & { getKey: (...args: Parameters) => string; clear: (...args: Parameters) => Promise; @@ -157,12 +160,11 @@ export function cacheable any>( ) => (payload: Awaited>) => Promise<'OK'>; }; -// Implementation +// Implementation for cacheable (Redis-only - async) export function cacheable any>( fnOrName: T | string, fnOrExpireInSec: number | T, - _expireInSecOrCacheMode?: number | CacheMode, - _cacheMode?: CacheMode, + _expireInSec?: number, ) { const name = typeof fnOrName === 'string' ? fnOrName : fnOrName.name; const fn = @@ -173,23 +175,14 @@ export function cacheable any>( : null; let expireInSec: number | null = null; - let cacheMode = 'redis'; // Parse parameters based on function signature if (typeof fnOrName === 'function') { - // Overload 1: cacheable(fn, expireInSec, lruCache?) + // Overload 1: cacheable(fn, expireInSec) expireInSec = typeof fnOrExpireInSec === 'number' ? fnOrExpireInSec : null; - cacheMode = - typeof _expireInSecOrCacheMode === 'boolean' - ? _expireInSecOrCacheMode - : 'redis'; } else { - // Overload 2: cacheable(name, fn, expireInSec, lruCache?) - expireInSec = - typeof _expireInSecOrCacheMode === 'number' - ? _expireInSecOrCacheMode - : null; - cacheMode = typeof _cacheMode === 'string' ? _cacheMode : 'redis'; + // Overload 2: cacheable(name, fn, expireInSec) + expireInSec = typeof _expireInSec === 'number' ? _expireInSec : null; } if (typeof fn !== 'function') { @@ -204,33 +197,13 @@ export function cacheable any>( const getKey = (...args: Parameters) => `${cachePrefix}:${stringify(args)}`; - // Create function-specific LRU cache if enabled - const functionLruCache = - cacheMode === 'lru' || cacheMode === 'both' - ? new LRUCache({ - max: 1000, - ttl: expireInSec * 1000, // Convert seconds to milliseconds for LRU - }) - : null; - + // Redis-only mode: asynchronous implementation const cachedFn = async ( ...args: Parameters ): Promise>> => { const key = getKey(...args); - // L1 Cache: Check LRU cache first (in-memory, instant) - if (functionLruCache) { - const lruHit = functionLruCache.get(key); - if (lruHit !== undefined && hasResult(lruHit)) { - return lruHit; - } - - if (cacheMode === 'lru') { - return null as any; - } - } - - // L2 Cache: Check Redis cache (shared across instances) + // Check Redis cache (shared across instances) const cached = await getRedisCache().get(key); if (cached) { try { @@ -244,10 +217,6 @@ export function cacheable any>( return value; }); if (hasResult(parsed)) { - // Store in LRU cache for next time - if (functionLruCache) { - functionLruCache.set(key, parsed); - } return parsed; } } catch (e) { @@ -259,12 +228,10 @@ export function cacheable any>( const result = await fn(...(args as any)); if (hasResult(result)) { - // Store in both caches - if (functionLruCache) { - functionLruCache.set(key, result); - } // Don't await Redis write - fire and forget for better performance - getRedisCache().setex(key, expireInSec, JSON.stringify(result)); + getRedisCache() + .setex(key, expireInSec, JSON.stringify(result)) + .catch(() => {}); } return result; @@ -273,21 +240,140 @@ export function cacheable any>( cachedFn.getKey = getKey; cachedFn.clear = async (...args: Parameters) => { const key = getKey(...args); - // Clear both LRU and Redis caches - if (functionLruCache) { - functionLruCache.delete(key); - } return getRedisCache().del(key); }; cachedFn.set = (...args: Parameters) => async (payload: Awaited>) => { const key = getKey(...args); - // Set in both caches - if (functionLruCache) { - functionLruCache.set(key, payload); - } - return getRedisCache().setex(key, expireInSec, JSON.stringify(payload)); + return getRedisCache() + .setex(key, expireInSec, JSON.stringify(payload)) + .catch(() => {}); + }; + + return cachedFn; +} + +// Overload 1: cacheableLru(fn, options) +export function cacheableLru any>( + fn: T, + options: CacheableLruOptions, +): T & { + getKey: (...args: Parameters) => string; + clear: (...args: Parameters) => boolean; + set: (...args: Parameters) => (payload: ReturnType) => void; +}; + +// Overload 2: cacheableLru(name, fn, options) +export function cacheableLru any>( + name: string, + fn: T, + options: CacheableLruOptions, +): T & { + getKey: (...args: Parameters) => string; + clear: (...args: Parameters) => boolean; + set: (...args: Parameters) => (payload: ReturnType) => void; +}; + +// Implementation for cacheableLru (LRU-only - synchronous) +export function cacheableLru any>( + fnOrName: T | string, + fnOrOptions: T | CacheableLruOptions, + _options?: CacheableLruOptions, +) { + const name = typeof fnOrName === 'string' ? fnOrName : fnOrName.name; + const fn = + typeof fnOrName === 'function' + ? fnOrName + : typeof fnOrOptions === 'function' + ? fnOrOptions + : null; + + let options: CacheableLruOptions; + + // Parse parameters based on function signature + if (typeof fnOrName === 'function') { + // Overload 1: cacheableLru(fn, options) + options = + typeof fnOrOptions === 'object' && fnOrOptions !== null + ? fnOrOptions + : ({} as CacheableLruOptions); + } else { + // Overload 2: cacheableLru(name, fn, options) + options = + typeof _options === 'object' && _options !== null + ? _options + : ({} as CacheableLruOptions); + } + + if (typeof fn !== 'function') { + throw new Error('fn is not a function'); + } + + if (typeof options.ttl !== 'number') { + throw new Error('options.ttl is required and must be a number'); + } + + const cachePrefix = `cachable:${name}`; + const getKey = (...args: Parameters) => + `${cachePrefix}:${stringify(args)}`; + + const maxSize = options.maxSize ?? 1000; + const ttl = options.ttl; + + // Create function-specific LRU cache + const functionLruCache = new LRUCache({ + max: maxSize, + ttl: ttl * 1000, // Convert seconds to milliseconds for LRU + }); + + // LRU-only mode: synchronous implementation (or returns promise if fn is async) + const cachedFn = ((...args: Parameters): ReturnType => { + const key = getKey(...args); + + // Check LRU cache + const lruHit = functionLruCache.get(key); + if (lruHit !== undefined && hasResult(lruHit)) { + return lruHit as ReturnType; + } + + // Cache miss: Execute function + const result = fn(...(args as any)) as ReturnType; + + // If result is a Promise, handle it asynchronously but cache the resolved value + if (result && typeof (result as any).then === 'function') { + return (result as Promise).then((resolved: any) => { + if (hasResult(resolved)) { + functionLruCache.set(key, resolved); + } + return resolved; + }) as ReturnType; + } + + // Synchronous result: cache and return + if (hasResult(result)) { + functionLruCache.set(key, result); + } + + return result; + }) as T & { + getKey: (...args: Parameters) => string; + clear: (...args: Parameters) => boolean; + set: (...args: Parameters) => (payload: ReturnType) => void; + }; + + cachedFn.getKey = getKey; + cachedFn.clear = (...args: Parameters) => { + const key = getKey(...args); + return functionLruCache.delete(key); + }; + cachedFn.set = + (...args: Parameters) => + (payload: ReturnType) => { + const key = getKey(...args); + if (hasResult(payload)) { + functionLruCache.set(key, payload); + } }; return cachedFn;