From 63d2359decbd12051ea8635fadd90838f1a416b6 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Fri, 13 Dec 2024 10:23:46 +0200 Subject: [PATCH] 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. --- ...nt-feature-toggle-cache-read-model-type.ts | 14 + .../client-feature-toggle-cache-read-model.ts | 242 ++++++++++++++++++ .../cache/client-feature-toggle-cache.ts | 43 +--- .../cache/createClientFeatureToggleCache.ts | 14 +- .../tests/client-feature-toggles.e2e.test.ts | 17 ++ src/lib/types/model.ts | 5 + 6 files changed, 292 insertions(+), 43 deletions(-) create mode 100644 src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model-type.ts create mode 100644 src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model.ts diff --git a/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model-type.ts b/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model-type.ts new file mode 100644 index 0000000000..ed9f0e6e04 --- /dev/null +++ b/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model-type.ts @@ -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; +} diff --git a/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model.ts b/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model.ts new file mode 100644 index 0000000000..7bddd95db9 --- /dev/null +++ b/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-read-model.ts @@ -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 { + 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)[]; + + 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 = 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, + row: Record, + ) { + 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): 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, + row: Record, + ): boolean { + return ( + row.strategy_id && + !feature.strategies?.find((s) => s?.id === row.strategy_id) + ); + } + + private addSegmentToStrategy( + feature: PartialDeep, + row: Record, + ) { + feature.strategies + ?.find((s) => s?.id === row.strategy_id) + ?.constraints?.push(...row.segment_constraints); + } +} diff --git a/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache.ts b/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache.ts index b879f6370d..834052e625 100644 --- a/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache.ts +++ b/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache.ts @@ -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 { - 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; } } diff --git a/src/lib/features/client-feature-toggles/cache/createClientFeatureToggleCache.ts b/src/lib/features/client-feature-toggles/cache/createClientFeatureToggleCache.ts index b49c213616..33f06aa63a 100644 --- a/src/lib/features/client-feature-toggles/cache/createClientFeatureToggleCache.ts +++ b/src/lib/features/client-feature-toggles/cache/createClientFeatureToggleCache.ts @@ -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, diff --git a/src/lib/features/client-feature-toggles/tests/client-feature-toggles.e2e.test.ts b/src/lib/features/client-feature-toggles/tests/client-feature-toggles.e2e.test.ts index 3964fd09a6..63e446239f 100644 --- a/src/lib/features/client-feature-toggles/tests/client-feature-toggles.e2e.test.ts +++ b/src/lib/features/client-feature-toggles/tests/client-feature-toggles.e2e.test.ts @@ -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); +}); diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 9300b58dad..fd855171f0 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -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;