diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index fe141f607b..87e3639d4f 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -115,6 +115,7 @@ exports[`should create default config 1`] = ` }, }, "filterInvalidClientMetrics": false, + "globalFrontendApiCache": false, "googleAuthEnabled": false, "inMemoryScheduledChangeRequests": false, "increaseUnleashWidth": false, diff --git a/src/lib/proxy/fake-client-feature-toggle-read-model.ts b/src/lib/proxy/fake-client-feature-toggle-read-model.ts index 206ec46b25..e34833e046 100644 --- a/src/lib/proxy/fake-client-feature-toggle-read-model.ts +++ b/src/lib/proxy/fake-client-feature-toggle-read-model.ts @@ -4,7 +4,7 @@ import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-mode export default class FakeClientFeatureToggleReadModel implements IClientFeatureToggleReadModel { - constructor(private value: Record) {} + constructor(private value: Record = {}) {} getClient(): Promise> { return Promise.resolve(this.value); diff --git a/src/lib/proxy/frontend-api-repository.ts b/src/lib/proxy/frontend-api-repository.ts index 18eb5f9a4f..d660a70c14 100644 --- a/src/lib/proxy/frontend-api-repository.ts +++ b/src/lib/proxy/frontend-api-repository.ts @@ -23,20 +23,20 @@ export class FrontendApiRepository private readonly token: IApiUser; - private globalFrontendApiRepository: GlobalFrontendApiCache; + private globalFrontendApiCache: GlobalFrontendApiCache; private running: boolean; constructor( config: Config, - globalFrontendApiRepository: GlobalFrontendApiCache, + globalFrontendApiCache: GlobalFrontendApiCache, token: IApiUser, ) { super(); this.config = config; this.logger = config.getLogger('frontend-api-repository.ts'); this.token = token; - this.globalFrontendApiRepository = globalFrontendApiRepository; + this.globalFrontendApiCache = globalFrontendApiCache; } getTogglesWithSegmentData(): EnhancedFeatureInterface[] { @@ -45,18 +45,18 @@ export class FrontendApiRepository } getSegment(id: number): Segment | undefined { - return this.globalFrontendApiRepository.getSegment(id); + return this.globalFrontendApiCache.getSegment(id); } getToggle(name: string): FeatureInterface { //@ts-ignore (we must update the node SDK to allow undefined) - return this.globalFrontendApiRepository - .getToggles(this.token) - .find((feature) => feature.name); + return this.getToggles(this.token).find( + (feature) => feature.name === name, + ); } getToggles(): FeatureInterface[] { - return this.globalFrontendApiRepository.getToggles(this.token); + return this.globalFrontendApiCache.getToggles(this.token); } async start(): Promise { diff --git a/src/lib/proxy/global-frontend-api-cache.test.ts b/src/lib/proxy/global-frontend-api-cache.test.ts index 50ef45c1d4..44c40b11b6 100644 --- a/src/lib/proxy/global-frontend-api-cache.test.ts +++ b/src/lib/proxy/global-frontend-api-cache.test.ts @@ -6,7 +6,12 @@ import noLogger from '../../test/fixtures/no-logger'; import { FakeSegmentReadModel } from '../features/segment/fake-segment-read-model'; import FakeClientFeatureToggleReadModel from './fake-client-feature-toggle-read-model'; import EventEmitter from 'events'; -import { IApiUser, IFeatureToggleClient, ISegment } from '../types'; +import { + IApiUser, + IFeatureToggleClient, + IFlagResolver, + ISegment, +} from '../types'; import { UPDATE_REVISION } from '../features/feature-toggle/configuration-revision-service'; const state = async ( @@ -33,11 +38,17 @@ const defaultFeature: IFeatureToggleClient = { }; const defaultSegment = { name: 'segment', id: 1 } as ISegment; +const alwaysOnFlagResolver = { + isEnabled() { + return true; + }, +} as unknown as IFlagResolver; + const createCache = ( segment: ISegment = defaultSegment, features: Record = {}, ) => { - const config = { getLogger: noLogger }; + const config = { getLogger: noLogger, flagResolver: alwaysOnFlagResolver }; const segmentReadModel = new FakeSegmentReadModel([segment as ISegment]); const clientFeatureToggleReadModel = new FakeClientFeatureToggleReadModel( features, diff --git a/src/lib/proxy/global-frontend-api-cache.ts b/src/lib/proxy/global-frontend-api-cache.ts index b484ec8dd1..a73e55e83c 100644 --- a/src/lib/proxy/global-frontend-api-cache.ts +++ b/src/lib/proxy/global-frontend-api-cache.ts @@ -13,7 +13,7 @@ import { UPDATE_REVISION } from '../features/feature-toggle/configuration-revisi import { mapValues } from '../util'; import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type'; -type Config = Pick; +type Config = Pick; export type GlobalFrontendApiCacheState = 'starting' | 'ready' | 'updated'; @@ -103,7 +103,9 @@ export class GlobalFrontendApiCache extends EventEmitter { } private async onUpdateRevisionEvent() { - await this.refreshData(); + if (this.config.flagResolver.isEnabled('globalFrontendApiCache')) { + await this.refreshData(); + } } private environmentNameForToken(token: IApiUser): string { diff --git a/src/lib/routes/proxy-api/index.ts b/src/lib/routes/proxy-api/index.ts index 3ac91b7d4d..69d0543cbc 100644 --- a/src/lib/routes/proxy-api/index.ts +++ b/src/lib/routes/proxy-api/index.ts @@ -10,6 +10,7 @@ import { emptyResponse, getStandardResponses, ProxyClientSchema, + ProxyFeatureSchema, proxyFeaturesSchema, ProxyFeaturesSchema, } from '../../openapi'; @@ -20,6 +21,7 @@ import NotImplementedError from '../../error/not-implemented-error'; import NotFoundError from '../../error/notfound-error'; import rateLimit from 'express-rate-limit'; import { minutesToMilliseconds } from 'date-fns'; +import isEqual from 'lodash.isequal'; interface ApiUserRequest< PARAM = any, @@ -173,10 +175,32 @@ export default class FrontendAPIController extends Controller { if (!this.config.flagResolver.isEnabled('embedProxy')) { throw new NotFoundError(); } - const toggles = await this.services.proxyService.getProxyFeatures( - req.user, - FrontendAPIController.createContext(req), - ); + let toggles: ProxyFeatureSchema[]; + let newToggles: ProxyFeatureSchema[]; + if (this.config.flagResolver.isEnabled('globalFrontendApiCache')) { + [toggles, newToggles] = await Promise.all([ + this.services.proxyService.getProxyFeatures( + req.user, + FrontendAPIController.createContext(req), + ), + this.services.proxyService.getNewProxyFeatures( + req.user, + FrontendAPIController.createContext(req), + ), + ]); + if (!isEqual(toggles, newToggles)) { + this.logger.warn( + 'old features and new feature are different', + toggles.length, + newToggles.length, + ); + } + } else { + toggles = await this.services.proxyService.getProxyFeatures( + req.user, + FrontendAPIController.createContext(req), + ); + } res.set('Cache-control', 'no-cache'); diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 501dc479fc..9560ee1593 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -111,6 +111,9 @@ import { import { InactiveUsersService } from '../users/inactive/inactive-users-service'; import { SegmentReadModel } from '../features/segment/segment-read-model'; import { FakeSegmentReadModel } from '../features/segment/fake-segment-read-model'; +import { GlobalFrontendApiCache } from '../proxy/global-frontend-api-cache'; +import ClientFeatureToggleReadModel from '../proxy/client-feature-toggle-read-model'; +import FakeClientFeatureToggleReadModel from '../proxy/fake-client-feature-toggle-read-model'; export const createServices = ( stores: IUnleashStores, @@ -293,12 +296,27 @@ export const createServices = ( ? createClientFeatureToggleService(db, config) : createFakeClientFeatureToggleService(config); - const proxyService = new ProxyService(config, stores, { - featureToggleServiceV2, - clientMetricsServiceV2, - settingService, + const clientFeatureToggleReadModel = db + ? new ClientFeatureToggleReadModel(db, config.eventBus) + : new FakeClientFeatureToggleReadModel(); + const globalFrontendApiCache = new GlobalFrontendApiCache( + config, + segmentReadModel, + clientFeatureToggleReadModel, configurationRevisionService, - }); + ); + + const proxyService = new ProxyService( + config, + stores, + { + featureToggleServiceV2, + clientMetricsServiceV2, + settingService, + configurationRevisionService, + }, + globalFrontendApiCache, + ); const edgeService = new EdgeService({ apiTokenService }, config); diff --git a/src/lib/services/proxy-service.test.ts b/src/lib/services/proxy-service.test.ts new file mode 100644 index 0000000000..88342b298a --- /dev/null +++ b/src/lib/services/proxy-service.test.ts @@ -0,0 +1,54 @@ +import { ProxyService, Config } from './proxy-service'; +import { GlobalFrontendApiCache } from '../proxy/global-frontend-api-cache'; +import { IApiUser } from '../types'; +import { FeatureInterface } from 'unleash-client/lib/feature'; +import noLogger from '../../test/fixtures/no-logger'; +import { ApiTokenType } from '../types/models/api-token'; + +test('proxy service fetching features from global cache', async () => { + const irrelevant = {} as any; + const globalFrontendApiCache = { + getToggles(_: IApiUser): FeatureInterface[] { + return [ + { + name: 'toggleA', + enabled: true, + project: 'projectA', + type: 'release', + variants: [], + strategies: [ + { name: 'default', parameters: [], constraints: [] }, + ], + }, + { + name: 'toggleB', + enabled: false, + project: 'projectA', + type: 'release', + variants: [], + strategies: [ + { name: 'default', parameters: [], constraints: [] }, + ], + }, + ]; + }, + } as GlobalFrontendApiCache; + const proxyService = new ProxyService( + { getLogger: noLogger } as unknown as Config, + irrelevant, + irrelevant, + globalFrontendApiCache, + ); + + const features = await proxyService.getNewProxyFeatures( + { + projects: ['irrelevant'], + environment: 'irrelevant', + type: ApiTokenType.FRONTEND, + } as unknown as IApiUser, + {}, + ); + + expect(features).toMatchObject([{ name: 'toggleA' }]); + expect(features).toHaveLength(1); +}); diff --git a/src/lib/services/proxy-service.ts b/src/lib/services/proxy-service.ts index 59ab7bb21c..c10f4bd8d8 100644 --- a/src/lib/services/proxy-service.ts +++ b/src/lib/services/proxy-service.ts @@ -17,18 +17,20 @@ import { validateOrigins } from '../util'; import { BadDataError, InvalidTokenError } from '../error'; import { PROXY_REPOSITORY_CREATED } from '../metric-events'; import { ProxyRepository } from '../proxy'; +import { FrontendApiRepository } from '../proxy/frontend-api-repository'; +import { GlobalFrontendApiCache } from '../proxy/global-frontend-api-cache'; -type Config = Pick< +export type Config = Pick< IUnleashConfig, 'getLogger' | 'frontendApi' | 'frontendApiOrigins' | 'eventBus' >; -type Stores = Pick< +export type Stores = Pick< IUnleashStores, 'projectStore' | 'eventStore' | 'segmentReadModel' >; -type Services = Pick< +export type Services = Pick< IUnleashServices, | 'featureToggleServiceV2' | 'clientMetricsServiceV2' @@ -45,21 +47,31 @@ export class ProxyService { private readonly services: Services; + private readonly globalFrontendApiCache: GlobalFrontendApiCache; + /** - * This is intentionally a Promise becasue we want to be able to await + * This is intentionally a Promise because we want to be able to await * until the client (which might be being created by a different request) is ready * Check this test that fails if we don't use a Promise: src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts */ private readonly clients: Map> = new Map(); + private readonly newClients: Map> = + new Map(); private cachedFrontendSettings?: FrontendSettings; - constructor(config: Config, stores: Stores, services: Services) { + constructor( + config: Config, + stores: Stores, + services: Services, + globalFrontendApiCache: GlobalFrontendApiCache, + ) { this.config = config; this.logger = config.getLogger('services/proxy-service.ts'); this.stores = stores; this.services = services; + this.globalFrontendApiCache = globalFrontendApiCache; } async getProxyFeatures( @@ -85,6 +97,33 @@ export class ProxyService { })); } + async getNewProxyFeatures( + token: IApiUser, + context: Context, + ): Promise { + const client = await this.newClientForProxyToken(token); + const definitions = client.getFeatureToggleDefinitions() || []; + const sessionId = context.sessionId || String(Math.random()); + + return definitions + .filter((feature) => { + const enabled = client.isEnabled(feature.name, { + ...context, + sessionId, + }); + return enabled; + }) + .map((feature) => ({ + name: feature.name, + enabled: Boolean(feature.enabled), + variant: client.getVariant(feature.name, { + ...context, + sessionId, + }), + impressionData: Boolean(feature.impressionData), + })); + } + async registerProxyMetrics( token: IApiUser, metrics: ClientMetricsSchema, @@ -117,6 +156,20 @@ export class ProxyService { return client; } + private async newClientForProxyToken(token: IApiUser): Promise { + ProxyService.assertExpectedTokenType(token); + + let newClient = this.newClients.get(token.secret); + if (!newClient) { + newClient = this.createNewClientForProxyToken(token); + this.newClients.set(token.secret, newClient); + // TODO: do we need this twice? + // this.config.eventBus.emit(PROXY_REPOSITORY_CREATED); + } + + return newClient; + } + private async createClientForProxyToken(token: IApiUser): Promise { const repository = new ProxyRepository( this.config, @@ -143,6 +196,33 @@ export class ProxyService { return client; } + private async createNewClientForProxyToken( + token: IApiUser, + ): Promise { + const repository = new FrontendApiRepository( + this.config, + this.globalFrontendApiCache, + token, + ); + const client = new Unleash({ + appName: 'proxy', + url: 'unused', + storageProvider: new InMemStorageProvider(), + disableMetrics: true, + repository, + disableAutoStart: true, + skipInstanceCountWarning: true, + }); + + client.on(UnleashEvents.Error, (error) => { + this.logger.error('We found an event error', error); + }); + + await client.start(); + + return client; + } + async deleteClientForProxyToken(secret: string): Promise { const clientPromise = this.clients.get(secret); if (clientPromise) { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 1c46175c7c..29bb91a035 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -53,7 +53,8 @@ export type IFlagKey = | 'sdkReporting' | 'responseTimeMetricsFix' | 'scimApi' - | 'displayEdgeBanner'; + | 'displayEdgeBanner' + | 'globalFrontendApiCache'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -262,6 +263,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_RESPONSE_TIME_METRICS_FIX, false, ), + globalFrontendApiCache: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_GLOBAL_FRONTEND_API_CACHE, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/server-dev.ts b/src/server-dev.ts index addfab74fa..88b1b46f89 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -50,6 +50,7 @@ process.nextTick(async () => { executiveDashboard: true, userAccessUIEnabled: true, sdkReporting: true, + globalFrontendApiCache: true, }, }, authentication: {