mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-04 00:18:01 +01:00
feat: delta rework (#9133)
We are changing how the Delta API works, as discussed: 1. We have removed the `updated` and `removed` arrays and now keep everything in the `events` array. 2. We decided to keep the hydration cache separate from the events array internally. Since the hydration cache has a special structure and may contain not just one feature but potentially 1,000 features, it behaved differently, requiring a lot of special logic to handle it. 3. Implemented `nameprefix` filtering, which we were missing before. Things still to implement: 1. Segment hydration and updates to it.
This commit is contained in:
parent
4bbff0c554
commit
280710f22a
@ -41,6 +41,7 @@ import {
|
|||||||
} from '../../internals';
|
} from '../../internals';
|
||||||
import isEqual from 'lodash.isequal';
|
import isEqual from 'lodash.isequal';
|
||||||
import { diff } from 'json-diff';
|
import { diff } from 'json-diff';
|
||||||
|
import type { DeltaHydrationEvent } from './delta/client-feature-toggle-delta-types';
|
||||||
|
|
||||||
const version = 2;
|
const version = 2;
|
||||||
|
|
||||||
@ -191,22 +192,34 @@ export default class FeatureController extends Controller {
|
|||||||
const sortedToggles = features.sort((a, b) =>
|
const sortedToggles = features.sort((a, b) =>
|
||||||
a.name.localeCompare(b.name),
|
a.name.localeCompare(b.name),
|
||||||
);
|
);
|
||||||
const sortedNewToggles = delta?.updated.sort((a, b) =>
|
if (delta?.events[0].type === 'hydration') {
|
||||||
a.name.localeCompare(b.name),
|
const hydrationEvent: DeltaHydrationEvent =
|
||||||
|
delta?.events[0];
|
||||||
|
const sortedNewToggles = hydrationEvent.features.sort(
|
||||||
|
(a, b) => a.name.localeCompare(b.name),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this.deepEqualIgnoreOrder(sortedToggles, sortedNewToggles)
|
!this.deepEqualIgnoreOrder(
|
||||||
|
sortedToggles,
|
||||||
|
sortedNewToggles,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`old features and new features are different. Old count ${
|
`old features and new features are different. Old count ${
|
||||||
features.length
|
features.length
|
||||||
}, new count ${delta?.updated.length}, query ${JSON.stringify(query)},
|
}, new count ${hydrationEvent.features.length}, query ${JSON.stringify(query)},
|
||||||
diff ${JSON.stringify(
|
diff ${JSON.stringify(
|
||||||
diff(sortedToggles, sortedNewToggles),
|
diff(sortedToggles, sortedNewToggles),
|
||||||
)}`,
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`Delta diff should have only hydration event, query ${JSON.stringify(query)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.storeFootprint();
|
this.storeFootprint();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Delta diff failed', e);
|
this.logger.error('Delta diff failed', e);
|
||||||
|
@ -37,7 +37,11 @@ const setupFeatures = async (
|
|||||||
{
|
{
|
||||||
name: 'default',
|
name: 'default',
|
||||||
constraints: [
|
constraints: [
|
||||||
{ contextName: 'userId', operator: 'IN', values: ['123'] },
|
{
|
||||||
|
contextName: 'userId',
|
||||||
|
operator: 'IN',
|
||||||
|
values: ['123'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
parameters: {},
|
parameters: {},
|
||||||
},
|
},
|
||||||
@ -88,17 +92,19 @@ test('should match with /api/client/delta', async () => {
|
|||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(body.features).toMatchObject(deltaBody.updated);
|
expect(body.features).toMatchObject(deltaBody.events[0].features);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should get 304 if asked for latest revision', async () => {
|
test('should get 304 if asked for latest revision', async () => {
|
||||||
await setupFeatures(db, app);
|
await setupFeatures(db, app);
|
||||||
|
|
||||||
const { body } = await app.request.get('/api/client/delta').expect(200);
|
const { body, headers } = await app.request
|
||||||
const currentRevisionId = body.revisionId;
|
.get('/api/client/delta')
|
||||||
|
.expect(200);
|
||||||
|
const etag = headers.etag;
|
||||||
|
|
||||||
await app.request
|
await app.request
|
||||||
.set('If-None-Match', `"${currentRevisionId}"`)
|
.set('If-None-Match', etag)
|
||||||
.get('/api/client/delta')
|
.get('/api/client/delta')
|
||||||
.expect(304);
|
.expect(304);
|
||||||
});
|
});
|
||||||
@ -106,71 +112,102 @@ test('should get 304 if asked for latest revision', async () => {
|
|||||||
test('should return correct delta after feature created', async () => {
|
test('should return correct delta after feature created', async () => {
|
||||||
await app.createFeature('base_feature');
|
await app.createFeature('base_feature');
|
||||||
await syncRevisions();
|
await syncRevisions();
|
||||||
const { body } = await app.request.get('/api/client/delta').expect(200);
|
const { body, headers } = await app.request
|
||||||
const currentRevisionId = body.revisionId;
|
.set('If-None-Match', null)
|
||||||
|
.get('/api/client/delta')
|
||||||
|
.expect(200);
|
||||||
|
const etag = headers.etag;
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
updated: [
|
events: [
|
||||||
|
{
|
||||||
|
type: 'hydration',
|
||||||
|
features: [
|
||||||
{
|
{
|
||||||
name: 'base_feature',
|
name: 'base_feature',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.createFeature('new_feature');
|
await app.createFeature('new_feature');
|
||||||
|
|
||||||
await syncRevisions();
|
await syncRevisions();
|
||||||
|
//@ts-ignore
|
||||||
|
await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
|
||||||
|
|
||||||
const { body: deltaBody } = await app.request
|
const { body: deltaBody } = await app.request
|
||||||
.get('/api/client/delta')
|
.get('/api/client/delta')
|
||||||
.set('If-None-Match', `"${currentRevisionId}"`)
|
.set('If-None-Match', etag)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(deltaBody).toMatchObject({
|
expect(deltaBody).toMatchObject({
|
||||||
updated: [
|
events: [
|
||||||
{
|
{
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: {
|
||||||
name: 'new_feature',
|
name: 'new_feature',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: {
|
||||||
|
name: 'new_feature',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const syncRevisions = async () => {
|
const syncRevisions = async () => {
|
||||||
await app.services.configurationRevisionService.updateMaxRevisionId();
|
await app.services.configurationRevisionService.updateMaxRevisionId();
|
||||||
// @ts-ignore
|
|
||||||
await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
test('archived features should not be returned as updated', async () => {
|
test('archived features should not be returned as updated', async () => {
|
||||||
await app.createFeature('base_feature');
|
await app.createFeature('base_feature');
|
||||||
await syncRevisions();
|
await syncRevisions();
|
||||||
const { body } = await app.request.get('/api/client/delta').expect(200);
|
const { body, headers } = await app.request
|
||||||
const currentRevisionId = body.revisionId;
|
.get('/api/client/delta')
|
||||||
|
.expect(200);
|
||||||
|
const etag = headers.etag;
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
updated: [
|
events: [
|
||||||
|
{
|
||||||
|
features: [
|
||||||
{
|
{
|
||||||
name: 'base_feature',
|
name: 'base_feature',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.archiveFeature('base_feature');
|
await app.archiveFeature('base_feature');
|
||||||
|
await syncRevisions();
|
||||||
await app.createFeature('new_feature');
|
await app.createFeature('new_feature');
|
||||||
|
|
||||||
await syncRevisions();
|
await syncRevisions();
|
||||||
|
await app.getProjectFeatures('new_feature'); // TODO: this is silly, but events syncing and tests do not work nicely. this is basically a setTimeout
|
||||||
|
|
||||||
const { body: deltaBody } = await app.request
|
const { body: deltaBody } = await app.request
|
||||||
.get('/api/client/delta')
|
.get('/api/client/delta')
|
||||||
.set('If-None-Match', `"${currentRevisionId}"`)
|
.set('If-None-Match', etag)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(deltaBody).toMatchObject({
|
expect(deltaBody).toMatchObject({
|
||||||
updated: [
|
events: [
|
||||||
{
|
{
|
||||||
|
type: 'feature-removed',
|
||||||
|
featureName: 'base_feature',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: {
|
||||||
name: 'new_feature',
|
name: 'new_feature',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
removed: ['base_feature'],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -103,15 +103,16 @@ export default class ClientFeatureToggleDeltaController extends Controller {
|
|||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const lastEventId =
|
||||||
if (changedFeatures.revisionId === currentSdkRevisionId) {
|
changedFeatures.events[changedFeatures.events.length - 1].eventId;
|
||||||
|
if (lastEventId === currentSdkRevisionId) {
|
||||||
res.status(304);
|
res.status(304);
|
||||||
res.getHeaderNames().forEach((header) => res.removeHeader(header));
|
res.getHeaderNames().forEach((header) => res.removeHeader(header));
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('ETag', `"${changedFeatures.revisionId}"`);
|
res.setHeader('ETag', `"${lastEventId}"`);
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
200,
|
200,
|
||||||
res,
|
res,
|
||||||
|
@ -0,0 +1,63 @@
|
|||||||
|
import type { ClientFeatureSchema } from '../../../openapi';
|
||||||
|
import type { IClientSegment } from '../../../types';
|
||||||
|
|
||||||
|
export type DeltaHydrationEvent = {
|
||||||
|
eventId: number;
|
||||||
|
type: 'hydration';
|
||||||
|
features: ClientFeatureSchema[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeltaEvent =
|
||||||
|
| {
|
||||||
|
eventId: number;
|
||||||
|
type: 'feature-updated';
|
||||||
|
feature: ClientFeatureSchema;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
eventId: number;
|
||||||
|
type: 'feature-removed';
|
||||||
|
featureName: string;
|
||||||
|
project: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
eventId: number;
|
||||||
|
type: 'segment-updated';
|
||||||
|
segment: IClientSegment;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
eventId: number;
|
||||||
|
type: 'segment-removed';
|
||||||
|
segmentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELTA_EVENT_TYPES = {
|
||||||
|
FEATURE_UPDATED: 'feature-updated',
|
||||||
|
FEATURE_REMOVED: 'feature-removed',
|
||||||
|
SEGMENT_UPDATED: 'segment-updated',
|
||||||
|
SEGMENT_REMOVED: 'segment-removed',
|
||||||
|
HYDRATION: 'hydration',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const isDeltaFeatureUpdatedEvent = (
|
||||||
|
event: DeltaEvent,
|
||||||
|
): event is Extract<DeltaEvent, { type: 'feature-updated' }> => {
|
||||||
|
return event.type === DELTA_EVENT_TYPES.FEATURE_UPDATED;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isDeltaFeatureRemovedEvent = (
|
||||||
|
event: DeltaEvent,
|
||||||
|
): event is Extract<DeltaEvent, { type: 'feature-removed' }> => {
|
||||||
|
return event.type === DELTA_EVENT_TYPES.FEATURE_REMOVED;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isDeltaSegmentUpdatedEvent = (
|
||||||
|
event: DeltaEvent,
|
||||||
|
): event is Extract<DeltaEvent, { type: 'segment-updated' }> => {
|
||||||
|
return event.type === DELTA_EVENT_TYPES.SEGMENT_UPDATED;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isDeltaSegmentRemovedEvent = (
|
||||||
|
event: DeltaEvent,
|
||||||
|
): event is Extract<DeltaEvent, { type: 'segment-removed' }> => {
|
||||||
|
return event.type === DELTA_EVENT_TYPES.SEGMENT_REMOVED;
|
||||||
|
};
|
@ -1,4 +1,9 @@
|
|||||||
import { calculateRequiredClientRevision } from './client-feature-toggle-delta';
|
import {
|
||||||
|
type DeltaEvent,
|
||||||
|
filterEventsByQuery,
|
||||||
|
filterHydrationEventByQuery,
|
||||||
|
} from './client-feature-toggle-delta';
|
||||||
|
import type { DeltaHydrationEvent } from './client-feature-toggle-delta-types';
|
||||||
|
|
||||||
const mockAdd = (params): any => {
|
const mockAdd = (params): any => {
|
||||||
const base = {
|
const base = {
|
||||||
@ -16,166 +21,86 @@ const mockAdd = (params): any => {
|
|||||||
return { ...base, ...params };
|
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 ', () => {
|
test('revision equal to the base case returns only later revisions ', () => {
|
||||||
const revisionList = [
|
const revisionList: DeltaEvent[] = [
|
||||||
{
|
{
|
||||||
revisionId: 1,
|
eventId: 2,
|
||||||
updated: [
|
type: 'feature-updated',
|
||||||
mockAdd({ name: 'feature1' }),
|
feature: mockAdd({ name: 'feature4' }),
|
||||||
mockAdd({ name: 'feature2' }),
|
},
|
||||||
mockAdd({ name: 'feature3' }),
|
{
|
||||||
|
eventId: 3,
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: mockAdd({ name: 'feature5' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const revisions = filterEventsByQuery(revisionList, 1, ['default'], '');
|
||||||
|
|
||||||
|
expect(revisions).toEqual([
|
||||||
|
{
|
||||||
|
eventId: 2,
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: mockAdd({ name: 'feature4' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 3,
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: mockAdd({ name: 'feature5' }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('project filter removes features not in project and nameprefix', () => {
|
||||||
|
const revisionList: DeltaEvent[] = [
|
||||||
|
{
|
||||||
|
eventId: 1,
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: mockAdd({ name: 'feature1', project: 'project1' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 2,
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: mockAdd({ name: 'feature2', project: 'project2' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 3,
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: mockAdd({ name: 'ffeature1', project: 'project1' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const revisions = filterEventsByQuery(revisionList, 0, ['project1'], 'ff');
|
||||||
|
|
||||||
|
expect(revisions).toEqual([
|
||||||
|
{
|
||||||
|
eventId: 3,
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: mockAdd({ name: 'ffeature1', project: 'project1' }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('project filter removes features not in project in hydration', () => {
|
||||||
|
const revisionList: DeltaHydrationEvent = {
|
||||||
|
eventId: 1,
|
||||||
|
type: 'hydration',
|
||||||
|
features: [
|
||||||
|
mockAdd({ name: 'feature1', project: 'project1' }),
|
||||||
|
mockAdd({ name: 'feature2', project: 'project2' }),
|
||||||
|
mockAdd({ name: 'myfeature2', project: 'project2' }),
|
||||||
],
|
],
|
||||||
removed: [],
|
};
|
||||||
},
|
|
||||||
{
|
|
||||||
revisionId: 2,
|
|
||||||
updated: [mockAdd({ name: 'feature4' })],
|
|
||||||
removed: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
revisionId: 3,
|
|
||||||
updated: [mockAdd({ name: 'feature5' })],
|
|
||||||
removed: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const revisions = calculateRequiredClientRevision(revisionList, 1, [
|
const revisions = filterHydrationEventByQuery(
|
||||||
'default',
|
revisionList,
|
||||||
]);
|
['project2'],
|
||||||
|
'my',
|
||||||
|
);
|
||||||
|
|
||||||
expect(revisions).toEqual({
|
expect(revisions).toEqual({
|
||||||
revisionId: 3,
|
eventId: 1,
|
||||||
updated: [mockAdd({ name: 'feature4' }), mockAdd({ name: 'feature5' })],
|
type: 'hydration',
|
||||||
removed: [],
|
features: [mockAdd({ name: 'myfeature2', project: 'project2' })],
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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: [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -9,7 +9,7 @@ import type {
|
|||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import type ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
import type ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
|
||||||
import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service';
|
import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service';
|
||||||
import { RevisionDelta } from './revision-delta';
|
import { DeltaCache } from './delta-cache';
|
||||||
import type {
|
import type {
|
||||||
FeatureConfigurationDeltaClient,
|
FeatureConfigurationDeltaClient,
|
||||||
IClientFeatureToggleDeltaReadModel,
|
IClientFeatureToggleDeltaReadModel,
|
||||||
@ -18,92 +18,75 @@ import { CLIENT_DELTA_MEMORY } from '../../../metric-events';
|
|||||||
import type EventEmitter from 'events';
|
import type EventEmitter from 'events';
|
||||||
import type { Logger } from '../../../logger';
|
import type { Logger } from '../../../logger';
|
||||||
import type { ClientFeaturesDeltaSchema } from '../../../openapi';
|
import type { ClientFeaturesDeltaSchema } from '../../../openapi';
|
||||||
|
import {
|
||||||
|
DELTA_EVENT_TYPES,
|
||||||
|
type DeltaEvent,
|
||||||
|
type DeltaHydrationEvent,
|
||||||
|
isDeltaFeatureRemovedEvent,
|
||||||
|
isDeltaFeatureUpdatedEvent,
|
||||||
|
} from './client-feature-toggle-delta-types';
|
||||||
|
|
||||||
type DeletedFeature = {
|
type EnvironmentRevisions = Record<string, DeltaCache>;
|
||||||
name: string;
|
|
||||||
project: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RevisionDeltaEntry = {
|
export const filterEventsByQuery = (
|
||||||
updated: FeatureConfigurationDeltaClient[];
|
events: DeltaEvent[],
|
||||||
revisionId: number;
|
|
||||||
removed: DeletedFeature[];
|
|
||||||
segments: IClientSegment[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Revision = {
|
|
||||||
revisionId: number;
|
|
||||||
updated: any[];
|
|
||||||
removed: DeletedFeature[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Revisions = Record<string, RevisionDelta>;
|
|
||||||
|
|
||||||
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.updated) {
|
|
||||||
removedMap.delete(feature.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const feature of last.removed) {
|
|
||||||
updatedMap.delete(feature.name);
|
|
||||||
removedMap.set(feature.name, feature);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
requiredRevisionId: number,
|
||||||
projects: string[],
|
projects: string[],
|
||||||
|
namePrefix: string,
|
||||||
) => {
|
) => {
|
||||||
const targetedRevisions = revisions.filter(
|
const targetedEvents = events.filter(
|
||||||
(revision) => revision.revisionId > requiredRevisionId,
|
(revision) => revision.eventId > requiredRevisionId,
|
||||||
);
|
);
|
||||||
const projectFeatureRevisions = targetedRevisions.map((revision) =>
|
const allProjects = projects.includes('*');
|
||||||
filterRevisionByProject(revision, projects),
|
const startsWithPrefix = (revision: DeltaEvent) => {
|
||||||
|
return (
|
||||||
|
(isDeltaFeatureUpdatedEvent(revision) &&
|
||||||
|
revision.feature.name.startsWith(namePrefix)) ||
|
||||||
|
(isDeltaFeatureRemovedEvent(revision) &&
|
||||||
|
revision.featureName.startsWith(namePrefix))
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return projectFeatureRevisions.reduce(applyRevision);
|
const isInProject = (revision: DeltaEvent) => {
|
||||||
|
return (
|
||||||
|
(isDeltaFeatureUpdatedEvent(revision) &&
|
||||||
|
projects.includes(revision.feature.project!)) ||
|
||||||
|
(isDeltaFeatureRemovedEvent(revision) &&
|
||||||
|
projects.includes(revision.project))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return targetedEvents.filter((revision) => {
|
||||||
|
return (
|
||||||
|
startsWithPrefix(revision) && (allProjects || isInProject(revision))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterHydrationEventByQuery = (
|
||||||
|
event: DeltaHydrationEvent,
|
||||||
|
projects: string[],
|
||||||
|
namePrefix: string,
|
||||||
|
): DeltaHydrationEvent => {
|
||||||
|
const allProjects = projects.includes('*');
|
||||||
|
const { type, features, eventId } = event;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId,
|
||||||
|
type,
|
||||||
|
features: features.filter((feature) => {
|
||||||
|
return (
|
||||||
|
feature.name.startsWith(namePrefix) &&
|
||||||
|
(allProjects || projects.includes(feature.project!))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ClientFeatureToggleDelta {
|
export class ClientFeatureToggleDelta {
|
||||||
private clientFeatureToggleDeltaReadModel: IClientFeatureToggleDeltaReadModel;
|
private clientFeatureToggleDeltaReadModel: IClientFeatureToggleDeltaReadModel;
|
||||||
|
|
||||||
private delta: Revisions = {};
|
private delta: EnvironmentRevisions = {};
|
||||||
|
|
||||||
private segments: IClientSegment[];
|
private segments: IClientSegment[];
|
||||||
|
|
||||||
@ -159,7 +142,8 @@ export class ClientFeatureToggleDelta {
|
|||||||
): Promise<ClientFeaturesDeltaSchema | undefined> {
|
): Promise<ClientFeaturesDeltaSchema | undefined> {
|
||||||
const projects = query.project ? query.project : ['*'];
|
const projects = query.project ? query.project : ['*'];
|
||||||
const environment = query.environment ? query.environment : 'default';
|
const environment = query.environment ? query.environment : 'default';
|
||||||
// TODO: filter by tags, what is namePrefix? anything else?
|
const namePrefix = query.namePrefix ? query.namePrefix : '';
|
||||||
|
|
||||||
const requiredRevisionId = sdkRevisionId || 0;
|
const requiredRevisionId = sdkRevisionId || 0;
|
||||||
|
|
||||||
const hasDelta = this.delta[environment] !== undefined;
|
const hasDelta = this.delta[environment] !== undefined;
|
||||||
@ -171,26 +155,48 @@ export class ClientFeatureToggleDelta {
|
|||||||
if (!hasSegments) {
|
if (!hasSegments) {
|
||||||
await this.updateSegments();
|
await this.updateSegments();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiredRevisionId >= this.currentRevisionId) {
|
if (requiredRevisionId >= this.currentRevisionId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
if (requiredRevisionId === 0) {
|
||||||
const environmentRevisions = this.delta[environment].getRevisions();
|
const hydrationEvent = this.delta[environment].getHydrationEvent();
|
||||||
|
const filteredEvent = filterHydrationEventByQuery(
|
||||||
const compressedRevision = calculateRequiredClientRevision(
|
hydrationEvent,
|
||||||
environmentRevisions,
|
|
||||||
requiredRevisionId,
|
|
||||||
projects,
|
projects,
|
||||||
|
namePrefix,
|
||||||
);
|
);
|
||||||
|
|
||||||
const revisionResponse: ClientFeaturesDeltaSchema = {
|
const response: ClientFeaturesDeltaSchema = {
|
||||||
...compressedRevision,
|
events: [
|
||||||
|
{
|
||||||
|
...filteredEvent,
|
||||||
segments: this.segments,
|
segments: this.segments,
|
||||||
removed: compressedRevision.removed.map((feature) => feature.name),
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return Promise.resolve(revisionResponse);
|
return Promise.resolve(response);
|
||||||
|
} else {
|
||||||
|
const environmentEvents = this.delta[environment].getEvents();
|
||||||
|
const events = filterEventsByQuery(
|
||||||
|
environmentEvents,
|
||||||
|
requiredRevisionId,
|
||||||
|
projects,
|
||||||
|
namePrefix,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ClientFeaturesDeltaSchema = {
|
||||||
|
events: events.map((event) => {
|
||||||
|
if (event.type === 'feature-removed') {
|
||||||
|
const { project, ...rest } = event;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Promise.resolve(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onUpdateRevisionEvent() {
|
public async onUpdateRevisionEvent() {
|
||||||
@ -220,7 +226,7 @@ export class ClientFeatureToggleDelta {
|
|||||||
latestRevision,
|
latestRevision,
|
||||||
);
|
);
|
||||||
|
|
||||||
const changedToggles = [
|
const featuresUpdated = [
|
||||||
...new Set(
|
...new Set(
|
||||||
changeEvents
|
changeEvents
|
||||||
.filter((event) => event.featureName)
|
.filter((event) => event.featureName)
|
||||||
@ -230,25 +236,49 @@ export class ClientFeatureToggleDelta {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
const removed = changeEvents
|
const featuresRemovedEvents: DeltaEvent[] = changeEvents
|
||||||
.filter((event) => event.featureName && event.project)
|
.filter((event) => event.featureName && event.project)
|
||||||
.filter((event) => event.type === 'feature-archived')
|
.filter((event) => event.type === 'feature-archived')
|
||||||
.map((event) => ({
|
.map((event) => ({
|
||||||
name: event.featureName!,
|
eventId: latestRevision,
|
||||||
|
type: DELTA_EVENT_TYPES.FEATURE_REMOVED,
|
||||||
|
featureName: event.featureName!,
|
||||||
project: event.project!,
|
project: event.project!,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// TODO: implement single segment fetching
|
||||||
|
// const segmentsUpdated = changeEvents
|
||||||
|
// .filter((event) => event.type === 'segment-updated')
|
||||||
|
// .map((event) => ({
|
||||||
|
// name: event.featureName!,
|
||||||
|
// project: event.project!,
|
||||||
|
// }));
|
||||||
|
//
|
||||||
|
// const segmentsRemoved = changeEvents
|
||||||
|
// .filter((event) => event.type === 'segment-deleted')
|
||||||
|
// .map((event) => ({
|
||||||
|
// name: event.featureName!,
|
||||||
|
// project: event.project!,
|
||||||
|
// }));
|
||||||
|
//
|
||||||
|
|
||||||
// TODO: we might want to only update the environments that had events changed for performance
|
// TODO: we might want to only update the environments that had events changed for performance
|
||||||
for (const environment of keys) {
|
for (const environment of keys) {
|
||||||
const newToggles = await this.getChangedToggles(
|
const newToggles = await this.getChangedToggles(
|
||||||
environment,
|
environment,
|
||||||
changedToggles,
|
featuresUpdated,
|
||||||
);
|
);
|
||||||
this.delta[environment].addRevision({
|
const featuresUpdatedEvents: DeltaEvent[] = newToggles.map(
|
||||||
updated: newToggles,
|
(toggle) => ({
|
||||||
revisionId: latestRevision,
|
eventId: latestRevision,
|
||||||
removed,
|
type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
|
||||||
});
|
feature: toggle,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.delta[environment].addEvents([
|
||||||
|
...featuresUpdatedEvents,
|
||||||
|
...featuresRemovedEvents,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
this.currentRevisionId = latestRevision;
|
this.currentRevisionId = latestRevision;
|
||||||
}
|
}
|
||||||
@ -257,11 +287,10 @@ export class ClientFeatureToggleDelta {
|
|||||||
environment: string,
|
environment: string,
|
||||||
toggles: string[],
|
toggles: string[],
|
||||||
): Promise<FeatureConfigurationDeltaClient[]> {
|
): Promise<FeatureConfigurationDeltaClient[]> {
|
||||||
const foundToggles = await this.getClientFeatures({
|
return this.getClientFeatures({
|
||||||
toggleNames: toggles,
|
toggleNames: toggles,
|
||||||
environment,
|
environment,
|
||||||
});
|
});
|
||||||
return foundToggles;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initEnvironmentDelta(environment: string) {
|
public async initEnvironmentDelta(environment: string) {
|
||||||
@ -273,14 +302,11 @@ export class ClientFeatureToggleDelta {
|
|||||||
this.currentRevisionId =
|
this.currentRevisionId =
|
||||||
await this.configurationRevisionService.getMaxRevisionId();
|
await this.configurationRevisionService.getMaxRevisionId();
|
||||||
|
|
||||||
const delta = new RevisionDelta([
|
this.delta[environment] = new DeltaCache({
|
||||||
{
|
eventId: this.currentRevisionId,
|
||||||
revisionId: this.currentRevisionId,
|
type: DELTA_EVENT_TYPES.HYDRATION,
|
||||||
updated: baseFeatures,
|
features: baseFeatures,
|
||||||
removed: [],
|
});
|
||||||
},
|
|
||||||
]);
|
|
||||||
this.delta[environment] = delta;
|
|
||||||
|
|
||||||
this.storeFootprint();
|
this.storeFootprint();
|
||||||
}
|
}
|
||||||
@ -313,3 +339,5 @@ export class ClientFeatureToggleDelta {
|
|||||||
return Buffer.byteLength(jsonString, 'utf8');
|
return Buffer.byteLength(jsonString, 'utf8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { DeltaEvent };
|
||||||
|
@ -0,0 +1,141 @@
|
|||||||
|
import { DeltaCache } from './delta-cache';
|
||||||
|
import type {
|
||||||
|
DeltaEvent,
|
||||||
|
DeltaHydrationEvent,
|
||||||
|
} from './client-feature-toggle-delta-types';
|
||||||
|
|
||||||
|
describe('RevisionCache', () => {
|
||||||
|
it('should always update the hydration event and remove event when over limit', () => {
|
||||||
|
const baseEvent: DeltaHydrationEvent = {
|
||||||
|
eventId: 1,
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'hydration',
|
||||||
|
};
|
||||||
|
const initialEvents: DeltaEvent[] = [
|
||||||
|
{
|
||||||
|
eventId: 2,
|
||||||
|
feature: {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
type: 'feature-updated',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxLength = 2;
|
||||||
|
const deltaCache = new DeltaCache(baseEvent, maxLength);
|
||||||
|
deltaCache.addEvents(initialEvents);
|
||||||
|
|
||||||
|
// Add a new revision to trigger changeBase
|
||||||
|
deltaCache.addEvents([
|
||||||
|
{
|
||||||
|
eventId: 3,
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: {
|
||||||
|
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: [],
|
||||||
|
description: null,
|
||||||
|
impressionData: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 4,
|
||||||
|
type: 'feature-removed',
|
||||||
|
featureName: 'test-flag',
|
||||||
|
project: 'default',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const events = deltaCache.getEvents();
|
||||||
|
|
||||||
|
// Check that the base has been changed and merged correctly
|
||||||
|
expect(events.length).toBe(2);
|
||||||
|
|
||||||
|
const hydrationEvent = deltaCache.getHydrationEvent();
|
||||||
|
expect(hydrationEvent.features).toHaveLength(2);
|
||||||
|
expect(hydrationEvent.features).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'my-feature-flag' }),
|
||||||
|
expect.objectContaining({ name: 'another-feature-flag' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
77
src/lib/features/client-feature-toggles/delta/delta-cache.ts
Normal file
77
src/lib/features/client-feature-toggles/delta/delta-cache.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import type { DeltaEvent } from './client-feature-toggle-delta';
|
||||||
|
import {
|
||||||
|
DELTA_EVENT_TYPES,
|
||||||
|
type DeltaHydrationEvent,
|
||||||
|
} from './client-feature-toggle-delta-types';
|
||||||
|
|
||||||
|
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 DeltaCache {
|
||||||
|
private events: DeltaEvent[] = [];
|
||||||
|
private maxLength: number;
|
||||||
|
private hydrationEvent: DeltaHydrationEvent;
|
||||||
|
|
||||||
|
constructor(hydrationEvent: DeltaHydrationEvent, maxLength: number = 20) {
|
||||||
|
this.hydrationEvent = hydrationEvent;
|
||||||
|
this.maxLength = maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addEvents(events: DeltaEvent[]): void {
|
||||||
|
this.events = [...this.events, ...events];
|
||||||
|
|
||||||
|
this.updateHydrationEvent(events);
|
||||||
|
while (this.events.length > this.maxLength) {
|
||||||
|
this.events.splice(1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEvents(): DeltaEvent[] {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHydrationEvent(): DeltaHydrationEvent {
|
||||||
|
return this.hydrationEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateHydrationEvent(events: DeltaEvent[]): void {
|
||||||
|
for (const appliedEvent of events) {
|
||||||
|
switch (appliedEvent.type) {
|
||||||
|
case DELTA_EVENT_TYPES.FEATURE_UPDATED: {
|
||||||
|
const featureToUpdate = this.hydrationEvent.features.find(
|
||||||
|
(feature) => feature.name === appliedEvent.feature.name,
|
||||||
|
);
|
||||||
|
if (featureToUpdate) {
|
||||||
|
Object.assign(featureToUpdate, appliedEvent.feature);
|
||||||
|
} else {
|
||||||
|
this.hydrationEvent.features.push(appliedEvent.feature);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DELTA_EVENT_TYPES.FEATURE_REMOVED: {
|
||||||
|
this.hydrationEvent.features =
|
||||||
|
this.hydrationEvent.features.filter(
|
||||||
|
(feature) =>
|
||||||
|
feature.name !== appliedEvent.featureName,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DELTA_EVENT_TYPES.SEGMENT_UPDATED: {
|
||||||
|
// TODO: segments do not exist in this scope, need to do it in different location
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DELTA_EVENT_TYPES.SEGMENT_REMOVED: {
|
||||||
|
// TODO: segments do not exist in this scope, need to do it in different location
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// TODO: something is seriously wrong
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,110 +0,0 @@
|
|||||||
import { RevisionDelta } from './revision-delta';
|
|
||||||
import type { Revision } from './client-feature-toggle-delta';
|
|
||||||
|
|
||||||
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 RevisionDelta(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' }),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,48 +0,0 @@
|
|||||||
import type { Revision } from './client-feature-toggle-delta';
|
|
||||||
|
|
||||||
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 RevisionDelta {
|
|
||||||
private delta: Revision[];
|
|
||||||
private maxLength: number;
|
|
||||||
|
|
||||||
constructor(data: Revision[] = [], maxLength: number = 20) {
|
|
||||||
this.delta = data;
|
|
||||||
this.maxLength = maxLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
public addRevision(revision: Revision): void {
|
|
||||||
if (this.delta.length >= this.maxLength) {
|
|
||||||
this.changeBase();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.delta = [...this.delta, revision];
|
|
||||||
}
|
|
||||||
|
|
||||||
public getRevisions(): Revision[] {
|
|
||||||
return this.delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
public hasRevision(revisionId: number): boolean {
|
|
||||||
return this.delta.some(
|
|
||||||
(revision) => revision.revisionId === revisionId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private changeBase(): void {
|
|
||||||
if (!(this.delta.length >= 2)) return;
|
|
||||||
const base = this.delta[0];
|
|
||||||
const newBase = this.delta[1];
|
|
||||||
|
|
||||||
newBase.removed = mergeWithoutDuplicates(base.removed, newBase.removed);
|
|
||||||
newBase.updated = mergeWithoutDuplicates(base.updated, newBase.updated);
|
|
||||||
|
|
||||||
this.delta = [newBase, ...this.delta.slice(2)];
|
|
||||||
}
|
|
||||||
}
|
|
@ -3,9 +3,16 @@ import type { ClientFeaturesDeltaSchema } from './client-features-delta-schema';
|
|||||||
|
|
||||||
test('clientFeaturesDeltaSchema all fields', () => {
|
test('clientFeaturesDeltaSchema all fields', () => {
|
||||||
const data: ClientFeaturesDeltaSchema = {
|
const data: ClientFeaturesDeltaSchema = {
|
||||||
revisionId: 6,
|
events: [
|
||||||
updated: [
|
|
||||||
{
|
{
|
||||||
|
eventId: 1,
|
||||||
|
type: 'feature-removed',
|
||||||
|
featureName: 'removed-event',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 1,
|
||||||
|
type: 'feature-updated',
|
||||||
|
feature: {
|
||||||
impressionData: false,
|
impressionData: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
name: 'base_feature',
|
name: 'base_feature',
|
||||||
@ -16,9 +23,22 @@ test('clientFeaturesDeltaSchema all fields', () => {
|
|||||||
variants: [],
|
variants: [],
|
||||||
strategies: [],
|
strategies: [],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 1,
|
||||||
|
type: 'segment-removed',
|
||||||
|
segmentId: 33,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 1,
|
||||||
|
type: 'segment-updated',
|
||||||
|
segment: {
|
||||||
|
id: 3,
|
||||||
|
name: 'hello',
|
||||||
|
constraints: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
removed: [],
|
|
||||||
segments: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
@ -13,36 +13,79 @@ import { dependentFeatureSchema } from './dependent-feature-schema';
|
|||||||
export const clientFeaturesDeltaSchema = {
|
export const clientFeaturesDeltaSchema = {
|
||||||
$id: '#/components/schemas/clientFeaturesDeltaSchema',
|
$id: '#/components/schemas/clientFeaturesDeltaSchema',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['updated', 'revisionId', 'removed'],
|
required: ['events'],
|
||||||
description: 'Schema for delta updates of feature configurations.',
|
description: 'Schema for delta updates of feature configurations.',
|
||||||
properties: {
|
properties: {
|
||||||
updated: {
|
events: {
|
||||||
description: 'A list of updated feature configurations.',
|
description: 'A list of delta events.',
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
required: ['eventId', 'type', 'feature'],
|
||||||
|
properties: {
|
||||||
|
eventId: { type: 'number' },
|
||||||
|
type: { type: 'string', enum: ['feature-updated'] },
|
||||||
|
feature: {
|
||||||
|
$ref: '#/components/schemas/clientFeatureSchema',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
required: ['eventId', 'type', 'featureName'],
|
||||||
|
properties: {
|
||||||
|
eventId: { type: 'number' },
|
||||||
|
type: { type: 'string', enum: ['feature-removed'] },
|
||||||
|
featureName: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
required: ['eventId', 'type', 'segment'],
|
||||||
|
properties: {
|
||||||
|
eventId: { type: 'number' },
|
||||||
|
type: { type: 'string', enum: ['segment-updated'] },
|
||||||
|
segment: {
|
||||||
|
$ref: '#/components/schemas/clientSegmentSchema',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
required: ['eventId', 'type', 'segmentId'],
|
||||||
|
properties: {
|
||||||
|
eventId: { type: 'number' },
|
||||||
|
type: { type: 'string', enum: ['segment-removed'] },
|
||||||
|
segmentId: { type: 'number' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
required: ['type', 'features', 'segments', 'eventId'],
|
||||||
|
properties: {
|
||||||
|
eventId: { type: 'number' },
|
||||||
|
type: { type: 'string', enum: ['hydration'] },
|
||||||
|
features: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
$ref: '#/components/schemas/clientFeatureSchema',
|
$ref: '#/components/schemas/clientFeatureSchema',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
revisionId: {
|
|
||||||
type: 'number',
|
|
||||||
description: 'The revision ID of the delta update.',
|
|
||||||
},
|
|
||||||
removed: {
|
|
||||||
description: 'A list of feature names that were removed.',
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
segments: {
|
segments: {
|
||||||
description:
|
|
||||||
'A list of [Segments](https://docs.getunleash.io/reference/segments) configured for this Unleash instance',
|
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
$ref: '#/components/schemas/clientSegmentSchema',
|
$ref: '#/components/schemas/clientSegmentSchema',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
constraintSchema,
|
constraintSchema,
|
||||||
|
Loading…
Reference in New Issue
Block a user