Swap out cache-manager for Keyv implementation.

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2021-05-09 17:00:59 +02:00
parent 884c2bd54f
commit d6936dcbde
8 changed files with 192 additions and 390 deletions
+5 -13
View File
@@ -75,19 +75,11 @@ export interface Config {
}
| {
store: 'memcache';
connection: {
/**
* An array of memcache hosts, optionally including a port number.
* e.g. 127.0.0.1:11211
*/
hosts: string[];
/**
* Number of milliseconds to wait before assuming there is a
* network timeout. Defaults to 500ms.
*/
netTimeout?: number;
};
/**
* A memcache connection string in the form `user:pass@host:port`.
* @secret
*/
connection: string;
};
cors?: {
+2 -5
View File
@@ -36,13 +36,10 @@
"@backstage/integration": "^0.5.2",
"@google-cloud/storage": "^5.8.0",
"@octokit/rest": "^18.5.3",
"@types/cache-manager": "^3.4.0",
"@types/cors": "^2.8.6",
"@types/dockerode": "^3.2.1",
"@types/express": "^4.17.6",
"archiver": "^5.0.2",
"cache-manager": "^3.4.3",
"cache-manager-memcached-store": "^4.0.0",
"compression": "^1.7.4",
"concat-stream": "^2.0.0",
"cors": "^2.8.5",
@@ -54,10 +51,11 @@
"git-url-parse": "^11.4.4",
"helmet": "^4.0.0",
"isomorphic-git": "^1.8.0",
"keyv": "^4.0.3",
"keyv-memcache": "^1.2.5",
"knex": "^0.95.1",
"lodash": "^4.17.15",
"logform": "^2.1.1",
"memcache-pp": "^0.3.3",
"minimatch": "^3.0.4",
"minimist": "^1.2.5",
"morgan": "^1.10.0",
@@ -80,7 +78,6 @@
"@backstage/cli": "^0.6.10",
"@backstage/test-utils": "^0.1.10",
"@types/archiver": "^5.1.0",
"@types/cache-manager": "^3.4.0",
"@types/compression": "^1.7.0",
"@types/concat-stream": "^1.6.0",
"@types/fs-extra": "^9.0.3",
+59 -156
View File
@@ -15,229 +15,132 @@
*/
import { DefaultCacheClient } from './CacheClient';
import cacheManager from 'cache-manager';
import Keyv from 'keyv';
describe('CacheClient', () => {
const pluginId = 'test';
const defaultTtl = 60;
const onError = 'returnEmpty';
let client: cacheManager.Cache;
let client: Keyv;
const b64 = (k: string) => Buffer.from(k).toString('base64');
beforeEach(() => {
client = cacheManager.caching({ store: 'none', ttl: 0 });
client = new Keyv();
client.get = jest.fn();
client.set = jest.fn();
client.del = jest.fn();
client.delete = jest.fn();
});
afterEach(() => jest.resetAllMocks());
describe('CacheClient.get', () => {
it('calls client with normalized key', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
const keyPartial = 'somekey';
const sut = new DefaultCacheClient({ client });
const key = 'somekey';
await sut.get(keyPartial);
await sut.get(key);
expect(client.get).toHaveBeenCalledWith(b64(`${pluginId}:${keyPartial}`));
expect(client.get).toHaveBeenCalledWith(b64(key));
});
it('calls client with normalized key (very long key)', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
const keyPartial = 'x'.repeat(251);
const sut = new DefaultCacheClient({ client });
const key = 'x'.repeat(251);
await sut.get(keyPartial);
await sut.get(key);
const spy = client.get as jest.Mock;
const actualKey = spy.mock.calls[0][0];
expect(actualKey).not.toEqual(b64(`${pluginId}:${keyPartial}`));
expect(actualKey).not.toEqual(b64(key));
expect(actualKey.length).toBeLessThan(250);
});
it('performs deserialization on returned data', async () => {
const expectedValue = { some: 'value' };
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
client.get = jest.fn().mockResolvedValue(JSON.stringify(expectedValue));
const actualValue = await sut.get('someKey');
expect(actualValue).toMatchObject(expectedValue);
});
it('returns undefined on any underlying error', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
client.get = jest.fn().mockRejectedValue(undefined);
const actualValue = await sut.get('someKey');
expect(actualValue).toStrictEqual(undefined);
});
it('rejects on underlying error if configured', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError: 'reject',
});
it('rejects on underlying error', async () => {
const sut = new DefaultCacheClient({ client });
const expectedError = new Error('Some runtime error');
client.get = jest.fn().mockRejectedValue(expectedError);
return expect(sut.get('someKey')).rejects.toEqual(expectedError);
});
it('resolves what underlying client resolves', async () => {
const sut = new DefaultCacheClient({ client });
const expectedValue = 'some value';
client.get = jest.fn().mockResolvedValue(expectedValue);
const actualValue = await sut.get('someKey');
return expect(actualValue).toEqual(expectedValue);
});
});
describe('CacheClient.set', () => {
it('calls client with normalized key', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
const keyPartial = 'somekey';
const sut = new DefaultCacheClient({ client });
const key = 'somekey';
await sut.set(keyPartial, {});
await sut.set(key, {});
const spy = client.set as jest.Mock;
const actualKey = spy.mock.calls[0][0];
expect(actualKey).toEqual(b64(`${pluginId}:${keyPartial}`));
});
it('performs serialization on given data', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
const expectedData = { some: 'value' };
await sut.set('someKey', expectedData);
const spy = client.set as jest.Mock;
const actualData = spy.mock.calls[0][1];
expect(actualData).toEqual(JSON.stringify(expectedData));
expect(actualKey).toEqual(b64(key));
});
it('passes ttl to client when given', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
const sut = new DefaultCacheClient({ client });
const expectedTtl = 3600;
await sut.set('someKey', {}, { ttl: expectedTtl });
const spy = client.set as jest.Mock;
const actualOptions = spy.mock.calls[0][2];
expect(actualOptions.ttl).toEqual(expectedTtl);
});
it('passes defaultTtl to client when not', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
await sut.set('someKey', {});
const spy = client.set as jest.Mock;
const actualOptions = spy.mock.calls[0][2];
expect(actualOptions.ttl).toEqual(defaultTtl);
});
it('does not reject on any client error by default', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
client.set = jest.fn().mockRejectedValue(undefined);
return expect(sut.set('someKey', {})).resolves.toBeUndefined();
const actualTtl = spy.mock.calls[0][2];
expect(actualTtl).toEqual(expectedTtl);
});
it('rejects on underlying error if configured', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError: 'reject',
});
const sut = new DefaultCacheClient({ client });
const expectedError = new Error('Some runtime error');
client.set = jest.fn().mockRejectedValue(expectedError);
return expect(sut.set('someKey', {})).rejects.toEqual(expectedError);
});
it('resolves what underlying client resolves', async () => {
const sut = new DefaultCacheClient({ client });
const expectedResponse = true;
client.set = jest.fn().mockReturnValue(expectedResponse);
const actualResponse = await sut.set('someKey', {});
return expect(actualResponse).toEqual(expectedResponse);
});
});
describe('CacheClient.delete', () => {
it('calls client with normalized key', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
const keyPartial = 'somekey';
const sut = new DefaultCacheClient({ client });
const key = 'somekey';
await sut.delete(keyPartial);
await sut.delete(key);
const spy = client.del as jest.Mock;
const spy = client.delete as jest.Mock;
const actualKey = spy.mock.calls[0][0];
expect(actualKey).toEqual(b64(`${pluginId}:${keyPartial}`));
});
it('does not reject on any client error by default', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError,
});
client.del = jest.fn().mockRejectedValue(undefined);
return expect(sut.delete('someKey')).resolves.toBeUndefined();
expect(actualKey).toEqual(b64(key));
});
it('rejects on underlying error if configured', async () => {
const sut = new DefaultCacheClient({
client,
defaultTtl,
pluginId,
onError: 'reject',
});
const sut = new DefaultCacheClient({ client });
const expectedError = new Error('Some runtime error');
client.del = jest.fn().mockRejectedValue(expectedError);
client.delete = jest.fn().mockRejectedValue(expectedError);
return expect(sut.delete('someKey')).rejects.toEqual(expectedError);
});
it('resolves what underlying client resolves', async () => {
const sut = new DefaultCacheClient({ client });
const expectedResponse = false;
client.delete = jest.fn().mockResolvedValue(expectedResponse);
const actualResponse = await sut.delete('someKey');
return expect(actualResponse).toEqual(expectedResponse);
});
});
});
+21 -57
View File
@@ -15,17 +15,18 @@
*/
import { JsonValue } from '@backstage/config';
import cacheManager from 'cache-manager';
import { createHash } from 'crypto';
import Keyv from 'keyv';
type CacheClientArgs = {
client: cacheManager.Cache;
pluginId: string;
defaultTtl: number;
onError: 'reject' | 'returnEmpty';
client: Keyv;
};
type CacheSetOptions = {
/**
* Optional TTL in milliseconds. Defaults to the TTL provided when the client
* was set up (or no TTL if none are provided).
*/
ttl?: number;
};
@@ -45,80 +46,50 @@ export interface CacheClient {
* optional TTL may also be provided, otherwise it defaults to the TTL that
* was provided when the client was instantiated.
*/
set(key: string, value: JsonValue, options: CacheSetOptions): Promise<void>;
set(key: string, value: JsonValue, options?: CacheSetOptions): Promise<boolean>;
/**
* Removes the given key from the cache store.
* Removes the given key from the cache store. Resolves true if the key
* existed, or false if not.
*/
delete(key: string): Promise<void>;
delete(key: string): Promise<boolean>;
}
/**
* A simple, concrete implementation of the CacheClient, suitable for almost
* A basic, concrete implementation of the CacheClient, suitable for almost
* all uses in Backstage.
*/
export class DefaultCacheClient implements CacheClient {
private readonly client: cacheManager.Cache;
private readonly defaultTtl: number;
private readonly pluginId: string;
private readonly onError: 'reject' | 'returnEmpty';
private readonly client: Keyv;
constructor({ client, defaultTtl, pluginId, onError }: CacheClientArgs) {
constructor({ client }: CacheClientArgs) {
this.client = client;
this.defaultTtl = defaultTtl;
this.pluginId = pluginId;
this.onError = onError;
}
async get(key: string): Promise<JsonValue | undefined> {
const k = this.getNormalizedKey(key);
try {
const data = (await this.client.get(k)) as string | undefined;
return this.deserializeData(data);
} catch (e) {
if (this.onError === 'reject') {
throw e;
}
return undefined;
}
return await this.client.get(k);
}
async set(
key: string,
value: JsonValue,
opts: CacheSetOptions = {},
): Promise<void> {
): Promise<boolean> {
const k = this.getNormalizedKey(key);
try {
const data = this.serializeData(value);
await this.client.set(k, data, {
ttl: opts.ttl || this.defaultTtl,
});
} catch (e) {
if (this.onError === 'reject') {
throw e;
}
}
return await this.client.set(k, value, opts.ttl)
}
async delete(key: string): Promise<void> {
async delete(key: string): Promise<boolean> {
const k = this.getNormalizedKey(key);
try {
await this.client.del(k);
} catch (e) {
if (this.onError === 'reject') {
throw e;
}
}
return await this.client.delete(k);
}
/**
* Namespaces key by plugin to discourage cross-plugin integration via the
* cache store.
* Ensures keys are well-formed for any/all cache stores.
*/
private getNormalizedKey(key: string): string {
// Namespace key by plugin ID and remove potentially invalid characters.
const candidateKey = `${this.pluginId}:${key}`;
private getNormalizedKey(candidateKey: string): string {
// Remove potentially invalid characters.
const wellFormedKey = Buffer.from(candidateKey).toString('base64');
// Memcache in particular doesn't do well with keys > 250 bytes.
@@ -129,11 +100,4 @@ export class DefaultCacheClient implements CacheClient {
return createHash('md5').update(candidateKey).digest('base64');
}
private serializeData(data: JsonValue): string {
return JSON.stringify(data);
}
private deserializeData(data: string | undefined): JsonValue | undefined {
return data ? JSON.parse(data) : undefined;
}
}
+31 -35
View File
@@ -15,11 +15,16 @@
*/
import { ConfigReader } from '@backstage/config';
import cacheManager from 'cache-manager';
import Keyv from 'keyv';
/* @ts-expect-error */
import KeyvMemcache from 'keyv-memcache';
import { DefaultCacheClient } from './CacheClient';
import { CacheManager } from './CacheManager';
cacheManager.caching = jest.fn() as jest.Mock;
jest.createMockFromModule('keyv');
jest.mock('keyv');
jest.createMockFromModule('keyv-memcache');
jest.mock('keyv-memcache');
jest.mock('./CacheClient', () => {
return {
DefaultCacheClient: jest.fn(),
@@ -40,18 +45,14 @@ describe('CacheManager', () => {
describe('CacheManager.fromConfig', () => {
it('accesses the backend.cache key', () => {
const getOptionalConfig = jest.fn();
const getOptionalString = jest.fn();
const config = defaultConfig();
config.getOptionalConfig = getOptionalConfig;
config.getOptionalString = getOptionalString;
CacheManager.fromConfig(config);
expect(getOptionalString.mock.calls[0][0]).toEqual('backend.cache.store');
expect(getOptionalConfig.mock.calls[0][0]).toEqual(
'backend.cache.connection',
);
expect(getOptionalString.mock.calls[1][0]).toEqual('backend.cache.connection');
});
it('does not require the backend.cache key', () => {
@@ -76,8 +77,7 @@ describe('CacheManager', () => {
it('connects to a cache store scoped to the plugin', async () => {
const pluginId = 'test1';
const expectedTtl = 3600;
manager.forPlugin(pluginId).getClient({ defaultTtl: expectedTtl });
manager.forPlugin(pluginId).getClient();
const client = DefaultCacheClient as jest.Mock;
expect(client).toHaveBeenCalledTimes(1);
@@ -90,62 +90,60 @@ describe('CacheManager', () => {
manager.forPlugin(plugin1Id).getClient({ defaultTtl: expectedTtl });
manager.forPlugin(plugin2Id).getClient({ defaultTtl: expectedTtl });
const cache = cacheManager.caching as jest.Mock;
const client = DefaultCacheClient as jest.Mock;
const cache = Keyv as unknown as jest.Mock;
expect(cache).toHaveBeenCalledTimes(2);
expect(client).toHaveBeenCalledTimes(2);
const plugin1CallArgs = client.mock.calls[0];
const plugin2CallArgs = client.mock.calls[1];
expect(plugin1CallArgs[0].pluginId).not.toEqual(
plugin2CallArgs[0].pluginId,
const plugin1CallArgs = cache.mock.calls[0];
const plugin2CallArgs = cache.mock.calls[1];
expect(plugin1CallArgs[0].namespace).not.toEqual(
plugin2CallArgs[0].namespace,
);
});
});
describe('CacheManager.forPlugin stores', () => {
it('returns none client when no cache is configured', () => {
it('returns memory client when no cache is configured', () => {
const manager = CacheManager.fromConfig(
new ConfigReader({ backend: {} }),
);
const expectedTtl = 3600;
manager.forPlugin('test').getClient({ defaultTtl: expectedTtl });
const expectedNamespace = 'test-plugin';
manager.forPlugin(expectedNamespace).getClient({ defaultTtl: expectedTtl });
const cache = cacheManager.caching as jest.Mock;
const cache = Keyv as unknown as jest.Mock;
const mockCalls = cache.mock.calls.splice(-1);
const callArgs = mockCalls[0];
expect(callArgs[0]).toMatchObject({
store: 'none',
ttl: expectedTtl,
namespace: expectedNamespace,
});
});
it('returns memory client when configured', () => {
it('returns memory client when explicitly configured', () => {
const manager = CacheManager.fromConfig(defaultConfig());
const expectedTtl = 3600;
manager.forPlugin('test').getClient({ defaultTtl: expectedTtl });
const expectedNamespace = 'test-plugin';
manager.forPlugin(expectedNamespace).getClient({ defaultTtl: expectedTtl });
const cache = cacheManager.caching as jest.Mock;
const cache = Keyv as unknown as jest.Mock;
const mockCalls = cache.mock.calls.splice(-1);
const callArgs = mockCalls[0];
expect(callArgs[0]).toMatchObject({
store: 'memory',
ttl: expectedTtl,
namespace: expectedNamespace,
});
});
it('returns a memcache client when configured', () => {
const expectedHost = '127.0.0.1:11211';
const expectedTimeout = 1000;
const manager = CacheManager.fromConfig(
new ConfigReader({
backend: {
cache: {
store: 'memcache',
connection: {
hosts: [expectedHost],
netTimeout: expectedTimeout,
},
connection: expectedHost,
},
},
}),
@@ -153,16 +151,14 @@ describe('CacheManager', () => {
const expectedTtl = 3600;
manager.forPlugin('test').getClient({ defaultTtl: expectedTtl });
const cache = cacheManager.caching as jest.Mock;
const mockCalls = cache.mock.calls.splice(-1);
const callArgs = mockCalls[0];
expect(callArgs[0]).toMatchObject({
options: {
hosts: [expectedHost],
netTimeout: expectedTimeout,
},
const cache = Keyv as unknown as jest.Mock;
const mockCacheCalls = cache.mock.calls.splice(-1);
expect(mockCacheCalls[0][0]).toMatchObject({
ttl: expectedTtl,
});
const memcache = KeyvMemcache as jest.Mock;
const mockMemcacheCalls = memcache.mock.calls.splice(-1);
expect(mockMemcacheCalls[0][0]).toEqual(expectedHost);
});
});
});
+29 -50
View File
@@ -14,34 +14,30 @@
* limitations under the License.
*/
import { Config, ConfigReader } from '@backstage/config';
import cacheManager from 'cache-manager';
import { Config } from '@backstage/config';
import Keyv from 'keyv';
// @ts-expect-error
import Memcache from 'memcache-pp';
// @ts-expect-error
import memcachedStore from 'cache-manager-memcached-store';
import KeyvMemcache from 'keyv-memcache';
import { DefaultCacheClient, CacheClient } from './CacheClient';
import { PluginCacheManager } from './types';
/**
* Implements a Cache Manager which will automatically create new cache clients
* for plugins when requested. All requested cacheclients are created with the
* credentials provided.
* connection details provided.
*/
export class CacheManager {
/**
* Keys represents supported `backend.cache.store` values, mapped to
* factories that return cacheManager.Cache instances appropriate to the
* store.
* Keys represent supported `backend.cache.store` values, mapped to factories
* that return Keyv instances appropriate to the store.
*/
private readonly storeFactories = {
memcache: this.getMemcacheClient,
memory: this.getMemoryClient,
none: this.getNoneClient,
};
private readonly store: keyof CacheManager['storeFactories'];
private readonly connection: Config;
private readonly connection: string;
/**
* Creates a new CacheManager instance by reading from the `backend` config
@@ -51,21 +47,18 @@ export class CacheManager {
*/
static fromConfig(config: Config): CacheManager {
// If no `backend.cache` config is provided, instantiate the CacheManager
// with empty config; allowing a "none" cache client will be returned.
const store = config.getOptionalString('backend.cache.store') || 'none';
const connectionConfig =
config.getOptionalConfig('backend.cache.connection') ||
new ConfigReader(undefined);
return new CacheManager(store, connectionConfig);
// with a "memory" cache client.
const store = config.getOptionalString('backend.cache.store') || 'memory';
const connectionString = config.getOptionalString('backend.cache.connection') || '';
return new CacheManager(store, connectionString);
}
private constructor(store: string, connectionConfig: Config) {
private constructor(store: string, connectionString: string) {
if (!this.storeFactories.hasOwnProperty(store)) {
throw new Error(`Unknown cache store: ${store}`);
}
this.store = store as keyof CacheManager['storeFactories'];
this.connection = connectionConfig;
this.connection = connectionString;
}
/**
@@ -75,47 +68,33 @@ export class CacheManager {
*/
forPlugin(pluginId: string): PluginCacheManager {
return {
getClient: ({ defaultTtl, onError }): CacheClient => {
const concreteClient = this.getClientWithTtl(defaultTtl);
getClient: (opts = {}): CacheClient => {
const concreteClient = this.getClientWithTtl(pluginId, opts.defaultTtl);
return new DefaultCacheClient({
client: concreteClient,
defaultTtl,
pluginId: pluginId,
onError: onError || 'returnEmpty',
});
},
};
}
private getClientWithTtl(ttl: number): cacheManager.Cache {
return this.storeFactories[this.store].call(this, ttl);
private getClientWithTtl(pluginId: string, ttl: number | undefined): Keyv {
return this.storeFactories[this.store].call(this, pluginId, ttl);
}
private getMemcacheClient(defaultTtl: number): cacheManager.Cache {
const hosts = this.connection.getStringArray('hosts');
const netTimeout = this.connection.getOptionalNumber('netTimeout');
return cacheManager.caching({
store: memcachedStore,
driver: Memcache,
options: {
hosts,
...(netTimeout && { netTimeout }),
},
private getMemcacheClient(pluginId: string, defaultTtl: number | undefined): Keyv {
const memcache = new KeyvMemcache(this.connection);
return new Keyv({
namespace: pluginId,
ttl: defaultTtl,
store: memcache,
});
}
private getMemoryClient(pluginId: string, defaultTtl: number | undefined): Keyv {
return new Keyv({
namespace: pluginId,
ttl: defaultTtl,
});
}
private getMemoryClient(defaultTtl: number): cacheManager.Cache {
return cacheManager.caching({
store: 'memory',
ttl: defaultTtl,
});
}
private getNoneClient(defaultTtl: number): cacheManager.Cache {
return cacheManager.caching({
store: 'none',
ttl: defaultTtl,
});
}
}
+7 -3
View File
@@ -17,8 +17,12 @@
import { CacheClient } from './CacheClient';
type ClientOptions = {
defaultTtl: number;
onError?: 'reject' | 'returnEmpty';
/**
* An optional default TTL (in milliseconds) to be set when getting a client
* instance. If not provided, data will persist indefinitely by default (or
* can be configured per entry at set-time).
*/
defaultTtl?: number;
};
/**
@@ -32,5 +36,5 @@ export type PluginCacheManager = {
* stores so that plugins are discouraged from cache-level integration
* and/or cache key collisions.
*/
getClient: (options: ClientOptions) => CacheClient;
getClient: (options?: ClientOptions) => CacheClient;
};