@splitsoftware/splitio-commons
Version:
Split JavaScript SDK common components
231 lines (202 loc) • 8.94 kB
text/typescript
import { isFiniteNumber, isNaNNumber } from '../../utils/lang';
import { KeyBuilder } from '../KeyBuilder';
import { IPluggableStorageWrapper } from '../types';
import { ILogger } from '../../logger/types';
import { ISplit, ISplitFiltersValidation } from '../../dtos/types';
import { LOG_PREFIX } from './constants';
import { AbstractSplitsCacheAsync } from '../AbstractSplitsCacheAsync';
import { returnDifference } from '../../utils/lang/sets';
/**
* ISplitsCacheAsync implementation for pluggable storages.
*/
export class SplitsCachePluggable extends AbstractSplitsCacheAsync {
private readonly log: ILogger;
private readonly keys: KeyBuilder;
private readonly wrapper: IPluggableStorageWrapper;
private readonly flagSetsFilter: string[];
/**
* Create a SplitsCache that uses a storage wrapper.
* @param log - Logger instance.
* @param keys - Key builder.
* @param wrapper - Adapted wrapper storage.
*/
constructor(log: ILogger, keys: KeyBuilder, wrapper: IPluggableStorageWrapper, splitFiltersValidation?: ISplitFiltersValidation) {
super();
this.log = log;
this.keys = keys;
this.wrapper = wrapper;
this.flagSetsFilter = splitFiltersValidation ? splitFiltersValidation.groupedFilters.bySet : [];
}
private _decrementCounts(split: ISplit) {
const ttKey = this.keys.buildTrafficTypeKey(split.trafficTypeName);
return this.wrapper.decr(ttKey).then(count => {
if (count === 0) return this.wrapper.del(ttKey);
});
}
private _incrementCounts(split: ISplit) {
const ttKey = this.keys.buildTrafficTypeKey(split.trafficTypeName);
return this.wrapper.incr(ttKey);
}
private _updateFlagSets(featureFlagName: string, flagSetsOfRemovedFlag?: string[], flagSetsOfAddedFlag?: string[]) {
const removeFromFlagSets = returnDifference(flagSetsOfRemovedFlag, flagSetsOfAddedFlag);
let addToFlagSets = returnDifference(flagSetsOfAddedFlag, flagSetsOfRemovedFlag);
if (this.flagSetsFilter.length > 0) {
addToFlagSets = addToFlagSets.filter(flagSet => {
return this.flagSetsFilter.some(filterFlagSet => filterFlagSet === flagSet);
});
}
const items = [featureFlagName];
return Promise.all([
...removeFromFlagSets.map(flagSetName => this.wrapper.removeItems(this.keys.buildFlagSetKey(flagSetName), items)),
...addToFlagSets.map(flagSetName => this.wrapper.addItems(this.keys.buildFlagSetKey(flagSetName), items))
]);
}
/**
* Add a given split.
* The returned promise is resolved when the operation success
* or rejected if it fails (e.g., wrapper operation fails)
*/
addSplit(split: ISplit): Promise<boolean> {
const name = split.name;
const splitKey = this.keys.buildSplitKey(name);
return this.wrapper.get(splitKey).then(splitFromStorage => {
// handling parsing error
let parsedPreviousSplit: ISplit, stringifiedNewSplit;
try {
parsedPreviousSplit = splitFromStorage ? JSON.parse(splitFromStorage) : undefined;
stringifiedNewSplit = JSON.stringify(split);
} catch (e) {
throw new Error('Error parsing feature flag definition: ' + e);
}
return this.wrapper.set(splitKey, stringifiedNewSplit).then(() => {
// avoid unnecessary increment/decrement operations
if (parsedPreviousSplit && parsedPreviousSplit.trafficTypeName === split.trafficTypeName) return;
// update traffic type counts
return this._incrementCounts(split).then(() => {
if (parsedPreviousSplit) return this._decrementCounts(parsedPreviousSplit);
});
}).then(() => this._updateFlagSets(name, parsedPreviousSplit && parsedPreviousSplit.sets, split.sets));
}).then(() => true);
}
/**
* Remove a given split.
* The returned promise is resolved when the operation success, with a boolean indicating if the split existed or not.
* or rejected if it fails (e.g., wrapper operation fails).
*/
removeSplit(name: string) {
return this.getSplit(name).then((split) => {
if (split) {
return this._decrementCounts(split).then(() => this._updateFlagSets(name, split.sets));
}
}).then(() => {
return this.wrapper.del(this.keys.buildSplitKey(name));
});
}
/**
* Get split.
* The returned promise is resolved with the split definition or null if it's not defined,
* or rejected if wrapper operation fails.
*/
getSplit(name: string): Promise<ISplit | null> {
return this.wrapper.get(this.keys.buildSplitKey(name))
.then(maybeSplit => maybeSplit && JSON.parse(maybeSplit));
}
/**
* Get list of splits.
* The returned promise is resolved with a map of split names to their split definition or null if it's not defined,
* or rejected if wrapper operation fails.
*/
getSplits(names: string[]): Promise<Record<string, ISplit | null>> {
const keys = names.map(name => this.keys.buildSplitKey(name));
return this.wrapper.getMany(keys).then(splitDefinitions => {
const splits: Record<string, ISplit | null> = {};
names.forEach((name, idx) => {
const split = splitDefinitions[idx];
splits[name] = split && JSON.parse(split);
});
return Promise.resolve(splits);
});
}
/**
* Get list of all split definitions.
* The returned promise is resolved with the list of split definitions,
* or rejected if wrapper operation fails.
*/
getAll(): Promise<ISplit[]> {
return this.wrapper.getKeysByPrefix(this.keys.buildSplitKeyPrefix())
.then((listOfKeys) => this.wrapper.getMany(listOfKeys))
.then((splitDefinitions) => splitDefinitions.map((splitDefinition) => {
return JSON.parse(splitDefinition as string);
}));
}
/**
* Get list of split names.
* The returned promise is resolved with the list of split names,
* or rejected if wrapper operation fails.
*/
getSplitNames(): Promise<string[]> {
return this.wrapper.getKeysByPrefix(this.keys.buildSplitKeyPrefix()).then(
(listOfKeys) => listOfKeys.map(this.keys.extractKey)
);
}
/**
* Get list of feature flag names related to a given list of flag set names.
* The returned promise is resolved with the list of feature flag names per flag set.
* It never rejects (If there is a wrapper error for some flag set, an empty set is returned for it).
*/
getNamesByFlagSets(flagSets: string[]): Promise<Set<string>[]> {
return Promise.all(flagSets.map(flagSet => {
const flagSetKey = this.keys.buildFlagSetKey(flagSet);
return this.wrapper.getItems(flagSetKey).catch(() => []);
})).then(namesByFlagSets => namesByFlagSets.map(namesByFlagSet => new Set(namesByFlagSet)));
}
/**
* Check traffic type existence.
* The returned promise is resolved with a boolean indicating whether the TT exist or not.
* In case of wrapper operation failures, the promise resolves with a true value, assuming that the TT might exist.
* It will never be rejected.
*/
trafficTypeExists(trafficType: string): Promise<boolean> {
// If there is a number there should be > 0, otherwise the TT is considered as not existent.
return this.wrapper.get(this.keys.buildTrafficTypeKey(trafficType))
.then((ttCount: string | null | number) => {
if (ttCount === null) return false; // if entry doesn't exist, means that TT doesn't exist
ttCount = parseInt(ttCount as string, 10);
if (!isFiniteNumber(ttCount) || ttCount < 0) {
this.log.info(LOG_PREFIX + `Could not validate traffic type existence of ${trafficType} due to data corruption of some sorts.`);
return false;
}
return ttCount > 0;
}).catch(e => {
this.log.error(LOG_PREFIX + `Could not validate traffic type existence of ${trafficType} due to an error: ${e}.`);
// If there is an error, bypass the validation so the event can get tracked.
return true;
});
}
/**
* Set till number.
* The returned promise is resolved when the operation success,
* or rejected if it fails (e.g., wrapper operation fails).
*/
setChangeNumber(changeNumber: number) {
return this.wrapper.set(this.keys.buildSplitsTillKey(), changeNumber + '');
}
/**
* Get till number or -1 if it's not defined.
* The returned promise is resolved with the changeNumber or -1 if it doesn't exist or a wrapper operation fails.
* The promise will never be rejected.
*/
getChangeNumber(): Promise<number> {
return this.wrapper.get(this.keys.buildSplitsTillKey()).then((value) => {
const i = parseInt(value as string, 10);
return isNaNNumber(i) ? -1 : i;
}).catch((e) => {
this.log.error(LOG_PREFIX + 'Could not retrieve changeNumber from storage. Error: ' + e);
return -1;
});
}
// @TODO implement if required by DataLoader or producer mode
clear() {
return Promise.resolve(true);
}
}