From 8f2631e418733cf680977a2dccbdf8bd51e1af79 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Fri, 8 Mar 2024 08:41:22 +0100 Subject: [PATCH] feat: stabilize global frontend api cache (#6466) --- .../segment/fake-segment-read-model.ts | 6 +- .../client-feature-toggle-read-model-type.ts | 5 + .../proxy/client-feature-toggle-read-model.ts | 5 +- .../fake-client-feature-toggle-read-model.ts | 16 ++ src/lib/proxy/frontend-api-repository.ts | 6 +- .../proxy/global-frontend-api-cache.test.ts | 169 ++++++++++++++++++ ...sitory.ts => global-frontend-api-cache.ts} | 41 +++-- 7 files changed, 226 insertions(+), 22 deletions(-) create mode 100644 src/lib/proxy/client-feature-toggle-read-model-type.ts create mode 100644 src/lib/proxy/fake-client-feature-toggle-read-model.ts create mode 100644 src/lib/proxy/global-frontend-api-cache.test.ts rename src/lib/proxy/{global-frontend-api-repository.ts => global-frontend-api-cache.ts} (71%) diff --git a/src/lib/features/segment/fake-segment-read-model.ts b/src/lib/features/segment/fake-segment-read-model.ts index a5ac245117..0a79bb3496 100644 --- a/src/lib/features/segment/fake-segment-read-model.ts +++ b/src/lib/features/segment/fake-segment-read-model.ts @@ -2,8 +2,10 @@ import { IClientSegment, IFeatureStrategySegment, ISegment } from '../../types'; import { ISegmentReadModel } from './segment-read-model-type'; export class FakeSegmentReadModel implements ISegmentReadModel { + constructor(private segments: ISegment[] = []) {} + async getAll(): Promise { - return []; + return this.segments; } async getAllFeatureStrategySegments(): Promise { @@ -11,7 +13,7 @@ export class FakeSegmentReadModel implements ISegmentReadModel { } async getActive(): Promise { - return []; + return this.segments; } async getActiveForClient(): Promise { diff --git a/src/lib/proxy/client-feature-toggle-read-model-type.ts b/src/lib/proxy/client-feature-toggle-read-model-type.ts new file mode 100644 index 0000000000..d33a5cd84f --- /dev/null +++ b/src/lib/proxy/client-feature-toggle-read-model-type.ts @@ -0,0 +1,5 @@ +import { IFeatureToggleClient } from '../types'; + +export interface IClientFeatureToggleReadModel { + getClient(): Promise>; +} diff --git a/src/lib/proxy/client-feature-toggle-read-model.ts b/src/lib/proxy/client-feature-toggle-read-model.ts index 038f9d09d4..202f3ef3b7 100644 --- a/src/lib/proxy/client-feature-toggle-read-model.ts +++ b/src/lib/proxy/client-feature-toggle-read-model.ts @@ -13,6 +13,7 @@ import Raw = Knex.Raw; import metricsHelper from '../util/metrics-helper'; import { DB_TIME } from '../metric-events'; import EventEmitter from 'events'; +import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type'; export interface IGetAllFeatures { featureQuery?: IFeatureToggleQuery; @@ -20,7 +21,9 @@ export interface IGetAllFeatures { userId?: number; } -export default class ClientFeatureToggleReadModel { +export default class ClientFeatureToggleReadModel + implements IClientFeatureToggleReadModel +{ private db: Db; private timer: Function; diff --git a/src/lib/proxy/fake-client-feature-toggle-read-model.ts b/src/lib/proxy/fake-client-feature-toggle-read-model.ts new file mode 100644 index 0000000000..206ec46b25 --- /dev/null +++ b/src/lib/proxy/fake-client-feature-toggle-read-model.ts @@ -0,0 +1,16 @@ +import { IFeatureToggleClient } from '../types'; +import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type'; + +export default class FakeClientFeatureToggleReadModel + implements IClientFeatureToggleReadModel +{ + constructor(private value: Record) {} + + getClient(): Promise> { + return Promise.resolve(this.value); + } + + setValue(value: Record) { + this.value = value; + } +} diff --git a/src/lib/proxy/frontend-api-repository.ts b/src/lib/proxy/frontend-api-repository.ts index 3cda3b2ff1..18eb5f9a4f 100644 --- a/src/lib/proxy/frontend-api-repository.ts +++ b/src/lib/proxy/frontend-api-repository.ts @@ -9,7 +9,7 @@ import { IApiUser } from '../types/api-user'; import { IUnleashConfig } from '../types'; import { UnleashEvents } from 'unleash-client'; import { Logger } from '../logger'; -import { GlobalFrontendApiRepository } from './global-frontend-api-repository'; +import { GlobalFrontendApiCache } from './global-frontend-api-cache'; type Config = Pick; @@ -23,13 +23,13 @@ export class FrontendApiRepository private readonly token: IApiUser; - private globalFrontendApiRepository: GlobalFrontendApiRepository; + private globalFrontendApiRepository: GlobalFrontendApiCache; private running: boolean; constructor( config: Config, - globalFrontendApiRepository: GlobalFrontendApiRepository, + globalFrontendApiRepository: GlobalFrontendApiCache, token: IApiUser, ) { super(); diff --git a/src/lib/proxy/global-frontend-api-cache.test.ts b/src/lib/proxy/global-frontend-api-cache.test.ts new file mode 100644 index 0000000000..50ef45c1d4 --- /dev/null +++ b/src/lib/proxy/global-frontend-api-cache.test.ts @@ -0,0 +1,169 @@ +import { + GlobalFrontendApiCache, + GlobalFrontendApiCacheState, +} from './global-frontend-api-cache'; +import noLogger from '../../test/fixtures/no-logger'; +import { FakeSegmentReadModel } from '../features/segment/fake-segment-read-model'; +import FakeClientFeatureToggleReadModel from './fake-client-feature-toggle-read-model'; +import EventEmitter from 'events'; +import { IApiUser, IFeatureToggleClient, ISegment } from '../types'; +import { UPDATE_REVISION } from '../features/feature-toggle/configuration-revision-service'; + +const state = async ( + cache: GlobalFrontendApiCache, + state: GlobalFrontendApiCacheState, +) => { + await new Promise((resolve) => { + cache.on(state, () => { + resolve('done'); + }); + }); +}; + +const defaultFeature: IFeatureToggleClient = { + name: 'featureA', + enabled: true, + strategies: [], + variants: [], + project: 'projectA', + dependencies: [], + type: 'release', + stale: false, + description: '', +}; +const defaultSegment = { name: 'segment', id: 1 } as ISegment; + +const createCache = ( + segment: ISegment = defaultSegment, + features: Record = {}, +) => { + const config = { getLogger: noLogger }; + const segmentReadModel = new FakeSegmentReadModel([segment as ISegment]); + const clientFeatureToggleReadModel = new FakeClientFeatureToggleReadModel( + features, + ); + const configurationRevisionService = new EventEmitter(); + const cache = new GlobalFrontendApiCache( + config, + segmentReadModel, + clientFeatureToggleReadModel, + configurationRevisionService, + ); + + return { + cache, + configurationRevisionService, + clientFeatureToggleReadModel, + }; +}; + +test('Can read initial segment', async () => { + const { cache } = createCache({ name: 'segment', id: 1 } as ISegment); + + const segmentBeforeRead = cache.getSegment(1); + expect(segmentBeforeRead).toEqual(undefined); + + await state(cache, 'ready'); + + const segment = cache.getSegment(1); + expect(segment).toEqual({ name: 'segment', id: 1 }); +}); + +test('Can read initial features', async () => { + const { cache } = createCache(defaultSegment, { + development: [ + { + ...defaultFeature, + name: 'featureA', + enabled: true, + project: 'projectA', + }, + { + ...defaultFeature, + name: 'featureB', + enabled: true, + project: 'projectB', + }, + ], + production: [ + { + ...defaultFeature, + name: 'featureA', + enabled: false, + project: 'projectA', + }, + ], + }); + + const featuresBeforeRead = cache.getToggles({ + environment: 'development', + projects: ['projectA'], + } as IApiUser); + expect(featuresBeforeRead).toEqual([]); + + await state(cache, 'ready'); + + const features = cache.getToggles({ + environment: 'development', + projects: ['projectA'], + } as IApiUser); + expect(features).toEqual([ + { + ...defaultFeature, + name: 'featureA', + enabled: true, + impressionData: false, + }, + ]); + + const allProjectFeatures = cache.getToggles({ + environment: 'development', + projects: ['*'], + } as IApiUser); + expect(allProjectFeatures.length).toBe(2); + + const defaultProjectFeatures = cache.getToggles({ + environment: '*', + projects: ['*'], + } as IApiUser); + expect(defaultProjectFeatures.length).toBe(0); +}); + +test('Can refresh data on revision update', async () => { + const { + cache, + configurationRevisionService, + clientFeatureToggleReadModel, + } = createCache(); + + await state(cache, 'ready'); + + clientFeatureToggleReadModel.setValue({ + development: [ + { + ...defaultFeature, + name: 'featureA', + enabled: false, + strategies: [{ name: 'default' }], + project: 'projectA', + }, + ], + }); + configurationRevisionService.emit(UPDATE_REVISION); + + await state(cache, 'updated'); + + const features = cache.getToggles({ + environment: 'development', + projects: ['projectA'], + } as IApiUser); + expect(features).toMatchObject([ + { + ...defaultFeature, + name: 'featureA', + enabled: false, + strategies: [{ name: 'default' }], + impressionData: false, + }, + ]); +}); diff --git a/src/lib/proxy/global-frontend-api-repository.ts b/src/lib/proxy/global-frontend-api-cache.ts similarity index 71% rename from src/lib/proxy/global-frontend-api-repository.ts rename to src/lib/proxy/global-frontend-api-cache.ts index 191a01a22a..b484ec8dd1 100644 --- a/src/lib/proxy/global-frontend-api-repository.ts +++ b/src/lib/proxy/global-frontend-api-cache.ts @@ -9,38 +9,36 @@ import { } from '../features/playground/offline-unleash-client'; import { ALL_ENVS } from '../util/constants'; import { Logger } from '../logger'; -import ConfigurationRevisionService, { - UPDATE_REVISION, -} from '../features/feature-toggle/configuration-revision-service'; -import ClientFeatureToggleReadModel from './client-feature-toggle-read-model'; +import { UPDATE_REVISION } from '../features/feature-toggle/configuration-revision-service'; import { mapValues } from '../util'; +import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type'; -type Config = Pick; +type Config = Pick; -export class GlobalFrontendApiRepository extends EventEmitter { +export type GlobalFrontendApiCacheState = 'starting' | 'ready' | 'updated'; + +export class GlobalFrontendApiCache extends EventEmitter { private readonly config: Config; private readonly logger: Logger; - private readonly clientFeatureToggleReadModel: ClientFeatureToggleReadModel; + private readonly clientFeatureToggleReadModel: IClientFeatureToggleReadModel; private readonly segmentReadModel: ISegmentReadModel; - private readonly configurationRevisionService: ConfigurationRevisionService; + private readonly configurationRevisionService: EventEmitter; - private featuresByEnvironment: Record; + private featuresByEnvironment: Record = {}; - private segments: Segment[]; + private segments: Segment[] = []; - private interval: number; - - private running: boolean; + private status: GlobalFrontendApiCacheState = 'starting'; constructor( config: Config, segmentReadModel: ISegmentReadModel, - clientFeatureToggleReadModel: ClientFeatureToggleReadModel, - configurationRevisionService: ConfigurationRevisionService, + clientFeatureToggleReadModel: IClientFeatureToggleReadModel, + configurationRevisionService: EventEmitter, ) { super(); this.config = config; @@ -49,7 +47,6 @@ export class GlobalFrontendApiRepository extends EventEmitter { this.configurationRevisionService = configurationRevisionService; this.segmentReadModel = segmentReadModel; this.onUpdateRevisionEvent = this.onUpdateRevisionEvent.bind(this); - this.interval = config.frontendApi.refreshIntervalInMs; this.refreshData(); this.configurationRevisionService.on( UPDATE_REVISION, @@ -62,6 +59,11 @@ export class GlobalFrontendApiRepository extends EventEmitter { } getToggles(token: IApiUser): FeatureInterface[] { + if ( + this.featuresByEnvironment[this.environmentNameForToken(token)] == + null + ) + return []; return this.featuresByEnvironment[ this.environmentNameForToken(token) ].filter( @@ -88,6 +90,13 @@ export class GlobalFrontendApiRepository extends EventEmitter { try { this.featuresByEnvironment = await this.getAllFeatures(); this.segments = await this.getAllSegments(); + if (this.status === 'starting') { + this.status = 'ready'; + this.emit('ready'); + } else if (this.status === 'ready' || this.status === 'updated') { + this.status = 'updated'; + this.emit('updated'); + } } catch (e) { this.logger.error('Cannot load data for token', e); }