mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-31 00:16:47 +01:00
feat: make frontend api complexity O(n) instead of O(n2) (#6477)
This commit is contained in:
parent
6f2bd546a6
commit
2e6d91846b
@ -14,7 +14,12 @@ type NonEmptyList<T> = [T, ...T[]];
|
||||
export const mapFeaturesForClient = (
|
||||
features: FeatureConfigurationClient[],
|
||||
): FeatureInterface[] =>
|
||||
features.map((feature) => ({
|
||||
features.map((feature) => mapFeatureForClient(feature));
|
||||
|
||||
export const mapFeatureForClient = (
|
||||
feature: FeatureConfigurationClient,
|
||||
): FeatureInterface => {
|
||||
return {
|
||||
impressionData: false,
|
||||
...feature,
|
||||
variants: (feature.variants || []).map((variant) => ({
|
||||
@ -47,7 +52,8 @@ export const mapFeaturesForClient = (
|
||||
})) || [],
|
||||
})),
|
||||
dependencies: feature.dependencies,
|
||||
}));
|
||||
};
|
||||
};
|
||||
|
||||
export const mapSegmentsForClient = (segments: ISegment[]): Segment[] =>
|
||||
serializeDates(segments) as Segment[];
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { IFeatureToggleClient } from '../types';
|
||||
|
||||
export interface IClientFeatureToggleReadModel {
|
||||
getClient(): Promise<Record<string, IFeatureToggleClient[]>>;
|
||||
getClient(): Promise<Record<string, Record<string, IFeatureToggleClient>>>;
|
||||
}
|
||||
|
@ -1,11 +1,5 @@
|
||||
import { Knex } from 'knex';
|
||||
import {
|
||||
IFeatureToggleClient,
|
||||
IFeatureToggleQuery,
|
||||
IStrategyConfig,
|
||||
ITag,
|
||||
PartialDeep,
|
||||
} from '../types';
|
||||
import { IFeatureToggleClient, IStrategyConfig, PartialDeep } from '../types';
|
||||
import { ensureStringValue, mapValues } from '../util';
|
||||
import { Db } from '../db/db';
|
||||
import FeatureToggleStore from '../features/feature-toggle/feature-toggle-store';
|
||||
@ -15,12 +9,6 @@ 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
|
||||
implements IClientFeatureToggleReadModel
|
||||
{
|
||||
@ -37,7 +25,9 @@ export default class ClientFeatureToggleReadModel
|
||||
});
|
||||
}
|
||||
|
||||
private async getAll(): Promise<Record<string, IFeatureToggleClient[]>> {
|
||||
private async getAll(): Promise<
|
||||
Record<string, Record<string, IFeatureToggleClient>>
|
||||
> {
|
||||
const stopTimer = this.timer(`getAll`);
|
||||
const selectColumns = [
|
||||
'features.name as name',
|
||||
@ -52,7 +42,6 @@ export default class ClientFeatureToggleReadModel
|
||||
'fe.environment as environment',
|
||||
'fs.id as strategy_id',
|
||||
'fs.strategy_name as strategy_name',
|
||||
'fs.title as strategy_title',
|
||||
'fs.disabled as strategy_disabled',
|
||||
'fs.parameters as parameters',
|
||||
'fs.constraints as constraints',
|
||||
@ -102,23 +91,25 @@ export default class ClientFeatureToggleReadModel
|
||||
return data;
|
||||
}
|
||||
|
||||
getAggregatedData(rows): Record<string, IFeatureToggleClient[]> {
|
||||
const featureTogglesByEnv: Record<string, IFeatureToggleClient[]> = {};
|
||||
getAggregatedData(
|
||||
rows,
|
||||
): Record<string, Record<string, IFeatureToggleClient>> {
|
||||
const featureTogglesByEnv: Record<
|
||||
string,
|
||||
Record<string, IFeatureToggleClient>
|
||||
> = {};
|
||||
|
||||
rows.forEach((row) => {
|
||||
const environment = row.environment;
|
||||
const featureName = row.name;
|
||||
|
||||
if (!featureTogglesByEnv[environment]) {
|
||||
featureTogglesByEnv[environment] = [];
|
||||
featureTogglesByEnv[environment] = {};
|
||||
}
|
||||
|
||||
let feature = featureTogglesByEnv[environment].find(
|
||||
(f) => f.name === row.name,
|
||||
);
|
||||
|
||||
if (!feature) {
|
||||
feature = {
|
||||
name: row.name,
|
||||
if (!featureTogglesByEnv[environment][featureName]) {
|
||||
featureTogglesByEnv[environment][featureName] = {
|
||||
name: featureName,
|
||||
strategies: [],
|
||||
variants: row.variants || [],
|
||||
impressionData: row.impression_data,
|
||||
@ -127,13 +118,12 @@ export default class ClientFeatureToggleReadModel
|
||||
project: row.project,
|
||||
stale: row.stale,
|
||||
type: row.type,
|
||||
dependencies: [],
|
||||
};
|
||||
featureTogglesByEnv[environment].push(feature);
|
||||
} else {
|
||||
if (this.isNewTag(feature, row)) {
|
||||
this.addTag(feature, row);
|
||||
}
|
||||
}
|
||||
|
||||
const feature = featureTogglesByEnv[environment][featureName];
|
||||
|
||||
if (row.parent) {
|
||||
feature.dependencies = feature.dependencies || [];
|
||||
feature.dependencies.push({
|
||||
@ -149,30 +139,20 @@ export default class ClientFeatureToggleReadModel
|
||||
this.isUnseenStrategyRow(feature, row) &&
|
||||
!row.strategy_disabled
|
||||
) {
|
||||
feature.strategies?.push(this.rowToStrategy(row));
|
||||
feature.strategies = feature.strategies || [];
|
||||
feature.strategies.push(this.rowToStrategy(row));
|
||||
}
|
||||
});
|
||||
Object.keys(featureTogglesByEnv).forEach((envKey) => {
|
||||
featureTogglesByEnv[envKey] = featureTogglesByEnv[envKey].map(
|
||||
(featureToggle) => ({
|
||||
...featureToggle,
|
||||
strategies: featureToggle.strategies
|
||||
?.sort((strategy1, strategy2) => {
|
||||
if (
|
||||
typeof strategy1.sortOrder === 'number' &&
|
||||
typeof strategy2.sortOrder === 'number'
|
||||
) {
|
||||
return (
|
||||
strategy1.sortOrder - strategy2.sortOrder
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
Object.values(featureTogglesByEnv).forEach((envFeatures) => {
|
||||
Object.values(envFeatures).forEach((feature) => {
|
||||
if (feature.strategies) {
|
||||
feature.strategies = feature.strategies
|
||||
.sort((a, b) => {
|
||||
return (a.sortOrder || 0) - (b.sortOrder || 0);
|
||||
})
|
||||
.map(({ id, title, sortOrder, ...strategy }) => ({
|
||||
...strategy,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
.map(({ id, sortOrder, ...strategy }) => strategy);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return featureTogglesByEnv;
|
||||
@ -191,13 +171,6 @@ export default class ClientFeatureToggleReadModel
|
||||
return strategy;
|
||||
}
|
||||
|
||||
private static rowToTag(row: Record<string, any>): ITag {
|
||||
return {
|
||||
value: row.tag_value,
|
||||
type: row.tag_type,
|
||||
};
|
||||
}
|
||||
|
||||
private isUnseenStrategyRow(
|
||||
feature: PartialDeep<IFeatureToggleClient>,
|
||||
row: Record<string, any>,
|
||||
@ -208,30 +181,9 @@ export default class ClientFeatureToggleReadModel
|
||||
);
|
||||
}
|
||||
|
||||
private addTag(
|
||||
feature: Record<string, any>,
|
||||
row: Record<string, any>,
|
||||
): void {
|
||||
const tags = feature.tags || [];
|
||||
const newTag = ClientFeatureToggleReadModel.rowToTag(row);
|
||||
feature.tags = [...tags, newTag];
|
||||
}
|
||||
|
||||
private isNewTag(
|
||||
feature: PartialDeep<IFeatureToggleClient>,
|
||||
row: Record<string, any>,
|
||||
): boolean {
|
||||
return (
|
||||
row.tag_type &&
|
||||
row.tag_value &&
|
||||
!feature.tags?.some(
|
||||
(tag) =>
|
||||
tag?.type === row.tag_type && tag?.value === row.tag_value,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getClient(): Promise<Record<string, IFeatureToggleClient[]>> {
|
||||
async getClient(): Promise<
|
||||
Record<string, Record<string, IFeatureToggleClient>>
|
||||
> {
|
||||
return this.getAll();
|
||||
}
|
||||
}
|
||||
|
@ -4,13 +4,18 @@ import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-mode
|
||||
export default class FakeClientFeatureToggleReadModel
|
||||
implements IClientFeatureToggleReadModel
|
||||
{
|
||||
constructor(private value: Record<string, IFeatureToggleClient[]> = {}) {}
|
||||
constructor(
|
||||
private value: Record<
|
||||
string,
|
||||
Record<string, IFeatureToggleClient>
|
||||
> = {},
|
||||
) {}
|
||||
|
||||
getClient(): Promise<Record<string, IFeatureToggleClient[]>> {
|
||||
getClient(): Promise<Record<string, Record<string, IFeatureToggleClient>>> {
|
||||
return Promise.resolve(this.value);
|
||||
}
|
||||
|
||||
setValue(value: Record<string, IFeatureToggleClient[]>) {
|
||||
setValue(value: Record<string, Record<string, IFeatureToggleClient>>) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
@ -50,9 +50,7 @@ export class FrontendApiRepository
|
||||
|
||||
getToggle(name: string): FeatureInterface {
|
||||
//@ts-ignore (we must update the node SDK to allow undefined)
|
||||
return this.getToggles(this.token).find(
|
||||
(feature) => feature.name === name,
|
||||
);
|
||||
return this.globalFrontendApiCache.getToggle(name, this.token);
|
||||
}
|
||||
|
||||
getToggles(): FeatureInterface[] {
|
||||
|
@ -46,7 +46,7 @@ const alwaysOnFlagResolver = {
|
||||
|
||||
const createCache = (
|
||||
segment: ISegment = defaultSegment,
|
||||
features: Record<string, IFeatureToggleClient[]> = {},
|
||||
features: Record<string, Record<string, IFeatureToggleClient>> = {},
|
||||
) => {
|
||||
const config = { getLogger: noLogger, flagResolver: alwaysOnFlagResolver };
|
||||
const segmentReadModel = new FakeSegmentReadModel([segment as ISegment]);
|
||||
@ -82,28 +82,28 @@ test('Can read initial segment', async () => {
|
||||
|
||||
test('Can read initial features', async () => {
|
||||
const { cache } = createCache(defaultSegment, {
|
||||
development: [
|
||||
{
|
||||
development: {
|
||||
featureA: {
|
||||
...defaultFeature,
|
||||
name: 'featureA',
|
||||
enabled: true,
|
||||
project: 'projectA',
|
||||
},
|
||||
{
|
||||
featureB: {
|
||||
...defaultFeature,
|
||||
name: 'featureB',
|
||||
enabled: true,
|
||||
project: 'projectB',
|
||||
},
|
||||
],
|
||||
production: [
|
||||
{
|
||||
},
|
||||
production: {
|
||||
featureA: {
|
||||
...defaultFeature,
|
||||
name: 'featureA',
|
||||
enabled: false,
|
||||
project: 'projectA',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const featuresBeforeRead = cache.getToggles({
|
||||
@ -138,6 +138,18 @@ test('Can read initial features', async () => {
|
||||
projects: ['*'],
|
||||
} as IApiUser);
|
||||
expect(defaultProjectFeatures.length).toBe(0);
|
||||
|
||||
const singleToggle = cache.getToggle('featureA', {
|
||||
environment: 'development',
|
||||
projects: ['*'],
|
||||
} as IApiUser);
|
||||
|
||||
expect(singleToggle).toMatchObject({
|
||||
...defaultFeature,
|
||||
name: 'featureA',
|
||||
enabled: true,
|
||||
impressionData: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('Can refresh data on revision update', async () => {
|
||||
@ -150,15 +162,15 @@ test('Can refresh data on revision update', async () => {
|
||||
await state(cache, 'ready');
|
||||
|
||||
clientFeatureToggleReadModel.setValue({
|
||||
development: [
|
||||
{
|
||||
development: {
|
||||
featureA: {
|
||||
...defaultFeature,
|
||||
name: 'featureA',
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
project: 'projectA',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
configurationRevisionService.emit(UPDATE_REVISION);
|
||||
|
||||
|
@ -2,19 +2,24 @@ import EventEmitter from 'events';
|
||||
import { Segment } from 'unleash-client/lib/strategy/strategy';
|
||||
import { FeatureInterface } from 'unleash-client/lib/feature';
|
||||
import { IApiUser } from '../types/api-user';
|
||||
import { ISegmentReadModel, IUnleashConfig } from '../types';
|
||||
import {
|
||||
mapFeaturesForClient,
|
||||
IFeatureToggleClient,
|
||||
ISegmentReadModel,
|
||||
IUnleashConfig,
|
||||
} from '../types';
|
||||
import {
|
||||
mapFeatureForClient,
|
||||
mapSegmentsForClient,
|
||||
} from '../features/playground/offline-unleash-client';
|
||||
import { ALL_ENVS } from '../util/constants';
|
||||
import { Logger } from '../logger';
|
||||
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' | 'flagResolver'>;
|
||||
|
||||
type FrontendApiFeatureCache = Record<string, Record<string, FeatureInterface>>;
|
||||
|
||||
export type GlobalFrontendApiCacheState = 'starting' | 'ready' | 'updated';
|
||||
|
||||
export class GlobalFrontendApiCache extends EventEmitter {
|
||||
@ -28,7 +33,7 @@ export class GlobalFrontendApiCache extends EventEmitter {
|
||||
|
||||
private readonly configurationRevisionService: EventEmitter;
|
||||
|
||||
private featuresByEnvironment: Record<string, FeatureInterface[]> = {};
|
||||
private featuresByEnvironment: FrontendApiFeatureCache = {};
|
||||
|
||||
private segments: Segment[] = [];
|
||||
|
||||
@ -58,30 +63,40 @@ export class GlobalFrontendApiCache extends EventEmitter {
|
||||
return this.segments.find((segment) => segment.id === id);
|
||||
}
|
||||
|
||||
getToggle(name: string, token: IApiUser): FeatureInterface {
|
||||
const features = this.getTogglesByEnvironment(
|
||||
this.environmentNameForToken(token),
|
||||
);
|
||||
return features[name];
|
||||
}
|
||||
|
||||
getToggles(token: IApiUser): FeatureInterface[] {
|
||||
if (
|
||||
this.featuresByEnvironment[this.environmentNameForToken(token)] ==
|
||||
null
|
||||
)
|
||||
return [];
|
||||
return this.featuresByEnvironment[
|
||||
this.environmentNameForToken(token)
|
||||
].filter(
|
||||
(feature) =>
|
||||
token.projects.includes('*') ||
|
||||
(feature.project && token.projects.includes(feature.project)),
|
||||
const features = this.getTogglesByEnvironment(
|
||||
this.environmentNameForToken(token),
|
||||
);
|
||||
return this.filterTogglesByProjects(features, token.projects);
|
||||
}
|
||||
|
||||
private filterTogglesByProjects(
|
||||
features: Record<string, FeatureInterface>,
|
||||
projects: string[],
|
||||
): FeatureInterface[] {
|
||||
if (projects.includes('*')) {
|
||||
return Object.values(features);
|
||||
}
|
||||
return Object.values(features).filter(
|
||||
(feature) => feature.project && projects.includes(feature.project),
|
||||
);
|
||||
}
|
||||
|
||||
private async getAllFeatures(): Promise<
|
||||
Record<string, FeatureInterface[]>
|
||||
> {
|
||||
const features = await this.clientFeatureToggleReadModel.getClient();
|
||||
return mapValues(features, mapFeaturesForClient);
|
||||
}
|
||||
private getTogglesByEnvironment(
|
||||
environment: string,
|
||||
): Record<string, FeatureInterface> {
|
||||
const features = this.featuresByEnvironment[environment];
|
||||
|
||||
private async getAllSegments(): Promise<Segment[]> {
|
||||
return mapSegmentsForClient(await this.segmentReadModel.getAll());
|
||||
if (features == null) return {};
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
// TODO: fetch only relevant projects/environments based on tokens
|
||||
@ -102,6 +117,15 @@ export class GlobalFrontendApiCache extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private async getAllFeatures(): Promise<FrontendApiFeatureCache> {
|
||||
const features = await this.clientFeatureToggleReadModel.getClient();
|
||||
return this.mapFeatures(features);
|
||||
}
|
||||
|
||||
private async getAllSegments(): Promise<Segment[]> {
|
||||
return mapSegmentsForClient(await this.segmentReadModel.getAll());
|
||||
}
|
||||
|
||||
private async onUpdateRevisionEvent() {
|
||||
if (this.config.flagResolver.isEnabled('globalFrontendApiCache')) {
|
||||
await this.refreshData();
|
||||
@ -114,4 +138,20 @@ export class GlobalFrontendApiCache extends EventEmitter {
|
||||
}
|
||||
return token.environment;
|
||||
}
|
||||
|
||||
private mapFeatures(
|
||||
features: Record<string, Record<string, IFeatureToggleClient>>,
|
||||
): FrontendApiFeatureCache {
|
||||
const entries = Object.entries(features).map(([key, value]) => [
|
||||
key,
|
||||
Object.fromEntries(
|
||||
Object.entries(value).map(([innerKey, innerValue]) => [
|
||||
innerKey,
|
||||
mapFeatureForClient(innerValue),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
}
|
||||
|
@ -32,6 +32,11 @@ test('proxy service fetching features from global cache', async () => {
|
||||
},
|
||||
];
|
||||
},
|
||||
getToggle(name: string, token: IApiUser): FeatureInterface {
|
||||
return this.getToggles(token).find(
|
||||
(t) => t.name === name,
|
||||
) as FeatureInterface;
|
||||
},
|
||||
} as GlobalFrontendApiCache;
|
||||
const proxyService = new ProxyService(
|
||||
{ getLogger: noLogger } as unknown as Config,
|
||||
|
Loading…
Reference in New Issue
Block a user