Kaynağa Gözat

feat(core): Implement redis cache plugin

Relates to #3043
Michael Bromley 1 yıl önce
ebeveyn
işleme
9d99593778

+ 12 - 0
packages/core/e2e/cache-service-default.e2e-spec.ts

@@ -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)', () => {
@@ -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));
@@ -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 - 0
packages/core/e2e/cache-service-in-memory.e2e-spec.ts

@@ -15,8 +15,12 @@ import {
     invalidatesALargeNumberOfKeysByTag,
     invalidatesByMultipleTags,
     invalidatesBySingleTag,
+    invalidatesManyByMultipleTags,
     setsAKey,
     setsAKeyWithTtl,
+    setsArrayOfObjects,
+    setsArrayValue,
+    setsObjectValue,
 } from './fixtures/cache-service-shared-tests';
 
 describe('CacheService in-memory', () => {
@@ -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));
@@ -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 - 0
packages/core/e2e/cache-service-redis.e2e-spec.ts

@@ -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));
+});

+ 54 - 14
packages/core/e2e/fixtures/cache-service-shared-tests.ts

@@ -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');
@@ -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) {

+ 1 - 1
packages/core/src/plugin/default-cache-plugin/sql-cache-strategy.ts

@@ -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';

+ 3 - 0
packages/core/src/plugin/index.ts

@@ -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 - 0
packages/core/src/plugin/redis-cache-plugin/constants.ts

@@ -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 - 0
packages/core/src/plugin/redis-cache-plugin/redis-cache-plugin.ts

@@ -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 - 0
packages/core/src/plugin/redis-cache-plugin/redis-cache-strategy.ts

@@ -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}`;
+    }
+}

+ 14 - 0
packages/core/src/plugin/redis-cache-plugin/types.ts

@@ -0,0 +1,14 @@
+import { CacheTtlProvider } from '../../cache/cache-ttl-provider';
+
+export interface RedisCachePluginInitOptions {
+    /**
+     * @description
+     * The maximum size of a single cache item in bytes. If a cache item exceeds this size, it will not be stored
+     * and an error will be logged.
+     *
+     * @default 128kb
+     */
+    maxItemSizeInBytes?: number;
+    namespace?: string;
+    redisOptions?: import('ioredis').RedisOptions;
+}