diff --git a/.changeset/chilled-terms-breathe.md b/.changeset/chilled-terms-breathe.md new file mode 100644 index 0000000000..654185492a --- /dev/null +++ b/.changeset/chilled-terms-breathe.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Allow a default cache TTL to be set through the app config diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index f6330d16bc..8bcc5465d5 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -149,6 +149,8 @@ export interface Config { cache?: | { store: 'memory'; + /** An optional default TTL (in milliseconds). */ + defaultTtl?: number; } | { store: 'redis'; @@ -157,6 +159,8 @@ export interface Config { * @visibility secret */ connection: string; + /** An optional default TTL (in milliseconds). */ + defaultTtl?: number; } | { store: 'memcache'; @@ -165,6 +169,8 @@ export interface Config { * @visibility secret */ connection: string; + /** An optional default TTL (in milliseconds). */ + defaultTtl?: number; }; cors?: { diff --git a/packages/backend-common/src/cache/CacheManager.test.ts b/packages/backend-common/src/cache/CacheManager.test.ts index 13a4a0d599..4b7bb05efd 100644 --- a/packages/backend-common/src/cache/CacheManager.test.ts +++ b/packages/backend-common/src/cache/CacheManager.test.ts @@ -33,11 +33,13 @@ jest.mock('./CacheClient', () => { }; }); +const globalDefaultTtl = 1234; describe('CacheManager', () => { const defaultConfigOptions = { backend: { cache: { store: 'memory', + defaultTtl: globalDefaultTtl, }, }, }; @@ -49,9 +51,11 @@ describe('CacheManager', () => { it('accesses the backend.cache key', () => { const getOptionalString = jest.fn(); const getOptionalBoolean = jest.fn(); + const getOptionalNumber = jest.fn(); const config = defaultConfig(); config.getOptionalString = getOptionalString; config.getOptionalBoolean = getOptionalBoolean; + config.getOptionalNumber = getOptionalNumber; CacheManager.fromConfig(config); @@ -62,6 +66,9 @@ describe('CacheManager', () => { expect(getOptionalBoolean.mock.calls[0][0]).toEqual( 'backend.cache.useRedisSets', ); + expect(getOptionalNumber.mock.calls[0][0]).toEqual( + 'backend.cache.defaultTtl', + ); }); it('does not require the backend.cache key', () => { @@ -159,6 +166,20 @@ describe('CacheManager', () => { }); }); + it('returns memory client with a global defaultTtl when explicitly configured', () => { + const manager = CacheManager.fromConfig(defaultConfig()); + const expectedNamespace = 'test-plugin'; + manager.forPlugin(expectedNamespace).getClient(); + + const cache = Keyv as unknown as jest.Mock; + const mockCalls = cache.mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0]).toMatchObject({ + ttl: globalDefaultTtl, + namespace: expectedNamespace, + }); + }); + it('shares memory across multiple instances of the memory client', () => { const manager = CacheManager.fromConfig(defaultConfig()); const plugin = 'test-plugin'; @@ -201,6 +222,32 @@ describe('CacheManager', () => { expect(mockMemcacheCalls[0][0]).toEqual(expectedHost); }); + it('returns a memcache client with a global defaultTtl when configured', () => { + const expectedHost = '127.0.0.1:11211'; + const manager = CacheManager.fromConfig( + new ConfigReader({ + backend: { + cache: { + store: 'memcache', + connection: expectedHost, + defaultTtl: globalDefaultTtl, + }, + }, + }), + ); + manager.forPlugin('test').getClient(); + + const cache = Keyv as unknown as jest.Mock; + const mockCacheCalls = cache.mock.calls.splice(-1); + expect(mockCacheCalls[0][0]).toMatchObject({ + ttl: globalDefaultTtl, + }); + expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvMemcache); + const memcache = KeyvMemcache as unknown as jest.Mock; + const mockMemcacheCalls = memcache.mock.calls.splice(-1); + expect(mockMemcacheCalls[0][0]).toEqual(expectedHost); + }); + it('returns a Redis client when configured', () => { const redisConnection = 'redis://127.0.0.1:6379'; const manager = CacheManager.fromConfig( @@ -227,6 +274,32 @@ describe('CacheManager', () => { expect(mockRedisCalls[0][0]).toEqual(redisConnection); }); + it('returns a Redis client with a global defaultTtl when configured', () => { + const redisConnection = 'redis://127.0.0.1:6379'; + const manager = CacheManager.fromConfig( + new ConfigReader({ + backend: { + cache: { + store: 'redis', + connection: redisConnection, + defaultTtl: globalDefaultTtl, + }, + }, + }), + ); + manager.forPlugin('test').getClient(); + + const cache = Keyv as unknown as jest.Mock; + const mockCacheCalls = cache.mock.calls.splice(-1); + expect(mockCacheCalls[0][0]).toMatchObject({ + ttl: globalDefaultTtl, + }); + expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvRedis); + const redis = KeyvRedis as unknown as jest.Mock; + const mockRedisCalls = redis.mock.calls.splice(-1); + expect(mockRedisCalls[0][0]).toEqual(redisConnection); + }); + it('returns a Redis client when configured with useRedisSets flag', () => { const redisConnection = 'redis://127.0.0.1:6379'; const useRedisSets = false; diff --git a/packages/backend-common/src/cache/CacheManager.ts b/packages/backend-common/src/cache/CacheManager.ts index efaaf04e3a..c7298ab3e9 100644 --- a/packages/backend-common/src/cache/CacheManager.ts +++ b/packages/backend-common/src/cache/CacheManager.ts @@ -57,6 +57,7 @@ export class CacheManager { private readonly connection: string; private readonly useRedisSets: boolean; private readonly errorHandler: CacheManagerOptions['onError']; + private readonly defaultTtl?: number; /** * Creates a new {@link CacheManager} instance by reading from the `backend` @@ -71,6 +72,7 @@ export class CacheManager { // If no `backend.cache` config is provided, instantiate the CacheManager // with an in-memory cache client. const store = config.getOptionalString('backend.cache.store') || 'memory'; + const defaultTtl = config.getOptionalNumber('backend.cache.defaultTtl'); const connectionString = config.getOptionalString('backend.cache.connection') || ''; const useRedisSets = @@ -84,6 +86,7 @@ export class CacheManager { useRedisSets, logger, options.onError, + defaultTtl, ); } @@ -93,6 +96,7 @@ export class CacheManager { useRedisSets: boolean, logger: LoggerService, errorHandler: CacheManagerOptions['onError'], + defaultTtl?: number, ) { if (!this.storeFactories.hasOwnProperty(store)) { throw new Error(`Unknown cache store: ${store}`); @@ -102,6 +106,7 @@ export class CacheManager { this.connection = connectionString; this.useRedisSets = useRedisSets; this.errorHandler = errorHandler; + this.defaultTtl = defaultTtl; } /** @@ -116,7 +121,7 @@ export class CacheManager { const clientFactory = (options: CacheServiceOptions) => { const concreteClient = this.getClientWithTtl( pluginId, - options.defaultTtl, + options.defaultTtl ?? this.defaultTtl, ); // Always provide an error handler to avoid stopping the process.