1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +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:
Jaanus Sellin 2025-01-22 13:57:42 +02:00 committed by GitHub
parent 4bbff0c554
commit 280710f22a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 672 additions and 482 deletions

View File

@ -41,6 +41,7 @@ import {
} from '../../internals';
import isEqual from 'lodash.isequal';
import { diff } from 'json-diff';
import type { DeltaHydrationEvent } from './delta/client-feature-toggle-delta-types';
const version = 2;
@ -191,22 +192,34 @@ export default class FeatureController extends Controller {
const sortedToggles = features.sort((a, b) =>
a.name.localeCompare(b.name),
);
const sortedNewToggles = delta?.updated.sort((a, b) =>
a.name.localeCompare(b.name),
);
if (delta?.events[0].type === 'hydration') {
const hydrationEvent: DeltaHydrationEvent =
delta?.events[0];
const sortedNewToggles = hydrationEvent.features.sort(
(a, b) => a.name.localeCompare(b.name),
);
if (
!this.deepEqualIgnoreOrder(sortedToggles, sortedNewToggles)
) {
this.logger.warn(
`old features and new features are different. Old count ${
features.length
}, new count ${delta?.updated.length}, query ${JSON.stringify(query)},
if (
!this.deepEqualIgnoreOrder(
sortedToggles,
sortedNewToggles,
)
) {
this.logger.warn(
`old features and new features are different. Old count ${
features.length
}, new count ${hydrationEvent.features.length}, query ${JSON.stringify(query)},
diff ${JSON.stringify(
diff(sortedToggles, sortedNewToggles),
)}`,
);
}
} else {
this.logger.warn(
`Delta diff should have only hydration event, query ${JSON.stringify(query)}`,
);
}
this.storeFootprint();
} catch (e) {
this.logger.error('Delta diff failed', e);

View File

@ -37,7 +37,11 @@ const setupFeatures = async (
{
name: 'default',
constraints: [
{ contextName: 'userId', operator: 'IN', values: ['123'] },
{
contextName: 'userId',
operator: 'IN',
values: ['123'],
},
],
parameters: {},
},
@ -88,17 +92,19 @@ test('should match with /api/client/delta', async () => {
.expect('Content-Type', /json/)
.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 () => {
await setupFeatures(db, app);
const { body } = await app.request.get('/api/client/delta').expect(200);
const currentRevisionId = body.revisionId;
const { body, headers } = await app.request
.get('/api/client/delta')
.expect(200);
const etag = headers.etag;
await app.request
.set('If-None-Match', `"${currentRevisionId}"`)
.set('If-None-Match', etag)
.get('/api/client/delta')
.expect(304);
});
@ -106,13 +112,21 @@ test('should get 304 if asked for latest revision', async () => {
test('should return correct delta after feature created', async () => {
await app.createFeature('base_feature');
await syncRevisions();
const { body } = await app.request.get('/api/client/delta').expect(200);
const currentRevisionId = body.revisionId;
const { body, headers } = await app.request
.set('If-None-Match', null)
.get('/api/client/delta')
.expect(200);
const etag = headers.etag;
expect(body).toMatchObject({
updated: [
events: [
{
name: 'base_feature',
type: 'hydration',
features: [
{
name: 'base_feature',
},
],
},
],
});
@ -120,16 +134,27 @@ test('should return correct delta after feature created', async () => {
await app.createFeature('new_feature');
await syncRevisions();
//@ts-ignore
await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
const { body: deltaBody } = await app.request
.get('/api/client/delta')
.set('If-None-Match', `"${currentRevisionId}"`)
.set('If-None-Match', etag)
.expect(200);
expect(deltaBody).toMatchObject({
updated: [
events: [
{
name: 'new_feature',
type: 'feature-updated',
feature: {
name: 'new_feature',
},
},
{
type: 'feature-updated',
feature: {
name: 'new_feature',
},
},
],
});
@ -137,40 +162,52 @@ test('should return correct delta after feature created', async () => {
const syncRevisions = async () => {
await app.services.configurationRevisionService.updateMaxRevisionId();
// @ts-ignore
await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
};
test('archived features should not be returned as updated', async () => {
await app.createFeature('base_feature');
await syncRevisions();
const { body } = await app.request.get('/api/client/delta').expect(200);
const currentRevisionId = body.revisionId;
const { body, headers } = await app.request
.get('/api/client/delta')
.expect(200);
const etag = headers.etag;
expect(body).toMatchObject({
updated: [
events: [
{
name: 'base_feature',
features: [
{
name: 'base_feature',
},
],
},
],
});
await app.archiveFeature('base_feature');
await syncRevisions();
await app.createFeature('new_feature');
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
.get('/api/client/delta')
.set('If-None-Match', `"${currentRevisionId}"`)
.set('If-None-Match', etag)
.expect(200);
expect(deltaBody).toMatchObject({
updated: [
events: [
{
name: 'new_feature',
type: 'feature-removed',
featureName: 'base_feature',
},
{
type: 'feature-updated',
feature: {
name: 'new_feature',
},
},
],
removed: ['base_feature'],
});
});

View File

@ -103,15 +103,16 @@ export default class ClientFeatureToggleDeltaController extends Controller {
res.end();
return;
}
if (changedFeatures.revisionId === currentSdkRevisionId) {
const lastEventId =
changedFeatures.events[changedFeatures.events.length - 1].eventId;
if (lastEventId === currentSdkRevisionId) {
res.status(304);
res.getHeaderNames().forEach((header) => res.removeHeader(header));
res.end();
return;
}
res.setHeader('ETag', `"${changedFeatures.revisionId}"`);
res.setHeader('ETag', `"${lastEventId}"`);
this.openApiService.respondWithValidation(
200,
res,

View File

@ -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;
};

View File

@ -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 base = {
@ -16,166 +21,86 @@ const mockAdd = (params): any => {
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 = [
const revisionList: DeltaEvent[] = [
{
revisionId: 1,
updated: [
mockAdd({ name: 'feature1' }),
mockAdd({ name: 'feature2' }),
mockAdd({ name: 'feature3' }),
],
removed: [],
eventId: 2,
type: 'feature-updated',
feature: mockAdd({ name: 'feature4' }),
},
{
revisionId: 2,
updated: [mockAdd({ name: 'feature4' })],
removed: [],
},
{
revisionId: 3,
updated: [mockAdd({ name: 'feature5' })],
removed: [],
eventId: 3,
type: 'feature-updated',
feature: mockAdd({ name: 'feature5' }),
},
];
const revisions = calculateRequiredClientRevision(revisionList, 1, [
'default',
]);
const revisions = filterEventsByQuery(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 = [
expect(revisions).toEqual([
{
revisionId: 1,
updated: [mockAdd({ name: 'feature1', project: 'project1' })],
removed: [],
eventId: 2,
type: 'feature-updated',
feature: mockAdd({ name: 'feature4' }),
},
{
revisionId: 2,
updated: [mockAdd({ name: 'feature2', project: 'project2' })],
removed: [],
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 = calculateRequiredClientRevision(revisionList, 0, [
'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' }),
],
};
const revisions = filterHydrationEventByQuery(
revisionList,
['project2'],
'my',
);
expect(revisions).toEqual({
revisionId: 2,
updated: [mockAdd({ name: 'feature1', project: 'project1' })],
removed: [],
eventId: 1,
type: 'hydration',
features: [mockAdd({ name: 'myfeature2', project: 'project2' })],
});
});

View File

@ -9,7 +9,7 @@ import type {
} from '../../../types';
import type ConfigurationRevisionService 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 {
FeatureConfigurationDeltaClient,
IClientFeatureToggleDeltaReadModel,
@ -18,92 +18,75 @@ import { CLIENT_DELTA_MEMORY } from '../../../metric-events';
import type EventEmitter from 'events';
import type { Logger } from '../../../logger';
import type { ClientFeaturesDeltaSchema } from '../../../openapi';
import {
DELTA_EVENT_TYPES,
type DeltaEvent,
type DeltaHydrationEvent,
isDeltaFeatureRemovedEvent,
isDeltaFeatureUpdatedEvent,
} from './client-feature-toggle-delta-types';
type DeletedFeature = {
name: string;
project: string;
};
type EnvironmentRevisions = Record<string, DeltaCache>;
export type RevisionDeltaEntry = {
updated: FeatureConfigurationDeltaClient[];
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[],
export const filterEventsByQuery = (
events: DeltaEvent[],
requiredRevisionId: number,
projects: string[],
namePrefix: string,
) => {
const targetedRevisions = revisions.filter(
(revision) => revision.revisionId > requiredRevisionId,
);
const projectFeatureRevisions = targetedRevisions.map((revision) =>
filterRevisionByProject(revision, projects),
const targetedEvents = events.filter(
(revision) => revision.eventId > requiredRevisionId,
);
const allProjects = projects.includes('*');
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 {
private clientFeatureToggleDeltaReadModel: IClientFeatureToggleDeltaReadModel;
private delta: Revisions = {};
private delta: EnvironmentRevisions = {};
private segments: IClientSegment[];
@ -159,7 +142,8 @@ export class ClientFeatureToggleDelta {
): Promise<ClientFeaturesDeltaSchema | undefined> {
const projects = query.project ? query.project : ['*'];
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 hasDelta = this.delta[environment] !== undefined;
@ -171,26 +155,48 @@ export class ClientFeatureToggleDelta {
if (!hasSegments) {
await this.updateSegments();
}
if (requiredRevisionId >= this.currentRevisionId) {
return undefined;
}
if (requiredRevisionId === 0) {
const hydrationEvent = this.delta[environment].getHydrationEvent();
const filteredEvent = filterHydrationEventByQuery(
hydrationEvent,
projects,
namePrefix,
);
const environmentRevisions = this.delta[environment].getRevisions();
const response: ClientFeaturesDeltaSchema = {
events: [
{
...filteredEvent,
segments: this.segments,
},
],
};
const compressedRevision = calculateRequiredClientRevision(
environmentRevisions,
requiredRevisionId,
projects,
);
return Promise.resolve(response);
} else {
const environmentEvents = this.delta[environment].getEvents();
const events = filterEventsByQuery(
environmentEvents,
requiredRevisionId,
projects,
namePrefix,
);
const revisionResponse: ClientFeaturesDeltaSchema = {
...compressedRevision,
segments: this.segments,
removed: compressedRevision.removed.map((feature) => feature.name),
};
const response: ClientFeaturesDeltaSchema = {
events: events.map((event) => {
if (event.type === 'feature-removed') {
const { project, ...rest } = event;
return rest;
}
return event;
}),
};
return Promise.resolve(revisionResponse);
return Promise.resolve(response);
}
}
public async onUpdateRevisionEvent() {
@ -220,7 +226,7 @@ export class ClientFeatureToggleDelta {
latestRevision,
);
const changedToggles = [
const featuresUpdated = [
...new Set(
changeEvents
.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.type === 'feature-archived')
.map((event) => ({
name: event.featureName!,
eventId: latestRevision,
type: DELTA_EVENT_TYPES.FEATURE_REMOVED,
featureName: event.featureName!,
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
for (const environment of keys) {
const newToggles = await this.getChangedToggles(
environment,
changedToggles,
featuresUpdated,
);
this.delta[environment].addRevision({
updated: newToggles,
revisionId: latestRevision,
removed,
});
const featuresUpdatedEvents: DeltaEvent[] = newToggles.map(
(toggle) => ({
eventId: latestRevision,
type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
feature: toggle,
}),
);
this.delta[environment].addEvents([
...featuresUpdatedEvents,
...featuresRemovedEvents,
]);
}
this.currentRevisionId = latestRevision;
}
@ -257,11 +287,10 @@ export class ClientFeatureToggleDelta {
environment: string,
toggles: string[],
): Promise<FeatureConfigurationDeltaClient[]> {
const foundToggles = await this.getClientFeatures({
return this.getClientFeatures({
toggleNames: toggles,
environment,
});
return foundToggles;
}
public async initEnvironmentDelta(environment: string) {
@ -273,14 +302,11 @@ export class ClientFeatureToggleDelta {
this.currentRevisionId =
await this.configurationRevisionService.getMaxRevisionId();
const delta = new RevisionDelta([
{
revisionId: this.currentRevisionId,
updated: baseFeatures,
removed: [],
},
]);
this.delta[environment] = delta;
this.delta[environment] = new DeltaCache({
eventId: this.currentRevisionId,
type: DELTA_EVENT_TYPES.HYDRATION,
features: baseFeatures,
});
this.storeFootprint();
}
@ -313,3 +339,5 @@ export class ClientFeatureToggleDelta {
return Buffer.byteLength(jsonString, 'utf8');
}
}
export type { DeltaEvent };

View File

@ -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' }),
]),
);
});
});

View 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
}
}
}
}

View File

@ -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' }),
]),
);
});
});

View File

@ -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)];
}
}

View File

@ -3,22 +3,42 @@ import type { ClientFeaturesDeltaSchema } from './client-features-delta-schema';
test('clientFeaturesDeltaSchema all fields', () => {
const data: ClientFeaturesDeltaSchema = {
revisionId: 6,
updated: [
events: [
{
impressionData: false,
enabled: false,
name: 'base_feature',
description: null,
project: 'default',
stale: false,
type: 'release',
variants: [],
strategies: [],
eventId: 1,
type: 'feature-removed',
featureName: 'removed-event',
},
{
eventId: 1,
type: 'feature-updated',
feature: {
impressionData: false,
enabled: false,
name: 'base_feature',
description: null,
project: 'default',
stale: false,
type: 'release',
variants: [],
strategies: [],
},
},
{
eventId: 1,
type: 'segment-removed',
segmentId: 33,
},
{
eventId: 1,
type: 'segment-updated',
segment: {
id: 3,
name: 'hello',
constraints: [],
},
},
],
removed: [],
segments: [],
};
expect(

View File

@ -13,33 +13,76 @@ import { dependentFeatureSchema } from './dependent-feature-schema';
export const clientFeaturesDeltaSchema = {
$id: '#/components/schemas/clientFeaturesDeltaSchema',
type: 'object',
required: ['updated', 'revisionId', 'removed'],
required: ['events'],
description: 'Schema for delta updates of feature configurations.',
properties: {
updated: {
description: 'A list of updated feature configurations.',
events: {
description: 'A list of delta events.',
type: 'array',
items: {
$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: {
description:
'A list of [Segments](https://docs.getunleash.io/reference/segments) configured for this Unleash instance',
type: 'array',
items: {
$ref: '#/components/schemas/clientSegmentSchema',
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',
items: {
$ref: '#/components/schemas/clientFeatureSchema',
},
},
segments: {
type: 'array',
items: {
$ref: '#/components/schemas/clientSegmentSchema',
},
},
},
},
],
},
},
},