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