mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-04 01:18:20 +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 {
|
import type {
|
||||||
IEventStore,
|
IEventStore,
|
||||||
IFeatureToggleClient,
|
IFeatureToggleClient,
|
||||||
IFeatureToggleClientStore,
|
|
||||||
IFeatureToggleQuery,
|
IFeatureToggleQuery,
|
||||||
IFlagResolver,
|
IFlagResolver,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
@ -9,6 +8,7 @@ import type { FeatureConfigurationClient } from '../../feature-toggle/types/feat
|
|||||||
import type ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
import type ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
||||||
import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service';
|
import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service';
|
||||||
import { RevisionCache } from './revision-cache';
|
import { RevisionCache } from './revision-cache';
|
||||||
|
import type { IClientFeatureToggleCacheReadModel } from './client-feature-toggle-cache-read-model-type';
|
||||||
|
|
||||||
type DeletedFeature = {
|
type DeletedFeature = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -90,7 +90,7 @@ export const calculateRequiredClientRevision = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class ClientFeatureToggleCache {
|
export class ClientFeatureToggleCache {
|
||||||
private clientFeatureToggleStore: IFeatureToggleClientStore;
|
private clientFeatureToggleCacheReadModel: IClientFeatureToggleCacheReadModel;
|
||||||
|
|
||||||
private cache: Revisions = {};
|
private cache: Revisions = {};
|
||||||
|
|
||||||
@ -105,14 +105,15 @@ export class ClientFeatureToggleCache {
|
|||||||
private configurationRevisionService: ConfigurationRevisionService;
|
private configurationRevisionService: ConfigurationRevisionService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
clientFeatureToggleStore: IFeatureToggleClientStore,
|
clientFeatureToggleCacheReadModel: IClientFeatureToggleCacheReadModel,
|
||||||
eventStore: IEventStore,
|
eventStore: IEventStore,
|
||||||
configurationRevisionService: ConfigurationRevisionService,
|
configurationRevisionService: ConfigurationRevisionService,
|
||||||
flagResolver: IFlagResolver,
|
flagResolver: IFlagResolver,
|
||||||
) {
|
) {
|
||||||
this.eventStore = eventStore;
|
this.eventStore = eventStore;
|
||||||
this.configurationRevisionService = configurationRevisionService;
|
this.configurationRevisionService = configurationRevisionService;
|
||||||
this.clientFeatureToggleStore = clientFeatureToggleStore;
|
this.clientFeatureToggleCacheReadModel =
|
||||||
|
clientFeatureToggleCacheReadModel;
|
||||||
this.flagResolver = flagResolver;
|
this.flagResolver = flagResolver;
|
||||||
this.onUpdateRevisionEvent = this.onUpdateRevisionEvent.bind(this);
|
this.onUpdateRevisionEvent = this.onUpdateRevisionEvent.bind(this);
|
||||||
this.cache = {};
|
this.cache = {};
|
||||||
@ -272,36 +273,10 @@ export class ClientFeatureToggleCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getClientFeatures(
|
async getClientFeatures(
|
||||||
query?: IFeatureToggleQuery,
|
query: IFeatureToggleQuery,
|
||||||
): Promise<FeatureConfigurationClient[]> {
|
): Promise<FeatureConfigurationClient[]> {
|
||||||
const result = await this.clientFeatureToggleStore.getClient(
|
const result =
|
||||||
query || {},
|
await this.clientFeatureToggleCacheReadModel.getAll(query);
|
||||||
);
|
return result;
|
||||||
|
|
||||||
return result.map(
|
|
||||||
({
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
enabled,
|
|
||||||
project,
|
|
||||||
stale,
|
|
||||||
strategies,
|
|
||||||
variants,
|
|
||||||
description,
|
|
||||||
impressionData,
|
|
||||||
dependencies,
|
|
||||||
}) => ({
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
enabled,
|
|
||||||
project,
|
|
||||||
stale,
|
|
||||||
strategies,
|
|
||||||
variants,
|
|
||||||
description,
|
|
||||||
impressionData,
|
|
||||||
dependencies,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,7 @@ import EventStore from '../../events/event-store';
|
|||||||
import ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
import ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
||||||
import type { IUnleashConfig } from '../../../types';
|
import type { IUnleashConfig } from '../../../types';
|
||||||
import type { Db } from '../../../db/db';
|
import type { Db } from '../../../db/db';
|
||||||
|
import ClientFeatureToggleCacheReadModel from './client-feature-toggle-cache-read-model';
|
||||||
import FeatureToggleClientStore from '../client-feature-toggle-store';
|
|
||||||
|
|
||||||
export const createClientFeatureToggleCache = (
|
export const createClientFeatureToggleCache = (
|
||||||
db: Db,
|
db: Db,
|
||||||
@ -13,18 +12,15 @@ export const createClientFeatureToggleCache = (
|
|||||||
const { getLogger, eventBus, flagResolver } = config;
|
const { getLogger, eventBus, flagResolver } = config;
|
||||||
|
|
||||||
const eventStore = new EventStore(db, getLogger);
|
const eventStore = new EventStore(db, getLogger);
|
||||||
const featureToggleClientStore = new FeatureToggleClientStore(
|
|
||||||
db,
|
const clientFeatureToggleCacheReadModel =
|
||||||
eventBus,
|
new ClientFeatureToggleCacheReadModel(db, eventBus);
|
||||||
getLogger,
|
|
||||||
flagResolver,
|
|
||||||
);
|
|
||||||
|
|
||||||
const configurationRevisionService =
|
const configurationRevisionService =
|
||||||
ConfigurationRevisionService.getInstance({ eventStore }, config);
|
ConfigurationRevisionService.getInstance({ eventStore }, config);
|
||||||
|
|
||||||
const clientFeatureToggleCache = new ClientFeatureToggleCache(
|
const clientFeatureToggleCache = new ClientFeatureToggleCache(
|
||||||
featureToggleClientStore,
|
clientFeatureToggleCacheReadModel,
|
||||||
eventStore,
|
eventStore,
|
||||||
configurationRevisionService,
|
configurationRevisionService,
|
||||||
flagResolver,
|
flagResolver,
|
||||||
|
@ -130,6 +130,7 @@ beforeAll(async () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
|
deltaApi: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -322,3 +323,19 @@ test('should match snapshot from /api/client/features', async () => {
|
|||||||
|
|
||||||
expect(result.body).toMatchSnapshot();
|
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;
|
inlineSegmentConstraints?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IFeatureToggleCacheQuery extends IFeatureToggleQuery {
|
||||||
|
toggleNames?: string[];
|
||||||
|
environment: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ITag {
|
export interface ITag {
|
||||||
value: string;
|
value: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user