Wrap cache clients in a Backstage-specific CacheClient to handle key normalization and error handling + allow us the freedom to move away from cache-manager in the future if necessary.

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2021-05-02 15:18:06 +02:00
parent 93ff8275dd
commit 6e0be00e1d
6 changed files with 137 additions and 10 deletions
+2
View File
@@ -26,6 +26,8 @@ backend:
baseUrl: http://localhost:7000
listen:
port: 7000
cache:
store: memory
database:
client: sqlite3
connection: ':memory:'
+94
View File
@@ -0,0 +1,94 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonValue } from '@backstage/config';
import cacheManager from 'cache-manager';
import { createHash } from 'crypto';
type CacheClientArgs = {
client: cacheManager.Cache;
pluginId: string;
};
export interface CacheClient {
get(key: string): Promise<JsonValue>;
set(key: string, value: JsonValue, ttl?: number): Promise<void>;
delete(key: string): Promise<void>;
}
export class ConcreteCacheClient implements CacheClient {
private readonly client: cacheManager.Cache;
private readonly pluginId: string;
constructor({ client, pluginId }: CacheClientArgs) {
this.client = client;
this.pluginId = pluginId;
}
async get(key: string): Promise<JsonValue> {
const k = this.getNormalizedKey(key);
try {
const data = (await this.client.get(k)) as string | undefined;
return this.unserializeData(data);
} catch (_e) {
return null;
}
}
async set(key: string, value: JsonValue, ttl?: number): Promise<void> {
const k = this.getNormalizedKey(key);
try {
const data = this.serializeData(value);
await this.client.set(k, data, ttl ? { ttl } : undefined);
} catch (_e) {
return;
}
}
async delete(key: string): Promise<void> {
const k = this.getNormalizedKey(key);
try {
await this.client.del(k);
} catch (_e) {
return;
}
}
/**
* Namespaces key by plugin to discourage cross-plugin integration via the
* cache store.
*/
private getNormalizedKey(key: string): string {
// Namespace key by plugin ID and remove potentially invalid characters.
const candidateKey = `${this.pluginId}:${key}`;
const wellFormedKey = Buffer.from(candidateKey).toString('base64');
// Memcache in particular doesn't do well with keys > 250 bytes.
if (wellFormedKey.length < 250) {
return wellFormedKey;
}
return createHash('md5').update(candidateKey).digest('hex');
}
private serializeData(data: JsonValue): string {
return JSON.stringify(data);
}
private unserializeData(data: string | undefined): JsonValue {
return data ? JSON.parse(data) : null;
}
}
+36 -7
View File
@@ -14,20 +14,28 @@
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { Config, ConfigReader } from '@backstage/config';
import cacheManager from 'cache-manager';
// @ts-expect-error
import Memcache from 'memcache-pp';
// @ts-expect-error
import memcachedStore from 'cache-manager-memcached-store';
import { ConcreteCacheClient, CacheClient } from './CacheClient';
export type PluginCacheManager = {
getClient: (ttl: number) => CacheClient;
};
/**
* A Cache Manager, which is able to retrieve a `node-cache-manager`-compliant
* cache client, configured according to app configuration. If no cache is
* configured, or an unknown store is provided, a no-op cache instance will be
* returned.
* Implements a Cache Manager which will automatically create new cache clients
* for plugins when requested. All requested cacheclients are created with the
* credentials provided.
*/
export class CacheManager {
/**
* Keys represented supported `backend.cache.store` values, mapped to getters
* that return cacheManager.Cache instances appropriate to the store.
*/
private readonly storeGetterMap = {
memcache: this.getMemcacheClient,
memory: this.getMemoryClient,
@@ -41,12 +49,33 @@ export class CacheManager {
* @param config The loaded application configuration.
*/
static fromConfig(config: Config): CacheManager {
return new CacheManager(config.getConfig('backend.cache'));
// If no `backend.cache` config is provided, instantiate the CacheManager
// with empty config; allowing a "none" cache client will be returned.
return new CacheManager(
config.getOptionalConfig('backend.cache') || new ConfigReader(undefined),
);
}
private constructor(private readonly config: Config) {}
getClientWithTtl(ttl: number): cacheManager.Cache {
/**
* Generates a CacheManagerInstance for consumption by plugins.
*
* @param pluginId The plugin that the cache manager should be created for. Plugin names should be unique.
*/
forPlugin(pluginId: string): PluginCacheManager {
return {
getClient: (ttl: number): CacheClient => {
const concreteClient = this.getClientWithTtl(ttl);
return new ConcreteCacheClient({
client: concreteClient,
pluginId: pluginId,
});
},
};
}
private getClientWithTtl(ttl: number): cacheManager.Cache {
const store = this.config.getOptionalString(
'store',
) as keyof CacheManager['storeGetterMap'];
+1
View File
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export * from './CacheClient';
export * from './CacheManager';
+2 -1
View File
@@ -60,11 +60,12 @@ function makeCreateEnv(config: Config) {
root.info(`Created UrlReader ${reader}`);
const databaseManager = SingleConnectionDatabaseManager.fromConfig(config);
const cache = CacheManager.fromConfig(config);
const cacheManager = CacheManager.fromConfig(config);
return (plugin: string): PluginEnvironment => {
const logger = root.child({ type: 'plugin', plugin });
const database = databaseManager.forPlugin(plugin);
const cache = cacheManager.forPlugin(plugin);
return { logger, cache, database, config, reader, discovery };
};
}
+2 -2
View File
@@ -17,7 +17,7 @@
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import {
CacheManager,
PluginCacheManager,
PluginDatabaseManager,
PluginEndpointDiscovery,
UrlReader,
@@ -25,7 +25,7 @@ import {
export type PluginEnvironment = {
logger: Logger;
cache: CacheManager;
cache: PluginCacheManager;
database: PluginDatabaseManager;
config: Config;
reader: UrlReader;