mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-31 01:16:01 +02:00
feat: new read model for client feature toggle cache (#8975)
This is based on the exising client feature toggle store, but some alterations. 1. We support all of the querying it did before. 2. Added support to filter by **featureNames** 3. Simplified logic, so we do not have admin API logic - no return of tags - no return of last seen - no return of favorites - no playground logic Next PR will try to include the revision ID.
This commit is contained in:
parent
8eb84e9645
commit
63d2359dec
14
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model-type.ts
vendored
Normal file
14
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model-type.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
import type { IFeatureToggleQuery } from '../../../types';
|
||||
import type { FeatureConfigurationClient } from '../../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||
|
||||
export interface FeatureConfigurationCacheClient
|
||||
extends FeatureConfigurationClient {
|
||||
description: string;
|
||||
impressionData: false;
|
||||
}
|
||||
|
||||
export interface IClientFeatureToggleCacheReadModel {
|
||||
getAll(
|
||||
featureQuery: IFeatureToggleQuery,
|
||||
): Promise<FeatureConfigurationCacheClient[]>;
|
||||
}
|
242
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model.ts
vendored
Normal file
242
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model.ts
vendored
Normal file
@ -0,0 +1,242 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import Raw = Knex.Raw;
|
||||
|
||||
import type EventEmitter from 'events';
|
||||
import { ALL_PROJECTS, ensureStringValue, mapValues } from '../../../util';
|
||||
import type {
|
||||
FeatureConfigurationCacheClient,
|
||||
IClientFeatureToggleCacheReadModel,
|
||||
} from './client-feature-toggle-cache-read-model-type';
|
||||
import type { Db } from '../../../db/db';
|
||||
import {
|
||||
DB_TIME,
|
||||
type IFeatureToggleCacheQuery,
|
||||
type IStrategyConfig,
|
||||
type PartialDeep,
|
||||
} from '../../../internals';
|
||||
import metricsHelper from '../../../util/metrics-helper';
|
||||
import FeatureToggleStore from '../../feature-toggle/feature-toggle-store';
|
||||
|
||||
export default class ClientFeatureToggleCacheReadModel
|
||||
implements IClientFeatureToggleCacheReadModel
|
||||
{
|
||||
private db: Db;
|
||||
|
||||
private timer: Function;
|
||||
|
||||
constructor(db: Db, eventBus: EventEmitter) {
|
||||
this.db = db;
|
||||
this.timer = (action: string) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'client-feature-toggle-cache-read-model',
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
public async getAll(
|
||||
featureQuery: IFeatureToggleCacheQuery,
|
||||
): Promise<FeatureConfigurationCacheClient[]> {
|
||||
const environment = featureQuery.environment;
|
||||
const stopTimer = this.timer(`getAll`);
|
||||
|
||||
const selectColumns = [
|
||||
'features.name as name',
|
||||
'features.description as description',
|
||||
'features.type as type',
|
||||
'features.project as project',
|
||||
'features.stale as stale',
|
||||
'features.impression_data as impression_data',
|
||||
'features.created_at as created_at',
|
||||
'fe.variants as variants',
|
||||
'fe.enabled as enabled',
|
||||
'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',
|
||||
'fs.sort_order as sort_order',
|
||||
'fs.variants as strategy_variants',
|
||||
'segments.id as segment_id',
|
||||
'segments.constraints as segment_constraints',
|
||||
'df.parent as parent',
|
||||
'df.variants as parent_variants',
|
||||
'df.enabled as parent_enabled',
|
||||
] as (string | Raw<any>)[];
|
||||
|
||||
let query = this.db('features')
|
||||
.modify(FeatureToggleStore.filterByArchived, false)
|
||||
.leftJoin(
|
||||
this.db('feature_strategies')
|
||||
.select('*')
|
||||
.where({ environment })
|
||||
.as('fs'),
|
||||
'fs.feature_name',
|
||||
'features.name',
|
||||
)
|
||||
.leftJoin(
|
||||
this.db('feature_environments')
|
||||
.select(
|
||||
'feature_name',
|
||||
'enabled',
|
||||
'environment',
|
||||
'variants',
|
||||
)
|
||||
.where({ environment })
|
||||
.as('fe'),
|
||||
'fe.feature_name',
|
||||
'features.name',
|
||||
)
|
||||
.leftJoin(
|
||||
'feature_strategy_segment as fss',
|
||||
`fss.feature_strategy_id`,
|
||||
`fs.id`,
|
||||
)
|
||||
.leftJoin('segments', `segments.id`, `fss.segment_id`)
|
||||
.leftJoin('dependent_features as df', 'df.child', 'features.name');
|
||||
|
||||
if (featureQuery?.toggleNames && featureQuery?.toggleNames.length > 0) {
|
||||
query = query.whereIn('features.name', featureQuery.toggleNames);
|
||||
}
|
||||
query = query.select(selectColumns);
|
||||
|
||||
if (featureQuery) {
|
||||
if (featureQuery.tag) {
|
||||
const tagQuery = this.db
|
||||
.from('feature_tag')
|
||||
.select('feature_name')
|
||||
.whereIn(['tag_type', 'tag_value'], featureQuery.tag);
|
||||
query = query.whereIn('features.name', tagQuery);
|
||||
}
|
||||
if (
|
||||
featureQuery.project &&
|
||||
!featureQuery.project.includes(ALL_PROJECTS)
|
||||
) {
|
||||
query = query.whereIn('project', featureQuery.project);
|
||||
}
|
||||
if (featureQuery.namePrefix) {
|
||||
query = query.where(
|
||||
'features.name',
|
||||
'like',
|
||||
`${featureQuery.namePrefix}%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const rows = await query;
|
||||
stopTimer();
|
||||
|
||||
const featureToggles = rows.reduce((acc, r) => {
|
||||
const feature: PartialDeep<FeatureConfigurationCacheClient> = acc[
|
||||
r.name
|
||||
] ?? {
|
||||
strategies: [],
|
||||
};
|
||||
if (this.isUnseenStrategyRow(feature, r) && !r.strategy_disabled) {
|
||||
feature.strategies?.push(this.rowToStrategy(r));
|
||||
}
|
||||
if (featureQuery?.inlineSegmentConstraints && r.segment_id) {
|
||||
this.addSegmentToStrategy(feature, r);
|
||||
} else if (
|
||||
!featureQuery?.inlineSegmentConstraints &&
|
||||
r.segment_id
|
||||
) {
|
||||
this.addSegmentIdsToStrategy(feature, r);
|
||||
}
|
||||
if (r.parent) {
|
||||
feature.dependencies = feature.dependencies || [];
|
||||
feature.dependencies.push({
|
||||
feature: r.parent,
|
||||
enabled: r.parent_enabled,
|
||||
...(r.parent_enabled
|
||||
? { variants: r.parent_variants }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
feature.impressionData = r.impression_data;
|
||||
feature.enabled = !!r.enabled;
|
||||
feature.name = r.name;
|
||||
feature.description = r.description;
|
||||
feature.project = r.project;
|
||||
feature.stale = r.stale;
|
||||
feature.type = r.type;
|
||||
feature.variants = r.variants || [];
|
||||
feature.project = r.project;
|
||||
|
||||
acc[r.name] = feature;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const features: FeatureConfigurationCacheClient[] =
|
||||
Object.values(featureToggles);
|
||||
|
||||
// strip away unwanted properties
|
||||
const cleanedFeatures = features.map(({ strategies, ...rest }) => ({
|
||||
...rest,
|
||||
strategies: strategies
|
||||
?.sort((strategy1, strategy2) => {
|
||||
if (
|
||||
typeof strategy1.sortOrder === 'number' &&
|
||||
typeof strategy2.sortOrder === 'number'
|
||||
) {
|
||||
return strategy1.sortOrder - strategy2.sortOrder;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.map(({ id, title, sortOrder, ...strategy }) => ({
|
||||
...strategy,
|
||||
})),
|
||||
}));
|
||||
|
||||
return cleanedFeatures;
|
||||
}
|
||||
|
||||
private addSegmentIdsToStrategy(
|
||||
feature: PartialDeep<FeatureConfigurationCacheClient>,
|
||||
row: Record<string, any>,
|
||||
) {
|
||||
const strategy = feature.strategies?.find(
|
||||
(s) => s?.id === row.strategy_id,
|
||||
);
|
||||
if (!strategy) {
|
||||
return;
|
||||
}
|
||||
if (!strategy.segments) {
|
||||
strategy.segments = [];
|
||||
}
|
||||
strategy.segments.push(row.segment_id);
|
||||
}
|
||||
|
||||
private rowToStrategy(row: Record<string, any>): IStrategyConfig {
|
||||
const strategy: IStrategyConfig = {
|
||||
id: row.strategy_id,
|
||||
name: row.strategy_name,
|
||||
title: row.strategy_title,
|
||||
constraints: row.constraints || [],
|
||||
parameters: mapValues(row.parameters || {}, ensureStringValue),
|
||||
sortOrder: row.sort_order,
|
||||
};
|
||||
strategy.variants = row.strategy_variants || [];
|
||||
return strategy;
|
||||
}
|
||||
|
||||
private isUnseenStrategyRow(
|
||||
feature: PartialDeep<FeatureConfigurationCacheClient>,
|
||||
row: Record<string, any>,
|
||||
): boolean {
|
||||
return (
|
||||
row.strategy_id &&
|
||||
!feature.strategies?.find((s) => s?.id === row.strategy_id)
|
||||
);
|
||||
}
|
||||
|
||||
private addSegmentToStrategy(
|
||||
feature: PartialDeep<FeatureConfigurationCacheClient>,
|
||||
row: Record<string, any>,
|
||||
) {
|
||||
feature.strategies
|
||||
?.find((s) => s?.id === row.strategy_id)
|
||||
?.constraints?.push(...row.segment_constraints);
|
||||
}
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import type {
|
||||
IEventStore,
|
||||
IFeatureToggleClient,
|
||||
IFeatureToggleClientStore,
|
||||
IFeatureToggleQuery,
|
||||
IFlagResolver,
|
||||
} from '../../../types';
|
||||
@ -9,6 +8,7 @@ import type { FeatureConfigurationClient } from '../../feature-toggle/types/feat
|
||||
import type ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
||||
import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service';
|
||||
import { RevisionCache } from './revision-cache';
|
||||
import type { IClientFeatureToggleCacheReadModel } from './client-feature-toggle-cache-read-model-type';
|
||||
|
||||
type DeletedFeature = {
|
||||
name: string;
|
||||
@ -90,7 +90,7 @@ export const calculateRequiredClientRevision = (
|
||||
};
|
||||
|
||||
export class ClientFeatureToggleCache {
|
||||
private clientFeatureToggleStore: IFeatureToggleClientStore;
|
||||
private clientFeatureToggleCacheReadModel: IClientFeatureToggleCacheReadModel;
|
||||
|
||||
private cache: Revisions = {};
|
||||
|
||||
@ -105,14 +105,15 @@ export class ClientFeatureToggleCache {
|
||||
private configurationRevisionService: ConfigurationRevisionService;
|
||||
|
||||
constructor(
|
||||
clientFeatureToggleStore: IFeatureToggleClientStore,
|
||||
clientFeatureToggleCacheReadModel: IClientFeatureToggleCacheReadModel,
|
||||
eventStore: IEventStore,
|
||||
configurationRevisionService: ConfigurationRevisionService,
|
||||
flagResolver: IFlagResolver,
|
||||
) {
|
||||
this.eventStore = eventStore;
|
||||
this.configurationRevisionService = configurationRevisionService;
|
||||
this.clientFeatureToggleStore = clientFeatureToggleStore;
|
||||
this.clientFeatureToggleCacheReadModel =
|
||||
clientFeatureToggleCacheReadModel;
|
||||
this.flagResolver = flagResolver;
|
||||
this.onUpdateRevisionEvent = this.onUpdateRevisionEvent.bind(this);
|
||||
this.cache = {};
|
||||
@ -272,36 +273,10 @@ export class ClientFeatureToggleCache {
|
||||
}
|
||||
|
||||
async getClientFeatures(
|
||||
query?: IFeatureToggleQuery,
|
||||
query: IFeatureToggleQuery,
|
||||
): Promise<FeatureConfigurationClient[]> {
|
||||
const result = await this.clientFeatureToggleStore.getClient(
|
||||
query || {},
|
||||
);
|
||||
|
||||
return result.map(
|
||||
({
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
project,
|
||||
stale,
|
||||
strategies,
|
||||
variants,
|
||||
description,
|
||||
impressionData,
|
||||
dependencies,
|
||||
}) => ({
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
project,
|
||||
stale,
|
||||
strategies,
|
||||
variants,
|
||||
description,
|
||||
impressionData,
|
||||
dependencies,
|
||||
}),
|
||||
);
|
||||
const result =
|
||||
await this.clientFeatureToggleCacheReadModel.getAll(query);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -3,8 +3,7 @@ import EventStore from '../../events/event-store';
|
||||
import ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
||||
import type { IUnleashConfig } from '../../../types';
|
||||
import type { Db } from '../../../db/db';
|
||||
|
||||
import FeatureToggleClientStore from '../client-feature-toggle-store';
|
||||
import ClientFeatureToggleCacheReadModel from './client-feature-toggle-cache-read-model';
|
||||
|
||||
export const createClientFeatureToggleCache = (
|
||||
db: Db,
|
||||
@ -13,18 +12,15 @@ export const createClientFeatureToggleCache = (
|
||||
const { getLogger, eventBus, flagResolver } = config;
|
||||
|
||||
const eventStore = new EventStore(db, getLogger);
|
||||
const featureToggleClientStore = new FeatureToggleClientStore(
|
||||
db,
|
||||
eventBus,
|
||||
getLogger,
|
||||
flagResolver,
|
||||
);
|
||||
|
||||
const clientFeatureToggleCacheReadModel =
|
||||
new ClientFeatureToggleCacheReadModel(db, eventBus);
|
||||
|
||||
const configurationRevisionService =
|
||||
ConfigurationRevisionService.getInstance({ eventStore }, config);
|
||||
|
||||
const clientFeatureToggleCache = new ClientFeatureToggleCache(
|
||||
featureToggleClientStore,
|
||||
clientFeatureToggleCacheReadModel,
|
||||
eventStore,
|
||||
configurationRevisionService,
|
||||
flagResolver,
|
||||
|
@ -130,6 +130,7 @@ beforeAll(async () => {
|
||||
experimental: {
|
||||
flags: {
|
||||
strictSchemaValidation: true,
|
||||
deltaApi: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -322,3 +323,19 @@ test('should match snapshot from /api/client/features', async () => {
|
||||
|
||||
expect(result.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match with /api/client/features/delta', async () => {
|
||||
await setupFeatures(db, app);
|
||||
|
||||
const { body } = await app.request
|
||||
.get('/api/client/features')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
const { body: deltaBody } = await app.request
|
||||
.get('/api/client/features/delta')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
expect(body.features).toMatchObject(deltaBody.updated);
|
||||
});
|
||||
|
@ -345,6 +345,11 @@ export interface IFeatureToggleQuery {
|
||||
inlineSegmentConstraints?: boolean;
|
||||
}
|
||||
|
||||
export interface IFeatureToggleCacheQuery extends IFeatureToggleQuery {
|
||||
toggleNames?: string[];
|
||||
environment: string;
|
||||
}
|
||||
|
||||
export interface ITag {
|
||||
value: string;
|
||||
type: string;
|
||||
|
Loading…
Reference in New Issue
Block a user