mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02: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';
|
import { ISegmentReadModel } from './segment-read-model-type';
|
||||||
|
|
||||||
export class FakeSegmentReadModel implements ISegmentReadModel {
|
export class FakeSegmentReadModel implements ISegmentReadModel {
|
||||||
|
constructor(private segments: ISegment[] = []) {}
|
||||||
|
|
||||||
async getAll(): Promise<ISegment[]> {
|
async getAll(): Promise<ISegment[]> {
|
||||||
return [];
|
return this.segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]> {
|
async getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]> {
|
||||||
@ -11,7 +13,7 @@ export class FakeSegmentReadModel implements ISegmentReadModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getActive(): Promise<ISegment[]> {
|
async getActive(): Promise<ISegment[]> {
|
||||||
return [];
|
return this.segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActiveForClient(): Promise<IClientSegment[]> {
|
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 metricsHelper from '../util/metrics-helper';
|
||||||
import { DB_TIME } from '../metric-events';
|
import { DB_TIME } from '../metric-events';
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
|
import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type';
|
||||||
|
|
||||||
export interface IGetAllFeatures {
|
export interface IGetAllFeatures {
|
||||||
featureQuery?: IFeatureToggleQuery;
|
featureQuery?: IFeatureToggleQuery;
|
||||||
@ -20,7 +21,9 @@ export interface IGetAllFeatures {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ClientFeatureToggleReadModel {
|
export default class ClientFeatureToggleReadModel
|
||||||
|
implements IClientFeatureToggleReadModel
|
||||||
|
{
|
||||||
private db: Db;
|
private db: Db;
|
||||||
|
|
||||||
private timer: Function;
|
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 { IUnleashConfig } from '../types';
|
||||||
import { UnleashEvents } from 'unleash-client';
|
import { UnleashEvents } from 'unleash-client';
|
||||||
import { Logger } from '../logger';
|
import { Logger } from '../logger';
|
||||||
import { GlobalFrontendApiRepository } from './global-frontend-api-repository';
|
import { GlobalFrontendApiCache } from './global-frontend-api-cache';
|
||||||
|
|
||||||
type Config = Pick<IUnleashConfig, 'getLogger'>;
|
type Config = Pick<IUnleashConfig, 'getLogger'>;
|
||||||
|
|
||||||
@ -23,13 +23,13 @@ export class FrontendApiRepository
|
|||||||
|
|
||||||
private readonly token: IApiUser;
|
private readonly token: IApiUser;
|
||||||
|
|
||||||
private globalFrontendApiRepository: GlobalFrontendApiRepository;
|
private globalFrontendApiRepository: GlobalFrontendApiCache;
|
||||||
|
|
||||||
private running: boolean;
|
private running: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: Config,
|
config: Config,
|
||||||
globalFrontendApiRepository: GlobalFrontendApiRepository,
|
globalFrontendApiRepository: GlobalFrontendApiCache,
|
||||||
token: IApiUser,
|
token: IApiUser,
|
||||||
) {
|
) {
|
||||||
super();
|
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';
|
} from '../features/playground/offline-unleash-client';
|
||||||
import { ALL_ENVS } from '../util/constants';
|
import { ALL_ENVS } from '../util/constants';
|
||||||
import { Logger } from '../logger';
|
import { Logger } from '../logger';
|
||||||
import ConfigurationRevisionService, {
|
import { UPDATE_REVISION } from '../features/feature-toggle/configuration-revision-service';
|
||||||
UPDATE_REVISION,
|
|
||||||
} from '../features/feature-toggle/configuration-revision-service';
|
|
||||||
import ClientFeatureToggleReadModel from './client-feature-toggle-read-model';
|
|
||||||
import { mapValues } from '../util';
|
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 config: Config;
|
||||||
|
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
|
|
||||||
private readonly clientFeatureToggleReadModel: ClientFeatureToggleReadModel;
|
private readonly clientFeatureToggleReadModel: IClientFeatureToggleReadModel;
|
||||||
|
|
||||||
private readonly segmentReadModel: ISegmentReadModel;
|
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 status: GlobalFrontendApiCacheState = 'starting';
|
||||||
|
|
||||||
private running: boolean;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: Config,
|
config: Config,
|
||||||
segmentReadModel: ISegmentReadModel,
|
segmentReadModel: ISegmentReadModel,
|
||||||
clientFeatureToggleReadModel: ClientFeatureToggleReadModel,
|
clientFeatureToggleReadModel: IClientFeatureToggleReadModel,
|
||||||
configurationRevisionService: ConfigurationRevisionService,
|
configurationRevisionService: EventEmitter,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@ -49,7 +47,6 @@ export class GlobalFrontendApiRepository extends EventEmitter {
|
|||||||
this.configurationRevisionService = configurationRevisionService;
|
this.configurationRevisionService = configurationRevisionService;
|
||||||
this.segmentReadModel = segmentReadModel;
|
this.segmentReadModel = segmentReadModel;
|
||||||
this.onUpdateRevisionEvent = this.onUpdateRevisionEvent.bind(this);
|
this.onUpdateRevisionEvent = this.onUpdateRevisionEvent.bind(this);
|
||||||
this.interval = config.frontendApi.refreshIntervalInMs;
|
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
this.configurationRevisionService.on(
|
this.configurationRevisionService.on(
|
||||||
UPDATE_REVISION,
|
UPDATE_REVISION,
|
||||||
@ -62,6 +59,11 @@ export class GlobalFrontendApiRepository extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getToggles(token: IApiUser): FeatureInterface[] {
|
getToggles(token: IApiUser): FeatureInterface[] {
|
||||||
|
if (
|
||||||
|
this.featuresByEnvironment[this.environmentNameForToken(token)] ==
|
||||||
|
null
|
||||||
|
)
|
||||||
|
return [];
|
||||||
return this.featuresByEnvironment[
|
return this.featuresByEnvironment[
|
||||||
this.environmentNameForToken(token)
|
this.environmentNameForToken(token)
|
||||||
].filter(
|
].filter(
|
||||||
@ -88,6 +90,13 @@ export class GlobalFrontendApiRepository extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
this.featuresByEnvironment = await this.getAllFeatures();
|
this.featuresByEnvironment = await this.getAllFeatures();
|
||||||
this.segments = await this.getAllSegments();
|
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) {
|
} catch (e) {
|
||||||
this.logger.error('Cannot load data for token', e);
|
this.logger.error('Cannot load data for token', e);
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user