From 9dc9fb35863ddfa31be4b2efb077c9ba509c8189 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Mon, 8 Apr 2024 08:54:41 +0200 Subject: [PATCH] feat: Feature lifecycle sql store (#6790) --- .../createFeatureLifecycle.ts | 29 +++++++++++ .../fake-feature-lifecycle-store.ts | 7 ++- .../feature-lifecycle-service.test.ts | 43 +++++++++------ .../feature-lifecycle-service.ts | 11 +++- .../feature-lifecycle-store.ts | 52 +++++++++++++++++++ .../feature-lifecycle.e2e.test.ts | 32 ++++++++++-- src/lib/services/index.ts | 6 ++- .../20240405120422-add-feature-lifecycles.js | 18 +++++++ 8 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 src/lib/features/feature-lifecycle/feature-lifecycle-store.ts create mode 100644 src/migrations/20240405120422-add-feature-lifecycles.js diff --git a/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts b/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts index 7b28ee5e4664..4d990682ec5f 100644 --- a/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts +++ b/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts @@ -3,6 +3,35 @@ import { FakeFeatureLifecycleStore } from './fake-feature-lifecycle-store'; import { FeatureLifecycleService } from './feature-lifecycle-service'; import FakeEnvironmentStore from '../project-environments/fake-environment-store'; import type { IUnleashConfig } from '../../types'; +import EventStore from '../../db/event-store'; +import type { Db } from '../../db/db'; +import { FeatureLifecycleStore } from './feature-lifecycle-store'; +import EnvironmentStore from '../project-environments/environment-store'; + +export const createFeatureLifecycleService = ( + db: Db, + config: IUnleashConfig, +) => { + const { eventBus, getLogger, flagResolver } = config; + const eventStore = new EventStore(db, getLogger, flagResolver); + const featureLifecycleStore = new FeatureLifecycleStore(db); + const environmentStore = new EnvironmentStore(db, eventBus, getLogger); + const featureLifecycleService = new FeatureLifecycleService( + { + eventStore, + featureLifecycleStore, + environmentStore, + }, + config, + ); + + return { + featureLifecycleService, + featureLifecycleStore, + eventStore, + environmentStore, + }; +}; export const createFakeFeatureLifecycleService = (config: IUnleashConfig) => { const eventStore = new FakeEventStore(); diff --git a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts index dc1a74165ecc..ee734caf713d 100644 --- a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts +++ b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts @@ -8,9 +8,12 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { private lifecycles: Record = {}; async insert(featureLifecycleStage: FeatureLifecycleStage): Promise { - const existing = await this.get(featureLifecycleStage.feature); + if (await this.stageExists(featureLifecycleStage)) { + return; + } + const existingStages = await this.get(featureLifecycleStage.feature); this.lifecycles[featureLifecycleStage.feature] = [ - ...existing, + ...existingStages, { stage: featureLifecycleStage.stage, enteredStageAt: new Date(), diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts index 612952659a04..8da4a8e7b3c0 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts @@ -8,10 +8,8 @@ import { } from '../../types'; import { createFakeFeatureLifecycleService } from './createFeatureLifecycle'; import EventEmitter from 'events'; - -function ms(timeMs) { - return new Promise((resolve) => setTimeout(resolve, timeMs)); -} +import type { StageName } from './feature-lifecycle-store-type'; +import { STAGE_ENTERED } from './feature-lifecycle-service'; test('can insert and read lifecycle stages', async () => { const eventBus = new EventEmitter(); @@ -22,9 +20,15 @@ test('can insert and read lifecycle stages', async () => { } as unknown as IUnleashConfig); const featureName = 'testFeature'; - async function emitMetricsEvent(environment: string) { - await eventBus.emit(CLIENT_METRICS, { featureName, environment }); - await ms(1); + function emitMetricsEvent(environment: string) { + eventBus.emit(CLIENT_METRICS, { featureName, environment }); + } + function reachedStage(name: StageName) { + return new Promise((resolve) => + featureLifecycleService.on(STAGE_ENTERED, (event) => { + if (event.stage === name) resolve(name); + }), + ); } await environmentStore.create({ @@ -45,18 +49,23 @@ test('can insert and read lifecycle stages', async () => { } as IEnvironment); featureLifecycleService.listen(); - await eventStore.emit(FEATURE_CREATED, { featureName }); + eventStore.emit(FEATURE_CREATED, { featureName }); + await reachedStage('initial'); - await emitMetricsEvent('unknown-environment'); - await emitMetricsEvent('my-dev-environment'); - await emitMetricsEvent('my-dev-environment'); - await emitMetricsEvent('my-another-dev-environment'); - await emitMetricsEvent('my-prod-environment'); - await emitMetricsEvent('my-prod-environment'); - await emitMetricsEvent('my-another-prod-environment'); + emitMetricsEvent('unknown-environment'); + emitMetricsEvent('my-dev-environment'); + await reachedStage('pre-live'); + emitMetricsEvent('my-dev-environment'); + emitMetricsEvent('my-another-dev-environment'); + emitMetricsEvent('my-prod-environment'); + await reachedStage('live'); + emitMetricsEvent('my-prod-environment'); + emitMetricsEvent('my-another-prod-environment'); - await eventStore.emit(FEATURE_COMPLETED, { featureName }); - await eventStore.emit(FEATURE_ARCHIVED, { featureName }); + eventStore.emit(FEATURE_COMPLETED, { featureName }); + await reachedStage('completed'); + eventStore.emit(FEATURE_ARCHIVED, { featureName }); + await reachedStage('archived'); const lifecycle = await featureLifecycleService.getFeatureLifecycle(featureName); diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts index 41e433ef3306..fb2af58a1562 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts @@ -12,9 +12,11 @@ import type { FeatureLifecycleView, IFeatureLifecycleStore, } from './feature-lifecycle-store-type'; -import type EventEmitter from 'events'; +import EventEmitter from 'events'; -export class FeatureLifecycleService { +export const STAGE_ENTERED = 'STAGE_ENTERED'; + +export class FeatureLifecycleService extends EventEmitter { private eventStore: IEventStore; private featureLifecycleStore: IFeatureLifecycleStore; @@ -40,6 +42,7 @@ export class FeatureLifecycleService { eventBus, }: Pick, ) { + super(); this.eventStore = eventStore; this.featureLifecycleStore = featureLifecycleStore; this.environmentStore = environmentStore; @@ -86,6 +89,7 @@ export class FeatureLifecycleService { private async featureInitialized(feature: string) { await this.featureLifecycleStore.insert({ feature, stage: 'initial' }); + this.emit(STAGE_ENTERED, { stage: 'initial' }); } private async stageReceivedMetrics( @@ -98,6 +102,7 @@ export class FeatureLifecycleService { }); if (!stageExists) { await this.featureLifecycleStore.insert({ feature, stage }); + this.emit(STAGE_ENTERED, { stage }); } } @@ -118,9 +123,11 @@ export class FeatureLifecycleService { feature, stage: 'completed', }); + this.emit(STAGE_ENTERED, { stage: 'completed' }); } private async featureArchived(feature: string) { await this.featureLifecycleStore.insert({ feature, stage: 'archived' }); + this.emit(STAGE_ENTERED, { stage: 'archived' }); } } diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts new file mode 100644 index 000000000000..f07c8f480d60 --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts @@ -0,0 +1,52 @@ +import type { + FeatureLifecycleStage, + IFeatureLifecycleStore, + FeatureLifecycleView, + StageName, +} from './feature-lifecycle-store-type'; +import type { Db } from '../../db/db'; + +type DBType = { + feature: string; + stage: StageName; + created_at: Date; +}; + +export class FeatureLifecycleStore implements IFeatureLifecycleStore { + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async insert(featureLifecycleStage: FeatureLifecycleStage): Promise { + await this.db('feature_lifecycles') + .insert({ + feature: featureLifecycleStage.feature, + stage: featureLifecycleStage.stage, + }) + .returning('*') + .onConflict(['feature', 'stage']) + .ignore(); + } + + async get(feature: string): Promise { + const results = await this.db('feature_lifecycles') + .where({ feature }) + .orderBy('created_at', 'asc'); + + return results.map(({ stage, created_at }: DBType) => ({ + stage, + enteredStageAt: created_at, + })); + } + + async stageExists(stage: FeatureLifecycleStage): Promise { + const result = await this.db.raw( + `SELECT EXISTS(SELECT 1 FROM feature_lifecycles WHERE stage = ? and feature = ?) AS present`, + [stage.stage, stage.feature], + ); + const { present } = result.rows[0]; + return present; + } +} diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts index 108c0e35ec3e..dec8c4aa75bc 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts @@ -5,14 +5,23 @@ import { } from '../../../test/e2e/helpers/test-helper'; import getLogger from '../../../test/fixtures/no-logger'; import { + CLIENT_METRICS, FEATURE_ARCHIVED, FEATURE_CREATED, type IEventStore, } from '../../types'; +import type EventEmitter from 'events'; +import { + type FeatureLifecycleService, + STAGE_ENTERED, +} from './feature-lifecycle-service'; +import type { StageName } from './feature-lifecycle-store-type'; let app: IUnleashTest; let db: ITestDb; +let featureLifecycleService: FeatureLifecycleService; let eventStore: IEventStore; +let eventBus: EventEmitter; beforeAll(async () => { db = await dbInit('feature_lifecycle', getLogger); @@ -28,6 +37,8 @@ beforeAll(async () => { db.rawDatabase, ); eventStore = db.stores.eventStore; + eventBus = app.config.eventBus; + featureLifecycleService = app.services.featureLifecycleService; await app.request .post(`/auth/demo/login`) @@ -50,18 +61,31 @@ const getFeatureLifecycle = async (featureName: string, expectedCode = 200) => { .expect(expectedCode); }; -function ms(timeMs) { - return new Promise((resolve) => setTimeout(resolve, timeMs)); +function reachedStage(name: StageName) { + return new Promise((resolve) => + featureLifecycleService.on(STAGE_ENTERED, (event) => { + if (event.stage === name) resolve(name); + }), + ); } test('should return lifecycle stages', async () => { - await eventStore.emit(FEATURE_CREATED, { featureName: 'my_feature_a' }); - await eventStore.emit(FEATURE_ARCHIVED, { featureName: 'my_feature_a' }); + await app.createFeature('my_feature_a'); + eventStore.emit(FEATURE_CREATED, { featureName: 'my_feature_a' }); + await reachedStage('initial'); + eventBus.emit(CLIENT_METRICS, { + featureName: 'my_feature_a', + environment: 'default', + }); + await reachedStage('live'); + eventStore.emit(FEATURE_ARCHIVED, { featureName: 'my_feature_a' }); + await reachedStage('archived'); const { body } = await getFeatureLifecycle('my_feature_a'); expect(body).toEqual([ { stage: 'initial', enteredStageAt: expect.any(String) }, + { stage: 'live', enteredStageAt: expect.any(String) }, { stage: 'archived', enteredStageAt: expect.any(String) }, ]); }); diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 3ce2513dd3ca..a5d78781b286 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -67,6 +67,7 @@ import { createEnvironmentService, createFakeEnvironmentService, createFakeProjectService, + createFeatureLifecycleService, createFeatureToggleService, createProjectService, } from '../features'; @@ -349,8 +350,9 @@ export const createServices = ( const inactiveUsersService = new InactiveUsersService(stores, config, { userService, }); - const { featureLifecycleService } = - createFakeFeatureLifecycleService(config); + const { featureLifecycleService } = db + ? createFeatureLifecycleService(db, config) + : createFakeFeatureLifecycleService(config); featureLifecycleService.listen(); return { diff --git a/src/migrations/20240405120422-add-feature-lifecycles.js b/src/migrations/20240405120422-add-feature-lifecycles.js new file mode 100644 index 000000000000..351d5bd0efc2 --- /dev/null +++ b/src/migrations/20240405120422-add-feature-lifecycles.js @@ -0,0 +1,18 @@ +'use strict'; + +exports.up = function(db, cb) { + db.runSql( + ` + CREATE TABLE IF NOT EXISTS feature_lifecycles ( + feature VARCHAR(255) NOT NULL REFERENCES features(name) ON DELETE CASCADE, + stage VARCHAR(255) NULL, + created_at TIMESTAMP WITH TIME ZONE default now(), + PRIMARY KEY (feature, stage) + );`, + cb, + ); +}; + +exports.down = function(db, cb) { + db.runSql('DROP TABLE IF EXISTS feature_lifecycles;', cb); +};