UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

329 lines • 15.6 kB
import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service.js'; import { DeltaCache } from './delta-cache.js'; import { CLIENT_DELTA_MEMORY } from '../../../metric-events.js'; import EventEmitter from 'events'; import { DELTA_EVENT_TYPES, isDeltaFeatureRemovedEvent, isDeltaFeatureUpdatedEvent, isDeltaSegmentEvent, } from './client-feature-toggle-delta-types.js'; import { FEATURE_PROJECT_CHANGE } from '../../../events/index.js'; import { getVisibleRevision } from './visible-revision.js'; import { createGauge } from '../../../util/metrics/index.js'; export const UPDATE_DELTA = 'UPDATE_DELTA'; export const filterEventsByQuery = (events, requestedRevisionId, projects, namePrefix) => { const targetedEvents = events.filter((revision) => revision.eventId > requestedRevisionId); const allProjects = projects.includes('*'); const startsWithPrefix = (revision) => { return ((isDeltaFeatureUpdatedEvent(revision) && revision.feature.name.startsWith(namePrefix)) || (isDeltaFeatureRemovedEvent(revision) && revision.featureName.startsWith(namePrefix))); }; const isInProject = (revision) => { return ((isDeltaFeatureUpdatedEvent(revision) && projects.includes(revision.feature.project)) || (isDeltaFeatureRemovedEvent(revision) && projects.includes(revision.project))); }; return targetedEvents.filter((revision) => { return (isDeltaSegmentEvent(revision) || (startsWithPrefix(revision) && (allProjects || isInProject(revision)))); }); }; export const filterHydrationEventByQuery = (event, projects, namePrefix) => { const allProjects = projects.includes('*'); const { type, features, eventId, segments } = event; return { eventId, type, segments, features: features.filter((feature) => { return (feature.name.startsWith(namePrefix) && (allProjects || projects.includes(feature.project))); }), }; }; const deltaRevisionIdMetric = createGauge({ name: 'delta_environment_revision_id', help: 'Current delta revision id for environment', labelNames: ['environment'], }); const setMaxRevision = (map, key, revisionId) => { const currentRevisionId = map.get(key) ?? 0; if (revisionId > currentRevisionId) { map.set(key, revisionId); } }; export class ClientFeatureToggleDelta extends EventEmitter { constructor(clientFeatureToggleDeltaReadModel, segmentReadModel, eventStore, configurationRevisionService, flagResolver, config) { super(); this.delta = {}; this.visibleRevisions = {}; this.lastDeltaProcessedRevisionId = 0; this.eventStore = eventStore; this.clientFeatureToggleDeltaReadModel = clientFeatureToggleDeltaReadModel; this.flagResolver = flagResolver; this.segmentReadModel = segmentReadModel; this.eventBus = config.eventBus; this.logger = config.getLogger('delta/client-feature-toggle-delta.js'); this.onUpdateRevisionEvent = this.onUpdateRevisionEvent.bind(this); this.delta = {}; // just subscribe to revision update events that are scheduled every second configurationRevisionService.on(UPDATE_REVISION, this.onUpdateRevisionEvent); } static getInstance(clientFeatureToggleDeltaReadModel, segmentReadModel, eventStore, configurationRevisionService, flagResolver, config) { if (!ClientFeatureToggleDelta.instance) { ClientFeatureToggleDelta.instance = new ClientFeatureToggleDelta(clientFeatureToggleDeltaReadModel, segmentReadModel, eventStore, configurationRevisionService, flagResolver, config); } return ClientFeatureToggleDelta.instance; } async getDelta(sdkRevisionId, query) { const projects = query.project && query.project.length > 0 ? query.project : ['*']; const environment = query.environment ? query.environment : 'default'; const namePrefix = query.namePrefix ? query.namePrefix : ''; const hasRequestedRevision = sdkRevisionId !== undefined; const requestedRevisionId = sdkRevisionId ?? 0; if (this.delta[environment] === undefined) { await this.initEnvironmentDelta(environment); } const visibleRevision = this.getVisibleRevision(environment, projects); if (hasRequestedRevision && requestedRevisionId >= visibleRevision) { this.logger.info(`[delta] No new delta for environment=${environment} projects=${projects.join(',')} visibleRevision=${visibleRevision} requestedRevision=${requestedRevisionId}`); return undefined; } const delta = this.delta[environment]; if (!hasRequestedRevision || delta.isMissingRevision(requestedRevisionId)) { const hydrationEvent = delta.getHydrationEvent(); const filteredEvent = filterHydrationEventByQuery(hydrationEvent, projects, namePrefix); const effectiveEventId = visibleRevision === 0 ? hydrationEvent.eventId : visibleRevision; filteredEvent.eventId = effectiveEventId; this.logger.info(`[revision] Fresh delta hydration for environment=${environment} projects=${projects.join(',')} visibleRevision=${visibleRevision} hydrationEventId=${hydrationEvent.eventId} returnedHydrationEventId=${filteredEvent.eventId}`); const response = { events: [filteredEvent], }; return Promise.resolve(response); } else { const environmentEvents = delta.getEvents(); const events = filterEventsByQuery(environmentEvents, requestedRevisionId, projects, namePrefix); if (events.length === 0) { return undefined; } return { events, }; } } async onUpdateRevisionEvent() { if (this.flagResolver.isEnabled('deltaApi')) { await this.updateFeaturesDelta(); this.storeFootprint(); this.emit(UPDATE_DELTA); } } /** * This is used in client-feature-delta-api.e2e.test.ts, do not remove */ resetDelta() { this.delta = {}; this.visibleRevisions = {}; } processChangeEvents(changeEvents) { const featuresRemoved = []; const segmentsUpdated = new Map(); // segmentId -> max revisionId const segmentsRemoved = new Map(); // segmentId -> max revisionId const globallyUpdatedFeatures = new Map(); // featureName -> max revisionId const environmentUpdatedFeatures = new Map(); // helper function const warnUnexpectedEventPayload = (event, field, expectedValue) => { this.logger.warn(`[delta] Skipping event ${event.id} ${event.createdAt.toISOString()} (${event.type}) because ${field} ${expectedValue}.`); }; for (const event of changeEvents) { if (event.type === FEATURE_PROJECT_CHANGE && event.featureName) { // A project change involves two steps: removing the feature from old project and adding it to new project featuresRemoved.push({ featureName: event.featureName, project: event.data.oldProject, revisionId: event.id, }); setMaxRevision(globallyUpdatedFeatures, event.featureName, event.id); } else if (event.type === 'feature-archived' && event.featureName && event.project) { featuresRemoved.push({ featureName: event.featureName, project: event.project, revisionId: event.id, }); } else if (event.type === 'segment-created' || event.type === 'segment-updated') { const segmentId = event.data?.id; if (!segmentId) { warnUnexpectedEventPayload(event, 'data', 'is missing id'); continue; } setMaxRevision(segmentsUpdated, segmentId, event.id); } else if (event.type === 'segment-deleted') { // we were previously using data.id for segment-deleted event, this was changed on Sep 29, 2023: https://github.com/Unleash/unleash/pull/4815 const segmentId = event.preData?.id; if (!segmentId) { warnUnexpectedEventPayload(event, 'preData', 'is missing id'); continue; } setMaxRevision(segmentsRemoved, segmentId, event.id); } else if (event.featureName && event.type !== 'feature-deleted') { if (event.environment == null) { setMaxRevision(globallyUpdatedFeatures, event.featureName, event.id); } else { const featureNames = environmentUpdatedFeatures.get(event.environment) ?? new Map(); setMaxRevision(featureNames, event.featureName, event.id); environmentUpdatedFeatures.set(event.environment, featureNames); } } } return { featuresRemoved, segmentsUpdated, segmentsRemoved, globallyUpdatedFeatures, environmentUpdatedFeatures, }; } // executes on every change, with max lag of 1 second async updateFeaturesDelta() { const environments = Object.keys(this.delta); if (environments.length === 0) return; const eventsFrom = this.lastDeltaProcessedRevisionId; const eventsTo = await this.eventStore.getMaxRevisionId(eventsFrom); if (eventsTo <= eventsFrom) { return; // no new events, no need to process } this.logger.info(`[revision] Delta max revision advanced: ${eventsFrom} -> ${eventsTo}`); const changeEvents = await this.eventStore.getRevisionRange(eventsFrom, eventsTo); const { featuresRemoved, segmentsUpdated, segmentsRemoved, globallyUpdatedFeatures, environmentUpdatedFeatures, } = this.processChangeEvents(changeEvents); const updatedSegments = await this.segmentReadModel.getAllForClientIds(Array.from(segmentsUpdated.keys())); const featuresRemovedEvents = featuresRemoved.map(({ revisionId, featureName, project }) => ({ eventId: revisionId, type: DELTA_EVENT_TYPES.FEATURE_REMOVED, featureName, project, })); const segmentsUpdatedEvents = updatedSegments.map((segment) => ({ eventId: segmentsUpdated.get(segment.id) ?? eventsTo, type: DELTA_EVENT_TYPES.SEGMENT_UPDATED, segment, })); const segmentsRemovedEvents = Array.from(segmentsRemoved.entries()).map(([segmentId, revisionId]) => ({ eventId: revisionId, type: DELTA_EVENT_TYPES.SEGMENT_REMOVED, segmentId, })); for (const environment of environments) { // merge globally updated features with environment specific updates, keep the max revision id for each feature const featureUpdatesInEnvironment = new Map(globallyUpdatedFeatures); for (const [featureName, revisionId,] of environmentUpdatedFeatures.get(environment) ?? []) { setMaxRevision(featureUpdatesInEnvironment, featureName, revisionId); } const updatedToggles = await this.getChangedToggles(environment, Array.from(featureUpdatesInEnvironment.keys())); const featuresUpdatedEvents = updatedToggles.map((toggle) => ({ eventId: featureUpdatesInEnvironment.get(toggle.name) ?? eventsTo, type: DELTA_EVENT_TYPES.FEATURE_UPDATED, feature: toggle, })); this.delta[environment].addEvents([ ...featuresRemovedEvents, ...featuresUpdatedEvents, ...segmentsUpdatedEvents, ...segmentsRemovedEvents, ]); this.updateVisibleRevisions(environment, [...featuresRemovedEvents, ...featuresUpdatedEvents], [...segmentsUpdatedEvents, ...segmentsRemovedEvents]); } this.lastDeltaProcessedRevisionId = eventsTo; } async getChangedToggles(environment, toggles) { if (toggles.length === 0) { return []; } return this.getClientFeatures({ toggleNames: toggles, environment, }); } async initEnvironmentDelta(environment) { const revisionState = await this.eventStore.getDeltaRevisionState(environment); const baseFeatures = await this.getClientFeatures({ environment, }); const baseSegments = await this.segmentReadModel.getAllForClientIds(); const maxRevision = getVisibleRevision(revisionState); this.delta[environment] = new DeltaCache({ eventId: maxRevision, type: DELTA_EVENT_TYPES.HYDRATION, features: baseFeatures, segments: baseSegments, }); this.lastDeltaProcessedRevisionId = maxRevision; this.visibleRevisions[environment] = revisionState; this.storeFootprint(); } getVisibleRevision(environment, projects) { const revisionState = this.visibleRevisions[environment]; return getVisibleRevision(revisionState, projects); } updateVisibleRevisions(environment, featureEvents, segmentEvents) { const revisionState = this.visibleRevisions[environment] ?? { projectRevisions: new Map(), globalSegmentRevision: 0, }; if (segmentEvents.length > 0) { for (const event of segmentEvents) { revisionState.globalSegmentRevision = Math.max(revisionState.globalSegmentRevision, event.eventId); } } // assume segment revision id as max feature event let environmentMax = revisionState.globalSegmentRevision; for (const event of featureEvents) { let project; if (event.type === DELTA_EVENT_TYPES.FEATURE_UPDATED) { project = event.feature.project; } else if (event.type === DELTA_EVENT_TYPES.FEATURE_REMOVED) { project = event.project; } if (project) { setMaxRevision(revisionState.projectRevisions, project, event.eventId); environmentMax = Math.max(environmentMax, event.eventId); } } deltaRevisionIdMetric.labels({ environment }).set(environmentMax); this.visibleRevisions[environment] = revisionState; } async getClientFeatures(query) { const result = await this.clientFeatureToggleDeltaReadModel.getAll(query); return result; } storeFootprint() { try { const memory = this.getCacheSizeInBytes(this.delta); this.eventBus.emit(CLIENT_DELTA_MEMORY, { memory }); } catch (e) { this.logger.error('Client delta footprint error', e); } } getCacheSizeInBytes(value) { const jsonString = JSON.stringify(value); return Buffer.byteLength(jsonString, 'utf8'); } } //# sourceMappingURL=client-feature-toggle-delta.js.map