import { GlobalConfig } from '@n8n/config'; import { caching } from 'cache-manager'; import { ApplicationError, jsonStringify } from 'n8n-workflow'; import Container, { Service } from 'typedi'; import config from '@/config'; import { TIME } from '@/constants'; import { MalformedRefreshValueError } from '@/errors/cache-errors/malformed-refresh-value.error'; import { UncacheableValueError } from '@/errors/cache-errors/uncacheable-value.error'; import type { TaggedRedisCache, TaggedMemoryCache, MaybeHash, Hash, } from '@/services/cache/cache.types'; import { TypedEmitter } from '@/typed-emitter'; type CacheEvents = { 'metrics.cache.hit': never; 'metrics.cache.miss': never; 'metrics.cache.update': never; }; @Service() export class CacheService extends TypedEmitter { constructor(private readonly globalConfig: GlobalConfig) { super(); } private cache: TaggedRedisCache | TaggedMemoryCache; async init() { const { backend } = this.globalConfig.cache; const mode = config.getEnv('executions.mode'); const useRedis = backend === 'redis' || (backend === 'auto' && mode === 'queue'); if (useRedis) { const { RedisClientService } = await import('../redis-client.service'); const redisClientService = Container.get(RedisClientService); const prefixBase = config.getEnv('redis.prefix'); const prefix = redisClientService.toValidPrefix( `${prefixBase}:${this.globalConfig.cache.redis.prefix}:`, ); const redisClient = redisClientService.createClient({ type: 'cache(n8n)', extraOptions: { keyPrefix: prefix }, }); const { redisStoreUsingClient } = await import('@/services/cache/redis.cache-manager'); const redisStore = redisStoreUsingClient(redisClient, { ttl: this.globalConfig.cache.redis.ttl, }); const redisCache = await caching(redisStore); this.cache = { ...redisCache, kind: 'redis' }; return; } const { maxSize, ttl } = this.globalConfig.cache.memory; const sizeCalculation = (item: unknown) => { const str = jsonStringify(item, { replaceCircularRefs: true }); return new TextEncoder().encode(str).length; }; const memoryCache = await caching('memory', { ttl, maxSize, sizeCalculation }); this.cache = { ...memoryCache, kind: 'memory' }; } async reset() { await this.cache.store.reset(); } isRedis() { return this.cache.kind === 'redis'; } isMemory() { return this.cache.kind === 'memory'; } // ---------------------------------- // storing // ---------------------------------- async set(key: string, value: unknown, ttl?: number) { if (!this.cache) await this.init(); if (!key || !value) return; if (this.cache.kind === 'redis' && !this.cache.store.isCacheable(value)) { throw new UncacheableValueError(key); } await this.cache.store.set(key, value, ttl); } async setMany(keysValues: Array<[key: string, value: unknown]>, ttl?: number) { if (!this.cache) await this.init(); if (keysValues.length === 0) return; const truthyKeysValues = keysValues.filter( ([key, value]) => key?.length > 0 && value !== undefined && value !== null, ); if (this.cache.kind === 'redis') { for (const [key, value] of truthyKeysValues) { if (!this.cache.store.isCacheable(value)) { throw new UncacheableValueError(key); } } } await this.cache.store.mset(truthyKeysValues, ttl); } /** * Set or append to a [Redis hash](https://redis.io/docs/data-types/hashes/) * stored under a key in the cache. If in-memory, the hash is a regular JS object. */ async setHash(key: string, hash: Hash) { if (!this.cache) await this.init(); if (!key?.length) return; for (const hashKey in hash) { if (hash[hashKey] === undefined || hash[hashKey] === null) return; } if (this.cache.kind === 'redis') { await this.cache.store.hset(key, hash); return; } const hashObject: Hash = (await this.get(key)) ?? {}; Object.assign(hashObject, hash); await this.set(key, hashObject); } async expire(key: string, ttlMs: number) { if (!this.cache) await this.init(); if (!key?.length) return; if (this.cache.kind === 'memory') { throw new ApplicationError('Method `expire` not yet implemented for in-memory cache', { level: 'warning', }); } await this.cache.store.expire(key, ttlMs / TIME.SECOND); } // ---------------------------------- // retrieving // ---------------------------------- /** * Retrieve a primitive value under a key. To retrieve a hash, use `getHash`, and * to retrieve a primitive value in a hash, use `getHashValue`. */ async get( key: string, { fallbackValue, refreshFn, }: { fallbackValue?: T; refreshFn?: (key: string) => Promise } = {}, ) { if (!this.cache) await this.init(); if (key?.length === 0) return; const value = await this.cache.store.get(key); if (value !== undefined) { this.emit('metrics.cache.hit'); return value; } this.emit('metrics.cache.miss'); if (refreshFn) { this.emit('metrics.cache.update'); const refreshValue = await refreshFn(key); await this.set(key, refreshValue); return refreshValue; } return fallbackValue; } async getMany( keys: string[], { fallbackValue, refreshFn, }: { fallbackValue?: T[]; refreshFn?: (keys: string[]) => Promise; } = {}, ) { if (!this.cache) await this.init(); if (keys.length === 0) return []; const values = await this.cache.store.mget(...keys); if (values !== undefined) { this.emit('metrics.cache.hit'); return values as T[]; } this.emit('metrics.cache.miss'); if (refreshFn) { this.emit('metrics.cache.update'); const refreshValue: T[] = await refreshFn(keys); if (keys.length !== refreshValue.length) { throw new MalformedRefreshValueError(); } const newValue: Array<[key: string, value: unknown]> = keys.map((key, i) => [ key, refreshValue[i], ]); await this.setMany(newValue); return refreshValue; } return fallbackValue; } /** * Retrieve a [Redis hash](https://redis.io/docs/data-types/hashes/) under a key. * If in-memory, the hash is a regular JS object. To retrieve a primitive value * in the hash, use `getHashValue`. */ async getHash( key: string, { fallbackValue, refreshFn, }: { fallbackValue?: T; refreshFn?: (key: string) => Promise> } = {}, ) { if (!this.cache) await this.init(); const hash: MaybeHash = this.cache.kind === 'redis' ? await this.cache.store.hgetall(key) : await this.get(key); if (hash !== undefined) { this.emit('metrics.cache.hit'); return hash; } this.emit('metrics.cache.miss'); if (refreshFn) { this.emit('metrics.cache.update'); const refreshValue = await refreshFn(key); await this.set(key, refreshValue); return refreshValue; } return fallbackValue as MaybeHash; } /** * Retrieve a primitive value in a [Redis hash](https://redis.io/docs/data-types/hashes/) * under a hash key. If in-memory, the hash is a regular JS object. To retrieve the hash * itself, use `getHash`. */ async getHashValue( cacheKey: string, hashKey: string, { fallbackValue, refreshFn, }: { fallbackValue?: T; refreshFn?: (key: string) => Promise } = {}, ) { if (!this.cache) await this.init(); let hashValue: MaybeHash; if (this.cache.kind === 'redis') { hashValue = await this.cache.store.hget(cacheKey, hashKey); } else { const hashObject = await this.cache.store.get>(cacheKey); hashValue = hashObject?.[hashKey] as MaybeHash; } if (hashValue !== undefined) { this.emit('metrics.cache.hit'); return hashValue as T; } this.emit('metrics.cache.miss'); if (refreshFn) { this.emit('metrics.cache.update'); const refreshValue = await refreshFn(cacheKey); await this.set(cacheKey, refreshValue); return refreshValue; } return fallbackValue; } // ---------------------------------- // deleting // ---------------------------------- async delete(key: string) { if (!this.cache) await this.init(); if (!key?.length) return; await this.cache.store.del(key); } async deleteMany(keys: string[]) { if (!this.cache) await this.init(); if (keys.length === 0) return; return await this.cache.store.mdel(...keys); } /** * Delete a value under a key in a [Redis hash](https://redis.io/docs/data-types/hashes/). * If in-memory, the hash is a regular JS object. To delete the hash itself, use `delete`. */ async deleteFromHash(cacheKey: string, hashKey: string) { if (!this.cache) await this.init(); if (!cacheKey || !hashKey) return; if (this.cache.kind === 'redis') { await this.cache.store.hdel(cacheKey, hashKey); return; } const hashObject = await this.get(cacheKey); if (!hashObject) return; delete hashObject[hashKey]; await this.cache.store.set(cacheKey, hashObject); } }