From 104642be11ec840ab099990b3f262d7084ae7238 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 14 Mar 2025 13:16:49 -0600 Subject: [PATCH 01/15] chore: ConfigurationManager --- src/client/eppo-client-with-bandits.spec.ts | 5 +- src/client/eppo-client.ts | 88 +++++++----- src/client/test-utils.ts | 6 +- src/configuration-requestor.spec.ts | 115 +++++---------- src/configuration-requestor.ts | 108 +++------------ .../configuration-manager.ts | 131 ++++++++++++++++++ .../i-configuration-manager.ts | 23 +++ src/i-configuration.ts | 2 + 8 files changed, 264 insertions(+), 214 deletions(-) create mode 100644 src/configuration-store/configuration-manager.ts create mode 100644 src/configuration-store/i-configuration-manager.ts diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index 878b909e..efd5e6e3 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -13,6 +13,7 @@ import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator'; import { IBanditEvent, IBanditLogger } from '../bandit-logger'; import ConfigurationRequestor from '../configuration-requestor'; +import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { IConfigurationWire, @@ -64,12 +65,12 @@ describe('EppoClient Bandits E2E test', () => { }, }); const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const configurationRequestor = new ConfigurationRequestor( - httpClient, + const configManager = new ConfigurationManager( flagStore, banditVariationStore, banditModelStore, ); + const configurationRequestor = new ConfigurationRequestor(httpClient, configManager, true); await configurationRequestor.fetchAndStoreConfigurations(); }); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 9c641def..f140c423 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -15,7 +15,9 @@ import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-ca import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache'; import ConfigurationRequestor from '../configuration-requestor'; +import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; +import { IConfigurationManager } from '../configuration-store/i-configuration-manager'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { ConfigurationWireV1, @@ -41,7 +43,7 @@ import { } from '../flag-evaluation-details-builder'; import { FlagEvaluationError } from '../flag-evaluation-error'; import FetchHttpClient from '../http-client'; -import { IConfiguration, StoreBackedConfiguration } from '../i-configuration'; +import { IConfiguration } from '../i-configuration'; import { BanditModelData, BanditParameters, @@ -138,6 +140,7 @@ export default class EppoClient { private readonly evaluator = new Evaluator(); private configurationRequestor?: ConfigurationRequestor; private readonly overrideValidator = new OverrideValidator(); + private configurationManager: IConfigurationManager; constructor({ eventDispatcher = new NoOpEventDispatcher(), @@ -165,16 +168,18 @@ export default class EppoClient { this.overrideStore = overrideStore; this.configurationRequestParameters = configurationRequestParameters; this.expectObfuscated = isObfuscated; + + // Initialize the configuration manager + this.configurationManager = new ConfigurationManager( + this.flagConfigurationStore, + this.banditVariationConfigurationStore || + new MemoryOnlyConfigurationStore(), + this.banditModelConfigurationStore || new MemoryOnlyConfigurationStore(), + ); } private getConfiguration(): IConfiguration { - return this.configurationRequestor - ? this.configurationRequestor.getConfiguration() - : new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); + return this.configurationManager.getConfiguration(); } private maybeWarnAboutObfuscationMismatch(configObfuscated: boolean) { @@ -238,21 +243,51 @@ export default class EppoClient { this.configurationRequestParameters = configurationRequestParameters; } - // noinspection JSUnusedGlobalSymbols setFlagConfigurationStore(flagConfigurationStore: IConfigurationStore) { this.flagConfigurationStore = flagConfigurationStore; this.configObfuscatedCache = undefined; + + // Update the configuration manager + this.configurationManager.setConfigurationStores({ + flagConfigurationStore: this.flagConfigurationStore, + banditReferenceConfigurationStore: + this.banditVariationConfigurationStore || + new MemoryOnlyConfigurationStore(), + banditConfigurationStore: + this.banditModelConfigurationStore || new MemoryOnlyConfigurationStore(), + }); } - // noinspection JSUnusedGlobalSymbols setBanditVariationConfigurationStore( banditVariationConfigurationStore: IConfigurationStore, ) { this.banditVariationConfigurationStore = banditVariationConfigurationStore; + + // Update the configuration manager + this.configurationManager.setConfigurationStores({ + flagConfigurationStore: this.flagConfigurationStore, + banditReferenceConfigurationStore: this.banditVariationConfigurationStore, + banditConfigurationStore: + this.banditModelConfigurationStore || new MemoryOnlyConfigurationStore(), + }); + } + + setBanditModelConfigurationStore( + banditModelConfigurationStore: IConfigurationStore, + ) { + this.banditModelConfigurationStore = banditModelConfigurationStore; + + // Update the configuration manager + this.configurationManager.setConfigurationStores({ + flagConfigurationStore: this.flagConfigurationStore, + banditReferenceConfigurationStore: + this.banditVariationConfigurationStore || + new MemoryOnlyConfigurationStore(), + banditConfigurationStore: this.banditModelConfigurationStore, + }); } /** Sets the EventDispatcher instance to use when tracking events with {@link track}. */ - // noinspection JSUnusedGlobalSymbols setEventDispatcher(eventDispatcher: EventDispatcher) { this.eventDispatcher = eventDispatcher; } @@ -272,25 +307,6 @@ export default class EppoClient { this.eventDispatcher?.attachContext(key, value); } - // noinspection JSUnusedGlobalSymbols - setBanditModelConfigurationStore( - banditModelConfigurationStore: IConfigurationStore, - ) { - this.banditModelConfigurationStore = banditModelConfigurationStore; - } - - // noinspection JSUnusedGlobalSymbols - /** - * Setting this value will have no side effects other than triggering a warning when the actual - * configuration's obfuscated does not match the value set here. - * - * @deprecated The client determines whether the configuration is obfuscated by inspection - * @param isObfuscated - */ - setIsObfuscated(isObfuscated: boolean) { - this.expectObfuscated = isObfuscated; - } - setOverrideStore(store: ISyncStore): void { this.overrideStore = store; } @@ -322,7 +338,7 @@ export default class EppoClient { apiKey, sdkName, sdkVersion, - baseUrl, // Default is set in ApiEndpoints constructor if undefined + baseUrl, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, numInitialRequestRetries = DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, numPollRequestRetries = DEFAULT_POLL_CONFIG_REQUEST_RETRIES, @@ -338,22 +354,22 @@ export default class EppoClient { pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS; } - // todo: Inject the chain of dependencies below const apiEndpoints = new ApiEndpoints({ baseUrl, queryParams: { apiKey, sdkName, sdkVersion }, }); const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); + + // Use the configuration manager when creating the requestor const configurationRequestor = new ConfigurationRequestor( httpClient, - this.flagConfigurationStore, - this.banditVariationConfigurationStore ?? null, - this.banditModelConfigurationStore ?? null, + this.configurationManager, + !!this.banditModelConfigurationStore && !!this.banditVariationConfigurationStore, ); this.configurationRequestor = configurationRequestor; const pollingCallback = async () => { - if (await configurationRequestor.isFlagConfigExpired()) { + if (await this.flagConfigurationStore.isExpired()) { this.configObfuscatedCache = undefined; return configurationRequestor.fetchAndStoreConfigurations(); } diff --git a/src/client/test-utils.ts b/src/client/test-utils.ts index 546ae366..f917fd0a 100644 --- a/src/client/test-utils.ts +++ b/src/client/test-utils.ts @@ -1,5 +1,6 @@ import ApiEndpoints from '../api-endpoints'; import ConfigurationRequestor from '../configuration-requestor'; +import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { IConfigurationStore } from '../configuration-store/configuration-store'; import FetchHttpClient from '../http-client'; import { Flag, ObfuscatedFlag } from '../interfaces'; @@ -18,9 +19,8 @@ export async function initConfiguration( const httpClient = new FetchHttpClient(apiEndpoints, 1000); const configurationRequestor = new ConfigurationRequestor( httpClient, - configurationStore, - null, - null, + new ConfigurationManager(configurationStore, null, null), + false, ); await configurationRequestor.fetchAndStoreConfigurations(); } diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index 2bfe61d6..dbaa0804 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -7,7 +7,9 @@ import { import ApiEndpoints from './api-endpoints'; import ConfigurationRequestor from './configuration-requestor'; +import { ConfigurationManager } from './configuration-store/configuration-manager'; import { IConfigurationStore } from './configuration-store/configuration-store'; +import { IConfigurationManager } from './configuration-store/i-configuration-manager'; import { MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import FetchHttpClient, { IBanditParametersResponse, @@ -22,6 +24,7 @@ describe('ConfigurationRequestor', () => { let banditVariationStore: IConfigurationStore; let banditModelStore: IConfigurationStore; let httpClient: IHttpClient; + let configurationManager: IConfigurationManager; let configurationRequestor: ConfigurationRequestor; beforeEach(async () => { @@ -34,15 +37,21 @@ describe('ConfigurationRequestor', () => { }, }); httpClient = new FetchHttpClient(apiEndpoints, 1000); + + // Create fresh stores for each test flagStore = new MemoryOnlyConfigurationStore(); banditVariationStore = new MemoryOnlyConfigurationStore(); banditModelStore = new MemoryOnlyConfigurationStore(); - configurationRequestor = new ConfigurationRequestor( - httpClient, + + // Create a ConfigurationManager instance + configurationManager = new ConfigurationManager( flagStore, banditVariationStore, banditModelStore, ); + + // Create ConfigurationRequestor with the manager + configurationRequestor = new ConfigurationRequestor(httpClient, configurationManager, true); }); afterEach(() => { @@ -218,7 +227,11 @@ describe('ConfigurationRequestor', () => { }); it('Will not fetch bandit parameters if there is no store', async () => { - configurationRequestor = new ConfigurationRequestor(httpClient, flagStore, null, null); + configurationRequestor = new ConfigurationRequestor( + httpClient, + configurationManager, + false, + ); await configurationRequestor.fetchAndStoreConfigurations(); expect(fetchSpy).toHaveBeenCalledTimes(1); }); @@ -498,29 +511,15 @@ describe('ConfigurationRequestor', () => { describe('getConfiguration', () => { it('should return an empty configuration instance before a config has been loaded', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - - const config = requestor.getConfiguration(); + const config = configurationRequestor.getConfiguration(); expect(config).toBeInstanceOf(StoreBackedConfiguration); expect(config.getFlagKeys()).toEqual([]); }); it('should return a populated configuration instance', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - - await requestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchAndStoreConfigurations(); - const config = requestor.getConfiguration(); + const config = configurationRequestor.getConfiguration(); expect(config).toBeInstanceOf(StoreBackedConfiguration); expect(config.getFlagKeys()).toEqual(['test_flag']); }); @@ -528,15 +527,9 @@ describe('ConfigurationRequestor', () => { describe('fetchAndStoreConfigurations', () => { it('should update configuration with flag data', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - const config = requestor.getConfiguration(); + const config = configurationRequestor.getConfiguration(); - await requestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchAndStoreConfigurations(); expect(config.getFlagKeys()).toEqual(['test_flag']); expect(config.getFlagConfigDetails()).toEqual({ @@ -548,33 +541,14 @@ describe('ConfigurationRequestor', () => { }); it('should update configuration with bandit data when present', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - const config = requestor.getConfiguration(); + const config = configurationRequestor.getConfiguration(); - await requestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchAndStoreConfigurations(); // Verify flag configuration expect(config.getFlagKeys()).toEqual(['test_flag']); // Verify bandit variation configuration - // expect(banditVariationDetails.entries).toEqual({ - // 'test_flag': [ - // { - // flagKey: 'test_flag', - // variationId: 'variation-1', - // // Add other expected properties based on your mock data - // } - // ] - // }); - // expect(banditVariationDetails.environment).toBe('test-env'); - // expect(banditVariationDetails.configFormat).toBe('SERVER'); - - // Verify bandit model configuration const banditVariations = config.getFlagBanditVariations('test_flag'); expect(banditVariations).toEqual([ { @@ -598,47 +572,22 @@ describe('ConfigurationRequestor', () => { modelName: 'falcon', modelVersion: '123', updatedAt: '2023-09-13T04:52:06.462Z', - // Add other expected properties based on your mock data }); }); it('should not fetch bandit parameters if model versions are already loaded', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - - const ufcResponse = { - flags: { test_flag: { key: 'test_flag', value: true } }, - banditReferences: { - bandit: { - modelVersion: 'v1', - flagVariations: [{ flagKey: 'test_flag', variationId: '1' }], - }, - }, - environment: 'test', - createdAt: '2024-01-01', - format: 'SERVER', - }; + // First call to load the initial data + await configurationRequestor.fetchAndStoreConfigurations(); + const initialFetchCount = fetchSpy.mock.calls.length; - await requestor.fetchAndStoreConfigurations(); - // const initialFetchCount = fetchSpy.mock.calls.length; + // Reset the mock call count + fetchSpy.mockClear(); // Second call with same model version - // fetchSpy.mockImplementationOnce(() => - // Promise.resolve({ - // ok: true, - // status: 200, - // json: () => Promise.resolve(ufcResponse) - // }) - // ); - - await requestor.fetchAndStoreConfigurations(); - - // Should only have one additional fetch (the UFC) and not the bandit parameters - // expect(fetchSpy.mock.calls.length).toBe(initialFetchCount + 1); + await configurationRequestor.fetchAndStoreConfigurations(); + + // Should only have one fetch (the UFC) and not the bandit parameters + expect(fetchSpy.mock.calls.length).toBe(1); }); }); }); diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 1f48e09e..7b8ede69 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,38 +1,20 @@ -import { IConfigurationStore } from './configuration-store/configuration-store'; +import { IConfigurationManager } from './configuration-store/i-configuration-manager'; import { IHttpClient } from './http-client'; -import { - ConfigStoreHydrationPacket, - IConfiguration, - StoreBackedConfiguration, -} from './i-configuration'; -import { BanditVariation, BanditParameters, Flag, BanditReference } from './interfaces'; +import { IConfiguration } from './i-configuration'; +import { BanditReference, BanditParameters } from './interfaces'; -// Requests AND stores flag configurations +// Requests configurations and delegates storage to the ConfigurationManager export default class ConfigurationRequestor { private banditModelVersions: string[] = []; - private readonly configuration: StoreBackedConfiguration; constructor( private readonly httpClient: IHttpClient, - private readonly flagConfigurationStore: IConfigurationStore, - private readonly banditVariationConfigurationStore: IConfigurationStore< - BanditVariation[] - > | null, - private readonly banditModelConfigurationStore: IConfigurationStore | null, - ) { - this.configuration = new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); - } - - public isFlagConfigExpired(): Promise { - return this.flagConfigurationStore.isExpired(); - } + private readonly configurationManager: IConfigurationManager, + private readonly fetchBandits: boolean, + ) {} public getConfiguration(): IConfiguration { - return this.configuration; + return this.configurationManager.getConfiguration(); } async fetchAndStoreConfigurations(): Promise { @@ -41,59 +23,23 @@ export default class ConfigurationRequestor { return; } - const flagResponsePacket: ConfigStoreHydrationPacket = { - entries: configResponse.flags, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - - let banditVariationPacket: ConfigStoreHydrationPacket | undefined; - let banditModelPacket: ConfigStoreHydrationPacket | undefined; const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; - const banditStoresProvided = Boolean( - this.banditVariationConfigurationStore && this.banditModelConfigurationStore, - ); - if (flagsHaveBandits && banditStoresProvided) { - // Map bandit flag associations by flag key for quick lookup (instead of bandit key as provided by the UFC) - const banditVariations = this.indexBanditVariationsByFlagKey(configResponse.banditReferences); - - banditVariationPacket = { - entries: banditVariations, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; + let banditResponse = undefined; - if ( - this.requiresBanditModelConfigurationStoreUpdate( - this.banditModelVersions, - configResponse.banditReferences, - ) - ) { - const banditResponse = await this.httpClient.getBanditParameters(); + if (this.fetchBandits && flagsHaveBandits) { + // Check if we need to fetch bandit parameters + if (this.requiresBanditModelConfigurationStoreUpdate(configResponse.banditReferences)) { + banditResponse = await this.httpClient.getBanditParameters(); if (banditResponse?.bandits) { - banditModelPacket = { - entries: banditResponse.bandits, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - this.banditModelVersions = this.getLoadedBanditModelVersions(banditResponse.bandits); } } } - if ( - await this.configuration.hydrateConfigurationStores( - flagResponsePacket, - banditVariationPacket, - banditModelPacket, - ) - ) { - // TODO: Notify that config updated. - } + await this.configurationManager.hydrateConfigurationStoresFromUfc( + configResponse, + banditResponse, + ); } private getLoadedBanditModelVersions(entries: Record): string[] { @@ -101,7 +47,6 @@ export default class ConfigurationRequestor { } private requiresBanditModelConfigurationStoreUpdate( - currentBanditModelVersions: string[], banditReferences: Record, ): boolean { const referencedModelVersions = Object.values(banditReferences).map( @@ -109,24 +54,7 @@ export default class ConfigurationRequestor { ); return !referencedModelVersions.every((modelVersion) => - currentBanditModelVersions.includes(modelVersion), + this.banditModelVersions.includes(modelVersion), ); } - - private indexBanditVariationsByFlagKey( - banditVariationsByBanditKey: Record, - ): Record { - const banditVariationsByFlagKey: Record = {}; - Object.values(banditVariationsByBanditKey).forEach((banditReference) => { - banditReference.flagVariations.forEach((banditVariation) => { - let banditVariations = banditVariationsByFlagKey[banditVariation.flagKey]; - if (!banditVariations) { - banditVariations = []; - banditVariationsByFlagKey[banditVariation.flagKey] = banditVariations; - } - banditVariations.push(banditVariation); - }); - }); - return banditVariationsByFlagKey; - } } diff --git a/src/configuration-store/configuration-manager.ts b/src/configuration-store/configuration-manager.ts new file mode 100644 index 00000000..56e79291 --- /dev/null +++ b/src/configuration-store/configuration-manager.ts @@ -0,0 +1,131 @@ +import { IUniversalFlagConfigResponse, IBanditParametersResponse } from '../http-client'; +import { + IConfiguration, + StoreBackedConfiguration, + ConfigStoreHydrationPacket, +} from '../i-configuration'; +import { + Flag, + ObfuscatedFlag, + BanditReference, + BanditParameters, + BanditVariation, +} from '../interfaces'; + +import { IConfigurationStore } from './configuration-store'; +import { IConfigurationManager } from './i-configuration-manager'; + +export class ConfigurationManager implements IConfigurationManager { + private configuration: StoreBackedConfiguration; + + constructor( + private flagConfigurationStore: IConfigurationStore, + private banditReferenceConfigurationStore: IConfigurationStore | null, + private banditConfigurationStore: IConfigurationStore | null, + ) { + this.configuration = new StoreBackedConfiguration( + this.flagConfigurationStore, + this.banditReferenceConfigurationStore, + this.banditConfigurationStore, + ); + } + + public getConfiguration(): IConfiguration { + return this.configuration; + } + + public async hydrateConfigurationStores( + flagConfigPacket: ConfigStoreHydrationPacket, + banditReferencePacket?: ConfigStoreHydrationPacket, + banditParametersPacket?: ConfigStoreHydrationPacket, + ): Promise { + // Delegate to the configuration to hydrate the stores + return this.configuration.hydrateConfigurationStores( + flagConfigPacket, + banditReferencePacket, + banditParametersPacket, + ); + } + + public async hydrateConfigurationStoresFromUfc( + configResponse: IUniversalFlagConfigResponse, + banditResponse?: IBanditParametersResponse, + ): Promise { + if (!configResponse?.flags) { + return false; + } + + const flagResponsePacket: ConfigStoreHydrationPacket = { + entries: configResponse.flags, + environment: configResponse.environment, + createdAt: configResponse.createdAt, + format: configResponse.format, + }; + + let banditVariationPacket: ConfigStoreHydrationPacket | undefined; + let banditModelPacket: ConfigStoreHydrationPacket | undefined; + const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; + + if (flagsHaveBandits) { + // Map bandit flag associations by flag key for quick lookup (instead of bandit key as provided by the UFC) + const banditVariations = this.indexBanditVariationsByFlagKey(configResponse.banditReferences); + + banditVariationPacket = { + entries: banditVariations, + environment: configResponse.environment, + createdAt: configResponse.createdAt, + format: configResponse.format, + }; + + if (banditResponse?.bandits) { + banditModelPacket = { + entries: banditResponse.bandits, + environment: configResponse.environment, + createdAt: configResponse.createdAt, + format: configResponse.format, + }; + } + } + + // Use the hydrateConfigurationStores method to avoid duplication + return this.hydrateConfigurationStores( + flagResponsePacket, + banditVariationPacket, + banditModelPacket, + ); + } + + public setConfigurationStores(configStores: { + flagConfigurationStore: IConfigurationStore; + banditReferenceConfigurationStore?: IConfigurationStore; + banditConfigurationStore?: IConfigurationStore; + }): void { + this.flagConfigurationStore = configStores.flagConfigurationStore; + this.banditReferenceConfigurationStore = configStores.banditReferenceConfigurationStore ?? null; + this.banditConfigurationStore = configStores.banditConfigurationStore ?? null; + + // Recreate the configuration with the new stores + this.configuration = new StoreBackedConfiguration( + this.flagConfigurationStore, + this.banditReferenceConfigurationStore, + this.banditConfigurationStore, + ); + } + + private indexBanditVariationsByFlagKey( + banditVariationsByBanditKey: Record, + ): Record { + const banditVariationsByFlagKey: Record = {}; + Object.values(banditVariationsByBanditKey).forEach((banditReference) => { + banditReference.flagVariations.forEach((banditVariation) => { + let banditVariations = banditVariationsByFlagKey[banditVariation.flagKey]; + if (!banditVariations) { + banditVariations = []; + banditVariationsByFlagKey[banditVariation.flagKey] = banditVariations; + } + banditVariations.push(banditVariation); + }); + }); + return banditVariationsByFlagKey; + } +} diff --git a/src/configuration-store/i-configuration-manager.ts b/src/configuration-store/i-configuration-manager.ts new file mode 100644 index 00000000..a6fdef47 --- /dev/null +++ b/src/configuration-store/i-configuration-manager.ts @@ -0,0 +1,23 @@ +import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../http-client'; +import { ConfigStoreHydrationPacket, IConfiguration } from '../i-configuration'; +import { BanditParameters, BanditVariation, Flag, ObfuscatedFlag } from '../interfaces'; + +import { IConfigurationStore } from './configuration-store'; + +export interface IConfigurationManager { + getConfiguration(): IConfiguration; + hydrateConfigurationStores( + flagConfigPacket: ConfigStoreHydrationPacket, + banditReferencePacket?: ConfigStoreHydrationPacket, + banditParametersPacket?: ConfigStoreHydrationPacket, + ): Promise; + hydrateConfigurationStoresFromUfc( + flags: IUniversalFlagConfigResponse, + bandits?: IBanditParametersResponse, + ): Promise; + setConfigurationStores(configStores: { + flagConfigurationStore: IConfigurationStore; + banditReferenceConfigurationStore?: IConfigurationStore; + banditConfigurationStore?: IConfigurationStore; + }): void; +} diff --git a/src/i-configuration.ts b/src/i-configuration.ts index f8d3b0df..b9b36cdb 100644 --- a/src/i-configuration.ts +++ b/src/i-configuration.ts @@ -63,6 +63,8 @@ export class StoreBackedConfiguration implements IConfiguration { ); } await Promise.all(promises); + + // TODO: notify of config change if `didUpdateFlags` is true return didUpdateFlags; } From 31c3db9fb6e258d7bd6ffb80661d87a6ff065835 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 14 Mar 2025 13:22:27 -0600 Subject: [PATCH 02/15] lint --- src/configuration-requestor.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index dbaa0804..fa51c8dd 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -578,7 +578,6 @@ describe('ConfigurationRequestor', () => { it('should not fetch bandit parameters if model versions are already loaded', async () => { // First call to load the initial data await configurationRequestor.fetchAndStoreConfigurations(); - const initialFetchCount = fetchSpy.mock.calls.length; // Reset the mock call count fetchSpy.mockClear(); From 759d038de0b95e215915e86a149686fee9e70809 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 14 Mar 2025 13:25:54 -0600 Subject: [PATCH 03/15] bootstrap to set initial config then, fire-and-forget initialization --- src/client/eppo-client.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index f140c423..2dd5d2aa 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -22,6 +22,7 @@ import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.stor import { ConfigurationWireV1, IConfigurationWire, + inflateResponse, IPrecomputedConfiguration, PrecomputedConfiguration, } from '../configuration-wire/configuration-wire-types'; @@ -325,7 +326,32 @@ export default class EppoClient { ); } + bootstrap(configuration: IConfigurationWire) { + if (!configuration.config) { + throw new Error('Flag configuration not provided'); + } + const flagConfigResponse = inflateResponse(configuration.config.response); + const banditParamResponse = configuration.bandits + ? inflateResponse(configuration.bandits.response) + : undefined; + + this.configurationManager.hydrateConfigurationStoresFromUfc( + flagConfigResponse, + banditParamResponse, + ); + + // Still initialize the client in case polling is needed. + this.inititalize(); + } + + /** + * @deprecated use `initialize` instead. + */ async fetchFlagConfigurations() { + return this.inititalize(); + } + + async inititalize() { if (!this.configurationRequestParameters) { throw new Error( 'Eppo SDK unable to fetch flag configurations without configuration request parameters', From 3defee3eef0d4c4717fdfa7d9b225a7a04b93ae2 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Fri, 14 Mar 2025 13:40:10 -0600 Subject: [PATCH 04/15] Config Manager test --- .../configuration-manager.spec.ts | 537 ++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 src/configuration-store/configuration-manager.spec.ts diff --git a/src/configuration-store/configuration-manager.spec.ts b/src/configuration-store/configuration-manager.spec.ts new file mode 100644 index 00000000..409856d6 --- /dev/null +++ b/src/configuration-store/configuration-manager.spec.ts @@ -0,0 +1,537 @@ +import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../http-client'; +import { ConfigStoreHydrationPacket, StoreBackedConfiguration } from '../i-configuration'; +import { + BanditParameters, + BanditReference, + BanditVariation, + Flag, + FormatEnum, + ObfuscatedFlag, + VariationType, +} from '../interfaces'; + +import { ConfigurationManager } from './configuration-manager'; +import { IConfigurationStore } from './configuration-store'; +import { hydrateConfigurationStore } from './configuration-store-utils'; +import { MemoryOnlyConfigurationStore } from './memory.store'; + +describe('ConfigurationManager', () => { + let flagStore: IConfigurationStore; + let banditVariationStore: IConfigurationStore; + let banditModelStore: IConfigurationStore; + let configManager: ConfigurationManager; + + beforeEach(() => { + // Create fresh stores for each test + flagStore = new MemoryOnlyConfigurationStore(); + banditVariationStore = new MemoryOnlyConfigurationStore(); + banditModelStore = new MemoryOnlyConfigurationStore(); + + // Create a ConfigurationManager instance + configManager = new ConfigurationManager(flagStore, banditVariationStore, banditModelStore); + }); + + describe('constructor', () => { + it('should create a StoreBackedConfiguration with the provided stores', () => { + const config = configManager.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config.getFlagKeys()).toEqual([]); + }); + + it('should handle null bandit stores', () => { + const managerWithNullStores = new ConfigurationManager(flagStore, null, null); + const config = managerWithNullStores.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config.getFlagKeys()).toEqual([]); + }); + }); + + describe('getConfiguration', () => { + it('should return the StoreBackedConfiguration instance', () => { + const config = configManager.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + }); + }); + + describe('hydrateConfigurationStores', () => { + it('should hydrate flag configuration store', async () => { + const flagPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'var-a': { key: 'var-a', value: 'A' }, + 'var-b': { key: 'var-b', value: 'B' }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + await configManager.hydrateConfigurationStores(flagPacket); + + const config = configManager.getConfiguration(); + expect(config.getFlagKeys()).toEqual(['test-flag']); + expect(config.getFlag('test-flag')).toEqual(flagPacket.entries['test-flag']); + }); + + it('should hydrate bandit variation store', async () => { + const flagPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'bandit-var': { key: 'bandit-var', value: 'bandit' }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + const banditVariationPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': [ + { + key: 'bandit-1', + flagKey: 'test-flag', + variationKey: 'bandit-var', + variationValue: 'bandit', + }, + ], + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + await configManager.hydrateConfigurationStores(flagPacket, banditVariationPacket); + + const config = configManager.getConfiguration(); + expect(config.getFlagBanditVariations('test-flag')).toEqual( + banditVariationPacket.entries['test-flag'], + ); + }); + + it('should hydrate bandit model store', async () => { + const flagPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'bandit-var': { key: 'bandit-var', value: 'bandit' }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + const banditVariationPacket: ConfigStoreHydrationPacket = { + entries: { + 'test-flag': [ + { + key: 'bandit-1', + flagKey: 'test-flag', + variationKey: 'bandit-var', + variationValue: 'bandit', + // allocationKey: 'allocation-1', + }, + ], + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + const banditModelPacket: ConfigStoreHydrationPacket = { + entries: { + 'bandit-1': { + banditKey: 'bandit-1', + modelName: 'test-model', + modelVersion: '1.0', + // updatedAt: '2023-01-01', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }, + }, + + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }; + + await configManager.hydrateConfigurationStores( + flagPacket, + banditVariationPacket, + banditModelPacket, + ); + + const config = configManager.getConfiguration(); + expect(config.getBandit('bandit-1')).toEqual(banditModelPacket.entries['bandit-1']); + }); + }); + + describe('hydrateConfigurationStoresFromUfc', () => { + it('should return false if no flags in response', async () => { + const result = await configManager.hydrateConfigurationStoresFromUfc( + {} as IUniversalFlagConfigResponse, + ); + expect(result).toBe(false); + }); + + it('should hydrate flag configuration from UFC response', async () => { + const ufcResponse: IUniversalFlagConfigResponse = { + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'var-a': { key: 'var-a', value: 'A' }, + }, + allocations: [], + totalShards: 10, + }, + }, + banditReferences: {}, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + }; + + await configManager.hydrateConfigurationStoresFromUfc(ufcResponse); + + const config = configManager.getConfiguration(); + expect(config.getFlagKeys()).toEqual(['test-flag']); + expect(config.getFlag('test-flag')).toEqual(ufcResponse.flags['test-flag']); + }); + + it('should hydrate bandit variations from UFC response', async () => { + const ufcResponse: IUniversalFlagConfigResponse = { + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'bandit-var': { key: 'bandit-var', value: 'bandit' }, + }, + allocations: [], + totalShards: 10, + }, + }, + banditReferences: { + 'bandit-1': { + modelVersion: '1.0', + flagVariations: [ + { + key: 'bandit-1', + flagKey: 'test-flag', + variationKey: 'bandit-var', + variationValue: 'bandit', + // allocationKey: 'allocation-1', + }, + ], + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + }; + + await configManager.hydrateConfigurationStoresFromUfc(ufcResponse); + + const config = configManager.getConfiguration(); + expect(config.getFlagBanditVariations('test-flag')).toHaveLength(1); + expect(config.getFlagBanditVariations('test-flag')[0].key).toBe('bandit-1'); + }); + + it('should hydrate bandit models from bandit response', async () => { + const ufcResponse: IUniversalFlagConfigResponse = { + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'bandit-var': { key: 'bandit-var', value: 'bandit' }, + }, + allocations: [], + totalShards: 10, + }, + }, + banditReferences: { + 'bandit-1': { + modelVersion: '1.0', + flagVariations: [ + { + key: 'bandit-1', + flagKey: 'test-flag', + variationKey: 'bandit-var', + variationValue: 'bandit', + // allocationKey: 'allocation-1', + }, + ], + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + }; + + const banditResponse: IBanditParametersResponse = { + bandits: { + 'bandit-1': { + banditKey: 'bandit-1', + modelName: 'test-model', + modelVersion: '1.0', + // updatedAt: '2023-01-01', + modelData: { + coefficients: {}, + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + }, + }, + }, + }; + + await configManager.hydrateConfigurationStoresFromUfc(ufcResponse, banditResponse); + + const config = configManager.getConfiguration(); + expect(config.getBandit('bandit-1')).toEqual(banditResponse.bandits['bandit-1']); + }); + + it('should handle UFC response with no bandit references', async () => { + const ufcResponse: IUniversalFlagConfigResponse = { + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + 'var-a': { key: 'var-a', value: 'A' }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + banditReferences: {}, + }; + + await configManager.hydrateConfigurationStoresFromUfc(ufcResponse); + + const config = configManager.getConfiguration(); + expect(config.getFlagKeys()).toEqual(['test-flag']); + expect(config.getFlagBanditVariations('test-flag')).toEqual([]); + }); + }); + + describe('setConfigurationStores', () => { + it('should update the flag configuration store', async () => { + const newFlagStore = new MemoryOnlyConfigurationStore(); + + // Pre-populate the new store + await hydrateConfigurationStore(newFlagStore, { + entries: { + 'new-flag': { + key: 'new-flag', + enabled: true, + variationType: VariationType.BOOLEAN, + variations: { + true: { key: 'true', value: true }, + false: { key: 'false', value: false }, + }, + allocations: [], + totalShards: 10, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: FormatEnum.SERVER, + }); + + configManager.setConfigurationStores({ + flagConfigurationStore: newFlagStore, + banditReferenceConfigurationStore: banditVariationStore, + banditConfigurationStore: banditModelStore, + }); + + const config = configManager.getConfiguration(); + expect(config.getFlagKeys()).toEqual(['new-flag']); + }); + + it('should update the bandit variation store', async () => { + const newBanditVariationStore = new MemoryOnlyConfigurationStore(); + + // Pre-populate the new store + hydrateConfigurationStore(newBanditVariationStore, { + entries: { + 'test-flag': [ + { + key: 'new-bandit', + flagKey: 'test-flag', + variationKey: 'var-a', + variationValue: 'A', + // allocationKey: 'allocation-1', + }, + ], + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }); + + configManager.setConfigurationStores({ + flagConfigurationStore: flagStore, + banditReferenceConfigurationStore: newBanditVariationStore, + banditConfigurationStore: banditModelStore, + }); + + const config = configManager.getConfiguration(); + expect(config.getFlagBanditVariations('test-flag')).toHaveLength(1); + expect(config.getFlagBanditVariations('test-flag')[0].key).toBe('new-bandit'); + }); + + it('should update the bandit model store', async () => { + const newBanditModelStore = new MemoryOnlyConfigurationStore(); + + // Pre-populate the new store + hydrateConfigurationStore(newBanditModelStore, { + entries: { + 'new-bandit': { + banditKey: 'new-bandit', + modelName: 'new-model', + modelVersion: '2.0', + // updatedAt: '2023-02-01', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }, + }, + environment: { name: 'test' }, + createdAt: '2023-01-01', + format: 'SERVER', + }); + + configManager.setConfigurationStores({ + flagConfigurationStore: flagStore, + banditReferenceConfigurationStore: banditVariationStore, + banditConfigurationStore: newBanditModelStore, + }); + + const config = configManager.getConfiguration(); + expect(config.getBandit('new-bandit')).toEqual({ + banditKey: 'new-bandit', + modelName: 'new-model', + modelVersion: '2.0', + // updatedAt: '2023-02-01', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }); + }); + + it('should handle optional bandit stores', () => { + configManager.setConfigurationStores({ + flagConfigurationStore: flagStore, + }); + + const config = configManager.getConfiguration(); + expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config.getFlagKeys()).toEqual([]); + }); + }); + + describe('indexBanditVariationsByFlagKey', () => { + it('should correctly index bandit variations by flag key', async () => { + // We need to test the private method, so we'll use a test-only approach + // by creating a subclass that exposes the private method for testing + class TestableConfigManager extends ConfigurationManager { + public testIndexBanditVariationsByFlagKey(banditRefs: Record) { + return this['indexBanditVariationsByFlagKey'](banditRefs); + } + } + + const testManager = new TestableConfigManager( + flagStore, + banditVariationStore, + banditModelStore, + ); + + const banditReferences: Record = { + 'bandit-1': { + modelVersion: '1.0', + flagVariations: [ + { + key: 'bandit-1-var-1', + flagKey: 'flag-1', + variationKey: 'var-a', + variationValue: 'A', + // allocationKey: 'alloc-1', + }, + { + key: 'bandit-1-var-2', + flagKey: 'flag-2', + variationKey: 'var-b', + variationValue: 'B', + // allocationKey: 'alloc-2', + }, + ], + }, + 'bandit-2': { + modelVersion: '2.0', + flagVariations: [ + { + key: 'bandit-2-var-1', + flagKey: 'flag-1', + variationKey: 'var-c', + variationValue: 'C', + // allocationKey: 'alloc-3', + }, + ], + }, + }; + + const result = testManager.testIndexBanditVariationsByFlagKey(banditReferences); + + expect(Object.keys(result)).toEqual(['flag-1', 'flag-2']); + expect(result['flag-1']).toHaveLength(2); + expect(result['flag-2']).toHaveLength(1); + + // Check that the variations are correctly assigned to their flag keys + expect(result['flag-1'].map((v) => v.key)).toEqual(['bandit-1-var-1', 'bandit-2-var-1']); + expect(result['flag-2'].map((v) => v.key)).toEqual(['bandit-1-var-2']); + }); + }); +}); From 247213c701188ec5ddb1b5f6137c0e5681b10539 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Mon, 17 Mar 2025 14:02:00 -0600 Subject: [PATCH 05/15] unify config store setting and round out bootstrap method --- src/client/eppo-client.ts | 90 ++++++++++++------- src/client/test-utils.ts | 2 +- .../configuration-manager.spec.ts | 2 +- .../configuration-manager.ts | 8 +- .../i-configuration-manager.ts | 12 +-- src/index.ts | 2 + 6 files changed, 75 insertions(+), 41 deletions(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 2dd5d2aa..0cbb8cde 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -17,7 +17,10 @@ import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment- import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; -import { IConfigurationManager } from '../configuration-store/i-configuration-manager'; +import { + ConfigurationStoreBundle, + IConfigurationManager, +} from '../configuration-store/i-configuration-manager'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { ConfigurationWireV1, @@ -43,7 +46,10 @@ import { IFlagEvaluationDetails, } from '../flag-evaluation-details-builder'; import { FlagEvaluationError } from '../flag-evaluation-error'; -import FetchHttpClient from '../http-client'; +import FetchHttpClient, { + IBanditParametersResponse, + IUniversalFlagConfigResponse, +} from '../http-client'; import { IConfiguration } from '../i-configuration'; import { BanditModelData, @@ -173,9 +179,8 @@ export default class EppoClient { // Initialize the configuration manager this.configurationManager = new ConfigurationManager( this.flagConfigurationStore, - this.banditVariationConfigurationStore || - new MemoryOnlyConfigurationStore(), - this.banditModelConfigurationStore || new MemoryOnlyConfigurationStore(), + this.banditVariationConfigurationStore, + this.banditModelConfigurationStore, ); } @@ -244,46 +249,54 @@ export default class EppoClient { this.configurationRequestParameters = configurationRequestParameters; } + public setConfigurationStores(configStores: ConfigurationStoreBundle) { + // Update the configuration manager + this.configurationManager.setConfigurationStores(configStores); + } + + // noinspection JSUnusedGlobalSymbols + /** + * @deprecated use `setConfigurationStores` instead + */ setFlagConfigurationStore(flagConfigurationStore: IConfigurationStore) { this.flagConfigurationStore = flagConfigurationStore; this.configObfuscatedCache = undefined; // Update the configuration manager - this.configurationManager.setConfigurationStores({ - flagConfigurationStore: this.flagConfigurationStore, - banditReferenceConfigurationStore: - this.banditVariationConfigurationStore || - new MemoryOnlyConfigurationStore(), - banditConfigurationStore: - this.banditModelConfigurationStore || new MemoryOnlyConfigurationStore(), - }); + this.innerSetConfigurationStores(); } + // noinspection JSUnusedGlobalSymbols + /** + * @deprecated use `setConfigurationStores` instead + */ setBanditVariationConfigurationStore( banditVariationConfigurationStore: IConfigurationStore, ) { this.banditVariationConfigurationStore = banditVariationConfigurationStore; // Update the configuration manager - this.configurationManager.setConfigurationStores({ - flagConfigurationStore: this.flagConfigurationStore, - banditReferenceConfigurationStore: this.banditVariationConfigurationStore, - banditConfigurationStore: - this.banditModelConfigurationStore || new MemoryOnlyConfigurationStore(), - }); + this.innerSetConfigurationStores(); } + // noinspection JSUnusedGlobalSymbols + /** + * @deprecated use `setConfigurationStores` instead + */ setBanditModelConfigurationStore( banditModelConfigurationStore: IConfigurationStore, ) { this.banditModelConfigurationStore = banditModelConfigurationStore; // Update the configuration manager - this.configurationManager.setConfigurationStores({ + this.innerSetConfigurationStores(); + } + + private innerSetConfigurationStores() { + // Set the set of configuration stores to those owned by the `this`. + this.setConfigurationStores({ flagConfigurationStore: this.flagConfigurationStore, - banditReferenceConfigurationStore: - this.banditVariationConfigurationStore || - new MemoryOnlyConfigurationStore(), + banditReferenceConfigurationStore: this.banditVariationConfigurationStore, banditConfigurationStore: this.banditModelConfigurationStore, }); } @@ -326,32 +339,49 @@ export default class EppoClient { ); } - bootstrap(configuration: IConfigurationWire) { + /** + * Initializes the `EppoClient` from the provided configuration. This method is async only to + * accommodate writing to a persistent store. For fastest initialization, (at the cost of persisting configuration), + * use `bootstrap` in conjunction with `MemoryOnlyConfigurationStore` instances which won't do an async write. + */ + async bootstrap(configuration: IConfigurationWire): Promise { if (!configuration.config) { throw new Error('Flag configuration not provided'); } - const flagConfigResponse = inflateResponse(configuration.config.response); - const banditParamResponse = configuration.bandits + const flagConfigResponse: IUniversalFlagConfigResponse = inflateResponse( + configuration.config.response, + ); + const banditParamResponse: IBanditParametersResponse | undefined = configuration.bandits ? inflateResponse(configuration.bandits.response) : undefined; - this.configurationManager.hydrateConfigurationStoresFromUfc( + // We need to run this method sync, but, because the configuration stores potentially have an async write at the end + // of updating the configuration, the method to do so it also async. Use an IIFE to wrap the async call. + await this.configurationManager.hydrateConfigurationStoresFromUfc( flagConfigResponse, banditParamResponse, ); // Still initialize the client in case polling is needed. - this.inititalize(); + // fire-and-forget so ignore the resolution of this promise. + this.initialize() + .then(() => { + logger.debug('Eppo SDK polling initialization complete'); + return; + }) + .catch((e) => { + logger.error('Eppo SDK Error initializing polling after bootstrap()', e); + }); } /** * @deprecated use `initialize` instead. */ async fetchFlagConfigurations() { - return this.inititalize(); + return this.initialize(); } - async inititalize() { + async initialize() { if (!this.configurationRequestParameters) { throw new Error( 'Eppo SDK unable to fetch flag configurations without configuration request parameters', diff --git a/src/client/test-utils.ts b/src/client/test-utils.ts index f917fd0a..30cb971b 100644 --- a/src/client/test-utils.ts +++ b/src/client/test-utils.ts @@ -19,7 +19,7 @@ export async function initConfiguration( const httpClient = new FetchHttpClient(apiEndpoints, 1000); const configurationRequestor = new ConfigurationRequestor( httpClient, - new ConfigurationManager(configurationStore, null, null), + new ConfigurationManager(configurationStore), false, ); await configurationRequestor.fetchAndStoreConfigurations(); diff --git a/src/configuration-store/configuration-manager.spec.ts b/src/configuration-store/configuration-manager.spec.ts index 409856d6..42877c40 100644 --- a/src/configuration-store/configuration-manager.spec.ts +++ b/src/configuration-store/configuration-manager.spec.ts @@ -39,7 +39,7 @@ describe('ConfigurationManager', () => { }); it('should handle null bandit stores', () => { - const managerWithNullStores = new ConfigurationManager(flagStore, null, null); + const managerWithNullStores = new ConfigurationManager(flagStore); const config = managerWithNullStores.getConfiguration(); expect(config).toBeInstanceOf(StoreBackedConfiguration); expect(config.getFlagKeys()).toEqual([]); diff --git a/src/configuration-store/configuration-manager.ts b/src/configuration-store/configuration-manager.ts index 56e79291..38769df4 100644 --- a/src/configuration-store/configuration-manager.ts +++ b/src/configuration-store/configuration-manager.ts @@ -20,8 +20,8 @@ export class ConfigurationManager implements IConfigurationManager { constructor( private flagConfigurationStore: IConfigurationStore, - private banditReferenceConfigurationStore: IConfigurationStore | null, - private banditConfigurationStore: IConfigurationStore | null, + private banditReferenceConfigurationStore?: IConfigurationStore, + private banditConfigurationStore?: IConfigurationStore, ) { this.configuration = new StoreBackedConfiguration( this.flagConfigurationStore, @@ -101,8 +101,8 @@ export class ConfigurationManager implements IConfigurationManager { banditConfigurationStore?: IConfigurationStore; }): void { this.flagConfigurationStore = configStores.flagConfigurationStore; - this.banditReferenceConfigurationStore = configStores.banditReferenceConfigurationStore ?? null; - this.banditConfigurationStore = configStores.banditConfigurationStore ?? null; + this.banditReferenceConfigurationStore = configStores.banditReferenceConfigurationStore; + this.banditConfigurationStore = configStores.banditConfigurationStore; // Recreate the configuration with the new stores this.configuration = new StoreBackedConfiguration( diff --git a/src/configuration-store/i-configuration-manager.ts b/src/configuration-store/i-configuration-manager.ts index a6fdef47..7054cb31 100644 --- a/src/configuration-store/i-configuration-manager.ts +++ b/src/configuration-store/i-configuration-manager.ts @@ -4,6 +4,12 @@ import { BanditParameters, BanditVariation, Flag, ObfuscatedFlag } from '../inte import { IConfigurationStore } from './configuration-store'; +export type ConfigurationStoreBundle = { + flagConfigurationStore: IConfigurationStore; + banditReferenceConfigurationStore?: IConfigurationStore; + banditConfigurationStore?: IConfigurationStore; +}; + export interface IConfigurationManager { getConfiguration(): IConfiguration; hydrateConfigurationStores( @@ -15,9 +21,5 @@ export interface IConfigurationManager { flags: IUniversalFlagConfigResponse, bandits?: IBanditParametersResponse, ): Promise; - setConfigurationStores(configStores: { - flagConfigurationStore: IConfigurationStore; - banditReferenceConfigurationStore?: IConfigurationStore; - banditConfigurationStore?: IConfigurationStore; - }): void; + setConfigurationStores(configStores: ConfigurationStoreBundle): void; } diff --git a/src/index.ts b/src/index.ts index a9ebaee9..9a0f65e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ import { ISyncStore, } from './configuration-store/configuration-store'; import { HybridConfigurationStore } from './configuration-store/hybrid.store'; +import { ConfigurationStoreBundle } from './configuration-store/i-configuration-manager'; import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import { ConfigurationWireHelper } from './configuration-wire/configuration-wire-helper'; import { @@ -110,6 +111,7 @@ export { MemoryStore, HybridConfigurationStore, MemoryOnlyConfigurationStore, + ConfigurationStoreBundle, // Assignment cache AssignmentCacheKey, From 5d2b695ea65f82bd45b282c81e26c2ae30b72a5f Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Mon, 17 Mar 2025 14:30:03 -0600 Subject: [PATCH 06/15] MOAR tests --- src/client/eppo-client.spec.ts | 245 +++++++++++++++++- .../configuration-manager.spec.ts | 7 - 2 files changed, 244 insertions(+), 8 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 4ef63f18..95110ea9 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -15,6 +15,7 @@ import { } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; +import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { IConfigurationStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { @@ -24,7 +25,15 @@ import { } from '../configuration-wire/configuration-wire-types'; import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; import { decodePrecomputedFlag } from '../decoding'; -import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../interfaces'; +import { + Flag, + ObfuscatedFlag, + VariationType, + FormatEnum, + Variation, + BanditVariation, + BanditParameters, +} from '../interfaces'; import { getMD5Hash } from '../obfuscation'; import { AttributeType } from '../types'; @@ -1191,3 +1200,237 @@ describe('EppoClient E2E test', () => { }); }); }); + +describe('EppoClient ConfigurationManager Integration', () => { + let client: EppoClient; + let flagStore: MemoryOnlyConfigurationStore; + let banditVariationStore: MemoryOnlyConfigurationStore; + let banditModelStore: MemoryOnlyConfigurationStore; + + // Sample flag with correct shape + const testFlag: Flag = { + key: 'test-flag', + enabled: true, + variationType: VariationType.STRING, + variations: { + control: { key: 'control', value: 'control-value' }, + treatment: { key: 'treatment', value: 'treatment-value' }, + }, + allocations: [ + { + key: 'allocation-1', + rules: [], + splits: [ + { + shards: [], + variationKey: 'treatment', + }, + ], + doLog: true, + }, + ], + totalShards: 10000, + }; + + // Sample bandit variation with correct shape + const testBanditVariation: BanditVariation = { + key: 'test-bandit', + flagKey: 'test-flag', + variationKey: 'treatment', + variationValue: 'treatment-value', + }; + + // Sample bandit parameters with correct shape + const testBanditParameters: BanditParameters = { + banditKey: 'test-bandit', + modelName: 'test-model', + modelVersion: '1.0', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Create fresh stores for each test + flagStore = new MemoryOnlyConfigurationStore(); + banditVariationStore = new MemoryOnlyConfigurationStore(); + banditModelStore = new MemoryOnlyConfigurationStore(); + + // Create client with the stores + client = new EppoClient({ + flagConfigurationStore: flagStore, + banditVariationConfigurationStore: banditVariationStore, + banditModelConfigurationStore: banditModelStore, + }); + }); + + it('should initialize ConfigurationManager in constructor', () => { + // Access the private configurationManager field + const configManager = (client as any).configurationManager; + + expect(configManager).toBeDefined(); + expect(configManager).toBeInstanceOf(ConfigurationManager); + }); + + it('should use ConfigurationManager for getConfiguration', () => { + // Create a spy on the ConfigurationManager's getConfiguration method + const configManager = (client as any).configurationManager; + const getConfigSpy = jest.spyOn(configManager, 'getConfiguration'); + + // Call the client's getConfiguration method + const config = (client as any).getConfiguration(); + + // Verify the manager's method was called + expect(getConfigSpy).toHaveBeenCalled(); + expect(config).toBe(configManager.getConfiguration()); + }); + + it('should update ConfigurationManager when setFlagConfigurationStore is called', async () => { + // Create a new store + const newFlagStore = new MemoryOnlyConfigurationStore(); + + // Pre-populate with a test flag + await newFlagStore.setEntries({ 'test-flag': testFlag }); + newFlagStore.setFormat(FormatEnum.SERVER); + + // Create a spy on the ConfigurationManager's setConfigurationStores method + const configManager = (client as any).configurationManager; + const setStoresSpy = jest.spyOn(configManager, 'setConfigurationStores'); + + // Call the setter method + client.setFlagConfigurationStore(newFlagStore); + + // Verify the manager's method was called with the correct arguments + expect(setStoresSpy).toHaveBeenCalledWith( + expect.objectContaining({ + flagConfigurationStore: newFlagStore, + }), + ); + + // Verify the configuration was updated by checking if we can access the flag + const config = configManager.getConfiguration(); + expect(config.getFlag('test-flag')).toEqual(testFlag); + }); + + it('should update ConfigurationManager when setBanditVariationConfigurationStore is called', async () => { + // Create a new store + const newBanditVariationStore = new MemoryOnlyConfigurationStore(); + + // Pre-populate with test data + await newBanditVariationStore.setEntries({ + 'test-flag': [testBanditVariation], + }); + newBanditVariationStore.setFormat(FormatEnum.SERVER); + + // Create a spy on the ConfigurationManager's setConfigurationStores method + const configManager = (client as any).configurationManager; + const setStoresSpy = jest.spyOn(configManager, 'setConfigurationStores'); + + // Call the setter method + client.setBanditVariationConfigurationStore(newBanditVariationStore); + + // Verify the manager's method was called with the correct arguments + expect(setStoresSpy).toHaveBeenCalledWith( + expect.objectContaining({ + banditReferenceConfigurationStore: newBanditVariationStore, + }), + ); + + // Verify the configuration was updated + const config = configManager.getConfiguration(); + expect(config.getBanditVariations()['test-flag']).toEqual([testBanditVariation]); + }); + + it('should update ConfigurationManager when setBanditModelConfigurationStore is called', async () => { + // Create a new store + const newBanditModelStore = new MemoryOnlyConfigurationStore(); + + // Pre-populate with test data + await newBanditModelStore.setEntries({ + 'test-bandit': testBanditParameters, + }); + newBanditModelStore.setFormat(FormatEnum.SERVER); + + // Create a spy on the ConfigurationManager's setConfigurationStores method + const configManager = (client as any).configurationManager; + const setStoresSpy = jest.spyOn(configManager, 'setConfigurationStores'); + + // Call the setter method + client.setBanditModelConfigurationStore(newBanditModelStore); + + // Verify the manager's method was called with the correct arguments + expect(setStoresSpy).toHaveBeenCalledWith( + expect.objectContaining({ + banditConfigurationStore: newBanditModelStore, + }), + ); + + // Verify the configuration was updated + const config = configManager.getConfiguration(); + expect(config.getBandits()['test-bandit']).toEqual(testBanditParameters); + }); + + it('should use configuration from ConfigurationManager for assignment decisions', async () => { + // Create a new flag store with a test flag + const newFlagStore = new MemoryOnlyConfigurationStore(); + await newFlagStore.setEntries({ 'test-flag': testFlag }); + newFlagStore.setFormat(FormatEnum.SERVER); + + // Update the client's flag store + client.setFlagConfigurationStore(newFlagStore); + + // Create a spy on the ConfigurationManager's getConfiguration method + const configManager = (client as any).configurationManager; + const getConfigSpy = jest.spyOn(configManager, 'getConfiguration'); + + // Get an assignment + const assignment = client.getStringAssignment('test-flag', 'subject-1', {}, 'default'); + + // Verify the manager's getConfiguration method was called + expect(getConfigSpy).toHaveBeenCalled(); + + // Verify we got the expected assignment (based on the test flag's configuration) + expect(assignment).toBe('treatment-value'); + }); + + it('should reflect changes in ConfigurationManager immediately in assignments', async () => { + // First, set up a flag store with one variation + const initialFlagStore = new MemoryOnlyConfigurationStore(); + const initialFlag = { ...testFlag }; + await initialFlagStore.setEntries({ 'test-flag': initialFlag }); + initialFlagStore.setFormat(FormatEnum.SERVER); + + client.setFlagConfigurationStore(initialFlagStore); + + // Get initial assignment + const initialAssignment = client.getStringAssignment('test-flag', 'subject-1', {}, 'default'); + expect(initialAssignment).toBe('treatment-value'); + + // Now create a new flag store with a different variation + const updatedFlagStore = new MemoryOnlyConfigurationStore(); + const updatedFlag = { + ...testFlag, + variations: { + control: { key: 'control', value: 'control-value' }, + treatment: { key: 'treatment', value: 'new-treatment-value' }, + }, + }; + await updatedFlagStore.setEntries({ 'test-flag': updatedFlag }); + updatedFlagStore.setFormat(FormatEnum.SERVER); + + // Update the client's flag store + client.setFlagConfigurationStore(updatedFlagStore); + + // Get updated assignment + const updatedAssignment = client.getStringAssignment('test-flag', 'subject-1', {}, 'default'); + + // Verify the assignment reflects the updated configuration + expect(updatedAssignment).toBe('new-treatment-value'); + }); +}); diff --git a/src/configuration-store/configuration-manager.spec.ts b/src/configuration-store/configuration-manager.spec.ts index 42877c40..c8e3de07 100644 --- a/src/configuration-store/configuration-manager.spec.ts +++ b/src/configuration-store/configuration-manager.spec.ts @@ -151,7 +151,6 @@ describe('ConfigurationManager', () => { flagKey: 'test-flag', variationKey: 'bandit-var', variationValue: 'bandit', - // allocationKey: 'allocation-1', }, ], }, @@ -250,7 +249,6 @@ describe('ConfigurationManager', () => { flagKey: 'test-flag', variationKey: 'bandit-var', variationValue: 'bandit', - // allocationKey: 'allocation-1', }, ], }, @@ -290,7 +288,6 @@ describe('ConfigurationManager', () => { flagKey: 'test-flag', variationKey: 'bandit-var', variationValue: 'bandit', - // allocationKey: 'allocation-1', }, ], }, @@ -397,7 +394,6 @@ describe('ConfigurationManager', () => { flagKey: 'test-flag', variationKey: 'var-a', variationValue: 'A', - // allocationKey: 'allocation-1', }, ], }, @@ -498,14 +494,12 @@ describe('ConfigurationManager', () => { flagKey: 'flag-1', variationKey: 'var-a', variationValue: 'A', - // allocationKey: 'alloc-1', }, { key: 'bandit-1-var-2', flagKey: 'flag-2', variationKey: 'var-b', variationValue: 'B', - // allocationKey: 'alloc-2', }, ], }, @@ -517,7 +511,6 @@ describe('ConfigurationManager', () => { flagKey: 'flag-1', variationKey: 'var-c', variationValue: 'C', - // allocationKey: 'alloc-3', }, ], }, From e26207bd3ddddcf49e096664dace9ab6b406552b Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Mon, 17 Mar 2025 22:41:21 -0600 Subject: [PATCH 07/15] universal tests --- src/client/eppo-client-with-bandits.spec.ts | 36 +- src/client/eppo-client.spec.ts | 482 ++++++------------ .../configuration-wire-types.ts | 4 + test/testHelpers.ts | 3 + 4 files changed, 197 insertions(+), 328 deletions(-) diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index efd5e6e3..eb2ba9d5 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -7,6 +7,8 @@ import { testCasesByFileName, BanditTestCase, BANDIT_TEST_DATA_DIR, + readMockConfigurationWireResponse, + SHARED_BOOTSTRAP_BANDIT_FLAGS_FILE, } from '../../test/testHelpers'; import ApiEndpoints from '../api-endpoints'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; @@ -19,6 +21,7 @@ import { IConfigurationWire, IPrecomputedConfiguration, IObfuscatedPrecomputedConfigurationResponse, + ConfigurationWireV1, } from '../configuration-wire/configuration-wire-types'; import { Evaluator, FlagEvaluation } from '../evaluator'; import { @@ -94,8 +97,8 @@ describe('EppoClient Bandits E2E test', () => { describe('Shared test cases', () => { const testCases = testCasesByFileName(BANDIT_TEST_DATA_DIR); - it.each(Object.keys(testCases))('Shared bandit test case - %s', async (fileName: string) => { - const { flag: flagKey, defaultValue, subjects } = testCases[fileName]; + function testBanditCaseAgainstClient(client: EppoClient, testCase: BanditTestCase) { + const { flag: flagKey, defaultValue, subjects } = testCase; let numAssignmentsChecked = 0; subjects.forEach((subject) => { // test files have actions as an array, so we convert them to a map as expected by the client @@ -131,6 +134,35 @@ describe('EppoClient Bandits E2E test', () => { }); // Ensure that this test case correctly checked some test assignments expect(numAssignmentsChecked).toBeGreaterThan(0); + } + + describe('bootstrapped client', () => { + const banditFlagsConfig = ConfigurationWireV1.fromString( + readMockConfigurationWireResponse(SHARED_BOOTSTRAP_BANDIT_FLAGS_FILE), + ); + + let client: EppoClient; + beforeAll(async () => { + client = new EppoClient({ + flagConfigurationStore: new MemoryOnlyConfigurationStore(), + banditVariationConfigurationStore: new MemoryOnlyConfigurationStore(), + banditModelConfigurationStore: new MemoryOnlyConfigurationStore(), + }); + client.setIsGracefulFailureMode(false); + + // Bootstrap using the bandit flag config. + await client.bootstrap(banditFlagsConfig); + }); + + it.each(Object.keys(testCases))('Shared bandit test case - %s', async (fileName: string) => { + testBanditCaseAgainstClient(client, testCases[fileName]); + }); + }); + + describe('traditional client', () => { + it.each(Object.keys(testCases))('Shared bandit test case - %s', async (fileName: string) => { + testBanditCaseAgainstClient(client, testCases[fileName]); + }); }); }); diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 95110ea9..14a4df73 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -8,32 +8,27 @@ import { IAssignmentTestCase, MOCK_UFC_RESPONSE_FILE, OBFUSCATED_MOCK_UFC_RESPONSE_FILE, + readMockConfigurationWireResponse, readMockUFCResponse, + SHARED_BOOTSTRAP_FLAGS_FILE, + SHARED_BOOTSTRAP_FLAGS_OBFUSCATED_FILE, SubjectTestCase, testCasesByFileName, validateTestAssignments, } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; -import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { IConfigurationStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { + ConfigurationWireV1, IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, ObfuscatedPrecomputedConfigurationResponse, } from '../configuration-wire/configuration-wire-types'; import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; import { decodePrecomputedFlag } from '../decoding'; -import { - Flag, - ObfuscatedFlag, - VariationType, - FormatEnum, - Variation, - BanditVariation, - BanditParameters, -} from '../interfaces'; +import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; import { AttributeType } from '../types'; @@ -326,109 +321,178 @@ describe('EppoClient E2E test', () => { }); }); + const testCases = testCasesByFileName(ASSIGNMENT_TEST_DATA_DIR); + + function testCasesAgainstClient(client: EppoClient, testCase: IAssignmentTestCase) { + const { flag, variationType, defaultValue, subjects } = testCase; + + let assignments: { + subject: SubjectTestCase; + assignment: string | boolean | number | null | object; + }[] = []; + + const typeAssignmentFunctions = { + [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), + [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), + [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), + [VariationType.STRING]: client.getStringAssignment.bind(client), + [VariationType.JSON]: client.getJSONAssignment.bind(client), + }; + + const assignmentFn = typeAssignmentFunctions[variationType] as ( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: boolean | string | number | object, + ) => never; + if (!assignmentFn) { + throw new Error(`Unknown variation type: ${variationType}`); + } + + assignments = getTestAssignments({ flag, variationType, defaultValue, subjects }, assignmentFn); + + validateTestAssignments(assignments, flag); + } + describe('UFC Shared Test Cases', () => { const testCases = testCasesByFileName(ASSIGNMENT_TEST_DATA_DIR); - describe('Not obfuscated', () => { - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), + describe('boostrapped client', () => { + const bootstrapFlagsConfig = ConfigurationWireV1.fromString( + readMockConfigurationWireResponse(SHARED_BOOTSTRAP_FLAGS_FILE), + ); + const bootstrapFlagsObfuscatedConfig = ConfigurationWireV1.fromString( + readMockConfigurationWireResponse(SHARED_BOOTSTRAP_FLAGS_OBFUSCATED_FILE), + ); + + describe('Not obfuscated', () => { + let client: EppoClient; + beforeAll(() => { + client = new EppoClient({ + flagConfigurationStore: new MemoryOnlyConfigurationStore(), }); - }) as jest.Mock; + client.setIsGracefulFailureMode(false); - await initConfiguration(storage); - }); + // Bootstrap using the flags config. + client.bootstrap(bootstrapFlagsConfig); + }); + + it('contains some key flags', () => { + const flagKeys = client.getFlagConfigurations(); - afterAll(() => { - jest.restoreAllMocks(); + expect(Object.keys(flagKeys)).toContain('numeric_flag'); + expect(Object.keys(flagKeys)).toContain('kill-switch'); + }); + + it.each(Object.keys(testCases))('test variation assignment splits - %s', (fileName) => { + testCasesAgainstClient(client, testCases[fileName]); + }); }); - it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { - const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient({ flagConfigurationStore: storage }); - client.setIsGracefulFailureMode(false); + describe('Obfuscated', () => { + let client: EppoClient; + beforeAll(async () => { + client = new EppoClient({ + flagConfigurationStore: new MemoryOnlyConfigurationStore(), + }); + client.setIsGracefulFailureMode(false); - let assignments: { - subject: SubjectTestCase; - assignment: string | boolean | number | null | object; - }[] = []; - - const typeAssignmentFunctions = { - [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), - [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), - [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), - [VariationType.STRING]: client.getStringAssignment.bind(client), - [VariationType.JSON]: client.getJSONAssignment.bind(client), - }; - - const assignmentFn = typeAssignmentFunctions[variationType] as ( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean | string | number | object, - ) => never; - if (!assignmentFn) { - throw new Error(`Unknown variation type: ${variationType}`); - } + // Bootstrap using the obfuscated flags config. + await client.bootstrap(bootstrapFlagsObfuscatedConfig); + }); - assignments = getTestAssignments( - { flag, variationType, defaultValue, subjects }, - assignmentFn, - ); + it('contains some key flags', () => { + const flagKeys = client.getFlagConfigurations(); - validateTestAssignments(assignments, flag); + expect(Object.keys(flagKeys)).toContain('73fcc84c69e49e31fe16a29b2b1f803b'); + expect(Object.keys(flagKeys)).toContain('69d2ea567a75b7b2da9648bf312dc3a5'); + }); + + it.each(Object.keys(testCases))('test variation assignment splits - %s', (fileName) => { + testCasesAgainstClient(client, testCases[fileName]); + }); }); }); - describe('Obfuscated', () => { - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(readMockUFCResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE)), - }); - }) as jest.Mock; + describe('traditional client', () => { + describe('Not obfuscated', () => { + beforeAll(async () => { + global.fetch = jest.fn(() => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), + }); + }) as jest.Mock; + + await initConfiguration(storage); + }); - await initConfiguration(storage); - }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + it.each(Object.keys(testCases))( + 'test variation assignment splits - %s', + async (fileName) => { + const client = new EppoClient({ flagConfigurationStore: storage }); + client.setIsGracefulFailureMode(false); - afterAll(() => { - jest.restoreAllMocks(); + testCasesAgainstClient(client, testCases[fileName]); + }, + ); }); - it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { - const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated: true }); - client.setIsGracefulFailureMode(false); + describe('Obfuscated', () => { + beforeAll(async () => { + global.fetch = jest.fn(() => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(readMockUFCResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE)), + }); + }) as jest.Mock; + + await initConfiguration(storage); + }); - const typeAssignmentFunctions = { - [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), - [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), - [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), - [VariationType.STRING]: client.getStringAssignment.bind(client), - [VariationType.JSON]: client.getJSONAssignment.bind(client), - }; - - const assignmentFn = typeAssignmentFunctions[variationType] as ( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean | string | number | object, - ) => never; - if (!assignmentFn) { - throw new Error(`Unknown variation type: ${variationType}`); - } + afterAll(() => { + jest.restoreAllMocks(); + }); - const assignments = getTestAssignments( - { flag, variationType, defaultValue, subjects }, - assignmentFn, + it.each(Object.keys(testCases))( + 'test variation assignment splits - %s', + async (fileName) => { + const { flag, variationType, defaultValue, subjects } = testCases[fileName]; + const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated: true }); + client.setIsGracefulFailureMode(false); + + const typeAssignmentFunctions = { + [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), + [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), + [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), + [VariationType.STRING]: client.getStringAssignment.bind(client), + [VariationType.JSON]: client.getJSONAssignment.bind(client), + }; + + const assignmentFn = typeAssignmentFunctions[variationType] as ( + flagKey: string, + subjectKey: string, + subjectAttributes: Record, + defaultValue: boolean | string | number | object, + ) => never; + if (!assignmentFn) { + throw new Error(`Unknown variation type: ${variationType}`); + } + + const assignments = getTestAssignments( + { flag, variationType, defaultValue, subjects }, + assignmentFn, + ); + + validateTestAssignments(assignments, flag); + }, ); - - validateTestAssignments(assignments, flag); }); }); }); @@ -1200,237 +1264,3 @@ describe('EppoClient E2E test', () => { }); }); }); - -describe('EppoClient ConfigurationManager Integration', () => { - let client: EppoClient; - let flagStore: MemoryOnlyConfigurationStore; - let banditVariationStore: MemoryOnlyConfigurationStore; - let banditModelStore: MemoryOnlyConfigurationStore; - - // Sample flag with correct shape - const testFlag: Flag = { - key: 'test-flag', - enabled: true, - variationType: VariationType.STRING, - variations: { - control: { key: 'control', value: 'control-value' }, - treatment: { key: 'treatment', value: 'treatment-value' }, - }, - allocations: [ - { - key: 'allocation-1', - rules: [], - splits: [ - { - shards: [], - variationKey: 'treatment', - }, - ], - doLog: true, - }, - ], - totalShards: 10000, - }; - - // Sample bandit variation with correct shape - const testBanditVariation: BanditVariation = { - key: 'test-bandit', - flagKey: 'test-flag', - variationKey: 'treatment', - variationValue: 'treatment-value', - }; - - // Sample bandit parameters with correct shape - const testBanditParameters: BanditParameters = { - banditKey: 'test-bandit', - modelName: 'test-model', - modelVersion: '1.0', - modelData: { - gamma: 0, - defaultActionScore: 0, - actionProbabilityFloor: 0, - coefficients: {}, - }, - }; - - beforeEach(() => { - // Reset mocks - jest.clearAllMocks(); - - // Create fresh stores for each test - flagStore = new MemoryOnlyConfigurationStore(); - banditVariationStore = new MemoryOnlyConfigurationStore(); - banditModelStore = new MemoryOnlyConfigurationStore(); - - // Create client with the stores - client = new EppoClient({ - flagConfigurationStore: flagStore, - banditVariationConfigurationStore: banditVariationStore, - banditModelConfigurationStore: banditModelStore, - }); - }); - - it('should initialize ConfigurationManager in constructor', () => { - // Access the private configurationManager field - const configManager = (client as any).configurationManager; - - expect(configManager).toBeDefined(); - expect(configManager).toBeInstanceOf(ConfigurationManager); - }); - - it('should use ConfigurationManager for getConfiguration', () => { - // Create a spy on the ConfigurationManager's getConfiguration method - const configManager = (client as any).configurationManager; - const getConfigSpy = jest.spyOn(configManager, 'getConfiguration'); - - // Call the client's getConfiguration method - const config = (client as any).getConfiguration(); - - // Verify the manager's method was called - expect(getConfigSpy).toHaveBeenCalled(); - expect(config).toBe(configManager.getConfiguration()); - }); - - it('should update ConfigurationManager when setFlagConfigurationStore is called', async () => { - // Create a new store - const newFlagStore = new MemoryOnlyConfigurationStore(); - - // Pre-populate with a test flag - await newFlagStore.setEntries({ 'test-flag': testFlag }); - newFlagStore.setFormat(FormatEnum.SERVER); - - // Create a spy on the ConfigurationManager's setConfigurationStores method - const configManager = (client as any).configurationManager; - const setStoresSpy = jest.spyOn(configManager, 'setConfigurationStores'); - - // Call the setter method - client.setFlagConfigurationStore(newFlagStore); - - // Verify the manager's method was called with the correct arguments - expect(setStoresSpy).toHaveBeenCalledWith( - expect.objectContaining({ - flagConfigurationStore: newFlagStore, - }), - ); - - // Verify the configuration was updated by checking if we can access the flag - const config = configManager.getConfiguration(); - expect(config.getFlag('test-flag')).toEqual(testFlag); - }); - - it('should update ConfigurationManager when setBanditVariationConfigurationStore is called', async () => { - // Create a new store - const newBanditVariationStore = new MemoryOnlyConfigurationStore(); - - // Pre-populate with test data - await newBanditVariationStore.setEntries({ - 'test-flag': [testBanditVariation], - }); - newBanditVariationStore.setFormat(FormatEnum.SERVER); - - // Create a spy on the ConfigurationManager's setConfigurationStores method - const configManager = (client as any).configurationManager; - const setStoresSpy = jest.spyOn(configManager, 'setConfigurationStores'); - - // Call the setter method - client.setBanditVariationConfigurationStore(newBanditVariationStore); - - // Verify the manager's method was called with the correct arguments - expect(setStoresSpy).toHaveBeenCalledWith( - expect.objectContaining({ - banditReferenceConfigurationStore: newBanditVariationStore, - }), - ); - - // Verify the configuration was updated - const config = configManager.getConfiguration(); - expect(config.getBanditVariations()['test-flag']).toEqual([testBanditVariation]); - }); - - it('should update ConfigurationManager when setBanditModelConfigurationStore is called', async () => { - // Create a new store - const newBanditModelStore = new MemoryOnlyConfigurationStore(); - - // Pre-populate with test data - await newBanditModelStore.setEntries({ - 'test-bandit': testBanditParameters, - }); - newBanditModelStore.setFormat(FormatEnum.SERVER); - - // Create a spy on the ConfigurationManager's setConfigurationStores method - const configManager = (client as any).configurationManager; - const setStoresSpy = jest.spyOn(configManager, 'setConfigurationStores'); - - // Call the setter method - client.setBanditModelConfigurationStore(newBanditModelStore); - - // Verify the manager's method was called with the correct arguments - expect(setStoresSpy).toHaveBeenCalledWith( - expect.objectContaining({ - banditConfigurationStore: newBanditModelStore, - }), - ); - - // Verify the configuration was updated - const config = configManager.getConfiguration(); - expect(config.getBandits()['test-bandit']).toEqual(testBanditParameters); - }); - - it('should use configuration from ConfigurationManager for assignment decisions', async () => { - // Create a new flag store with a test flag - const newFlagStore = new MemoryOnlyConfigurationStore(); - await newFlagStore.setEntries({ 'test-flag': testFlag }); - newFlagStore.setFormat(FormatEnum.SERVER); - - // Update the client's flag store - client.setFlagConfigurationStore(newFlagStore); - - // Create a spy on the ConfigurationManager's getConfiguration method - const configManager = (client as any).configurationManager; - const getConfigSpy = jest.spyOn(configManager, 'getConfiguration'); - - // Get an assignment - const assignment = client.getStringAssignment('test-flag', 'subject-1', {}, 'default'); - - // Verify the manager's getConfiguration method was called - expect(getConfigSpy).toHaveBeenCalled(); - - // Verify we got the expected assignment (based on the test flag's configuration) - expect(assignment).toBe('treatment-value'); - }); - - it('should reflect changes in ConfigurationManager immediately in assignments', async () => { - // First, set up a flag store with one variation - const initialFlagStore = new MemoryOnlyConfigurationStore(); - const initialFlag = { ...testFlag }; - await initialFlagStore.setEntries({ 'test-flag': initialFlag }); - initialFlagStore.setFormat(FormatEnum.SERVER); - - client.setFlagConfigurationStore(initialFlagStore); - - // Get initial assignment - const initialAssignment = client.getStringAssignment('test-flag', 'subject-1', {}, 'default'); - expect(initialAssignment).toBe('treatment-value'); - - // Now create a new flag store with a different variation - const updatedFlagStore = new MemoryOnlyConfigurationStore(); - const updatedFlag = { - ...testFlag, - variations: { - control: { key: 'control', value: 'control-value' }, - treatment: { key: 'treatment', value: 'new-treatment-value' }, - }, - }; - await updatedFlagStore.setEntries({ 'test-flag': updatedFlag }); - updatedFlagStore.setFormat(FormatEnum.SERVER); - - // Update the client's flag store - client.setFlagConfigurationStore(updatedFlagStore); - - // Get updated assignment - const updatedAssignment = client.getStringAssignment('test-flag', 'subject-1', {}, 'default'); - - // Verify the assignment reflects the updated configuration - expect(updatedAssignment).toBe('new-treatment-value'); - }); -}); diff --git a/src/configuration-wire/configuration-wire-types.ts b/src/configuration-wire/configuration-wire-types.ts index 9d1a8d2d..d7734d77 100644 --- a/src/configuration-wire/configuration-wire-types.ts +++ b/src/configuration-wire/configuration-wire-types.ts @@ -200,6 +200,10 @@ export class ConfigurationWireV1 implements IConfigurationWire { readonly bandits?: IConfigResponse, ) {} + public static fromString(stringifiedPayload: string): IConfigurationWire { + return JSON.parse(stringifiedPayload) as IConfigurationWire; + } + public static fromResponses( flagConfig: IUniversalFlagConfigResponse, banditConfig?: IBanditParametersResponse, diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 24b2c49c..8b8be06d 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -19,6 +19,9 @@ const TEST_CONFIGURATION_WIRE_DATA_DIR = './test/data/configuration-wire/'; const MOCK_PRECOMPUTED_FILENAME = 'precomputed-v1'; export const MOCK_PRECOMPUTED_WIRE_FILE = `${MOCK_PRECOMPUTED_FILENAME}.json`; export const MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE = `${MOCK_PRECOMPUTED_FILENAME}-deobfuscated.json`; +export const SHARED_BOOTSTRAP_FLAGS_FILE = 'bootstrap-flags-v1.json'; +export const SHARED_BOOTSTRAP_FLAGS_OBFUSCATED_FILE = 'bootstrap-flags-obfuscated-v1.json'; +export const SHARED_BOOTSTRAP_BANDIT_FLAGS_FILE = 'bootstrap-bandit-flags-v1.json'; export interface SubjectTestCase { subjectKey: string; From 7d8df74dbb7515ac8097d7ad0137963fccd53add Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Mon, 17 Mar 2025 23:00:48 -0600 Subject: [PATCH 08/15] point at new test data --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index baad711e..cbe9c598 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ help: Makefile testDataDir := test/data/ tempDir := ${testDataDir}temp/ gitDataDir := ${tempDir}sdk-test-data/ -branchName := main +branchName := tp/bootstrap-config githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git repoName := sdk-test-data .PHONY: test-data From fc1c4e9b77198115f52f603201483f9c4cbcfe54 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Mon, 17 Mar 2025 23:05:59 -0600 Subject: [PATCH 09/15] don't initialize after bootstrap --- src/client/eppo-client.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 0cbb8cde..9637714d 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -361,27 +361,9 @@ export default class EppoClient { flagConfigResponse, banditParamResponse, ); - - // Still initialize the client in case polling is needed. - // fire-and-forget so ignore the resolution of this promise. - this.initialize() - .then(() => { - logger.debug('Eppo SDK polling initialization complete'); - return; - }) - .catch((e) => { - logger.error('Eppo SDK Error initializing polling after bootstrap()', e); - }); } - /** - * @deprecated use `initialize` instead. - */ async fetchFlagConfigurations() { - return this.initialize(); - } - - async initialize() { if (!this.configurationRequestParameters) { throw new Error( 'Eppo SDK unable to fetch flag configurations without configuration request parameters', From 79aa01c32d8205ae1287ac8f5acbe4b2ee3b2177 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Wed, 19 Mar 2025 09:03:52 -0600 Subject: [PATCH 10/15] update comment --- src/client/eppo-client.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 9637714d..63734d22 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -355,8 +355,9 @@ export default class EppoClient { ? inflateResponse(configuration.bandits.response) : undefined; - // We need to run this method sync, but, because the configuration stores potentially have an async write at the end - // of updating the configuration, the method to do so it also async. Use an IIFE to wrap the async call. + // This method runs async because the configuration stores potentially have an async write at the end of updating + // the configuration. Most instances of offlineInit will use `MemoryOnlyConfigurationStore` instances which actually + // accomplish the config write synchronously. await this.configurationManager.hydrateConfigurationStoresFromUfc( flagConfigResponse, banditParamResponse, From 102cefadc13697ad1631736def0a99fb27652a60 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Wed, 19 Mar 2025 09:04:12 -0600 Subject: [PATCH 11/15] revert temp branch --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index cbe9c598..baad711e 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ help: Makefile testDataDir := test/data/ tempDir := ${testDataDir}temp/ gitDataDir := ${tempDir}sdk-test-data/ -branchName := tp/bootstrap-config +branchName := main githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git repoName := sdk-test-data .PHONY: test-data From a404826f26e28f1509882e290f999cb81d0a1f71 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Wed, 19 Mar 2025 11:02:49 -0600 Subject: [PATCH 12/15] await bootstrapping --- src/client/eppo-client.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 14a4df73..7f92d66f 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -367,14 +367,14 @@ describe('EppoClient E2E test', () => { describe('Not obfuscated', () => { let client: EppoClient; - beforeAll(() => { + beforeAll(async () => { client = new EppoClient({ flagConfigurationStore: new MemoryOnlyConfigurationStore(), }); client.setIsGracefulFailureMode(false); // Bootstrap using the flags config. - client.bootstrap(bootstrapFlagsConfig); + await client.bootstrap(bootstrapFlagsConfig); }); it('contains some key flags', () => { From f1db0f146e88f7b2ff59fcdde5468a8f9d9059f7 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Wed, 19 Mar 2025 11:35:11 -0600 Subject: [PATCH 13/15] lint and revert setStore changes --- src/client/eppo-client.spec.ts | 2 -- src/client/eppo-client.ts | 21 ++------------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 7f92d66f..6a548ac7 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -321,8 +321,6 @@ describe('EppoClient E2E test', () => { }); }); - const testCases = testCasesByFileName(ASSIGNMENT_TEST_DATA_DIR); - function testCasesAgainstClient(client: EppoClient, testCase: IAssignmentTestCase) { const { flag, variationType, defaultValue, subjects } = testCase; diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 63734d22..6cf93793 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -17,10 +17,7 @@ import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment- import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; -import { - ConfigurationStoreBundle, - IConfigurationManager, -} from '../configuration-store/i-configuration-manager'; +import { IConfigurationManager } from '../configuration-store/i-configuration-manager'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { ConfigurationWireV1, @@ -249,15 +246,7 @@ export default class EppoClient { this.configurationRequestParameters = configurationRequestParameters; } - public setConfigurationStores(configStores: ConfigurationStoreBundle) { - // Update the configuration manager - this.configurationManager.setConfigurationStores(configStores); - } - // noinspection JSUnusedGlobalSymbols - /** - * @deprecated use `setConfigurationStores` instead - */ setFlagConfigurationStore(flagConfigurationStore: IConfigurationStore) { this.flagConfigurationStore = flagConfigurationStore; this.configObfuscatedCache = undefined; @@ -267,9 +256,6 @@ export default class EppoClient { } // noinspection JSUnusedGlobalSymbols - /** - * @deprecated use `setConfigurationStores` instead - */ setBanditVariationConfigurationStore( banditVariationConfigurationStore: IConfigurationStore, ) { @@ -280,9 +266,6 @@ export default class EppoClient { } // noinspection JSUnusedGlobalSymbols - /** - * @deprecated use `setConfigurationStores` instead - */ setBanditModelConfigurationStore( banditModelConfigurationStore: IConfigurationStore, ) { @@ -294,7 +277,7 @@ export default class EppoClient { private innerSetConfigurationStores() { // Set the set of configuration stores to those owned by the `this`. - this.setConfigurationStores({ + this.configurationManager.setConfigurationStores({ flagConfigurationStore: this.flagConfigurationStore, banditReferenceConfigurationStore: this.banditVariationConfigurationStore, banditConfigurationStore: this.banditModelConfigurationStore, From b7882b59f7aba8bcb272dc01a31405fd00783e09 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Wed, 19 Mar 2025 11:40:12 -0600 Subject: [PATCH 14/15] drop superfluous interface --- src/client/eppo-client.ts | 3 +-- src/configuration-requestor.spec.ts | 3 +-- src/configuration-requestor.ts | 4 +-- .../configuration-manager.ts | 15 +++++------ .../i-configuration-manager.ts | 25 ------------------- 5 files changed, 12 insertions(+), 38 deletions(-) delete mode 100644 src/configuration-store/i-configuration-manager.ts diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 6cf93793..085f5c43 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -17,7 +17,6 @@ import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment- import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationManager } from '../configuration-store/configuration-manager'; import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; -import { IConfigurationManager } from '../configuration-store/i-configuration-manager'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { ConfigurationWireV1, @@ -144,7 +143,7 @@ export default class EppoClient { private readonly evaluator = new Evaluator(); private configurationRequestor?: ConfigurationRequestor; private readonly overrideValidator = new OverrideValidator(); - private configurationManager: IConfigurationManager; + private configurationManager: ConfigurationManager; constructor({ eventDispatcher = new NoOpEventDispatcher(), diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index fa51c8dd..82286b14 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -9,7 +9,6 @@ import ApiEndpoints from './api-endpoints'; import ConfigurationRequestor from './configuration-requestor'; import { ConfigurationManager } from './configuration-store/configuration-manager'; import { IConfigurationStore } from './configuration-store/configuration-store'; -import { IConfigurationManager } from './configuration-store/i-configuration-manager'; import { MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import FetchHttpClient, { IBanditParametersResponse, @@ -24,7 +23,7 @@ describe('ConfigurationRequestor', () => { let banditVariationStore: IConfigurationStore; let banditModelStore: IConfigurationStore; let httpClient: IHttpClient; - let configurationManager: IConfigurationManager; + let configurationManager: ConfigurationManager; let configurationRequestor: ConfigurationRequestor; beforeEach(async () => { diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 7b8ede69..7e520534 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,4 +1,4 @@ -import { IConfigurationManager } from './configuration-store/i-configuration-manager'; +import { ConfigurationManager } from './configuration-store/configuration-manager'; import { IHttpClient } from './http-client'; import { IConfiguration } from './i-configuration'; import { BanditReference, BanditParameters } from './interfaces'; @@ -9,7 +9,7 @@ export default class ConfigurationRequestor { constructor( private readonly httpClient: IHttpClient, - private readonly configurationManager: IConfigurationManager, + private readonly configurationManager: ConfigurationManager, private readonly fetchBandits: boolean, ) {} diff --git a/src/configuration-store/configuration-manager.ts b/src/configuration-store/configuration-manager.ts index 38769df4..fb1b8ba6 100644 --- a/src/configuration-store/configuration-manager.ts +++ b/src/configuration-store/configuration-manager.ts @@ -13,9 +13,14 @@ import { } from '../interfaces'; import { IConfigurationStore } from './configuration-store'; -import { IConfigurationManager } from './i-configuration-manager'; -export class ConfigurationManager implements IConfigurationManager { +export type ConfigurationStoreBundle = { + flagConfigurationStore: IConfigurationStore; + banditReferenceConfigurationStore?: IConfigurationStore; + banditConfigurationStore?: IConfigurationStore; +}; + +export class ConfigurationManager { private configuration: StoreBackedConfiguration; constructor( @@ -95,11 +100,7 @@ export class ConfigurationManager implements IConfigurationManager { ); } - public setConfigurationStores(configStores: { - flagConfigurationStore: IConfigurationStore; - banditReferenceConfigurationStore?: IConfigurationStore; - banditConfigurationStore?: IConfigurationStore; - }): void { + public setConfigurationStores(configStores: ConfigurationStoreBundle): void { this.flagConfigurationStore = configStores.flagConfigurationStore; this.banditReferenceConfigurationStore = configStores.banditReferenceConfigurationStore; this.banditConfigurationStore = configStores.banditConfigurationStore; diff --git a/src/configuration-store/i-configuration-manager.ts b/src/configuration-store/i-configuration-manager.ts deleted file mode 100644 index 7054cb31..00000000 --- a/src/configuration-store/i-configuration-manager.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../http-client'; -import { ConfigStoreHydrationPacket, IConfiguration } from '../i-configuration'; -import { BanditParameters, BanditVariation, Flag, ObfuscatedFlag } from '../interfaces'; - -import { IConfigurationStore } from './configuration-store'; - -export type ConfigurationStoreBundle = { - flagConfigurationStore: IConfigurationStore; - banditReferenceConfigurationStore?: IConfigurationStore; - banditConfigurationStore?: IConfigurationStore; -}; - -export interface IConfigurationManager { - getConfiguration(): IConfiguration; - hydrateConfigurationStores( - flagConfigPacket: ConfigStoreHydrationPacket, - banditReferencePacket?: ConfigStoreHydrationPacket, - banditParametersPacket?: ConfigStoreHydrationPacket, - ): Promise; - hydrateConfigurationStoresFromUfc( - flags: IUniversalFlagConfigResponse, - bandits?: IBanditParametersResponse, - ): Promise; - setConfigurationStores(configStores: ConfigurationStoreBundle): void; -} From e256730edbe94fee95a9f20fc44ee99b69c46e89 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Wed, 19 Mar 2025 11:49:25 -0600 Subject: [PATCH 15/15] un async bootstrap :) --- src/client/eppo-client-with-bandits.spec.ts | 2 +- src/client/eppo-client.spec.ts | 8 ++++---- src/client/eppo-client.ts | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index eb2ba9d5..36320f56 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -151,7 +151,7 @@ describe('EppoClient Bandits E2E test', () => { client.setIsGracefulFailureMode(false); // Bootstrap using the bandit flag config. - await client.bootstrap(banditFlagsConfig); + client.bootstrap(banditFlagsConfig); }); it.each(Object.keys(testCases))('Shared bandit test case - %s', async (fileName: string) => { diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 6a548ac7..6fecaade 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -365,14 +365,14 @@ describe('EppoClient E2E test', () => { describe('Not obfuscated', () => { let client: EppoClient; - beforeAll(async () => { + beforeAll(() => { client = new EppoClient({ flagConfigurationStore: new MemoryOnlyConfigurationStore(), }); client.setIsGracefulFailureMode(false); // Bootstrap using the flags config. - await client.bootstrap(bootstrapFlagsConfig); + client.bootstrap(bootstrapFlagsConfig); }); it('contains some key flags', () => { @@ -389,14 +389,14 @@ describe('EppoClient E2E test', () => { describe('Obfuscated', () => { let client: EppoClient; - beforeAll(async () => { + beforeAll(() => { client = new EppoClient({ flagConfigurationStore: new MemoryOnlyConfigurationStore(), }); client.setIsGracefulFailureMode(false); // Bootstrap using the obfuscated flags config. - await client.bootstrap(bootstrapFlagsObfuscatedConfig); + client.bootstrap(bootstrapFlagsObfuscatedConfig); }); it('contains some key flags', () => { diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 085f5c43..a324af8d 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -326,7 +326,7 @@ export default class EppoClient { * accommodate writing to a persistent store. For fastest initialization, (at the cost of persisting configuration), * use `bootstrap` in conjunction with `MemoryOnlyConfigurationStore` instances which won't do an async write. */ - async bootstrap(configuration: IConfigurationWire): Promise { + bootstrap(configuration: IConfigurationWire): void { if (!configuration.config) { throw new Error('Flag configuration not provided'); } @@ -340,7 +340,8 @@ export default class EppoClient { // This method runs async because the configuration stores potentially have an async write at the end of updating // the configuration. Most instances of offlineInit will use `MemoryOnlyConfigurationStore` instances which actually // accomplish the config write synchronously. - await this.configurationManager.hydrateConfigurationStoresFromUfc( + // `void` keyword here suppresses warnings about leaving the promise hanging. + void this.configurationManager.hydrateConfigurationStoresFromUfc( flagConfigResponse, banditParamResponse, );