mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-17 01:17:29 +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 { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||
import type {
|
||||
ClientFeatureChange,
|
||||
ClientFeatureToggleCache,
|
||||
} from './cache/client-feature-toggle-cache';
|
||||
|
||||
export class ClientFeatureToggleService {
|
||||
private logger: Logger;
|
||||
@ -17,15 +21,19 @@ export class ClientFeatureToggleService {
|
||||
|
||||
private segmentReadModel: ISegmentReadModel;
|
||||
|
||||
private clientFeatureToggleCache: ClientFeatureToggleCache | null = null;
|
||||
|
||||
constructor(
|
||||
{
|
||||
clientFeatureToggleStore,
|
||||
}: Pick<IUnleashStores, 'clientFeatureToggleStore'>,
|
||||
segmentReadModel: ISegmentReadModel,
|
||||
clientFeatureToggleCache: ClientFeatureToggleCache | null,
|
||||
{ getLogger }: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
|
||||
) {
|
||||
this.logger = getLogger('services/client-feature-toggle-service.ts');
|
||||
this.segmentReadModel = segmentReadModel;
|
||||
this.clientFeatureToggleCache = clientFeatureToggleCache;
|
||||
this.clientFeatureToggleStore = clientFeatureToggleStore;
|
||||
}
|
||||
|
||||
@ -33,6 +41,24 @@ export class ClientFeatureToggleService {
|
||||
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(
|
||||
query?: IFeatureToggleQuery,
|
||||
): Promise<FeatureConfigurationClient[]> {
|
||||
|
@ -185,7 +185,6 @@ export default class FeatureToggleClientStore
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
stopTimer();
|
||||
|
||||
|
@ -33,6 +33,7 @@ import {
|
||||
} from '../../openapi/spec/client-features-schema';
|
||||
import type ConfigurationRevisionService from '../feature-toggle/configuration-revision-service';
|
||||
import type { ClientFeatureToggleService } from './client-feature-toggle-service';
|
||||
import type { ClientFeatureChange } from './cache/client-feature-toggle-cache';
|
||||
|
||||
const version = 2;
|
||||
|
||||
@ -94,6 +95,25 @@ export default class FeatureController extends Controller {
|
||||
this.flagResolver = config.flagResolver;
|
||||
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({
|
||||
method: 'get',
|
||||
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> {
|
||||
// TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
|
||||
const revisionId =
|
||||
|
@ -5,6 +5,7 @@ import FakeClientFeatureToggleStore from './fakes/fake-client-feature-toggle-sto
|
||||
import { ClientFeatureToggleService } from './client-feature-toggle-service';
|
||||
import { SegmentReadModel } from '../segment/segment-read-model';
|
||||
import { FakeSegmentReadModel } from '../segment/fake-segment-read-model';
|
||||
import { createClientFeatureToggleCache } from './cache/createClientFeatureToggleCache';
|
||||
|
||||
export const createClientFeatureToggleService = (
|
||||
db: Db,
|
||||
@ -21,11 +22,14 @@ export const createClientFeatureToggleService = (
|
||||
|
||||
const segmentReadModel = new SegmentReadModel(db);
|
||||
|
||||
const clientFeatureToggleCache = createClientFeatureToggleCache(db, config);
|
||||
|
||||
const clientFeatureToggleService = new ClientFeatureToggleService(
|
||||
{
|
||||
clientFeatureToggleStore: featureToggleClientStore,
|
||||
},
|
||||
segmentReadModel,
|
||||
clientFeatureToggleCache,
|
||||
{ getLogger, flagResolver },
|
||||
);
|
||||
|
||||
@ -46,6 +50,7 @@ export const createFakeClientFeatureToggleService = (
|
||||
clientFeatureToggleStore: fakeClientFeatureToggleStore,
|
||||
},
|
||||
fakeSegmentReadModel,
|
||||
null,
|
||||
{ getLogger, flagResolver },
|
||||
);
|
||||
|
||||
|
@ -201,6 +201,27 @@ class EventStore implements IEventStore {
|
||||
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> {
|
||||
await this.db(TABLE).where({ id: key }).del();
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type { Logger } from '../../logger';
|
||||
import type {
|
||||
IEvent,
|
||||
IEventStore,
|
||||
IFlagResolver,
|
||||
IUnleashConfig,
|
||||
@ -72,13 +73,17 @@ export default class ConfigurationRevisionService extends EventEmitter {
|
||||
'Updating feature configuration with new revision Id',
|
||||
revisionId,
|
||||
);
|
||||
this.emit(UPDATE_REVISION, revisionId);
|
||||
this.revisionId = revisionId;
|
||||
this.emit(UPDATE_REVISION, revisionId);
|
||||
}
|
||||
|
||||
return this.revisionId;
|
||||
}
|
||||
|
||||
async getRevisionRange(start: number, end: number): Promise<IEvent[]> {
|
||||
return this.eventStore.getRevisionRange(start, end);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
ConfigurationRevisionService.instance?.removeAllListeners();
|
||||
}
|
||||
|
@ -47,6 +47,7 @@ async function createApp(
|
||||
const stores = createStores(config, db);
|
||||
await compareAndLogPostgresVersion(config, stores.settingStore);
|
||||
const services = createServices(stores, config, db);
|
||||
|
||||
if (!config.disableScheduler) {
|
||||
await scheduleServices(services, config);
|
||||
}
|
||||
|
@ -62,7 +62,8 @@ export type IFlagKey =
|
||||
| 'granularAdminPermissions'
|
||||
| 'streaming'
|
||||
| 'etagVariant'
|
||||
| 'oidcRedirect';
|
||||
| 'oidcRedirect'
|
||||
| 'deltaApi';
|
||||
|
||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||
|
||||
@ -295,6 +296,10 @@ const flags: IFlags = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_OIDC_REDIRECT,
|
||||
false,
|
||||
),
|
||||
deltaApi: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_DELTA_API,
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||
|
@ -43,6 +43,7 @@ export interface IEventStore
|
||||
queryParams: IQueryParam[],
|
||||
): Promise<IEvent[]>;
|
||||
getMaxRevisionId(currentMax?: number): Promise<number>;
|
||||
getRevisionRange(start: number, end: number): Promise<IEvent[]>;
|
||||
query(operations: IQueryOperations[]): Promise<IEvent[]>;
|
||||
queryCount(operations: IQueryOperations[]): Promise<number>;
|
||||
setCreatedByUserId(batchSize: number): Promise<number | undefined>;
|
||||
|
@ -57,6 +57,7 @@ process.nextTick(async () => {
|
||||
flagOverviewRedesign: false,
|
||||
licensedUsers: true,
|
||||
granularAdminPermissions: true,
|
||||
deltaApi: true,
|
||||
},
|
||||
},
|
||||
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.events = [];
|
||||
}
|
||||
getRevisionRange(start: number, end: number): Promise<IEvent[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
getProjectRecentEventActivity(
|
||||
project: string,
|
||||
|
Loading…
Reference in New Issue
Block a user