Skip to content

Commit

Permalink
feat: stabilize global frontend api cache (#6466)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Mar 8, 2024
1 parent 97a8116 commit 8f2631e
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 22 deletions.
6 changes: 4 additions & 2 deletions src/lib/features/segment/fake-segment-read-model.ts
Expand Up @@ -2,16 +2,18 @@ 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[]> {
return [];
}

async getActive(): Promise<ISegment[]> {
return [];
return this.segments;
}

async getActiveForClient(): Promise<IClientSegment[]> {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/proxy/client-feature-toggle-read-model-type.ts
@@ -0,0 +1,5 @@
import { IFeatureToggleClient } from '../types';

export interface IClientFeatureToggleReadModel {
getClient(): Promise<Record<string, IFeatureToggleClient[]>>;
}
5 changes: 4 additions & 1 deletion src/lib/proxy/client-feature-toggle-read-model.ts
Expand Up @@ -13,14 +13,17 @@ 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;
archived: boolean;
userId?: number;
}

export default class ClientFeatureToggleReadModel {
export default class ClientFeatureToggleReadModel
implements IClientFeatureToggleReadModel
{
private db: Db;

private timer: Function;
Expand Down
16 changes: 16 additions & 0 deletions 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<string, IFeatureToggleClient[]>) {}

getClient(): Promise<Record<string, IFeatureToggleClient[]>> {
return Promise.resolve(this.value);
}

setValue(value: Record<string, IFeatureToggleClient[]>) {
this.value = value;
}
}
6 changes: 3 additions & 3 deletions src/lib/proxy/frontend-api-repository.ts
Expand Up @@ -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'>;

Expand All @@ -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();
Expand Down
169 changes: 169 additions & 0 deletions 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<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,
},
]);
});
Expand Up @@ -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 featuresByEnvironment: Record<string, FeatureInterface[]>;
private readonly configurationRevisionService: EventEmitter;

private segments: Segment[];
private featuresByEnvironment: Record<string, FeatureInterface[]> = {};

private interval: number;
private segments: Segment[] = [];

private running: boolean;
private status: GlobalFrontendApiCacheState = 'starting';

constructor(
config: Config,
segmentReadModel: ISegmentReadModel,
clientFeatureToggleReadModel: ClientFeatureToggleReadModel,
configurationRevisionService: ConfigurationRevisionService,
clientFeatureToggleReadModel: IClientFeatureToggleReadModel,
configurationRevisionService: EventEmitter,
) {
super();
this.config = config;
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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);
}
Expand Down

0 comments on commit 8f2631e

Please sign in to comment.