mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: stabilize global frontend api cache (#6466)
This commit is contained in:
		
							parent
							
								
									97a81162ac
								
							
						
					
					
						commit
						8f2631e418
					
				@ -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<ISegment[]> {
 | 
			
		||||
        return [];
 | 
			
		||||
        return this.segments;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]> {
 | 
			
		||||
@ -11,7 +13,7 @@ export class FakeSegmentReadModel implements ISegmentReadModel {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getActive(): Promise<ISegment[]> {
 | 
			
		||||
        return [];
 | 
			
		||||
        return this.segments;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getActiveForClient(): Promise<IClientSegment[]> {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								src/lib/proxy/client-feature-toggle-read-model-type.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/lib/proxy/client-feature-toggle-read-model-type.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
import { IFeatureToggleClient } from '../types';
 | 
			
		||||
 | 
			
		||||
export interface IClientFeatureToggleReadModel {
 | 
			
		||||
    getClient(): Promise<Record<string, IFeatureToggleClient[]>>;
 | 
			
		||||
}
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										16
									
								
								src/lib/proxy/fake-client-feature-toggle-read-model.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/lib/proxy/fake-client-feature-toggle-read-model.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string, IFeatureToggleClient[]>) {}
 | 
			
		||||
 | 
			
		||||
    getClient(): Promise<Record<string, IFeatureToggleClient[]>> {
 | 
			
		||||
        return Promise.resolve(this.value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setValue(value: Record<string, IFeatureToggleClient[]>) {
 | 
			
		||||
        this.value = value;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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<IUnleashConfig, 'getLogger'>;
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										169
									
								
								src/lib/proxy/global-frontend-api-cache.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								src/lib/proxy/global-frontend-api-cache.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string, IFeatureToggleClient[]> = {},
 | 
			
		||||
) => {
 | 
			
		||||
    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,
 | 
			
		||||
        },
 | 
			
		||||
    ]);
 | 
			
		||||
});
 | 
			
		||||
@ -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<IUnleashConfig, 'getLogger' | 'frontendApi' | 'eventBus'>;
 | 
			
		||||
type Config = Pick<IUnleashConfig, 'getLogger'>;
 | 
			
		||||
 | 
			
		||||
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<string, FeatureInterface[]>;
 | 
			
		||||
    private featuresByEnvironment: Record<string, FeatureInterface[]> = {};
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
        }
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user