1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-31 00:16:47 +01:00
unleash.unleash/src/lib/db/feature-toggle-store.ts

436 lines
13 KiB
TypeScript
Raw Normal View History

import { Knex } from 'knex';
import EventEmitter from 'events';
import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import NotFoundError from '../error/notfound-error';
import { Logger, LogProvider } from '../logger';
2021-11-26 13:06:36 +01:00
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../types/model';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { Db } from './db';
const FEATURE_COLUMNS = [
'name',
'description',
'type',
'project',
'stale',
'created_at',
'impression_data',
'last_seen_at',
'archived_at',
];
export interface FeaturesTable {
name: string;
description: string;
type: string;
stale: boolean;
project: string;
last_seen_at?: Date;
created_at?: Date;
impression_data: boolean;
archived?: boolean;
archived_at?: Date;
}
interface VariantDTO {
variants: IVariant[];
}
const TABLE = 'features';
const FEATURE_ENVIRONMENTS_TABLE = 'feature_environments';
export default class FeatureToggleStore implements IFeatureToggleStore {
private db: Db;
private logger: Logger;
private timer: Function;
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('feature-toggle-store.ts');
this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-toggle',
action,
});
}
async count(
query: {
archived?: boolean;
project?: string;
stale?: boolean;
} = { archived: false },
): Promise<number> {
const { archived, ...rest } = query;
return this.db
.from(TABLE)
.count('*')
.where(rest)
.modify(FeatureToggleStore.filterByArchived, archived)
.then((res) => Number(res[0].count));
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
destroy(): void {}
async get(name: string): Promise<FeatureToggle> {
return this.db
.first(FEATURE_COLUMNS)
.from(TABLE)
.where({ name })
.then(this.rowToFeature);
}
async getAll(
query: {
archived?: boolean;
project?: string;
stale?: boolean;
} = { archived: false },
): Promise<FeatureToggle[]> {
const { archived, ...rest } = query;
const rows = await this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
.where(rest)
.modify(FeatureToggleStore.filterByArchived, archived);
return rows.map(this.rowToFeature);
}
2023-01-11 16:00:20 +01:00
async getAllByNames(names: string[]): Promise<FeatureToggle[]> {
const query = this.db<FeaturesTable>(TABLE).orderBy('name', 'asc');
2023-02-14 13:13:58 +01:00
query.whereIn('name', names);
2023-01-11 16:00:20 +01:00
const rows = await query;
return rows.map(this.rowToFeature);
}
async countByDate(queryModifiers: {
archived?: boolean;
project?: string;
date?: string;
range?: string[];
dateAccessor: string;
}): Promise<number> {
const { project, archived, dateAccessor } = queryModifiers;
let query = this.db
.count()
.from(TABLE)
.where({ project })
.modify(FeatureToggleStore.filterByArchived, archived);
if (queryModifiers.date) {
query.andWhere(dateAccessor, '>=', queryModifiers.date);
}
if (queryModifiers.range && queryModifiers.range.length === 2) {
query.andWhereBetween(dateAccessor, [
queryModifiers.range[0],
queryModifiers.range[1],
]);
}
const queryResult = await query.first();
return parseInt(queryResult.count || 0);
}
/**
* Get projectId from feature filtered by name. Used by Rbac middleware
* @deprecated
* @param name
*/
async getProjectId(name: string): Promise<string> {
return this.db
.first(['project'])
.from(TABLE)
.where({ name })
.then((r) => (r ? r.project : undefined))
.catch((e) => {
this.logger.error(e);
return undefined;
});
}
async exists(name: string): Promise<boolean> {
const result = await this.db.raw(
'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present',
[name],
);
const { present } = result.rows[0];
return present;
}
async setLastSeen(toggleNames: string[]): Promise<void> {
const now = new Date();
try {
await this.db(TABLE)
.update({ last_seen_at: now })
.whereIn(
'name',
this.db(TABLE)
.select('name')
.whereIn('name', toggleNames)
.forUpdate()
.skipLocked(),
);
} catch (err) {
this.logger.error('Could not update lastSeen, error: ', err);
}
}
static filterByArchived: Knex.QueryCallbackWithArgs = (
queryBuilder: Knex.QueryBuilder,
archived: boolean,
) => {
return archived
? queryBuilder.whereNotNull('archived_at')
: queryBuilder.whereNull('archived_at');
};
rowToFeature(row: FeaturesTable): FeatureToggle {
if (!row) {
throw new NotFoundError('No feature toggle found');
}
return {
name: row.name,
description: row.description,
type: row.type,
project: row.project,
stale: row.stale,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at,
impressionData: row.impression_data,
archivedAt: row.archived_at,
archived: row.archived_at != null,
};
}
rowToEnvVariants(variantRows: VariantDTO[]): IVariant[] {
if (!variantRows.length) {
return [];
}
2021-11-26 13:06:36 +01:00
const sortedVariants =
(variantRows[0].variants as unknown as IVariant[]) || [];
sortedVariants.sort((a, b) => a.name.localeCompare(b.name));
2021-11-26 13:06:36 +01:00
return sortedVariants;
}
dtoToRow(project: string, data: FeatureToggleDTO): FeaturesTable {
const row = {
name: data.name,
description: data.description,
type: data.type,
project,
archived_at: data.archived ? new Date() : null,
stale: data.stale,
created_at: data.createdAt,
impression_data: data.impressionData,
};
if (!row.created_at) {
delete row.created_at;
}
return row;
}
async create(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle> {
try {
const row = await this.db(TABLE)
.insert(this.dtoToRow(project, data))
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]);
} catch (err) {
this.logger.error('Could not insert feature, error: ', err);
}
return undefined;
}
async update(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle> {
const row = await this.db(TABLE)
.where({ name: data.name })
.update(this.dtoToRow(project, data))
.returning(FEATURE_COLUMNS);
#4205: mark potentially stale features (#4217) This PR lays most of the groundwork required for emitting events when features are marked as potentially stale by Unleash. It does **not** emit any events just yet. The summary is: - periodically look for features that are potentially stale and mark them (set to run every 10 seconds for now; can be changed) - when features are updated, if the update data contains changes to the feature's type or createdAt date, also update the potentially stale status. It is currently about 220 lines of tests and about 100 lines of application code (primarily db migration and two new methods on the IFeatureToggleStore interface). The reason I wanted to put this into a single PR (instead of just the db migration, then just the potentially stale marking, then the update logic) is: If users get the db migration first, but not the rest of the update logic until the events are fired, then they could get a bunch of new events for features that should have been marked as potentially stale several days/weeks/months ago. That seemed undesirable to me, so I decided to bunch those changes together. Of course, I'd be happy to break it into smaller parts. ## Rules A toggle will be marked as potentially stale iff: - it is not already stale - its createdAt date is older than its feature type's expected lifetime would dictate ## Migration The migration adds a new `potentially_stale` column to the features table and sets this to true for any toggles that have exceeded their expected lifetime and that have not already been marked as `stale`. ## Discussion ### The `currentTime` parameter of `markPotentiallyStaleFeatures` The `markPotentiallyStaleFetaures` method takes an optional `currentTime` parameter. This was added to make it easier to test (so you can test "into the future"), but it's not used in the application. We can rewrite the tests to instead update feature toggles manually, but that wouldn't test the actual marking method. Happy to discuss.
2023-07-13 14:02:33 +02:00
const feature = this.rowToFeature(row[0]);
// if a feature toggle's type or createdAt has changed, update its potentially stale status
if (!feature.stale && (data.type || data.createdAt)) {
await this.db(TABLE)
.where({ name: data.name })
.update(
'potentially_stale',
this.db.raw(
`(? > (features.created_at + ((
SELECT feature_types.lifetime_days
FROM feature_types
WHERE feature_types.id = features.type
) * INTERVAL '1 day')))`,
this.db.fn.now(),
),
);
}
return feature;
}
async archive(name: string): Promise<FeatureToggle> {
const now = new Date();
const row = await this.db(TABLE)
.where({ name })
.update({ archived_at: now })
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]);
}
async batchArchive(names: string[]): Promise<FeatureToggle[]> {
const now = new Date();
const rows = await this.db(TABLE)
.whereIn('name', names)
.update({ archived_at: now })
.returning(FEATURE_COLUMNS);
return rows.map((row) => this.rowToFeature(row));
}
2023-03-15 07:37:06 +01:00
async batchStale(
names: string[],
stale: boolean,
): Promise<FeatureToggle[]> {
const rows = await this.db(TABLE)
.whereIn('name', names)
.update({ stale })
.returning(FEATURE_COLUMNS);
return rows.map((row) => this.rowToFeature(row));
}
async delete(name: string): Promise<void> {
await this.db(TABLE)
.where({ name }) // Feature toggle must be archived to allow deletion
.whereNotNull('archived_at')
.del();
}
2023-03-15 14:08:08 +01:00
async batchDelete(names: string[]): Promise<void> {
await this.db(TABLE)
.whereIn('name', names)
.whereNotNull('archived_at')
.del();
}
async revive(name: string): Promise<FeatureToggle> {
const row = await this.db(TABLE)
.where({ name })
.update({ archived_at: null })
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]);
}
2023-03-16 08:51:18 +01:00
async batchRevive(names: string[]): Promise<FeatureToggle[]> {
const rows = await this.db(TABLE)
.whereIn('name', names)
.update({ archived_at: null })
.returning(FEATURE_COLUMNS);
return rows.map((row) => this.rowToFeature(row));
}
async getVariants(featureName: string): Promise<IVariant[]> {
if (!(await this.exists(featureName))) {
throw new NotFoundError('No feature toggle found');
}
const row = await this.db(`${TABLE} as f`)
.select('fe.variants')
.join(
`${FEATURE_ENVIRONMENTS_TABLE} as fe`,
'fe.feature_name',
'f.name',
)
.where({ name: featureName })
.limit(1);
return this.rowToEnvVariants(row);
}
async getVariantsForEnv(
featureName: string,
environment: string,
): Promise<IVariant[]> {
const row = await this.db(`${TABLE} as f`)
.select('fev.variants')
.join(
`${FEATURE_ENVIRONMENTS_TABLE} as fev`,
'fev.feature_name',
'f.name',
)
.where({ name: featureName })
.andWhere({ environment });
return this.rowToEnvVariants(row);
}
async saveVariants(
project: string,
featureName: string,
newVariants: IVariant[],
): Promise<FeatureToggle> {
const variantsString = JSON.stringify(newVariants);
await this.db('feature_environments')
.update('variants', variantsString)
.where('feature_name', featureName);
const row = await this.db(TABLE)
.select(FEATURE_COLUMNS)
.where({ project: project, name: featureName });
const toggle = this.rowToFeature(row[0]);
toggle.variants = newVariants;
return toggle;
}
#4205: mark potentially stale features (#4217) This PR lays most of the groundwork required for emitting events when features are marked as potentially stale by Unleash. It does **not** emit any events just yet. The summary is: - periodically look for features that are potentially stale and mark them (set to run every 10 seconds for now; can be changed) - when features are updated, if the update data contains changes to the feature's type or createdAt date, also update the potentially stale status. It is currently about 220 lines of tests and about 100 lines of application code (primarily db migration and two new methods on the IFeatureToggleStore interface). The reason I wanted to put this into a single PR (instead of just the db migration, then just the potentially stale marking, then the update logic) is: If users get the db migration first, but not the rest of the update logic until the events are fired, then they could get a bunch of new events for features that should have been marked as potentially stale several days/weeks/months ago. That seemed undesirable to me, so I decided to bunch those changes together. Of course, I'd be happy to break it into smaller parts. ## Rules A toggle will be marked as potentially stale iff: - it is not already stale - its createdAt date is older than its feature type's expected lifetime would dictate ## Migration The migration adds a new `potentially_stale` column to the features table and sets this to true for any toggles that have exceeded their expected lifetime and that have not already been marked as `stale`. ## Discussion ### The `currentTime` parameter of `markPotentiallyStaleFeatures` The `markPotentiallyStaleFetaures` method takes an optional `currentTime` parameter. This was added to make it easier to test (so you can test "into the future"), but it's not used in the application. We can rewrite the tests to instead update feature toggles manually, but that wouldn't test the actual marking method. Happy to discuss.
2023-07-13 14:02:33 +02:00
async markPotentiallyStaleFeatures(
currentTime?: string,
): Promise<string[]> {
const query = this.db(TABLE)
.update('potentially_stale', true)
.whereRaw(
`? > (features.created_at + ((
SELECT feature_types.lifetime_days
FROM feature_types
WHERE feature_types.id = features.type
) * INTERVAL '1 day'))`,
[currentTime || this.db.fn.now()],
)
.andWhere(function () {
this.where('potentially_stale', null).orWhere(
'potentially_stale',
false,
);
})
.andWhereNot('stale', true);
const updatedFeatures = await query.returning(FEATURE_COLUMNS);
return updatedFeatures.map(({ name }) => name);
}
async isPotentiallyStale(featureName: string): Promise<boolean> {
const result = await this.db(TABLE)
.first(['potentially_stale'])
.from(TABLE)
.where({ name: featureName });
return result?.potentially_stale ?? false;
}
}
module.exports = FeatureToggleStore;