Skip to content

Commit

Permalink
feat(core): Initial DefaultCachePlugin implementation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
michaelbromley committed Sep 10, 2024
1 parent 3603b11 commit 9c2433f
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 0 deletions.
21 changes: 21 additions & 0 deletions packages/core/src/plugin/default-cache-plugin/cache-item.entity.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/core/src/plugin/default-cache-plugin/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const PLUGIN_INIT_OPTIONS = Symbol('PLUGIN_INIT_OPTIONS');
Original file line number Diff line number Diff line change
@@ -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 changes: 102 additions & 0 deletions packages/core/src/plugin/default-cache-plugin/sql-cache-strategy.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/core/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

0 comments on commit 9c2433f

Please sign in to comment.