Skip to content

Commit

Permalink
feat(core): Implement cache invalidation by tags
Browse files Browse the repository at this point in the history
Relates to #3043
  • Loading branch information
michaelbromley committed Oct 29, 2024
1 parent 0a60ee9 commit 382e314
Show file tree
Hide file tree
Showing 12 changed files with 435 additions and 14 deletions.
66 changes: 66 additions & 0 deletions packages/core/e2e/cache-service-default.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { CacheService, DefaultCachePlugin, mergeConfig } from '@vendure/core';
import { createTestEnvironment } from '@vendure/testing';
import path from 'path';
import { afterAll, beforeAll, describe, it } from 'vitest';

import { initialData } from '../../../e2e-common/e2e-initial-data';
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
import { TestingCacheTtlProvider } from '../src/cache/cache-ttl-provider';

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

describe('CacheService with DefaultCachePlugin (sql)', () => {
const ttlProvider = new TestingCacheTtlProvider();

let cacheService: CacheService;
const { server, adminClient } = createTestEnvironment(
mergeConfig(testConfig(), {
plugins: [
DefaultCachePlugin.init({
cacheSize: 5,
cacheTtlProvider: ttlProvider,
}),
],
}),
);

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('deletes a key', () => deletesAKey(cacheService));

it('sets a key with ttl', () => setsAKeyWithTtl(cacheService, ttlProvider));

it('evicts the oldest key when cache is full', () => evictsTheOldestKeyWhenCacheIsFull(cacheService));

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

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

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

import { initialData } from '../../../e2e-common/e2e-initial-data';
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
import { TestingCacheTtlProvider } from '../src/cache/cache-ttl-provider';
import { InMemoryCacheStrategy } from '../src/config/system/in-memory-cache-strategy';

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

describe('CacheService in-memory', () => {
const ttlProvider = new TestingCacheTtlProvider();

let cacheService: CacheService;
const { server, adminClient } = createTestEnvironment(
mergeConfig(testConfig(), {
systemOptions: {
cacheStrategy: new InMemoryCacheStrategy({
cacheSize: 5,
cacheTtlProvider: ttlProvider,
}),
},
}),
);

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('deletes a key', () => deletesAKey(cacheService));

it('sets a key with ttl', () => setsAKeyWithTtl(cacheService, ttlProvider));

it('evicts the oldest key when cache is full', () => evictsTheOldestKeyWhenCacheIsFull(cacheService));

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

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

it('invalidates a large number of keys by tag', () => invalidatesALargeNumberOfKeysByTag(cacheService));
});
89 changes: 89 additions & 0 deletions packages/core/e2e/fixtures/cache-service-shared-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { CacheService } from '@vendure/core';
import { expect } from 'vitest';

import { TestingCacheTtlProvider } from '../../src/cache/cache-ttl-provider';

export async function getReturnsUndefinedForNonExistentKey(cacheService: CacheService) {
const result = await cacheService.get('non-existent-key');
expect(result).toBeUndefined();
}

export async function setsAKey(cacheService: CacheService) {
await cacheService.set('test-key', 'test-value');
const result = await cacheService.get('test-key');
expect(result).toBe('test-value');
}

export async function deletesAKey(cacheService: CacheService) {
await cacheService.set('test-key', 'test-value');
await cacheService.delete('test-key');
const result = await cacheService.get('test-key');

expect(result).toBeUndefined();
}

export async function setsAKeyWithTtl(cacheService: CacheService, ttlProvider: TestingCacheTtlProvider) {
ttlProvider.setTime(new Date().getTime());
await cacheService.set('test-key', 'test-value', { ttl: 1000 });
const result = await cacheService.get('test-key');
expect(result).toBe('test-value');

ttlProvider.incrementTime(2000);

const result2 = await cacheService.get('test-key');

expect(result2).toBeUndefined();
}

export async function evictsTheOldestKeyWhenCacheIsFull(cacheService: CacheService) {
await cacheService.set('key1', 'value1');
await cacheService.set('key2', 'value2');
await cacheService.set('key3', 'value3');
await cacheService.set('key4', 'value4');
await cacheService.set('key5', 'value5');

const result1 = await cacheService.get('key1');
expect(result1).toBe('value1');

await cacheService.set('key6', 'value6');

const result2 = await cacheService.get('key1');
expect(result2).toBeUndefined();
}

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

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

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

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

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

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

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

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

export async function invalidatesALargeNumberOfKeysByTag(cacheService: CacheService) {
for (let i = 0; i < 100; i++) {
await cacheService.set(`taggedKey${i}`, `value${i}`, { tags: ['tag'] });
}
await cacheService.invalidateTags(['tag']);

for (let i = 0; i < 100; i++) {
expect(await cacheService.get(`taggedKey${i}`)).toBeUndefined();
}
}
51 changes: 51 additions & 0 deletions packages/core/src/cache/cache-ttl-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @description
* This interface is used to provide the current time in milliseconds.
* The reason it is abstracted in this way is so that the cache
* implementations can be more easily tested.
*
* In an actual application you would not need to change the default.
*/
export interface CacheTtlProvider {
/**
* @description
* Returns the current timestamp in milliseconds.
*/
getTime(): number;
}

/**
* @description
* The default implementation of the {@link CacheTtlProvider} which
* simply returns the current time.
*/
export class DefaultCacheTtlProvider implements CacheTtlProvider {
/**
* @description
* Returns the current timestamp in milliseconds.
*/
getTime(): number {
return new Date().getTime();
}
}

/**
* @description
* A testing implementation of the {@link CacheTtlProvider} which
* allows the time to be set manually.
*/
export class TestingCacheTtlProvider implements CacheTtlProvider {
private time = 0;

setTime(timestampInMs: number) {
this.time = timestampInMs;
}

incrementTime(ms: number) {
this.time += ms;
}

getTime(): number {
return this.time;
}
}
17 changes: 17 additions & 0 deletions packages/core/src/cache/cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,21 @@ export class CacheService {
Logger.error(`Could not delete key [${key}] from CacheService`, undefined, e.stack);
}
}

/**
* @description
* Deletes all items from the cache which contain at least one matching tag.
*/
async invalidateTags(tags: string[]): Promise<void> {
try {
await this.cacheStrategy.invalidateTags(tags);
Logger.debug(`Invalidated tags [${tags.join(', ')}] from CacheService`);
} catch (e: any) {
Logger.error(
`Could not invalidate tags [${tags.join(', ')}] from CacheService`,
undefined,
e.stack,
);
}
}
}
1 change: 1 addition & 0 deletions packages/core/src/cache/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './request-context-cache.service';
export * from './cache.service';
12 changes: 12 additions & 0 deletions packages/core/src/config/system/cache-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export interface SetCacheKeyOptions {
* this is equivalent to having an infinite ttl.
*/
ttl?: number;
/**
* @description
* An array of tags which can be used to group cache keys together.
* This can be useful for bulk deletion of related keys.
*/
tags?: string[];
}

/**
Expand Down Expand Up @@ -49,4 +55,10 @@ export interface CacheStrategy extends InjectableStrategy {
* Deletes an item from the cache.
*/
delete(key: string): Promise<void>;

/**
* @description
* Deletes all items from the cache which contain at least one matching tag.
*/
invalidateTags(tags: string[]): Promise<void>;
}
31 changes: 27 additions & 4 deletions packages/core/src/config/system/in-memory-cache-strategy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { JsonCompatible } from '@vendure/common/lib/shared-types';

import { CacheTtlProvider, DefaultCacheTtlProvider } from '../../cache/cache-ttl-provider';

import { CacheStrategy, SetCacheKeyOptions } from './cache-strategy';

export interface CacheItem<T> {
Expand All @@ -18,19 +20,21 @@ export interface CacheItem<T> {
*/
export class InMemoryCacheStrategy implements CacheStrategy {
protected cache = new Map<string, CacheItem<any>>();
protected cacheTags = new Map<string, Set<string>>();
protected cacheSize = 10_000;
protected ttlProvider: CacheTtlProvider;

constructor(config?: { cacheSize?: number }) {
constructor(config?: { cacheSize?: number; cacheTtlProvider?: CacheTtlProvider }) {
if (config?.cacheSize) {
this.cacheSize = config.cacheSize;
}
this.ttlProvider = config?.cacheTtlProvider || new DefaultCacheTtlProvider();
}

async get<T extends JsonCompatible<T>>(key: string): Promise<T | undefined> {
const hit = this.cache.get(key);
if (hit) {
const now = new Date().getTime();
if (!hit.expires || (hit.expires && now < hit.expires)) {
if (!hit.expires || (hit.expires && this.ttlProvider.getTime() < hit.expires)) {
return hit.value;
} else {
this.cache.delete(key);
Expand All @@ -49,14 +53,33 @@ export class InMemoryCacheStrategy implements CacheStrategy {
}
this.cache.set(key, {
value,
expires: options?.ttl ? new Date().getTime() + options.ttl : undefined,
expires: options?.ttl ? this.ttlProvider.getTime() + options.ttl : undefined,
});
if (options?.tags) {
for (const tag of options.tags) {
const tagged = this.cacheTags.get(tag) || new Set<string>();
tagged.add(key);
this.cacheTags.set(tag, tagged);
}
}
}

async delete(key: string) {
this.cache.delete(key);
}

async invalidateTags(tags: string[]) {
for (const tag of tags) {
const tagged = this.cacheTags.get(tag);
if (tagged) {
for (const key of tagged) {
this.cache.delete(key);
}
this.cacheTags.delete(tag);
}
}
}

private first() {
return this.cache.keys().next().value;
}
Expand Down
Loading

0 comments on commit 382e314

Please sign in to comment.