Skip to content

Commit

Permalink
feat(core): Implement redis cache plugin
Browse files Browse the repository at this point in the history
Relates to #3043
  • Loading branch information
michaelbromley committed Oct 29, 2024
1 parent 382e314 commit 9d99593
Show file tree
Hide file tree
Showing 10 changed files with 308 additions and 15 deletions.
12 changes: 12 additions & 0 deletions packages/core/e2e/cache-service-default.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ import {
invalidatesALargeNumberOfKeysByTag,
invalidatesByMultipleTags,
invalidatesBySingleTag,
invalidatesManyByMultipleTags,
setsAKey,
setsAKeyWithTtl,
setsArrayOfObjects,
setsArrayValue,
setsObjectValue,
} from './fixtures/cache-service-shared-tests';

describe('CacheService with DefaultCachePlugin (sql)', () => {
Expand Down Expand Up @@ -52,6 +56,12 @@ describe('CacheService with DefaultCachePlugin (sql)', () => {

it('sets a key', () => setsAKey(cacheService));

it('sets an object value', () => setsObjectValue(cacheService));

it('sets an array value', () => setsArrayValue(cacheService));

it('sets an array of objects', () => setsArrayOfObjects(cacheService));

it('deletes a key', () => deletesAKey(cacheService));

it('sets a key with ttl', () => setsAKeyWithTtl(cacheService, ttlProvider));
Expand All @@ -62,5 +72,7 @@ describe('CacheService with DefaultCachePlugin (sql)', () => {

it('invalidates by multiple tags', () => invalidatesByMultipleTags(cacheService));

it('invalidates many by multiple tags', () => invalidatesManyByMultipleTags(cacheService));

it('invalidates a large number of keys by tag', () => invalidatesALargeNumberOfKeysByTag(cacheService));
});
12 changes: 12 additions & 0 deletions packages/core/e2e/cache-service-in-memory.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ import {
invalidatesALargeNumberOfKeysByTag,
invalidatesByMultipleTags,
invalidatesBySingleTag,
invalidatesManyByMultipleTags,
setsAKey,
setsAKeyWithTtl,
setsArrayOfObjects,
setsArrayValue,
setsObjectValue,
} from './fixtures/cache-service-shared-tests';

describe('CacheService in-memory', () => {
Expand Down Expand Up @@ -53,6 +57,12 @@ describe('CacheService in-memory', () => {

it('sets a key', () => setsAKey(cacheService));

it('sets an object value', () => setsObjectValue(cacheService));

it('sets an array value', () => setsArrayValue(cacheService));

it('sets an array of objects', () => setsArrayOfObjects(cacheService));

it('deletes a key', () => deletesAKey(cacheService));

it('sets a key with ttl', () => setsAKeyWithTtl(cacheService, ttlProvider));
Expand All @@ -63,5 +73,7 @@ describe('CacheService in-memory', () => {

it('invalidates by multiple tags', () => invalidatesByMultipleTags(cacheService));

it('invalidates many by multiple tags', () => invalidatesManyByMultipleTags(cacheService));

it('invalidates a large number of keys by tag', () => invalidatesALargeNumberOfKeysByTag(cacheService));
});
73 changes: 73 additions & 0 deletions packages/core/e2e/cache-service-redis.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { CacheService, mergeConfig, RedisCachePlugin } from '@vendure/core';
import { createTestEnvironment } from '@vendure/testing';
import path from 'path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { initialData } from '../../../e2e-common/e2e-initial-data';
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';

import {
deletesAKey,
evictsTheOldestKeyWhenCacheIsFull,
getReturnsUndefinedForNonExistentKey,
invalidatesALargeNumberOfKeysByTag,
invalidatesByMultipleTags,
invalidatesBySingleTag,
invalidatesManyByMultipleTags,
setsAKey,
setsAKeyWithTtl,
setsArrayOfObjects,
setsArrayValue,
setsObjectValue,
} from './fixtures/cache-service-shared-tests';

describe('CacheService with RedisCachePlugin', () => {
let cacheService: CacheService;
const { server, adminClient } = createTestEnvironment(
mergeConfig(testConfig(), {
plugins: [
RedisCachePlugin.init({
redisOptions: {
host: '127.0.0.1',
port: process.env.CI ? +(process.env.E2E_REDIS_PORT || 6379) : 6379,
},
}),
],
}),
);

beforeAll(async () => {
await server.init({
initialData,
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
customerCount: 1,
});
await adminClient.asSuperAdmin();
cacheService = server.app.get(CacheService);
}, TEST_SETUP_TIMEOUT_MS);

afterAll(async () => {
await server.destroy();
});

it('get returns undefined for non-existent key', () =>
getReturnsUndefinedForNonExistentKey(cacheService));

it('sets a key', () => setsAKey(cacheService));

it('sets an object value', () => setsObjectValue(cacheService));

it('sets an array value', () => setsArrayValue(cacheService));

it('sets an array of objects', () => setsArrayOfObjects(cacheService));

it('deletes a key', () => deletesAKey(cacheService));

it('invalidates by single tag', () => invalidatesBySingleTag(cacheService));

it('invalidates by multiple tags', () => invalidatesByMultipleTags(cacheService));

it('invalidates many by multiple tags', () => invalidatesManyByMultipleTags(cacheService));

it('invalidates a large number of keys by tag', () => invalidatesALargeNumberOfKeysByTag(cacheService));
});
68 changes: 54 additions & 14 deletions packages/core/e2e/fixtures/cache-service-shared-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,30 @@ export async function setsAKey(cacheService: CacheService) {
expect(result).toBe('test-value');
}

export async function setsObjectValue(cacheService: CacheService) {
const obj = { name: 'test', value: 42 };
await cacheService.set('test-key', obj);
const result = await cacheService.get('test-key');
expect(result).toEqual(obj);
}

export async function setsArrayValue(cacheService: CacheService) {
const arr = [1, 2, 3, 4, 5];
await cacheService.set('test-key', arr);
const result = await cacheService.get('test-key');
expect(result).toEqual(arr);
}

export async function setsArrayOfObjects(cacheService: CacheService) {
const arr = [
{ name: 'test1', value: 42 },
{ name: 'test2', value: 43 },
];
await cacheService.set('test-key', arr);
const result = await cacheService.get('test-key');
expect(result).toEqual(arr);
}

export async function deletesAKey(cacheService: CacheService) {
await cacheService.set('test-key', 'test-value');
await cacheService.delete('test-key');
Expand Down Expand Up @@ -52,29 +76,45 @@ export async function evictsTheOldestKeyWhenCacheIsFull(cacheService: CacheServi
}

export async function invalidatesBySingleTag(cacheService: CacheService) {
await cacheService.set('taggedKey1', 'value1', { tags: ['tag1'] });
await cacheService.set('taggedKey2', 'value2', { tags: ['tag2'] });
await cacheService.set('taggedKey1.1', 'value1', { tags: ['tag1.1'] });
await cacheService.set('taggedKey1.2', 'value2', { tags: ['tag1.2'] });

expect(await cacheService.get('taggedKey1')).toBe('value1');
expect(await cacheService.get('taggedKey2')).toBe('value2');
expect(await cacheService.get('taggedKey1.1')).toBe('value1');
expect(await cacheService.get('taggedKey1.2')).toBe('value2');

await cacheService.invalidateTags(['tag1']);
await cacheService.invalidateTags(['tag1.1']);

expect(await cacheService.get('taggedKey1')).toBeUndefined();
expect(await cacheService.get('taggedKey2')).toBe('value2');
expect(await cacheService.get('taggedKey1.1')).toBeUndefined();
expect(await cacheService.get('taggedKey1.2')).toBe('value2');
}

export async function invalidatesByMultipleTags(cacheService: CacheService) {
await cacheService.set('taggedKey1', 'value1', { tags: ['tag1'] });
await cacheService.set('taggedKey2', 'value2', { tags: ['tag2'] });
await cacheService.set('taggedKey2.1', 'value1', { tags: ['tag2.1'] });
await cacheService.set('taggedKey2.2', 'value2', { tags: ['tag2.2'] });

expect(await cacheService.get('taggedKey2.1')).toBe('value1');
expect(await cacheService.get('taggedKey2.2')).toBe('value2');

await cacheService.invalidateTags(['tag2.1', 'tag2.2']);

expect(await cacheService.get('taggedKey2.1')).toBeUndefined();
expect(await cacheService.get('taggedKey2.2')).toBeUndefined();
}

expect(await cacheService.get('taggedKey1')).toBe('value1');
expect(await cacheService.get('taggedKey2')).toBe('value2');
export async function invalidatesManyByMultipleTags(cacheService: CacheService) {
await cacheService.set('taggedKey3.1', 'data', { tags: ['tag3.1', 'tag3.4'] });
await cacheService.set('taggedKey3.2', 'data', { tags: ['tag3.2', 'tag3.1'] });
await cacheService.set('taggedKey3.3', 'data', { tags: ['tag3.4'] });
await cacheService.set('taggedKey3.4', 'data', { tags: ['tag3.1'] });
await cacheService.set('taggedKey3.5', 'data', { tags: ['tag3.2'] });

await cacheService.invalidateTags(['tag1', 'tag2']);
await cacheService.invalidateTags(['tag3.2', 'tag3.4']);

expect(await cacheService.get('taggedKey1')).toBeUndefined();
expect(await cacheService.get('taggedKey2')).toBeUndefined();
expect(await cacheService.get('taggedKey3.1')).toBeUndefined();
expect(await cacheService.get('taggedKey3.2')).toBeUndefined();
expect(await cacheService.get('taggedKey3.3')).toBeUndefined();
expect(await cacheService.get('taggedKey3.4')).toBe('data');
expect(await cacheService.get('taggedKey3.5')).toBeUndefined();
}

export async function invalidatesALargeNumberOfKeysByTag(cacheService: CacheService) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { JsonCompatible } from '@vendure/common/lib/shared-types';

import { CacheTtlProvider, DefaultCacheTtlProvider } from '../../cache/cache-ttl-provider';
import { Injector } from '../../common/index';
import { Injector } from '../../common/injector';
import { ConfigService, Logger } from '../../config/index';
import { CacheStrategy, SetCacheKeyOptions } from '../../config/system/cache-strategy';
import { TransactionalConnection } from '../../connection/index';
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export * from './default-job-queue-plugin/job-record-buffer.entity';
export * from './default-job-queue-plugin/sql-job-buffer-storage-strategy';
export * from './default-cache-plugin/default-cache-plugin';
export * from './default-cache-plugin/sql-cache-strategy';
export * from './redis-cache-plugin/redis-cache-plugin';
export * from './redis-cache-plugin/redis-cache-strategy';
export * from './redis-cache-plugin/types';
export * from './vendure-plugin';
export * from './plugin-common.module';
export * from './plugin-utils';
4 changes: 4 additions & 0 deletions packages/core/src/plugin/redis-cache-plugin/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const PLUGIN_INIT_OPTIONS = Symbol('PLUGIN_INIT_OPTIONS');
export const loggerCtx = 'RedisCacheStrategy';
export const DEFAULT_NAMESPACE = 'vendure-cache';
export const DEFAULT_TTL = 86400 * 30;
28 changes: 28 additions & 0 deletions packages/core/src/plugin/redis-cache-plugin/redis-cache-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { PluginCommonModule } from '../plugin-common.module';
import { VendurePlugin } from '../vendure-plugin';

import { PLUGIN_INIT_OPTIONS } from './constants';
import { RedisCacheStrategy } from './redis-cache-strategy';
import { RedisCachePluginInitOptions } from './types';

@VendurePlugin({
imports: [PluginCommonModule],
providers: [{ provide: PLUGIN_INIT_OPTIONS, useFactory: () => RedisCachePlugin.options }],
configuration: config => {
config.systemOptions.cacheStrategy = new RedisCacheStrategy(RedisCachePlugin.options);
return config;
},
compatibility: '>0.0.0',
})
export class RedisCachePlugin {
static options: RedisCachePluginInitOptions = {
maxItemSizeInBytes: 128_000,
redisOptions: {},
namespace: 'vendure-cache',
};

static init(options: RedisCachePluginInitOptions) {
this.options = options;
return RedisCachePlugin;
}
}
107 changes: 107 additions & 0 deletions packages/core/src/plugin/redis-cache-plugin/redis-cache-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { JsonCompatible } from '@vendure/common/lib/shared-types';

import { Logger } from '../../config/logger/vendure-logger';
import { CacheStrategy, SetCacheKeyOptions } from '../../config/system/cache-strategy';

import { DEFAULT_NAMESPACE, DEFAULT_TTL, loggerCtx } from './constants';
import { RedisCachePluginInitOptions } from './types';

export class RedisCacheStrategy implements CacheStrategy {
private client: import('ioredis').Redis;

constructor(private options: RedisCachePluginInitOptions) {}

async init() {
const IORedis = await import('ioredis').then(m => m.default);
this.client = new IORedis.Redis(this.options.redisOptions ?? {});
this.client.on('error', err => Logger.error(err.message, loggerCtx, err.stack));
}
async destroy() {
await this.client.quit();
}

async get<T extends JsonCompatible<T>>(key: string): Promise<T | undefined> {
try {
const retrieved = await this.client.get(this.namespace(key));
if (retrieved) {
try {
return JSON.parse(retrieved);
} catch (e: any) {
Logger.error(`Could not parse cache item ${key}: ${e.message as string}`, loggerCtx);
}
}
} catch (e: any) {
Logger.error(`Could not get cache item ${key}: ${e.message as string}`, loggerCtx);
}
}
async set<T extends JsonCompatible<T>>(
key: string,
value: T,
options?: SetCacheKeyOptions,
): Promise<void> {
try {
const multi = this.client.multi();
const ttl = options?.ttl ? options.ttl / 1000 : DEFAULT_TTL;
const namedspacedKey = this.namespace(key);
const serializedValue = JSON.stringify(value);
if (this.options.maxItemSizeInBytes) {
if (Buffer.byteLength(serializedValue) > this.options.maxItemSizeInBytes) {
Logger.error(
`Could not set cache item ${key}: item size of ${Buffer.byteLength(
serializedValue,
)} bytes exceeds maxItemSizeInBytes of ${this.options.maxItemSizeInBytes} bytes`,
loggerCtx,
);
return;
}
}
multi.set(namedspacedKey, JSON.stringify(value), 'EX', ttl);
if (options?.tags) {
for (const tag of options.tags) {
multi.sadd(this.tagNamespace(tag), namedspacedKey);
}
}
await multi.exec();
} catch (e: any) {
Logger.error(`Could not set cache item ${key}: ${e.message as string}`, loggerCtx);
}
}

async delete(key: string): Promise<void> {
try {
await this.client.del(this.namespace(key));
} catch (e: any) {
Logger.error(`Could not delete cache item ${key}: ${e.message as string}`, loggerCtx);
}
}

async invalidateTags(tags: string[]): Promise<void> {
try {
const keys = [
...(await Promise.all(tags.map(tag => this.client.smembers(this.tagNamespace(tag))))),
];
const pipeline = this.client.pipeline();

keys.forEach(key => {
pipeline.del(key);
});

tags.forEach(tag => {
const namespacedTag = this.tagNamespace(tag);
pipeline.del(namespacedTag);
});

await pipeline.exec();
} catch (err) {
return Promise.reject(err);
}
}

private namespace(key: string) {
return `${this.options.namespace ?? DEFAULT_NAMESPACE}:${key}`;
}

private tagNamespace(tag: string) {
return `${this.options.namespace ?? DEFAULT_NAMESPACE}:tag:${tag}`;
}
}
Loading

0 comments on commit 9d99593

Please sign in to comment.