diff --git a/src/lib/features/client-feature-toggles/client-feature-toggle.controller.ts b/src/lib/features/client-feature-toggles/client-feature-toggle.controller.ts index eed4eedea2..d3c22579ea 100644 --- a/src/lib/features/client-feature-toggles/client-feature-toggle.controller.ts +++ b/src/lib/features/client-feature-toggles/client-feature-toggle.controller.ts @@ -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); diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts b/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts index f158d5c7ac..d678a0bb2f 100644 --- a/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-delta-api.e2e.test.ts @@ -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'], }); }); diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts index 9c61ec2e5b..74760aee54 100644 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-controller.ts @@ -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, diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-types.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-types.ts new file mode 100644 index 0000000000..d3251baf34 --- /dev/null +++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta-types.ts @@ -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 => { + return event.type === DELTA_EVENT_TYPES.FEATURE_UPDATED; +}; + +export const isDeltaFeatureRemovedEvent = ( + event: DeltaEvent, +): event is Extract => { + return event.type === DELTA_EVENT_TYPES.FEATURE_REMOVED; +}; + +export const isDeltaSegmentUpdatedEvent = ( + event: DeltaEvent, +): event is Extract => { + return event.type === DELTA_EVENT_TYPES.SEGMENT_UPDATED; +}; + +export const isDeltaSegmentRemovedEvent = ( + event: DeltaEvent, +): event is Extract => { + return event.type === DELTA_EVENT_TYPES.SEGMENT_REMOVED; +}; diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.test.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.test.ts index e9e597a504..40400da0d3 100644 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.test.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.test.ts @@ -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' })], }); }); diff --git a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts index 22d305b395..991b0bf857 100644 --- a/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts +++ b/src/lib/features/client-feature-toggles/delta/client-feature-toggle-delta.ts @@ -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; -export type RevisionDeltaEntry = { - updated: FeatureConfigurationDeltaClient[]; - revisionId: number; - removed: DeletedFeature[]; - segments: IClientSegment[]; -}; - -export type Revision = { - revisionId: number; - updated: any[]; - removed: DeletedFeature[]; -}; - -type Revisions = Record; - -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 { 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 { - 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 }; diff --git a/src/lib/features/client-feature-toggles/delta/delta-cache.test.ts b/src/lib/features/client-feature-toggles/delta/delta-cache.test.ts new file mode 100644 index 0000000000..a7cdfd856e --- /dev/null +++ b/src/lib/features/client-feature-toggles/delta/delta-cache.test.ts @@ -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' }), + ]), + ); + }); +}); diff --git a/src/lib/features/client-feature-toggles/delta/delta-cache.ts b/src/lib/features/client-feature-toggles/delta/delta-cache.ts new file mode 100644 index 0000000000..02fab7eb9d --- /dev/null +++ b/src/lib/features/client-feature-toggles/delta/delta-cache.ts @@ -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 + } + } + } +} diff --git a/src/lib/features/client-feature-toggles/delta/revision-delta.test.ts b/src/lib/features/client-feature-toggles/delta/revision-delta.test.ts deleted file mode 100644 index beacc06332..0000000000 --- a/src/lib/features/client-feature-toggles/delta/revision-delta.test.ts +++ /dev/null @@ -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' }), - ]), - ); - }); -}); diff --git a/src/lib/features/client-feature-toggles/delta/revision-delta.ts b/src/lib/features/client-feature-toggles/delta/revision-delta.ts deleted file mode 100644 index 112bd3de60..0000000000 --- a/src/lib/features/client-feature-toggles/delta/revision-delta.ts +++ /dev/null @@ -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)]; - } -} diff --git a/src/lib/openapi/spec/client-features-delta-schema.test.ts b/src/lib/openapi/spec/client-features-delta-schema.test.ts index 0510137771..3a985a208b 100644 --- a/src/lib/openapi/spec/client-features-delta-schema.test.ts +++ b/src/lib/openapi/spec/client-features-delta-schema.test.ts @@ -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( diff --git a/src/lib/openapi/spec/client-features-delta-schema.ts b/src/lib/openapi/spec/client-features-delta-schema.ts index 8effe5579c..6069f8e224 100644 --- a/src/lib/openapi/spec/client-features-delta-schema.ts +++ b/src/lib/openapi/spec/client-features-delta-schema.ts @@ -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', + }, + }, + }, + }, + ], }, }, },