diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 1b4281e68d..0a6c897e26 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -46,6 +46,7 @@ import { SegmentReadModel } from '../features/segment/segment-read-model'; import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model'; import { FeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store'; import { ProjectFlagCreatorsReadModel } from '../features/project/project-flag-creators-read-model'; +import { FeatureStrategiesReadModel } from '../features/feature-toggle/feature-strategies-read-model'; export const createStores = ( config: IUnleashConfig, @@ -159,6 +160,7 @@ export const createStores = ( projectOwnersReadModel: new ProjectOwnersReadModel(db), projectFlagCreatorsReadModel: new ProjectFlagCreatorsReadModel(db), featureLifecycleStore: new FeatureLifecycleStore(db), + featureStrategiesReadModel: new FeatureStrategiesReadModel(db), }; }; diff --git a/src/lib/features/feature-toggle/fake-feature-strategies-read-model.ts b/src/lib/features/feature-toggle/fake-feature-strategies-read-model.ts new file mode 100644 index 0000000000..c6726836cb --- /dev/null +++ b/src/lib/features/feature-toggle/fake-feature-strategies-read-model.ts @@ -0,0 +1,20 @@ +import type { IFeatureStrategiesReadModel } from './types/feature-strategies-read-model-type'; + +export class FakeFeatureStrategiesReadModel + implements IFeatureStrategiesReadModel +{ + async getMaxFeatureEnvironmentStrategies(): Promise<{ + feature: string; + environment: string; + count: number; + } | null> { + return null; + } + + async getMaxFeatureStrategies(): Promise<{ + feature: string; + count: number; + } | null> { + return null; + } +} diff --git a/src/lib/features/feature-toggle/feature-strategies-read-model.ts b/src/lib/features/feature-toggle/feature-strategies-read-model.ts new file mode 100644 index 0000000000..5341462c4c --- /dev/null +++ b/src/lib/features/feature-toggle/feature-strategies-read-model.ts @@ -0,0 +1,50 @@ +import type { Db } from '../../db/db'; +import type { IFeatureStrategiesReadModel } from './types/feature-strategies-read-model-type'; + +export class FeatureStrategiesReadModel implements IFeatureStrategiesReadModel { + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async getMaxFeatureEnvironmentStrategies(): Promise<{ + feature: string; + environment: string; + count: number; + } | null> { + const rows = await this.db('feature_strategies') + .select('feature_name', 'environment') + .count('id as strategy_count') + .groupBy('feature_name', 'environment') + .orderBy('strategy_count', 'desc') + .limit(1); + + return rows.length > 0 + ? { + feature: String(rows[0].feature_name), + environment: String(rows[0].environment), + count: Number(rows[0].strategy_count), + } + : null; + } + + async getMaxFeatureStrategies(): Promise<{ + feature: string; + count: number; + } | null> { + const rows = await this.db('feature_strategies') + .select('feature_name') + .count('id as strategy_count') + .groupBy('feature_name') + .orderBy('strategy_count', 'desc') + .limit(1); + + return rows.length > 0 + ? { + feature: String(rows[0].feature_name), + count: Number(rows[0].strategy_count), + } + : null; + } +} diff --git a/src/lib/features/feature-toggle/tests/feature-toggle-strategies-store.e2e.test.ts b/src/lib/features/feature-toggle/tests/feature-toggle-strategies-store.e2e.test.ts index 9e938a612b..002ec8198b 100644 --- a/src/lib/features/feature-toggle/tests/feature-toggle-strategies-store.e2e.test.ts +++ b/src/lib/features/feature-toggle/tests/feature-toggle-strategies-store.e2e.test.ts @@ -4,13 +4,18 @@ import dbInit, { type ITestDb, } from '../../../../test/e2e/helpers/database-init'; import getLogger from '../../../../test/fixtures/no-logger'; -import type { IProjectStore, IUnleashStores } from '../../../types'; +import type { + IFeatureStrategiesReadModel, + IProjectStore, + IUnleashStores, +} from '../../../types'; let stores: IUnleashStores; let db: ITestDb; let featureStrategiesStore: IFeatureStrategiesStore; let featureToggleStore: IFeatureToggleStore; let projectStore: IProjectStore; +let featureStrategiesReadModel: IFeatureStrategiesReadModel; const featureName = 'test-strategies-move-project'; @@ -20,12 +25,17 @@ beforeAll(async () => { featureStrategiesStore = stores.featureStrategiesStore; featureToggleStore = stores.featureToggleStore; projectStore = stores.projectStore; + featureStrategiesReadModel = stores.featureStrategiesReadModel; await featureToggleStore.create('default', { name: featureName, createdByUserId: 9999, }); }); +afterEach(async () => { + await featureStrategiesStore.deleteAll(); +}); + afterAll(async () => { await db.destroy(); }); @@ -236,4 +246,47 @@ describe('strategy parameters default to sane defaults', () => { }); expect(strategy.parameters.stickiness).toBe(defaultStickiness); }); + test('Read feature with max number of strategies', async () => { + const toggle = await featureToggleStore.create('default', { + name: 'featureA', + createdByUserId: 9999, + }); + + const maxStrategiesBefore = + await featureStrategiesReadModel.getMaxFeatureStrategies(); + const maxEnvStrategiesBefore = + await featureStrategiesReadModel.getMaxFeatureEnvironmentStrategies(); + expect(maxStrategiesBefore).toBe(null); + expect(maxEnvStrategiesBefore).toBe(null); + + await featureStrategiesStore.createStrategyFeatureEnv({ + strategyName: 'gradualRollout', + projectId: 'default', + environment: 'default', + featureName: toggle.name, + constraints: [], + sortOrder: 0, + parameters: {}, + }); + await featureStrategiesStore.createStrategyFeatureEnv({ + strategyName: 'gradualRollout', + projectId: 'default', + environment: 'default', + featureName: toggle.name, + constraints: [], + sortOrder: 0, + parameters: {}, + }); + + const maxStrategies = + await featureStrategiesReadModel.getMaxFeatureStrategies(); + const maxEnvStrategies = + await featureStrategiesReadModel.getMaxFeatureEnvironmentStrategies(); + expect(maxStrategies).toEqual({ feature: 'featureA', count: 2 }); + expect(maxEnvStrategies).toEqual({ + feature: 'featureA', + environment: 'default', + count: 2, + }); + }); }); diff --git a/src/lib/features/feature-toggle/types/feature-strategies-read-model-type.ts b/src/lib/features/feature-toggle/types/feature-strategies-read-model-type.ts new file mode 100644 index 0000000000..7a432ccfc0 --- /dev/null +++ b/src/lib/features/feature-toggle/types/feature-strategies-read-model-type.ts @@ -0,0 +1,11 @@ +export interface IFeatureStrategiesReadModel { + getMaxFeatureEnvironmentStrategies(): Promise<{ + feature: string; + environment: string; + count: number; + } | null>; + getMaxFeatureStrategies(): Promise<{ + feature: string; + count: number; + } | null>; +} diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 833c2aabab..1601e895d2 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -109,6 +109,16 @@ export default class MetricsMonitor { help: 'Number of feature flags', labelNames: ['version'], }); + const maxFeatureEnvironmentStrategies = createGauge({ + name: 'max_feature_environment_strategies', + help: 'Maximum number of environment strategies in one feature', + labelNames: ['feature', 'environment'], + }); + const maxFeatureStrategies = createGauge({ + name: 'max_feature_strategies', + help: 'Maximum number of strategies in one feature', + labelNames: ['feature'], + }); const featureTogglesArchivedTotal = createGauge({ name: 'feature_toggles_archived_total', @@ -274,6 +284,11 @@ export default class MetricsMonitor { async function collectStaticCounters() { try { const stats = await instanceStatsService.getStats(); + const [maxStrategies, maxEnvironmentStrategies] = + await Promise.all([ + stores.featureStrategiesReadModel.getMaxFeatureStrategies(), + stores.featureStrategiesReadModel.getMaxFeatureEnvironmentStrategies(), + ]); featureFlagsTotal.reset(); featureFlagsTotal.labels({ version }).set(stats.featureToggles); @@ -302,6 +317,22 @@ export default class MetricsMonitor { apiTokens.labels({ type }).set(value); } + if (maxEnvironmentStrategies) { + maxFeatureEnvironmentStrategies.reset(); + maxFeatureEnvironmentStrategies + .labels({ + environment: maxEnvironmentStrategies.environment, + feature: maxEnvironmentStrategies.feature, + }) + .set(maxEnvironmentStrategies.count); + } + if (maxStrategies) { + maxFeatureStrategies.reset(); + maxFeatureStrategies + .labels({ feature: maxStrategies.feature }) + .set(maxStrategies.count); + } + enabledMetricsBucketsPreviousDay.reset(); enabledMetricsBucketsPreviousDay.set( stats.previousDayMetricsBucketsCount.enabledCount, @@ -426,6 +457,7 @@ export default class MetricsMonitor { ); } catch (e) {} } + await schedulerService.schedule( collectStaticCounters.bind(this), hoursToMilliseconds(2), diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index c98234d365..1e59813cda 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -43,6 +43,7 @@ import { ISegmentReadModel } from '../features/segment/segment-read-model-type'; import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type'; import { IFeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store-type'; import { IProjectFlagCreatorsReadModel } from '../features/project/project-flag-creators-read-model.type'; +import { IFeatureStrategiesReadModel } from '../features/feature-toggle/types/feature-strategies-read-model-type'; export interface IUnleashStores { accessStore: IAccessStore; @@ -90,6 +91,7 @@ export interface IUnleashStores { projectOwnersReadModel: IProjectOwnersReadModel; projectFlagCreatorsReadModel: IProjectFlagCreatorsReadModel; featureLifecycleStore: IFeatureLifecycleStore; + featureStrategiesReadModel: IFeatureStrategiesReadModel; } export { @@ -136,4 +138,5 @@ export { IProjectOwnersReadModel, IFeatureLifecycleStore, IProjectFlagCreatorsReadModel, + IFeatureStrategiesReadModel, }; diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 83106c7cdf..8297832201 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -46,6 +46,7 @@ import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-re import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model'; import { FakeFeatureLifecycleStore } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-store'; import { FakeProjectFlagCreatorsReadModel } from '../../lib/features/project/fake-project-flag-creators-read-model'; +import { FakeFeatureStrategiesReadModel } from '../../lib/features/feature-toggle/fake-feature-strategies-read-model'; const db = { select: () => ({ @@ -101,6 +102,7 @@ const createStores: () => IUnleashStores = () => { projectOwnersReadModel: new FakeProjectOwnersReadModel(), projectFlagCreatorsReadModel: new FakeProjectFlagCreatorsReadModel(), featureLifecycleStore: new FakeFeatureLifecycleStore(), + featureStrategiesReadModel: new FakeFeatureStrategiesReadModel(), }; };