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 = (
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[];

View File

@ -1,5 +1,5 @@
import { IFeatureToggleClient } from '../types';
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 {
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();
}
}

View File

@ -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;
}
}

View File

@ -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[] {

View File

@ -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);

View File

@ -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);
}
}

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