Browse Source

feat(core): Initial DefaultCachePlugin implementation

Relates to #3043. This plugin implements a simple SQL cache strategy to store cache items
in the main database. The implementation needs further testing and potential
performance optimization.
Michael Bromley 1 year ago
parent
commit
9c2433f8ab

+ 21 - 0
packages/core/src/plugin/default-cache-plugin/cache-item.entity.ts

@@ -0,0 +1,21 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { Column, Entity, Index } from 'typeorm';
+
+import { VendureEntity } from '../../entity/base/base.entity';
+
+@Entity()
+export class CacheItem extends VendureEntity {
+    constructor(input: DeepPartial<CacheItem>) {
+        super(input);
+    }
+
+    @Index('cache_item_key')
+    @Column({ unique: true })
+    key: string;
+
+    @Column('text')
+    value: string;
+
+    @Column({ nullable: true })
+    expiresAt?: Date;
+}

+ 1 - 0
packages/core/src/plugin/default-cache-plugin/constants.ts

@@ -0,0 +1 @@
+export const PLUGIN_INIT_OPTIONS = Symbol('PLUGIN_INIT_OPTIONS');

+ 33 - 0
packages/core/src/plugin/default-cache-plugin/default-cache-plugin.ts

@@ -0,0 +1,33 @@
+import { PluginCommonModule } from '../plugin-common.module';
+import { VendurePlugin } from '../vendure-plugin';
+
+import { CacheItem } from './cache-item.entity';
+import { PLUGIN_INIT_OPTIONS } from './constants';
+import { SqlCacheStrategy } from './sql-cache-strategy';
+
+export interface DefaultCachePluginInitOptions {
+    cacheSize?: number;
+}
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    entities: [CacheItem],
+    providers: [{ provide: PLUGIN_INIT_OPTIONS, useFactory: () => DefaultCachePlugin.options }],
+    configuration: config => {
+        config.systemOptions.cacheStrategy = new SqlCacheStrategy({
+            cacheSize: DefaultCachePlugin.options.cacheSize,
+        });
+        return config;
+    },
+    compatibility: '>0.0.0',
+})
+export class DefaultCachePlugin {
+    static options: DefaultCachePluginInitOptions = {
+        cacheSize: 10_000,
+    };
+
+    static init(options: DefaultCachePluginInitOptions) {
+        this.options = options;
+        return DefaultCachePlugin;
+    }
+}

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

@@ -0,0 +1,102 @@
+import { JsonCompatible } from '@vendure/common/lib/shared-types';
+
+import { Injector } from '../../common/index';
+import { ConfigService, Logger } from '../../config/index';
+import { CacheStrategy, SetCacheKeyOptions } from '../../config/system/cache-strategy';
+import { TransactionalConnection } from '../../connection/index';
+
+import { CacheItem } from './cache-item.entity';
+
+/**
+ * A {@link CacheStrategy} that stores the cache in memory using a simple
+ * JavaScript Map.
+ *
+ * **Caution** do not use this in a multi-instance deployment because
+ * cache invalidation will not propagate to other instances.
+ *
+ * @since 3.1.0
+ */
+export class SqlCacheStrategy implements CacheStrategy {
+    protected cacheSize = 10_000;
+
+    constructor(config?: { cacheSize?: number }) {
+        if (config?.cacheSize) {
+            this.cacheSize = config.cacheSize;
+        }
+    }
+
+    protected connection: TransactionalConnection;
+    protected configService: ConfigService;
+
+    init(injector: Injector) {
+        this.connection = injector.get(TransactionalConnection);
+        this.configService = injector.get(ConfigService);
+    }
+
+    async get<T extends JsonCompatible<T>>(key: string): Promise<T | undefined> {
+        const hit = await this.connection.rawConnection.getRepository(CacheItem).findOne({
+            where: {
+                key,
+            },
+        });
+
+        if (hit) {
+            const now = new Date().getTime();
+            if (!hit.expiresAt || (hit.expiresAt && now < hit.expiresAt.getTime())) {
+                try {
+                    return JSON.parse(hit.value);
+                } catch (e: any) {
+                    /* */
+                }
+            } else {
+                await this.connection.rawConnection.getRepository(CacheItem).delete({
+                    key,
+                });
+            }
+        }
+    }
+
+    async set<T extends JsonCompatible<T>>(key: string, value: T, options?: SetCacheKeyOptions) {
+        const cacheSize = await this.connection.rawConnection.getRepository(CacheItem).count();
+        if (cacheSize > this.cacheSize) {
+            // evict oldest
+            const subQuery1 = this.connection.rawConnection
+                .getRepository(CacheItem)
+                .createQueryBuilder('item')
+                .select('item.id', 'item_id')
+                .orderBy('item.updatedAt', 'DESC')
+                .limit(1000)
+                .offset(this.cacheSize);
+            const subQuery2 = this.connection.rawConnection
+                .createQueryBuilder()
+                .select('t.item_id')
+                .from(`(${subQuery1.getQuery()})`, 't');
+            const qb = this.connection.rawConnection
+                .getRepository(CacheItem)
+                .createQueryBuilder('cache_item')
+                .delete()
+                .from(CacheItem, 'cache_item')
+                .where(`cache_item.id IN (${subQuery2.getQuery()})`);
+
+            try {
+                await qb.execute();
+            } catch (e: any) {
+                Logger.error(`An error occured when attempting to prune the cache: ${e.message as string}`);
+            }
+        }
+        await this.connection.rawConnection.getRepository(CacheItem).upsert(
+            new CacheItem({
+                key,
+                value: JSON.stringify(value),
+                expiresAt: options?.ttl ? new Date(new Date().getTime() + options.ttl) : undefined,
+            }),
+            ['key'],
+        );
+    }
+
+    async delete(key: string) {
+        await this.connection.rawConnection.getRepository(CacheItem).delete({
+            key,
+        });
+    }
+}

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

@@ -2,6 +2,8 @@ export * from './default-search-plugin/index';
 export * from './default-job-queue-plugin/default-job-queue-plugin';
 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 './vendure-plugin';
 export * from './plugin-common.module';
 export * from './plugin-utils';