mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
feat: first revision of delta api (#8967)
This is not changing existing logic. We are creating a new endpoint, which is guarded behind a flag. --------- Co-authored-by: Simon Hornby <liquidwicked64@gmail.com> Co-authored-by: FredrikOseberg <fredrik.no@gmail.com>
This commit is contained in:
parent
fe8308da1f
commit
59bdfcd84b
181
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache.test.ts
vendored
Normal file
181
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache.test.ts
vendored
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { calculateRequiredClientRevision } from './client-feature-toggle-cache';
|
||||||
|
|
||||||
|
const mockAdd = (params): any => {
|
||||||
|
const base = {
|
||||||
|
name: 'feature',
|
||||||
|
project: 'default',
|
||||||
|
stale: false,
|
||||||
|
type: 'release',
|
||||||
|
enabled: true,
|
||||||
|
strategies: [],
|
||||||
|
variants: [],
|
||||||
|
description: 'A feature',
|
||||||
|
impressionData: [],
|
||||||
|
dependencies: [],
|
||||||
|
};
|
||||||
|
return { ...base, ...params };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('compresses multiple revisions to a single update', () => {
|
||||||
|
const revisionList = [
|
||||||
|
{
|
||||||
|
revisionId: 1,
|
||||||
|
updated: [mockAdd({ type: 'release' })],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: 2,
|
||||||
|
updated: [mockAdd({ type: 'test' })],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const revisions = calculateRequiredClientRevision(revisionList, 0, [
|
||||||
|
'default',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(revisions).toEqual({
|
||||||
|
revisionId: 2,
|
||||||
|
updated: [mockAdd({ type: 'test' })],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revision that adds, removes then adds again does not end up with the remove', () => {
|
||||||
|
const revisionList = [
|
||||||
|
{
|
||||||
|
revisionId: 1,
|
||||||
|
updated: [mockAdd({ name: 'some-toggle' })],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: 2,
|
||||||
|
updated: [],
|
||||||
|
removed: [
|
||||||
|
{
|
||||||
|
name: 'some-toggle',
|
||||||
|
project: 'default',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: 3,
|
||||||
|
updated: [mockAdd({ name: 'some-toggle' })],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const revisions = calculateRequiredClientRevision(revisionList, 0, [
|
||||||
|
'default',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(revisions).toEqual({
|
||||||
|
revisionId: 3,
|
||||||
|
updated: [mockAdd({ name: 'some-toggle' })],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revision that removes, adds then removes again does not end up with the remove', () => {
|
||||||
|
const revisionList = [
|
||||||
|
{
|
||||||
|
revisionId: 1,
|
||||||
|
updated: [],
|
||||||
|
removed: [
|
||||||
|
{
|
||||||
|
name: 'some-toggle',
|
||||||
|
project: 'default',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: 2,
|
||||||
|
updated: [mockAdd({ name: 'some-toggle' })],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: 3,
|
||||||
|
updated: [],
|
||||||
|
removed: [
|
||||||
|
{
|
||||||
|
name: 'some-toggle',
|
||||||
|
project: 'default',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const revisions = calculateRequiredClientRevision(revisionList, 0, [
|
||||||
|
'default',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(revisions).toEqual({
|
||||||
|
revisionId: 3,
|
||||||
|
updated: [],
|
||||||
|
removed: [
|
||||||
|
{
|
||||||
|
name: 'some-toggle',
|
||||||
|
project: 'default',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revision equal to the base case returns only later revisions ', () => {
|
||||||
|
const revisionList = [
|
||||||
|
{
|
||||||
|
revisionId: 1,
|
||||||
|
updated: [
|
||||||
|
mockAdd({ name: 'feature1' }),
|
||||||
|
mockAdd({ name: 'feature2' }),
|
||||||
|
mockAdd({ name: 'feature3' }),
|
||||||
|
],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: 2,
|
||||||
|
updated: [mockAdd({ name: 'feature4' })],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: 3,
|
||||||
|
updated: [mockAdd({ name: 'feature5' })],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const revisions = calculateRequiredClientRevision(revisionList, 1, [
|
||||||
|
'default',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(revisions).toEqual({
|
||||||
|
revisionId: 3,
|
||||||
|
updated: [mockAdd({ name: 'feature4' }), mockAdd({ name: 'feature5' })],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('project filter removes features not in project', () => {
|
||||||
|
const revisionList = [
|
||||||
|
{
|
||||||
|
revisionId: 1,
|
||||||
|
updated: [mockAdd({ name: 'feature1', project: 'project1' })],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: 2,
|
||||||
|
updated: [mockAdd({ name: 'feature2', project: 'project2' })],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const revisions = calculateRequiredClientRevision(revisionList, 0, [
|
||||||
|
'project1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(revisions).toEqual({
|
||||||
|
revisionId: 2,
|
||||||
|
updated: [mockAdd({ name: 'feature1', project: 'project1' })],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
});
|
330
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache.ts
vendored
Normal file
330
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache.ts
vendored
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import type {
|
||||||
|
IEventStore,
|
||||||
|
IFeatureToggleClient,
|
||||||
|
IFeatureToggleClientStore,
|
||||||
|
IFeatureToggleQuery,
|
||||||
|
} from '../../../types';
|
||||||
|
import type { FeatureConfigurationClient } from '../../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||||
|
import type ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
||||||
|
import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service';
|
||||||
|
import { RevisionCache } from './revision-cache';
|
||||||
|
|
||||||
|
type DeletedFeature = {
|
||||||
|
name: string;
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientFeatureChange = {
|
||||||
|
updated: IFeatureToggleClient[];
|
||||||
|
removed: DeletedFeature[];
|
||||||
|
revisionId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Revision = {
|
||||||
|
revisionId: number;
|
||||||
|
updated: any[];
|
||||||
|
removed: DeletedFeature[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Revisions = Record<string, RevisionCache>;
|
||||||
|
|
||||||
|
const applyRevision = (first: Revision, last: Revision): Revision => {
|
||||||
|
const updatedMap = new Map(
|
||||||
|
[...first.updated, ...last.updated].map((feature) => [
|
||||||
|
feature.name,
|
||||||
|
feature,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const removedMap = new Map(
|
||||||
|
[...first.removed, ...last.removed].map((feature) => [
|
||||||
|
feature.name,
|
||||||
|
feature,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const feature of last.removed) {
|
||||||
|
updatedMap.delete(feature.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of last.updated) {
|
||||||
|
removedMap.delete(feature.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
revisionId: last.revisionId,
|
||||||
|
updated: Array.from(updatedMap.values()),
|
||||||
|
removed: Array.from(removedMap.values()),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterRevisionByProject = (
|
||||||
|
revision: Revision,
|
||||||
|
projects: string[],
|
||||||
|
): Revision => {
|
||||||
|
const updated = revision.updated.filter(
|
||||||
|
(feature) =>
|
||||||
|
projects.includes('*') || projects.includes(feature.project),
|
||||||
|
);
|
||||||
|
const removed = revision.removed.filter(
|
||||||
|
(feature) =>
|
||||||
|
projects.includes('*') || projects.includes(feature.project),
|
||||||
|
);
|
||||||
|
return { ...revision, updated, removed };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateRequiredClientRevision = (
|
||||||
|
revisions: Revision[],
|
||||||
|
requiredRevisionId: number,
|
||||||
|
projects: string[],
|
||||||
|
) => {
|
||||||
|
const targetedRevisions = revisions.filter(
|
||||||
|
(revision) => revision.revisionId > requiredRevisionId,
|
||||||
|
);
|
||||||
|
console.log('targeted revisions', targetedRevisions);
|
||||||
|
const projectFeatureRevisions = targetedRevisions.map((revision) =>
|
||||||
|
filterRevisionByProject(revision, projects),
|
||||||
|
);
|
||||||
|
|
||||||
|
return projectFeatureRevisions.reduce(applyRevision);
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ClientFeatureToggleCache {
|
||||||
|
private clientFeatureToggleStore: IFeatureToggleClientStore;
|
||||||
|
|
||||||
|
private cache: Revisions = {};
|
||||||
|
|
||||||
|
private eventStore: IEventStore;
|
||||||
|
|
||||||
|
private currentRevisionId: number = 0;
|
||||||
|
|
||||||
|
private interval: NodeJS.Timer;
|
||||||
|
|
||||||
|
private configurationRevisionService: ConfigurationRevisionService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
clientFeatureToggleStore: IFeatureToggleClientStore,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
configurationRevisionService: ConfigurationRevisionService,
|
||||||
|
) {
|
||||||
|
this.eventStore = eventStore;
|
||||||
|
this.configurationRevisionService = configurationRevisionService;
|
||||||
|
this.clientFeatureToggleStore = clientFeatureToggleStore;
|
||||||
|
this.onUpdateRevisionEvent = this.onUpdateRevisionEvent.bind(this);
|
||||||
|
|
||||||
|
this.initCache();
|
||||||
|
this.configurationRevisionService.on(
|
||||||
|
UPDATE_REVISION,
|
||||||
|
this.onUpdateRevisionEvent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDelta(
|
||||||
|
sdkRevisionId: number | undefined,
|
||||||
|
environment: string,
|
||||||
|
projects: string[],
|
||||||
|
): Promise<ClientFeatureChange | undefined> {
|
||||||
|
const requiredRevisionId = sdkRevisionId || 0;
|
||||||
|
|
||||||
|
// Should get the latest state if revision does not exist or if sdkRevision is not present
|
||||||
|
// We should be able to do this without going to the database by merging revisions from the cache with
|
||||||
|
// the base case
|
||||||
|
if (
|
||||||
|
!sdkRevisionId ||
|
||||||
|
(sdkRevisionId &&
|
||||||
|
sdkRevisionId !== this.currentRevisionId &&
|
||||||
|
!this.cache[environment].hasRevision(sdkRevisionId))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
revisionId: this.currentRevisionId,
|
||||||
|
// @ts-ignore
|
||||||
|
updated: await this.getClientFeatures({ environment }),
|
||||||
|
removed: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredRevisionId >= this.currentRevisionId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const environmentRevisions = this.cache[environment].getRevisions();
|
||||||
|
|
||||||
|
const compressedRevision = calculateRequiredClientRevision(
|
||||||
|
environmentRevisions,
|
||||||
|
requiredRevisionId,
|
||||||
|
projects,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve(compressedRevision);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onUpdateRevisionEvent() {
|
||||||
|
await this.pollEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pollEvents() {
|
||||||
|
const latestRevision =
|
||||||
|
await this.configurationRevisionService.getMaxRevisionId();
|
||||||
|
|
||||||
|
if (this.currentRevisionId === 0) {
|
||||||
|
await this.populateBaseCache(latestRevision);
|
||||||
|
} else {
|
||||||
|
const changeEvents = await this.eventStore.getRevisionRange(
|
||||||
|
this.currentRevisionId,
|
||||||
|
latestRevision,
|
||||||
|
);
|
||||||
|
|
||||||
|
const changedToggles = [
|
||||||
|
...new Set(
|
||||||
|
changeEvents
|
||||||
|
.filter((event) => event.featureName)
|
||||||
|
.map((event) => event.featureName!),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const newToggles = await this.getChangedToggles(
|
||||||
|
changedToggles,
|
||||||
|
latestRevision, // TODO: this should come back from the same query to not be out of sync
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.cache.development) {
|
||||||
|
this.cache.development.addRevision(newToggles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.currentRevisionId = latestRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async populateBaseCache(latestRevisionId: number) {
|
||||||
|
const features = await this.getClientFeatures({
|
||||||
|
environment: 'development',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.cache.development) {
|
||||||
|
this.cache.development.addRevision({
|
||||||
|
updated: features as any, //impressionData is not on the type but should be
|
||||||
|
removed: [],
|
||||||
|
revisionId: latestRevisionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Populated base cache with ${features.length} features`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChangedToggles(
|
||||||
|
toggles: string[],
|
||||||
|
revisionId: number,
|
||||||
|
): Promise<ClientFeatureChange> {
|
||||||
|
const foundToggles = await this.getClientFeatures({
|
||||||
|
// @ts-ignore removed toggleNames from the type, we should not use this method at all,
|
||||||
|
toggleNames: toggles,
|
||||||
|
environment: 'development',
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundToggleNames = foundToggles.map((toggle) => toggle.name);
|
||||||
|
const removed = toggles
|
||||||
|
.filter((toggle) => !foundToggleNames.includes(toggle))
|
||||||
|
.map((name) => ({
|
||||||
|
name,
|
||||||
|
project: 'default', // TODO: this needs to be smart and figure out the project . IMPORTANT
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
updated: foundToggles as any, // impressionData is not on the type but should be
|
||||||
|
removed,
|
||||||
|
revisionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// TODO: I think we should remove it as is, because we do not need initialized cache, I think we should populate cache on demand for each env
|
||||||
|
// also we already have populateBaseCache method
|
||||||
|
public async initCache() {
|
||||||
|
//TODO: This only returns stuff for the default environment!!! Need to pass a query to get the relevant environment
|
||||||
|
// featuresByEnvironment cache
|
||||||
|
|
||||||
|
// The base cache is a record of <environment, array>
|
||||||
|
// Each array holds a collection of objects that contains the revisionId and which
|
||||||
|
// flags changed in each revision. It also holds a type that informs us whether or not
|
||||||
|
// the revision is the base case or if is an update or remove operation
|
||||||
|
|
||||||
|
// To get the base for each cache we need to get all features for all environments and the max revision id
|
||||||
|
|
||||||
|
// hardcoded for now
|
||||||
|
// const environments = ["default", "development", "production"];
|
||||||
|
const defaultBaseFeatures = await this.getClientFeatures({
|
||||||
|
environment: 'default',
|
||||||
|
});
|
||||||
|
const developmentBaseFeatures = await this.getClientFeatures({
|
||||||
|
environment: 'development',
|
||||||
|
});
|
||||||
|
const productionBaseFeatures = await this.getClientFeatures({
|
||||||
|
environment: 'production',
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultCache = new RevisionCache([
|
||||||
|
{
|
||||||
|
revisionId: this.currentRevisionId,
|
||||||
|
updated: [defaultBaseFeatures],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const developmentCache = new RevisionCache([
|
||||||
|
{
|
||||||
|
revisionId: this.currentRevisionId,
|
||||||
|
updated: [developmentBaseFeatures],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const productionCache = new RevisionCache([
|
||||||
|
{
|
||||||
|
revisionId: this.currentRevisionId,
|
||||||
|
updated: [productionBaseFeatures],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Always assume that the first item of the array is the base
|
||||||
|
const cache = {
|
||||||
|
default: defaultCache,
|
||||||
|
development: developmentCache,
|
||||||
|
production: productionCache,
|
||||||
|
};
|
||||||
|
|
||||||
|
const latestRevision =
|
||||||
|
await this.configurationRevisionService.getMaxRevisionId();
|
||||||
|
|
||||||
|
this.currentRevisionId = latestRevision;
|
||||||
|
this.cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClientFeatures(
|
||||||
|
query?: IFeatureToggleQuery,
|
||||||
|
): Promise<FeatureConfigurationClient[]> {
|
||||||
|
const result = await this.clientFeatureToggleStore.getClient(
|
||||||
|
query || {},
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.map(
|
||||||
|
({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
enabled,
|
||||||
|
project,
|
||||||
|
stale,
|
||||||
|
strategies,
|
||||||
|
variants,
|
||||||
|
description,
|
||||||
|
impressionData,
|
||||||
|
dependencies,
|
||||||
|
}) => ({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
enabled,
|
||||||
|
project,
|
||||||
|
stale,
|
||||||
|
strategies,
|
||||||
|
variants,
|
||||||
|
description,
|
||||||
|
impressionData,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
33
src/lib/features/client-feature-toggles/cache/createClientFeatureToggleCache.ts
vendored
Normal file
33
src/lib/features/client-feature-toggles/cache/createClientFeatureToggleCache.ts
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ClientFeatureToggleCache } from './client-feature-toggle-cache';
|
||||||
|
import EventStore from '../../events/event-store';
|
||||||
|
import ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
||||||
|
import type { IUnleashConfig } from '../../../types';
|
||||||
|
import type { Db } from '../../../db/db';
|
||||||
|
|
||||||
|
import FeatureToggleClientStore from '../client-feature-toggle-store';
|
||||||
|
|
||||||
|
export const createClientFeatureToggleCache = (
|
||||||
|
db: Db,
|
||||||
|
config: IUnleashConfig,
|
||||||
|
): ClientFeatureToggleCache => {
|
||||||
|
const { getLogger, eventBus, flagResolver } = config;
|
||||||
|
|
||||||
|
const eventStore = new EventStore(db, getLogger);
|
||||||
|
const featureToggleClientStore = new FeatureToggleClientStore(
|
||||||
|
db,
|
||||||
|
eventBus,
|
||||||
|
getLogger,
|
||||||
|
flagResolver,
|
||||||
|
);
|
||||||
|
|
||||||
|
const configurationRevisionService =
|
||||||
|
ConfigurationRevisionService.getInstance({ eventStore }, config);
|
||||||
|
|
||||||
|
const clientFeatureToggleCache = new ClientFeatureToggleCache(
|
||||||
|
featureToggleClientStore,
|
||||||
|
eventStore,
|
||||||
|
configurationRevisionService,
|
||||||
|
);
|
||||||
|
|
||||||
|
return clientFeatureToggleCache;
|
||||||
|
};
|
110
src/lib/features/client-feature-toggles/cache/revision-cache.test.ts
vendored
Normal file
110
src/lib/features/client-feature-toggles/cache/revision-cache.test.ts
vendored
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { RevisionCache } from './revision-cache';
|
||||||
|
import type { Revision } from './client-feature-toggle-cache';
|
||||||
|
|
||||||
|
describe('RevisionCache', () => {
|
||||||
|
it('should create a new base when trying to add a new revision at the max limit', () => {
|
||||||
|
const initialRevisions: Revision[] = [
|
||||||
|
{
|
||||||
|
revisionId: 1,
|
||||||
|
updated: [
|
||||||
|
{
|
||||||
|
name: 'test-flag',
|
||||||
|
type: 'release',
|
||||||
|
enabled: false,
|
||||||
|
project: 'default',
|
||||||
|
stale: false,
|
||||||
|
strategies: [
|
||||||
|
{
|
||||||
|
name: 'flexibleRollout',
|
||||||
|
constraints: [],
|
||||||
|
parameters: {
|
||||||
|
groupId: 'test-flag',
|
||||||
|
rollout: '100',
|
||||||
|
stickiness: 'default',
|
||||||
|
},
|
||||||
|
variants: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: [],
|
||||||
|
description: null,
|
||||||
|
impressionData: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionId: 2,
|
||||||
|
updated: [
|
||||||
|
{
|
||||||
|
name: 'my-feature-flag',
|
||||||
|
type: 'release',
|
||||||
|
enabled: true,
|
||||||
|
project: 'default',
|
||||||
|
stale: false,
|
||||||
|
strategies: [
|
||||||
|
{
|
||||||
|
name: 'flexibleRollout',
|
||||||
|
constraints: [],
|
||||||
|
parameters: {
|
||||||
|
groupId: 'my-feature-flag',
|
||||||
|
rollout: '100',
|
||||||
|
stickiness: 'default',
|
||||||
|
},
|
||||||
|
variants: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: [],
|
||||||
|
description: null,
|
||||||
|
impressionData: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removed: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxLength = 2;
|
||||||
|
const deltaCache = new RevisionCache(initialRevisions, maxLength);
|
||||||
|
|
||||||
|
// Add a new revision to trigger changeBase
|
||||||
|
deltaCache.addRevision({
|
||||||
|
revisionId: 3,
|
||||||
|
updated: [
|
||||||
|
{
|
||||||
|
name: 'another-feature-flag',
|
||||||
|
type: 'release',
|
||||||
|
enabled: true,
|
||||||
|
project: 'default',
|
||||||
|
stale: false,
|
||||||
|
strategies: [
|
||||||
|
{
|
||||||
|
name: 'flexibleRollout',
|
||||||
|
constraints: [],
|
||||||
|
parameters: {
|
||||||
|
groupId: 'another-feature-flag',
|
||||||
|
rollout: '100',
|
||||||
|
stickiness: 'default',
|
||||||
|
},
|
||||||
|
variants: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: [],
|
||||||
|
description: null,
|
||||||
|
impressionData: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const revisions = deltaCache.getRevisions();
|
||||||
|
|
||||||
|
// Check that the base has been changed and merged correctly
|
||||||
|
expect(revisions.length).toBe(2);
|
||||||
|
expect(revisions[0].updated.length).toBe(2);
|
||||||
|
expect(revisions[0].updated).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'test-flag' }),
|
||||||
|
expect.objectContaining({ name: 'my-feature-flag' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
48
src/lib/features/client-feature-toggles/cache/revision-cache.ts
vendored
Normal file
48
src/lib/features/client-feature-toggles/cache/revision-cache.ts
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import type { Revision } from './client-feature-toggle-cache';
|
||||||
|
|
||||||
|
const mergeWithoutDuplicates = (arr1: any[], arr2: any[]) => {
|
||||||
|
const map = new Map();
|
||||||
|
arr1.concat(arr2).forEach((item) => {
|
||||||
|
map.set(item.name, item);
|
||||||
|
});
|
||||||
|
return Array.from(map.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RevisionCache {
|
||||||
|
private cache: Revision[];
|
||||||
|
private maxLength: number;
|
||||||
|
|
||||||
|
constructor(data: Revision[] = [], maxLength: number = 100) {
|
||||||
|
this.cache = data;
|
||||||
|
this.maxLength = maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addRevision(revision: Revision): void {
|
||||||
|
if (this.cache.length >= this.maxLength) {
|
||||||
|
this.changeBase();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache = [...this.cache, revision];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRevisions(): Revision[] {
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasRevision(revisionId: number): boolean {
|
||||||
|
return this.cache.some(
|
||||||
|
(revision) => revision.revisionId === revisionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private changeBase(): void {
|
||||||
|
if (!(this.cache.length >= 2)) return;
|
||||||
|
const base = this.cache[0];
|
||||||
|
const newBase = this.cache[1];
|
||||||
|
|
||||||
|
newBase.removed = mergeWithoutDuplicates(base.removed, newBase.removed);
|
||||||
|
newBase.updated = mergeWithoutDuplicates(base.updated, newBase.updated);
|
||||||
|
|
||||||
|
this.cache = [newBase, ...this.cache.slice(2)];
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,10 @@ import type {
|
|||||||
import type { Logger } from '../../logger';
|
import type { Logger } from '../../logger';
|
||||||
|
|
||||||
import type { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
import type { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||||
|
import type {
|
||||||
|
ClientFeatureChange,
|
||||||
|
ClientFeatureToggleCache,
|
||||||
|
} from './cache/client-feature-toggle-cache';
|
||||||
|
|
||||||
export class ClientFeatureToggleService {
|
export class ClientFeatureToggleService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@ -17,15 +21,19 @@ export class ClientFeatureToggleService {
|
|||||||
|
|
||||||
private segmentReadModel: ISegmentReadModel;
|
private segmentReadModel: ISegmentReadModel;
|
||||||
|
|
||||||
|
private clientFeatureToggleCache: ClientFeatureToggleCache | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
{
|
{
|
||||||
clientFeatureToggleStore,
|
clientFeatureToggleStore,
|
||||||
}: Pick<IUnleashStores, 'clientFeatureToggleStore'>,
|
}: Pick<IUnleashStores, 'clientFeatureToggleStore'>,
|
||||||
segmentReadModel: ISegmentReadModel,
|
segmentReadModel: ISegmentReadModel,
|
||||||
|
clientFeatureToggleCache: ClientFeatureToggleCache | null,
|
||||||
{ getLogger }: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
|
{ getLogger }: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
|
||||||
) {
|
) {
|
||||||
this.logger = getLogger('services/client-feature-toggle-service.ts');
|
this.logger = getLogger('services/client-feature-toggle-service.ts');
|
||||||
this.segmentReadModel = segmentReadModel;
|
this.segmentReadModel = segmentReadModel;
|
||||||
|
this.clientFeatureToggleCache = clientFeatureToggleCache;
|
||||||
this.clientFeatureToggleStore = clientFeatureToggleStore;
|
this.clientFeatureToggleStore = clientFeatureToggleStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,6 +41,24 @@ export class ClientFeatureToggleService {
|
|||||||
return this.segmentReadModel.getActiveForClient();
|
return this.segmentReadModel.getActiveForClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getClientDelta(
|
||||||
|
revisionId: number | undefined,
|
||||||
|
projects: string[],
|
||||||
|
environment: string,
|
||||||
|
): Promise<ClientFeatureChange | undefined> {
|
||||||
|
if (this.clientFeatureToggleCache !== null) {
|
||||||
|
return this.clientFeatureToggleCache.getDelta(
|
||||||
|
revisionId,
|
||||||
|
environment,
|
||||||
|
projects,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
'Calling the partial updates but the cache is not initialized',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getClientFeatures(
|
async getClientFeatures(
|
||||||
query?: IFeatureToggleQuery,
|
query?: IFeatureToggleQuery,
|
||||||
): Promise<FeatureConfigurationClient[]> {
|
): Promise<FeatureConfigurationClient[]> {
|
||||||
|
@ -185,7 +185,6 @@ export default class FeatureToggleClientStore
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await query;
|
const rows = await query;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
|
|
||||||
|
@ -33,6 +33,7 @@ import {
|
|||||||
} from '../../openapi/spec/client-features-schema';
|
} from '../../openapi/spec/client-features-schema';
|
||||||
import type ConfigurationRevisionService from '../feature-toggle/configuration-revision-service';
|
import type ConfigurationRevisionService from '../feature-toggle/configuration-revision-service';
|
||||||
import type { ClientFeatureToggleService } from './client-feature-toggle-service';
|
import type { ClientFeatureToggleService } from './client-feature-toggle-service';
|
||||||
|
import type { ClientFeatureChange } from './cache/client-feature-toggle-cache';
|
||||||
|
|
||||||
const version = 2;
|
const version = 2;
|
||||||
|
|
||||||
@ -94,6 +95,25 @@ export default class FeatureController extends Controller {
|
|||||||
this.flagResolver = config.flagResolver;
|
this.flagResolver = config.flagResolver;
|
||||||
this.logger = config.getLogger('client-api/feature.js');
|
this.logger = config.getLogger('client-api/feature.js');
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'get',
|
||||||
|
path: '/delta',
|
||||||
|
handler: this.getDelta,
|
||||||
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
summary: 'Get partial updates (SDK)',
|
||||||
|
description:
|
||||||
|
'Initially returns the full set of feature flags available to the provided API key. When called again with the returned etag, only returns the flags that have changed',
|
||||||
|
operationId: 'getDelta',
|
||||||
|
tags: ['Unstable'],
|
||||||
|
responses: {
|
||||||
|
200: createResponseSchema('clientFeaturesSchema'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/:featureName',
|
path: '/:featureName',
|
||||||
@ -278,6 +298,46 @@ export default class FeatureController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDelta(
|
||||||
|
req: IAuthRequest,
|
||||||
|
res: Response<ClientFeatureChange>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.flagResolver.isEnabled('deltaApi')) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
const query = await this.resolveQuery(req);
|
||||||
|
const etag = req.headers['if-none-match'];
|
||||||
|
const meta = await this.calculateMeta(query);
|
||||||
|
|
||||||
|
const currentSdkRevisionId = etag ? Number.parseInt(etag) : undefined;
|
||||||
|
const projects = query.project ? query.project : ['*'];
|
||||||
|
const environment = query.environment ? query.environment : 'default';
|
||||||
|
|
||||||
|
const changedFeatures =
|
||||||
|
await this.clientFeatureToggleService.getClientDelta(
|
||||||
|
currentSdkRevisionId,
|
||||||
|
projects,
|
||||||
|
environment,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!changedFeatures) {
|
||||||
|
res.status(304);
|
||||||
|
res.getHeaderNames().forEach((header) => res.removeHeader(header));
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedFeatures.revisionId === currentSdkRevisionId) {
|
||||||
|
res.status(304);
|
||||||
|
res.getHeaderNames().forEach((header) => res.removeHeader(header));
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('ETag', changedFeatures.revisionId.toString());
|
||||||
|
res.send(changedFeatures);
|
||||||
|
}
|
||||||
|
|
||||||
async calculateMeta(query: IFeatureToggleQuery): Promise<IMeta> {
|
async calculateMeta(query: IFeatureToggleQuery): Promise<IMeta> {
|
||||||
// TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
|
// TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
|
||||||
const revisionId =
|
const revisionId =
|
||||||
|
@ -5,6 +5,7 @@ import FakeClientFeatureToggleStore from './fakes/fake-client-feature-toggle-sto
|
|||||||
import { ClientFeatureToggleService } from './client-feature-toggle-service';
|
import { ClientFeatureToggleService } from './client-feature-toggle-service';
|
||||||
import { SegmentReadModel } from '../segment/segment-read-model';
|
import { SegmentReadModel } from '../segment/segment-read-model';
|
||||||
import { FakeSegmentReadModel } from '../segment/fake-segment-read-model';
|
import { FakeSegmentReadModel } from '../segment/fake-segment-read-model';
|
||||||
|
import { createClientFeatureToggleCache } from './cache/createClientFeatureToggleCache';
|
||||||
|
|
||||||
export const createClientFeatureToggleService = (
|
export const createClientFeatureToggleService = (
|
||||||
db: Db,
|
db: Db,
|
||||||
@ -21,11 +22,14 @@ export const createClientFeatureToggleService = (
|
|||||||
|
|
||||||
const segmentReadModel = new SegmentReadModel(db);
|
const segmentReadModel = new SegmentReadModel(db);
|
||||||
|
|
||||||
|
const clientFeatureToggleCache = createClientFeatureToggleCache(db, config);
|
||||||
|
|
||||||
const clientFeatureToggleService = new ClientFeatureToggleService(
|
const clientFeatureToggleService = new ClientFeatureToggleService(
|
||||||
{
|
{
|
||||||
clientFeatureToggleStore: featureToggleClientStore,
|
clientFeatureToggleStore: featureToggleClientStore,
|
||||||
},
|
},
|
||||||
segmentReadModel,
|
segmentReadModel,
|
||||||
|
clientFeatureToggleCache,
|
||||||
{ getLogger, flagResolver },
|
{ getLogger, flagResolver },
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -46,6 +50,7 @@ export const createFakeClientFeatureToggleService = (
|
|||||||
clientFeatureToggleStore: fakeClientFeatureToggleStore,
|
clientFeatureToggleStore: fakeClientFeatureToggleStore,
|
||||||
},
|
},
|
||||||
fakeSegmentReadModel,
|
fakeSegmentReadModel,
|
||||||
|
null,
|
||||||
{ getLogger, flagResolver },
|
{ getLogger, flagResolver },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -201,6 +201,27 @@ class EventStore implements IEventStore {
|
|||||||
return row?.max ?? 0;
|
return row?.max ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRevisionRange(start: number, end: number): Promise<IEvent[]> {
|
||||||
|
const query = this.db
|
||||||
|
.select(EVENT_COLUMNS)
|
||||||
|
.from(TABLE)
|
||||||
|
.where('id', '>', start)
|
||||||
|
.andWhere('id', '<=', end)
|
||||||
|
.andWhere((builder) =>
|
||||||
|
builder
|
||||||
|
.whereNotNull('feature_name')
|
||||||
|
.orWhereIn('type', [
|
||||||
|
SEGMENT_UPDATED,
|
||||||
|
FEATURE_IMPORT,
|
||||||
|
FEATURES_IMPORTED,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.orderBy('id', 'asc');
|
||||||
|
|
||||||
|
const rows = await query;
|
||||||
|
return rows.map(this.rowToEvent);
|
||||||
|
}
|
||||||
|
|
||||||
async delete(key: number): Promise<void> {
|
async delete(key: number): Promise<void> {
|
||||||
await this.db(TABLE).where({ id: key }).del();
|
await this.db(TABLE).where({ id: key }).del();
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import type { Logger } from '../../logger';
|
import type { Logger } from '../../logger';
|
||||||
import type {
|
import type {
|
||||||
|
IEvent,
|
||||||
IEventStore,
|
IEventStore,
|
||||||
IFlagResolver,
|
IFlagResolver,
|
||||||
IUnleashConfig,
|
IUnleashConfig,
|
||||||
@ -72,13 +73,17 @@ export default class ConfigurationRevisionService extends EventEmitter {
|
|||||||
'Updating feature configuration with new revision Id',
|
'Updating feature configuration with new revision Id',
|
||||||
revisionId,
|
revisionId,
|
||||||
);
|
);
|
||||||
this.emit(UPDATE_REVISION, revisionId);
|
|
||||||
this.revisionId = revisionId;
|
this.revisionId = revisionId;
|
||||||
|
this.emit(UPDATE_REVISION, revisionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.revisionId;
|
return this.revisionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRevisionRange(start: number, end: number): Promise<IEvent[]> {
|
||||||
|
return this.eventStore.getRevisionRange(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
ConfigurationRevisionService.instance?.removeAllListeners();
|
ConfigurationRevisionService.instance?.removeAllListeners();
|
||||||
}
|
}
|
||||||
|
@ -47,6 +47,7 @@ async function createApp(
|
|||||||
const stores = createStores(config, db);
|
const stores = createStores(config, db);
|
||||||
await compareAndLogPostgresVersion(config, stores.settingStore);
|
await compareAndLogPostgresVersion(config, stores.settingStore);
|
||||||
const services = createServices(stores, config, db);
|
const services = createServices(stores, config, db);
|
||||||
|
|
||||||
if (!config.disableScheduler) {
|
if (!config.disableScheduler) {
|
||||||
await scheduleServices(services, config);
|
await scheduleServices(services, config);
|
||||||
}
|
}
|
||||||
|
@ -62,7 +62,8 @@ export type IFlagKey =
|
|||||||
| 'granularAdminPermissions'
|
| 'granularAdminPermissions'
|
||||||
| 'streaming'
|
| 'streaming'
|
||||||
| 'etagVariant'
|
| 'etagVariant'
|
||||||
| 'oidcRedirect';
|
| 'oidcRedirect'
|
||||||
|
| 'deltaApi';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
|
|
||||||
@ -295,6 +296,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_OIDC_REDIRECT,
|
process.env.UNLEASH_EXPERIMENTAL_OIDC_REDIRECT,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
deltaApi: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_DELTA_API,
|
||||||
|
false,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
|
@ -43,6 +43,7 @@ export interface IEventStore
|
|||||||
queryParams: IQueryParam[],
|
queryParams: IQueryParam[],
|
||||||
): Promise<IEvent[]>;
|
): Promise<IEvent[]>;
|
||||||
getMaxRevisionId(currentMax?: number): Promise<number>;
|
getMaxRevisionId(currentMax?: number): Promise<number>;
|
||||||
|
getRevisionRange(start: number, end: number): Promise<IEvent[]>;
|
||||||
query(operations: IQueryOperations[]): Promise<IEvent[]>;
|
query(operations: IQueryOperations[]): Promise<IEvent[]>;
|
||||||
queryCount(operations: IQueryOperations[]): Promise<number>;
|
queryCount(operations: IQueryOperations[]): Promise<number>;
|
||||||
setCreatedByUserId(batchSize: number): Promise<number | undefined>;
|
setCreatedByUserId(batchSize: number): Promise<number | undefined>;
|
||||||
|
@ -57,6 +57,7 @@ process.nextTick(async () => {
|
|||||||
flagOverviewRedesign: false,
|
flagOverviewRedesign: false,
|
||||||
licensedUsers: true,
|
licensedUsers: true,
|
||||||
granularAdminPermissions: true,
|
granularAdminPermissions: true,
|
||||||
|
deltaApi: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
3
src/test/fixtures/fake-event-store.ts
vendored
3
src/test/fixtures/fake-event-store.ts
vendored
@ -17,6 +17,9 @@ class FakeEventStore implements IEventStore {
|
|||||||
this.eventEmitter.setMaxListeners(0);
|
this.eventEmitter.setMaxListeners(0);
|
||||||
this.events = [];
|
this.events = [];
|
||||||
}
|
}
|
||||||
|
getRevisionRange(start: number, end: number): Promise<IEvent[]> {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
getProjectRecentEventActivity(
|
getProjectRecentEventActivity(
|
||||||
project: string,
|
project: string,
|
||||||
|
Loading…
Reference in New Issue
Block a user