diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c18ec28..813ae0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: ['14', '16', '18'] + node-version: ['16', '18', '20', '22'] os: [macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} @@ -38,3 +38,8 @@ jobs: - name: Test run: yarn test --testPathIgnorePatterns "benchmark.test.ts" + env: + API_KEY: ${{ secrets.API_KEY }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + EU_API_KEY: ${{ secrets.EU_API_KEY }} + EU_SECRET_KEY: ${{ secrets.EU_SECRET_KEY }} diff --git a/packages/node/.gitignore b/packages/node/.gitignore index 5de3475..5289a04 100644 --- a/packages/node/.gitignore +++ b/packages/node/.gitignore @@ -1,2 +1,3 @@ # Ignore generated files gen +.env* \ No newline at end of file diff --git a/packages/node/README.md b/packages/node/README.md new file mode 100644 index 0000000..230893d --- /dev/null +++ b/packages/node/README.md @@ -0,0 +1,7 @@ +To setup for running test on local, create a `.env` file with following +contents, and replace `{API_KEY}` and `{SECRET_KEY}` for the project in test: + +``` +API_KEY={API_KEY} +SECRET_KEY={SECRET_KEY} +``` diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 26670e8..f274e86 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -11,16 +11,18 @@ import { InMemoryAssignmentFilter } from '../assignment/assignment-filter'; import { AmplitudeAssignmentService } from '../assignment/assignment-service'; import { FetchHttpClient } from '../transport/http'; import { StreamEventSourceFactory } from '../transport/stream'; +import { USER_GROUP_TYPE } from '../types/cohort'; import { AssignmentConfig, AssignmentConfigDefaults, LocalEvaluationConfig, - LocalEvaluationDefaults, } from '../types/config'; import { FlagConfigCache } from '../types/flag'; import { HttpClient } from '../types/transport'; import { ExperimentUser } from '../types/user'; import { Variant, Variants } from '../types/variant'; +import { CohortUtils } from '../util/cohort'; +import { populateLocalConfigDefaults } from '../util/config'; import { ConsoleLogger } from '../util/logger'; import { Logger } from '../util/logger'; import { convertUserToEvaluationContext } from '../util/user'; @@ -30,6 +32,10 @@ import { } from '../util/variant'; import { InMemoryFlagConfigCache } from './cache'; +import { CohortFetcher } from './cohort/fetcher'; +import { CohortPoller } from './cohort/poller'; +import { InMemoryCohortStorage } from './cohort/storage'; +import { CohortUpdater } from './cohort/updater'; import { FlagConfigFetcher } from './fetcher'; import { FlagConfigPoller } from './poller'; import { FlagConfigStreamer } from './streamer'; @@ -40,16 +46,19 @@ const STREAM_RETRY_JITTER_MAX_MILLIS = 2000; // The jitter to add to delay after const STREAM_ATTEMPTS = 1; // Number of attempts before fallback to poller. const STREAM_TRY_DELAY_MILLIS = 1000; // The delay between attempts. +const COHORT_POLLING_INTERVAL_MILLIS_MIN = 60000; + /** * Experiment client for evaluating variants for a user locally. * @category Core Usage */ export class LocalEvaluationClient { private readonly logger: Logger; - private readonly config: LocalEvaluationConfig; + protected readonly config: LocalEvaluationConfig; private readonly updater: FlagConfigUpdater; private readonly assignmentService: AssignmentService; private readonly evaluation: EvaluationEngine; + private readonly cohortUpdater?: CohortUpdater; /** * Directly access the client's flag config cache. @@ -57,16 +66,17 @@ export class LocalEvaluationClient { * Used for directly manipulating the flag configs used for evaluation. */ public readonly cache: InMemoryFlagConfigCache; + public readonly cohortStorage: InMemoryCohortStorage; constructor( apiKey: string, - config: LocalEvaluationConfig, + config?: LocalEvaluationConfig, flagConfigCache?: FlagConfigCache, httpClient: HttpClient = new FetchHttpClient(config?.httpAgent), streamEventSourceFactory: StreamEventSourceFactory = (url, params) => new EventSource(url, params), ) { - this.config = { ...LocalEvaluationDefaults, ...config }; + this.config = populateLocalConfigDefaults(config); const fetcher = new FlagConfigFetcher( apiKey, httpClient, @@ -78,27 +88,57 @@ export class LocalEvaluationClient { this.config.bootstrap, ); this.logger = new ConsoleLogger(this.config.debug); + + this.cohortStorage = new InMemoryCohortStorage(); + let cohortFetcher: CohortFetcher = undefined; + if (this.config.cohortSyncConfig) { + cohortFetcher = new CohortFetcher( + this.config.cohortSyncConfig.apiKey, + this.config.cohortSyncConfig.secretKey, + httpClient, + this.config.cohortSyncConfig?.cohortServerUrl, + this.config.cohortSyncConfig?.maxCohortSize, + undefined, + this.config.debug, + ); + this.cohortUpdater = new CohortPoller( + cohortFetcher, + this.cohortStorage, + this.cache, + Math.max( + COHORT_POLLING_INTERVAL_MILLIS_MIN, + this.config.cohortSyncConfig?.cohortPollingIntervalMillis, + ), + this.config.debug, + ); + } + + const flagsPoller = new FlagConfigPoller( + fetcher, + this.cache, + this.cohortStorage, + cohortFetcher, + this.config.flagConfigPollingIntervalMillis, + this.config.debug, + ); this.updater = this.config.streamUpdates ? new FlagConfigStreamer( apiKey, - fetcher, + flagsPoller, this.cache, streamEventSourceFactory, - this.config.flagConfigPollingIntervalMillis, this.config.streamFlagConnTimeoutMillis, STREAM_ATTEMPTS, STREAM_TRY_DELAY_MILLIS, STREAM_RETRY_DELAY_MILLIS + Math.floor(Math.random() * STREAM_RETRY_JITTER_MAX_MILLIS), this.config.streamServerUrl, + this.cohortStorage, + cohortFetcher, this.config.debug, ) - : new FlagConfigPoller( - fetcher, - this.cache, - this.config.flagConfigPollingIntervalMillis, - this.config.debug, - ); + : flagsPoller; + if (this.config.assignmentConfig) { this.config.assignmentConfig = { ...AssignmentConfigDefaults, @@ -144,6 +184,7 @@ export class LocalEvaluationClient { flagKeys?: string[], ): Record { const flags = this.cache.getAllCached() as Record; + this.enrichUserWithCohorts(user, flags); this.logger.debug('[Experiment] evaluate - user:', user, 'flags:', flags); const context = convertUserToEvaluationContext(user); const sortedFlags = topologicalSort(flags, flagKeys); @@ -153,6 +194,87 @@ export class LocalEvaluationClient { return evaluationVariantsToVariants(results); } + protected checkFlagsCohortsAvailable( + cohortIdsByFlag: Record>, + ): boolean { + const availableCohortIds = this.cohortStorage.getAllCohortIds(); + for (const key in cohortIdsByFlag) { + const flagCohortIds = cohortIdsByFlag[key]; + const unavailableCohortIds = CohortUtils.setSubtract( + flagCohortIds, + availableCohortIds, + ); + if (unavailableCohortIds.size > 0) { + this.logger.error( + `[Experiment] Flag ${key} has cohort ids ${[ + ...unavailableCohortIds, + ]} unavailable, evaluation may be incorrect`, + ); + return false; + } + } + return true; + } + + protected enrichUserWithCohorts( + user: ExperimentUser, + flags: Record, + ): void { + const cohortIdsByFlag: Record> = {}; + const cohortIdsByGroup = {}; + for (const key in flags) { + const cohortIdsByGroupOfFlag = + CohortUtils.extractCohortIdsByGroupFromFlag(flags[key]); + + CohortUtils.mergeValuesOfBIntoValuesOfA( + cohortIdsByGroup, + cohortIdsByGroupOfFlag, + ); + + cohortIdsByFlag[key] = CohortUtils.mergeAllValues(cohortIdsByGroupOfFlag); + } + + this.checkFlagsCohortsAvailable(cohortIdsByFlag); + + // Enrich cohorts with user group type. + const userCohortIds = cohortIdsByGroup[USER_GROUP_TYPE]; + if (user.user_id && userCohortIds && userCohortIds.size != 0) { + user.cohort_ids = Array.from( + this.cohortStorage.getCohortsForUser(user.user_id, userCohortIds), + ); + } + + // Enrich other group types for this user. + if (user.groups) { + for (const groupType in user.groups) { + const groupNames = user.groups[groupType]; + if (groupNames.length == 0) { + continue; + } + const groupName = groupNames[0]; + + const cohortIds = cohortIdsByGroup[groupType]; + if (!cohortIds || cohortIds.size == 0) { + continue; + } + + if (!user.group_cohort_ids) { + user.group_cohort_ids = {}; + } + if (!(groupType in user.group_cohort_ids)) { + user.group_cohort_ids[groupType] = {}; + } + user.group_cohort_ids[groupType][groupName] = Array.from( + this.cohortStorage.getCohortsForGroup( + groupType, + groupName, + cohortIds, + ), + ); + } + } + } + /** * Locally evaluates flag variants for a user. * @@ -184,7 +306,8 @@ export class LocalEvaluationClient { * Calling this function while the poller is already running does nothing. */ public async start(): Promise { - return await this.updater.start(); + await this.updater.start(); + await this.cohortUpdater?.start(); } /** @@ -193,6 +316,7 @@ export class LocalEvaluationClient { * Calling this function while the poller is not running will do nothing. */ public stop(): void { - return this.updater.stop(); + this.updater.stop(); + this.cohortUpdater?.stop(); } } diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts new file mode 100644 index 0000000..9845db2 --- /dev/null +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -0,0 +1,97 @@ +import { HttpClient } from '@amplitude/experiment-core'; + +import { Cohort } from '../../types/cohort'; + +export type GetCohortOptions = { + libraryName: string; + libraryVersion: string; + cohortId: string; + maxCohortSize: number; + lastModified?: number; + timeoutMillis?: number; +}; + +export interface CohortApi { + /** + * Calls /sdk/v1/cohort/ with query params maxCohortSize and lastModified if specified. + * Returns a promise that + * resolves to a + * Cohort if the cohort downloads successfully or + * undefined if cohort has no change since lastModified timestamp and + * throws an error if download failed. + * @param options + */ + getCohort(options?: GetCohortOptions): Promise; +} + +export class CohortClientRequestError extends Error {} // 4xx errors except 429 +export class CohortMaxSizeExceededError extends CohortClientRequestError {} // 413 error +export class CohortDownloadError extends Error {} // All other errors + +export class SdkCohortApi implements CohortApi { + private readonly cohortApiKey; + private readonly serverUrl; + private readonly httpClient; + + constructor(cohortApiKey: string, serverUrl: string, httpClient: HttpClient) { + this.cohortApiKey = cohortApiKey; + this.serverUrl = serverUrl; + this.httpClient = httpClient; + } + + public async getCohort( + options?: GetCohortOptions, + ): Promise { + const headers: Record = { + Authorization: `Basic ${this.cohortApiKey}`, + }; + if (options?.libraryName && options?.libraryVersion) { + headers[ + 'X-Amp-Exp-Library' + ] = `${options.libraryName}/${options.libraryVersion}`; + } + + const reqUrl = `${this.serverUrl}/sdk/v1/cohort/${ + options.cohortId + }?maxCohortSize=${options.maxCohortSize}${ + options.lastModified ? `&lastModified=${options.lastModified}` : '' + }`; + const response = await this.httpClient.request({ + requestUrl: reqUrl, + method: 'GET', + headers: headers, + timeoutMillis: options?.timeoutMillis, + }); + + // Check status code. + // 200: download success. + // 204: no change. + // 413: cohort larger than maxCohortSize + if (response.status == 200) { + const cohort: Cohort = JSON.parse(response.body) as Cohort; + if (Array.isArray(cohort.memberIds)) { + cohort.memberIds = new Set(cohort.memberIds); + } + return cohort; + } else if (response.status == 204) { + return undefined; + } else if (response.status == 413) { + throw new CohortMaxSizeExceededError( + `Cohort size > ${options.maxCohortSize}`, + ); + } else if ( + 400 <= response.status && + response.status < 500 && + response.status != 429 + ) { + // Any 4xx other than 429. + throw new CohortClientRequestError( + `Cohort client error response status ${response.status}, body ${response.body}`, + ); + } else { + throw new CohortDownloadError( + `Cohort error response status ${response.status}, body ${response.body}`, + ); + } + } +} diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts new file mode 100644 index 0000000..933d1ed --- /dev/null +++ b/packages/node/src/local/cohort/fetcher.ts @@ -0,0 +1,106 @@ +import { version as PACKAGE_VERSION } from '../../../gen/version'; +import { WrapperClient } from '../../transport/http'; +import { Cohort } from '../../types/cohort'; +import { CohortSyncConfigDefaults } from '../../types/config'; +import { HttpClient } from '../../types/transport'; +import { ConsoleLogger, Logger } from '../../util/logger'; +import { Mutex, Executor } from '../../util/threading'; +import { sleep } from '../../util/time'; + +import { + CohortClientRequestError, + CohortMaxSizeExceededError, + SdkCohortApi, +} from './cohort-api'; + +export const COHORT_CONFIG_TIMEOUT = 20000; + +const ATTEMPTS = 3; + +export class CohortFetcher { + private readonly logger: Logger; + + readonly cohortApi: SdkCohortApi; + readonly maxCohortSize: number; + readonly cohortRequestDelayMillis: number; + + private readonly inProgressCohorts: Record< + string, + Promise + > = {}; + private readonly mutex: Mutex = new Mutex(); + private readonly executor: Executor = new Executor(4); + + constructor( + apiKey: string, + secretKey: string, + httpClient: HttpClient, + serverUrl = CohortSyncConfigDefaults.cohortServerUrl, + maxCohortSize = CohortSyncConfigDefaults.maxCohortSize, + cohortRequestDelayMillis = 100, + debug = false, + ) { + this.cohortApi = new SdkCohortApi( + Buffer.from(apiKey + ':' + secretKey).toString('base64'), + serverUrl, + new WrapperClient(httpClient), + ); + this.maxCohortSize = maxCohortSize; + this.cohortRequestDelayMillis = cohortRequestDelayMillis; + this.logger = new ConsoleLogger(debug); + } + + static getKey(cohortId: string, lastModified?: number): string { + return `${cohortId}_${lastModified ? lastModified : ''}`; + } + + async fetch( + cohortId: string, + lastModified?: number, + ): Promise { + // This block may have async and awaits. No guarantee that executions are not interleaved. + const unlock = await this.mutex.lock(); + const key = CohortFetcher.getKey(cohortId, lastModified); + + if (!this.inProgressCohorts[key]) { + this.inProgressCohorts[key] = this.executor.run(async () => { + this.logger.debug('Start downloading', cohortId); + for (let i = 0; i < ATTEMPTS; i++) { + try { + const cohort = await this.cohortApi.getCohort({ + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + cohortId: cohortId, + maxCohortSize: this.maxCohortSize, + lastModified: lastModified, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); + // Do unlock before return. + const unlock = await this.mutex.lock(); + delete this.inProgressCohorts[key]; + unlock(); + this.logger.debug('Stop downloading', cohortId); + return cohort; + } catch (e) { + if ( + i === ATTEMPTS - 1 || + e instanceof CohortMaxSizeExceededError || + e instanceof CohortClientRequestError + ) { + const unlock = await this.mutex.lock(); + delete this.inProgressCohorts[key]; + unlock(); + throw e; + } + await sleep(this.cohortRequestDelayMillis); + } + } + }); + } + + const cohortPromise: Promise = + this.inProgressCohorts[key]; + unlock(); + return cohortPromise; + } +} diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts new file mode 100644 index 0000000..8326b6c --- /dev/null +++ b/packages/node/src/local/cohort/poller.ts @@ -0,0 +1,110 @@ +import { CohortStorage } from '../../types/cohort'; +import { FlagConfigCache } from '../../types/flag'; +import { CohortUtils } from '../../util/cohort'; +import { ConsoleLogger } from '../../util/logger'; +import { Logger } from '../../util/logger'; + +import { CohortFetcher } from './fetcher'; +import { CohortUpdater } from './updater'; + +export class CohortPoller implements CohortUpdater { + private readonly logger: Logger; + + public readonly fetcher: CohortFetcher; + public readonly storage: CohortStorage; + public readonly flagCache: FlagConfigCache; + + private poller: NodeJS.Timeout; + private pollingIntervalMillis: number; + + constructor( + fetcher: CohortFetcher, + storage: CohortStorage, + flagCache: FlagConfigCache, + pollingIntervalMillis = 60000, + debug = false, + ) { + this.fetcher = fetcher; + this.storage = storage; + this.flagCache = flagCache; + this.pollingIntervalMillis = pollingIntervalMillis; + this.logger = new ConsoleLogger(debug); + } + + /** + * You must call this function to begin polling for cohort updates. + * + * Calling this function while the poller is already running does nothing. + */ + public async start( + onChange?: (storage: CohortStorage) => Promise, + ): Promise { + if (!this.poller) { + this.logger.debug('[Experiment] cohort poller - start'); + this.poller = setInterval(async () => { + try { + await this.update(onChange); + } catch (e) { + this.logger.debug('[Experiment] cohort update failed', e); + } + }, this.pollingIntervalMillis); + } + } + + /** + * Stop polling for cohorts. + * + * Calling this function while the poller is not running will do nothing. + */ + public stop(): void { + if (this.poller) { + this.logger.debug('[Experiment] cohort poller - stop'); + clearTimeout(this.poller); + this.poller = undefined; + } + } + + public async update( + onChange?: (storage: CohortStorage) => Promise, + ): Promise { + let changed = false; + const promises = []; + const cohortIds = CohortUtils.extractCohortIds( + await this.flagCache.getAll(), + ); + + for (const cohortId of cohortIds) { + this.logger.debug(`[Experiment] updating cohort ${cohortId}`); + + // Get existing cohort and lastModified. + const existingCohort = this.storage.getCohort(cohortId); + let lastModified = undefined; + if (existingCohort) { + lastModified = existingCohort.lastModified; + } + + promises.push( + this.fetcher + .fetch(cohortId, lastModified) + .then((cohort) => { + // Set. + if (cohort) { + this.storage.put(cohort); + changed = true; + } + }) + .catch((err) => { + this.logger.error('[Experiment] cohort poll failed', err); + }), + ); + } + + await Promise.all(promises); + + this.logger.debug(`[Experiment] cohort polled, changed: ${changed}`); + + if (onChange && changed) { + await onChange(this.storage); + } + } +} diff --git a/packages/node/src/local/cohort/storage.ts b/packages/node/src/local/cohort/storage.ts new file mode 100644 index 0000000..90aecce --- /dev/null +++ b/packages/node/src/local/cohort/storage.ts @@ -0,0 +1,42 @@ +import { Cohort, CohortStorage, USER_GROUP_TYPE } from '../../types/cohort'; + +export class InMemoryCohortStorage implements CohortStorage { + store: Record = {}; + + getAllCohortIds(): Set { + return new Set(Object.keys(this.store)); + } + + getCohort(cohortId: string): Cohort | undefined { + return cohortId in this.store ? this.store[cohortId] : undefined; + } + + getCohortsForUser(userId: string, cohortIds: Set): Set { + return this.getCohortsForGroup(USER_GROUP_TYPE, userId, cohortIds); + } + + getCohortsForGroup( + groupType: string, + groupName: string, + cohortIds: Set, + ): Set { + const validCohortIds = new Set(); + for (const cohortId of cohortIds) { + if ( + this.store[cohortId]?.groupType == groupType && + this.store[cohortId]?.memberIds.has(groupName) + ) { + validCohortIds.add(cohortId); + } + } + return validCohortIds; + } + + put(cohort: Cohort): void { + this.store[cohort.cohortId] = cohort; + } + + delete(cohortId: string): void { + delete this.store[cohortId]; + } +} diff --git a/packages/node/src/local/cohort/updater.ts b/packages/node/src/local/cohort/updater.ts new file mode 100644 index 0000000..b696313 --- /dev/null +++ b/packages/node/src/local/cohort/updater.ts @@ -0,0 +1,17 @@ +import { CohortStorage } from '../../types/cohort'; + +export interface CohortUpdater { + /** + * Force a cohort fetch and store the update with an optional callback + * which gets called if the cohorts change in any way. + * + * @param onChange optional callback which will get called if the cohorts + * in the storage have changed. + * @throws error if update failed. + */ + update(onChange?: (storage: CohortStorage) => Promise): Promise; + + start(onChange?: (storage: CohortStorage) => Promise): Promise; + + stop(): void; +} diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index cb2463c..e6ad1eb 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -1,11 +1,11 @@ +import { CohortStorage } from '../types/cohort'; import { LocalEvaluationDefaults } from '../types/config'; import { FlagConfigCache } from '../types/flag'; -import { doWithBackoff, BackoffPolicy } from '../util/backoff'; -import { ConsoleLogger } from '../util/logger'; -import { Logger } from '../util/logger'; +import { BackoffPolicy, doWithBackoffFailLoudly } from '../util/backoff'; +import { CohortFetcher } from './cohort/fetcher'; import { FlagConfigFetcher } from './fetcher'; -import { FlagConfigUpdater } from './updater'; +import { FlagConfigUpdater, FlagConfigUpdaterBase } from './updater'; const BACKOFF_POLICY: BackoffPolicy = { attempts: 5, @@ -14,25 +14,27 @@ const BACKOFF_POLICY: BackoffPolicy = { scalar: 1, }; -export class FlagConfigPoller implements FlagConfigUpdater { - private readonly logger: Logger; +export class FlagConfigPoller + extends FlagConfigUpdaterBase + implements FlagConfigUpdater +{ private readonly pollingIntervalMillis: number; private poller: NodeJS.Timeout; public readonly fetcher: FlagConfigFetcher; - public readonly cache: FlagConfigCache; constructor( fetcher: FlagConfigFetcher, cache: FlagConfigCache, + cohortStorage: CohortStorage, + cohortFetcher?: CohortFetcher, pollingIntervalMillis = LocalEvaluationDefaults.flagConfigPollingIntervalMillis, debug = false, ) { + super(cache, cohortStorage, cohortFetcher, debug); this.fetcher = fetcher; - this.cache = cache; this.pollingIntervalMillis = pollingIntervalMillis; - this.logger = new ConsoleLogger(debug); } /** @@ -58,9 +60,20 @@ export class FlagConfigPoller implements FlagConfigUpdater { }, this.pollingIntervalMillis); // Fetch initial flag configs and await the result. - await doWithBackoff(async () => { - await this.update(onChange); - }, BACKOFF_POLICY); + try { + const flagConfigs = await doWithBackoffFailLoudly( + async () => await this.fetcher.fetch(), + BACKOFF_POLICY, + ); + await super._update(flagConfigs, onChange); + } catch (e) { + this.logger.error( + '[Experiment] flag config initial poll failed, stopping', + e, + ); + this.stop(); + throw e; + } } } @@ -77,29 +90,11 @@ export class FlagConfigPoller implements FlagConfigUpdater { } } - /** - * Force a flag config fetch and cache the update with an optional callback - * which gets called if the flag configs change in any way. - * - * @param onChange optional callback which will get called if the flag configs - * in the cache have changed. - */ public async update( onChange?: (cache: FlagConfigCache) => Promise, ): Promise { this.logger.debug('[Experiment] updating flag configs'); const flagConfigs = await this.fetcher.fetch(); - let changed = false; - if (onChange) { - const current = await this.cache.getAll(); - if (!Object.is(current, flagConfigs)) { - changed = true; - } - } - await this.cache.clear(); - await this.cache.putAll(flagConfigs); - if (changed) { - await onChange(this.cache); - } + await super._update(flagConfigs, onChange); } } diff --git a/packages/node/src/local/stream-flag-api.ts b/packages/node/src/local/stream-flag-api.ts index d28add3..9e37ae8 100644 --- a/packages/node/src/local/stream-flag-api.ts +++ b/packages/node/src/local/stream-flag-api.ts @@ -63,6 +63,8 @@ export class SdkStreamFlagApi implements StreamFlagApi { // Flag for whether the stream is open and retrying or closed. This is to avoid calling connect() twice. private isClosedAndNotTrying = true; + // Callback for updating flag configs. Can be set or changed multiple times and effect immediately. + public onInitUpdate?: StreamFlagOnUpdateCallback; // Callback for updating flag configs. Can be set or changed multiple times and effect immediately. public onUpdate?: StreamFlagOnUpdateCallback; // Callback for notifying user of fatal errors. Can be set or changed multiple times and effect immediately. @@ -115,12 +117,16 @@ export class SdkStreamFlagApi implements StreamFlagApi { return reject(DEFAULT_STREAM_ERR_EVENTS.DATA_UNPARSABLE); } // Update the callbacks. - this.api.onUpdate = (data: string) => this.handleNewMsg(data); + this.api.onUpdate = (data: string) => this.handleNewMsg(data, false); this.api.onError = (err: StreamErrorEvent) => this.errorAndRetry(err); // Handoff data to application. Make sure it finishes processing initial new flag configs. - await this.handleNewMsg(data); - // Resolve promise which declares client ready. - resolve(); + try { + await this.handleNewMsg(data, true); + // Resolve promise which declares client ready. + resolve(); + } catch { + reject(); + } }; this.api.onUpdate = dealWithFlagUpdateInOneTry; @@ -230,7 +236,7 @@ export class SdkStreamFlagApi implements StreamFlagApi { } // Handles new messages, parse them, and handoff to application. Retries if have parsing error. - private async handleNewMsg(data: string) { + private async handleNewMsg(data: string, isInit: boolean) { let flagConfigs; try { flagConfigs = SdkStreamFlagApi.parseFlagConfigs(data); @@ -239,11 +245,17 @@ export class SdkStreamFlagApi implements StreamFlagApi { return; } // Put update outside try catch. onUpdate error doesn't mean stream error. - if (this.onUpdate) { + const updateFunc = + isInit && this.onInitUpdate ? this.onInitUpdate : this.onUpdate; + if (updateFunc) { try { - await this.onUpdate(flagConfigs); - // eslint-disable-next-line no-empty - } catch {} // Don't care about application errors after handoff. + await updateFunc(flagConfigs); + } catch (e) { + // Only care about application errors after handoff if initing. Ensure init is success. + if (isInit) { + throw e; + } + } } } diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 8c73a90..84653d2 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -3,49 +3,42 @@ import { StreamErrorEvent, StreamEventSourceFactory, } from '../transport/stream'; +import { CohortStorage } from '../types/cohort'; import { LocalEvaluationDefaults } from '../types/config'; import { FlagConfigCache } from '../types/flag'; -import { ConsoleLogger } from '../util/logger'; -import { Logger } from '../util/logger'; -import { FlagConfigFetcher } from './fetcher'; +import { CohortFetcher } from './cohort/fetcher'; import { FlagConfigPoller } from './poller'; import { SdkStreamFlagApi } from './stream-flag-api'; -import { FlagConfigUpdater } from './updater'; - -export class FlagConfigStreamer implements FlagConfigUpdater { - private readonly logger: Logger; +import { FlagConfigUpdater, FlagConfigUpdaterBase } from './updater'; +export class FlagConfigStreamer + extends FlagConfigUpdaterBase + implements FlagConfigUpdater +{ private readonly poller: FlagConfigPoller; private readonly stream: SdkStreamFlagApi; private readonly streamFlagRetryDelayMillis: number; private streamRetryInterval?: NodeJS.Timeout; - public readonly cache: FlagConfigCache; - constructor( apiKey: string, - fetcher: FlagConfigFetcher, + poller: FlagConfigPoller, cache: FlagConfigCache, streamEventSourceFactory: StreamEventSourceFactory, - pollingIntervalMillis = LocalEvaluationDefaults.flagConfigPollingIntervalMillis, streamFlagConnTimeoutMillis = LocalEvaluationDefaults.streamFlagConnTimeoutMillis, streamFlagTryAttempts: number, streamFlagTryDelayMillis: number, streamFlagRetryDelayMillis: number, serverUrl: string = LocalEvaluationDefaults.serverUrl, + cohortStorage: CohortStorage, + cohortFetcher?: CohortFetcher, debug = false, ) { - this.logger = new ConsoleLogger(debug); + super(cache, cohortStorage, cohortFetcher, debug); this.logger.debug('[Experiment] streamer - init'); - this.cache = cache; - this.poller = new FlagConfigPoller( - fetcher, - cache, - pollingIntervalMillis, - debug, - ); + this.poller = poller; this.stream = new SdkStreamFlagApi( apiKey, serverUrl, @@ -73,26 +66,20 @@ export class FlagConfigStreamer implements FlagConfigUpdater { this.stream.onError = (e) => { const err = e as StreamErrorEvent; this.logger.debug( - `[Experiment] streamer - onError, fallback to poller, err status: ${err.status}, err message: ${err.message}`, + `[Experiment] streamer - onError, fallback to poller, err status: ${err?.status}, err message: ${err?.message}, err ${err}`, ); this.poller.start(onChange); this.startRetryStreamInterval(); }; + this.stream.onInitUpdate = async (flagConfigs) => { + this.logger.debug('[Experiment] streamer - receives updates'); + await super._update(flagConfigs, onChange); + this.logger.debug('[Experiment] streamer - start flags stream success'); + }; this.stream.onUpdate = async (flagConfigs) => { this.logger.debug('[Experiment] streamer - receives updates'); - let changed = false; - if (onChange) { - const current = await this.cache.getAll(); - if (!Object.is(current, flagConfigs)) { - changed = true; - } - } - await this.cache.clear(); - await this.cache.putAll(flagConfigs); - if (changed) { - await onChange(this.cache); - } + await super._update(flagConfigs, onChange); }; try { @@ -107,11 +94,10 @@ export class FlagConfigStreamer implements FlagConfigUpdater { libraryVersion: PACKAGE_VERSION, }); this.poller.stop(); - this.logger.debug('[Experiment] streamer - start stream success'); } catch (e) { const err = e as StreamErrorEvent; this.logger.debug( - `[Experiment] streamer - start stream failed, fallback to poller, err status: ${err.status}, err message: ${err.message}`, + `[Experiment] streamer - start stream failed, fallback to poller, err status: ${err?.status}, err message: ${err?.message}, err ${err}`, ); await this.poller.start(onChange); this.startRetryStreamInterval(); diff --git a/packages/node/src/local/updater.ts b/packages/node/src/local/updater.ts index 3674785..5e88bf1 100644 --- a/packages/node/src/local/updater.ts +++ b/packages/node/src/local/updater.ts @@ -1,4 +1,9 @@ -import { FlagConfigCache } from '..'; +import { FlagConfig, FlagConfigCache } from '..'; +import { CohortStorage } from '../types/cohort'; +import { CohortUtils } from '../util/cohort'; +import { ConsoleLogger, Logger } from '../util/logger'; + +import { CohortFetcher } from './cohort/fetcher'; export interface FlagConfigUpdater { /** @@ -24,3 +29,97 @@ export interface FlagConfigUpdater { */ update(onChange?: (cache: FlagConfigCache) => Promise): Promise; } + +export class FlagConfigUpdaterBase { + protected readonly logger: Logger; + + public readonly cache: FlagConfigCache; + + public readonly cohortStorage: CohortStorage; + public readonly cohortFetcher?: CohortFetcher; + + constructor( + cache: FlagConfigCache, + cohortStorage: CohortStorage, + cohortFetcher?: CohortFetcher, + debug = false, + ) { + this.cache = cache; + this.cohortFetcher = cohortFetcher; + this.cohortStorage = cohortStorage; + this.logger = new ConsoleLogger(debug); + } + + protected async _update( + flagConfigs: Record, + onChange?: (cache: FlagConfigCache) => Promise, + ): Promise { + let changed = false; + if (onChange) { + const current = await this.cache.getAll(); + if (!Object.is(current, flagConfigs)) { + changed = true; + } + } + + // Get all cohort needs update. + const cohortIds = CohortUtils.extractCohortIds(flagConfigs); + if (cohortIds && cohortIds.size > 0 && !this.cohortFetcher) { + this.logger.error( + 'Cohorts found in flag configs but no cohort download configured', + ); + } else { + // Download new cohorts into cohortStorage. + await this.downloadNewCohorts(cohortIds); + } + + // Update the flags with new flags. + await this.cache.clear(); + await this.cache.putAll(flagConfigs); + + // Remove cohorts not used by new flags. + await this.removeUnusedCohorts(cohortIds); + + if (changed) { + await onChange(this.cache); + } + } + + protected async downloadNewCohorts( + cohortIds: Set, + ): Promise> { + const oldCohortIds = this.cohortStorage?.getAllCohortIds(); + const newCohortIds = CohortUtils.setSubtract(cohortIds, oldCohortIds); + const failedCohortIds = new Set(); + const cohortDownloadPromises = [...newCohortIds].map((cohortId) => + this.cohortFetcher + ?.fetch(cohortId) + .then((cohort) => { + if (cohort) { + this.cohortStorage.put(cohort); + } + }) + .catch((err) => { + this.logger.error( + `[Experiment] Cohort download failed ${cohortId}`, + err, + ); + failedCohortIds.add(cohortId); + }), + ); + await Promise.all(cohortDownloadPromises); + return failedCohortIds; + } + + protected async removeUnusedCohorts( + validCohortIds: Set, + ): Promise { + const cohortIdsToBeRemoved = CohortUtils.setSubtract( + this.cohortStorage.getAllCohortIds(), + validCohortIds, + ); + cohortIdsToBeRemoved.forEach((id) => { + this.cohortStorage.delete(id); + }); + } +} diff --git a/packages/node/src/remote/client.ts b/packages/node/src/remote/client.ts index 0a6a175..f0b601b 100644 --- a/packages/node/src/remote/client.ts +++ b/packages/node/src/remote/client.ts @@ -6,14 +6,11 @@ import { import { version as PACKAGE_VERSION } from '../../gen/version'; import { FetchHttpClient, WrapperClient } from '../transport/http'; -import { - ExperimentConfig, - RemoteEvaluationDefaults, - RemoteEvaluationConfig, -} from '../types/config'; +import { ExperimentConfig, RemoteEvaluationConfig } from '../types/config'; import { FetchOptions } from '../types/fetch'; import { ExperimentUser } from '../types/user'; import { Variant, Variants } from '../types/variant'; +import { populateRemoteConfigDefaults } from '../util/config'; import { sleep } from '../util/time'; import { evaluationVariantsToVariants, @@ -35,9 +32,9 @@ export class RemoteEvaluationClient { * @param apiKey The environment API Key * @param config See {@link ExperimentConfig} for config options */ - public constructor(apiKey: string, config: RemoteEvaluationConfig) { + public constructor(apiKey: string, config?: RemoteEvaluationConfig) { this.apiKey = apiKey; - this.config = { ...RemoteEvaluationDefaults, ...config }; + this.config = populateRemoteConfigDefaults(config); this.evaluationApi = new SdkEvaluationApi( apiKey, this.config.serverUrl, diff --git a/packages/node/src/transport/stream.ts b/packages/node/src/transport/stream.ts index dde0394..bce023b 100644 --- a/packages/node/src/transport/stream.ts +++ b/packages/node/src/transport/stream.ts @@ -112,7 +112,7 @@ export class SdkStream implements Stream { this.streamConnTimeoutMillis = streamConnTimeoutMillis; } - public async connect(options?: StreamOptions) { + public async connect(options?: StreamOptions): Promise { if (this.eventSource) { return; } @@ -171,7 +171,7 @@ export class SdkStream implements Stream { }, this.streamConnTimeoutMillis); } - public close() { + public close(): void { if (this.eventSource) { this.eventSource.close(); this.eventSource = undefined; @@ -192,7 +192,7 @@ export class SdkStream implements Stream { } } - private async error(err: StreamErrorEvent) { + private async error(err: StreamErrorEvent): Promise { this.close(); if (this.onError) { try { diff --git a/packages/node/src/types/cohort.ts b/packages/node/src/types/cohort.ts new file mode 100644 index 0000000..c24f9f3 --- /dev/null +++ b/packages/node/src/types/cohort.ts @@ -0,0 +1,25 @@ +export interface CohortStorage { + getAllCohortIds(): Set; + getCohort(cohortId: string): Cohort | undefined; + getCohortsForUser(userId: string, cohortIds: Set): Set; + getCohortsForGroup( + groupType: string, + groupName: string, + cohortIds: Set, + ): Set; + put(cohort: Cohort): void; + delete(cohortIds: string): void; +} + +export const USER_GROUP_TYPE = 'User'; + +export type CohortDescription = { + cohortId: string; + groupType: string; + groupTypeId: number; + lastComputed: number; + lastModified: number; + size: number; +}; + +export type Cohort = CohortDescription & { memberIds: Set }; diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index 2204fd4..7a5e9c6 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -14,7 +14,13 @@ export type RemoteEvaluationConfig = { debug?: boolean; /** - * The server endpoint from which to request variants. + * Select the Amplitude data center to get flags and variants from, `us` or `eu`. + */ + serverZone?: string; + + /** + * The server endpoint from which to request variants. For hitting the EU data center, use serverZone. + * Setting this value will override serverZone defaults. */ serverUrl?: string; @@ -81,6 +87,7 @@ export type ExperimentConfig = RemoteEvaluationConfig; */ export const RemoteEvaluationDefaults: RemoteEvaluationConfig = { debug: false, + serverZone: 'us', serverUrl: 'https://api.lab.amplitude.com', fetchTimeoutMillis: 10000, fetchRetries: 8, @@ -96,6 +103,7 @@ export const RemoteEvaluationDefaults: RemoteEvaluationConfig = { */ export const Defaults: ExperimentConfig = { debug: false, + serverZone: 'us', serverUrl: 'https://api.lab.amplitude.com', fetchTimeoutMillis: 10000, fetchRetries: 8, @@ -118,7 +126,13 @@ export type LocalEvaluationConfig = { debug?: boolean; /** - * The server endpoint from which to request variants. + * Select the Amplitude data center to get flags and variants from, `us` or `eu`. + */ + serverZone?: 'us' | 'eu'; + + /** + * The server endpoint from which to request flags. For hitting the EU data center, use serverZone. + * Setting this value will override serverZone defaults. */ serverUrl?: string; @@ -169,6 +183,8 @@ export type LocalEvaluationConfig = { * flag configs. */ streamFlagConnTimeoutMillis?: number; + + cohortSyncConfig?: CohortSyncConfig; }; export type AssignmentConfig = { @@ -184,6 +200,33 @@ export type AssignmentConfig = { cacheCapacity?: number; } & NodeOptions; +export type CohortSyncConfig = { + apiKey: string; + secretKey: string; + + /** + * The cohort server endpoint from which to fetch cohort data. For hitting the EU data center, use serverZone. + * Setting this value will override serverZone defaults. + */ + cohortServerUrl?: string; + + /** + * The max cohort size to be able to download. Any cohort larger than this + * size will be skipped. + */ + maxCohortSize?: number; + + /** + * The interval in milliseconds to poll the amplitude server for cohort + * updates. These cohorts stored in memory and used when calling evaluate() to + * perform local evaluation. + * + * Default: 60000 (60 seconds) + * Minimum: 60000 + */ + cohortPollingIntervalMillis?: number; +}; + /** Defaults for {@link LocalEvaluationConfig} options. @@ -198,6 +241,7 @@ export type AssignmentConfig = { */ export const LocalEvaluationDefaults: LocalEvaluationConfig = { debug: false, + serverZone: 'us', serverUrl: 'https://api.lab.amplitude.com', bootstrap: {}, flagConfigPollingIntervalMillis: 30000, @@ -210,3 +254,20 @@ export const LocalEvaluationDefaults: LocalEvaluationConfig = { export const AssignmentConfigDefaults: Omit = { cacheCapacity: 65536, }; + +export const CohortSyncConfigDefaults: Omit< + CohortSyncConfig, + 'apiKey' | 'secretKey' +> = { + cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', + maxCohortSize: 2147483647, + cohortPollingIntervalMillis: 60000, +}; + +export const EU_SERVER_URLS = { + name: 'eu', + remote: 'https://api.lab.eu.amplitude.com', + flags: 'https://flag.lab.eu.amplitude.com', + stream: 'https://stream.lab.eu.amplitude.com', + cohort: 'https://cohort-v2.lab.eu.amplitude.com', +}; diff --git a/packages/node/src/types/user.ts b/packages/node/src/types/user.ts index f08dea0..feb811b 100644 --- a/packages/node/src/types/user.ts +++ b/packages/node/src/types/user.ts @@ -112,4 +112,12 @@ export type ExperimentUser = { }; }; }; + + cohort_ids?: Array; + + group_cohort_ids?: { + [groupType: string]: { + [groupName: string]: Array; + }; + }; }; diff --git a/packages/node/src/util/backoff.ts b/packages/node/src/util/backoff.ts index 931c08c..0ac70d8 100644 --- a/packages/node/src/util/backoff.ts +++ b/packages/node/src/util/backoff.ts @@ -22,4 +22,22 @@ async function doWithBackoff( } } -export { doWithBackoff, BackoffPolicy }; +async function doWithBackoffFailLoudly( + action: () => Promise, + backoffPolicy: BackoffPolicy, +): Promise { + let delay = backoffPolicy.min; + for (let i = 0; i < backoffPolicy.attempts; i++) { + try { + return await action(); + } catch (e) { + if (i === backoffPolicy.attempts - 1) { + throw e; + } + await sleep(delay); + delay = Math.min(delay * backoffPolicy.scalar, backoffPolicy.max); + } + } +} + +export { doWithBackoff, doWithBackoffFailLoudly, BackoffPolicy }; diff --git a/packages/node/src/util/cohort.ts b/packages/node/src/util/cohort.ts new file mode 100644 index 0000000..f06b015 --- /dev/null +++ b/packages/node/src/util/cohort.ts @@ -0,0 +1,102 @@ +import { + EvaluationCondition, + EvaluationOperator, + EvaluationSegment, +} from '@amplitude/experiment-core'; + +import { FlagConfig } from '..'; +import { USER_GROUP_TYPE } from '../types/cohort'; + +export class CohortUtils { + public static isCohortFilter(condition: EvaluationCondition): boolean { + return ( + (condition.op == EvaluationOperator.SET_CONTAINS_ANY || + condition.op == EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY) && + condition.selector.length != 0 && + condition.selector[condition.selector.length - 1] == 'cohort_ids' + ); + } + + public static extractCohortIds( + flagConfigs: Record, + ): Set { + const cohortIdsByFlag = {}; + for (const key in flagConfigs) { + cohortIdsByFlag[key] = CohortUtils.mergeAllValues( + CohortUtils.extractCohortIdsByGroupFromFlag(flagConfigs[key]), + ); + } + return CohortUtils.mergeAllValues(cohortIdsByFlag); + } + + public static extractCohortIdsByGroupFromFlag( + flag: FlagConfig, + ): Record> { + const cohortIdsByGroup = {}; + if (flag.segments && Array.isArray(flag.segments)) { + const segments = flag.segments as EvaluationSegment[]; + for (const segment of segments) { + if (!segment || !segment.conditions) { + continue; + } + + for (const outer of segment.conditions) { + for (const condition of outer) { + if (CohortUtils.isCohortFilter(condition)) { + // User cohort selector is [context, user, cohort_ids] + // Groups cohort selector is [context, groups, {group_type}, cohort_ids] + let groupType; + if (condition.selector.length > 2) { + if (condition.selector[1] == 'user') { + groupType = USER_GROUP_TYPE; + } else if (condition.selector.includes('groups')) { + groupType = condition.selector[2]; + } else { + continue; + } + if (!(groupType in cohortIdsByGroup)) { + cohortIdsByGroup[groupType] = new Set(); + } + condition.values.forEach( + cohortIdsByGroup[groupType].add, + cohortIdsByGroup[groupType], + ); + } + } + } + } + } + } + return cohortIdsByGroup; + } + + public static mergeValuesOfBIntoValuesOfA( + a: Record>, + b: Record>, + ): void { + for (const groupType in b) { + if (!(groupType in a)) { + a[groupType] = new Set(); + } + + b[groupType].forEach(a[groupType].add, a[groupType]); + } + } + + public static mergeAllValues(a: Record>): Set { + const merged = new Set(); + for (const key in a) { + a[key].forEach(merged.add, merged); + } + return merged; + } + + public static setSubtract(one: Set, other: Set): Set { + const result = new Set(); + one.forEach((v) => { + if (!other.has(v)) result.add(v); + }); + + return result; + } +} diff --git a/packages/node/src/util/config.ts b/packages/node/src/util/config.ts new file mode 100644 index 0000000..f735161 --- /dev/null +++ b/packages/node/src/util/config.ts @@ -0,0 +1,53 @@ +import { + RemoteEvaluationConfig, + RemoteEvaluationDefaults, + LocalEvaluationConfig, +} from '..'; +import { + EU_SERVER_URLS, + LocalEvaluationDefaults, + CohortSyncConfigDefaults, +} from '../types/config'; + +export const populateRemoteConfigDefaults = ( + customConfig?: RemoteEvaluationConfig, +): RemoteEvaluationConfig => { + const config = { ...RemoteEvaluationDefaults, ...customConfig }; + const isEu = config.serverZone.toLowerCase() === EU_SERVER_URLS.name; + config.serverZone = isEu ? 'eu' : 'us'; + + if (!customConfig?.serverUrl) { + config.serverUrl = isEu + ? EU_SERVER_URLS.remote + : RemoteEvaluationDefaults.serverUrl; + } + return config; +}; + +export const populateLocalConfigDefaults = ( + customConfig?: LocalEvaluationConfig, +): LocalEvaluationConfig => { + const config = { ...LocalEvaluationDefaults, ...customConfig }; + const isEu = config.serverZone.toLowerCase() === EU_SERVER_URLS.name; + config.serverZone = isEu ? 'eu' : 'us'; + + if (!customConfig?.serverUrl) { + config.serverUrl = isEu + ? EU_SERVER_URLS.flags + : LocalEvaluationDefaults.serverUrl; + } + if (!customConfig?.streamServerUrl) { + config.streamServerUrl = isEu + ? EU_SERVER_URLS.stream + : LocalEvaluationDefaults.streamServerUrl; + } + if ( + customConfig?.cohortSyncConfig && + !customConfig?.cohortSyncConfig.cohortServerUrl + ) { + config.cohortSyncConfig.cohortServerUrl = isEu + ? EU_SERVER_URLS.cohort + : CohortSyncConfigDefaults.cohortServerUrl; + } + return config; +}; diff --git a/packages/node/src/util/logger.ts b/packages/node/src/util/logger.ts index 3058d49..aa29ffa 100644 --- a/packages/node/src/util/logger.ts +++ b/packages/node/src/util/logger.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ export interface Logger { debug(message?: string, ...optionalParams: any[]): void; + warn(message?: string, ...optionalParams: any[]): void; error(message?: string, ...optionalParams: any[]): void; } @@ -14,6 +15,9 @@ export class ConsoleLogger implements Logger { console.debug(message, ...optionalParams); } } + warn(message?: string, ...optionalParams: any[]): void { + console.warn(message, ...optionalParams); + } error(message?: string, ...optionalParams: any[]): void { console.error(message, ...optionalParams); } diff --git a/packages/node/src/util/threading.ts b/packages/node/src/util/threading.ts new file mode 100644 index 0000000..78182ed --- /dev/null +++ b/packages/node/src/util/threading.ts @@ -0,0 +1,71 @@ +export class Mutex { + _locking; + + constructor() { + this._locking = Promise.resolve(); + } + + lock(): Promise<() => void> { + let unlockNext; + const willLock = new Promise((resolve) => (unlockNext = resolve)); + const willUnlock = this._locking.then(() => unlockNext); + this._locking = this._locking.then(() => willLock); + return willUnlock; + } +} + +export class Semaphore { + public readonly limit: number; + private queue: { + willResolve: (v: unknown) => void; + }[] = []; + private running = 0; + + constructor(limit: number) { + this.limit = limit; + } + + get(): Promise<() => void> { + let willResolve; + const promise = new Promise<() => void>((resolve) => { + willResolve = resolve; + }); + + this.queue.push({ willResolve }); + + this.tryRunNext(); + + return promise; + } + + private tryRunNext(): void { + if (this.running >= this.limit || this.queue.length == 0) { + return; + } + + this.running++; + const { willResolve } = this.queue.shift(); + + willResolve(() => { + this.running--; + this.tryRunNext(); + }); + } +} + +export class Executor { + private readonly semaphore: Semaphore; + + constructor(limit: number) { + this.semaphore = new Semaphore(limit); + } + + async run(task: () => Promise): Promise { + const unlock = await this.semaphore.get(); + try { + return await task(); + } finally { + unlock(); + } + } +} diff --git a/packages/node/src/util/user.ts b/packages/node/src/util/user.ts index b6eda89..4c1044b 100644 --- a/packages/node/src/util/user.ts +++ b/packages/node/src/util/user.ts @@ -8,10 +8,12 @@ export const convertUserToEvaluationContext = ( } const userGroups = user.groups; const userGroupProperties = user.group_properties; + const userGroupCohortIds = user.group_cohort_ids; const context: Record = {}; user = { ...user }; delete user['groups']; delete user['group_properties']; + delete user['group_cohort_ids']; if (Object.keys(user).length > 0) { context['user'] = user; } @@ -31,6 +33,11 @@ export const convertUserToEvaluationContext = ( if (groupProperties && Object.keys(groupProperties).length > 0) { groupNameMap['group_properties'] = groupProperties; } + // Check for group cohort ids. + const groupCohortIds = userGroupCohortIds?.[groupType]?.[groupName]; + if (groupCohortIds && Object.keys(groupCohortIds).length > 0) { + groupNameMap['cohort_ids'] = groupCohortIds; + } groups[groupType] = groupNameMap; } } diff --git a/packages/node/test/local/benchmark.test.ts b/packages/node/test/local/benchmark.test.ts index 78dfcb5..1343954 100644 --- a/packages/node/test/local/benchmark.test.ts +++ b/packages/node/test/local/benchmark.test.ts @@ -1,3 +1,6 @@ +import path from 'path'; + +import * as dotenv from 'dotenv'; import { Experiment } from 'src/factory'; import { ExperimentUser } from 'src/types/user'; @@ -5,7 +8,23 @@ import { measure } from './util/performance'; const apiKey = 'server-Ed2doNl5YOblB5lRavQ9toj02arvHpMj'; -const client = Experiment.initializeLocal(apiKey, { debug: false }); +dotenv.config({ path: path.join(__dirname, '../../', '.env') }); + +if (!process.env['API_KEY'] && !process.env['SECRET_KEY']) { + throw new Error( + 'No env vars found. If running on local, have you created .env file correct environment variables? Checkout README.md', + ); +} + +const cohortSyncConfig = { + apiKey: process.env['API_KEY'], + secretKey: process.env['SECRET_KEY'], +}; + +const client = Experiment.initializeLocal(apiKey, { + debug: false, + cohortSyncConfig: cohortSyncConfig, +}); beforeAll(async () => { await client.start(); diff --git a/packages/node/test/local/client.eu.test.ts b/packages/node/test/local/client.eu.test.ts new file mode 100644 index 0000000..c5b6ae8 --- /dev/null +++ b/packages/node/test/local/client.eu.test.ts @@ -0,0 +1,70 @@ +import path from 'path'; + +import * as dotenv from 'dotenv'; +import { Experiment } from 'src/factory'; + +dotenv.config({ path: path.join(__dirname, '../../', '.env') }); + +if (!process.env['EU_API_KEY'] && !process.env['EU_SECRET_KEY']) { + throw new Error( + 'No env vars found. If running on local, have you created .env file correct environment variables? Checkout README.md', + ); +} + +// Simple EU test for connectivity. +const apiKey = 'server-Qlp7XiSu6JtP2S3JzA95PnP27duZgQCF'; + +const client = Experiment.initializeLocal(apiKey, { + serverZone: 'eu', + cohortSyncConfig: { + apiKey: process.env['EU_API_KEY'], + secretKey: process.env['EU_SECRET_KEY'], + }, +}); + +beforeAll(async () => { + await client.start(); +}); + +afterAll(async () => { + client.stop(); +}); + +test('ExperimentClient.evaluate all flags, success', async () => { + const variants = await client.evaluate({ + user_id: 'test_user', + }); + const variant = variants['sdk-local-evaluation-userid']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); +}); + +// Evaluate V2 + +test('ExperimentClient.evaluateV2 all flags, success', async () => { + const variants = await client.evaluateV2({ + user_id: 'test_user', + }); + const variant = variants['sdk-local-evaluation-userid']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); +}); + +test('ExperimentClient.evaluateV2 cohort, targeted', async () => { + const variants = await client.evaluateV2({ + device_id: '0', + user_id: '1', + }); + const variant = variants['sdk-local-evaluation-user-cohort']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); +}); + +test('ExperimentClient.evaluateV2 cohort, not targeted', async () => { + const variants = await client.evaluateV2({ + user_id: '666', + }); + const variant = variants['sdk-local-evaluation-user-cohort']; + expect(variant.key).toEqual('off'); + expect(variant.value).toBeUndefined(); +}); diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index c193701..e1cc8d1 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -1,149 +1,641 @@ +import path from 'path'; + +import { EvaluationFlag } from '@amplitude/experiment-core'; +import * as dotenv from 'dotenv'; import { Experiment } from 'src/factory'; +import { InMemoryFlagConfigCache, LocalEvaluationClient } from 'src/index'; +import { USER_GROUP_TYPE } from 'src/types/cohort'; +import { LocalEvaluationDefaults } from 'src/types/config'; import { ExperimentUser } from 'src/types/user'; +import { sleep } from 'src/util/time'; + +import { COHORTS, FLAGS, NEW_FLAGS } from './util/mockData'; +import { MockHttpClient } from './util/mockHttpClient'; + +dotenv.config({ path: path.join(__dirname, '../../', '.env') }); const apiKey = 'server-qz35UwzJ5akieoAdIgzM4m9MIiOLXLoz'; const testUser: ExperimentUser = { user_id: 'test_user' }; -const client = Experiment.initializeLocal(apiKey); +if (!process.env['API_KEY'] && !process.env['SECRET_KEY']) { + throw new Error( + 'No env vars found. If running on local, have you created .env file correct environment variables? Checkout README.md', + ); +} -beforeAll(async () => { - await client.start(); -}); +const setupEvaluateTestNormalCases = (client: LocalEvaluationClient) => { + test('ExperimentClient.evaluate all flags, success', async () => { + const variants = await client.evaluate(testUser); + const variant = variants['sdk-local-evaluation-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect(variant.payload).toEqual('payload'); + }); -afterAll(async () => { - client.stop(); -}); + test('ExperimentClient.evaluate one flag, success', async () => { + const variants = await client.evaluate(testUser, [ + 'sdk-local-evaluation-ci-test', + ]); + const variant = variants['sdk-local-evaluation-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect(variant.payload).toEqual('payload'); + }); -test('ExperimentClient.evaluate all flags, success', async () => { - const variants = await client.evaluate(testUser); - const variant = variants['sdk-local-evaluation-ci-test']; - expect(variant.key).toEqual('on'); - expect(variant.value).toEqual('on'); - expect(variant.payload).toEqual('payload'); -}); + test('ExperimentClient.evaluate with dependencies, no flag keys, success', async () => { + const variants = await client.evaluate({ + user_id: 'user_id', + device_id: 'device_id', + }); + const variant = variants['sdk-ci-local-dependencies-test']; + expect(variant.key).toEqual('control'); + expect(variant.value).toEqual('control'); + }); -test('ExperimentClient.evaluate one flag, success', async () => { - const variants = await client.evaluate(testUser, [ - 'sdk-local-evaluation-ci-test', - ]); - const variant = variants['sdk-local-evaluation-ci-test']; - expect(variant.key).toEqual('on'); - expect(variant.value).toEqual('on'); - expect(variant.payload).toEqual('payload'); -}); + test('ExperimentClient.evaluate with dependencies, with flag keys, success', async () => { + const variants = await client.evaluate( + { + user_id: 'user_id', + device_id: 'device_id', + }, + ['sdk-ci-local-dependencies-test'], + ); + const variant = variants['sdk-ci-local-dependencies-test']; + expect(variant.key).toEqual('control'); + expect(variant.value).toEqual('control'); + }); -test('ExperimentClient.evaluate with dependencies, no flag keys, success', async () => { - const variants = await client.evaluate({ - user_id: 'user_id', - device_id: 'device_id', + test('ExperimentClient.evaluate with dependencies, with unknown flag keys, no variant', async () => { + const variants = await client.evaluate( + { + user_id: 'user_id', + device_id: 'device_id', + }, + ['does-not-exist'], + ); + const variant = variants['sdk-ci-local-dependencies-test']; + expect(variant).toBeUndefined(); }); - const variant = variants['sdk-ci-local-dependencies-test']; - expect(variant.key).toEqual('control'); - expect(variant.value).toEqual('control'); -}); -test('ExperimentClient.evaluate with dependencies, with flag keys, success', async () => { - const variants = await client.evaluate( - { + test('ExperimentClient.evaluate with dependencies, variant held out', async () => { + const variants = await client.evaluate({ user_id: 'user_id', device_id: 'device_id', - }, - ['sdk-ci-local-dependencies-test'], - ); - const variant = variants['sdk-ci-local-dependencies-test']; - expect(variant.key).toEqual('control'); - expect(variant.value).toEqual('control'); -}); + }); + const variant = variants['sdk-ci-local-dependencies-test-holdout']; + expect(variant).toBeUndefined(); + expect( + await client.cache.get('sdk-ci-local-dependencies-test-holdout'), + ).toBeDefined(); + }); -test('ExperimentClient.evaluate with dependencies, with unknown flag keys, no variant', async () => { - const variants = await client.evaluate( - { + // Evaluate V2. + test('ExperimentClient.evaluateV2 all flags, success', async () => { + const variants = await client.evaluateV2(testUser); + const variant = variants['sdk-local-evaluation-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect(variant.payload).toEqual('payload'); + }); + + test('ExperimentClient.evaluateV2 one flag, success', async () => { + const variants = await client.evaluateV2(testUser, [ + 'sdk-local-evaluation-ci-test', + ]); + const variant = variants['sdk-local-evaluation-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect(variant.payload).toEqual('payload'); + }); + + test('ExperimentClient.evaluateV2 with dependencies, no flag keys, success', async () => { + const variants = await client.evaluateV2({ user_id: 'user_id', device_id: 'device_id', - }, - ['does-not-exist'], - ); - const variant = variants['sdk-ci-local-dependencies-test']; - expect(variant).toBeUndefined(); -}); + }); + const variant = variants['sdk-ci-local-dependencies-test']; + expect(variant.key).toEqual('control'); + expect(variant.value).toEqual('control'); + }); -test('ExperimentClient.evaluate with dependencies, variant held out', async () => { - const variants = await client.evaluate({ - user_id: 'user_id', - device_id: 'device_id', + test('ExperimentClient.evaluateV2 with dependencies, with flag keys, success', async () => { + const variants = await client.evaluateV2( + { + user_id: 'user_id', + device_id: 'device_id', + }, + ['sdk-ci-local-dependencies-test'], + ); + const variant = variants['sdk-ci-local-dependencies-test']; + expect(variant.key).toEqual('control'); + expect(variant.value).toEqual('control'); }); - const variant = variants['sdk-ci-local-dependencies-test-holdout']; - expect(variant).toBeUndefined(); - expect( - await client.cache.get('sdk-ci-local-dependencies-test-holdout'), - ).toBeDefined(); -}); -// Evaluate V2 + test('ExperimentClient.evaluateV2 with dependencies, with unknown flag keys, no variant', async () => { + const variants = await client.evaluateV2( + { + user_id: 'user_id', + device_id: 'device_id', + }, + ['does-not-exist'], + ); + const variant = variants['sdk-ci-local-dependencies-test']; + expect(variant).toBeUndefined(); + }); -test('ExperimentClient.evaluateV2 all flags, success', async () => { - const variants = await client.evaluateV2(testUser); - const variant = variants['sdk-local-evaluation-ci-test']; - expect(variant.key).toEqual('on'); - expect(variant.value).toEqual('on'); - expect(variant.payload).toEqual('payload'); -}); + test('ExperimentClient.evaluateV2 with dependencies, variant held out', async () => { + const variants = await client.evaluateV2({ + user_id: 'user_id', + device_id: 'device_id', + }); + const variant = variants['sdk-ci-local-dependencies-test-holdout']; + expect(variant.key).toEqual('off'); + expect(variant.value).toBeUndefined(); + expect( + await client.cache.get('sdk-ci-local-dependencies-test-holdout'), + ).toBeDefined(); + }); +}; -test('ExperimentClient.evaluateV2 one flag, success', async () => { - const variants = await client.evaluateV2(testUser, [ - 'sdk-local-evaluation-ci-test', - ]); - const variant = variants['sdk-local-evaluation-ci-test']; - expect(variant.key).toEqual('on'); - expect(variant.value).toEqual('on'); - expect(variant.payload).toEqual('payload'); -}); +const setupEvaluateCohortTestNormalCases = (client: LocalEvaluationClient) => { + test('ExperimentClient.evaluateV2 with user or group cohort not targeted', async () => { + const variants = await client.evaluateV2({ + user_id: '2333', + device_id: 'device_id', + groups: { + 'org name': ['Amplitude Inc sth sth sth'], + }, + }); + const userVariant = variants['sdk-local-evaluation-user-cohort-ci-test']; + expect(userVariant.key).toEqual('off'); + expect(userVariant.value).toBeUndefined(); + expect( + await client.cache.get('sdk-local-evaluation-user-cohort-ci-test'), + ).toBeDefined(); + const groupVariant = variants['sdk-local-evaluation-group-cohort-ci-test']; + expect(groupVariant.key).toEqual('off'); + expect(groupVariant.value).toBeUndefined(); + expect( + await client.cache.get('sdk-local-evaluation-group-cohort-ci-test'), + ).toBeDefined(); + }); -test('ExperimentClient.evaluateV2 with dependencies, no flag keys, success', async () => { - const variants = await client.evaluateV2({ - user_id: 'user_id', - device_id: 'device_id', + test('ExperimentClient.evaluateV2 with user cohort segment targeted', async () => { + const variants = await client.evaluateV2({ + user_id: '12345', + device_id: 'device_id', + }); + const variant = variants['sdk-local-evaluation-user-cohort-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect( + await client.cache.get('sdk-local-evaluation-user-cohort-ci-test'), + ).toBeDefined(); }); - const variant = variants['sdk-ci-local-dependencies-test']; - expect(variant.key).toEqual('control'); - expect(variant.value).toEqual('control'); -}); -test('ExperimentClient.evaluateV2 with dependencies, with flag keys, success', async () => { - const variants = await client.evaluateV2( - { - user_id: 'user_id', + test('ExperimentClient.evaluateV2 with user cohort tester targeted', async () => { + const variants = await client.evaluateV2({ + user_id: '1', device_id: 'device_id', - }, - ['sdk-ci-local-dependencies-test'], - ); - const variant = variants['sdk-ci-local-dependencies-test']; - expect(variant.key).toEqual('control'); - expect(variant.value).toEqual('control'); -}); + }); + const variant = variants['sdk-local-evaluation-user-cohort-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect( + await client.cache.get('sdk-local-evaluation-user-cohort-ci-test'), + ).toBeDefined(); + }); -test('ExperimentClient.evaluateV2 with dependencies, with unknown flag keys, no variant', async () => { - const variants = await client.evaluateV2( - { - user_id: 'user_id', + test('ExperimentClient.evaluateV2 with group cohort segment targeted', async () => { + const variants = await client.evaluateV2({ + user_id: '12345', device_id: 'device_id', - }, - ['does-not-exist'], - ); - const variant = variants['sdk-ci-local-dependencies-test']; - expect(variant).toBeUndefined(); + groups: { + 'org id': ['1'], + }, + }); + const variant = variants['sdk-local-evaluation-group-cohort-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect( + await client.cache.get('sdk-local-evaluation-group-cohort-ci-test'), + ).toBeDefined(); + }); + + test('ExperimentClient.evaluateV2 with group cohort tester targeted', async () => { + const variants = await client.evaluateV2({ + user_id: '2333', + device_id: 'device_id', + groups: { + 'org name': ['Amplitude Website (Portfolio)'], + }, + }); + const variant = variants['sdk-local-evaluation-group-cohort-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect( + await client.cache.get('sdk-local-evaluation-group-cohort-ci-test'), + ).toBeDefined(); + }); +}; + +const setupEvaluateCohortTestErrorClientCases = ( + client: LocalEvaluationClient, +) => { + test('ExperimentClient.evaluateV2 with user or group cohort, no error thrown, but unknown behavior', async () => { + const variants = await client.evaluateV2({ + user_id: '2333', + device_id: 'device_id', + groups: { + 'org name': ['Amplitude Inc sth sth sth'], + }, + }); + const userVariant = variants['sdk-local-evaluation-user-cohort-ci-test']; + expect(userVariant).toBeDefined(); + expect( + await client.cache.get('sdk-local-evaluation-user-cohort-ci-test'), + ).toBeDefined(); + const groupVariant = variants['sdk-local-evaluation-group-cohort-ci-test']; + expect(groupVariant).toBeDefined(); + expect( + await client.cache.get('sdk-local-evaluation-group-cohort-ci-test'), + ).toBeDefined(); + }); +}; + +describe('ExperimentClient end-to-end tests, normal cases', () => { + describe('Normal cases', () => { + const client = Experiment.initializeLocal(apiKey, { + cohortSyncConfig: { + apiKey: process.env['API_KEY'], + secretKey: process.env['SECRET_KEY'], + }, + }); + + beforeAll(async () => { + await client.start(); + }); + + afterAll(async () => { + client.stop(); + }); + + setupEvaluateTestNormalCases(client); + setupEvaluateCohortTestNormalCases(client); + }); + + describe('No cohort config', () => { + const client = Experiment.initializeLocal(apiKey); + + beforeAll(async () => { + await client.start(); + }); + + afterAll(async () => { + client.stop(); + }); + + setupEvaluateTestNormalCases(client); + setupEvaluateCohortTestErrorClientCases(client); + }); + + describe('Bad cohort config', () => { + const client = Experiment.initializeLocal(apiKey, { + cohortSyncConfig: { + apiKey: 'bad_api_key', + secretKey: 'bad_secret_key', + }, + }); + + beforeAll(async () => { + await client.start(); + }); + + afterAll(async () => { + client.stop(); + }); + + setupEvaluateTestNormalCases(client); + setupEvaluateCohortTestErrorClientCases(client); + }); +}); + +describe('ExperimentClient integration tests', () => { + let flagFetchRequestCount; + let cohortFetchRequestCount; + let mockHttpClient; + + beforeEach(() => { + jest.clearAllMocks(); + + flagFetchRequestCount = 0; + cohortFetchRequestCount = 0; + mockHttpClient = new MockHttpClient(async (params) => { + const url = new URL(params.requestUrl); + let body; + if (url.pathname.startsWith('/sdk/v2/flags')) { + // Flags. + flagFetchRequestCount++; + if (flagFetchRequestCount == 1) { + body = JSON.stringify(Object.values(FLAGS)); + } else { + body = JSON.stringify(Object.values(NEW_FLAGS)); + } + } else if (url.pathname.startsWith('/sdk/v1/cohort')) { + // Cohorts. + const cohortId = url.pathname.substring(15); + if (!(cohortId in COHORTS)) { + return { status: 404, body: 'Not found' }; + } + if (url.searchParams.get('maxCohortSize') < COHORTS[cohortId].size) { + return { status: 413, body: 'Max size exceeded' }; + } + if ( + url.searchParams.get('lastModified') == COHORTS[cohortId].lastModified + ) { + return { status: 204, body: '' }; + } + const cohort = { ...COHORTS[cohortId] }; + cohort.memberIds = [...cohort.memberIds]; + body = JSON.stringify(cohort); + } + return { status: 200, body: body }; + }); + }); + + test('ExperimentClient cohort targeting success', async () => { + const client = new LocalEvaluationClient( + 'apikey', + { + cohortSyncConfig: { + apiKey: 'apiKey', + secretKey: 'secretKey', + maxCohortSize: 10, + }, + }, + null, + mockHttpClient, + ); + await client.start(); + + let result; + result = client.evaluateV2({ user_id: 'membera1', device_id: '1' }); + expect(result['flag1'].key).toBe('on'); + expect(result['flag2'].key).toBe('on'); + expect(result['flag3'].key).toBe('var1'); + expect(result['flag4'].key).toBe('var1'); + + result = client.evaluateV2({ user_id: 'membera2', device_id: '1' }); + expect(result['flag1'].key).toBe('off'); + expect(result['flag2'].key).toBe('on'); + expect(result['flag3'].key).toBe('var2'); + expect(result['flag4'].key).toBe('var1'); + + result = client.evaluateV2({ user_id: 'membera3', device_id: '1' }); + expect(result['flag1'].key).toBe('off'); + expect(result['flag2'].key).toBe('on'); + expect(result['flag3'].key).toBe('var1'); + expect(result['flag4'].key).toBe('var1'); + + result = client.evaluateV2({ + user_id: '1', + device_id: '1', + groups: { 'org name': ['org name 1'] }, + }); + expect(result['flag1'].key).toBe('off'); + expect(result['flag2'].key).toBe('off'); + expect(result['flag3'].key).toBe('off'); + expect(result['flag4'].key).toBe('var2'); + + result = client.evaluateV2({ + user_id: '1', + device_id: '1', + }); + expect(result['flag1'].key).toBe('off'); + expect(result['flag2'].key).toBe('off'); + expect(result['flag3'].key).toBe('off'); + expect(result['flag4'].key).toBe('off'); + + client.stop(); + }); + + test('ExperimentClient cohort no config doesnt throw', async () => { + const client = new LocalEvaluationClient( + 'apikey', + {}, + null, + mockHttpClient, + ); + await client.start(); + + const result = client.evaluateV2({ user_id: 'membera1', device_id: '1' }); + // Currently cohort failed to download simply means there's no members in cohort as it's not going to be added to evaluation context. + // This behavior will change. + expect(result['flag1'].key).toBe('off'); + expect(result['flag2'].key).toBe('off'); + expect(result['flag3'].key).toBe('off'); + expect(result['flag4'].key).toBe('off'); + + client.stop(); + }); + + test('ExperimentClient cohort maxCohortSize download fail', async () => { + const client = new LocalEvaluationClient( + 'apikey', + { + cohortSyncConfig: { + apiKey: 'apiKey', + secretKey: 'secretKey', + maxCohortSize: 0, + }, + }, + null, + mockHttpClient, + ); + await client.start(); + + const result = client.evaluateV2({ user_id: 'membera1', device_id: '1' }); + // Currently cohort failed to download simply means there's no members in cohort as it's not going to be added to evaluation context. + // This behavior will change. + expect(result['flag1'].key).toBe('off'); + expect(result['flag2'].key).toBe('off'); + expect(result['flag3'].key).toBe('off'); + expect(result['flag4'].key).toBe('off'); + + client.stop(); + }); + + test('ExperimentClient cohort download initial failures, but poller would success', async () => { + jest.setTimeout(70000); + const client = new LocalEvaluationClient( + 'apikey', + { + flagConfigPollingIntervalMillis: 40000, + cohortSyncConfig: { + apiKey: 'apiKey', + secretKey: 'secretKey', + maxCohortSize: 10, + }, + }, + null, + new MockHttpClient(async (params) => { + const url = new URL(params.requestUrl); + let body; + if (url.pathname.startsWith('/sdk/v2/flags')) { + // Flags. + flagFetchRequestCount++; + if (flagFetchRequestCount == 1) { + body = JSON.stringify(Object.values(FLAGS)); + } else { + body = JSON.stringify(Object.values(NEW_FLAGS)); + } + } else if (url.pathname.startsWith('/sdk/v1/cohort')) { + // Cohorts. + cohortFetchRequestCount++; + if (cohortFetchRequestCount <= 3 * 11) { + // 3 retries per cohort, 11 requests before poller poll. + // 5 initial requests, 6 requests after flag update. + throw Error('Timeout'); + } + const cohortId = url.pathname.substring(15); + if (!(cohortId in COHORTS)) { + return { status: 404, body: 'Not found' }; + } + if (url.searchParams.get('maxCohortSize') < COHORTS[cohortId].size) { + return { status: 413, body: 'Max size exceeded' }; + } + if ( + url.searchParams.get('lastModified') == + COHORTS[cohortId].lastModified + ) { + return { status: 204, body: '' }; + } + const cohort = { ...COHORTS[cohortId] }; + cohort.memberIds = [...cohort.memberIds]; + body = JSON.stringify(cohort); + } + return { status: 200, body: body }; + }), + ); + await client.start(); + + let result = client.evaluateV2({ user_id: 'membera1', device_id: '1' }); + // Currently cohort failed to download simply means there's no members in cohort as it's not going to be added to evaluation context. + // This behavior will change. + expect(result['flag1'].key).toBe('off'); + expect(result['flag2'].key).toBe('off'); + expect(result['flag3'].key).toBe('off'); + expect(result['flag4'].key).toBe('off'); + expect(result['flag5']).toBeUndefined(); + + await sleep(62000); // Poller polls after 60s. + // Within this time, + // Flag poller (flagConfigPollingIntervalMillis = 40000) will poll a new version, NEW_FLAGS which contains flag5. + // This flag poll will also try to download cohorts. + // Cohort poller (pollingIntervalMillis = 60000) will poll all cohorts in the flags, which will should success. + + result = client.evaluateV2({ user_id: 'membera1', device_id: '1' }); + // Currently cohort failed to download simply means there's no members in cohort as it's not going to be added to evaluation context. + // This behavior will change. + expect(result['flag1'].key).toBe('on'); + expect(result['flag2'].key).toBe('on'); + expect(result['flag3'].key).toBe('var1'); + expect(result['flag4'].key).toBe('var1'); + expect(result['flag5'].key).toBe('off'); + + client.stop(); + }); }); -test('ExperimentClient.evaluateV2 with dependencies, variant held out', async () => { - const variants = await client.evaluateV2({ - user_id: 'user_id', - device_id: 'device_id', - }); - const variant = variants['sdk-ci-local-dependencies-test-holdout']; - expect(variant.key).toEqual('off'); - expect(variant.value).toBeUndefined(); - expect( - await client.cache.get('sdk-ci-local-dependencies-test-holdout'), - ).toBeDefined(); +describe('ExperimentClient unit tests', () => { + // Unit tests + class TestLocalEvaluationClient extends LocalEvaluationClient { + public enrichUserWithCohorts( + user: ExperimentUser, + flags: Record, + ) { + super.enrichUserWithCohorts(user, flags); + } + } + + test('ExperimentClient.enrichUserWithCohorts', async () => { + const client = new TestLocalEvaluationClient( + apiKey, + LocalEvaluationDefaults, + new InMemoryFlagConfigCache(), + ); + client.cohortStorage.put({ + cohortId: 'cohort1', + groupType: USER_GROUP_TYPE, + groupTypeId: 0, + lastComputed: 0, + lastModified: 0, + size: 1, + memberIds: new Set(['userId']), + }); + client.cohortStorage.put({ + cohortId: 'groupcohort1', + groupType: 'groupname', + groupTypeId: 1, + lastComputed: 0, + lastModified: 0, + size: 1, + memberIds: new Set(['amplitude', 'experiment']), + }); + const user = { + user_id: 'userId', + groups: { + groupname: ['amplitude'], + }, + }; + client.enrichUserWithCohorts(user, { + flag1: { + key: 'flag1', + variants: {}, + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['cohort1'], + }, + ], + ], + }, + ], + }, + flag2: { + key: 'flag2', + variants: {}, + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'groups', 'groupname', 'cohort_ids'], + values: ['groupcohort1', 'groupcohortnotinstorage'], + }, + ], + ], + }, + ], + }, + }); + expect(user).toStrictEqual({ + user_id: 'userId', + cohort_ids: ['cohort1'], + groups: { + groupname: ['amplitude'], + }, + group_cohort_ids: { + groupname: { + amplitude: ['groupcohort1'], + }, + }, + }); + }); }); diff --git a/packages/node/test/local/cohort/cohortApi.test.ts b/packages/node/test/local/cohort/cohortApi.test.ts new file mode 100644 index 0000000..e03a0fb --- /dev/null +++ b/packages/node/test/local/cohort/cohortApi.test.ts @@ -0,0 +1,242 @@ +import { SdkCohortApi } from 'src/local/cohort/cohort-api'; +import { WrapperClient } from 'src/transport/http'; + +import { version as PACKAGE_VERSION } from '../../../gen/version'; +import { MockHttpClient } from '../util/mockHttpClient'; + +const SAMPLE_PARAMS = { + libraryName: 'lib', + libraryVersion: 'ver', + cohortId: 'cohortId', + maxCohortSize: 10, + lastModified: 100, +}; + +test('getCohort no lastModified', async () => { + const mockHttpClient = new MockHttpClient(async (params) => { + expect(params.requestUrl).toBe( + 'https://example.com/cohortapi/sdk/v1/cohort/cohortId?maxCohortSize=10', + ); + expect(params.headers).toStrictEqual({ + Authorization: 'Basic apikeyapikey', + 'X-Amp-Exp-Library': 'lib/ver', + }); + return { status: 200, body: '{}' }; + }); + const api = new SdkCohortApi( + 'apikeyapikey', + 'https://example.com/cohortapi', + new WrapperClient(mockHttpClient), + ); + await api.getCohort({ + libraryName: 'lib', + libraryVersion: 'ver', + cohortId: 'cohortId', + maxCohortSize: 10, + lastModified: undefined, + }); +}); + +test('getCohort with lastModified', async () => { + const mockHttpClient = new MockHttpClient(async (params) => { + expect(params.requestUrl).toBe( + 'https://example.com/cohortapi/sdk/v1/cohort/cohortId?maxCohortSize=10&lastModified=100', + ); + expect(params.headers).toStrictEqual({ + Authorization: 'Basic apikeyapikey', + 'X-Amp-Exp-Library': 'lib/ver', + }); + return { status: 200, body: '{}' }; + }); + const api = new SdkCohortApi( + 'apikeyapikey', + 'https://example.com/cohortapi', + new WrapperClient(mockHttpClient), + ); + await api.getCohort({ + libraryName: 'lib', + libraryVersion: 'ver', + cohortId: 'cohortId', + maxCohortSize: 10, + lastModified: 100, + }); +}); + +test('getCohort with 204', async () => { + const mockHttpClient = new MockHttpClient(async () => { + return { status: 204, body: '' }; + }); + const api = new SdkCohortApi('', '', new WrapperClient(mockHttpClient)); + const cohort = await api.getCohort(SAMPLE_PARAMS); + expect(cohort).toBeUndefined(); +}); + +test('getCohort with 413', async () => { + const mockHttpClient = new MockHttpClient(async () => { + return { status: 413, body: '' }; + }); + const api = new SdkCohortApi('', '', new WrapperClient(mockHttpClient)); + await expect(api.getCohort(SAMPLE_PARAMS)).rejects.toThrow(); +}); + +test('getCohort with other status code', async () => { + const mockHttpClient = new MockHttpClient(async () => { + return { status: 500, body: '' }; + }); + const api = new SdkCohortApi('', '', new WrapperClient(mockHttpClient)); + await expect(api.getCohort(SAMPLE_PARAMS)).rejects.toThrow(); +}); + +const C_A = { + cohortId: 'c_a', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 0, + size: 2, + memberIds: new Set(['membera1', 'membera2']), // memberIds needs to convert to array before json stringify. +}; + +const cohortId = '1'; +const apiKey = 'apple'; +const secretKey = 'banana'; +const serverUrl = 'https://example.com/cohortapi'; +const encodedKey = Buffer.from(`${apiKey}:${secretKey}`).toString('base64'); +const expectedHeaders = { + Authorization: `Basic ${encodedKey}`, + 'X-Amp-Exp-Library': `experiment-node-server/${PACKAGE_VERSION}`, +}; + +test('getCohort success', async () => { + const maxCohortSize = 10; + const httpClient = new MockHttpClient(async (params) => { + expect(params.requestUrl).toBe( + `${serverUrl}/sdk/v1/cohort/${cohortId}?maxCohortSize=${maxCohortSize}`, + ); + expect(params.headers).toStrictEqual(expectedHeaders); + return { + status: 200, + body: JSON.stringify({ ...C_A, memberIds: Array.from(C_A.memberIds) }), + }; + }); + const api = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + const cohort = await api.getCohort({ + cohortId, + maxCohortSize, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + }); + expect(cohort).toStrictEqual(C_A); +}); + +test('getCohort 413', async () => { + const maxCohortSize = 1; + const httpClient = new MockHttpClient(async (params) => { + expect(params.requestUrl).toBe( + `${serverUrl}/sdk/v1/cohort/${cohortId}?maxCohortSize=${maxCohortSize}`, + ); + expect(params.headers).toStrictEqual(expectedHeaders); + return { status: 413, body: '' }; + }); + const api = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + await expect( + api.getCohort({ + cohortId, + maxCohortSize, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + }), + ).rejects.toThrow(); +}); + +test('getCohort no modification 204', async () => { + const maxCohortSize = 10; + const lastModified = 10; + const httpClient = new MockHttpClient(async (params) => { + expect(params.requestUrl).toBe( + `${serverUrl}/sdk/v1/cohort/${cohortId}?maxCohortSize=${maxCohortSize}&lastModified=${lastModified}`, + ); + expect(params.headers).toStrictEqual(expectedHeaders); + return { status: 204, body: '' }; + }); + const api = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + expect( + await api.getCohort({ + cohortId, + maxCohortSize, + lastModified, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + }), + ).toBeUndefined(); +}); + +test('getCohort no modification but still return cohort due to cache miss', async () => { + const maxCohortSize = 10; + const lastModified = 10; + const httpClient = new MockHttpClient(async (params) => { + expect(params.requestUrl).toBe( + `${serverUrl}/sdk/v1/cohort/${cohortId}?maxCohortSize=${maxCohortSize}&lastModified=${lastModified}`, + ); + expect(params.headers).toStrictEqual(expectedHeaders); + return { + status: 200, + body: JSON.stringify({ ...C_A, memberIds: Array.from(C_A.memberIds) }), + }; + }); + const api = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + expect( + await api.getCohort({ + cohortId, + maxCohortSize, + lastModified, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + }), + ).toStrictEqual(C_A); +}); + +test('getCohort other errors', async () => { + const maxCohortSize = 10; + const lastModified = 10; + const httpClient = new MockHttpClient(async (params) => { + expect(params.requestUrl).toBe( + `${serverUrl}/sdk/v1/cohort/${cohortId}?maxCohortSize=${maxCohortSize}&lastModified=${lastModified}`, + ); + expect(params.headers).toStrictEqual(expectedHeaders); + return { + status: 500, + body: JSON.stringify({ ...C_A, memberIds: Array.from(C_A.memberIds) }), + }; + }); + const api = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + await expect( + api.getCohort({ + cohortId, + maxCohortSize, + lastModified, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + }), + ).rejects.toThrow(); +}); diff --git a/packages/node/test/local/cohort/cohortFetcher.test.ts b/packages/node/test/local/cohort/cohortFetcher.test.ts new file mode 100644 index 0000000..3f5637a --- /dev/null +++ b/packages/node/test/local/cohort/cohortFetcher.test.ts @@ -0,0 +1,268 @@ +import { + CohortClientRequestError, + CohortMaxSizeExceededError, + SdkCohortApi, +} from 'src/local/cohort/cohort-api'; +import { COHORT_CONFIG_TIMEOUT, CohortFetcher } from 'src/local/cohort/fetcher'; +import { CohortSyncConfigDefaults } from 'src/types/config'; +import { sleep } from 'src/util/time'; + +import { version as PACKAGE_VERSION } from '../../../gen/version'; + +const COHORTS = { + c1: { + cohortId: 'c1', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 1, + size: 2, + memberIds: new Set(['membera1', 'membera2']), + }, + c2: { + cohortId: 'c2', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 10, + size: 3, + memberIds: new Set(['membera1', 'membera2', 'membera3']), + }, + c3: { + cohortId: 'c3', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 10, + size: 3, + memberIds: new Set(['membera1', 'membera2', 'membera3']), + }, +}; + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('cohort fetch success', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation( + async (options) => COHORTS[options.cohortId], + ); + + const cohortFetcher = new CohortFetcher('', '', null); + + const c1 = await cohortFetcher.fetch('c1'); + expect(c1).toBe(COHORTS['c1']); + + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: undefined, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: CohortSyncConfigDefaults.maxCohortSize, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); +}); + +test('cohort fetch success using maxCohortSize and lastModified', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation( + async (options) => COHORTS[options.cohortId], + ); + + const cohortFetcher = new CohortFetcher('', '', null, 'someurl', 10); + + const c1 = await cohortFetcher.fetch('c1', 10); + expect(c1).toBe(COHORTS['c1']); + + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: 10, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); +}); + +test('cohort fetch unchanged returns undefined', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation(async () => { + return undefined; + }); + + const cohortFetcher = new CohortFetcher('', '', null, 'someurl', 10); + + // Make 3 requests at the same time. + const c1 = await cohortFetcher.fetch('c1', 20); + + expect(cohortApiGetCohortSpy).toBeCalledTimes(1); + expect(c1).toBeUndefined(); + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: 20, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); +}); + +test('cohort fetch failed', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation(async () => { + throw Error(); + }); + + const cohortFetcher = new CohortFetcher('', '', null, 'someurl', 10, 100); + const cohortPromise = cohortFetcher.fetch('c1', 10); + await sleep(10); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(1); + await sleep(100); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(2); + + await expect(cohortPromise).rejects.toThrowError(); + + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(3); + + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: 10, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); +}); + +test('cohort fetch maxSize exceeded, no retry', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation(async () => { + throw new CohortMaxSizeExceededError(); + }); + + const cohortFetcher = new CohortFetcher('', '', null, 'someurl', 10, 100); + const cohortPromise = cohortFetcher.fetch('c1', 10); + + await expect(cohortPromise).rejects.toThrowError(); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(1); + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: 10, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); +}); + +test('cohort fetch client error, no retry', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation(async () => { + throw new CohortClientRequestError(); + }); + + const cohortFetcher = new CohortFetcher('', '', null, 'someurl', 10, 100); + const cohortPromise = cohortFetcher.fetch('c1', 10); + + await expect(cohortPromise).rejects.toThrowError(); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(1); + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: 10, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); +}); + +test('cohort fetch twice on same cohortId uses same promise and make only one request', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation(async (options) => { + // Await 2s to allow second fetch call being made. + await new Promise((resolve) => setTimeout(resolve, 2000)); + // Always return a new object. + return { ...COHORTS[options.cohortId] }; + }); + + const cohortFetcher = new CohortFetcher('', '', null, 'someurl', 10); + + // Make 2 requests at the same time. + const promise1 = cohortFetcher.fetch('c1', 10); + const promise2 = cohortFetcher.fetch('c1', 10); + + // Cannot do following assertion because the promise returned by an async func may not be exactly the promise being returned. + // https://stackoverflow.com/questions/61354565/does-async-tag-wrap-a-javascript-function-with-a-promise + // expect(promise1 === promise2).toBeTruthy(); + + const c1 = await promise1; + const c1_2 = await promise2; + + // Only made one request. + expect(cohortApiGetCohortSpy).toBeCalledTimes(1); + // The references of objects returned by both are the same. + expect(c1 === c1_2).toBeTruthy(); + // A new object is returned. + expect(c1 !== COHORTS['c1']).toBeTruthy(); + // Contents are the same. + expect(c1).toStrictEqual(COHORTS['c1']); + // Check args. + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: 10, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); +}); + +test('cohort fetch twice on same cohortId different lastModified makes 2 requests', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation(async (options) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return { ...COHORTS[options.cohortId] }; + }); + + const cohortFetcher = new CohortFetcher('', '', null, 'someurl', 10); + + // Make 3 requests at the same time. + const promise1 = cohortFetcher.fetch('c1', 20); + const promise2 = cohortFetcher.fetch('c1', 10); + const promise3 = cohortFetcher.fetch('c2', 10); + const c1 = await promise1; + const c1_2 = await promise2; + const c2 = await promise3; + + expect(cohortApiGetCohortSpy).toBeCalledTimes(3); + expect(c1 !== c1_2).toBeTruthy(); + expect(c1 !== c2).toBeTruthy(); + expect(c1).toStrictEqual(COHORTS['c1']); + expect(c1_2).toStrictEqual(COHORTS['c1']); + expect(c2).toStrictEqual(COHORTS['c2']); + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: 20, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: 10, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c2', + lastModified: 10, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); +}); diff --git a/packages/node/test/local/cohort/cohortPoller.test.ts b/packages/node/test/local/cohort/cohortPoller.test.ts new file mode 100644 index 0000000..729cd5a --- /dev/null +++ b/packages/node/test/local/cohort/cohortPoller.test.ts @@ -0,0 +1,325 @@ +import { FlagConfigCache, InMemoryFlagConfigCache } from 'src/index'; +import { SdkCohortApi } from 'src/local/cohort/cohort-api'; +import { CohortFetcher } from 'src/local/cohort/fetcher'; +import { CohortPoller } from 'src/local/cohort/poller'; +import { InMemoryCohortStorage } from 'src/local/cohort/storage'; +import { CohortStorage } from 'src/types/cohort'; +import { sleep } from 'src/util/time'; + +import { getFlagWithCohort } from '../util/mockData'; + +const OLD_COHORTS = { + c1: { + cohortId: 'c1', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 1, + size: 2, + memberIds: new Set(['membera1', 'membera2']), + }, + c2: { + cohortId: 'c2', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 10, + size: 3, + memberIds: new Set(['membera1', 'membera2', 'membera3']), + }, + c3: { + cohortId: 'c3', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 10, + size: 3, + memberIds: new Set(['membera1', 'membera2', 'membera3']), + }, +}; + +const NEW_COHORTS = { + c1: { + cohortId: 'c1', + groupType: 'a', + groupTypeId: 0, + lastComputed: 1, + lastModified: 2, + size: 2, + memberIds: new Set(['membera1', 'membera2']), + }, + c2: { + cohortId: 'c2', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 20, + size: 3, + memberIds: new Set(['membera1', 'membera2', 'membera3']), + }, + c3: { + cohortId: 'c3', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 20, + size: 3, + memberIds: new Set(['membera1', 'membera2', 'membera3']), + }, +}; + +const POLL_MILLIS = 500; +let flagsCache: FlagConfigCache; +let storage: CohortStorage; +let fetcher: CohortFetcher; +let poller: CohortPoller; +let storageGetCohortSpy: jest.SpyInstance; +let storagePutSpy: jest.SpyInstance; + +beforeEach(() => { + flagsCache = new InMemoryFlagConfigCache(); + storage = new InMemoryCohortStorage(); + fetcher = new CohortFetcher('', '', null); + poller = new CohortPoller(fetcher, storage, flagsCache, POLL_MILLIS); + + const flagsCacheGetAllSpy = jest.spyOn(flagsCache, 'getAll'); + flagsCacheGetAllSpy.mockImplementation(async () => ({ + flag_c1: getFlagWithCohort('c1'), + flag_c2: getFlagWithCohort('c2'), + })); + storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + storageGetCohortSpy.mockImplementation( + (cohortId: string) => OLD_COHORTS[cohortId], + ); + storagePutSpy = jest.spyOn(storage, 'put'); +}); + +afterEach(() => { + poller.stop(); + jest.clearAllMocks(); +}); + +test('CohortPoller update success', async () => { + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation( + async (cohortId: string) => NEW_COHORTS[cohortId], + ); + + await poller.update(); + + for (const cohortId of storage.getAllCohortIds()) { + expect(storageGetCohortSpy).toHaveBeenCalledWith(cohortId); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + cohortId, + OLD_COHORTS[cohortId].lastModified, + ); + } + expect(storagePutSpy).toHaveBeenCalledWith(NEW_COHORTS['c1']); + expect(storagePutSpy).toHaveBeenCalledWith(NEW_COHORTS['c2']); +}); + +test("CohortPoller update don't update unchanged cohort", async () => { + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation(async (cohortId) => { + if (cohortId === 'c1') { + return NEW_COHORTS['c1']; + } + return undefined; + }); + + await poller.update(); + + for (const cohortId of storage.getAllCohortIds()) { + expect(storageGetCohortSpy).toHaveBeenCalledWith(cohortId); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + cohortId, + OLD_COHORTS[cohortId].lastModified, + ); + } + expect(storagePutSpy).toHaveBeenCalledWith(NEW_COHORTS['c1']); + expect(storagePutSpy).toHaveBeenCalledTimes(1); +}); + +test("CohortPoller update error don't update cohort", async () => { + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation(async (cohortId) => { + if (cohortId === 'c1') { + return NEW_COHORTS['c1']; + } + throw Error(); + }); + + await poller.update(); + + for (const cohortId of storage.getAllCohortIds()) { + expect(storageGetCohortSpy).toHaveBeenCalledWith(cohortId); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + cohortId, + OLD_COHORTS[cohortId].lastModified, + ); + } + expect(storagePutSpy).toHaveBeenCalledTimes(1); + expect(storagePutSpy).toHaveBeenCalledWith(NEW_COHORTS['c1']); +}); + +test('CohortPoller update no lastModified still fetches cohort', async () => { + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation(async (cohortId) => NEW_COHORTS[cohortId]); + storageGetCohortSpy.mockImplementation((cohortId: string) => { + const cohort = OLD_COHORTS[cohortId]; + if (cohortId === 'c2') { + delete cohort['lastModified']; + } + return cohort; + }); + + await poller.update(); + + expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c1', + OLD_COHORTS['c1'].lastModified, + ); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); + expect(fetcherFetchSpy).toHaveBeenCalledWith('c2', undefined); + expect(storagePutSpy).toHaveBeenCalledTimes(2); + expect(storagePutSpy).toHaveBeenCalledWith(NEW_COHORTS['c1']); + expect(storagePutSpy).toHaveBeenCalledWith(NEW_COHORTS['c2']); +}); + +test('CohortPoller polls every defined ms', async () => { + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation(async (cohortId) => { + return NEW_COHORTS[cohortId]; + }); + + const pollerUpdateSpy = jest.spyOn(poller, 'update'); + + await poller.start(); + + await sleep(100); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(0); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(0); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(0); + expect(storagePutSpy).toHaveBeenCalledTimes(0); + + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(1); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(2); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(2); + expect(storagePutSpy).toHaveBeenCalledTimes(2); + + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(2); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(4); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(4); + expect(storagePutSpy).toHaveBeenCalledTimes(4); + + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(3); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(6); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(6); + expect(storagePutSpy).toHaveBeenCalledTimes(6); + + for (const cohortId of storage.getAllCohortIds()) { + expect(storageGetCohortSpy).toHaveBeenCalledWith(cohortId); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + cohortId, + OLD_COHORTS[cohortId].lastModified, + ); + } +}); + +test('CohortPoller polls takes long time but only makes necessary requests', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation(async (options) => { + await new Promise((resolve) => setTimeout(resolve, POLL_MILLIS * 2.25)); + return NEW_COHORTS[options.cohortId]; + }); + + const pollerUpdateSpy = jest.spyOn(poller, 'update'); + + await poller.start(); + + await sleep(100); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(0); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(0); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(0); + + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(1); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(2); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(2); + + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(2); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(4); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(2); + + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(3); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(6); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(2); + + for (const cohortId of storage.getAllCohortIds()) { + expect(storageGetCohortSpy).toHaveBeenCalledWith(cohortId); + } + expect(storagePutSpy).toHaveBeenCalledTimes(0); + + await sleep(POLL_MILLIS / 2); + + expect(storagePutSpy).toHaveBeenCalledTimes(6); +}); + +test('CohortPoller polls every defined ms with failures', async () => { + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + + const pollerUpdateSpy = jest.spyOn(poller, 'update'); + + await poller.start(); + + await sleep(100); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(0); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(0); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(0); + expect(storagePutSpy).toHaveBeenCalledTimes(0); + + // Error case. + fetcherFetchSpy.mockImplementation(async () => { + throw Error(); + }); + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(1); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(2); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(2); + expect(storagePutSpy).toHaveBeenCalledTimes(0); + + // No update. + fetcherFetchSpy.mockImplementation(async () => { + return undefined; + }); + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(2); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(4); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(4); + expect(storagePutSpy).toHaveBeenCalledTimes(0); + + // Success case. + fetcherFetchSpy.mockImplementation(async (cohortId) => { + return NEW_COHORTS[cohortId]; + }); + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(3); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(6); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(6); + expect(storagePutSpy).toHaveBeenCalledTimes(2); + + for (const cohortId of storage.getAllCohortIds()) { + expect(storageGetCohortSpy).toHaveBeenCalledWith(cohortId); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + cohortId, + OLD_COHORTS[cohortId].lastModified, + ); + expect(storagePutSpy).toHaveBeenCalledWith(NEW_COHORTS[cohortId]); + } +}); diff --git a/packages/node/test/local/cohort/cohortStorage.test.ts b/packages/node/test/local/cohort/cohortStorage.test.ts new file mode 100644 index 0000000..e9f91b1 --- /dev/null +++ b/packages/node/test/local/cohort/cohortStorage.test.ts @@ -0,0 +1,194 @@ +import { InMemoryCohortStorage } from 'src/local/cohort/storage'; +import { USER_GROUP_TYPE } from 'src/types/cohort'; + +const C_A = { + cohortId: 'c_a', + groupType: 'a', + groupTypeId: 0, + lastComputed: 0, + lastModified: 0, + size: 2, + memberIds: new Set(['membera1', 'membera2']), +}; +const C_B = { + cohortId: 'c_b', + groupType: 'b', + groupTypeId: 1, + lastComputed: 1, + lastModified: 1, + size: 2, + memberIds: new Set(['memberb1', 'memberball']), +}; +const C_B2 = { + cohortId: 'c_b2', + groupType: 'b', + groupTypeId: 1, + lastComputed: 1, + lastModified: 1, + size: 2, + memberIds: new Set(['memberb2', 'memberball']), +}; +const C_U1 = { + cohortId: 'c_u1', + groupType: USER_GROUP_TYPE, + groupTypeId: 1, + lastComputed: 1, + lastModified: 1, + size: 2, + memberIds: new Set(['user1', 'user2']), +}; +const C_U2 = { + cohortId: 'c_u2', + groupType: USER_GROUP_TYPE, + groupTypeId: 1, + lastComputed: 1, + lastModified: 1, + size: 3, + memberIds: new Set(['user1', 'user2', 'user3']), +}; +const C_U3 = { + cohortId: 'c_u3', + groupType: USER_GROUP_TYPE, + groupTypeId: 1, + lastComputed: 1, + lastModified: 1, + size: 1, + memberIds: new Set(['user1']), +}; + +test('cohort storage put, delete, getAllCohortIds, getCohort', async () => { + const storage = new InMemoryCohortStorage(); + + // {C_A} + storage.put(C_A); + expect(storage.getAllCohortIds()).toStrictEqual( + new Set([C_A.cohortId]), + ); + expect(storage.getCohort(C_A.cohortId)).toBe(C_A); + expect(storage.getCohort(C_B.cohortId)).toBeUndefined(); + + // {C_B} + storage.delete(C_A.cohortId); + storage.put(C_B); + expect(storage.getAllCohortIds()).toStrictEqual( + new Set([C_B.cohortId]), + ); + expect(storage.getCohort(C_A.cohortId)).toBeUndefined(); + expect(storage.getCohort(C_B.cohortId)).toBe(C_B); + + // {C_A, C_B, C_B2} + storage.put(C_A); + storage.put(C_B); + storage.put(C_B2); + expect(storage.getAllCohortIds()).toStrictEqual( + new Set([C_A.cohortId, C_B.cohortId, C_B2.cohortId]), + ); + expect(storage.getCohort(C_A.cohortId)).toBe(C_A); + expect(storage.getCohort(C_B.cohortId)).toBe(C_B); + expect(storage.getCohort(C_B2.cohortId)).toBe(C_B2); + + // {C_B, C_B2} + storage.delete(C_A.cohortId); + expect(storage.getAllCohortIds()).toStrictEqual( + new Set([C_B.cohortId, C_B2.cohortId]), + ); +}); + +test('cohort storage getCohortsForGroup', async () => { + const storage = new InMemoryCohortStorage(); + + // {C_A} + storage.put(C_A); + + expect( + storage.getCohortsForGroup( + 'a', + 'membera1', + new Set([C_A.cohortId]), + ), + ).toStrictEqual(new Set([C_A.cohortId])); + expect( + storage.getCohortsForGroup( + 'a', + 'membera1', + new Set([C_A.cohortId, C_B.cohortId]), + ), + ).toStrictEqual(new Set(['c_a'])); + expect( + storage.getCohortsForGroup( + 'b', + 'memberb1', + new Set([C_A.cohortId, C_B.cohortId]), + ), + ).toStrictEqual(new Set()); + + // {C_A, C_B, C_B2} + storage.put(C_B); + storage.put(C_B2); + + expect( + storage.getCohortsForGroup( + 'a', + 'membera1', + new Set(['c_a', C_B.cohortId]), + ), + ).toStrictEqual(new Set(['c_a'])); + expect( + storage.getCohortsForGroup( + 'b', + 'memberb1', + new Set(['c_a', C_B.cohortId]), + ), + ).toStrictEqual(new Set([C_B.cohortId])); + expect( + storage.getCohortsForGroup( + 'b', + 'memberball', + new Set(['c_a', C_B.cohortId, C_B2.cohortId]), + ), + ).toStrictEqual(new Set([C_B.cohortId, C_B2.cohortId])); +}); + +test('cohort storage getCohortsForUser', async () => { + const storage = new InMemoryCohortStorage(); + storage.put(C_U1); + storage.put(C_U2); + storage.put(C_U3); + + expect( + storage.getCohortsForUser( + 'user1', + new Set([C_U1.cohortId, C_U2.cohortId, C_U3.cohortId]), + ), + ).toStrictEqual( + new Set([C_U1.cohortId, C_U2.cohortId, C_U3.cohortId]), + ); + + expect( + storage.getCohortsForUser( + 'user2', + new Set([C_U1.cohortId, C_U2.cohortId, C_U3.cohortId]), + ), + ).toStrictEqual(new Set([C_U1.cohortId, C_U2.cohortId])); + + expect( + storage.getCohortsForUser( + 'user3', + new Set([C_U1.cohortId, C_U2.cohortId, C_U3.cohortId]), + ), + ).toStrictEqual(new Set([C_U2.cohortId])); + + expect( + storage.getCohortsForUser( + 'nonexistinguser', + new Set([C_U1.cohortId, C_U2.cohortId, C_U3.cohortId]), + ), + ).toStrictEqual(new Set()); + + expect( + storage.getCohortsForUser( + 'user1', + new Set([C_U1.cohortId, C_U2.cohortId]), + ), + ).toStrictEqual(new Set([C_U1.cohortId, C_U2.cohortId])); +}); diff --git a/packages/node/test/local/flagConfigPoller.test.ts b/packages/node/test/local/flagConfigPoller.test.ts new file mode 100644 index 0000000..157991f --- /dev/null +++ b/packages/node/test/local/flagConfigPoller.test.ts @@ -0,0 +1,201 @@ +import { + FlagConfigFetcher, + FlagConfigPoller, + InMemoryFlagConfigCache, +} from 'src/index'; +import { SdkCohortApi } from 'src/local/cohort/cohort-api'; +import { CohortFetcher } from 'src/local/cohort/fetcher'; +import { InMemoryCohortStorage } from 'src/local/cohort/storage'; +import { sleep } from 'src/util/time'; + +import { FLAGS, NEW_FLAGS } from './util/mockData'; +import { MockHttpClient } from './util/mockHttpClient'; + +afterEach(() => { + // Note that if a test failed, and the poller has not stopped, + // the test will hang and this won't be called. + // So other tests may also fail as a result. + jest.clearAllMocks(); +}); + +test('flagConfig poller success', async () => { + const cohortStorage = new InMemoryCohortStorage(); + const poller = new FlagConfigPoller( + new FlagConfigFetcher( + 'key', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + new InMemoryFlagConfigCache(), + cohortStorage, + new CohortFetcher( + 'apikey', + 'secretkey', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + 2000, + ); + let flagPolled = 0; + // Return FLAG for flag polls. + jest + .spyOn(FlagConfigFetcher.prototype, 'fetch') + .mockImplementation(async () => { + ++flagPolled; + if (flagPolled == 1) return { ...FLAGS, flagPolled: { key: flagPolled } }; + return { ...NEW_FLAGS, flagPolled: { key: flagPolled } }; + }); + // Return cohort with their own cohortId. + jest + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async (options) => { + return { + cohortId: options.cohortId, + groupType: '', + groupTypeId: 0, + lastComputed: 0, + lastModified: flagPolled, + size: 0, + memberIds: new Set([]), + }; + }); + // On start, polling should poll flags and storage should contains cohorts. + await poller.start(); + expect(flagPolled).toBe(1); + expect(await poller.cache.getAll()).toStrictEqual({ + ...FLAGS, + flagPolled: { key: flagPolled }, + }); + expect(cohortStorage.getCohort('usercohort1').cohortId).toBe('usercohort1'); + expect(cohortStorage.getCohort('usercohort2').cohortId).toBe('usercohort2'); + expect(cohortStorage.getCohort('usercohort3').cohortId).toBe('usercohort3'); + expect(cohortStorage.getCohort('usercohort4').cohortId).toBe('usercohort4'); + expect(cohortStorage.getCohort('orgnamecohort1').cohortId).toBe( + 'orgnamecohort1', + ); + expect(cohortStorage.getCohort('newcohortid')).toBeUndefined(); + expect(cohortStorage.getCohort('usercohort1').lastModified).toBe(1); + expect(cohortStorage.getCohort('usercohort2').lastModified).toBe(1); + expect(cohortStorage.getCohort('usercohort3').lastModified).toBe(1); + expect(cohortStorage.getCohort('usercohort4').lastModified).toBe(1); + expect(cohortStorage.getCohort('orgnamecohort1').lastModified).toBe(1); + + // On update, flag, existing cohort doesn't update. + await sleep(2000); + expect(flagPolled).toBe(2); + expect(await poller.cache.getAll()).toStrictEqual({ + ...NEW_FLAGS, + flagPolled: { key: flagPolled }, + }); + expect(cohortStorage.getCohort('usercohort1').cohortId).toBe('usercohort1'); + expect(cohortStorage.getCohort('usercohort2').cohortId).toBe('usercohort2'); + expect(cohortStorage.getCohort('usercohort3').cohortId).toBe('usercohort3'); + expect(cohortStorage.getCohort('usercohort4').cohortId).toBe('usercohort4'); + expect(cohortStorage.getCohort('orgnamecohort1').cohortId).toBe( + 'orgnamecohort1', + ); + expect(cohortStorage.getCohort('anewcohortid').cohortId).toBe('anewcohortid'); + expect(cohortStorage.getCohort('usercohort1').lastModified).toBe(1); + expect(cohortStorage.getCohort('usercohort2').lastModified).toBe(1); + expect(cohortStorage.getCohort('usercohort3').lastModified).toBe(1); + expect(cohortStorage.getCohort('usercohort4').lastModified).toBe(1); + expect(cohortStorage.getCohort('orgnamecohort1').lastModified).toBe(1); + expect(cohortStorage.getCohort('anewcohortid').lastModified).toBe(2); + poller.stop(); +}); + +test('flagConfig poller initial cohort error, still init', async () => { + const poller = new FlagConfigPoller( + new FlagConfigFetcher( + 'key', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + new InMemoryFlagConfigCache(), + new InMemoryCohortStorage(), + new CohortFetcher( + 'apikey', + 'secretkey', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + 10, + ); + // Fetch returns FLAGS, but cohort fails. + jest + .spyOn(FlagConfigFetcher.prototype, 'fetch') + .mockImplementation(async () => { + return FLAGS; + }); + jest + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async () => { + throw new Error(); + }); + // FLAGS should be empty, as cohort failed. Poller should be stopped immediately and test exists cleanly. + try { + // Should throw when init failed. + await poller.start(); + } catch { + fail(); + } + expect(await poller.cache.getAll()).toStrictEqual(FLAGS); + expect(poller.cohortStorage.getAllCohortIds()).toStrictEqual( + new Set(), + ); + + poller.stop(); +}); + +test('flagConfig poller initial success, polling flag success, cohort failed, and still updates flags', async () => { + const poller = new FlagConfigPoller( + new FlagConfigFetcher( + 'key', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + new InMemoryFlagConfigCache(), + new InMemoryCohortStorage(), + new CohortFetcher( + 'apikey', + 'secretkey', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + 2000, + ); + + // Only return the flag on first poll, return a different one on future polls where cohort would fail. + let flagPolled = 0; + jest + .spyOn(FlagConfigFetcher.prototype, 'fetch') + .mockImplementation(async () => { + if (++flagPolled === 1) return FLAGS; + return NEW_FLAGS; + }); + // Only success on first poll and fail on all later ones. + jest + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async (options) => { + if (options.cohortId !== 'anewcohortid') { + return { + cohortId: options.cohortId, + groupType: '', + groupTypeId: 0, + lastComputed: 0, + lastModified: 10, + size: 0, + memberIds: new Set([]), + }; + } + throw new Error(); + }); + + // First poll should return FLAGS. + await poller.start(); + expect(await poller.cache.getAll()).toStrictEqual(FLAGS); + expect(flagPolled).toBe(1); + + // Second poll flags with new cohort should fail when new cohort download failed. + // The different flag should not be updated. + await sleep(2000); + expect(flagPolled).toBeGreaterThanOrEqual(2); + await sleep(250); // Wait for cohort download retry to finish. + expect(await poller.cache.getAll()).toStrictEqual(NEW_FLAGS); + + poller.stop(); +}); diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 26ed392..8f222af 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -1,12 +1,21 @@ import assert from 'assert'; -import { InMemoryFlagConfigCache } from 'src/index'; +import { FlagConfigPoller, InMemoryFlagConfigCache } from 'src/index'; +import { SdkCohortApi } from 'src/local/cohort/cohort-api'; +import { CohortFetcher } from 'src/local/cohort/fetcher'; +import { InMemoryCohortStorage } from 'src/local/cohort/storage'; import { FlagConfigFetcher } from 'src/local/fetcher'; import { FlagConfigStreamer } from 'src/local/streamer'; +import { getFlagStrWithCohort } from './util/mockData'; import { MockHttpClient } from './util/mockHttpClient'; import { getNewClient } from './util/mockStreamEventSource'; +let updater; +afterEach(() => { + updater?.stop(); +}); + const getTestObjs = ({ pollingIntervalMillis = 1000, streamFlagConnTimeoutMillis = 1000, @@ -15,19 +24,31 @@ const getTestObjs = ({ streamFlagRetryDelayMillis = 15000, apiKey = 'client-xxxx', serverUrl = 'http://localhostxxxx:00000000', + cohortFetcherDelayMillis = 100, + fetcherData = [ + '[{"key": "fetcher-a", "variants": {}, "segments": []}]', + '[{"key": "fetcher-b", "variants": {}, "segments": []}]', + ], debug = false, }) => { - const fetchObj = { fetchCalls: 0, fetcher: undefined }; + const fetchObj = { + fetchCalls: 0, + fetcher: undefined, + cohortStorage: new InMemoryCohortStorage(), + cohortFetcher: new CohortFetcher( + 'apikey', + 'secretkey', + new MockHttpClient(async () => ({ status: 200, body: '' })), + serverUrl, + cohortFetcherDelayMillis, + ), + }; let dataI = 0; - const data = [ - '[{"key": "fetcher-a", "variants": {}, "segments": []}]', - '[{"key": "fetcher-b", "variants": {}, "segments": []}]', - ]; fetchObj.fetcher = new FlagConfigFetcher( apiKey, new MockHttpClient(async () => { fetchObj.fetchCalls++; - return { status: 200, body: data[dataI] }; + return { status: 200, body: fetcherData[dataI] }; }), ); const fetcherReturnNext = () => { @@ -35,17 +56,25 @@ const getTestObjs = ({ }; const cache = new InMemoryFlagConfigCache(); const mockClient = getNewClient(); - const updater = new FlagConfigStreamer( + updater = new FlagConfigStreamer( apiKey, - fetchObj.fetcher, + new FlagConfigPoller( + fetchObj.fetcher, + cache, + fetchObj.cohortStorage, + fetchObj.cohortFetcher, + pollingIntervalMillis, + debug, + ), cache, mockClient.clientFactory, - pollingIntervalMillis, streamFlagConnTimeoutMillis, streamFlagTryAttempts, streamFlagTryDelayMillis, streamFlagRetryDelayMillis, serverUrl, + fetchObj.cohortStorage, + fetchObj.cohortFetcher, debug, ); return { @@ -61,325 +90,250 @@ test('FlagConfigUpdater.connect, success', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == 0); - assert(mockClient.numCreated == 1); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); + assert(mockClient.numCreated == 1); + updater.stop(); }); test('FlagConfigUpdater.connect, start success, gets initial flag configs, gets subsequent flag configs', async () => { const { fetchObj, cache, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ - data: '[{"key": "a", "variants": {}, "segments": []}]', - }); - assert(fetchObj.fetchCalls == 0); - assert(mockClient.numCreated == 1); - await new Promise((r) => setTimeout(r, 200)); - assert((await cache.get('a')).key == 'a'); + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: '[{"key": "a", "variants": {}, "segments": []}]', + }); + assert(fetchObj.fetchCalls == 0); + assert(mockClient.numCreated == 1); + await new Promise((r) => setTimeout(r, 200)); + assert((await cache.get('a')).key == 'a'); - await mockClient.client.doMsg({ - data: '[{"key": "b", "variants": {}, "segments": []}]', - }); - await new Promise((r) => setTimeout(r, 200)); - assert((await cache.get('b')).key == 'b'); - assert((await cache.get('a')) == undefined); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + await mockClient.client.doMsg({ + data: '[{"key": "b", "variants": {}, "segments": []}]', + }); + await new Promise((r) => setTimeout(r, 200)); + assert((await cache.get('b')).key == 'b'); + assert((await cache.get('a')) == undefined); + + updater.stop(); }); test('FlagConfigUpdater.connect, stream start fail, only 1 attempt, fallback to poller, poller updates flag configs correctly', async () => { const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, streamFlagTryAttempts: 1 }); - try { - updater.start(); - await mockClient.client.doErr({ status: 503 }); // Send 503 non fatal to fallback to poller after single attempt. - await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. - assert(fetchObj.fetchCalls >= 1); - assert(mockClient.numCreated == 1); - assert((await cache.get('fetcher-a')).key == 'fetcher-a'); - - fetcherReturnNext(); - await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. - assert((await cache.get('fetcher-b')).key == 'fetcher-b'); - assert((await cache.get('fetcher-a')) == undefined); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doErr({ status: 503 }); // Send 503 non fatal to fallback to poller after single attempt. + await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. + assert(fetchObj.fetchCalls >= 1); + assert(mockClient.numCreated == 1); + assert((await cache.get('fetcher-a')).key == 'fetcher-a'); + + fetcherReturnNext(); + await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. + assert((await cache.get('fetcher-b')).key == 'fetcher-b'); + assert((await cache.get('fetcher-a')) == undefined); + + updater.stop(); }); test('FlagConfigUpdater.connect, stream start fail, fallback to poller, poller updates flag configs correctly', async () => { const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100 }); - try { - updater.start(); - await mockClient.client.doErr({ status: 501 }); // Send 501 fatal err to fallback to poller. - await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. - assert(fetchObj.fetchCalls >= 1); - assert(mockClient.numCreated == 1); - assert((await cache.get('fetcher-a')).key == 'fetcher-a'); - - fetcherReturnNext(); - await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. - assert((await cache.get('fetcher-b')).key == 'fetcher-b'); - assert((await cache.get('fetcher-a')) == undefined); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doErr({ status: 501 }); // Send 501 fatal err to fallback to poller. + await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. + assert(fetchObj.fetchCalls >= 1); + assert(mockClient.numCreated == 1); + assert((await cache.get('fetcher-a')).key == 'fetcher-a'); + + fetcherReturnNext(); + await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. + assert((await cache.get('fetcher-b')).key == 'fetcher-b'); + assert((await cache.get('fetcher-a')) == undefined); + + updater.stop(); }); test('FlagConfigUpdater.connect, start success, gets error initial flag configs, fallback to poller', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ - data: 'xxx', - }); // Initial error flag configs for first try. - await new Promise((r) => setTimeout(r, 1100)); // Wait try delay. - - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ - data: '[{"key: aaa}]', - }); // Another error flag configs for second try. - await new Promise((r) => setTimeout(r, 1100)); // Wait try delay. - - // Should fallbacked to poller. - assert(fetchObj.fetchCalls > 0); - assert(mockClient.numCreated == 2); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: 'xxx', + }); // Initial error flag configs for first try. + await new Promise((r) => setTimeout(r, 1100)); // Wait try delay. + + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: '[{"key: aaa}]', + }); // Another error flag configs for second try. + await new Promise((r) => setTimeout(r, 1100)); // Wait try delay. + + // Should fallbacked to poller. + assert(fetchObj.fetchCalls > 0); + assert(mockClient.numCreated == 2); + + updater.stop(); }); test('FlagConfigUpdater.connect, start success, gets ok initial flag configs, but gets error flag configs later, fallback to poller', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ - data: '[{"key": "a", "variants": {}, "segments": []}]', - }); // Initial flag configs are fine. - await new Promise((r) => setTimeout(r, 200)); - assert(fetchObj.fetchCalls == 0); - let n = mockClient.numCreated; - assert(n == 1); - - // Start error ones. - await mockClient.client.doMsg({ - data: 'hahaha', - }); // An error flag configs to start retry. - await new Promise((r) => setTimeout(r, 500)); - - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ - data: 'xxx', - }); // Error flag configs for first retry. - await new Promise((r) => setTimeout(r, 1000)); // Need to yield quite some time to start retry. - - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ - data: '[{"key: aaa}]', - }); // Error flag configs for second retry. - await new Promise((r) => setTimeout(r, 1000)); // Need to yield quite some time to start retry. - - assert(fetchObj.fetchCalls > 0); - n = mockClient.numCreated; - assert(n == 3); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: '[{"key": "a", "variants": {}, "segments": []}]', + }); // Initial flag configs are fine. + await new Promise((r) => setTimeout(r, 200)); + assert(fetchObj.fetchCalls == 0); + let n = mockClient.numCreated; + assert(n == 1); + + // Start error ones. + await mockClient.client.doMsg({ + data: 'hahaha', + }); // An error flag configs to start retry. + await new Promise((r) => setTimeout(r, 500)); + + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: 'xxx', + }); // Error flag configs for first retry. + await new Promise((r) => setTimeout(r, 1000)); // Need to yield quite some time to start retry. + + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: '[{"key: aaa}]', + }); // Error flag configs for second retry. + await new Promise((r) => setTimeout(r, 1000)); // Need to yield quite some time to start retry. + + assert(fetchObj.fetchCalls > 0); + n = mockClient.numCreated; + assert(n == 3); + + updater.stop(); }); test('FlagConfigUpdater.connect, open but no initial flag configs', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await new Promise((r) => setTimeout(r, 1100)); - await mockClient.client.doOpen({ type: 'open' }); - await new Promise((r) => setTimeout(r, 2000)); - assert(fetchObj.fetchCalls > 0); - assert(mockClient.numCreated == 2); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await new Promise((r) => setTimeout(r, 1100)); + await mockClient.client.doOpen({ type: 'open' }); + await new Promise((r) => setTimeout(r, 2000)); + assert(fetchObj.fetchCalls > 0); + assert(mockClient.numCreated == 2); + updater.stop(); }); test('FlagConfigUpdater.connect, success and then fails and then reconnects', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - await mockClient.client.doErr({ status: 500 }); - await new Promise((r) => setTimeout(r, 500)); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == 0); - assert(mockClient.numCreated == 2); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + await mockClient.client.doErr({ status: 500 }); + await new Promise((r) => setTimeout(r, 500)); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); + assert(mockClient.numCreated == 2); + updater.stop(); }); test('FlagConfigUpdater.connect, timeout first try, retry success', async () => { const { mockClient, updater } = getTestObjs({}); - try { - updater.start(); - await new Promise((r) => setTimeout(r, 2200)); // Wait at least 2 secs, at most 3 secs for first try timeout. - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(mockClient.numCreated == 2); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await new Promise((r) => setTimeout(r, 2200)); // Wait at least 2 secs, at most 3 secs for first try timeout. + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(mockClient.numCreated == 2); + updater.stop(); }); test('FlagConfigUpdater.connect, retry timeout, backoff to poll after 2 tries', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - await updater.start(); // Awaits start(), no data sent. - assert(fetchObj.fetchCalls >= 1); - assert(mockClient.numCreated == 2); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + await updater.start(); // Awaits start(), no data sent. + assert(fetchObj.fetchCalls >= 1); + assert(mockClient.numCreated == 2); + updater.stop(); }); test('FlagConfigUpdater.connect, 501, backoff to poll after 1 try', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - await mockClient.client.doErr({ status: 501 }); // Send 501 fatal err. - await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. - assert(fetchObj.fetchCalls >= 1); - assert(mockClient.numCreated == 1); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doErr({ status: 501 }); // Send 501 fatal err. + await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. + assert(fetchObj.fetchCalls >= 1); + assert(mockClient.numCreated == 1); + updater.stop(); }); test('FlagConfigUpdater.connect, 404, backoff to poll after 2 tries', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - await mockClient.client.doErr({ status: 404 }); // Send error for first try. - await new Promise((r) => setTimeout(r, 1100)); // Wait for poller to poll. - await mockClient.client.doErr({ status: 404 }); // Send error for second try. - await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. - assert(fetchObj.fetchCalls >= 1); - assert(mockClient.numCreated == 2); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doErr({ status: 404 }); // Send error for first try. + await new Promise((r) => setTimeout(r, 1100)); // Wait for poller to poll. + await mockClient.client.doErr({ status: 404 }); // Send error for second try. + await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. + assert(fetchObj.fetchCalls >= 1); + assert(mockClient.numCreated == 2); + updater.stop(); }); test('FlagConfigUpdater.connect, two starts, second does nothing', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - await new Promise((r) => setTimeout(r, 2500)); // Wait for stream to init success. - assert(fetchObj.fetchCalls == 0); - assert(mockClient.numCreated == 1); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + await new Promise((r) => setTimeout(r, 2500)); // Wait for stream to init success. + assert(fetchObj.fetchCalls == 0); + assert(mockClient.numCreated == 1); + updater.stop(); }); test('FlagConfigUpdater.connect, start and immediately stop does not retry', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - updater.stop(); - await new Promise((r) => setTimeout(r, 1000)); - assert(fetchObj.fetchCalls == 0); - assert(mockClient.numCreated == 1); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + updater.stop(); + await new Promise((r) => setTimeout(r, 1000)); + assert(fetchObj.fetchCalls == 0); + assert(mockClient.numCreated == 1); }); test('FlagConfigUpdater.connect, start fail, retry and immediately stop, no poller start', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100, }); - try { - updater.start(); - await new Promise((r) => setTimeout(r, 2100)); // Wait for timeout and try delay. - updater.stop(); - assert(fetchObj.fetchCalls == 0); - assert(mockClient.numCreated == 2); - - await new Promise((r) => setTimeout(r, 200)); // Wait to check poller start. - assert(fetchObj.fetchCalls == 0); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await new Promise((r) => setTimeout(r, 2100)); // Wait for timeout and try delay. + updater.stop(); + assert(fetchObj.fetchCalls == 0); + assert(mockClient.numCreated == 2); + + await new Promise((r) => setTimeout(r, 200)); // Wait to check poller start. + assert(fetchObj.fetchCalls == 0); }); test('FlagConfigUpdater.connect, test error after connection, poller starts, stream retry success, poller stops', async () => { @@ -389,94 +343,84 @@ test('FlagConfigUpdater.connect, test error after connection, poller starts, str pollingIntervalMillis: 200, streamFlagRetryDelayMillis, }); - try { - // Test error after normal close. - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - let n = mockClient.numCreated; - assert(n == 1); - // Pass errors to stop first stream. - await mockClient.client.doErr({ status: 500 }); - await new Promise((r) => setTimeout(r, 200)); // Wait for stream to init. - await mockClient.client.doErr({ status: 500 }); // Pass errors to make first retry fail. - n = mockClient.numCreated; - assert(n == 2); - await new Promise((r) => setTimeout(r, 1200)); // Wait for stream to init. - await mockClient.client.doErr({ status: 500 }); // Pass error to make second retry fail. - await new Promise((r) => setTimeout(r, 500)); // Wait for stream to init. - // No stop() here. The streamRetryTimeout will still be running. - assert(fetchObj.fetchCalls > 0); - n = mockClient.numCreated; - assert(n == 3); - // Check retry. - await new Promise((r) => setTimeout(r, streamFlagRetryDelayMillis)); // Wait for retry. - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - n = mockClient.numCreated; - assert(n == 4); - // Check poller stop. - const prevFetchCalls = fetchObj.fetchCalls; - await new Promise((r) => setTimeout(r, 500)); // Wait to see if poller runs while waiting. - assert(fetchObj.fetchCalls == prevFetchCalls); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + // Test error after normal close. + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + let n = mockClient.numCreated; + assert(n == 1); + // Pass errors to stop first stream. + await mockClient.client.doErr({ status: 500 }); + await new Promise((r) => setTimeout(r, 200)); // Wait for stream to init. + await mockClient.client.doErr({ status: 500 }); // Pass errors to make first retry fail. + n = mockClient.numCreated; + assert(n == 2); + await new Promise((r) => setTimeout(r, 1200)); // Wait for stream to init. + await mockClient.client.doErr({ status: 500 }); // Pass error to make second retry fail. + await new Promise((r) => setTimeout(r, 500)); // Wait for stream to init. + // No stop() here. The streamRetryTimeout will still be running. + assert(fetchObj.fetchCalls > 0); + n = mockClient.numCreated; + assert(n == 3); + // Check retry. + await new Promise((r) => setTimeout(r, streamFlagRetryDelayMillis)); // Wait for retry. + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + n = mockClient.numCreated; + assert(n == 4); + // Check poller stop. + const prevFetchCalls = fetchObj.fetchCalls; + await new Promise((r) => setTimeout(r, 500)); // Wait to see if poller runs while waiting. + assert(fetchObj.fetchCalls == prevFetchCalls); + updater.stop(); }); test('FlagConfigUpdater.connect, test restarts', async () => { const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 200, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == 0); - let n = mockClient.numCreated; - assert(n == 1); - updater.stop(); - - // Test start after normal close. - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == 0); - n = mockClient.numCreated; - assert(n == 2); - updater.stop(); - - // Test error after normal close. - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - await mockClient.client.doErr({ status: 500 }); // Send error to stop current stream. - await new Promise((r) => setTimeout(r, 200)); // Wait for stream to init. - await mockClient.client.doErr({ status: 500 }); // Send error for first retry. - await new Promise((r) => setTimeout(r, 1200)); // Wait for stream to timeout and start second try. - await mockClient.client.doErr({ status: 500 }); // Send error for second retry. - await new Promise((r) => setTimeout(r, 500)); // Wait for stream to init. - assert(fetchObj.fetchCalls > 0); - n = mockClient.numCreated; - assert(n == 5); - // No stop() here. The streamRetryTimeout will still be running. - - // Test normal start after error close. Poller should be stopped. - const prevFetchCalls = fetchObj.fetchCalls; - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - await new Promise((r) => setTimeout(r, 500)); // Wait for stream to init. - assert(fetchObj.fetchCalls == prevFetchCalls); - n = mockClient.numCreated; - assert(n == 6); - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); + let n = mockClient.numCreated; + assert(n == 1); + updater.stop(); + + // Test start after normal close. + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); + n = mockClient.numCreated; + assert(n == 2); + updater.stop(); + + // Test error after normal close. + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + await mockClient.client.doErr({ status: 500 }); // Send error to stop current stream. + await new Promise((r) => setTimeout(r, 200)); // Wait for stream to init. + await mockClient.client.doErr({ status: 500 }); // Send error for first retry. + await new Promise((r) => setTimeout(r, 1200)); // Wait for stream to timeout and start second try. + await mockClient.client.doErr({ status: 500 }); // Send error for second retry. + await new Promise((r) => setTimeout(r, 500)); // Wait for stream to init. + assert(fetchObj.fetchCalls > 0); + n = mockClient.numCreated; + assert(n == 5); + // No stop() here. The streamRetryTimeout will still be running. + + // Test normal start after error close. Poller should be stopped. + const prevFetchCalls = fetchObj.fetchCalls; + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + await new Promise((r) => setTimeout(r, 500)); // Wait for stream to init. + assert(fetchObj.fetchCalls == prevFetchCalls); + n = mockClient.numCreated; + assert(n == 6); + updater.stop(); }); test('FlagConfigUpdater.connect, start success, keep alive success, no fallback to poller', async () => { @@ -484,31 +428,26 @@ test('FlagConfigUpdater.connect, start success, keep alive success, no fallback const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 200, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == 0); - let n = mockClient.numCreated; - assert(n == 1); - - // Test keep alive. - await new Promise((r) => setTimeout(r, 15000)); // Wait before keep alive timeouts. - await mockClient.client.doMsg({ data: ' ' }); - assert(fetchObj.fetchCalls == 0); - n = mockClient.numCreated; - assert(n == 1); - - await new Promise((r) => setTimeout(r, 3000)); // Wait for original keep alive timeout to reach. - assert(fetchObj.fetchCalls == 0); - n = mockClient.numCreated; - assert(n == 1); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); + let n = mockClient.numCreated; + assert(n == 1); + + // Test keep alive. + await new Promise((r) => setTimeout(r, 15000)); // Wait before keep alive timeouts. + await mockClient.client.doMsg({ data: ' ' }); + assert(fetchObj.fetchCalls == 0); + n = mockClient.numCreated; + assert(n == 1); + + await new Promise((r) => setTimeout(r, 3000)); // Wait for original keep alive timeout to reach. + assert(fetchObj.fetchCalls == 0); + n = mockClient.numCreated; + assert(n == 1); + + updater.stop(); }); test('FlagConfigStreamer.connect, start success, keep alive fail, retry success', async () => { @@ -516,27 +455,22 @@ test('FlagConfigStreamer.connect, start success, keep alive fail, retry success' const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 200, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == 0); - let n = mockClient.numCreated; - assert(n == 1); - - // Test keep alive fail. - await new Promise((r) => setTimeout(r, 17500)); // Wait for keep alive to fail and enter retry. - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == 0); - n = mockClient.numCreated; - assert(n == 2); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); + let n = mockClient.numCreated; + assert(n == 1); + + // Test keep alive fail. + await new Promise((r) => setTimeout(r, 17500)); // Wait for keep alive to fail and enter retry. + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); + n = mockClient.numCreated; + assert(n == 2); + + updater.stop(); }); test('FlagConfigUpdater.connect, start success, keep alive fail, retry fail twice, fallback to poller', async () => { @@ -544,29 +478,24 @@ test('FlagConfigUpdater.connect, start success, keep alive fail, retry fail twic const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 200, }); - try { - updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == 0); - let n = mockClient.numCreated; - assert(n == 1); - - // Test keep alive fail. - await new Promise((r) => setTimeout(r, 17500)); // Wait for keep alive to fail and enter retry. - await mockClient.client.doErr({ status: 500 }); // Send error for first try. - await new Promise((r) => setTimeout(r, 1200)); // Wait for stream to init. - await mockClient.client.doErr({ status: 500 }); // Send error for second try. - await new Promise((r) => setTimeout(r, 500)); // Wait for poller to init. - assert(fetchObj.fetchCalls > 0); - n = mockClient.numCreated; - assert(n == 3); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); + let n = mockClient.numCreated; + assert(n == 1); + + // Test keep alive fail. + await new Promise((r) => setTimeout(r, 17500)); // Wait for keep alive to fail and enter retry. + await mockClient.client.doErr({ status: 500 }); // Send error for first try. + await new Promise((r) => setTimeout(r, 1200)); // Wait for stream to init. + await mockClient.client.doErr({ status: 500 }); // Send error for second try. + await new Promise((r) => setTimeout(r, 500)); // Wait for poller to init. + assert(fetchObj.fetchCalls > 0); + n = mockClient.numCreated; + assert(n == 3); + + updater.stop(); }); test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream success, stop poller, no more retry stream', async () => { @@ -575,39 +504,34 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream su pollingIntervalMillis: 200, streamFlagRetryDelayMillis: 2000, }); - try { - updater.start(); - await mockClient.client.doErr({ status: 501 }); // Fatal err to fail initial conn. - await new Promise((r) => setTimeout(r, 500)); // Wait for poller to start. - assert(fetchObj.fetchCalls > 0); - let n = mockClient.numCreated; - assert(n == 1); - - // Check for retry stream start. - await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. - n = mockClient.numCreated; - assert(n == 2); - - // Retry stream success. - const prevFetchCalls = fetchObj.fetchCalls; - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == prevFetchCalls); - - // Wait to check poller stopped. - await new Promise((r) => setTimeout(r, 500)); - assert(fetchObj.fetchCalls == prevFetchCalls); - - // Check there is no more retry stream. - await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. - n = mockClient.numCreated; - assert(n == 2); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doErr({ status: 501 }); // Fatal err to fail initial conn. + await new Promise((r) => setTimeout(r, 500)); // Wait for poller to start. + assert(fetchObj.fetchCalls > 0); + let n = mockClient.numCreated; + assert(n == 1); + + // Check for retry stream start. + await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. + n = mockClient.numCreated; + assert(n == 2); + + // Retry stream success. + const prevFetchCalls = fetchObj.fetchCalls; + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == prevFetchCalls); + + // Wait to check poller stopped. + await new Promise((r) => setTimeout(r, 500)); + assert(fetchObj.fetchCalls == prevFetchCalls); + + // Check there is no more retry stream. + await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. + n = mockClient.numCreated; + assert(n == 2); + + updater.stop(); }); test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream fail, continue poller, retry stream success, stop poller', async () => { @@ -616,49 +540,161 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream fa pollingIntervalMillis: 200, streamFlagRetryDelayMillis: 2000, }); - try { - updater.start(); - await mockClient.client.doErr({ status: 501 }); // Fatal err to fail initial conn. - await new Promise((r) => setTimeout(r, 500)); // Wait for poller to start. - assert(fetchObj.fetchCalls > 0); - let n = mockClient.numCreated; - assert(n == 1); - - // Wait for retry stream start. - await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. - n = mockClient.numCreated; - assert(n == 2); - - // Retry stream fail. - let prevFetchCalls = fetchObj.fetchCalls; - await mockClient.client.doErr({ status: 500 }); // Fatal err to fail stream retry. - - // Wait to check poller continues to poll. - await new Promise((r) => setTimeout(r, 500)); - assert(fetchObj.fetchCalls > prevFetchCalls); - - // Wait for another retry stream start. - await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. - n = mockClient.numCreated; - assert(n == 3); - - // Retry stream success. - prevFetchCalls = fetchObj.fetchCalls; - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ data: '[]' }); - assert(fetchObj.fetchCalls == prevFetchCalls); - - // Wait to check poller stopped. - await new Promise((r) => setTimeout(r, 500)); - assert(fetchObj.fetchCalls == prevFetchCalls); - - updater.stop(); - } catch (e) { - updater.stop(); - fail(e); - } + updater.start(); + await mockClient.client.doErr({ status: 501 }); // Fatal err to fail initial conn. + await new Promise((r) => setTimeout(r, 500)); // Wait for poller to start. + assert(fetchObj.fetchCalls > 0); + let n = mockClient.numCreated; + assert(n == 1); + + // Wait for retry stream start. + await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. + n = mockClient.numCreated; + assert(n == 2); + + // Retry stream fail. + let prevFetchCalls = fetchObj.fetchCalls; + await mockClient.client.doErr({ status: 500 }); // Fatal err to fail stream retry. + + // Wait to check poller continues to poll. + await new Promise((r) => setTimeout(r, 500)); + assert(fetchObj.fetchCalls > prevFetchCalls); + + // Wait for another retry stream start. + await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. + n = mockClient.numCreated; + assert(n == 3); + + // Retry stream success. + prevFetchCalls = fetchObj.fetchCalls; + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == prevFetchCalls); + + // Wait to check poller stopped. + await new Promise((r) => setTimeout(r, 500)); + assert(fetchObj.fetchCalls == prevFetchCalls); + + updater.stop(); }); test.todo( 'FlagConfigUpdater.connect, start and immediately stop and immediately start is an unhandled edge case', ); + +test('FlagConfigUpdater.connect, flag success, cohort success', async () => { + const { fetchObj, mockClient, updater, cache } = getTestObjs({ + pollingIntervalMillis: 100, + }); + // Return cohort with their own cohortId. + jest + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async (options) => { + return { + cohortId: options.cohortId, + groupType: '', + groupTypeId: 0, + lastComputed: 0, + lastModified: 0, + size: 0, + memberIds: new Set([]), + }; + }); + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: `[${getFlagStrWithCohort('cohort1')}]`, + }); + await new Promise((r) => setTimeout(r, 1000)); // Wait for poller to poll. + expect(fetchObj.fetchCalls).toBe(0); + expect(mockClient.numCreated).toBe(1); + expect(await cache.get('flag_cohort1')).toBeDefined(); + + // Return cohort with their own cohortId. + // Now update the flags with a new cohort that will fail to download. + await mockClient.client.doMsg({ + data: `[${getFlagStrWithCohort('cohort2')}]`, + }); + await new Promise((r) => setTimeout(r, 1000)); // Wait for poller to poll. + + expect(fetchObj.fetchCalls).toBe(0); // No poller poll. + expect(mockClient.numCreated).toBe(1); + expect(await cache.get('flag_cohort1')).toBeUndefined(); // Old flag removed. + expect(await cache.get('flag_cohort2')).toBeDefined(); // New flag added. + updater.stop(); +}); + +test('FlagConfigUpdater.connect, flag cohort and init success, flag update success, cohort fail, wont fallback to poller as flag stream is ok', async () => { + jest.setTimeout(20000); + jest + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async (options) => { + if (options.cohortId != 'cohort1') throw Error(); + return { + cohortId: options.cohortId, + groupType: '', + groupTypeId: 0, + lastComputed: 0, + lastModified: 0, + size: 0, + memberIds: new Set([]), + }; + }); + const { fetchObj, mockClient, updater, cache } = getTestObjs({ + pollingIntervalMillis: 100, + streamFlagTryAttempts: 2, + streamFlagTryDelayMillis: 1000, + streamFlagRetryDelayMillis: 100000, + }); + + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: `[${getFlagStrWithCohort('cohort1')}]`, + }); + await new Promise((r) => setTimeout(r, 1000)); // Wait for poller to poll. + expect(fetchObj.fetchCalls).toBe(0); // No poller poll. + expect(mockClient.numCreated).toBe(1); + expect(await cache.get('flag_cohort1')).toBeDefined(); + + // Return cohort with their own cohortId. + // Now update the flags with a new cohort that will fail to download. + await mockClient.client.doMsg({ + data: `[${getFlagStrWithCohort('cohort2')}]`, + }); + await new Promise((r) => setTimeout(r, 1000)); // Wait for poller to poll. + + expect(fetchObj.fetchCalls).toBe(0); // No poller poll. + expect(mockClient.numCreated).toBe(1); + expect(await cache.get('flag_cohort1')).toBeUndefined(); // Old flag removed. + expect(await cache.get('flag_cohort2')).toBeDefined(); // Still add flag to cache if new cohort fails. + updater.stop(); +}); + +test('FlagConfigUpdater.connect, flag success, cohort fail, retry fail, initialization still success, no fallback to poller', async () => { + jest.setTimeout(20000); + jest + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async () => { + throw Error(); + }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + streamFlagTryAttempts: 2, + streamFlagTryDelayMillis: 1000, + streamFlagRetryDelayMillis: 100000, + debug: true, + }); + // Return cohort with their own cohortId. + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: `[${getFlagStrWithCohort('cohort1')}]`, + }); + await new Promise((resolve) => setTimeout(resolve, 250)); // Wait for cohort download done retries and fails. + await new Promise((resolve) => setTimeout(resolve, 1050)); // Wait for poller start. + + expect(fetchObj.fetchCalls).toBe(0); + expect(mockClient.numCreated).toBe(1); + updater.stop(); +}); diff --git a/packages/node/test/local/flagConfigUpdater.test.ts b/packages/node/test/local/flagConfigUpdater.test.ts new file mode 100644 index 0000000..31b0757 --- /dev/null +++ b/packages/node/test/local/flagConfigUpdater.test.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { InMemoryFlagConfigCache } from 'src/index'; +import { SdkCohortApi } from 'src/local/cohort/cohort-api'; +import { CohortFetcher } from 'src/local/cohort/fetcher'; +import { InMemoryCohortStorage } from 'src/local/cohort/storage'; +import { FlagConfigUpdaterBase } from 'src/local/updater'; +import { CohortUtils } from 'src/util/cohort'; + +import { FLAGS, NEW_FLAGS } from './util/mockData'; +import { MockHttpClient } from './util/mockHttpClient'; + +class TestFlagConfigUpdaterBase extends FlagConfigUpdaterBase { + public async update(flagConfigs, onChange) { + await super._update(flagConfigs, onChange); + } + public async downloadNewCohorts(cohortIds) { + return await super.downloadNewCohorts(cohortIds); + } + public async removeUnusedCohorts(validCohortIds) { + return await super.removeUnusedCohorts(validCohortIds); + } +} + +const createCohort = (cohortId) => ({ + cohortId, + groupType: '', + groupTypeId: 0, + lastComputed: 0, + lastModified: 0, + size: 0, + memberIds: new Set([]), +}); + +let updater: TestFlagConfigUpdaterBase; +beforeEach(() => { + jest + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async (options) => { + if (options.cohortId === 'anewcohortid') throw Error(); + return { + cohortId: options.cohortId, + groupType: '', + groupTypeId: 0, + lastComputed: 0, + lastModified: 0, + size: 0, + memberIds: new Set([]), + }; + }); + const cache = new InMemoryFlagConfigCache(); + const cohortStorage = new InMemoryCohortStorage(); + const cohortFetcher = new CohortFetcher( + '', + '', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ); + updater = new TestFlagConfigUpdaterBase( + cache, + cohortStorage, + cohortFetcher, + false, + ); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('FlagConfigUpdaterBase, update success', async () => { + await updater.update(FLAGS, () => {}); + expect(await updater.cache.getAll()).toStrictEqual(FLAGS); +}); + +test('FlagConfigUpdaterBase, update no error even if cohort download error', async () => { + await updater.update(NEW_FLAGS, () => {}); + expect(await updater.cache.getAll()).toStrictEqual(NEW_FLAGS); +}); + +test('FlagConfigUpdaterBase.downloadNewCohorts', async () => { + const failedCohortIds = await updater.downloadNewCohorts( + CohortUtils.extractCohortIds(NEW_FLAGS), + ); + expect(updater.cohortStorage.getAllCohortIds()).toStrictEqual( + CohortUtils.extractCohortIds(FLAGS), + ); + expect(failedCohortIds).toStrictEqual(new Set(['anewcohortid'])); +}); + +test('FlagConfigUpdaterBase.removeUnusedCohorts', async () => { + CohortUtils.extractCohortIds(NEW_FLAGS).forEach((cohortId) => { + updater.cohortStorage.put(createCohort(cohortId)); + }); + await updater.removeUnusedCohorts(CohortUtils.extractCohortIds(FLAGS)); + expect(updater.cohortStorage.getAllCohortIds()).toStrictEqual( + CohortUtils.extractCohortIds(FLAGS), + ); +}); diff --git a/packages/node/test/local/util/cohortUtils.test.ts b/packages/node/test/local/util/cohortUtils.test.ts new file mode 100644 index 0000000..2a32325 --- /dev/null +++ b/packages/node/test/local/util/cohortUtils.test.ts @@ -0,0 +1,36 @@ +import { CohortUtils } from 'src/util/cohort'; + +import { FLAGS } from './mockData'; + +test('test extract cohortIds from flags', async () => { + expect( + CohortUtils.extractCohortIdsByGroupFromFlag(FLAGS['flag1']), + ).toStrictEqual({ + User: new Set(['usercohort1']), + }); + expect( + CohortUtils.extractCohortIdsByGroupFromFlag(FLAGS['flag2']), + ).toStrictEqual({ + User: new Set(['usercohort2']), + }); + expect( + CohortUtils.extractCohortIdsByGroupFromFlag(FLAGS['flag3']), + ).toStrictEqual({ + User: new Set(['usercohort3', 'usercohort4']), + }); + expect( + CohortUtils.extractCohortIdsByGroupFromFlag(FLAGS['flag4']), + ).toStrictEqual({ + User: new Set(['usercohort3', 'usercohort4']), + 'org name': new Set(['orgnamecohort1']), + }); + expect(CohortUtils.extractCohortIds(FLAGS)).toStrictEqual( + new Set([ + 'usercohort1', + 'usercohort2', + 'usercohort3', + 'usercohort4', + 'orgnamecohort1', + ]), + ); +}); diff --git a/packages/node/test/local/util/mockData.ts b/packages/node/test/local/util/mockData.ts new file mode 100644 index 0000000..9ad25bd --- /dev/null +++ b/packages/node/test/local/util/mockData.ts @@ -0,0 +1,275 @@ +// Some test flags. +// FLAGS are normal flags with cohortIds. +// NEW_FLAGS adds a flag with cohortId `anewcohortid` on top of FLAGS. + +import { EvaluationFlag } from '@amplitude/experiment-core'; + +export const getFlagStrWithCohort = ( + cohortId: string, +): string => `{"key":"flag_${cohortId}","segments":[{ + "conditions":[[{"op":"set contains any","selector":["context","user","cohort_ids"],"values":["${cohortId}"]}]], + "metadata":{"segmentName": "Segment 1"},"variant": "off" + }],"variants": {}}`; + +export const getFlagWithCohort = (cohortId: string): EvaluationFlag => + JSON.parse(getFlagStrWithCohort(cohortId)); + +export const FLAGS = [ + { + key: 'flag1', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['usercohort1'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + variant: 'on', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: { + on: { key: 'on', value: 'on' }, + off: { key: 'off', metadata: { default: true } }, + }, + }, + { + key: 'flag2', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['usercohort2'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + variant: 'on', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: { + on: { key: 'on', value: 'on' }, + off: { key: 'off', metadata: { default: true } }, + }, + }, + { + key: 'flag3', + metadata: { + deployed: true, + evaluationMode: 'local', + experimentKey: 'exp-1', + flagType: 'experiment', + flagVersion: 6, + }, + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['usercohort3'], + }, + ], + ], + variant: 'var1', + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['usercohort4'], + }, + ], + ], + variant: 'var2', + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cocoids'], + values: ['nohaha'], + }, + ], + ], + variant: 'var2', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: { + var1: { key: 'var1', value: 'var1value' }, + var2: { key: 'var2', value: 'var2value' }, + off: { key: 'off', metadata: { default: true } }, + }, + }, + { + key: 'flag4', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['usercohort3', 'usercohort4'], + }, + ], + ], + variant: 'var1', + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'groups', 'org name', 'cohort_ids'], + values: ['orgnamecohort1'], + }, + ], + ], + metadata: { + segmentName: 'Segment 2', + }, + variant: 'var2', + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'gg', 'org name', 'cohort_ids'], + values: ['nohahaorgname'], + }, + ], + ], + metadata: { + segmentName: 'Segment 3', + }, + variant: 'var3', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: { + var1: { key: 'var1', value: 'var1value' }, + var2: { key: 'var2', value: 'var2value' }, + var3: { key: 'var3', value: 'var3value' }, + off: { key: 'off', metadata: { default: true } }, + }, + }, +].reduce((acc, flag) => { + acc[flag.key] = flag; + return acc; +}, {}); + +export const NEW_FLAGS = { + ...FLAGS, + flag5: { + key: 'flag5', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['anewcohortid'], + }, + ], + ], + variant: 'on', + }, + { + variant: 'off', + }, + ], + variants: { + on: { key: 'on', value: 'on' }, + off: { key: 'off', metadata: { default: true } }, + }, + }, +}; + +export const COHORTS = { + usercohort1: { + cohortId: 'usercohort1', + groupType: 'User', + groupTypeId: 0, + lastComputed: 0, + lastModified: 1, + size: 1, + memberIds: new Set(['membera1']), + }, + usercohort2: { + cohortId: 'usercohort2', + groupType: 'User', + groupTypeId: 0, + lastComputed: 0, + lastModified: 2, + size: 3, + memberIds: new Set(['membera1', 'membera2', 'membera3']), + }, + usercohort3: { + cohortId: 'usercohort3', + groupType: 'User', + groupTypeId: 0, + lastComputed: 0, + lastModified: 10, + size: 3, + memberIds: new Set(['membera1', 'membera3']), + }, + usercohort4: { + cohortId: 'usercohort4', + groupType: 'User', + groupTypeId: 0, + lastComputed: 0, + lastModified: 10, + size: 2, + memberIds: new Set(['membera1', 'membera2']), + }, + orgnamecohort1: { + cohortId: 'orgnamecohort1', + groupType: 'org name', + groupTypeId: 100, + lastComputed: 6, + lastModified: 10, + size: 2, + memberIds: new Set(['org name 1', 'org name 2']), + }, +}; diff --git a/packages/node/test/local/util/mockHttpClient.ts b/packages/node/test/local/util/mockHttpClient.ts index 0d7f458..d4f9f47 100644 --- a/packages/node/test/local/util/mockHttpClient.ts +++ b/packages/node/test/local/util/mockHttpClient.ts @@ -1,10 +1,21 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { HttpClient, SimpleResponse } from 'src/types/transport'; +type MockedRequestParams = { + requestUrl: string; + method: string; + headers: Record; + body: string; + timeoutMillis?: number; +}; export class MockHttpClient implements HttpClient { - private readonly responder: () => Promise; + private readonly responder: ( + params: MockedRequestParams, + ) => Promise; - constructor(responder: () => Promise) { + constructor( + responder: (params: MockedRequestParams) => Promise, + ) { this.responder = responder; } @@ -15,6 +26,6 @@ export class MockHttpClient implements HttpClient { body: string, timeoutMillis?: number, ): Promise { - return this.responder(); + return this.responder({ requestUrl, method, headers, body, timeoutMillis }); } } diff --git a/packages/node/test/util/config.test.ts b/packages/node/test/util/config.test.ts new file mode 100644 index 0000000..5843652 --- /dev/null +++ b/packages/node/test/util/config.test.ts @@ -0,0 +1,82 @@ +import { LocalEvaluationConfig } from 'src/index'; +import { + LocalEvaluationDefaults, + CohortSyncConfigDefaults, + EU_SERVER_URLS, + RemoteEvaluationConfig, +} from 'src/types/config'; +import { + populateLocalConfigDefaults, + populateRemoteConfigDefaults, +} from 'src/util/config'; + +test.each([ + [ + {}, + [ + 'us', + LocalEvaluationDefaults.serverUrl, + LocalEvaluationDefaults.streamServerUrl, + CohortSyncConfigDefaults.cohortServerUrl, + ], + ], + [ + { zone: 'EU' }, + ['eu', EU_SERVER_URLS.flags, EU_SERVER_URLS.stream, EU_SERVER_URLS.cohort], + ], + [ + { url: 'urlurl', stream: 'streamurl', cohort: 'cohorturl' }, + ['us', 'urlurl', 'streamurl', 'cohorturl'], + ], + [ + { zone: 'eu', url: 'urlurl', stream: 'streamurl', cohort: 'cohorturl' }, + ['eu', 'urlurl', 'streamurl', 'cohorturl'], + ], + [ + { zone: 'eu', url: 'urlurl' }, + ['eu', 'urlurl', EU_SERVER_URLS.stream, EU_SERVER_URLS.cohort], + ], +])("'%s'", (testcase, expected) => { + const config: LocalEvaluationConfig = { + cohortSyncConfig: { + apiKey: '', + secretKey: '', + }, + }; + if ('zone' in testcase) { + config.serverZone = testcase.zone as never; + } + if ('url' in testcase) { + config.serverUrl = testcase.url; + } + if ('stream' in testcase) { + config.streamServerUrl = testcase.stream; + } + if ('cohort' in testcase) { + config.cohortSyncConfig.cohortServerUrl = testcase.cohort; + } + const newConfig = populateLocalConfigDefaults(config); + expect(newConfig.serverZone).toBe(expected[0]); + expect(newConfig.serverUrl).toBe(expected[1]); + expect(newConfig.streamServerUrl).toBe(expected[2]); + expect(newConfig.cohortSyncConfig.cohortServerUrl).toBe(expected[3]); +}); + +test.each([ + [{}, 'us', LocalEvaluationDefaults.serverUrl], + [{ zone: 'EU' }, 'eu', EU_SERVER_URLS.remote], + [{ url: 'urlurl' }, 'us', 'urlurl'], + [{ zone: 'eu', url: 'urlurl' }, 'eu', 'urlurl'], + [{ zone: 'eu', url: 'urlurl' }, 'eu', 'urlurl'], +])("'%s'", (testcase, expectedZone, expectedUrl) => { + const config: RemoteEvaluationConfig = {}; + if ('zone' in testcase) { + config.serverZone = testcase.zone as never; + } + if ('url' in testcase) { + config.serverUrl = testcase.url; + } + const newConfig = populateRemoteConfigDefaults(config); + expect(newConfig.serverZone).toBe(expectedZone); + expect(newConfig.serverUrl).toBe(expectedUrl); +}); diff --git a/packages/node/test/util/threading.test.ts b/packages/node/test/util/threading.test.ts new file mode 100644 index 0000000..84f5911 --- /dev/null +++ b/packages/node/test/util/threading.test.ts @@ -0,0 +1,118 @@ +import { Executor, Mutex, Semaphore } from 'src/util/threading'; +import { sleep } from 'src/util/time'; + +function mutexSleepFunc(lock, ms, acc) { + return async () => { + const unlock = await lock.lock(); + acc.runs++; + await sleep(ms); + unlock(); + }; +} + +function semaphoreSleepFunc(semaphore, ms, acc) { + return async () => { + const unlock = await semaphore.get(); + acc.runs++; + await sleep(ms); + unlock(); + }; +} + +function executorSleepFunc(ms, acc) { + return async () => { + acc.runs++; + await sleep(ms); + }; +} + +test('Mutex test locks', async () => { + const acc = { runs: 0 }; + const mutex = new Mutex(); + mutexSleepFunc(mutex, 100, acc)(); + mutexSleepFunc(mutex, 100, acc)(); + mutexSleepFunc(mutex, 100, acc)(); + mutexSleepFunc(mutex, 100, acc)(); + mutexSleepFunc(mutex, 100, acc)(); + await sleep(10); + expect(acc.runs).toBe(1); + await sleep(20); + expect(acc.runs).toBe(1); + await sleep(100); + expect(acc.runs).toBe(2); + await sleep(100); + expect(acc.runs).toBe(3); + await sleep(100); + expect(acc.runs).toBe(4); + await sleep(100); + expect(acc.runs).toBe(5); +}); + +test('Semaphore test locks', async () => { + const acc = { runs: 0 }; + const semaphore = new Semaphore(3); + semaphoreSleepFunc(semaphore, 100, acc)(); + semaphoreSleepFunc(semaphore, 100, acc)(); + semaphoreSleepFunc(semaphore, 100, acc)(); + semaphoreSleepFunc(semaphore, 100, acc)(); + semaphoreSleepFunc(semaphore, 100, acc)(); + await sleep(10); + expect(acc.runs).toBe(3); + await sleep(40); + expect(acc.runs).toBe(3); + await sleep(100); + expect(acc.runs).toBe(5); +}); + +test('Semaphore test fifo', async () => { + const acc = { runs: 0 }; + const semaphore = new Semaphore(3); + semaphoreSleepFunc(semaphore, 100, acc)(); + semaphoreSleepFunc(semaphore, 300, acc)(); + semaphoreSleepFunc(semaphore, 500, acc)(); + semaphoreSleepFunc(semaphore, 200, acc)(); + semaphoreSleepFunc(semaphore, 400, acc)(); + semaphoreSleepFunc(semaphore, 0, acc)(); + await sleep(10); + expect(acc.runs).toBe(3); + await sleep(40); + expect(acc.runs).toBe(3); + await sleep(100); + expect(acc.runs).toBe(4); + await sleep(100); + expect(acc.runs).toBe(4); + await sleep(100); + expect(acc.runs).toBe(6); + await sleep(200); + expect(acc.runs).toBe(6); + semaphoreSleepFunc(semaphore, 100, acc)(); + await sleep(10); + expect(acc.runs).toBe(7); +}); + +test('Executor test', async () => { + const acc = { runs: 0 }; + const executor = new Executor(3); + + executor.run(executorSleepFunc(100, acc)); + executor.run(executorSleepFunc(300, acc)); + executor.run(executorSleepFunc(500, acc)); + executor.run(executorSleepFunc(200, acc)); + executor.run(executorSleepFunc(400, acc)); + executor.run(executorSleepFunc(0, acc)); + await sleep(10); + expect(acc.runs).toBe(3); + await sleep(40); + expect(acc.runs).toBe(3); + await sleep(100); + expect(acc.runs).toBe(4); + await sleep(100); + expect(acc.runs).toBe(4); + await sleep(100); + expect(acc.runs).toBe(6); + await sleep(200); + expect(acc.runs).toBe(6); + executor.run(executorSleepFunc(100, acc)); + await sleep(10); + expect(acc.runs).toBe(7); +}); diff --git a/packages/node/test/util/user.test.ts b/packages/node/test/util/user.test.ts index e7bc7cb..7bc1f8d 100644 --- a/packages/node/test/util/user.test.ts +++ b/packages/node/test/util/user.test.ts @@ -88,4 +88,30 @@ describe('userToEvaluationContext', () => { }, }); }); + test('cohorts', () => { + const user: ExperimentUser = { + user_id: 'userId', + groups: { grouptype: ['groupname'] }, + cohort_ids: ['cohort1'], + group_cohort_ids: { + grouptype: { + groupname: ['cohort1'], + }, + }, + }; + + const context = convertUserToEvaluationContext(user); + expect(context).toEqual({ + user: { + user_id: 'userId', + cohort_ids: ['cohort1'], + }, + groups: { + grouptype: { + group_name: 'groupname', + cohort_ids: ['cohort1'], + }, + }, + }); + }); });