feat(backend-defaults): implement more comprehensive redis option passthrough

Signed-off-by: Mark Nachazel <markn@doordash.com>
This commit is contained in:
Mark Nachazel
2025-03-11 15:17:58 +00:00
committed by Mark Nachazel
parent ae33d1635a
commit 01edf6e916
4 changed files with 176 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': minor
---
Allow pass through of redis client and cluster options to Cache core service
@@ -15,7 +15,7 @@
*/
import { mockServices, TestCaches } from '@backstage/backend-test-utils';
import KeyvRedis from '@keyv/redis';
import KeyvRedis, { createCluster } from '@keyv/redis';
import KeyvMemcache from '@keyv/memcache';
import { CacheManager } from './CacheManager';
@@ -30,6 +30,7 @@ jest.mock('@keyv/redis', () => {
...Actual,
__esModule: true,
default: jest.fn((...args: any[]) => new DefaultConstructor(...args)),
createCluster: jest.fn(),
};
});
jest.mock('@keyv/memcache', () => {
@@ -194,3 +195,73 @@ describe('CacheManager integration', () => {
).toThrow(/Invalid duration 'hello' in config/);
});
});
describe('CacheManager store options', () => {
it('uses default options when no store-specific config exists', () => {
const manager = CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'redis',
connection: 'redis://localhost:6379',
},
},
},
}),
);
manager.forPlugin('p1');
expect(KeyvRedis).toHaveBeenCalledWith('redis://localhost:6379', {
keyPrefixSeparator: ':',
});
});
it('defaults to non-clustered mode when cluster config is missing root nodes', () => {
const manager = CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'redis',
connection: 'redis://localhost:6379',
cluster: {},
},
},
},
}),
);
manager.forPlugin('p1');
expect(KeyvRedis).toHaveBeenCalledWith('redis://localhost:6379', {
keyPrefixSeparator: ':',
});
});
it('uses cluster config when present', () => {
const manager = CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'redis',
connection: 'redis://localhost:6379',
redis: {
cluster: {
rootNodes: [{ url: 'redis://localhost:6379' }],
},
},
},
},
},
}),
);
manager.forPlugin('p1');
expect(createCluster).toHaveBeenCalledWith({
rootNodes: [{ url: 'redis://localhost:6379' }],
defaults: undefined,
});
});
});
@@ -22,7 +22,12 @@ import {
} from '@backstage/backend-plugin-api';
import Keyv from 'keyv';
import { DefaultCacheClient } from './CacheClient';
import { CacheManagerOptions, ttlToMilliseconds } from './types';
import {
CacheManagerOptions,
ttlToMilliseconds,
CacheStoreOptions,
RedisCacheStoreOptions,
} from './types';
import { durationToMilliseconds } from '@backstage/types';
import { readDurationFromConfig } from '@backstage/config';
@@ -51,6 +56,7 @@ export class CacheManager {
private readonly connection: string;
private readonly errorHandler: CacheManagerOptions['onError'];
private readonly defaultTtl?: number;
private readonly storeOptions?: CacheStoreOptions;
/**
* Creates a new {@link CacheManager} instance by reading from the `backend`
@@ -89,15 +95,73 @@ export class CacheManager {
}
}
// Read store-specific options from config
const storeOptions = CacheManager.parseStoreOptions(store, config, logger);
return new CacheManager(
store,
connectionString,
options.onError,
logger,
defaultTtl,
storeOptions,
);
}
/**
* Parse store-specific options from configuration.
*
* @param store - The cache store type ('redis', 'memcache', or 'memory')
* @param config - The configuration service
* @param logger - Optional logger for warnings
* @returns The parsed store options
*/
private static parseStoreOptions(
store: string,
config: RootConfigService,
logger?: LoggerService,
): CacheStoreOptions | undefined {
const storeConfigPath = `backend.cache.${store}`;
if (store === 'redis' && config.has(storeConfigPath)) {
return CacheManager.parseRedisOptions(storeConfigPath, config, logger);
}
return undefined;
}
/**
* Parse Redis-specific options from configuration.
*/
private static parseRedisOptions(
storeConfigPath: string,
config: RootConfigService,
logger?: LoggerService,
): RedisCacheStoreOptions {
const redisOptions: RedisCacheStoreOptions = {};
const redisConfig = config.getConfig(storeConfigPath);
redisOptions.client = redisConfig.getOptional('client');
if (redisConfig.has('cluster')) {
const clusterConfig = redisConfig.getConfig('cluster');
if (!clusterConfig.has('rootNodes')) {
logger?.warn(
`Redis cluster config has no 'rootNodes' key, defaulting to non-clustered mode`,
);
return redisOptions;
}
redisOptions.cluster = {
rootNodes: clusterConfig.get('rootNodes'),
defaults: clusterConfig.getOptional('defaults'),
};
}
return redisOptions;
}
/** @internal */
constructor(
store: string,
@@ -105,6 +169,7 @@ export class CacheManager {
errorHandler: CacheManagerOptions['onError'],
logger?: LoggerService,
defaultTtl?: number,
storeOptions?: CacheStoreOptions,
) {
if (!this.storeFactories.hasOwnProperty(store)) {
throw new Error(`Unknown cache store: ${store}`);
@@ -114,6 +179,7 @@ export class CacheManager {
this.connection = connectionString;
this.errorHandler = errorHandler;
this.defaultTtl = defaultTtl;
this.storeOptions = storeOptions;
}
/**
@@ -140,13 +206,23 @@ export class CacheManager {
private createRedisStoreFactory(): StoreFactory {
const KeyvRedis = require('@keyv/redis').default;
const stores: Record<string, typeof KeyvRedis> = {};
const { createCluster } = require('@keyv/redis');
const stores: Record<string, any> = {};
return (pluginId, defaultTtl) => {
if (!stores[pluginId]) {
stores[pluginId] = new KeyvRedis(this.connection, {
keyPrefixSeparator: ':',
});
if (this.storeOptions?.cluster) {
// Create a Redis cluster
const cluster = createCluster(this.storeOptions?.cluster);
stores[pluginId] = new KeyvRedis(cluster);
} else {
// Create a regular Redis connection
const redisOptions = this.storeOptions?.client || {
keyPrefixSeparator: ':',
};
stores[pluginId] = new KeyvRedis(this.connection, redisOptions);
}
// Always provide an error handler to avoid stopping the process
stores[pluginId].on('error', (err: Error) => {
this.logger?.error('Failed to create redis cache client', err);
@@ -16,6 +16,24 @@
import { LoggerService } from '@backstage/backend-plugin-api';
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
import { RedisClusterOptions, RedisClientOptions } from '@keyv/redis';
/**
* Options for Redis cache store.
*
* @public
*/
export type RedisCacheStoreOptions = {
client?: RedisClientOptions;
cluster?: RedisClusterOptions;
};
/**
* Union type of all cache store options.
*
* @public
*/
export type CacheStoreOptions = RedisCacheStoreOptions;
/**
* Options given when constructing a {@link CacheManager}.