Skip to content

Commit 382e314

Browse files
feat(core): Implement cache invalidation by tags
Relates to #3043
1 parent 0a60ee9 commit 382e314

12 files changed

+435
-14
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { CacheService, DefaultCachePlugin, mergeConfig } from '@vendure/core';
2+
import { createTestEnvironment } from '@vendure/testing';
3+
import path from 'path';
4+
import { afterAll, beforeAll, describe, it } from 'vitest';
5+
6+
import { initialData } from '../../../e2e-common/e2e-initial-data';
7+
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
8+
import { TestingCacheTtlProvider } from '../src/cache/cache-ttl-provider';
9+
10+
import {
11+
deletesAKey,
12+
evictsTheOldestKeyWhenCacheIsFull,
13+
getReturnsUndefinedForNonExistentKey,
14+
invalidatesALargeNumberOfKeysByTag,
15+
invalidatesByMultipleTags,
16+
invalidatesBySingleTag,
17+
setsAKey,
18+
setsAKeyWithTtl,
19+
} from './fixtures/cache-service-shared-tests';
20+
21+
describe('CacheService with DefaultCachePlugin (sql)', () => {
22+
const ttlProvider = new TestingCacheTtlProvider();
23+
24+
let cacheService: CacheService;
25+
const { server, adminClient } = createTestEnvironment(
26+
mergeConfig(testConfig(), {
27+
plugins: [
28+
DefaultCachePlugin.init({
29+
cacheSize: 5,
30+
cacheTtlProvider: ttlProvider,
31+
}),
32+
],
33+
}),
34+
);
35+
36+
beforeAll(async () => {
37+
await server.init({
38+
initialData,
39+
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
40+
customerCount: 1,
41+
});
42+
await adminClient.asSuperAdmin();
43+
cacheService = server.app.get(CacheService);
44+
}, TEST_SETUP_TIMEOUT_MS);
45+
46+
afterAll(async () => {
47+
await server.destroy();
48+
});
49+
50+
it('get returns undefined for non-existent key', () =>
51+
getReturnsUndefinedForNonExistentKey(cacheService));
52+
53+
it('sets a key', () => setsAKey(cacheService));
54+
55+
it('deletes a key', () => deletesAKey(cacheService));
56+
57+
it('sets a key with ttl', () => setsAKeyWithTtl(cacheService, ttlProvider));
58+
59+
it('evicts the oldest key when cache is full', () => evictsTheOldestKeyWhenCacheIsFull(cacheService));
60+
61+
it('invalidates by single tag', () => invalidatesBySingleTag(cacheService));
62+
63+
it('invalidates by multiple tags', () => invalidatesByMultipleTags(cacheService));
64+
65+
it('invalidates a large number of keys by tag', () => invalidatesALargeNumberOfKeysByTag(cacheService));
66+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { CacheService, mergeConfig } from '@vendure/core';
2+
import { createTestEnvironment } from '@vendure/testing';
3+
import path from 'path';
4+
import { afterAll, beforeAll, describe, it } from 'vitest';
5+
6+
import { initialData } from '../../../e2e-common/e2e-initial-data';
7+
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
8+
import { TestingCacheTtlProvider } from '../src/cache/cache-ttl-provider';
9+
import { InMemoryCacheStrategy } from '../src/config/system/in-memory-cache-strategy';
10+
11+
import {
12+
deletesAKey,
13+
evictsTheOldestKeyWhenCacheIsFull,
14+
getReturnsUndefinedForNonExistentKey,
15+
invalidatesALargeNumberOfKeysByTag,
16+
invalidatesByMultipleTags,
17+
invalidatesBySingleTag,
18+
setsAKey,
19+
setsAKeyWithTtl,
20+
} from './fixtures/cache-service-shared-tests';
21+
22+
describe('CacheService in-memory', () => {
23+
const ttlProvider = new TestingCacheTtlProvider();
24+
25+
let cacheService: CacheService;
26+
const { server, adminClient } = createTestEnvironment(
27+
mergeConfig(testConfig(), {
28+
systemOptions: {
29+
cacheStrategy: new InMemoryCacheStrategy({
30+
cacheSize: 5,
31+
cacheTtlProvider: ttlProvider,
32+
}),
33+
},
34+
}),
35+
);
36+
37+
beforeAll(async () => {
38+
await server.init({
39+
initialData,
40+
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
41+
customerCount: 1,
42+
});
43+
await adminClient.asSuperAdmin();
44+
cacheService = server.app.get(CacheService);
45+
}, TEST_SETUP_TIMEOUT_MS);
46+
47+
afterAll(async () => {
48+
await server.destroy();
49+
});
50+
51+
it('get returns undefined for non-existent key', () =>
52+
getReturnsUndefinedForNonExistentKey(cacheService));
53+
54+
it('sets a key', () => setsAKey(cacheService));
55+
56+
it('deletes a key', () => deletesAKey(cacheService));
57+
58+
it('sets a key with ttl', () => setsAKeyWithTtl(cacheService, ttlProvider));
59+
60+
it('evicts the oldest key when cache is full', () => evictsTheOldestKeyWhenCacheIsFull(cacheService));
61+
62+
it('invalidates by single tag', () => invalidatesBySingleTag(cacheService));
63+
64+
it('invalidates by multiple tags', () => invalidatesByMultipleTags(cacheService));
65+
66+
it('invalidates a large number of keys by tag', () => invalidatesALargeNumberOfKeysByTag(cacheService));
67+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { CacheService } from '@vendure/core';
2+
import { expect } from 'vitest';
3+
4+
import { TestingCacheTtlProvider } from '../../src/cache/cache-ttl-provider';
5+
6+
export async function getReturnsUndefinedForNonExistentKey(cacheService: CacheService) {
7+
const result = await cacheService.get('non-existent-key');
8+
expect(result).toBeUndefined();
9+
}
10+
11+
export async function setsAKey(cacheService: CacheService) {
12+
await cacheService.set('test-key', 'test-value');
13+
const result = await cacheService.get('test-key');
14+
expect(result).toBe('test-value');
15+
}
16+
17+
export async function deletesAKey(cacheService: CacheService) {
18+
await cacheService.set('test-key', 'test-value');
19+
await cacheService.delete('test-key');
20+
const result = await cacheService.get('test-key');
21+
22+
expect(result).toBeUndefined();
23+
}
24+
25+
export async function setsAKeyWithTtl(cacheService: CacheService, ttlProvider: TestingCacheTtlProvider) {
26+
ttlProvider.setTime(new Date().getTime());
27+
await cacheService.set('test-key', 'test-value', { ttl: 1000 });
28+
const result = await cacheService.get('test-key');
29+
expect(result).toBe('test-value');
30+
31+
ttlProvider.incrementTime(2000);
32+
33+
const result2 = await cacheService.get('test-key');
34+
35+
expect(result2).toBeUndefined();
36+
}
37+
38+
export async function evictsTheOldestKeyWhenCacheIsFull(cacheService: CacheService) {
39+
await cacheService.set('key1', 'value1');
40+
await cacheService.set('key2', 'value2');
41+
await cacheService.set('key3', 'value3');
42+
await cacheService.set('key4', 'value4');
43+
await cacheService.set('key5', 'value5');
44+
45+
const result1 = await cacheService.get('key1');
46+
expect(result1).toBe('value1');
47+
48+
await cacheService.set('key6', 'value6');
49+
50+
const result2 = await cacheService.get('key1');
51+
expect(result2).toBeUndefined();
52+
}
53+
54+
export async function invalidatesBySingleTag(cacheService: CacheService) {
55+
await cacheService.set('taggedKey1', 'value1', { tags: ['tag1'] });
56+
await cacheService.set('taggedKey2', 'value2', { tags: ['tag2'] });
57+
58+
expect(await cacheService.get('taggedKey1')).toBe('value1');
59+
expect(await cacheService.get('taggedKey2')).toBe('value2');
60+
61+
await cacheService.invalidateTags(['tag1']);
62+
63+
expect(await cacheService.get('taggedKey1')).toBeUndefined();
64+
expect(await cacheService.get('taggedKey2')).toBe('value2');
65+
}
66+
67+
export async function invalidatesByMultipleTags(cacheService: CacheService) {
68+
await cacheService.set('taggedKey1', 'value1', { tags: ['tag1'] });
69+
await cacheService.set('taggedKey2', 'value2', { tags: ['tag2'] });
70+
71+
expect(await cacheService.get('taggedKey1')).toBe('value1');
72+
expect(await cacheService.get('taggedKey2')).toBe('value2');
73+
74+
await cacheService.invalidateTags(['tag1', 'tag2']);
75+
76+
expect(await cacheService.get('taggedKey1')).toBeUndefined();
77+
expect(await cacheService.get('taggedKey2')).toBeUndefined();
78+
}
79+
80+
export async function invalidatesALargeNumberOfKeysByTag(cacheService: CacheService) {
81+
for (let i = 0; i < 100; i++) {
82+
await cacheService.set(`taggedKey${i}`, `value${i}`, { tags: ['tag'] });
83+
}
84+
await cacheService.invalidateTags(['tag']);
85+
86+
for (let i = 0; i < 100; i++) {
87+
expect(await cacheService.get(`taggedKey${i}`)).toBeUndefined();
88+
}
89+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @description
3+
* This interface is used to provide the current time in milliseconds.
4+
* The reason it is abstracted in this way is so that the cache
5+
* implementations can be more easily tested.
6+
*
7+
* In an actual application you would not need to change the default.
8+
*/
9+
export interface CacheTtlProvider {
10+
/**
11+
* @description
12+
* Returns the current timestamp in milliseconds.
13+
*/
14+
getTime(): number;
15+
}
16+
17+
/**
18+
* @description
19+
* The default implementation of the {@link CacheTtlProvider} which
20+
* simply returns the current time.
21+
*/
22+
export class DefaultCacheTtlProvider implements CacheTtlProvider {
23+
/**
24+
* @description
25+
* Returns the current timestamp in milliseconds.
26+
*/
27+
getTime(): number {
28+
return new Date().getTime();
29+
}
30+
}
31+
32+
/**
33+
* @description
34+
* A testing implementation of the {@link CacheTtlProvider} which
35+
* allows the time to be set manually.
36+
*/
37+
export class TestingCacheTtlProvider implements CacheTtlProvider {
38+
private time = 0;
39+
40+
setTime(timestampInMs: number) {
41+
this.time = timestampInMs;
42+
}
43+
44+
incrementTime(ms: number) {
45+
this.time += ms;
46+
}
47+
48+
getTime(): number {
49+
return this.time;
50+
}
51+
}

packages/core/src/cache/cache.service.ts

+17
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,21 @@ export class CacheService {
7171
Logger.error(`Could not delete key [${key}] from CacheService`, undefined, e.stack);
7272
}
7373
}
74+
75+
/**
76+
* @description
77+
* Deletes all items from the cache which contain at least one matching tag.
78+
*/
79+
async invalidateTags(tags: string[]): Promise<void> {
80+
try {
81+
await this.cacheStrategy.invalidateTags(tags);
82+
Logger.debug(`Invalidated tags [${tags.join(', ')}] from CacheService`);
83+
} catch (e: any) {
84+
Logger.error(
85+
`Could not invalidate tags [${tags.join(', ')}] from CacheService`,
86+
undefined,
87+
e.stack,
88+
);
89+
}
90+
}
7491
}

packages/core/src/cache/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './request-context-cache.service';
2+
export * from './cache.service';

packages/core/src/config/system/cache-strategy.ts

+12
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export interface SetCacheKeyOptions {
1515
* this is equivalent to having an infinite ttl.
1616
*/
1717
ttl?: number;
18+
/**
19+
* @description
20+
* An array of tags which can be used to group cache keys together.
21+
* This can be useful for bulk deletion of related keys.
22+
*/
23+
tags?: string[];
1824
}
1925

2026
/**
@@ -49,4 +55,10 @@ export interface CacheStrategy extends InjectableStrategy {
4955
* Deletes an item from the cache.
5056
*/
5157
delete(key: string): Promise<void>;
58+
59+
/**
60+
* @description
61+
* Deletes all items from the cache which contain at least one matching tag.
62+
*/
63+
invalidateTags(tags: string[]): Promise<void>;
5264
}

packages/core/src/config/system/in-memory-cache-strategy.ts

+27-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { JsonCompatible } from '@vendure/common/lib/shared-types';
22

3+
import { CacheTtlProvider, DefaultCacheTtlProvider } from '../../cache/cache-ttl-provider';
4+
35
import { CacheStrategy, SetCacheKeyOptions } from './cache-strategy';
46

57
export interface CacheItem<T> {
@@ -18,19 +20,21 @@ export interface CacheItem<T> {
1820
*/
1921
export class InMemoryCacheStrategy implements CacheStrategy {
2022
protected cache = new Map<string, CacheItem<any>>();
23+
protected cacheTags = new Map<string, Set<string>>();
2124
protected cacheSize = 10_000;
25+
protected ttlProvider: CacheTtlProvider;
2226

23-
constructor(config?: { cacheSize?: number }) {
27+
constructor(config?: { cacheSize?: number; cacheTtlProvider?: CacheTtlProvider }) {
2428
if (config?.cacheSize) {
2529
this.cacheSize = config.cacheSize;
2630
}
31+
this.ttlProvider = config?.cacheTtlProvider || new DefaultCacheTtlProvider();
2732
}
2833

2934
async get<T extends JsonCompatible<T>>(key: string): Promise<T | undefined> {
3035
const hit = this.cache.get(key);
3136
if (hit) {
32-
const now = new Date().getTime();
33-
if (!hit.expires || (hit.expires && now < hit.expires)) {
37+
if (!hit.expires || (hit.expires && this.ttlProvider.getTime() < hit.expires)) {
3438
return hit.value;
3539
} else {
3640
this.cache.delete(key);
@@ -49,14 +53,33 @@ export class InMemoryCacheStrategy implements CacheStrategy {
4953
}
5054
this.cache.set(key, {
5155
value,
52-
expires: options?.ttl ? new Date().getTime() + options.ttl : undefined,
56+
expires: options?.ttl ? this.ttlProvider.getTime() + options.ttl : undefined,
5357
});
58+
if (options?.tags) {
59+
for (const tag of options.tags) {
60+
const tagged = this.cacheTags.get(tag) || new Set<string>();
61+
tagged.add(key);
62+
this.cacheTags.set(tag, tagged);
63+
}
64+
}
5465
}
5566

5667
async delete(key: string) {
5768
this.cache.delete(key);
5869
}
5970

71+
async invalidateTags(tags: string[]) {
72+
for (const tag of tags) {
73+
const tagged = this.cacheTags.get(tag);
74+
if (tagged) {
75+
for (const key of tagged) {
76+
this.cache.delete(key);
77+
}
78+
this.cacheTags.delete(tag);
79+
}
80+
}
81+
}
82+
6083
private first() {
6184
return this.cache.keys().next().value;
6285
}

0 commit comments

Comments
 (0)