From 66dbf0affff13ff7ff55d6b232b4ea4d305d137b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 23 Aug 2024 16:29:00 +0200 Subject: [PATCH] add human duration ttls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/tiny-waves-provide.md | 7 ++ packages/backend-common/config.d.ts | 8 +- .../src/entrypoints/cache/CacheClient.ts | 5 +- .../entrypoints/cache/CacheManager.test.ts | 92 +++++++++++++++++++ .../src/entrypoints/cache/CacheManager.ts | 29 +++++- .../src/entrypoints/cache/types.ts | 5 + packages/backend-plugin-api/api-report.md | 4 +- .../src/services/definitions/CacheService.ts | 10 +- 8 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 .changeset/tiny-waves-provide.md diff --git a/.changeset/tiny-waves-provide.md b/.changeset/tiny-waves-provide.md new file mode 100644 index 0000000000..aabafcfd6b --- /dev/null +++ b/.changeset/tiny-waves-provide.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/backend-defaults': patch +'@backstage/backend-common': patch +--- + +Allow the cache service to accept the human duration format for TTL diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index ed253dd54d..8844b07044 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { HumanDuration } from '@backstage/types'; + export interface Config { app: { baseUrl: string; // defined in core, but repeated here without doc @@ -180,7 +182,7 @@ export interface Config { | { store: 'memory'; /** An optional default TTL (in milliseconds). */ - defaultTtl?: number; + defaultTtl?: number | HumanDuration; } | { store: 'redis'; @@ -190,7 +192,7 @@ export interface Config { */ connection: string; /** An optional default TTL (in milliseconds). */ - defaultTtl?: number; + defaultTtl?: number | HumanDuration; /** * Whether or not [useRedisSets](https://github.com/jaredwray/keyv/tree/main/packages/redis#useredissets) should be configured to this redis cache. * Defaults to true if unspecified. @@ -205,7 +207,7 @@ export interface Config { */ connection: string; /** An optional default TTL (in milliseconds). */ - defaultTtl?: number; + defaultTtl?: number | HumanDuration; }; cors?: { diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheClient.ts b/packages/backend-defaults/src/entrypoints/cache/CacheClient.ts index aba62558d4..f519daa6df 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheClient.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheClient.ts @@ -22,6 +22,7 @@ import { import { JsonValue } from '@backstage/types'; import { createHash } from 'crypto'; import Keyv from 'keyv'; +import { ttlToMilliseconds } from './types'; export type CacheClientFactory = (options: CacheServiceOptions) => Keyv; @@ -58,7 +59,9 @@ export class DefaultCacheClient implements CacheService { opts: CacheServiceSetOptions = {}, ): Promise { const k = this.getNormalizedKey(key); - await this.#client.set(k, value, opts.ttl); + const ttl = + opts.ttl !== undefined ? ttlToMilliseconds(opts.ttl) : undefined; + await this.#client.set(k, value, ttl); } async delete(key: string): Promise { diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts index 6eb907764d..6a1dad2c91 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts @@ -93,4 +93,96 @@ describe('CacheManager integration', () => { await expect(plugin2b.get('a')).resolves.toBe('plugin2b'); }, ); + + it.each(caches.eachSupportedId())( + 'supports both milliseconds and human durations throughout, %p', + async cacheId => { + const { store, connection } = await caches.init(cacheId); + + for (const defaultTtl of [200, { milliseconds: 200 }]) { + const manager = CacheManager.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + cache: { + store, + connection, + defaultTtl, + }, + }, + }, + }), + ).forPlugin('p'); + + const defaultClient = manager.getClient(); + const numberOverrideClient = manager.getClient({ defaultTtl: 400 }); + const durationOverrideClient = manager.getClient({ + defaultTtl: { milliseconds: 400 }, + }); + + await defaultClient.set('a', 'x'); + await defaultClient.set('b', 'x'); + await numberOverrideClient.set('c', 'x'); + await durationOverrideClient.set('d', 'x'); + await defaultClient.set('e', 'x', { ttl: 400 }); + await defaultClient.set('f', 'x', { ttl: { milliseconds: 400 } }); + + await expect(defaultClient.get('a')).resolves.toBe('x'); + await expect(defaultClient.get('b')).resolves.toBe('x'); + await expect(defaultClient.get('c')).resolves.toBe('x'); + await expect(defaultClient.get('d')).resolves.toBe('x'); + await expect(defaultClient.get('e')).resolves.toBe('x'); + await expect(defaultClient.get('f')).resolves.toBe('x'); + + await new Promise(resolve => setTimeout(resolve, 50 + 200)); + + await expect(defaultClient.get('a')).resolves.toBeUndefined(); + await expect(defaultClient.get('b')).resolves.toBeUndefined(); + await expect(defaultClient.get('c')).resolves.toBe('x'); + await expect(defaultClient.get('d')).resolves.toBe('x'); + await expect(defaultClient.get('e')).resolves.toBe('x'); + await expect(defaultClient.get('f')).resolves.toBe('x'); + + await new Promise(resolve => setTimeout(resolve, 200)); + + await expect(defaultClient.get('a')).resolves.toBeUndefined(); + await expect(defaultClient.get('b')).resolves.toBeUndefined(); + await expect(defaultClient.get('c')).resolves.toBeUndefined(); + await expect(defaultClient.get('d')).resolves.toBeUndefined(); + await expect(defaultClient.get('e')).resolves.toBeUndefined(); + await expect(defaultClient.get('f')).resolves.toBeUndefined(); + } + }, + ); + + it('rejects invalid defaultTtl', () => { + expect(() => + CacheManager.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + cache: { + store: 'memory', + }, + }, + }, + }), + ), + ).not.toThrow(); + + expect(() => + CacheManager.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + cache: { + store: 'memory', + defaultTtl: 'hello', + }, + }, + }, + }), + ), + ).toThrow(/Invalid configuration backend.cache.defaultTtl/); + }); }); diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts index 873bcf9648..9dd42862c9 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts @@ -21,7 +21,12 @@ import { import { Config } from '@backstage/config'; import Keyv from 'keyv'; import { DefaultCacheClient } from './CacheClient'; -import { CacheManagerOptions, PluginCacheManager } from './types'; +import { + CacheManagerOptions, + PluginCacheManager, + ttlToMilliseconds, +} from './types'; +import { durationToMilliseconds } from '@backstage/types'; type StoreFactory = (pluginId: string, defaultTtl: number | undefined) => Keyv; @@ -63,7 +68,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 defaultTtlConfig = config.getOptional('backend.cache.defaultTtl'); const connectionString = config.getOptionalString('backend.cache.connection') || ''; const useRedisSets = @@ -71,6 +76,23 @@ export class CacheManager { const logger = options.logger?.child({ type: 'cacheManager', }); + + let defaultTtl: number | undefined; + if (defaultTtlConfig !== undefined && defaultTtlConfig !== null) { + if (typeof defaultTtlConfig === 'number') { + defaultTtl = defaultTtlConfig; + } else if ( + typeof defaultTtlConfig === 'object' && + !Array.isArray(defaultTtlConfig) + ) { + defaultTtl = durationToMilliseconds(defaultTtlConfig); + } else { + throw new Error( + `Invalid configuration backend.cache.defaultTtl: ${defaultTtlConfig}, expected milliseconds number or HumanDuration object`, + ); + } + } + return new CacheManager( store, connectionString, @@ -111,9 +133,10 @@ export class CacheManager { return { getClient: (defaultOptions = {}) => { const clientFactory = (options: CacheServiceOptions) => { + const ttl = options.defaultTtl ?? this.defaultTtl; return this.getClientWithTtl( pluginId, - options.defaultTtl ?? this.defaultTtl, + ttl !== undefined ? ttlToMilliseconds(ttl) : undefined, ); }; diff --git a/packages/backend-defaults/src/entrypoints/cache/types.ts b/packages/backend-defaults/src/entrypoints/cache/types.ts index ccb3ed5f6d..23c8bcaf3d 100644 --- a/packages/backend-defaults/src/entrypoints/cache/types.ts +++ b/packages/backend-defaults/src/entrypoints/cache/types.ts @@ -19,6 +19,7 @@ import { CacheService, CacheServiceOptions, } from '@backstage/backend-plugin-api'; +import { HumanDuration, durationToMilliseconds } from '@backstage/types'; /** * Options given when constructing a {@link CacheManager}. @@ -44,3 +45,7 @@ export type CacheManagerOptions = { export interface PluginCacheManager { getClient(options?: CacheServiceOptions): CacheService; } + +export function ttlToMilliseconds(ttl: number | HumanDuration): number { + return typeof ttl === 'number' ? ttl : durationToMilliseconds(ttl); +} diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 1732998e44..89aea6620f 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -162,12 +162,12 @@ export interface CacheService { // @public export type CacheServiceOptions = { - defaultTtl?: number; + defaultTtl?: number | HumanDuration; }; // @public export type CacheServiceSetOptions = { - ttl?: number; + ttl?: number | HumanDuration; }; // @public diff --git a/packages/backend-plugin-api/src/services/definitions/CacheService.ts b/packages/backend-plugin-api/src/services/definitions/CacheService.ts index 3b13292a58..4c5cfd74b3 100644 --- a/packages/backend-plugin-api/src/services/definitions/CacheService.ts +++ b/packages/backend-plugin-api/src/services/definitions/CacheService.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { JsonValue } from '@backstage/types'; +import { HumanDuration, JsonValue } from '@backstage/types'; /** * Options passed to {@link CacheService.set}. @@ -23,10 +23,10 @@ import { JsonValue } from '@backstage/types'; */ export type CacheServiceSetOptions = { /** - * Optional TTL in milliseconds. Defaults to the TTL provided when the client + * Optional TTL (in milliseconds if given as a number). Defaults to the TTL provided when the client * was set up (or no TTL if none are provided). */ - ttl?: number; + ttl?: number | HumanDuration; }; /** @@ -36,11 +36,11 @@ export type CacheServiceSetOptions = { */ export type CacheServiceOptions = { /** - * An optional default TTL (in milliseconds) to be set when getting a client + * An optional default TTL (in milliseconds if given as a number) 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; + defaultTtl?: number | HumanDuration; }; /**