Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Feature lifecycle sql store #6790

Merged
merged 4 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/lib/features/feature-lifecycle/createFeatureLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
private lifecycles: Record<string, FeatureLifecycleView> = {};

async insert(featureLifecycleStage: FeatureLifecycleStage): Promise<void> {
const existing = await this.get(featureLifecycleStage.feature);
if (await this.stageExists(featureLifecycleStage)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prevent double inserts in fake impl

return;
}
const existingStages = await this.get(featureLifecycleStage.feature);
this.lifecycles[featureLifecycleStage.feature] = [
...existing,
...existingStages,
{
stage: featureLifecycleStage.stage,
enteredStageAt: new Date(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -24,7 +22,13 @@ test('can insert and read lifecycle stages', async () => {

async function emitMetricsEvent(environment: string) {
await eventBus.emit(CLIENT_METRICS, { featureName, environment });
await ms(1);
}
function reachedStage(name: StageName) {
return new Promise((resolve) =>
featureLifecycleService.on(STAGE_ENTERED, (event) => {
if (event.stage === name) resolve(name);
}),
);
}

await environmentStore.create({
Expand All @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions src/lib/features/feature-lifecycle/feature-lifecycle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,6 +42,7 @@ export class FeatureLifecycleService {
eventBus,
}: Pick<IUnleashConfig, 'flagResolver' | 'eventBus'>,
) {
super();
this.eventStore = eventStore;
this.featureLifecycleStore = featureLifecycleStore;
this.environmentStore = environmentStore;
Expand Down Expand Up @@ -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(
Expand All @@ -98,6 +102,7 @@ export class FeatureLifecycleService {
});
if (!stageExists) {
await this.featureLifecycleStore.insert({ feature, stage });
this.emit(STAGE_ENTERED, { stage });
}
}

Expand All @@ -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' });
}
}
52 changes: 52 additions & 0 deletions src/lib/features/feature-lifecycle/feature-lifecycle-store.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await this.db('feature_lifecycles')
.insert({
feature: featureLifecycleStage.feature,
stage: featureLifecycleStage.stage,
})
.returning('*')
.onConflict(['feature', 'stage'])
.ignore();
}

async get(feature: string): Promise<FeatureLifecycleView> {
const results = await this.db('feature_lifecycles')
.where({ feature })
.orderBy('created_at', 'asc');

return results.map(({ stage, created_at }: DBType) => ({
stage,
enteredStageAt: created_at,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will probably rename enteredStageAt to createdAt in the service and openapi

}));
}

async stageExists(stage: FeatureLifecycleStage): Promise<boolean> {
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;
}
}
32 changes: 28 additions & 4 deletions src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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`)
Expand All @@ -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) },
]);
});
6 changes: 4 additions & 2 deletions src/lib/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
createEnvironmentService,
createFakeEnvironmentService,
createFakeProjectService,
createFeatureLifecycleService,
createFeatureToggleService,
createProjectService,
} from '../features';
Expand Down Expand Up @@ -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);
kwasniew marked this conversation as resolved.
Show resolved Hide resolved
featureLifecycleService.listen();

return {
Expand Down
18 changes: 18 additions & 0 deletions src/migrations/20240405120422-add-feature-lifecycles.js
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we want to delete feature lifecycle when feature is removed

stage VARCHAR(255) NULL,
created_at TIMESTAMP WITH TIME ZONE default now(),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sticking to generic name instead of specific stage_entered_at

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using timestamp with timezone to have the same type as features table

PRIMARY KEY (feature, stage)
);`,
cb,
);
};

exports.down = function(db, cb) {
db.runSql('DROP TABLE IF EXISTS feature_lifecycles;', cb);
};
Loading