diff --git a/lib/utils/cache.test.ts b/lib/utils/cache.test.ts new file mode 100644 index 0000000000..1870241ffc --- /dev/null +++ b/lib/utils/cache.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, jest, afterEach, afterAll, beforeAll } from '@jest/globals'; +import wait from '@/utils/wait'; + +beforeAll(() => { + process.env.CACHE_EXPIRE = '1'; + process.env.CACHE_CONTENT_EXPIRE = '3'; +}); + +afterEach(() => { + delete process.env.CACHE_TYPE; + jest.resetModules(); +}); + +afterAll(() => { + delete process.env.CACHE_EXPIRE; +}); + +describe('cache', () => { + it('memory', async () => { + process.env.CACHE_TYPE = 'memory'; + const cache = (await import('@/utils/cache')).default; + if (!cache.clients.memoryCache || !cache.status.available) { + throw new Error('Memory cache client error'); + } + await cache.set('mock', undefined); + expect(await cache.get('mock')).toBe(''); + + await cache.globalCache.set('mock', undefined); + expect(await cache.globalCache.get('mock')).toBe(''); + await cache.globalCache.set('mock', { + mock: 1, + }); + expect(await cache.globalCache.get('mock')).toBe('{"mock":1}'); + }, 10000); + + it('redis', async () => { + process.env.CACHE_TYPE = 'redis'; + process.env.REDIS_URL = 'redis://localhost:6380'; + const cache = (await import('@/utils/cache')).default; + await wait(500); + if (!cache.clients.redisClient || !cache.status.available) { + throw new Error('Redis client error'); + } + await cache.set('mock1', undefined); + expect(await cache.get('mock1')).toBe(''); + await cache.set('mock2', '2'); + await cache.set('mock2', '2'); + expect(await cache.get('mock2')).toBe('2'); + await cache.clients.redisClient?.quit(); + }, 10000); + + it('redis with quit', async () => { + process.env.CACHE_TYPE = 'redis'; + const cache = (await import('@/utils/cache')).default; + if (cache.clients.redisClient) { + await cache.clients.redisClient.quit(); + } else { + throw new Error('No redis client'); + } + await cache.set('mock2', '2'); + expect(await cache.get('mock2')).toBe(null); + }); + + it('redis with error', async () => { + process.env.CACHE_TYPE = 'redis'; + process.env.REDIS_URL = 'redis://wrongpath:6379'; + const cache = (await import('@/utils/cache')).default; + await cache.set('mock2', '2'); + expect(await cache.get('mock2')).toBe(null); + await cache.clients.redisClient?.quit(); + }); + + it('no cache', async () => { + process.env.CACHE_TYPE = 'NO'; + const cache = (await import('@/utils/cache')).default; + await cache.set('mock2', '2'); + expect(await cache.get('mock2')).toBe(null); + }); + + it('throws TTL key', async () => { + process.env.CACHE_TYPE = 'redis'; + const cache = (await import('@/utils/cache')).default; + + try { + await cache.get('rsshub:cacheTtl:mock'); + } catch (error: any) { + expect(error.message).toContain('reserved for the internal usage'); + } finally { + await cache.clients.redisClient?.quit(); + } + }); +}); diff --git a/lib/utils/cache/index.ts b/lib/utils/cache/index.ts index fee6b4e77a..422f7e3d5c 100644 --- a/lib/utils/cache/index.ts +++ b/lib/utils/cache/index.ts @@ -2,6 +2,7 @@ import { config } from '@/config'; import redis from './redis'; import memory from './memory'; import type CacheModule from './base'; +import logger from '@/utils/logger'; const globalCache: { get: (key: string) => Promise | string | null | undefined; @@ -24,7 +25,7 @@ if (config.cache.type === 'redis') { } }; globalCache.set = cacheModule.set; -} else { +} else if (config.cache.type === 'memory') { cacheModule = memory; cacheModule.init(); const { memoryCache } = cacheModule.clients; @@ -44,6 +45,17 @@ if (config.cache.type === 'redis') { return memoryCache.set(key, value, { ttl: maxAge * 1000 }); } }; +} else { + cacheModule = { + init: () => null, + get: () => null, + set: () => null, + status: { + available: false, + }, + clients: {}, + }; + logger.error('Cache not available, concurrent requests are not limited. This could lead to bad behavior.'); } // only give cache string, as the `!` condition tricky diff --git a/lib/utils/cache/memory.ts b/lib/utils/cache/memory.ts index b7ef70c742..395f35602c 100644 --- a/lib/utils/cache/memory.ts +++ b/lib/utils/cache/memory.ts @@ -3,24 +3,27 @@ import { config } from '@/config'; import type CacheModule from './base'; const status = { available: false }; - -let memoryCache: LRUCache | undefined; +const clients: { + memoryCache?: LRUCache; +} = {}; export default { init: () => { - memoryCache = new LRUCache({ + clients.memoryCache = new LRUCache({ ttl: config.cache.routeExpire * 1000, max: config.memory.max, }); status.available = true; }, get: (key: string, refresh = true) => { - if (key && status.available && memoryCache) { - let value = memoryCache.get(key, { updateAgeOnGet: refresh }) as string | undefined; + if (key && status.available && clients.memoryCache) { + let value = clients.memoryCache.get(key, { updateAgeOnGet: refresh }) as string | undefined; if (value) { value = value + ''; } return value; + } else { + return null; } }, set: (key, value, maxAge = config.cache.contentExpire) => { @@ -30,10 +33,10 @@ export default { if (typeof value === 'object') { value = JSON.stringify(value); } - if (key && status.available && memoryCache) { - return memoryCache.set(key, value, { ttl: maxAge * 1000 }); + if (key && status.available && clients.memoryCache) { + return clients.memoryCache.set(key, value, { ttl: maxAge * 1000 }); } }, - clients: { memoryCache }, + clients, status, } as CacheModule; diff --git a/lib/utils/cache/redis.ts b/lib/utils/cache/redis.ts index 73f0d63b73..7830b49feb 100644 --- a/lib/utils/cache/redis.ts +++ b/lib/utils/cache/redis.ts @@ -3,9 +3,10 @@ import Redis from 'ioredis'; import logger from '@/utils/logger'; import type CacheModule from './base'; -let redisClient: Redis | undefined; - const status = { available: false }; +const clients: { + redisClient?: Redis; +} = {}; const getCacheTtlKey = (key: string) => { if (key.startsWith('rsshub:cacheTtl:')) { @@ -16,43 +17,43 @@ const getCacheTtlKey = (key: string) => { export default { init: () => { - redisClient = new Redis(config.redis.url); + clients.redisClient = new Redis(config.redis.url); - const status = { available: false }; - - redisClient.on('error', (error) => { + clients.redisClient.on('error', (error) => { status.available = false; logger.error('Redis error: ', error); }); - redisClient.on('end', () => { + clients.redisClient.on('end', () => { status.available = false; }); - redisClient.on('connect', () => { + clients.redisClient.on('connect', () => { status.available = true; logger.info('Redis connected.'); }); }, get: async (key: string, refresh = true) => { - if (key && status.available && redisClient) { + if (key && status.available && clients.redisClient) { const cacheTtlKey = getCacheTtlKey(key); - let [value, cacheTtl] = await redisClient.mget(key, cacheTtlKey); + let [value, cacheTtl] = await clients.redisClient.mget(key, cacheTtlKey); if (value && refresh) { if (cacheTtl) { - redisClient.expire(cacheTtlKey, cacheTtl); + clients.redisClient.expire(cacheTtlKey, cacheTtl); } else { // if cacheTtl is not set, that means the cache expire time is contentExpire cacheTtl = config.cache.contentExpire + ''; // dont save cacheTtl to Redis, as it is the default value // redisClient.set(cacheTtlKey, cacheTtl, 'EX', cacheTtl); } - redisClient.expire(key, cacheTtl); + clients.redisClient.expire(key, cacheTtl); value = value + ''; } - return value; + return value || ''; + } else { + return null; } }, set: (key: string, value?: string | Record, maxAge = config.cache.contentExpire) => { - if (!status.available || !redisClient) { + if (!status.available || !clients.redisClient) { return; } if (!value || value === 'undefined') { @@ -64,11 +65,11 @@ export default { if (key) { if (maxAge !== config.cache.contentExpire) { // Only set cacheTtlKey if maxAge !== contentExpire - redisClient.set(getCacheTtlKey(key), maxAge, 'EX', maxAge); + clients.redisClient.set(getCacheTtlKey(key), maxAge, 'EX', maxAge); } - return redisClient.set(key, value, 'EX', maxAge); // setMode: https://redis.io/commands/set + return clients.redisClient.set(key, value, 'EX', maxAge); // setMode: https://redis.io/commands/set } }, - clients: { redisClient }, + clients, status, } as CacheModule;