1
0
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:
Jaanus Sellin 2024-03-08 15:00:38 +02:00 committed by GitHub
parent 6f2bd546a6
commit 2e6d91846b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 143 additions and 125 deletions

View File

@ -14,7 +14,12 @@ type NonEmptyList<T> = [T, ...T[]];
export const mapFeaturesForClient = ( export const mapFeaturesForClient = (
features: FeatureConfigurationClient[], features: FeatureConfigurationClient[],
): FeatureInterface[] => ): FeatureInterface[] =>
features.map((feature) => ({ features.map((feature) => mapFeatureForClient(feature));
export const mapFeatureForClient = (
feature: FeatureConfigurationClient,
): FeatureInterface => {
return {
impressionData: false, impressionData: false,
...feature, ...feature,
variants: (feature.variants || []).map((variant) => ({ variants: (feature.variants || []).map((variant) => ({
@ -47,7 +52,8 @@ export const mapFeaturesForClient = (
})) || [], })) || [],
})), })),
dependencies: feature.dependencies, dependencies: feature.dependencies,
})); };
};
export const mapSegmentsForClient = (segments: ISegment[]): Segment[] => export const mapSegmentsForClient = (segments: ISegment[]): Segment[] =>
serializeDates(segments) as Segment[]; serializeDates(segments) as Segment[];

View File

@ -1,5 +1,5 @@
import { IFeatureToggleClient } from '../types'; import { IFeatureToggleClient } from '../types';
export interface IClientFeatureToggleReadModel { export interface IClientFeatureToggleReadModel {
getClient(): Promise<Record<string, IFeatureToggleClient[]>>; getClient(): Promise<Record<string, Record<string, IFeatureToggleClient>>>;
} }

View File

@ -1,11 +1,5 @@
import { Knex } from 'knex'; import { Knex } from 'knex';
import { import { IFeatureToggleClient, IStrategyConfig, PartialDeep } from '../types';
IFeatureToggleClient,
IFeatureToggleQuery,
IStrategyConfig,
ITag,
PartialDeep,
} from '../types';
import { ensureStringValue, mapValues } from '../util'; import { ensureStringValue, mapValues } from '../util';
import { Db } from '../db/db'; import { Db } from '../db/db';
import FeatureToggleStore from '../features/feature-toggle/feature-toggle-store'; import FeatureToggleStore from '../features/feature-toggle/feature-toggle-store';
@ -15,12 +9,6 @@ import { DB_TIME } from '../metric-events';
import EventEmitter from 'events'; import EventEmitter from 'events';
import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type'; 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 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 stopTimer = this.timer(`getAll`);
const selectColumns = [ const selectColumns = [
'features.name as name', 'features.name as name',
@ -52,7 +42,6 @@ export default class ClientFeatureToggleReadModel
'fe.environment as environment', 'fe.environment as environment',
'fs.id as strategy_id', 'fs.id as strategy_id',
'fs.strategy_name as strategy_name', 'fs.strategy_name as strategy_name',
'fs.title as strategy_title',
'fs.disabled as strategy_disabled', 'fs.disabled as strategy_disabled',
'fs.parameters as parameters', 'fs.parameters as parameters',
'fs.constraints as constraints', 'fs.constraints as constraints',
@ -102,23 +91,25 @@ export default class ClientFeatureToggleReadModel
return data; return data;
} }
getAggregatedData(rows): Record<string, IFeatureToggleClient[]> { getAggregatedData(
const featureTogglesByEnv: Record<string, IFeatureToggleClient[]> = {}; rows,
): Record<string, Record<string, IFeatureToggleClient>> {
const featureTogglesByEnv: Record<
string,
Record<string, IFeatureToggleClient>
> = {};
rows.forEach((row) => { rows.forEach((row) => {
const environment = row.environment; const environment = row.environment;
const featureName = row.name;
if (!featureTogglesByEnv[environment]) { if (!featureTogglesByEnv[environment]) {
featureTogglesByEnv[environment] = []; featureTogglesByEnv[environment] = {};
} }
let feature = featureTogglesByEnv[environment].find( if (!featureTogglesByEnv[environment][featureName]) {
(f) => f.name === row.name, featureTogglesByEnv[environment][featureName] = {
); name: featureName,
if (!feature) {
feature = {
name: row.name,
strategies: [], strategies: [],
variants: row.variants || [], variants: row.variants || [],
impressionData: row.impression_data, impressionData: row.impression_data,
@ -127,13 +118,12 @@ export default class ClientFeatureToggleReadModel
project: row.project, project: row.project,
stale: row.stale, stale: row.stale,
type: row.type, 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) { if (row.parent) {
feature.dependencies = feature.dependencies || []; feature.dependencies = feature.dependencies || [];
feature.dependencies.push({ feature.dependencies.push({
@ -149,30 +139,20 @@ export default class ClientFeatureToggleReadModel
this.isUnseenStrategyRow(feature, row) && this.isUnseenStrategyRow(feature, row) &&
!row.strategy_disabled !row.strategy_disabled
) { ) {
feature.strategies?.push(this.rowToStrategy(row)); feature.strategies = feature.strategies || [];
feature.strategies.push(this.rowToStrategy(row));
} }
}); });
Object.keys(featureTogglesByEnv).forEach((envKey) => { Object.values(featureTogglesByEnv).forEach((envFeatures) => {
featureTogglesByEnv[envKey] = featureTogglesByEnv[envKey].map( Object.values(envFeatures).forEach((feature) => {
(featureToggle) => ({ if (feature.strategies) {
...featureToggle, feature.strategies = feature.strategies
strategies: featureToggle.strategies .sort((a, b) => {
?.sort((strategy1, strategy2) => { return (a.sortOrder || 0) - (b.sortOrder || 0);
if (
typeof strategy1.sortOrder === 'number' &&
typeof strategy2.sortOrder === 'number'
) {
return (
strategy1.sortOrder - strategy2.sortOrder
);
}
return 0;
}) })
.map(({ id, title, sortOrder, ...strategy }) => ({ .map(({ id, sortOrder, ...strategy }) => strategy);
...strategy, }
})), });
}),
);
}); });
return featureTogglesByEnv; return featureTogglesByEnv;
@ -191,13 +171,6 @@ export default class ClientFeatureToggleReadModel
return strategy; return strategy;
} }
private static rowToTag(row: Record<string, any>): ITag {
return {
value: row.tag_value,
type: row.tag_type,
};
}
private isUnseenStrategyRow( private isUnseenStrategyRow(
feature: PartialDeep<IFeatureToggleClient>, feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>, row: Record<string, any>,
@ -208,30 +181,9 @@ export default class ClientFeatureToggleReadModel
); );
} }
private addTag( async getClient(): Promise<
feature: Record<string, any>, Record<string, Record<string, IFeatureToggleClient>>
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[]>> {
return this.getAll(); return this.getAll();
} }
} }

View File

@ -4,13 +4,18 @@ import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-mode
export default class FakeClientFeatureToggleReadModel export default class FakeClientFeatureToggleReadModel
implements IClientFeatureToggleReadModel 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); return Promise.resolve(this.value);
} }
setValue(value: Record<string, IFeatureToggleClient[]>) { setValue(value: Record<string, Record<string, IFeatureToggleClient>>) {
this.value = value; this.value = value;
} }
} }

View File

@ -50,9 +50,7 @@ export class FrontendApiRepository
getToggle(name: string): FeatureInterface { getToggle(name: string): FeatureInterface {
//@ts-ignore (we must update the node SDK to allow undefined) //@ts-ignore (we must update the node SDK to allow undefined)
return this.getToggles(this.token).find( return this.globalFrontendApiCache.getToggle(name, this.token);
(feature) => feature.name === name,
);
} }
getToggles(): FeatureInterface[] { getToggles(): FeatureInterface[] {

View File

@ -46,7 +46,7 @@ const alwaysOnFlagResolver = {
const createCache = ( const createCache = (
segment: ISegment = defaultSegment, segment: ISegment = defaultSegment,
features: Record<string, IFeatureToggleClient[]> = {}, features: Record<string, Record<string, IFeatureToggleClient>> = {},
) => { ) => {
const config = { getLogger: noLogger, flagResolver: alwaysOnFlagResolver }; const config = { getLogger: noLogger, flagResolver: alwaysOnFlagResolver };
const segmentReadModel = new FakeSegmentReadModel([segment as ISegment]); const segmentReadModel = new FakeSegmentReadModel([segment as ISegment]);
@ -82,28 +82,28 @@ test('Can read initial segment', async () => {
test('Can read initial features', async () => { test('Can read initial features', async () => {
const { cache } = createCache(defaultSegment, { const { cache } = createCache(defaultSegment, {
development: [ development: {
{ featureA: {
...defaultFeature, ...defaultFeature,
name: 'featureA', name: 'featureA',
enabled: true, enabled: true,
project: 'projectA', project: 'projectA',
}, },
{ featureB: {
...defaultFeature, ...defaultFeature,
name: 'featureB', name: 'featureB',
enabled: true, enabled: true,
project: 'projectB', project: 'projectB',
}, },
], },
production: [ production: {
{ featureA: {
...defaultFeature, ...defaultFeature,
name: 'featureA', name: 'featureA',
enabled: false, enabled: false,
project: 'projectA', project: 'projectA',
}, },
], },
}); });
const featuresBeforeRead = cache.getToggles({ const featuresBeforeRead = cache.getToggles({
@ -138,6 +138,18 @@ test('Can read initial features', async () => {
projects: ['*'], projects: ['*'],
} as IApiUser); } as IApiUser);
expect(defaultProjectFeatures.length).toBe(0); 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 () => { test('Can refresh data on revision update', async () => {
@ -150,15 +162,15 @@ test('Can refresh data on revision update', async () => {
await state(cache, 'ready'); await state(cache, 'ready');
clientFeatureToggleReadModel.setValue({ clientFeatureToggleReadModel.setValue({
development: [ development: {
{ featureA: {
...defaultFeature, ...defaultFeature,
name: 'featureA', name: 'featureA',
enabled: false, enabled: false,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
project: 'projectA', project: 'projectA',
}, },
], },
}); });
configurationRevisionService.emit(UPDATE_REVISION); configurationRevisionService.emit(UPDATE_REVISION);

View File

@ -2,19 +2,24 @@ import EventEmitter from 'events';
import { Segment } from 'unleash-client/lib/strategy/strategy'; import { Segment } from 'unleash-client/lib/strategy/strategy';
import { FeatureInterface } from 'unleash-client/lib/feature'; import { FeatureInterface } from 'unleash-client/lib/feature';
import { IApiUser } from '../types/api-user'; import { IApiUser } from '../types/api-user';
import { ISegmentReadModel, IUnleashConfig } from '../types';
import { import {
mapFeaturesForClient, IFeatureToggleClient,
ISegmentReadModel,
IUnleashConfig,
} from '../types';
import {
mapFeatureForClient,
mapSegmentsForClient, mapSegmentsForClient,
} 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 { UPDATE_REVISION } from '../features/feature-toggle/configuration-revision-service'; import { UPDATE_REVISION } from '../features/feature-toggle/configuration-revision-service';
import { mapValues } from '../util';
import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type'; import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type';
type Config = Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>; type Config = Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>;
type FrontendApiFeatureCache = Record<string, Record<string, FeatureInterface>>;
export type GlobalFrontendApiCacheState = 'starting' | 'ready' | 'updated'; export type GlobalFrontendApiCacheState = 'starting' | 'ready' | 'updated';
export class GlobalFrontendApiCache extends EventEmitter { export class GlobalFrontendApiCache extends EventEmitter {
@ -28,7 +33,7 @@ export class GlobalFrontendApiCache extends EventEmitter {
private readonly configurationRevisionService: EventEmitter; private readonly configurationRevisionService: EventEmitter;
private featuresByEnvironment: Record<string, FeatureInterface[]> = {}; private featuresByEnvironment: FrontendApiFeatureCache = {};
private segments: Segment[] = []; private segments: Segment[] = [];
@ -58,30 +63,40 @@ export class GlobalFrontendApiCache extends EventEmitter {
return this.segments.find((segment) => segment.id === id); 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[] { getToggles(token: IApiUser): FeatureInterface[] {
if ( const features = this.getTogglesByEnvironment(
this.featuresByEnvironment[this.environmentNameForToken(token)] == this.environmentNameForToken(token),
null );
) return this.filterTogglesByProjects(features, token.projects);
return []; }
return this.featuresByEnvironment[
this.environmentNameForToken(token) private filterTogglesByProjects(
].filter( features: Record<string, FeatureInterface>,
(feature) => projects: string[],
token.projects.includes('*') || ): FeatureInterface[] {
(feature.project && token.projects.includes(feature.project)), if (projects.includes('*')) {
return Object.values(features);
}
return Object.values(features).filter(
(feature) => feature.project && projects.includes(feature.project),
); );
} }
private async getAllFeatures(): Promise< private getTogglesByEnvironment(
Record<string, FeatureInterface[]> environment: string,
> { ): Record<string, FeatureInterface> {
const features = await this.clientFeatureToggleReadModel.getClient(); const features = this.featuresByEnvironment[environment];
return mapValues(features, mapFeaturesForClient);
}
private async getAllSegments(): Promise<Segment[]> { if (features == null) return {};
return mapSegmentsForClient(await this.segmentReadModel.getAll());
return features;
} }
// TODO: fetch only relevant projects/environments based on tokens // 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() { private async onUpdateRevisionEvent() {
if (this.config.flagResolver.isEnabled('globalFrontendApiCache')) { if (this.config.flagResolver.isEnabled('globalFrontendApiCache')) {
await this.refreshData(); await this.refreshData();
@ -114,4 +138,20 @@ export class GlobalFrontendApiCache extends EventEmitter {
} }
return token.environment; 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);
}
} }

View File

@ -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; } as GlobalFrontendApiCache;
const proxyService = new ProxyService( const proxyService = new ProxyService(
{ getLogger: noLogger } as unknown as Config, { getLogger: noLogger } as unknown as Config,