n8n/packages/cli/test/unit/services/cache.service.test.ts
Iván Ovejero f53c482939
perf(core): Improve caching service (#8213)
Story: https://linear.app/n8n/issue/PAY-1188

- Implement Redis hashes on the caching service, based on Micha's work
in #7747, adapted from `node-cache-manager-ioredis-yet`. Optimize
workflow ownership lookups and manual webhook lookups with Redis hashes.
- Simplify the caching service by removing all currently unused methods
and options: `enable`, `disable`, `getCache`, `keys`, `keyValues`,
`refreshFunctionEach`, `refreshFunctionMany`, `refreshTtl`, etc.
- Remove the flag `N8N_CACHE_ENABLED`. Currently some features on
`master` are broken with caching disabled, and test webhooks now rely
entirely on caching, for multi-main setup support. We originally
introduced this flag to protect against excessive memory usage, but
total cache usage is low enough that we decided to drop this setting.
Apparently this flag was also never documented.
- Overall caching service refactor: use generics, reduce branching, add
discriminants for cache kinds for better type safety, type caching
events, improve readability, remove outdated docs, etc. Also refactor
and expand caching service tests.

Follow-up to: https://github.com/n8n-io/n8n/pull/8176

---------

Co-authored-by: Michael Auerswald <michael.auerswald@gmail.com>
2024-01-05 11:52:44 +01:00

241 lines
6.8 KiB
TypeScript

import { CacheService } from '@/services/cache/cache.service';
import config from '@/config';
import { sleep } from 'n8n-workflow';
jest.mock('ioredis', () => {
const Redis = require('ioredis-mock');
return function (...args: unknown[]) {
return new Redis(args);
};
});
for (const backend of ['memory', 'redis'] as const) {
describe(backend, () => {
let cacheService: CacheService;
beforeAll(async () => {
config.set('cache.backend', backend);
cacheService = new CacheService();
await cacheService.init();
});
afterEach(async () => {
await cacheService.reset();
config.load(config.default);
});
describe('init', () => {
test('should select backend based on config', () => {
expect(cacheService.isMemory()).toBe(backend === 'memory');
expect(cacheService.isRedis()).toBe(backend === 'redis');
});
if (backend === 'redis') {
test('with auto backend and queue mode, should select redis', async () => {
config.set('executions.mode', 'queue');
await cacheService.init();
expect(cacheService.isRedis()).toBe(true);
});
}
if (backend === 'memory') {
test('should honor max size when enough', async () => {
config.set('cache.memory.maxSize', 16); // enough bytes for "withoutUnicode"
await cacheService.init();
await cacheService.set('key', 'withoutUnicode');
await expect(cacheService.get('key')).resolves.toBe('withoutUnicode');
// restore
config.set('cache.memory.maxSize', 3 * 1024 * 1024);
await cacheService.init();
});
test('should honor max size when not enough', async () => {
config.set('cache.memory.maxSize', 16); // not enough bytes for "withUnicodeԱԲԳ"
await cacheService.init();
await cacheService.set('key', 'withUnicodeԱԲԳ');
await expect(cacheService.get('key')).resolves.toBeUndefined();
// restore
config.set('cache.memory.maxSize', 3 * 1024 * 1024);
await cacheService.init();
});
}
});
describe('set', () => {
test('should set a string value', async () => {
await cacheService.set('key', 'value');
await expect(cacheService.get('key')).resolves.toBe('value');
});
test('should set a number value', async () => {
await cacheService.set('key', 123);
await expect(cacheService.get('key')).resolves.toBe(123);
});
test('should set an object value', async () => {
const object = { a: { b: { c: { d: 1 } } } };
await cacheService.set('key', object);
await expect(cacheService.get('key')).resolves.toMatchObject(object);
});
test('should not cache `null` or `undefined` values', async () => {
await cacheService.set('key1', null);
await cacheService.set('key2', undefined);
await cacheService.set('key3', 'value');
await expect(cacheService.get('key1')).resolves.toBeUndefined();
await expect(cacheService.get('key2')).resolves.toBeUndefined();
await expect(cacheService.get('key3')).resolves.toBe('value');
});
test('should disregard zero-length keys', async () => {
await cacheService.set('', 'value');
await expect(cacheService.get('')).resolves.toBeUndefined();
});
test('should honor ttl', async () => {
await cacheService.set('key', 'value', 100);
await expect(cacheService.get('key')).resolves.toBe('value');
await sleep(200);
await expect(cacheService.get('key')).resolves.toBeUndefined();
});
});
describe('get', () => {
test('should fall back to fallback value', async () => {
const promise = cacheService.get('key', { fallbackValue: 'fallback' });
await expect(promise).resolves.toBe('fallback');
});
test('should refresh value', async () => {
const promise = cacheService.get('testString', {
refreshFn: async () => 'refreshValue',
});
await expect(promise).resolves.toBe('refreshValue');
});
test('should handle non-ASCII key', async () => {
const nonAsciiKey = 'ԱԲԳ';
await cacheService.set(nonAsciiKey, 'value');
await expect(cacheService.get(nonAsciiKey)).resolves.toBe('value');
});
});
describe('delete', () => {
test('should delete a key', async () => {
await cacheService.set('key', 'value');
await cacheService.delete('key');
await expect(cacheService.get('key')).resolves.toBeUndefined();
});
});
describe('setMany', () => {
test('should set multiple string values', async () => {
await cacheService.setMany([
['key1', 'value1'],
['key2', 'value2'],
]);
const promise = cacheService.getMany(['key1', 'key2']);
await expect(promise).resolves.toStrictEqual(['value1', 'value2']);
});
test('should set multiple number values', async () => {
await cacheService.setMany([
['key1', 123],
['key2', 456],
]);
const promise = cacheService.getMany(['key1', 'key2']);
await expect(promise).resolves.toStrictEqual([123, 456]);
});
test('should disregard zero-length keys', async () => {
await cacheService.setMany([['', 'value1']]);
await expect(cacheService.get('')).resolves.toBeUndefined();
});
});
describe('getMany', () => {
test('should return undefined on missing result', async () => {
await cacheService.setMany([
['key1', 123],
['key2', 456],
]);
const promise = cacheService.getMany(['key2', 'key3']);
await expect(promise).resolves.toStrictEqual([456, undefined]);
});
});
describe('delete', () => {
test('should handle non-ASCII key', async () => {
const nonAsciiKey = 'ԱԲԳ';
await cacheService.set(nonAsciiKey, 'value');
await expect(cacheService.get(nonAsciiKey)).resolves.toBe('value');
await cacheService.delete(nonAsciiKey);
await expect(cacheService.get(nonAsciiKey)).resolves.toBeUndefined();
});
});
describe('setHash', () => {
test('should set a hash if non-existing', async () => {
await cacheService.setHash('keyW', { field: 'value' });
await expect(cacheService.getHash('keyW')).resolves.toStrictEqual({ field: 'value' });
});
test('should add to a hash value if existing', async () => {
await cacheService.setHash('key', { field1: 'value1' });
await cacheService.setHash('key', { field2: 'value2' });
await expect(cacheService.getHash('key')).resolves.toStrictEqual({
field1: 'value1',
field2: 'value2',
});
});
});
describe('deleteFromHash', () => {
test('should delete a hash field', async () => {
await cacheService.setHash('key', { field1: 'value1', field2: 'value2' });
await cacheService.deleteFromHash('key', 'field1');
await expect(cacheService.getHash('key')).resolves.toStrictEqual({ field2: 'value2' });
});
});
describe('getHashValue', () => {
test('should return a hash field value', async () => {
await cacheService.setHash('key', { field1: 'value1', field2: 'value2' });
await expect(cacheService.getHashValue('key', 'field1')).resolves.toBe('value1');
});
});
});
}