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:
@@ -26,6 +26,8 @@ backend:
|
||||
baseUrl: http://localhost:7000
|
||||
listen:
|
||||
port: 7000
|
||||
cache:
|
||||
store: memory
|
||||
database:
|
||||
client: sqlite3
|
||||
connection: ':memory:'
|
||||
|
||||
@@ -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
@@ -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
@@ -14,4 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './CacheClient';
|
||||
export * from './CacheManager';
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user