From 8a1140ccb90fd8a5f07ec27d1b723c3d42e50fc1 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 11 Jun 2024 09:44:02 -0700 Subject: [PATCH 01/48] move poller outside --- packages/node/src/local/client.ts | 16 ++++++++-------- packages/node/src/local/streamer.ts | 10 ++-------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 26670e8..9818b3d 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -78,13 +78,18 @@ export class LocalEvaluationClient { this.config.bootstrap, ); this.logger = new ConsoleLogger(this.config.debug); + const flagsPoller = new FlagConfigPoller( + fetcher, + this.cache, + 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, @@ -93,12 +98,7 @@ export class LocalEvaluationClient { this.config.streamServerUrl, this.config.debug, ) - : new FlagConfigPoller( - fetcher, - this.cache, - this.config.flagConfigPollingIntervalMillis, - this.config.debug, - ); + : flagsPoller; if (this.config.assignmentConfig) { this.config.assignmentConfig = { ...AssignmentConfigDefaults, diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 8c73a90..307af20 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -26,10 +26,9 @@ export class FlagConfigStreamer implements FlagConfigUpdater { constructor( apiKey: string, - fetcher: FlagConfigFetcher, + poller: FlagConfigPoller, cache: FlagConfigCache, streamEventSourceFactory: StreamEventSourceFactory, - pollingIntervalMillis = LocalEvaluationDefaults.flagConfigPollingIntervalMillis, streamFlagConnTimeoutMillis = LocalEvaluationDefaults.streamFlagConnTimeoutMillis, streamFlagTryAttempts: number, streamFlagTryDelayMillis: number, @@ -40,12 +39,7 @@ export class FlagConfigStreamer implements FlagConfigUpdater { this.logger = new ConsoleLogger(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, From 5b3926d6e9a74fee0da3a540f037dd2077d23985 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 12 Jun 2024 12:53:00 -0700 Subject: [PATCH 02/48] added cohort fetches --- packages/node/src/local/client.ts | 23 ++++++ packages/node/src/local/cohort/cohort-api.ts | 73 ++++++++++++++++++++ packages/node/src/local/cohort/fetcher.ts | 46 ++++++++++++ packages/node/src/local/cohort/poller.ts | 66 ++++++++++++++++++ packages/node/src/local/cohort/storage.ts | 34 +++++++++ packages/node/src/local/cohort/updater.ts | 15 ++++ packages/node/src/local/poller.ts | 28 ++++++-- packages/node/src/local/streamer.ts | 22 ++++-- packages/node/src/types/cohort.ts | 23 ++++++ packages/node/src/types/config.ts | 7 ++ packages/node/src/util/cohort.ts | 56 +++++++++++++++ 11 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 packages/node/src/local/cohort/cohort-api.ts create mode 100644 packages/node/src/local/cohort/fetcher.ts create mode 100644 packages/node/src/local/cohort/poller.ts create mode 100644 packages/node/src/local/cohort/storage.ts create mode 100644 packages/node/src/local/cohort/updater.ts create mode 100644 packages/node/src/types/cohort.ts create mode 100644 packages/node/src/util/cohort.ts diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 9818b3d..c9de034 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -30,6 +30,9 @@ import { } from '../util/variant'; import { InMemoryFlagConfigCache } from './cache'; +import { CohortFetcher } from './cohort/fetcher'; +import { CohortPoller } from './cohort/poller'; +import { InMemoryCohortStorage } from './cohort/storage'; import { FlagConfigFetcher } from './fetcher'; import { FlagConfigPoller } from './poller'; import { FlagConfigStreamer } from './streamer'; @@ -57,6 +60,7 @@ export class LocalEvaluationClient { * Used for directly manipulating the flag configs used for evaluation. */ public readonly cache: InMemoryFlagConfigCache; + public readonly cohortStorage: InMemoryCohortStorage; constructor( apiKey: string, @@ -78,10 +82,27 @@ export class LocalEvaluationClient { this.config.bootstrap, ); this.logger = new ConsoleLogger(this.config.debug); + + const cohortFetcher = new CohortFetcher( + 'apiKey', + 'secretKey', + httpClient, + this.config.serverUrl, + this.config.debug, + ); + const cohortPoller = new CohortPoller( + cohortFetcher, + this.cohortStorage, + this.config.maxCohortSize, + this.config.debug, + ); + const cohortUpdater = cohortPoller; + const flagsPoller = new FlagConfigPoller( fetcher, this.cache, this.config.flagConfigPollingIntervalMillis, + cohortUpdater, this.config.debug, ); this.updater = this.config.streamUpdates @@ -96,9 +117,11 @@ export class LocalEvaluationClient { STREAM_RETRY_DELAY_MILLIS + Math.floor(Math.random() * STREAM_RETRY_JITTER_MAX_MILLIS), this.config.streamServerUrl, + cohortUpdater, this.config.debug, ) : flagsPoller; + if (this.config.assignmentConfig) { this.config.assignmentConfig = { ...AssignmentConfigDefaults, 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..2d60866 --- /dev/null +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -0,0 +1,73 @@ +import { HttpClient, EvaluationFlag } from '@amplitude/experiment-core'; +import { Cohort } from 'src/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 SdkCohortApi implements CohortApi { + private readonly key; + private readonly serverUrl; + private readonly httpClient; + + constructor(key: string, serverUrl: string, httpClient: HttpClient) { + this.key = key; + this.serverUrl = serverUrl; + this.httpClient = httpClient; + } + + public async getCohort( + options?: GetCohortOptions, + ): Promise { + const headers: Record = { + Authorization: `Basic ${this.key}`, + }; + if (options?.libraryName && options?.libraryVersion) { + headers[ + 'X-Amp-Exp-Library' + ] = `${options.libraryName}/${options.libraryVersion}`; + } + + const response = await this.httpClient.request({ + requestUrl: `${this.serverUrl}/sdk/v1/cohort/${ + options.cohortId + }?maxCohortSize=${options.maxCohortSize}${ + options.lastModified ? `&lastModified=${options.lastModified}` : '' + }`, + 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; + return cohort; + } else if (response.status == 204) { + return undefined; + } else if (response.status == 413) { + throw Error(`Cohort error response: size > ${options.maxCohortSize}`); + } else { + throw Error(`Cohort error resposne: status=${response.status}`); + } + } +} diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts new file mode 100644 index 0000000..4ea028e --- /dev/null +++ b/packages/node/src/local/cohort/fetcher.ts @@ -0,0 +1,46 @@ +import { WrapperClient } from 'src/transport/http'; +import { Cohort } from 'src/types/cohort'; +import { LocalEvaluationDefaults } from 'src/types/config'; +import { HttpClient } from 'src/types/transport'; + +import { version as PACKAGE_VERSION } from '../../../gen/version'; + +import { SdkCohortApi } from './cohort-api'; + +const COHORT_CONFIG_TIMEOUT = 5000; + +export class CohortFetcher { + readonly cohortApi: SdkCohortApi; + readonly debug: boolean; + + constructor( + apiKey: string, + secretKey: string, + httpClient: HttpClient, + serverUrl = LocalEvaluationDefaults.serverUrl, + debug = false, + ) { + const key = apiKey + ':' + secretKey; + this.cohortApi = new SdkCohortApi( + key, + serverUrl, + new WrapperClient(httpClient), + ); + this.debug = debug; + } + + async fetch( + cohortId: string, + maxCohortSize: number, + lastModified?: number, + ): Promise { + return this.cohortApi.getCohort({ + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + cohortId: cohortId, + maxCohortSize: maxCohortSize, + lastModified: lastModified, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }); + } +} diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts new file mode 100644 index 0000000..9207d21 --- /dev/null +++ b/packages/node/src/local/cohort/poller.ts @@ -0,0 +1,66 @@ +import { CohortStorage } from 'src/types/cohort'; +import { LocalEvaluationDefaults } from 'src/types/config'; + +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; + private readonly maxCohortSize: number; + + constructor( + fetcher: CohortFetcher, + storage: CohortStorage, + maxCohortSize = LocalEvaluationDefaults.maxCohortSize, + debug = false, + ) { + this.fetcher = fetcher; + this.storage = storage; + this.maxCohortSize = maxCohortSize; + this.logger = new ConsoleLogger(debug); + } + + public async update( + cohortIds: Set, + onChange?: (storage: CohortStorage) => Promise, + ): Promise { + this.logger.debug('[Experiment] updating cohorts'); + + let changed = false; + for (const cohortId of cohortIds) { + // Get existing lastModified. + const existingCohort = this.storage.getCohort(cohortId); + let lastModified = undefined; + if (existingCohort) { + lastModified = existingCohort.lastModified; + } + + try { + // Download. + const cohort = await this.fetcher.fetch( + cohortId, + this.maxCohortSize, + lastModified, + ); + + // Set. + if (cohort) { + this.storage.put(cohort); + changed = true; + } + } catch { + this.logger.error(`[Experiment] cohort ${cohortId} download failed`); + } + } + + 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..b5ac941 --- /dev/null +++ b/packages/node/src/local/cohort/storage.ts @@ -0,0 +1,34 @@ +import { Cohort, CohortStorage, USER_GROUP_TYPE } from 'src/types/cohort'; + +export class InMemoryCohortStorage implements CohortStorage { + store: Record = {}; + + getCohort(cohortId: string): Cohort | undefined { + return cohortId in this.store ? this.store[cohortId] : undefined; + } + + getCohortsForUser(userId: string, cohortIds: string[]): string[] { + return this.getCohortsForGroup(USER_GROUP_TYPE, userId, cohortIds); + } + + getCohortsForGroup( + groupType: string, + groupName: string, + cohortIds: string[], + ): string[] { + const validCohortIds = []; + for (const cohortId of cohortIds) { + if ( + this.store[cohortId]?.groupType == groupType && + this.store[cohortId]?.memberIds.has(groupName) + ) { + validCohortIds.push(cohortId); + } + } + return validCohortIds; + } + + put(cohort: Cohort): undefined { + this.store[cohort.cohortId] = cohort; + } +} diff --git a/packages/node/src/local/cohort/updater.ts b/packages/node/src/local/cohort/updater.ts new file mode 100644 index 0000000..cace6b4 --- /dev/null +++ b/packages/node/src/local/cohort/updater.ts @@ -0,0 +1,15 @@ +import { CohortStorage } from 'src/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. + */ + update( + cohortIds: Set, + onChange?: (storage: CohortStorage) => Promise, + ): Promise; +} diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index cb2463c..4424b8b 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -1,9 +1,17 @@ +import { + EvaluationCondition, + EvaluationOperator, + EvaluationSegment, +} from '@amplitude/experiment-core'; +import { CohortUtils } from 'src/util/cohort'; + import { LocalEvaluationDefaults } from '../types/config'; -import { FlagConfigCache } from '../types/flag'; +import { FlagConfig, FlagConfigCache } from '../types/flag'; import { doWithBackoff, BackoffPolicy } from '../util/backoff'; import { ConsoleLogger } from '../util/logger'; import { Logger } from '../util/logger'; +import { CohortUpdater } from './cohort/updater'; import { FlagConfigFetcher } from './fetcher'; import { FlagConfigUpdater } from './updater'; @@ -23,15 +31,19 @@ export class FlagConfigPoller implements FlagConfigUpdater { public readonly fetcher: FlagConfigFetcher; public readonly cache: FlagConfigCache; + public readonly cohortUpdater?: CohortUpdater; + constructor( fetcher: FlagConfigFetcher, cache: FlagConfigCache, pollingIntervalMillis = LocalEvaluationDefaults.flagConfigPollingIntervalMillis, + cohortUpdater?: CohortUpdater, debug = false, ) { this.fetcher = fetcher; this.cache = cache; this.pollingIntervalMillis = pollingIntervalMillis; + this.cohortUpdater = cohortUpdater; this.logger = new ConsoleLogger(debug); } @@ -96,10 +108,18 @@ export class FlagConfigPoller implements FlagConfigUpdater { changed = true; } } - await this.cache.clear(); - await this.cache.putAll(flagConfigs); if (changed) { - await onChange(this.cache); + try { + await this.cohortUpdater?.update( + CohortUtils.extractCohortIds(flagConfigs), + ); + } catch { + this.logger.debug('[Experiment] cohort update failed'); + } finally { + await this.cache.clear(); + await this.cache.putAll(flagConfigs); + await onChange(this.cache); + } } } } diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 307af20..b2395d8 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -1,3 +1,5 @@ +import { CohortUtils } from 'src/util/cohort'; + import { version as PACKAGE_VERSION } from '../../gen/version'; import { StreamErrorEvent, @@ -8,7 +10,7 @@ import { FlagConfigCache } from '../types/flag'; import { ConsoleLogger } from '../util/logger'; import { Logger } from '../util/logger'; -import { FlagConfigFetcher } from './fetcher'; +import { CohortUpdater } from './cohort/updater'; import { FlagConfigPoller } from './poller'; import { SdkStreamFlagApi } from './stream-flag-api'; import { FlagConfigUpdater } from './updater'; @@ -24,6 +26,8 @@ export class FlagConfigStreamer implements FlagConfigUpdater { public readonly cache: FlagConfigCache; + public readonly cohortUpdater?: CohortUpdater; + constructor( apiKey: string, poller: FlagConfigPoller, @@ -34,6 +38,7 @@ export class FlagConfigStreamer implements FlagConfigUpdater { streamFlagTryDelayMillis: number, streamFlagRetryDelayMillis: number, serverUrl: string = LocalEvaluationDefaults.serverUrl, + cohortUpdater?: CohortUpdater, debug = false, ) { this.logger = new ConsoleLogger(debug); @@ -50,6 +55,7 @@ export class FlagConfigStreamer implements FlagConfigUpdater { streamFlagTryDelayMillis, ); this.streamFlagRetryDelayMillis = streamFlagRetryDelayMillis; + this.cohortUpdater = cohortUpdater; } /** @@ -82,10 +88,18 @@ export class FlagConfigStreamer implements FlagConfigUpdater { changed = true; } } - await this.cache.clear(); - await this.cache.putAll(flagConfigs); if (changed) { - await onChange(this.cache); + try { + await this.cohortUpdater?.update( + CohortUtils.extractCohortIds(flagConfigs), + ); + } catch { + this.logger.debug('[Experiment] cohort update failed'); + } finally { + await this.cache.clear(); + await this.cache.putAll(flagConfigs); + await onChange(this.cache); + } } }; diff --git a/packages/node/src/types/cohort.ts b/packages/node/src/types/cohort.ts new file mode 100644 index 0000000..390b6d5 --- /dev/null +++ b/packages/node/src/types/cohort.ts @@ -0,0 +1,23 @@ +export interface CohortStorage { + getCohort(cohortId: string): Cohort | undefined; + getCohortsForUser(userId: string, cohortIds: string[]): string[]; + getCohortsForGroup( + groupType: string, + groupName: string, + cohortIds: string[], + ): string[]; + put(cohort: Cohort): undefined; +} + +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..e6ea619 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -169,6 +169,12 @@ export type LocalEvaluationConfig = { * flag configs. */ streamFlagConnTimeoutMillis?: number; + + /** + * The max cohort size to be able to download. Any cohort larger than this + * size will be skipped. + */ + maxCohortSize?: number; }; export type AssignmentConfig = { @@ -205,6 +211,7 @@ export const LocalEvaluationDefaults: LocalEvaluationConfig = { streamUpdates: false, streamServerUrl: 'https://stream.lab.amplitude.com', streamFlagConnTimeoutMillis: 1500, + maxCohortSize: 10_000_000, }; export const AssignmentConfigDefaults: Omit = { diff --git a/packages/node/src/util/cohort.ts b/packages/node/src/util/cohort.ts new file mode 100644 index 0000000..aedb25a --- /dev/null +++ b/packages/node/src/util/cohort.ts @@ -0,0 +1,56 @@ +import { + EvaluationCondition, + EvaluationOperator, + EvaluationSegment, +} from '@amplitude/experiment-core'; + +import { FlagConfig } from '..'; + +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 cohortIds = new Set(); + for (const key in flagConfigs) { + if ( + flagConfigs[key].segments && + Array.isArray(flagConfigs[key].segments) + ) { + const segments = flagConfigs[key].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] + if (condition.selector.length > 2) { + if ( + condition.selector[1] != 'user' && + !condition.selector.includes('groups') + ) { + continue; + } + condition.values.forEach(cohortIds.add, cohortIds); + } + } + } + } + } + } + } + return cohortIds; + } +} From 9f2fc2e5cbf8f38bfebc053974191e3ff7c3b7b4 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 12 Jun 2024 13:36:41 -0700 Subject: [PATCH 03/48] remove unused imports --- packages/node/src/local/cohort/cohort-api.ts | 2 +- packages/node/src/local/poller.ts | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index 2d60866..260ab0e 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -1,4 +1,4 @@ -import { HttpClient, EvaluationFlag } from '@amplitude/experiment-core'; +import { HttpClient } from '@amplitude/experiment-core'; import { Cohort } from 'src/types/cohort'; export type GetCohortOptions = { diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index 4424b8b..3d3c992 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -1,12 +1,7 @@ -import { - EvaluationCondition, - EvaluationOperator, - EvaluationSegment, -} from '@amplitude/experiment-core'; import { CohortUtils } from 'src/util/cohort'; import { LocalEvaluationDefaults } from '../types/config'; -import { FlagConfig, FlagConfigCache } from '../types/flag'; +import { FlagConfigCache } from '../types/flag'; import { doWithBackoff, BackoffPolicy } from '../util/backoff'; import { ConsoleLogger } from '../util/logger'; import { Logger } from '../util/logger'; From 4eba1394a366b4459eda3aa6593b24c71553f386 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 12 Jun 2024 13:51:55 -0700 Subject: [PATCH 04/48] always update cohort and fix test --- packages/node/src/local/poller.ts | 20 +++++++++---------- packages/node/src/local/streamer.ts | 20 +++++++++---------- .../test/local/flagConfigStreamer.test.ts | 12 ++++++++--- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index 3d3c992..3e8aee5 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -103,16 +103,16 @@ export class FlagConfigPoller implements FlagConfigUpdater { changed = true; } } - if (changed) { - try { - await this.cohortUpdater?.update( - CohortUtils.extractCohortIds(flagConfigs), - ); - } catch { - this.logger.debug('[Experiment] cohort update failed'); - } finally { - await this.cache.clear(); - await this.cache.putAll(flagConfigs); + try { + await this.cohortUpdater?.update( + CohortUtils.extractCohortIds(flagConfigs), + ); + } catch { + this.logger.debug('[Experiment] cohort update failed'); + } finally { + await this.cache.clear(); + await this.cache.putAll(flagConfigs); + if (changed) { await onChange(this.cache); } } diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index b2395d8..2900b84 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -88,16 +88,16 @@ export class FlagConfigStreamer implements FlagConfigUpdater { changed = true; } } - if (changed) { - try { - await this.cohortUpdater?.update( - CohortUtils.extractCohortIds(flagConfigs), - ); - } catch { - this.logger.debug('[Experiment] cohort update failed'); - } finally { - await this.cache.clear(); - await this.cache.putAll(flagConfigs); + try { + await this.cohortUpdater?.update( + CohortUtils.extractCohortIds(flagConfigs), + ); + } catch { + this.logger.debug('[Experiment] cohort update failed'); + } finally { + await this.cache.clear(); + await this.cache.putAll(flagConfigs); + if (changed) { await onChange(this.cache); } } diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 26ed392..34a2eb7 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -1,6 +1,6 @@ import assert from 'assert'; -import { InMemoryFlagConfigCache } from 'src/index'; +import { FlagConfigPoller, InMemoryFlagConfigCache } from 'src/index'; import { FlagConfigFetcher } from 'src/local/fetcher'; import { FlagConfigStreamer } from 'src/local/streamer'; @@ -37,15 +37,21 @@ const getTestObjs = ({ const mockClient = getNewClient(); const updater = new FlagConfigStreamer( apiKey, - fetchObj.fetcher, + new FlagConfigPoller( + fetchObj.fetcher, + cache, + pollingIntervalMillis, + null, + debug, + ), cache, mockClient.clientFactory, - pollingIntervalMillis, streamFlagConnTimeoutMillis, streamFlagTryAttempts, streamFlagTryDelayMillis, streamFlagRetryDelayMillis, serverUrl, + null, debug, ); return { From 88b1453689e6d86b760c8b40226cf6cea23eb4c7 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Jun 2024 09:12:26 -0700 Subject: [PATCH 05/48] added cohort server url --- packages/node/src/local/client.ts | 2 +- packages/node/src/local/cohort/fetcher.ts | 2 +- packages/node/src/local/cohort/poller.ts | 2 +- packages/node/src/types/config.ts | 6 ++ packages/node/test/local/streamTest.test.ts | 88 +++++++++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 packages/node/test/local/streamTest.test.ts diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index c9de034..206843e 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -87,7 +87,7 @@ export class LocalEvaluationClient { 'apiKey', 'secretKey', httpClient, - this.config.serverUrl, + this.config.cohortServerUrl, this.config.debug, ); const cohortPoller = new CohortPoller( diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts index 4ea028e..3c9ec87 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -17,7 +17,7 @@ export class CohortFetcher { apiKey: string, secretKey: string, httpClient: HttpClient, - serverUrl = LocalEvaluationDefaults.serverUrl, + serverUrl = LocalEvaluationDefaults.cohortServerUrl, debug = false, ) { const key = apiKey + ':' + secretKey; diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index 9207d21..1fe3d45 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -30,7 +30,7 @@ export class CohortPoller implements CohortUpdater { cohortIds: Set, onChange?: (storage: CohortStorage) => Promise, ): Promise { - this.logger.debug('[Experiment] updating cohorts'); + this.logger.debug(`[Experiment] updating cohorts ${cohortIds}`); let changed = false; for (const cohortId of cohortIds) { diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index e6ea619..f0db204 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -170,6 +170,11 @@ export type LocalEvaluationConfig = { */ streamFlagConnTimeoutMillis?: number; + /** + * The cohort server endpoint from which to fetch cohort data. + */ + cohortServerUrl?: string; + /** * The max cohort size to be able to download. Any cohort larger than this * size will be skipped. @@ -211,6 +216,7 @@ export const LocalEvaluationDefaults: LocalEvaluationConfig = { streamUpdates: false, streamServerUrl: 'https://stream.lab.amplitude.com', streamFlagConnTimeoutMillis: 1500, + cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', maxCohortSize: 10_000_000, }; diff --git a/packages/node/test/local/streamTest.test.ts b/packages/node/test/local/streamTest.test.ts new file mode 100644 index 0000000..c3b2774 --- /dev/null +++ b/packages/node/test/local/streamTest.test.ts @@ -0,0 +1,88 @@ +// eslint-disable-next-line import/order +import EventSource from 'eventsource'; +import { SdkStreamFlagApi } from 'src/local/stream-flag-api'; + +// const flagName = "peter-test-5"; +const flagName = 'peter-test-2'; +test('FlagConfigUpdater.connect, success', async () => { + jest.setTimeout(100000); + let notConnected = new Set([]); + let noFlagReceived = new Set([]); + let changed = new Set([]); + let noFlag = 0; + let errs = {}; + let connectError = {}; + let totalUpdates = 0; + for (let i = 0; i < 1; i++) { + const maxJ = 1; + for (let j = 0; j < maxJ; j++) { + const n = i * maxJ + j; + notConnected.add(n); + noFlagReceived.add(n); + const api = new SdkStreamFlagApi( + 'server-tUTqR62DZefq7c73zMpbIr1M5VDtwY8T', + 'https://skylab-stream.stag2.amplitude.com', + + // 'server-DGZM7VCQz8pAnq0w7JKCsmPKFFJcFc73', + // 'server-pkO9htcbpp0jtF4JdaNUx1QRzV5xCoWI', + // 'http://localhost:7999', + // 'https://stream.lab.amplitude.com', + + // 'server-xTVBeoujVeuXhOHzbt0wfoMO7qR0YYzK', + // 'server-BH9F9X8Hcbkw4qqhktGPX70bZ2iAB0ku', + // 'server-6LH7ZitI6lKImPA8iS49lNPpipM2JSCa', + // 'https://stream.lab.eu.amplitude.com', + (url, params) => new EventSource(url, params), + // 1500, + // 1500, + // 1, + ); + api.onError = async (err) => { + errs[n] = err; + console.log('error!!!!!!', err); + }; + api.onUpdate = async (flags) => { + changed[n] = true; + noFlagReceived.delete(n); + totalUpdates++; + if (!flags) { + noFlag++; + } + console.log( + totalUpdates, + notConnected.size < 10 ? notConnected : notConnected.size, + noFlagReceived.size < 10 ? noFlagReceived : noFlagReceived.size, + noFlag, + connectError, + errs, + ); + }; + api + .connect() + .then(() => { + notConnected.delete(n); + console.log( + totalUpdates, + notConnected.size < 10 ? notConnected : notConnected.size, + noFlagReceived.size < 10 ? noFlagReceived : noFlagReceived.size, + noFlag, + connectError, + errs, + ); + }) + .catch((err) => { + connectError[n] = err; + console.log( + totalUpdates, + notConnected.size < 10 ? notConnected : notConnected.size, + noFlagReceived.size < 10 ? noFlagReceived : noFlagReceived.size, + noFlag, + connectError, + errs, + ); + }); + } + await new Promise((r) => setTimeout(r, 100)); + } + await new Promise((r) => setTimeout(r, 20000)); +}); From 69b74af926eee92b5e807165d3f22d9ec8bdba99 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Jun 2024 09:13:33 -0700 Subject: [PATCH 06/48] remove streamTest file --- packages/node/test/local/streamTest.test.ts | 88 --------------------- 1 file changed, 88 deletions(-) delete mode 100644 packages/node/test/local/streamTest.test.ts diff --git a/packages/node/test/local/streamTest.test.ts b/packages/node/test/local/streamTest.test.ts deleted file mode 100644 index c3b2774..0000000 --- a/packages/node/test/local/streamTest.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// eslint-disable-next-line import/order -import EventSource from 'eventsource'; -import { SdkStreamFlagApi } from 'src/local/stream-flag-api'; - -// const flagName = "peter-test-5"; -const flagName = 'peter-test-2'; -test('FlagConfigUpdater.connect, success', async () => { - jest.setTimeout(100000); - let notConnected = new Set([]); - let noFlagReceived = new Set([]); - let changed = new Set([]); - let noFlag = 0; - let errs = {}; - let connectError = {}; - let totalUpdates = 0; - for (let i = 0; i < 1; i++) { - const maxJ = 1; - for (let j = 0; j < maxJ; j++) { - const n = i * maxJ + j; - notConnected.add(n); - noFlagReceived.add(n); - const api = new SdkStreamFlagApi( - 'server-tUTqR62DZefq7c73zMpbIr1M5VDtwY8T', - 'https://skylab-stream.stag2.amplitude.com', - - // 'server-DGZM7VCQz8pAnq0w7JKCsmPKFFJcFc73', - // 'server-pkO9htcbpp0jtF4JdaNUx1QRzV5xCoWI', - // 'http://localhost:7999', - // 'https://stream.lab.amplitude.com', - - // 'server-xTVBeoujVeuXhOHzbt0wfoMO7qR0YYzK', - // 'server-BH9F9X8Hcbkw4qqhktGPX70bZ2iAB0ku', - // 'server-6LH7ZitI6lKImPA8iS49lNPpipM2JSCa', - // 'https://stream.lab.eu.amplitude.com', - (url, params) => new EventSource(url, params), - // 1500, - // 1500, - // 1, - ); - api.onError = async (err) => { - errs[n] = err; - console.log('error!!!!!!', err); - }; - api.onUpdate = async (flags) => { - changed[n] = true; - noFlagReceived.delete(n); - totalUpdates++; - if (!flags) { - noFlag++; - } - console.log( - totalUpdates, - notConnected.size < 10 ? notConnected : notConnected.size, - noFlagReceived.size < 10 ? noFlagReceived : noFlagReceived.size, - noFlag, - connectError, - errs, - ); - }; - api - .connect() - .then(() => { - notConnected.delete(n); - console.log( - totalUpdates, - notConnected.size < 10 ? notConnected : notConnected.size, - noFlagReceived.size < 10 ? noFlagReceived : noFlagReceived.size, - noFlag, - connectError, - errs, - ); - }) - .catch((err) => { - connectError[n] = err; - console.log( - totalUpdates, - notConnected.size < 10 ? notConnected : notConnected.size, - noFlagReceived.size < 10 ? noFlagReceived : noFlagReceived.size, - noFlag, - connectError, - errs, - ); - }); - } - await new Promise((r) => setTimeout(r, 100)); - } - await new Promise((r) => setTimeout(r, 20000)); -}); From 42bdc9d2bcc9938d4d7aa57c1d699a1392c280f1 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Jun 2024 09:24:41 -0700 Subject: [PATCH 07/48] added fetch key to base64 and memberId convert to set --- packages/node/src/local/cohort/cohort-api.ts | 14 +++++++++----- packages/node/src/local/cohort/fetcher.ts | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index 260ab0e..e11c150 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -44,12 +44,13 @@ export class SdkCohortApi implements CohortApi { ] = `${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: `${this.serverUrl}/sdk/v1/cohort/${ - options.cohortId - }?maxCohortSize=${options.maxCohortSize}${ - options.lastModified ? `&lastModified=${options.lastModified}` : '' - }`, + requestUrl: reqUrl, method: 'GET', headers: headers, timeoutMillis: options?.timeoutMillis, @@ -61,6 +62,9 @@ export class SdkCohortApi implements CohortApi { // 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; diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts index 3c9ec87..5a602c9 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -20,7 +20,7 @@ export class CohortFetcher { serverUrl = LocalEvaluationDefaults.cohortServerUrl, debug = false, ) { - const key = apiKey + ':' + secretKey; + const key = Buffer.from(apiKey + ':' + secretKey).toString('base64'); this.cohortApi = new SdkCohortApi( key, serverUrl, From cb508c12af740735f80ce9520aa9f60f4afb4e61 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Jun 2024 09:28:55 -0700 Subject: [PATCH 08/48] fixed type --- packages/node/src/local/cohort/storage.ts | 2 +- packages/node/src/types/cohort.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node/src/local/cohort/storage.ts b/packages/node/src/local/cohort/storage.ts index b5ac941..e90c49e 100644 --- a/packages/node/src/local/cohort/storage.ts +++ b/packages/node/src/local/cohort/storage.ts @@ -28,7 +28,7 @@ export class InMemoryCohortStorage implements CohortStorage { return validCohortIds; } - put(cohort: Cohort): undefined { + put(cohort: Cohort): void { this.store[cohort.cohortId] = cohort; } } diff --git a/packages/node/src/types/cohort.ts b/packages/node/src/types/cohort.ts index 390b6d5..4ea577b 100644 --- a/packages/node/src/types/cohort.ts +++ b/packages/node/src/types/cohort.ts @@ -6,7 +6,7 @@ export interface CohortStorage { groupName: string, cohortIds: string[], ): string[]; - put(cohort: Cohort): undefined; + put(cohort: Cohort): void; } export const USER_GROUP_TYPE = 'User'; From 4dda93d02434ec79cc8e69332e44508e31c309c0 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Jun 2024 12:00:14 -0700 Subject: [PATCH 09/48] added cohort to eval context --- packages/node/src/local/client.ts | 53 ++++++++++++++++++-- packages/node/src/local/cohort/cohort-api.ts | 8 +-- packages/node/src/local/cohort/fetcher.ts | 7 ++- packages/node/src/local/cohort/poller.ts | 11 ++-- packages/node/src/local/cohort/storage.ts | 10 ++-- packages/node/src/types/cohort.ts | 6 +-- packages/node/src/types/config.ts | 37 +++++++++----- packages/node/src/types/user.ts | 8 +++ packages/node/src/util/cohort.ts | 32 +++++++++--- packages/node/src/util/user.ts | 7 +++ 10 files changed, 137 insertions(+), 42 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 206843e..7b7928a 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -5,6 +5,8 @@ import { topologicalSort, } from '@amplitude/experiment-core'; import EventSource from 'eventsource'; +import { USER_GROUP_TYPE } from 'src/types/cohort'; +import { CohortUtils } from 'src/util/cohort'; import { Assignment, AssignmentService } from '../assignment/assignment'; import { InMemoryAssignmentFilter } from '../assignment/assignment-filter'; @@ -84,16 +86,16 @@ export class LocalEvaluationClient { this.logger = new ConsoleLogger(this.config.debug); const cohortFetcher = new CohortFetcher( - 'apiKey', - 'secretKey', + this.config.cohortConfig.apiKey, + this.config.cohortConfig.secretKey, httpClient, - this.config.cohortServerUrl, + this.config.cohortConfig?.cohortServerUrl, this.config.debug, ); const cohortPoller = new CohortPoller( cohortFetcher, this.cohortStorage, - this.config.maxCohortSize, + this.config.cohortConfig?.maxCohortSize, this.config.debug, ); const cohortUpdater = cohortPoller; @@ -167,6 +169,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); @@ -176,6 +179,48 @@ export class LocalEvaluationClient { return evaluationVariantsToVariants(results); } + private enrichUserWithCohorts( + user: ExperimentUser, + flags: Record, + ): void { + const cohortIdsByGroup = CohortUtils.extractCohortIdsByGroup(flags); + + // Enrich cohorts with user group type. + const userCohortIds = cohortIdsByGroup[USER_GROUP_TYPE]; + if (user.user_id && userCohortIds && userCohortIds.size == 0) { + user.cohort_ids = 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]; // TODO: Only get the first one? + + const cohortIds = cohortIdsByGroup[groupType]; + if (!cohortIds || cohortIds.size == 0) { + continue; + } + + if (!(groupType in user.group_cohort_ids)) { + user.group_cohort_ids[groupType] = {}; + } + user.group_cohort_ids[groupType][groupName] = + this.cohortStorage.getCohortsForGroup( + groupType, + groupName, + cohortIds, + ); + } + } + } + /** * Locally evaluates flag variants for a user. * diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index e11c150..6dd84f5 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -22,12 +22,12 @@ export interface CohortApi { getCohort(options?: GetCohortOptions): Promise; } export class SdkCohortApi implements CohortApi { - private readonly key; + private readonly cohortApiKey; private readonly serverUrl; private readonly httpClient; - constructor(key: string, serverUrl: string, httpClient: HttpClient) { - this.key = key; + constructor(cohortApiKey: string, serverUrl: string, httpClient: HttpClient) { + this.cohortApiKey = cohortApiKey; this.serverUrl = serverUrl; this.httpClient = httpClient; } @@ -36,7 +36,7 @@ export class SdkCohortApi implements CohortApi { options?: GetCohortOptions, ): Promise { const headers: Record = { - Authorization: `Basic ${this.key}`, + Authorization: `Basic ${this.cohortApiKey}`, }; if (options?.libraryName && options?.libraryVersion) { headers[ diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts index 5a602c9..5464c38 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -1,6 +1,6 @@ import { WrapperClient } from 'src/transport/http'; import { Cohort } from 'src/types/cohort'; -import { LocalEvaluationDefaults } from 'src/types/config'; +import { CohortConfigDefaults } from 'src/types/config'; import { HttpClient } from 'src/types/transport'; import { version as PACKAGE_VERSION } from '../../../gen/version'; @@ -17,12 +17,11 @@ export class CohortFetcher { apiKey: string, secretKey: string, httpClient: HttpClient, - serverUrl = LocalEvaluationDefaults.cohortServerUrl, + serverUrl = CohortConfigDefaults.cohortServerUrl, debug = false, ) { - const key = Buffer.from(apiKey + ':' + secretKey).toString('base64'); this.cohortApi = new SdkCohortApi( - key, + Buffer.from(apiKey + ':' + secretKey).toString('base64'), serverUrl, new WrapperClient(httpClient), ); diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index 1fe3d45..bb783fc 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -1,5 +1,8 @@ import { CohortStorage } from 'src/types/cohort'; -import { LocalEvaluationDefaults } from 'src/types/config'; +import { + CohortConfigDefaults, + LocalEvaluationDefaults, +} from 'src/types/config'; import { ConsoleLogger } from '../../util/logger'; import { Logger } from '../../util/logger'; @@ -17,7 +20,7 @@ export class CohortPoller implements CohortUpdater { constructor( fetcher: CohortFetcher, storage: CohortStorage, - maxCohortSize = LocalEvaluationDefaults.maxCohortSize, + maxCohortSize = CohortConfigDefaults.maxCohortSize, debug = false, ) { this.fetcher = fetcher; @@ -54,8 +57,8 @@ export class CohortPoller implements CohortUpdater { this.storage.put(cohort); changed = true; } - } catch { - this.logger.error(`[Experiment] cohort ${cohortId} download failed`); + } catch (e) { + this.logger.error(`[Experiment] cohort ${cohortId} download failed`, e); } } diff --git a/packages/node/src/local/cohort/storage.ts b/packages/node/src/local/cohort/storage.ts index e90c49e..165689c 100644 --- a/packages/node/src/local/cohort/storage.ts +++ b/packages/node/src/local/cohort/storage.ts @@ -7,22 +7,22 @@ export class InMemoryCohortStorage implements CohortStorage { return cohortId in this.store ? this.store[cohortId] : undefined; } - getCohortsForUser(userId: string, cohortIds: string[]): string[] { + getCohortsForUser(userId: string, cohortIds: Set): Set { return this.getCohortsForGroup(USER_GROUP_TYPE, userId, cohortIds); } getCohortsForGroup( groupType: string, groupName: string, - cohortIds: string[], - ): string[] { - const validCohortIds = []; + 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.push(cohortId); + validCohortIds.add(cohortId); } } return validCohortIds; diff --git a/packages/node/src/types/cohort.ts b/packages/node/src/types/cohort.ts index 4ea577b..5fdbe57 100644 --- a/packages/node/src/types/cohort.ts +++ b/packages/node/src/types/cohort.ts @@ -1,11 +1,11 @@ export interface CohortStorage { getCohort(cohortId: string): Cohort | undefined; - getCohortsForUser(userId: string, cohortIds: string[]): string[]; + getCohortsForUser(userId: string, cohortIds: Set): Set; getCohortsForGroup( groupType: string, groupName: string, - cohortIds: string[], - ): string[]; + cohortIds: Set, + ): Set; put(cohort: Cohort): void; } diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index f0db204..8326402 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -170,16 +170,7 @@ export type LocalEvaluationConfig = { */ streamFlagConnTimeoutMillis?: number; - /** - * The cohort server endpoint from which to fetch cohort data. - */ - cohortServerUrl?: string; - - /** - * The max cohort size to be able to download. Any cohort larger than this - * size will be skipped. - */ - maxCohortSize?: number; + cohortConfig?: CohortConfig; }; export type AssignmentConfig = { @@ -195,6 +186,22 @@ export type AssignmentConfig = { cacheCapacity?: number; } & NodeOptions; +export type CohortConfig = { + apiKey: string; + secretKey: string; + + /** + * The cohort server endpoint from which to fetch cohort data. + */ + cohortServerUrl?: string; + + /** + * The max cohort size to be able to download. Any cohort larger than this + * size will be skipped. + */ + maxCohortSize?: number; +}; + /** Defaults for {@link LocalEvaluationConfig} options. @@ -216,10 +223,16 @@ export const LocalEvaluationDefaults: LocalEvaluationConfig = { streamUpdates: false, streamServerUrl: 'https://stream.lab.amplitude.com', streamFlagConnTimeoutMillis: 1500, - cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', - maxCohortSize: 10_000_000, }; export const AssignmentConfigDefaults: Omit = { cacheCapacity: 65536, }; + +export const CohortConfigDefaults: Omit< + Omit, + 'secretKey' +> = { + cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', + maxCohortSize: 10_000_000, +}; diff --git a/packages/node/src/types/user.ts b/packages/node/src/types/user.ts index f08dea0..8415a4d 100644 --- a/packages/node/src/types/user.ts +++ b/packages/node/src/types/user.ts @@ -112,4 +112,12 @@ export type ExperimentUser = { }; }; }; + + cohort_ids: Set; + + group_cohort_ids: { + [groupType: string]: { + [groupName: string]: Set; + }; + }; }; diff --git a/packages/node/src/util/cohort.ts b/packages/node/src/util/cohort.ts index aedb25a..dccbcf3 100644 --- a/packages/node/src/util/cohort.ts +++ b/packages/node/src/util/cohort.ts @@ -3,6 +3,7 @@ import { EvaluationOperator, EvaluationSegment, } from '@amplitude/experiment-core'; +import { USER_GROUP_TYPE } from 'src/types/cohort'; import { FlagConfig } from '..'; @@ -19,7 +20,18 @@ export class CohortUtils { public static extractCohortIds( flagConfigs: Record, ): Set { + const cohorts = this.extractCohortIdsByGroup(flagConfigs); const cohortIds = new Set(); + for (const groupType in cohorts) { + cohorts[groupType].forEach(cohortIds.add, cohortIds); + } + return cohortIds; + } + + public static extractCohortIdsByGroup( + flagConfigs: Record, + ): Record> { + const cohortIdsByGroup = {}; for (const key in flagConfigs) { if ( flagConfigs[key].segments && @@ -36,14 +48,22 @@ export class CohortUtils { 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' && - !condition.selector.includes('groups') - ) { + if (condition.selector[1] == 'user') { + groupType = USER_GROUP_TYPE; + } else if (condition.selector.includes('groups')) { + groupType = condition.selector[2]; + } else { continue; } - condition.values.forEach(cohortIds.add, cohortIds); + if (!(groupType in cohortIdsByGroup)) { + cohortIdsByGroup[groupType] = new Set(); + } + condition.values.forEach( + cohortIdsByGroup[groupType].add, + cohortIdsByGroup[groupType], + ); } } } @@ -51,6 +71,6 @@ export class CohortUtils { } } } - return cohortIds; + return cohortIdsByGroup; } } 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; } } From 025e980886b04b492780f8a00e3f8970461ea829 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 13 Jun 2024 17:53:21 -0700 Subject: [PATCH 10/48] fixed bugs, moved configs, surface cohort errors to flag pollers --- packages/node/src/local/client.ts | 47 +++++++++++++---------- packages/node/src/local/cohort/poller.ts | 28 ++++++-------- packages/node/src/local/cohort/updater.ts | 1 + packages/node/src/local/poller.ts | 27 ++++++------- packages/node/src/types/config.ts | 12 +++--- packages/node/src/types/user.ts | 6 +-- 6 files changed, 62 insertions(+), 59 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 7b7928a..dc5bd77 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -85,20 +85,24 @@ export class LocalEvaluationClient { ); this.logger = new ConsoleLogger(this.config.debug); - const cohortFetcher = new CohortFetcher( - this.config.cohortConfig.apiKey, - this.config.cohortConfig.secretKey, - httpClient, - this.config.cohortConfig?.cohortServerUrl, - this.config.debug, - ); - const cohortPoller = new CohortPoller( - cohortFetcher, - this.cohortStorage, - this.config.cohortConfig?.maxCohortSize, - this.config.debug, - ); - const cohortUpdater = cohortPoller; + let cohortUpdater = undefined; + if (this.config.cohortConfig) { + this.cohortStorage = new InMemoryCohortStorage(); + const cohortFetcher = new CohortFetcher( + this.config.cohortConfig.apiKey, + this.config.cohortConfig.secretKey, + httpClient, + this.config.cohortConfig?.cohortServerUrl, + this.config.debug, + ); + const cohortPoller = new CohortPoller( + cohortFetcher, + this.cohortStorage, + this.config.cohortConfig?.maxCohortSize, + this.config.debug, + ); + cohortUpdater = cohortPoller; + } const flagsPoller = new FlagConfigPoller( fetcher, @@ -187,10 +191,9 @@ export class LocalEvaluationClient { // Enrich cohorts with user group type. const userCohortIds = cohortIdsByGroup[USER_GROUP_TYPE]; - if (user.user_id && userCohortIds && userCohortIds.size == 0) { - user.cohort_ids = this.cohortStorage.getCohortsForUser( - user.user_id, - userCohortIds, + if (user.user_id && userCohortIds && userCohortIds.size != 0) { + user.cohort_ids = Array.from( + this.cohortStorage.getCohortsForUser(user.user_id, userCohortIds), ); } @@ -208,15 +211,19 @@ export class LocalEvaluationClient { 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] = + user.group_cohort_ids[groupType][groupName] = Array.from( this.cohortStorage.getCohortsForGroup( groupType, groupName, cohortIds, - ); + ), + ); } } } diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index bb783fc..3d104ef 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -33,10 +33,10 @@ export class CohortPoller implements CohortUpdater { cohortIds: Set, onChange?: (storage: CohortStorage) => Promise, ): Promise { - this.logger.debug(`[Experiment] updating cohorts ${cohortIds}`); - let changed = false; for (const cohortId of cohortIds) { + this.logger.debug(`[Experiment] updating cohort ${cohortId}`); + // Get existing lastModified. const existingCohort = this.storage.getCohort(cohortId); let lastModified = undefined; @@ -44,21 +44,17 @@ export class CohortPoller implements CohortUpdater { lastModified = existingCohort.lastModified; } - try { - // Download. - const cohort = await this.fetcher.fetch( - cohortId, - this.maxCohortSize, - lastModified, - ); + // Download. + const cohort = await this.fetcher.fetch( + cohortId, + this.maxCohortSize, + lastModified, + ); - // Set. - if (cohort) { - this.storage.put(cohort); - changed = true; - } - } catch (e) { - this.logger.error(`[Experiment] cohort ${cohortId} download failed`, e); + // Set. + if (cohort) { + this.storage.put(cohort); + changed = true; } } diff --git a/packages/node/src/local/cohort/updater.ts b/packages/node/src/local/cohort/updater.ts index cace6b4..fa48428 100644 --- a/packages/node/src/local/cohort/updater.ts +++ b/packages/node/src/local/cohort/updater.ts @@ -7,6 +7,7 @@ export interface CohortUpdater { * * @param onChange optional callback which will get called if the cohorts * in the storage have changed. + * @throws error if update failed. */ update( cohortIds: Set, diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index 3e8aee5..8399b11 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -66,7 +66,15 @@ export class FlagConfigPoller implements FlagConfigUpdater { // Fetch initial flag configs and await the result. await doWithBackoff(async () => { - await this.update(onChange); + try { + await this.update(onChange); + } catch (e) { + this.logger.error( + '[Experiment] flag config initial poll failed, stopping', + e, + ); + this.stop(); + } }, BACKOFF_POLICY); } } @@ -103,18 +111,11 @@ export class FlagConfigPoller implements FlagConfigUpdater { changed = true; } } - try { - await this.cohortUpdater?.update( - CohortUtils.extractCohortIds(flagConfigs), - ); - } catch { - this.logger.debug('[Experiment] cohort update failed'); - } finally { - await this.cache.clear(); - await this.cache.putAll(flagConfigs); - if (changed) { - await onChange(this.cache); - } + await this.cohortUpdater?.update(CohortUtils.extractCohortIds(flagConfigs)); // Throws error if cohort update failed. + await this.cache.clear(); + await this.cache.putAll(flagConfigs); + if (changed) { + await onChange(this.cache); } } } diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index 8326402..aa84106 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -229,10 +229,8 @@ export const AssignmentConfigDefaults: Omit = { cacheCapacity: 65536, }; -export const CohortConfigDefaults: Omit< - Omit, - 'secretKey' -> = { - cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', - maxCohortSize: 10_000_000, -}; +export const CohortConfigDefaults: Omit = + { + cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', + maxCohortSize: 10_000_000, + }; diff --git a/packages/node/src/types/user.ts b/packages/node/src/types/user.ts index 8415a4d..feb811b 100644 --- a/packages/node/src/types/user.ts +++ b/packages/node/src/types/user.ts @@ -113,11 +113,11 @@ export type ExperimentUser = { }; }; - cohort_ids: Set; + cohort_ids?: Array; - group_cohort_ids: { + group_cohort_ids?: { [groupType: string]: { - [groupName: string]: Set; + [groupName: string]: Array; }; }; }; From 33e709c00ba3fa3aefa5d21e68aa43ee167a8d0d Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 18 Jun 2024 15:23:45 -0700 Subject: [PATCH 11/48] storage update only after all cohort loads --- packages/node/src/local/cohort/poller.ts | 35 +++++++++++++++++------ packages/node/src/local/cohort/storage.ts | 5 ++-- packages/node/src/types/cohort.ts | 2 +- packages/node/src/util/backoff.ts | 20 ++++++++++++- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index 3d104ef..1a89302 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -1,8 +1,9 @@ -import { CohortStorage } from 'src/types/cohort'; +import { Cohort, CohortStorage } from 'src/types/cohort'; import { CohortConfigDefaults, LocalEvaluationDefaults, } from 'src/types/config'; +import { BackoffPolicy, doWithBackoffFailLoudly } from 'src/util/backoff'; import { ConsoleLogger } from '../../util/logger'; import { Logger } from '../../util/logger'; @@ -10,6 +11,13 @@ import { Logger } from '../../util/logger'; import { CohortFetcher } from './fetcher'; import { CohortUpdater } from './updater'; +const BACKOFF_POLICY: BackoffPolicy = { + attempts: 5, + min: 1, + max: 1, + scalar: 1, +}; + export class CohortPoller implements CohortUpdater { private readonly logger: Logger; @@ -34,29 +42,40 @@ export class CohortPoller implements CohortUpdater { onChange?: (storage: CohortStorage) => Promise, ): Promise { let changed = false; + const updatedCohorts: Record = {}; for (const cohortId of cohortIds) { this.logger.debug(`[Experiment] updating cohort ${cohortId}`); - // Get existing lastModified. + // Get existing cohort and lastModified. const existingCohort = this.storage.getCohort(cohortId); let lastModified = undefined; if (existingCohort) { lastModified = existingCohort.lastModified; + updatedCohorts[cohortId] = existingCohort; } // Download. - const cohort = await this.fetcher.fetch( - cohortId, - this.maxCohortSize, - lastModified, - ); + let cohort = undefined; + try { + cohort = await doWithBackoffFailLoudly(async () => { + return await this.fetcher.fetch( + cohortId, + this.maxCohortSize, + lastModified, + ); + }, BACKOFF_POLICY); + } catch (e) { + this.logger.error('[Experiment] cohort poll failed', e); + throw e; + } // Set. if (cohort) { - this.storage.put(cohort); + updatedCohorts[cohortId] = cohort; changed = true; } } + this.storage.replaceAll(updatedCohorts); 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 index 165689c..5b747ca 100644 --- a/packages/node/src/local/cohort/storage.ts +++ b/packages/node/src/local/cohort/storage.ts @@ -28,7 +28,8 @@ export class InMemoryCohortStorage implements CohortStorage { return validCohortIds; } - put(cohort: Cohort): void { - this.store[cohort.cohortId] = cohort; + replaceAll(cohorts: Record): void { + // Assignments are atomic. + this.store = { ...cohorts }; } } diff --git a/packages/node/src/types/cohort.ts b/packages/node/src/types/cohort.ts index 5fdbe57..801545b 100644 --- a/packages/node/src/types/cohort.ts +++ b/packages/node/src/types/cohort.ts @@ -6,7 +6,7 @@ export interface CohortStorage { groupName: string, cohortIds: Set, ): Set; - put(cohort: Cohort): void; + replaceAll(cohorts: Record): void; } export const USER_GROUP_TYPE = 'User'; 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 }; From a4b4102f6cf1e6a1a1a15723121dc1c47864e7f8 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 20 Jun 2024 16:47:19 -0700 Subject: [PATCH 12/48] added tests --- packages/node/src/local/client.ts | 4 +- packages/node/src/local/cohort/poller.ts | 4 +- packages/node/test/local/client.test.ts | 98 +++++++ .../node/test/local/cohort/cohortApi.test.ts | 87 ++++++ .../test/local/cohort/cohortFetcher.test.ts | 140 +++++++++ .../test/local/cohort/cohortPoller.test.ts | 272 ++++++++++++++++++ .../test/local/cohort/cohortStorage.test.ts | 180 ++++++++++++ .../node/test/local/util/cohortUtils.test.ts | 133 +++++++++ .../node/test/local/util/mockHttpClient.ts | 17 +- packages/node/test/util/user.test.ts | 27 ++ 10 files changed, 956 insertions(+), 6 deletions(-) create mode 100644 packages/node/test/local/cohort/cohortApi.test.ts create mode 100644 packages/node/test/local/cohort/cohortFetcher.test.ts create mode 100644 packages/node/test/local/cohort/cohortPoller.test.ts create mode 100644 packages/node/test/local/cohort/cohortStorage.test.ts create mode 100644 packages/node/test/local/util/cohortUtils.test.ts diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index dc5bd77..9cd6230 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -85,9 +85,9 @@ export class LocalEvaluationClient { ); this.logger = new ConsoleLogger(this.config.debug); + this.cohortStorage = new InMemoryCohortStorage(); let cohortUpdater = undefined; if (this.config.cohortConfig) { - this.cohortStorage = new InMemoryCohortStorage(); const cohortFetcher = new CohortFetcher( this.config.cohortConfig.apiKey, this.config.cohortConfig.secretKey, @@ -183,7 +183,7 @@ export class LocalEvaluationClient { return evaluationVariantsToVariants(results); } - private enrichUserWithCohorts( + protected enrichUserWithCohorts( user: ExperimentUser, flags: Record, ): void { diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index 1a89302..463f091 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -75,7 +75,9 @@ export class CohortPoller implements CohortUpdater { changed = true; } } - this.storage.replaceAll(updatedCohorts); + if (changed) { + this.storage.replaceAll(updatedCohorts); + } if (onChange && changed) { await onChange(this.storage); diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index c193701..7700d7b 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -1,4 +1,11 @@ +import { EvaluationFlag } from '@amplitude/experiment-core'; import { Experiment } from 'src/factory'; +import { InMemoryFlagConfigCache, LocalEvaluationClient } from 'src/index'; +import { USER_GROUP_TYPE } from 'src/types/cohort'; +import { + AssignmentConfigDefaults, + LocalEvaluationDefaults, +} from 'src/types/config'; import { ExperimentUser } from 'src/types/user'; const apiKey = 'server-qz35UwzJ5akieoAdIgzM4m9MIiOLXLoz'; @@ -147,3 +154,94 @@ test('ExperimentClient.evaluateV2 with dependencies, variant held out', async () await client.cache.get('sdk-ci-local-dependencies-test-holdout'), ).toBeDefined(); }); + +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.replaceAll({ + cohort1: { + cohortId: 'cohort1', + groupType: USER_GROUP_TYPE, + groupTypeId: 0, + lastComputed: 0, + lastModified: 0, + size: 1, + memberIds: new Set(['userId']), + }, + groupcohort1: { + 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..04a25c3 --- /dev/null +++ b/packages/node/test/local/cohort/cohortApi.test.ts @@ -0,0 +1,87 @@ +import { SdkCohortApi } from 'src/local/cohort/cohort-api'; +import { WrapperClient } from 'src/transport/http'; + +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(); +}); 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..14460cf --- /dev/null +++ b/packages/node/test/local/cohort/cohortFetcher.test.ts @@ -0,0 +1,140 @@ +import { CohortFetcher } from 'src/local/cohort/fetcher'; + +import { version as PACKAGE_VERSION } from '../../../gen/version'; +import { MockHttpClient } from '../util/mockHttpClient'; + +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 = `Basic ${Buffer.from(`${apiKey}:${secretKey}`).toString( + 'base64', +)}`; +const expectedHeaders = { + Authorization: encodedKey, + 'X-Amp-Exp-Library': `experiment-node-server/${PACKAGE_VERSION}`, +}; + +test('cohort fetcher 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 fetcher = new CohortFetcher( + apiKey, + secretKey, + httpClient, + serverUrl, + false, + ); + const cohort = await fetcher.fetch(cohortId, maxCohortSize); + expect(cohort).toStrictEqual(C_A); +}); + +test('cohort fetcher 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 fetcher = new CohortFetcher( + apiKey, + secretKey, + httpClient, + serverUrl, + false, + ); + await expect(fetcher.fetch(cohortId, maxCohortSize)).rejects.toThrow(); +}); + +test('cohort fetcher 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 fetcher = new CohortFetcher( + apiKey, + secretKey, + httpClient, + serverUrl, + false, + ); + expect( + await fetcher.fetch(cohortId, maxCohortSize, lastModified), + ).toBeUndefined(); +}); + +test('cohort fetcher 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 fetcher = new CohortFetcher( + apiKey, + secretKey, + httpClient, + serverUrl, + false, + ); + expect( + await fetcher.fetch(cohortId, maxCohortSize, lastModified), + ).toStrictEqual(C_A); +}); + +test('cohort fetcher 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 fetcher = new CohortFetcher( + apiKey, + secretKey, + httpClient, + serverUrl, + false, + ); + await expect( + fetcher.fetch(cohortId, maxCohortSize, lastModified), + ).rejects.toThrow(); +}); 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..7454595 --- /dev/null +++ b/packages/node/test/local/cohort/cohortPoller.test.ts @@ -0,0 +1,272 @@ +import { CohortFetcher } from 'src/local/cohort/fetcher'; +import { CohortPoller } from 'src/local/cohort/poller'; +import { InMemoryCohortStorage } from 'src/local/cohort/storage'; +import { CohortConfigDefaults } from 'src/types/config'; + +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('', async () => { + const fetcher = new CohortFetcher('', '', null); + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation( + async (cohortId: string) => COHORTS[cohortId], + ); + + const storage = new InMemoryCohortStorage(); + const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); + const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + + const cohortPoller = new CohortPoller(fetcher, storage); + + await cohortPoller.update(new Set(['c1', 'c2'])); + + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c1', + CohortConfigDefaults.maxCohortSize, + undefined, + ); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c2', + CohortConfigDefaults.maxCohortSize, + undefined, + ); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); + expect(storageReplaceAllSpy).toHaveBeenCalledWith({ + c1: COHORTS['c1'], + c2: COHORTS['c2'], + }); +}); + +test('cohort fetch all failed', async () => { + const fetcher = new CohortFetcher('', '', null); + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation(async () => { + throw Error(); + }); + + const storage = new InMemoryCohortStorage(); + const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); + const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); + + const cohortPoller = new CohortPoller(fetcher, storage); + + await expect( + cohortPoller.update(new Set(['c1', 'c2', 'c3'])), + ).rejects.toThrow(); + + expect(fetcherFetchSpy).toHaveBeenCalled(); + expect(storageGetCohortSpy).toHaveBeenCalled(); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); +}); + +test('cohort fetch partial failed', async () => { + const fetcher = new CohortFetcher('', '', null); + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation(async (cohortId: string) => { + if (cohortId === 'c3') { + throw Error(); + } + return COHORTS[cohortId]; + }); + + const storage = new InMemoryCohortStorage(); + const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); + const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + + const cohortPoller = new CohortPoller(fetcher, storage); + + await expect( + cohortPoller.update(new Set(['c1', 'c2', 'c3'])), + ).rejects.toThrow(); + + expect(fetcherFetchSpy).toHaveBeenCalled(); + expect(storageGetCohortSpy).toHaveBeenCalled(); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); +}); + +test('cohort fetch no change', async () => { + const fetcher = new CohortFetcher('', '', null); + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation(async () => undefined); + + const storage = new InMemoryCohortStorage(); + const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); + const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + + const cohortPoller = new CohortPoller(fetcher, storage); + + await cohortPoller.update(new Set(['c1', 'c2'])); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c1', + CohortConfigDefaults.maxCohortSize, + undefined, + ); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c2', + CohortConfigDefaults.maxCohortSize, + undefined, + ); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); +}); + +test('cohort fetch partial changed', async () => { + const fetcher = new CohortFetcher('', '', null); + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation(async (cohortId: string) => { + if (cohortId === 'c1') { + return undefined; + } + return COHORTS[cohortId]; + }); + + const storage = new InMemoryCohortStorage(); + const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); + const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + + const cohortPoller = new CohortPoller(fetcher, storage); + + await cohortPoller.update(new Set(['c1', 'c2'])); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c1', + CohortConfigDefaults.maxCohortSize, + undefined, + ); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c2', + CohortConfigDefaults.maxCohortSize, + undefined, + ); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(1); +}); + +test('cohort fetch using maxCohortSize', async () => { + const fetcher = new CohortFetcher('', '', null); + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation( + async (cohortId: string) => COHORTS[cohortId], + ); + + const storage = new InMemoryCohortStorage(); + const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); + const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + + const cohortPoller = new CohortPoller(fetcher, storage, 100); + + await cohortPoller.update(new Set(['c1', 'c2'])); + expect(fetcherFetchSpy).toHaveBeenCalledWith('c1', 100, undefined); + expect(fetcherFetchSpy).toHaveBeenCalledWith('c2', 100, undefined); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(1); +}); + +test('cohort fetch using lastModified', async () => { + const fetcher = new CohortFetcher('', '', null); + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + fetcherFetchSpy.mockImplementation( + async (cohortId: string, maxCohortSize: number, lastModified?) => { + if (lastModified === COHORTS[cohortId].lastModified) { + return undefined; + } + return COHORTS[cohortId]; + }, + ); + + const storage = new InMemoryCohortStorage(); + const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); + const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + + const cohortPoller = new CohortPoller(fetcher, storage); + + await cohortPoller.update(new Set(['c1', 'c2'])); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c1', + CohortConfigDefaults.maxCohortSize, + undefined, + ); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c2', + CohortConfigDefaults.maxCohortSize, + undefined, + ); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(1); + jest.clearAllMocks(); + + await cohortPoller.update(new Set(['c1', 'c2'])); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c1', + CohortConfigDefaults.maxCohortSize, + COHORTS['c1'].lastModified, + ); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c2', + CohortConfigDefaults.maxCohortSize, + COHORTS['c2'].lastModified, + ); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); + jest.clearAllMocks(); + + await cohortPoller.update(new Set(['c1', 'c2', 'c3'])); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c1', + CohortConfigDefaults.maxCohortSize, + COHORTS['c1'].lastModified, + ); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c2', + CohortConfigDefaults.maxCohortSize, + COHORTS['c2'].lastModified, + ); + expect(fetcherFetchSpy).toHaveBeenCalledWith( + 'c3', + CohortConfigDefaults.maxCohortSize, + undefined, + ); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c3'); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(1); + jest.clearAllMocks(); +}); 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..d336f89 --- /dev/null +++ b/packages/node/test/local/cohort/cohortStorage.test.ts @@ -0,0 +1,180 @@ +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 replaceAll and getCohort', async () => { + const storage = new InMemoryCohortStorage(); + storage.replaceAll({ + [C_A.cohortId]: C_A, + }); + expect(storage.getCohort(C_A.cohortId)).toBe(C_A); + expect(storage.getCohort(C_B.cohortId)).toBeUndefined(); + + 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()); + + storage.replaceAll({ + c_b: C_B, + }); + expect(storage.getCohort(C_A.cohortId)).toBeUndefined(); + expect(storage.getCohort(C_B.cohortId)).toBe(C_B); + + storage.replaceAll({ + [C_A.cohortId]: C_A, + [C_B.cohortId]: C_B, + [C_B2.cohortId]: C_B2, + }); + 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); +}); + +test('cohort storage getCohortsForGroup', async () => { + const storage = new InMemoryCohortStorage(); + storage.replaceAll({ + [C_A.cohortId]: C_A, + [C_B.cohortId]: C_B, + [C_B2.cohortId]: 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.replaceAll({ + [C_U1.cohortId]: C_U1, + [C_U2.cohortId]: C_U2, + [C_U3.cohortId]: 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/util/cohortUtils.test.ts b/packages/node/test/local/util/cohortUtils.test.ts new file mode 100644 index 0000000..9b18e03 --- /dev/null +++ b/packages/node/test/local/util/cohortUtils.test.ts @@ -0,0 +1,133 @@ +import { assert } from 'console'; + +import { EvaluationFlag } from '@amplitude/experiment-core'; +import { CohortUtils } from 'src/util/cohort'; + +test('test extract cohortIds from flags', async () => { + // Flag definition is not complete, only those useful for thest is included. + const rawFlags = [ + { + key: 'flag1', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['hahahaha1'], + }, + ], + ], + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: {}, + }, + { + key: 'flag2', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['hahahaha2'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + variant: 'off', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: {}, + }, + { + 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: ['hahahaha3'], + }, + ], + ], + variant: 'off', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: {}, + }, + { + key: 'flag5', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['hahahaha3', 'hahahaha4'], + }, + ], + ], + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'groups', 'org name', 'cohort_ids'], + values: ['hahaorgname1'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + }, + ], + variants: {}, + }, + ]; + + const flags: Record = {}; + for (const f of rawFlags) { + flags[f.key] = f; + } + const expected: Record> = { + User: new Set(['hahahaha1', 'hahahaha2', 'hahahaha3', 'hahahaha4']), + 'org name': new Set(['hahaorgname1']), + }; + const actual = CohortUtils.extractCohortIdsByGroup(flags); + expect(actual).toStrictEqual(expected); +}); 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/user.test.ts b/packages/node/test/util/user.test.ts index e7bc7cb..496cecc 100644 --- a/packages/node/test/util/user.test.ts +++ b/packages/node/test/util/user.test.ts @@ -1,3 +1,4 @@ +import { USER_GROUP_TYPE } from 'src/types/cohort'; import { ExperimentUser } from 'src/types/user'; import { convertUserToEvaluationContext } from 'src/util/user'; @@ -88,4 +89,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'], + }, + }, + }); + }); }); From dd36ceee4705c62a4933686b8f7d24b54688f6e1 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 25 Jun 2024 10:13:25 -0700 Subject: [PATCH 13/48] added tests --- packages/node/src/local/cohort/poller.ts | 6 +- .../test/local/cohort/cohortPoller.test.ts | 29 ++ .../node/test/local/flagConfigPoller.test.ts | 314 ++++++++++++++++++ .../node/test/local/util/cohortUtils.test.ts | 55 ++- 4 files changed, 388 insertions(+), 16 deletions(-) create mode 100644 packages/node/test/local/flagConfigPoller.test.ts diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index 463f091..9292305 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -12,9 +12,9 @@ import { CohortFetcher } from './fetcher'; import { CohortUpdater } from './updater'; const BACKOFF_POLICY: BackoffPolicy = { - attempts: 5, - min: 1, - max: 1, + attempts: 3, + min: 1000, + max: 1000, scalar: 1, }; diff --git a/packages/node/test/local/cohort/cohortPoller.test.ts b/packages/node/test/local/cohort/cohortPoller.test.ts index 7454595..a807ffb 100644 --- a/packages/node/test/local/cohort/cohortPoller.test.ts +++ b/packages/node/test/local/cohort/cohortPoller.test.ts @@ -270,3 +270,32 @@ test('cohort fetch using lastModified', async () => { expect(storageReplaceAllSpy).toHaveBeenCalledTimes(1); jest.clearAllMocks(); }); + +test('cohort fetch fails 4 times success 5th', async () => { + const fetcher = new CohortFetcher('', '', null); + const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); + let tries = 0; + fetcherFetchSpy.mockImplementation(async (cohortId: string) => { + if (++tries === 5) { + return COHORTS[cohortId]; + } + throw Error(); + }); + + const storage = new InMemoryCohortStorage(); + const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); + const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); + + const cohortPoller = new CohortPoller(fetcher, storage); + + const start = new Date().getTime(); + await cohortPoller.update(new Set(['c1'])); + expect(new Date().getTime() - start).toBeGreaterThan(4000); + + expect(fetcherFetchSpy).toHaveBeenCalledTimes(5); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(1); + expect(storageReplaceAllSpy).toHaveBeenCalledWith({ + c1: COHORTS['c1'], + }); +}); diff --git a/packages/node/test/local/flagConfigPoller.test.ts b/packages/node/test/local/flagConfigPoller.test.ts new file mode 100644 index 0000000..2eb7ff7 --- /dev/null +++ b/packages/node/test/local/flagConfigPoller.test.ts @@ -0,0 +1,314 @@ +import { + FlagConfigFetcher, + FlagConfigPoller, + InMemoryFlagConfigCache, +} from 'src/index'; +import { CohortFetcher } from 'src/local/cohort/fetcher'; +import { CohortPoller } from 'src/local/cohort/poller'; +import { InMemoryCohortStorage } from 'src/local/cohort/storage'; + +import { MockHttpClient } from './util/mockHttpClient'; + +const FLAG = [ + { + key: 'flag1', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['hahahaha1'], + }, + ], + ], + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: {}, + }, + { + key: 'flag2', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['hahahaha2'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + variant: 'off', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: {}, + }, + { + 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: ['hahahaha3'], + }, + ], + ], + variant: 'off', + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cocoids'], + values: ['nohaha'], + }, + ], + ], + variant: 'off', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: {}, + }, + { + key: 'flag5', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['hahahaha3', 'hahahaha4'], + }, + ], + ], + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'groups', 'org name', 'cohort_ids'], + values: ['hahaorgname1'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'gg', 'org name', 'cohort_ids'], + values: ['nohahaorgname'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + }, + ], + variants: {}, + }, +].reduce((acc, flag) => { + acc[flag.key] = flag; + return acc; +}, {}); + +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(), + 2000, + new CohortPoller( + new CohortFetcher( + 'apikey', + 'secretkey', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + cohortStorage, + ), + ); + let flagPolled = 0; + // Return FLAG for flag polls. + jest + .spyOn(FlagConfigFetcher.prototype, 'fetch') + .mockImplementation(async () => { + return { ...FLAG, flagPolled: { key: flagPolled++ } }; + }); + // Return cohort with their own cohortId. + jest + .spyOn(CohortFetcher.prototype, 'fetch') + .mockImplementation(async (cohortId) => { + return { + cohortId: 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({ + ...FLAG, + flagPolled: { key: 0 }, + }); + expect(cohortStorage.getCohort('hahahaha1').cohortId).toBe('hahahaha1'); + expect(cohortStorage.getCohort('hahahaha2').cohortId).toBe('hahahaha2'); + expect(cohortStorage.getCohort('hahahaha3').cohortId).toBe('hahahaha3'); + expect(cohortStorage.getCohort('hahahaha4').cohortId).toBe('hahahaha4'); + expect(cohortStorage.getCohort('hahaorgname1').cohortId).toBe('hahaorgname1'); + expect(cohortStorage.getCohort('hahahaha1').lastModified).toBe(1); + expect(cohortStorage.getCohort('hahahaha2').lastModified).toBe(1); + expect(cohortStorage.getCohort('hahahaha3').lastModified).toBe(1); + expect(cohortStorage.getCohort('hahahaha4').lastModified).toBe(1); + expect(cohortStorage.getCohort('hahaorgname1').lastModified).toBe(1); + + // On update, flag and cohort should both be updated. + await new Promise((f) => setTimeout(f, 2000)); + expect(flagPolled).toBe(2); + expect(await poller.cache.getAll()).toStrictEqual({ + ...FLAG, + flagPolled: { key: 1 }, + }); + expect(cohortStorage.getCohort('hahahaha1').cohortId).toBe('hahahaha1'); + expect(cohortStorage.getCohort('hahahaha2').cohortId).toBe('hahahaha2'); + expect(cohortStorage.getCohort('hahahaha3').cohortId).toBe('hahahaha3'); + expect(cohortStorage.getCohort('hahahaha4').cohortId).toBe('hahahaha4'); + expect(cohortStorage.getCohort('hahaorgname1').cohortId).toBe('hahaorgname1'); + expect(cohortStorage.getCohort('hahahaha1').lastModified).toBe(2); + expect(cohortStorage.getCohort('hahahaha2').lastModified).toBe(2); + expect(cohortStorage.getCohort('hahahaha3').lastModified).toBe(2); + expect(cohortStorage.getCohort('hahahaha4').lastModified).toBe(2); + expect(cohortStorage.getCohort('hahaorgname1').lastModified).toBe(2); + poller.stop(); +}); + +test('flagConfig poller initial error', async () => { + const poller = new FlagConfigPoller( + new FlagConfigFetcher( + 'key', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + new InMemoryFlagConfigCache(), + 10, + new CohortPoller( + new CohortFetcher( + 'apikey', + 'secretkey', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + new InMemoryCohortStorage(), + ), + ); + // Fetch returns FLAG, but cohort fails. + jest + .spyOn(FlagConfigFetcher.prototype, 'fetch') + .mockImplementation(async () => { + return FLAG; + }); + jest.spyOn(CohortPoller.prototype, 'update').mockImplementation(async () => { + throw new Error(); + }); + // FLAG should be empty, as cohort failed. Poller should be stopped immediately and test exists cleanly. + await poller.start(); + expect(await poller.cache.getAll()).toStrictEqual({}); +}); + +test('flagConfig poller initial success, polling error and use old flags', async () => { + const poller = new FlagConfigPoller( + new FlagConfigFetcher( + 'key', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + new InMemoryFlagConfigCache(), + 2000, + new CohortPoller( + new CohortFetcher( + 'apikey', + 'secretkey', + new MockHttpClient(async () => ({ status: 200, body: '' })), + ), + new InMemoryCohortStorage(), + ), + ); + + // Only return the flag on first poll, return a different one on future polls where cohort would fail. + let cohortPolled = 0; + jest + .spyOn(FlagConfigFetcher.prototype, 'fetch') + .mockImplementation(async () => { + if (cohortPolled === 0) return FLAG; + return {}; + }); + // Only success on first poll and fail on all later ones. + jest.spyOn(CohortPoller.prototype, 'update').mockImplementation(async () => { + if (cohortPolled++ === 0) return; + throw new Error(); + }); + + // First poll should return FLAG. + await poller.start(); + expect(await poller.cache.getAll()).toStrictEqual(FLAG); + expect(cohortPolled).toBe(1); + + // Second poll should fail. The different flag should not be updated. + await new Promise((f) => setTimeout(f, 2000)); + expect(cohortPolled).toBe(2); + expect(await poller.cache.getAll()).toStrictEqual(FLAG); + + poller.stop(); +}); diff --git a/packages/node/test/local/util/cohortUtils.test.ts b/packages/node/test/local/util/cohortUtils.test.ts index 9b18e03..1c931b2 100644 --- a/packages/node/test/local/util/cohortUtils.test.ts +++ b/packages/node/test/local/util/cohortUtils.test.ts @@ -1,11 +1,8 @@ -import { assert } from 'console'; - -import { EvaluationFlag } from '@amplitude/experiment-core'; import { CohortUtils } from 'src/util/cohort'; test('test extract cohortIds from flags', async () => { // Flag definition is not complete, only those useful for thest is included. - const rawFlags = [ + const flags = [ { key: 'flag1', segments: [ @@ -78,6 +75,18 @@ test('test extract cohortIds from flags', async () => { ], variant: 'off', }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cocoids'], + values: ['nohaha'], + }, + ], + ], + variant: 'off', + }, { metadata: { segmentName: 'All Other Users', @@ -115,19 +124,39 @@ test('test extract cohortIds from flags', async () => { segmentName: 'Segment 1', }, }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'gg', 'org name', 'cohort_ids'], + values: ['nohahaorgname'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + }, ], variants: {}, }, - ]; + ].reduce((acc, flag) => { + acc[flag.key] = flag; + return acc; + }, {}); - const flags: Record = {}; - for (const f of rawFlags) { - flags[f.key] = f; - } - const expected: Record> = { + expect(CohortUtils.extractCohortIdsByGroup(flags)).toStrictEqual({ User: new Set(['hahahaha1', 'hahahaha2', 'hahahaha3', 'hahahaha4']), 'org name': new Set(['hahaorgname1']), - }; - const actual = CohortUtils.extractCohortIdsByGroup(flags); - expect(actual).toStrictEqual(expected); + }); + expect(CohortUtils.extractCohortIds(flags)).toStrictEqual( + new Set([ + 'hahahaha1', + 'hahahaha2', + 'hahahaha3', + 'hahahaha4', + 'hahaorgname1', + ]), + ); }); From 9b0da108a16186836c6e8546ee8282b3fcad6091 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 25 Jun 2024 11:57:34 -0700 Subject: [PATCH 14/48] add ci test with secrets from env --- .github/workflows/test.yml | 4 ++++ packages/node/src/local/cohort/poller.ts | 5 +---- packages/node/test/local/client.test.ts | 28 +++++++++++++++++++----- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c18ec28..a205964 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,7 @@ on: jobs: test: + environment: ci strategy: fail-fast: false matrix: @@ -38,3 +39,6 @@ jobs: - name: Test run: yarn test --testPathIgnorePatterns "benchmark.test.ts" + env: + API_KEY: ${{ secrets.API_KEY }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index 9292305..bcae428 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -1,8 +1,5 @@ import { Cohort, CohortStorage } from 'src/types/cohort'; -import { - CohortConfigDefaults, - LocalEvaluationDefaults, -} from 'src/types/config'; +import { CohortConfigDefaults } from 'src/types/config'; import { BackoffPolicy, doWithBackoffFailLoudly } from 'src/util/backoff'; import { ConsoleLogger } from '../../util/logger'; diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index 7700d7b..2b37d80 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -2,17 +2,22 @@ import { EvaluationFlag } from '@amplitude/experiment-core'; import { Experiment } from 'src/factory'; import { InMemoryFlagConfigCache, LocalEvaluationClient } from 'src/index'; import { USER_GROUP_TYPE } from 'src/types/cohort'; -import { - AssignmentConfigDefaults, - LocalEvaluationDefaults, -} from 'src/types/config'; +import { LocalEvaluationDefaults } from 'src/types/config'; import { ExperimentUser } from 'src/types/user'; const apiKey = 'server-qz35UwzJ5akieoAdIgzM4m9MIiOLXLoz'; const testUser: ExperimentUser = { user_id: 'test_user' }; -const client = Experiment.initializeLocal(apiKey); +const cohortConfig = process.env['API_KEY'] + ? { + apiKey: process.env['API_KEY'], + secretKey: process.env['SECRET_KEY'], + } + : undefined; +const client = Experiment.initializeLocal(apiKey, { + cohortConfig: cohortConfig, +}); beforeAll(async () => { await client.start(); @@ -155,6 +160,19 @@ test('ExperimentClient.evaluateV2 with dependencies, variant held out', async () ).toBeDefined(); }); +test('ExperimentClient.evaluateV2 with cohort', 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(); +}); + class TestLocalEvaluationClient extends LocalEvaluationClient { public enrichUserWithCohorts( user: ExperimentUser, From dfe05171f7a091415918fb0880ca3fecb629bfe7 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 25 Jun 2024 12:04:12 -0700 Subject: [PATCH 15/48] fix cohortPoller.test.ts --- packages/node/test/local/cohort/cohortPoller.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/node/test/local/cohort/cohortPoller.test.ts b/packages/node/test/local/cohort/cohortPoller.test.ts index a807ffb..7e6c370 100644 --- a/packages/node/test/local/cohort/cohortPoller.test.ts +++ b/packages/node/test/local/cohort/cohortPoller.test.ts @@ -271,12 +271,12 @@ test('cohort fetch using lastModified', async () => { jest.clearAllMocks(); }); -test('cohort fetch fails 4 times success 5th', async () => { +test('cohort fetch fails 2 times success 3rd', async () => { const fetcher = new CohortFetcher('', '', null); const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); let tries = 0; fetcherFetchSpy.mockImplementation(async (cohortId: string) => { - if (++tries === 5) { + if (++tries === 3) { return COHORTS[cohortId]; } throw Error(); @@ -291,9 +291,9 @@ test('cohort fetch fails 4 times success 5th', async () => { const start = new Date().getTime(); await cohortPoller.update(new Set(['c1'])); - expect(new Date().getTime() - start).toBeGreaterThan(4000); + expect(new Date().getTime() - start).toBeGreaterThanOrEqual(2000); - expect(fetcherFetchSpy).toHaveBeenCalledTimes(5); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(3); expect(storageGetCohortSpy).toHaveBeenCalledTimes(1); expect(storageReplaceAllSpy).toHaveBeenCalledWith({ c1: COHORTS['c1'], From 8a06cd6eb2807aecd161e72224e021046bed953a Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 25 Jun 2024 12:07:04 -0700 Subject: [PATCH 16/48] not use environment --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a205964..3e7170d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,6 @@ on: jobs: test: - environment: ci strategy: fail-fast: false matrix: From 992543e26184bd2cc4aea7cf499138c5401a0ccc Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 25 Jun 2024 15:23:34 -0700 Subject: [PATCH 17/48] added .env instr, added tests --- packages/node/.gitignore | 1 + packages/node/README.md | 5 ++ packages/node/src/local/client.ts | 2 +- packages/node/src/local/poller.ts | 8 ++- packages/node/src/util/logger.ts | 4 ++ packages/node/test/local/client.test.ts | 77 ++++++++++++++++++++++++- 6 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 packages/node/README.md 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..5f5bb55 --- /dev/null +++ b/packages/node/README.md @@ -0,0 +1,5 @@ +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 9cd6230..3a149df 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -204,7 +204,7 @@ export class LocalEvaluationClient { if (groupNames.length == 0) { continue; } - const groupName = groupNames[0]; // TODO: Only get the first one? + const groupName = groupNames[0]; const cohortIds = cohortIdsByGroup[groupType]; if (!cohortIds || cohortIds.size == 0) { diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index 8399b11..9f6778b 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -111,7 +111,13 @@ export class FlagConfigPoller implements FlagConfigUpdater { changed = true; } } - await this.cohortUpdater?.update(CohortUtils.extractCohortIds(flagConfigs)); // Throws error if cohort update failed. + const cohortIds = CohortUtils.extractCohortIds(flagConfigs); + if (cohortIds && cohortIds.size > 0 && !this.cohortUpdater) { + this.logger.warn( + '[Experiment] cohort found in flag configs but no cohort download configured', + ); + } + await this.cohortUpdater?.update(cohortIds); // Throws error if cohort update failed. await this.cache.clear(); await this.cache.putAll(flagConfigs); if (changed) { 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/test/local/client.test.ts b/packages/node/test/local/client.test.ts index 2b37d80..4fffbb1 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -1,14 +1,25 @@ +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'; +dotenv.config({ path: path.join(__dirname, '../../', '.env') }); + const apiKey = 'server-qz35UwzJ5akieoAdIgzM4m9MIiOLXLoz'; const testUser: ExperimentUser = { user_id: 'test_user' }; +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 cohortConfig = process.env['API_KEY'] ? { apiKey: process.env['API_KEY'], @@ -160,7 +171,26 @@ test('ExperimentClient.evaluateV2 with dependencies, variant held out', async () ).toBeDefined(); }); -test('ExperimentClient.evaluateV2 with cohort', async () => { +test('ExperimentClient.evaluateV2 with user or group cohort not targeted', async () => { + const variants = await client.evaluateV2({ + user_id: '2333', + device_id: 'device_id', + }); + 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 user cohort segment targeted', async () => { const variants = await client.evaluateV2({ user_id: '12345', device_id: 'device_id', @@ -173,6 +203,51 @@ test('ExperimentClient.evaluateV2 with cohort', async () => { ).toBeDefined(); }); +test('ExperimentClient.evaluateV2 with user cohort tester targeted', async () => { + const variants = await client.evaluateV2({ + user_id: '1', + 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(); +}); + +test('ExperimentClient.evaluateV2 with group cohort segment targeted', async () => { + const variants = await client.evaluateV2({ + user_id: '12345', + device_id: 'device_id', + 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 test 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(); +}); + class TestLocalEvaluationClient extends LocalEvaluationClient { public enrichUserWithCohorts( user: ExperimentUser, From 113b74780d2c45e79a345b1b2b98ef33ef292010 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 25 Jun 2024 15:23:51 -0700 Subject: [PATCH 18/48] cleanup unnecessary check --- packages/node/test/local/client.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index 4fffbb1..eb1928d 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -20,12 +20,10 @@ if (!process.env['API_KEY'] && !process.env['SECRET_KEY']) { ); } -const cohortConfig = process.env['API_KEY'] - ? { - apiKey: process.env['API_KEY'], - secretKey: process.env['SECRET_KEY'], - } - : undefined; +const cohortConfig = { + apiKey: process.env['API_KEY'], + secretKey: process.env['SECRET_KEY'], +}; const client = Experiment.initializeLocal(apiKey, { cohortConfig: cohortConfig, }); From e6a263ef018acfdcf066cc67a028cd3746b92cc6 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 25 Jun 2024 15:35:46 -0700 Subject: [PATCH 19/48] lint --- packages/node/README.md | 4 +++- packages/node/src/transport/stream.ts | 6 +++--- packages/node/test/util/user.test.ts | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index 5f5bb55..230893d 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -1,4 +1,6 @@ -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: +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/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/test/util/user.test.ts b/packages/node/test/util/user.test.ts index 496cecc..7bc1f8d 100644 --- a/packages/node/test/util/user.test.ts +++ b/packages/node/test/util/user.test.ts @@ -1,4 +1,3 @@ -import { USER_GROUP_TYPE } from 'src/types/cohort'; import { ExperimentUser } from 'src/types/user'; import { convertUserToEvaluationContext } from 'src/util/user'; From 62b34a592434cca5b6d0267b144ff22aefe6795a Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 25 Jun 2024 15:45:41 -0700 Subject: [PATCH 20/48] fix test node version matrix --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e7170d..9f0d0ea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,9 @@ jobs: matrix: node-version: ['14', '16', '18'] os: [macos-latest, ubuntu-latest] + exclude: # no node14 darwin arm64 https://github.com/actions/node-versions/blob/main/versions-manifest.json + - os: macos-latest + node-version: 14 runs-on: ${{ matrix.os }} steps: From 755351653d48a339bf0a4466300e6d7c38e6ae1d Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 27 Jun 2024 10:17:16 -0700 Subject: [PATCH 21/48] polish test and add macos-13 --- .github/workflows/test.yml | 4 ++-- packages/node/test/local/client.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f0d0ea..92703fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,8 +12,8 @@ jobs: fail-fast: false matrix: node-version: ['14', '16', '18'] - os: [macos-latest, ubuntu-latest] - exclude: # no node14 darwin arm64 https://github.com/actions/node-versions/blob/main/versions-manifest.json + os: [macos-latest, macos-13, ubuntu-latest] + exclude: # macos-latest is m1, no node14 darwin arm64 https://github.com/actions/node-versions/blob/main/versions-manifest.json - os: macos-latest node-version: 14 runs-on: ${{ matrix.os }} diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index eb1928d..e839c59 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -173,6 +173,9 @@ 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'); @@ -230,7 +233,7 @@ test('ExperimentClient.evaluateV2 with group cohort segment targeted', async () ).toBeDefined(); }); -test('ExperimentClient.evaluateV2 with group cohort test targeted', async () => { +test('ExperimentClient.evaluateV2 with group cohort tester targeted', async () => { const variants = await client.evaluateV2({ user_id: '2333', device_id: 'device_id', From f7ddd2322cec46543572f37e33711a2a36613a44 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 27 Jun 2024 11:02:12 -0700 Subject: [PATCH 22/48] updated gh action node versions to current lts's --- .github/workflows/test.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92703fa..4663032 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,11 +11,8 @@ jobs: strategy: fail-fast: false matrix: - node-version: ['14', '16', '18'] - os: [macos-latest, macos-13, ubuntu-latest] - exclude: # macos-latest is m1, no node14 darwin arm64 https://github.com/actions/node-versions/blob/main/versions-manifest.json - - os: macos-latest - node-version: 14 + node-version: ['16', '18', '20', '22', '24'] + os: [macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} steps: From b36b302607a51c9bd53bb0b79bfcff58230a1571 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 27 Jun 2024 11:05:17 -0700 Subject: [PATCH 23/48] remove unsupported node v24 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4663032..f29a0fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: ['16', '18', '20', '22', '24'] + node-version: ['16', '18', '20', '22'] os: [macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} From 50dd195419688d96442157a6226499a20416f2db Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 27 Jun 2024 11:56:35 -0700 Subject: [PATCH 24/48] added serverZone config option --- packages/node/src/local/client.ts | 6 +-- packages/node/src/remote/client.ts | 4 +- packages/node/src/types/config.ts | 68 +++++++++++++++++++++-- packages/node/test/local/client.test.ts | 72 ++++++++++++++++++++++++- 4 files changed, 141 insertions(+), 9 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 3a149df..6385a79 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -17,7 +17,7 @@ import { AssignmentConfig, AssignmentConfigDefaults, LocalEvaluationConfig, - LocalEvaluationDefaults, + populateLocalConfigDefaults, } from '../types/config'; import { FlagConfigCache } from '../types/flag'; import { HttpClient } from '../types/transport'; @@ -51,7 +51,7 @@ const STREAM_TRY_DELAY_MILLIS = 1000; // The delay between attempts. */ 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; @@ -72,7 +72,7 @@ export class LocalEvaluationClient { streamEventSourceFactory: StreamEventSourceFactory = (url, params) => new EventSource(url, params), ) { - this.config = { ...LocalEvaluationDefaults, ...config }; + this.config = populateLocalConfigDefaults(config); const fetcher = new FlagConfigFetcher( apiKey, httpClient, diff --git a/packages/node/src/remote/client.ts b/packages/node/src/remote/client.ts index 0a6a175..3988417 100644 --- a/packages/node/src/remote/client.ts +++ b/packages/node/src/remote/client.ts @@ -8,8 +8,8 @@ import { version as PACKAGE_VERSION } from '../../gen/version'; import { FetchHttpClient, WrapperClient } from '../transport/http'; import { ExperimentConfig, - RemoteEvaluationDefaults, RemoteEvaluationConfig, + populateRemoteConfigDefaults, } from '../types/config'; import { FetchOptions } from '../types/fetch'; import { ExperimentUser } from '../types/user'; @@ -37,7 +37,7 @@ export class RemoteEvaluationClient { */ 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/types/config.ts b/packages/node/src/types/config.ts index aa84106..e0b2ce0 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?: string; + + /** + * 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; @@ -191,7 +205,8 @@ export type CohortConfig = { secretKey: string; /** - * The cohort server endpoint from which to fetch cohort data. + * 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; @@ -216,6 +231,7 @@ export type CohortConfig = { */ export const LocalEvaluationDefaults: LocalEvaluationConfig = { debug: false, + serverZone: 'us', serverUrl: 'https://api.lab.amplitude.com', bootstrap: {}, flagConfigPollingIntervalMillis: 30000, @@ -234,3 +250,49 @@ export const CohortConfigDefaults: Omit = cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', maxCohortSize: 10_000_000, }; + +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', +}; + +export const populateRemoteConfigDefaults = ( + customConfig: RemoteEvaluationConfig, +): RemoteEvaluationConfig => { + const config = { ...RemoteEvaluationDefaults, ...customConfig }; + const isEu = config.serverZone.toLowerCase() === EU_SERVER_URLS.name; + + 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; + + 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.cohortConfig && !customConfig.cohortConfig.cohortServerUrl) { + config.cohortConfig.cohortServerUrl = isEu + ? EU_SERVER_URLS.cohort + : CohortConfigDefaults.cohortServerUrl; + } + return config; +}; diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index e839c59..dd6dfee 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -5,7 +5,11 @@ 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 { + CohortConfigDefaults, + EU_SERVER_URLS, + LocalEvaluationDefaults, +} from 'src/types/config'; import { ExperimentUser } from 'src/types/user'; dotenv.config({ path: path.join(__dirname, '../../', '.env') }); @@ -249,7 +253,11 @@ test('ExperimentClient.evaluateV2 with group cohort tester targeted', async () = ).toBeDefined(); }); +// Unit tests class TestLocalEvaluationClient extends LocalEvaluationClient { + public getConfig() { + return this.config; + } public enrichUserWithCohorts( user: ExperimentUser, flags: Record, @@ -258,6 +266,68 @@ class TestLocalEvaluationClient extends LocalEvaluationClient { } } +test('LocalEvaluationClient config, default to US server urls', async () => { + const client = new TestLocalEvaluationClient(apiKey, { + cohortConfig: { apiKey: '', secretKey: '' }, + }); + expect(client.getConfig().serverZone).toBe('us'); + expect(client.getConfig().serverUrl).toBe(LocalEvaluationDefaults.serverUrl); + expect(client.getConfig().streamServerUrl).toBe( + LocalEvaluationDefaults.streamServerUrl, + ); + expect(client.getConfig().cohortConfig.cohortServerUrl).toBe( + CohortConfigDefaults.cohortServerUrl, + ); +}); +test('LocalEvaluationClient config, EU server zone sets to EU server urls', async () => { + const client = new TestLocalEvaluationClient(apiKey, { + serverZone: 'EU', + cohortConfig: { apiKey: '', secretKey: '' }, + }); + expect(client.getConfig().serverZone).toBe('EU'); + expect(client.getConfig().serverUrl).toBe(EU_SERVER_URLS.flags); + expect(client.getConfig().streamServerUrl).toBe(EU_SERVER_URLS.stream); + expect(client.getConfig().cohortConfig.cohortServerUrl).toBe( + EU_SERVER_URLS.cohort, + ); +}); +test('LocalEvaluationClient config, US server zone but serverUrl overrides', async () => { + const client = new TestLocalEvaluationClient(apiKey, { + serverUrl: 'urlurl', + streamServerUrl: 'streamurl', + cohortConfig: { apiKey: '', secretKey: '', cohortServerUrl: 'cohorturl' }, + }); + expect(client.getConfig().serverZone).toBe('us'); + expect(client.getConfig().serverUrl).toBe('urlurl'); + expect(client.getConfig().streamServerUrl).toBe('streamurl'); + expect(client.getConfig().cohortConfig.cohortServerUrl).toBe('cohorturl'); +}); +test('LocalEvaluationClient config, EU server zone but serverUrl overrides', async () => { + const client = new TestLocalEvaluationClient(apiKey, { + serverZone: 'eu', + serverUrl: 'urlurl', + streamServerUrl: 'streamurl', + cohortConfig: { apiKey: '', secretKey: '', cohortServerUrl: 'cohorturl' }, + }); + expect(client.getConfig().serverZone).toBe('eu'); + expect(client.getConfig().serverUrl).toBe('urlurl'); + expect(client.getConfig().streamServerUrl).toBe('streamurl'); + expect(client.getConfig().cohortConfig.cohortServerUrl).toBe('cohorturl'); +}); +test('LocalEvaluationClient config, EU server zone but partial serverUrl overrides', async () => { + const client = new TestLocalEvaluationClient(apiKey, { + serverZone: 'eu', + serverUrl: 'urlurl', + cohortConfig: { apiKey: '', secretKey: '' }, + }); + expect(client.getConfig().serverZone).toBe('eu'); + expect(client.getConfig().serverUrl).toBe('urlurl'); + expect(client.getConfig().streamServerUrl).toBe(EU_SERVER_URLS.stream); + expect(client.getConfig().cohortConfig.cohortServerUrl).toBe( + EU_SERVER_URLS.cohort, + ); +}); + test('ExperimentClient.enrichUserWithCohorts', async () => { const client = new TestLocalEvaluationClient( apiKey, From cc611c347948f54ede8fc4936e5be553cddf18dd Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 27 Jun 2024 12:51:05 -0700 Subject: [PATCH 25/48] parameterize test --- packages/node/test/local/client.test.ts | 111 +++++++++++------------- 1 file changed, 51 insertions(+), 60 deletions(-) diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index dd6dfee..1e6f0eb 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -8,6 +8,7 @@ import { USER_GROUP_TYPE } from 'src/types/cohort'; import { CohortConfigDefaults, EU_SERVER_URLS, + LocalEvaluationConfig, LocalEvaluationDefaults, } from 'src/types/config'; import { ExperimentUser } from 'src/types/user'; @@ -266,66 +267,56 @@ class TestLocalEvaluationClient extends LocalEvaluationClient { } } -test('LocalEvaluationClient config, default to US server urls', async () => { - const client = new TestLocalEvaluationClient(apiKey, { - cohortConfig: { apiKey: '', secretKey: '' }, - }); - expect(client.getConfig().serverZone).toBe('us'); - expect(client.getConfig().serverUrl).toBe(LocalEvaluationDefaults.serverUrl); - expect(client.getConfig().streamServerUrl).toBe( - LocalEvaluationDefaults.streamServerUrl, - ); - expect(client.getConfig().cohortConfig.cohortServerUrl).toBe( - CohortConfigDefaults.cohortServerUrl, - ); -}); -test('LocalEvaluationClient config, EU server zone sets to EU server urls', async () => { - const client = new TestLocalEvaluationClient(apiKey, { - serverZone: 'EU', - cohortConfig: { apiKey: '', secretKey: '' }, - }); - expect(client.getConfig().serverZone).toBe('EU'); - expect(client.getConfig().serverUrl).toBe(EU_SERVER_URLS.flags); - expect(client.getConfig().streamServerUrl).toBe(EU_SERVER_URLS.stream); - expect(client.getConfig().cohortConfig.cohortServerUrl).toBe( - EU_SERVER_URLS.cohort, - ); -}); -test('LocalEvaluationClient config, US server zone but serverUrl overrides', async () => { - const client = new TestLocalEvaluationClient(apiKey, { - serverUrl: 'urlurl', - streamServerUrl: 'streamurl', - cohortConfig: { apiKey: '', secretKey: '', cohortServerUrl: 'cohorturl' }, - }); - expect(client.getConfig().serverZone).toBe('us'); - expect(client.getConfig().serverUrl).toBe('urlurl'); - expect(client.getConfig().streamServerUrl).toBe('streamurl'); - expect(client.getConfig().cohortConfig.cohortServerUrl).toBe('cohorturl'); -}); -test('LocalEvaluationClient config, EU server zone but serverUrl overrides', async () => { - const client = new TestLocalEvaluationClient(apiKey, { - serverZone: 'eu', - serverUrl: 'urlurl', - streamServerUrl: 'streamurl', - cohortConfig: { apiKey: '', secretKey: '', cohortServerUrl: 'cohorturl' }, - }); - expect(client.getConfig().serverZone).toBe('eu'); - expect(client.getConfig().serverUrl).toBe('urlurl'); - expect(client.getConfig().streamServerUrl).toBe('streamurl'); - expect(client.getConfig().cohortConfig.cohortServerUrl).toBe('cohorturl'); -}); -test('LocalEvaluationClient config, EU server zone but partial serverUrl overrides', async () => { - const client = new TestLocalEvaluationClient(apiKey, { - serverZone: 'eu', - serverUrl: 'urlurl', - cohortConfig: { apiKey: '', secretKey: '' }, - }); - expect(client.getConfig().serverZone).toBe('eu'); - expect(client.getConfig().serverUrl).toBe('urlurl'); - expect(client.getConfig().streamServerUrl).toBe(EU_SERVER_URLS.stream); - expect(client.getConfig().cohortConfig.cohortServerUrl).toBe( - EU_SERVER_URLS.cohort, - ); +it.each([ + [ + {}, + [ + 'us', + LocalEvaluationDefaults.serverUrl, + LocalEvaluationDefaults.streamServerUrl, + CohortConfigDefaults.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 = { + cohortConfig: { + apiKey: '', + secretKey: '', + }, + }; + if ('zone' in testcase) { + config.serverZone = testcase.zone; + } + if ('url' in testcase) { + config.serverUrl = testcase.url; + } + if ('stream' in testcase) { + config.streamServerUrl = testcase.stream; + } + if ('cohort' in testcase) { + config.cohortConfig.cohortServerUrl = testcase.cohort; + } + const client = new TestLocalEvaluationClient(apiKey, config); + expect(client.getConfig().serverZone).toBe(expected[0]); + expect(client.getConfig().serverUrl).toBe(expected[1]); + expect(client.getConfig().streamServerUrl).toBe(expected[2]); + expect(client.getConfig().cohortConfig.cohortServerUrl).toBe(expected[3]); }); test('ExperimentClient.enrichUserWithCohorts', async () => { From c5cce04cbd2cb8070a0bf44effefcbb3a8735ae4 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 27 Jun 2024 13:05:01 -0700 Subject: [PATCH 26/48] moved config util code under util --- packages/node/src/local/client.ts | 2 +- packages/node/src/remote/client.ts | 7 +-- packages/node/src/types/config.ts | 42 +------------ packages/node/src/util/config.ts | 49 +++++++++++++++ packages/node/test/local/client.test.ts | 62 +------------------ packages/node/test/util/config.test.ts | 82 +++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 107 deletions(-) create mode 100644 packages/node/src/util/config.ts create mode 100644 packages/node/test/util/config.test.ts diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 6385a79..714d821 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -7,6 +7,7 @@ import { import EventSource from 'eventsource'; import { USER_GROUP_TYPE } from 'src/types/cohort'; import { CohortUtils } from 'src/util/cohort'; +import { populateLocalConfigDefaults } from 'src/util/config'; import { Assignment, AssignmentService } from '../assignment/assignment'; import { InMemoryAssignmentFilter } from '../assignment/assignment-filter'; @@ -17,7 +18,6 @@ import { AssignmentConfig, AssignmentConfigDefaults, LocalEvaluationConfig, - populateLocalConfigDefaults, } from '../types/config'; import { FlagConfigCache } from '../types/flag'; import { HttpClient } from '../types/transport'; diff --git a/packages/node/src/remote/client.ts b/packages/node/src/remote/client.ts index 3988417..5a3ac3f 100644 --- a/packages/node/src/remote/client.ts +++ b/packages/node/src/remote/client.ts @@ -3,14 +3,11 @@ import { FetchError, SdkEvaluationApi, } from '@amplitude/experiment-core'; +import { populateRemoteConfigDefaults } from 'src/util/config'; import { version as PACKAGE_VERSION } from '../../gen/version'; import { FetchHttpClient, WrapperClient } from '../transport/http'; -import { - ExperimentConfig, - RemoteEvaluationConfig, - populateRemoteConfigDefaults, -} 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'; diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index e0b2ce0..f83c218 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -87,7 +87,7 @@ export type ExperimentConfig = RemoteEvaluationConfig; */ export const RemoteEvaluationDefaults: RemoteEvaluationConfig = { debug: false, - serverZone: 'US', + serverZone: 'us', serverUrl: 'https://api.lab.amplitude.com', fetchTimeoutMillis: 10000, fetchRetries: 8, @@ -103,7 +103,7 @@ export const RemoteEvaluationDefaults: RemoteEvaluationConfig = { */ export const Defaults: ExperimentConfig = { debug: false, - serverZone: 'US', + serverZone: 'us', serverUrl: 'https://api.lab.amplitude.com', fetchTimeoutMillis: 10000, fetchRetries: 8, @@ -258,41 +258,3 @@ export const EU_SERVER_URLS = { stream: 'https://stream.lab.eu.amplitude.com', cohort: 'https://cohort-v2.lab.eu.amplitude.com', }; - -export const populateRemoteConfigDefaults = ( - customConfig: RemoteEvaluationConfig, -): RemoteEvaluationConfig => { - const config = { ...RemoteEvaluationDefaults, ...customConfig }; - const isEu = config.serverZone.toLowerCase() === EU_SERVER_URLS.name; - - 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; - - 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.cohortConfig && !customConfig.cohortConfig.cohortServerUrl) { - config.cohortConfig.cohortServerUrl = isEu - ? EU_SERVER_URLS.cohort - : CohortConfigDefaults.cohortServerUrl; - } - return config; -}; diff --git a/packages/node/src/util/config.ts b/packages/node/src/util/config.ts new file mode 100644 index 0000000..c86ab9f --- /dev/null +++ b/packages/node/src/util/config.ts @@ -0,0 +1,49 @@ +import { + EU_SERVER_URLS, + LocalEvaluationDefaults, + CohortConfigDefaults, +} from 'src/types/config'; + +import { + RemoteEvaluationConfig, + RemoteEvaluationDefaults, + LocalEvaluationConfig, +} from '..'; + +export const populateRemoteConfigDefaults = ( + customConfig: RemoteEvaluationConfig, +): RemoteEvaluationConfig => { + const config = { ...RemoteEvaluationDefaults, ...customConfig }; + const isEu = config.serverZone.toLowerCase() === EU_SERVER_URLS.name; + + 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; + + 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.cohortConfig && !customConfig.cohortConfig.cohortServerUrl) { + config.cohortConfig.cohortServerUrl = isEu + ? EU_SERVER_URLS.cohort + : CohortConfigDefaults.cohortServerUrl; + } + return config; +}; diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index 1e6f0eb..6d333e6 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -5,12 +5,7 @@ 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 { - CohortConfigDefaults, - EU_SERVER_URLS, - LocalEvaluationConfig, - LocalEvaluationDefaults, -} from 'src/types/config'; +import { LocalEvaluationDefaults } from 'src/types/config'; import { ExperimentUser } from 'src/types/user'; dotenv.config({ path: path.join(__dirname, '../../', '.env') }); @@ -256,9 +251,6 @@ test('ExperimentClient.evaluateV2 with group cohort tester targeted', async () = // Unit tests class TestLocalEvaluationClient extends LocalEvaluationClient { - public getConfig() { - return this.config; - } public enrichUserWithCohorts( user: ExperimentUser, flags: Record, @@ -267,58 +259,6 @@ class TestLocalEvaluationClient extends LocalEvaluationClient { } } -it.each([ - [ - {}, - [ - 'us', - LocalEvaluationDefaults.serverUrl, - LocalEvaluationDefaults.streamServerUrl, - CohortConfigDefaults.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 = { - cohortConfig: { - apiKey: '', - secretKey: '', - }, - }; - if ('zone' in testcase) { - config.serverZone = testcase.zone; - } - if ('url' in testcase) { - config.serverUrl = testcase.url; - } - if ('stream' in testcase) { - config.streamServerUrl = testcase.stream; - } - if ('cohort' in testcase) { - config.cohortConfig.cohortServerUrl = testcase.cohort; - } - const client = new TestLocalEvaluationClient(apiKey, config); - expect(client.getConfig().serverZone).toBe(expected[0]); - expect(client.getConfig().serverUrl).toBe(expected[1]); - expect(client.getConfig().streamServerUrl).toBe(expected[2]); - expect(client.getConfig().cohortConfig.cohortServerUrl).toBe(expected[3]); -}); - test('ExperimentClient.enrichUserWithCohorts', async () => { const client = new TestLocalEvaluationClient( apiKey, diff --git a/packages/node/test/util/config.test.ts b/packages/node/test/util/config.test.ts new file mode 100644 index 0000000..d9ecaed --- /dev/null +++ b/packages/node/test/util/config.test.ts @@ -0,0 +1,82 @@ +import { LocalEvaluationConfig } from 'src/index'; +import { + LocalEvaluationDefaults, + CohortConfigDefaults, + EU_SERVER_URLS, + RemoteEvaluationConfig, +} from 'src/types/config'; +import { + populateLocalConfigDefaults, + populateRemoteConfigDefaults, +} from 'src/util/config'; + +test.each([ + [ + {}, + [ + 'us', + LocalEvaluationDefaults.serverUrl, + LocalEvaluationDefaults.streamServerUrl, + CohortConfigDefaults.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 = { + cohortConfig: { + apiKey: '', + secretKey: '', + }, + }; + if ('zone' in testcase) { + config.serverZone = testcase.zone; + } + if ('url' in testcase) { + config.serverUrl = testcase.url; + } + if ('stream' in testcase) { + config.streamServerUrl = testcase.stream; + } + if ('cohort' in testcase) { + config.cohortConfig.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.cohortConfig.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; + } + if ('url' in testcase) { + config.serverUrl = testcase.url; + } + const newConfig = populateRemoteConfigDefaults(config); + expect(newConfig.serverZone).toBe(expectedZone); + expect(newConfig.serverUrl).toBe(expectedUrl); +}); From f23680c2717ecc7df6ea1cdd90e3b888ec04dd45 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 27 Jun 2024 16:11:49 -0700 Subject: [PATCH 27/48] added eu test --- packages/node/src/local/cohort/poller.ts | 1 + packages/node/test/local/client.eu.test.ts | 70 ++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 packages/node/test/local/client.eu.test.ts diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index bcae428..d3e0cfc 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -74,6 +74,7 @@ export class CohortPoller implements CohortUpdater { } if (changed) { this.storage.replaceAll(updatedCohorts); + this.logger.debug('[Experiment] cohort updated'); } if (onChange && changed) { 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..738912f --- /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['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', + ); +} + +// Simple EU test for connectivity. +const apiKey = 'server-Qlp7XiSu6JtP2S3JzA95PnP27duZgQCF'; + +const client = Experiment.initializeLocal(apiKey, { + serverZone: 'eu', + cohortConfig: { + 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(); +}); From 80723810d6b7764bac0b4a90711e6d3f99e8c2e5 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 28 Jun 2024 13:50:18 -0700 Subject: [PATCH 28/48] increase cohort fetch timeout --- packages/node/src/local/cohort/fetcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts index 5464c38..f6f1f1f 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -7,7 +7,7 @@ import { version as PACKAGE_VERSION } from '../../../gen/version'; import { SdkCohortApi } from './cohort-api'; -const COHORT_CONFIG_TIMEOUT = 5000; +const COHORT_CONFIG_TIMEOUT = 20000; export class CohortFetcher { readonly cohortApi: SdkCohortApi; From b5f441fbba2629725b016fc971b3ca7188e089ea Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 3 Jul 2024 15:33:12 -0700 Subject: [PATCH 29/48] update to flag poller loads new cohort, cohort updater polls updates --- packages/node/src/local/client.ts | 18 +- packages/node/src/local/cohort/cohort-api.ts | 2 + packages/node/src/local/cohort/fetcher.ts | 109 +++++++++++- packages/node/src/local/cohort/poller.ts | 102 +++++++----- packages/node/src/local/cohort/storage.ts | 18 +- packages/node/src/local/cohort/updater.ts | 5 +- packages/node/src/local/poller.ts | 54 ++---- packages/node/src/local/streamer.ts | 55 +++---- packages/node/src/local/updater.ts | 153 ++++++++++++++++- packages/node/src/types/cohort.ts | 5 +- packages/node/src/util/cohort.ts | 104 ++++++++---- packages/node/src/util/mutex.ts | 73 +++++++++ packages/node/test/local/client.test.ts | 2 +- .../node/test/local/cohort/cohortApi.test.ts | 155 ++++++++++++++++++ 14 files changed, 678 insertions(+), 177 deletions(-) create mode 100644 packages/node/src/util/mutex.ts diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 714d821..ecdc848 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -35,6 +35,7 @@ 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'; @@ -55,6 +56,7 @@ export class LocalEvaluationClient { private readonly updater: FlagConfigUpdater; private readonly assignmentService: AssignmentService; private readonly evaluation: EvaluationEngine; + private readonly cohortUpdater: CohortUpdater; /** * Directly access the client's flag config cache. @@ -86,29 +88,30 @@ export class LocalEvaluationClient { this.logger = new ConsoleLogger(this.config.debug); this.cohortStorage = new InMemoryCohortStorage(); - let cohortUpdater = undefined; + let cohortFetcher = undefined; if (this.config.cohortConfig) { - const cohortFetcher = new CohortFetcher( + cohortFetcher = new CohortFetcher( this.config.cohortConfig.apiKey, this.config.cohortConfig.secretKey, httpClient, this.config.cohortConfig?.cohortServerUrl, + this.config.cohortConfig?.maxCohortSize, this.config.debug, ); - const cohortPoller = new CohortPoller( + new CohortPoller( cohortFetcher, this.cohortStorage, - this.config.cohortConfig?.maxCohortSize, + 60000, // this.config.cohortConfig?.cohortPollingIntervalMillis, this.config.debug, ); - cohortUpdater = cohortPoller; } const flagsPoller = new FlagConfigPoller( fetcher, this.cache, + this.cohortStorage, + cohortFetcher, this.config.flagConfigPollingIntervalMillis, - cohortUpdater, this.config.debug, ); this.updater = this.config.streamUpdates @@ -123,7 +126,8 @@ export class LocalEvaluationClient { STREAM_RETRY_DELAY_MILLIS + Math.floor(Math.random() * STREAM_RETRY_JITTER_MAX_MILLIS), this.config.streamServerUrl, - cohortUpdater, + this.cohortStorage, + cohortFetcher, this.config.debug, ) : flagsPoller; diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index 6dd84f5..5c009f1 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -9,6 +9,7 @@ export type GetCohortOptions = { lastModified?: number; timeoutMillis?: number; }; + export interface CohortApi { /** * Calls /sdk/v1/cohort/ with query params maxCohortSize and lastModified if specified. @@ -21,6 +22,7 @@ export interface CohortApi { */ getCohort(options?: GetCohortOptions): Promise; } + export class SdkCohortApi implements CohortApi { private readonly cohortApiKey; private readonly serverUrl; diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts index f6f1f1f..ec7a81e 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -2,6 +2,8 @@ import { WrapperClient } from 'src/transport/http'; import { Cohort } from 'src/types/cohort'; import { CohortConfigDefaults } from 'src/types/config'; import { HttpClient } from 'src/types/transport'; +import { BackoffPolicy, doWithBackoffFailLoudly } from 'src/util/backoff'; +import { Mutex, Executor } from 'src/util/mutex'; import { version as PACKAGE_VERSION } from '../../../gen/version'; @@ -9,15 +11,31 @@ import { SdkCohortApi } from './cohort-api'; const COHORT_CONFIG_TIMEOUT = 20000; +const BACKOFF_POLICY: BackoffPolicy = { + attempts: 3, + min: 1000, + max: 1000, + scalar: 1, +}; + export class CohortFetcher { readonly cohortApi: SdkCohortApi; + readonly maxCohortSize: number; readonly debug: boolean; + 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 = CohortConfigDefaults.cohortServerUrl, + maxCohortSize = CohortConfigDefaults.maxCohortSize, debug = false, ) { this.cohortApi = new SdkCohortApi( @@ -25,21 +43,94 @@ export class CohortFetcher { serverUrl, new WrapperClient(httpClient), ); + this.maxCohortSize = maxCohortSize; this.debug = debug; } async fetch( cohortId: string, - maxCohortSize: number, lastModified?: number, ): Promise { - return this.cohortApi.getCohort({ - libraryName: 'experiment-node-server', - libraryVersion: PACKAGE_VERSION, - cohortId: cohortId, - maxCohortSize: maxCohortSize, - lastModified: lastModified, - timeoutMillis: COHORT_CONFIG_TIMEOUT, - }); + // This block may have async and awaits. No guarantee that executions are not interleaved. + // TODO: Add download concurrency limit. + const unlock = await this.mutex.lock(); + + if (!this.inProgressCohorts[cohortId]) { + this.inProgressCohorts[cohortId] = this.executor.run(async () => { + console.log('Start downloading', cohortId); + const cohort = await doWithBackoffFailLoudly( + async () => + this.cohortApi.getCohort({ + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + cohortId: cohortId, + maxCohortSize: this.maxCohortSize, + lastModified: lastModified, + timeoutMillis: COHORT_CONFIG_TIMEOUT, + }), + BACKOFF_POLICY, + ) + .then(async (cohort) => { + const unlock = await this.mutex.lock(); + delete this.inProgressCohorts[cohortId]; + unlock(); + return cohort; + }) + .catch(async (err) => { + const unlock = await this.mutex.lock(); + delete this.inProgressCohorts[cohortId]; + unlock(); + throw err; + }); + console.log('Stop downloading', cohortId, cohort['cohortId']); + return cohort; + }); + } + + unlock(); + return this.inProgressCohorts[cohortId]; } + + // queueMutex = new Mutex(); + // queue = []; + // running = 0; + + // private startNextTask() { + // const unlock = this.queueMutex.lock(); + // if (this.running >= 10) { + // unlock(); + // return; + // } + + // const nextTask = this.queue[0]; + // delete this.queue[0]; + + // this.running++; + // new Promise((resolve, reject) => { + // nextTask() + // .then((v) => { + // const unlock = this.queueMutex.lock(); + // this.running--; + // unlock(); + // this.startNextTask(); + // return v; + // }) + // .catch((err) => { + // const unlock = this.queueMutex.lock(); + // this.running--; + // unlock(); + // this.startNextTask(); + // throw err; + // }); + // }); + + // unlock(); + // } + + // private queueTask(task: () => Promise): Promise { + // const unlock = this.queueMutex.lock(); + // this.queue.push(task); + // unlock(); + // this.startNextTask(); + // } } diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index d3e0cfc..3e9d117 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -1,6 +1,4 @@ -import { Cohort, CohortStorage } from 'src/types/cohort'; -import { CohortConfigDefaults } from 'src/types/config'; -import { BackoffPolicy, doWithBackoffFailLoudly } from 'src/util/backoff'; +import { CohortStorage } from 'src/types/cohort'; import { ConsoleLogger } from '../../util/logger'; import { Logger } from '../../util/logger'; @@ -8,39 +6,68 @@ import { Logger } from '../../util/logger'; import { CohortFetcher } from './fetcher'; import { CohortUpdater } from './updater'; -const BACKOFF_POLICY: BackoffPolicy = { - attempts: 3, - min: 1000, - max: 1000, - scalar: 1, -}; - export class CohortPoller implements CohortUpdater { private readonly logger: Logger; public readonly fetcher: CohortFetcher; public readonly storage: CohortStorage; - private readonly maxCohortSize: number; + + private poller: NodeJS.Timeout; + private pollingIntervalMillis: number; constructor( fetcher: CohortFetcher, storage: CohortStorage, - maxCohortSize = CohortConfigDefaults.maxCohortSize, + pollingIntervalMillis = 60, debug = false, ) { this.fetcher = fetcher; this.storage = storage; - this.maxCohortSize = maxCohortSize; + this.pollingIntervalMillis = pollingIntervalMillis; this.logger = new ConsoleLogger(debug); } + /** + * You must call this function to begin polling for flag config updates. + * The promise returned by this function is resolved when the initial call + * to fetch the flag configuration completes. + * + * 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] flag config update failed', e); + } + }, this.pollingIntervalMillis); + } + } + + /** + * Stop polling for flag configurations. + * + * 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( - cohortIds: Set, onChange?: (storage: CohortStorage) => Promise, ): Promise { let changed = false; - const updatedCohorts: Record = {}; - for (const cohortId of cohortIds) { + const promises = []; + + for (const cohortId of this.storage.getAllCohortIds()) { this.logger.debug(`[Experiment] updating cohort ${cohortId}`); // Get existing cohort and lastModified. @@ -48,35 +75,28 @@ export class CohortPoller implements CohortUpdater { let lastModified = undefined; if (existingCohort) { lastModified = existingCohort.lastModified; - updatedCohorts[cohortId] = existingCohort; - } - - // Download. - let cohort = undefined; - try { - cohort = await doWithBackoffFailLoudly(async () => { - return await this.fetcher.fetch( - cohortId, - this.maxCohortSize, - lastModified, - ); - }, BACKOFF_POLICY); - } catch (e) { - this.logger.error('[Experiment] cohort poll failed', e); - throw e; } - // Set. - if (cohort) { - updatedCohorts[cohortId] = cohort; - changed = true; - } - } - if (changed) { - this.storage.replaceAll(updatedCohorts); - this.logger.debug('[Experiment] cohort updated'); + 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 index 5b747ca..a3d6c3f 100644 --- a/packages/node/src/local/cohort/storage.ts +++ b/packages/node/src/local/cohort/storage.ts @@ -3,6 +3,10 @@ import { Cohort, CohortStorage, USER_GROUP_TYPE } from 'src/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; } @@ -28,8 +32,18 @@ export class InMemoryCohortStorage implements CohortStorage { return validCohortIds; } - replaceAll(cohorts: Record): void { + put(cohort: Cohort): void { + this.store[cohort.cohortId] = cohort; + } + + putAll(cohorts: Record): void { // Assignments are atomic. - this.store = { ...cohorts }; + this.store = { ...this.store, ...cohorts }; + } + + removeAll(cohortIds: Set): void { + cohortIds.forEach((id) => { + delete this.store[id]; + }); } } diff --git a/packages/node/src/local/cohort/updater.ts b/packages/node/src/local/cohort/updater.ts index fa48428..1b4e899 100644 --- a/packages/node/src/local/cohort/updater.ts +++ b/packages/node/src/local/cohort/updater.ts @@ -9,8 +9,5 @@ export interface CohortUpdater { * in the storage have changed. * @throws error if update failed. */ - update( - cohortIds: Set, - onChange?: (storage: CohortStorage) => Promise, - ): Promise; + update(onChange?: (storage: CohortStorage) => Promise): Promise; } diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index 9f6778b..6c98dbd 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -1,14 +1,12 @@ -import { CohortUtils } from 'src/util/cohort'; +import { CohortStorage } from 'src/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 { CohortUpdater } from './cohort/updater'; +import { CohortFetcher } from './cohort/fetcher'; import { FlagConfigFetcher } from './fetcher'; -import { FlagConfigUpdater } from './updater'; +import { FlagConfigUpdater, FlagConfigUpdaterBase } from './updater'; const BACKOFF_POLICY: BackoffPolicy = { attempts: 5, @@ -17,29 +15,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; - - public readonly cohortUpdater?: CohortUpdater; constructor( fetcher: FlagConfigFetcher, cache: FlagConfigCache, + cohortStorage: CohortStorage, + cohortFetcher?: CohortFetcher, pollingIntervalMillis = LocalEvaluationDefaults.flagConfigPollingIntervalMillis, - cohortUpdater?: CohortUpdater, debug = false, ) { + super(cache, cohortStorage, cohortFetcher, debug); this.fetcher = fetcher; - this.cache = cache; this.pollingIntervalMillis = pollingIntervalMillis; - this.cohortUpdater = cohortUpdater; - this.logger = new ConsoleLogger(debug); } /** @@ -67,7 +63,8 @@ export class FlagConfigPoller implements FlagConfigUpdater { // Fetch initial flag configs and await the result. await doWithBackoff(async () => { try { - await this.update(onChange); + const flagConfigs = await this.fetcher.fetch(); + await super._update(flagConfigs, true, onChange); } catch (e) { this.logger.error( '[Experiment] flag config initial poll failed, stopping', @@ -92,36 +89,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; - } - } - const cohortIds = CohortUtils.extractCohortIds(flagConfigs); - if (cohortIds && cohortIds.size > 0 && !this.cohortUpdater) { - this.logger.warn( - '[Experiment] cohort found in flag configs but no cohort download configured', - ); - } - await this.cohortUpdater?.update(cohortIds); // Throws error if cohort update failed. - await this.cache.clear(); - await this.cache.putAll(flagConfigs); - if (changed) { - await onChange(this.cache); - } + await super._update(flagConfigs, false, onChange); } } diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 2900b84..eca4d77 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -1,4 +1,4 @@ -import { CohortUtils } from 'src/util/cohort'; +import { CohortStorage } from 'src/types/cohort'; import { version as PACKAGE_VERSION } from '../../gen/version'; import { @@ -7,27 +7,22 @@ import { } from '../transport/stream'; import { LocalEvaluationDefaults } from '../types/config'; import { FlagConfigCache } from '../types/flag'; -import { ConsoleLogger } from '../util/logger'; -import { Logger } from '../util/logger'; -import { CohortUpdater } from './cohort/updater'; +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; - - public readonly cohortUpdater?: CohortUpdater; - constructor( apiKey: string, poller: FlagConfigPoller, @@ -38,12 +33,12 @@ export class FlagConfigStreamer implements FlagConfigUpdater { streamFlagTryDelayMillis: number, streamFlagRetryDelayMillis: number, serverUrl: string = LocalEvaluationDefaults.serverUrl, - cohortUpdater?: CohortUpdater, + 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 = poller; this.stream = new SdkStreamFlagApi( apiKey, @@ -55,7 +50,6 @@ export class FlagConfigStreamer implements FlagConfigUpdater { streamFlagTryDelayMillis, ); this.streamFlagRetryDelayMillis = streamFlagRetryDelayMillis; - this.cohortUpdater = cohortUpdater; } /** @@ -79,27 +73,20 @@ export class FlagConfigStreamer implements FlagConfigUpdater { this.startRetryStreamInterval(); }; + let isInitUpdate = true; 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; - } - } - try { - await this.cohortUpdater?.update( - CohortUtils.extractCohortIds(flagConfigs), - ); - } catch { - this.logger.debug('[Experiment] cohort update failed'); - } finally { - await this.cache.clear(); - await this.cache.putAll(flagConfigs); - if (changed) { - await onChange(this.cache); + if (isInitUpdate) { + isInitUpdate = false; + try { + super._update(flagConfigs, true, onChange); + } catch { + // Flag update failed on init, stop, fallback to poller. + await this.poller.start(onChange); + this.startRetryStreamInterval(); } + } else { + super._update(flagConfigs, false, onChange); } }; diff --git a/packages/node/src/local/updater.ts b/packages/node/src/local/updater.ts index 3674785..c81b0dd 100644 --- a/packages/node/src/local/updater.ts +++ b/packages/node/src/local/updater.ts @@ -1,4 +1,10 @@ -import { FlagConfigCache } from '..'; +import { CohortStorage } from 'src/types/cohort'; +import { CohortUtils } from 'src/util/cohort'; +import { ConsoleLogger, Logger } from 'src/util/logger'; + +import { FlagConfig, FlagConfigCache } from '..'; + +import { CohortFetcher } from './cohort/fetcher'; export interface FlagConfigUpdater { /** @@ -24,3 +30,148 @@ 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, + isInit: boolean, + 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) { + throw Error( + 'cohort found in flag configs but no cohort download configured', + ); + } + + // Download new cohorts into cohortStorage. + const failedCohortIds = await this.downloadNewCohorts(cohortIds); + if (isInit && failedCohortIds.size > 0) { + throw Error('Cohort download failed'); + } + + // Update the flags that has all cohorts successfully updated into flags cache. + const newFlagConfigs = await this.filterFlagConfigsWithFullCohorts( + flagConfigs, + ); + + // Update the flags with new flags. + await this.cache.clear(); + await this.cache.putAll(newFlagConfigs); + + // Remove cohorts not used by new flags. + await this.removeUnusedCohorts( + CohortUtils.extractCohortIds(newFlagConfigs), + ); + + if (changed) { + await onChange(this.cache); + } + } + + private async downloadNewCohorts( + cohortIds: Set, + ): Promise> { + const oldCohortIds = this.cohortStorage?.getAllCohortIds(); + const newCohortIds = FlagConfigUpdaterBase.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.warn( + `[Experiment] Cohort download failed ${cohortId}, using existing cohort`, + err, + ); + failedCohortIds.add(cohortId); + }), + ); + await Promise.all(cohortDownloadPromises); + return failedCohortIds; + } + + private async filterFlagConfigsWithFullCohorts( + flagConfigs: Record, + ) { + const newFlagConfigs = {}; + for (const flagKey in flagConfigs) { + // Get cohorts for this flag. + const cohortIdsForFlag = CohortUtils.extractCohortIdsFromFlag( + flagConfigs[flagKey], + ); + + // Check if all cohorts for this flag has downloaded. + // If any cohort failed, don't use the new flag. + const updateFlag = + cohortIdsForFlag.size === 0 || + [...cohortIdsForFlag] + .map((id) => this.cohortStorage.getCohort(id)) + .reduce((acc, cur) => acc && cur); + + if (updateFlag) { + newFlagConfigs[flagKey] = flagConfigs[flagKey]; + } else { + this.logger.warn( + `[Experiment] Flag ${flagKey} failed to update due to cohort update failure`, + ); + const existingFlag = await this.cache.get(flagKey); + if (existingFlag) { + newFlagConfigs[flagKey] = existingFlag; + } + } + } + + return newFlagConfigs; + } + + private async removeUnusedCohorts(validCohortIds: Set) { + const cohortIdsToBeRemoved = FlagConfigUpdaterBase.setSubtract( + this.cohortStorage.getAllCohortIds(), + validCohortIds, + ); + this.cohortStorage.removeAll(cohortIdsToBeRemoved); + } + + private static setSubtract(one: Set, other: Set) { + const result = new Set(one); + other.forEach((v) => result.delete(v)); + + return result; + } +} diff --git a/packages/node/src/types/cohort.ts b/packages/node/src/types/cohort.ts index 801545b..8f975d8 100644 --- a/packages/node/src/types/cohort.ts +++ b/packages/node/src/types/cohort.ts @@ -1,4 +1,5 @@ export interface CohortStorage { + getAllCohortIds(): Set; getCohort(cohortId: string): Cohort | undefined; getCohortsForUser(userId: string, cohortIds: Set): Set; getCohortsForGroup( @@ -6,7 +7,9 @@ export interface CohortStorage { groupName: string, cohortIds: Set, ): Set; - replaceAll(cohorts: Record): void; + put(cohort: Cohort): void; + putAll(cohorts: Record): void; + removeAll(cohortIds: Set): void; } export const USER_GROUP_TYPE = 'User'; diff --git a/packages/node/src/util/cohort.ts b/packages/node/src/util/cohort.ts index dccbcf3..ae452a9 100644 --- a/packages/node/src/util/cohort.ts +++ b/packages/node/src/util/cohort.ts @@ -20,12 +20,9 @@ export class CohortUtils { public static extractCohortIds( flagConfigs: Record, ): Set { - const cohorts = this.extractCohortIdsByGroup(flagConfigs); - const cohortIds = new Set(); - for (const groupType in cohorts) { - cohorts[groupType].forEach(cohortIds.add, cohortIds); - } - return cohortIds; + return CohortUtils.mergeAllValues( + CohortUtils.extractCohortIdsByGroup(flagConfigs), + ); } public static extractCohortIdsByGroup( @@ -33,38 +30,52 @@ export class CohortUtils { ): Record> { const cohortIdsByGroup = {}; for (const key in flagConfigs) { - if ( - flagConfigs[key].segments && - Array.isArray(flagConfigs[key].segments) - ) { - const segments = flagConfigs[key].segments as EvaluationSegment[]; - for (const segment of segments) { - if (!segment || !segment.conditions) { - continue; - } + CohortUtils.mergeBIntoA( + cohortIdsByGroup, + CohortUtils.extractCohortIdsByGroupFromFlag(flagConfigs[key]), + ); + } + return cohortIdsByGroup; + } + + public static extractCohortIdsFromFlag(flag: FlagConfig): Set { + return CohortUtils.mergeAllValues( + CohortUtils.extractCohortIdsByGroupFromFlag(flag), + ); + } + + 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], - ); + 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], + ); } } } @@ -73,4 +84,25 @@ export class CohortUtils { } return cohortIdsByGroup; } + + private static mergeBIntoA( + a: Record>, + b: Record>, + ) { + for (const groupType in b) { + if (!(groupType in a)) { + a[groupType] = new Set(); + } + + b[groupType].forEach(a[groupType].add, a[groupType]); + } + } + + private static mergeAllValues(a: Record>) { + const merged = new Set(); + for (const key in a) { + a[key].forEach(merged.add, merged); + } + return merged; + } } diff --git a/packages/node/src/util/mutex.ts b/packages/node/src/util/mutex.ts new file mode 100644 index 0000000..afb4215 --- /dev/null +++ b/packages/node/src/util/mutex.ts @@ -0,0 +1,73 @@ +// https://news.ycombinator.com/item?id=11823816 + +export class Mutex { + _locking; + + constructor() { + this._locking = Promise.resolve(); + } + + lock() { + 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; + } + + 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/test/local/client.test.ts b/packages/node/test/local/client.test.ts index 6d333e6..b081bb6 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -265,7 +265,7 @@ test('ExperimentClient.enrichUserWithCohorts', async () => { LocalEvaluationDefaults, new InMemoryFlagConfigCache(), ); - client.cohortStorage.replaceAll({ + client.cohortStorage.putAll({ cohort1: { cohortId: 'cohort1', groupType: USER_GROUP_TYPE, diff --git a/packages/node/test/local/cohort/cohortApi.test.ts b/packages/node/test/local/cohort/cohortApi.test.ts index 04a25c3..394288c 100644 --- a/packages/node/test/local/cohort/cohortApi.test.ts +++ b/packages/node/test/local/cohort/cohortApi.test.ts @@ -1,6 +1,7 @@ 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 = { @@ -85,3 +86,157 @@ test('getCohort with other status code', async () => { 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 fetcher = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + const cohort = await fetcher.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 fetcher = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + await expect( + fetcher.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 fetcher = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + expect( + await fetcher.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 fetcher = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + expect( + await fetcher.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 fetcher = new SdkCohortApi( + encodedKey, + serverUrl, + new WrapperClient(httpClient), + ); + await expect( + fetcher.getCohort({ + cohortId, + maxCohortSize, + lastModified, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + }), + ).rejects.toThrow(); +}); From 85bcf02bafb0d3b3663a19861ab2201b4d12c965 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 9 Jul 2024 10:49:14 -0700 Subject: [PATCH 30/48] fix client start and stop, cleanup --- packages/node/src/local/client.ts | 8 +- packages/node/src/local/cohort/fetcher.ts | 61 ++------- packages/node/src/local/cohort/poller.ts | 1 + packages/node/src/local/cohort/storage.ts | 11 +- packages/node/src/local/cohort/updater.ts | 4 + packages/node/src/local/updater.ts | 10 +- packages/node/src/types/cohort.ts | 3 +- .../node/src/util/{mutex.ts => threading.ts} | 8 +- packages/node/test/local/client.test.ts | 36 +++--- .../node/test/local/cohort/cohortApi.test.ts | 20 +-- .../test/local/cohort/cohortStorage.test.ts | 78 ++++++----- packages/node/test/util/threading.test.ts | 121 ++++++++++++++++++ 12 files changed, 227 insertions(+), 134 deletions(-) rename packages/node/src/util/{mutex.ts => threading.ts} (92%) create mode 100644 packages/node/test/util/threading.test.ts diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index ecdc848..7460b82 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -98,7 +98,7 @@ export class LocalEvaluationClient { this.config.cohortConfig?.maxCohortSize, this.config.debug, ); - new CohortPoller( + this.cohortUpdater = new CohortPoller( cohortFetcher, this.cohortStorage, 60000, // this.config.cohortConfig?.cohortPollingIntervalMillis, @@ -263,7 +263,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(); } /** @@ -272,6 +273,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/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts index ec7a81e..f4958d0 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -3,13 +3,14 @@ import { Cohort } from 'src/types/cohort'; import { CohortConfigDefaults } from 'src/types/config'; import { HttpClient } from 'src/types/transport'; import { BackoffPolicy, doWithBackoffFailLoudly } from 'src/util/backoff'; -import { Mutex, Executor } from 'src/util/mutex'; +import { ConsoleLogger, Logger } from 'src/util/logger'; +import { Mutex, Executor } from 'src/util/threading'; import { version as PACKAGE_VERSION } from '../../../gen/version'; import { SdkCohortApi } from './cohort-api'; -const COHORT_CONFIG_TIMEOUT = 20000; +export const COHORT_CONFIG_TIMEOUT = 20000; const BACKOFF_POLICY: BackoffPolicy = { attempts: 3, @@ -19,9 +20,10 @@ const BACKOFF_POLICY: BackoffPolicy = { }; export class CohortFetcher { + private readonly logger: Logger; + readonly cohortApi: SdkCohortApi; readonly maxCohortSize: number; - readonly debug: boolean; private readonly inProgressCohorts: Record< string, @@ -44,7 +46,7 @@ export class CohortFetcher { new WrapperClient(httpClient), ); this.maxCohortSize = maxCohortSize; - this.debug = debug; + this.logger = new ConsoleLogger(debug); } async fetch( @@ -52,12 +54,11 @@ export class CohortFetcher { lastModified?: number, ): Promise { // This block may have async and awaits. No guarantee that executions are not interleaved. - // TODO: Add download concurrency limit. const unlock = await this.mutex.lock(); if (!this.inProgressCohorts[cohortId]) { this.inProgressCohorts[cohortId] = this.executor.run(async () => { - console.log('Start downloading', cohortId); + this.logger.debug('Start downloading', cohortId); const cohort = await doWithBackoffFailLoudly( async () => this.cohortApi.getCohort({ @@ -82,55 +83,13 @@ export class CohortFetcher { unlock(); throw err; }); - console.log('Stop downloading', cohortId, cohort['cohortId']); + this.logger.debug('Stop downloading', cohortId, cohort['cohortId']); return cohort; }); } + const cohortPromise = this.inProgressCohorts[cohortId]; unlock(); - return this.inProgressCohorts[cohortId]; + return cohortPromise; } - - // queueMutex = new Mutex(); - // queue = []; - // running = 0; - - // private startNextTask() { - // const unlock = this.queueMutex.lock(); - // if (this.running >= 10) { - // unlock(); - // return; - // } - - // const nextTask = this.queue[0]; - // delete this.queue[0]; - - // this.running++; - // new Promise((resolve, reject) => { - // nextTask() - // .then((v) => { - // const unlock = this.queueMutex.lock(); - // this.running--; - // unlock(); - // this.startNextTask(); - // return v; - // }) - // .catch((err) => { - // const unlock = this.queueMutex.lock(); - // this.running--; - // unlock(); - // this.startNextTask(); - // throw err; - // }); - // }); - - // unlock(); - // } - - // private queueTask(task: () => Promise): Promise { - // const unlock = this.queueMutex.lock(); - // this.queue.push(task); - // unlock(); - // this.startNextTask(); - // } } diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index 3e9d117..aec0627 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -26,6 +26,7 @@ export class CohortPoller implements CohortUpdater { this.pollingIntervalMillis = pollingIntervalMillis; this.logger = new ConsoleLogger(debug); } + /** * You must call this function to begin polling for flag config updates. * The promise returned by this function is resolved when the initial call diff --git a/packages/node/src/local/cohort/storage.ts b/packages/node/src/local/cohort/storage.ts index a3d6c3f..42e114a 100644 --- a/packages/node/src/local/cohort/storage.ts +++ b/packages/node/src/local/cohort/storage.ts @@ -36,14 +36,7 @@ export class InMemoryCohortStorage implements CohortStorage { this.store[cohort.cohortId] = cohort; } - putAll(cohorts: Record): void { - // Assignments are atomic. - this.store = { ...this.store, ...cohorts }; - } - - removeAll(cohortIds: Set): void { - cohortIds.forEach((id) => { - delete this.store[id]; - }); + 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 index 1b4e899..7b93c87 100644 --- a/packages/node/src/local/cohort/updater.ts +++ b/packages/node/src/local/cohort/updater.ts @@ -10,4 +10,8 @@ export interface CohortUpdater { * @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/updater.ts b/packages/node/src/local/updater.ts index c81b0dd..e50d2ed 100644 --- a/packages/node/src/local/updater.ts +++ b/packages/node/src/local/updater.ts @@ -132,15 +132,15 @@ export class FlagConfigUpdaterBase { const newFlagConfigs = {}; for (const flagKey in flagConfigs) { // Get cohorts for this flag. - const cohortIdsForFlag = CohortUtils.extractCohortIdsFromFlag( + const cohortIds = CohortUtils.extractCohortIdsFromFlag( flagConfigs[flagKey], ); // Check if all cohorts for this flag has downloaded. // If any cohort failed, don't use the new flag. const updateFlag = - cohortIdsForFlag.size === 0 || - [...cohortIdsForFlag] + cohortIds.size === 0 || + [...cohortIds] .map((id) => this.cohortStorage.getCohort(id)) .reduce((acc, cur) => acc && cur); @@ -165,7 +165,9 @@ export class FlagConfigUpdaterBase { this.cohortStorage.getAllCohortIds(), validCohortIds, ); - this.cohortStorage.removeAll(cohortIdsToBeRemoved); + cohortIdsToBeRemoved.forEach((id) => { + this.cohortStorage.delete(id); + }); } private static setSubtract(one: Set, other: Set) { diff --git a/packages/node/src/types/cohort.ts b/packages/node/src/types/cohort.ts index 8f975d8..c24f9f3 100644 --- a/packages/node/src/types/cohort.ts +++ b/packages/node/src/types/cohort.ts @@ -8,8 +8,7 @@ export interface CohortStorage { cohortIds: Set, ): Set; put(cohort: Cohort): void; - putAll(cohorts: Record): void; - removeAll(cohortIds: Set): void; + delete(cohortIds: string): void; } export const USER_GROUP_TYPE = 'User'; diff --git a/packages/node/src/util/mutex.ts b/packages/node/src/util/threading.ts similarity index 92% rename from packages/node/src/util/mutex.ts rename to packages/node/src/util/threading.ts index afb4215..b277c03 100644 --- a/packages/node/src/util/mutex.ts +++ b/packages/node/src/util/threading.ts @@ -1,13 +1,13 @@ -// https://news.ycombinator.com/item?id=11823816 - export class Mutex { + // https://news.ycombinator.com/item?id=11823816 + _locking; constructor() { this._locking = Promise.resolve(); } - lock() { + lock(): Promise<() => void> { let unlockNext; const willLock = new Promise((resolve) => (unlockNext = resolve)); const willUnlock = this._locking.then(() => unlockNext); @@ -40,7 +40,7 @@ export class Semaphore { return promise; } - tryRunNext(): void { + private tryRunNext(): void { if (this.running >= this.limit || this.queue.length == 0) { return; } diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index b081bb6..d348baf 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -265,25 +265,23 @@ test('ExperimentClient.enrichUserWithCohorts', async () => { LocalEvaluationDefaults, new InMemoryFlagConfigCache(), ); - client.cohortStorage.putAll({ - cohort1: { - cohortId: 'cohort1', - groupType: USER_GROUP_TYPE, - groupTypeId: 0, - lastComputed: 0, - lastModified: 0, - size: 1, - memberIds: new Set(['userId']), - }, - groupcohort1: { - cohortId: 'groupcohort1', - groupType: 'groupname', - groupTypeId: 1, - lastComputed: 0, - lastModified: 0, - size: 1, - memberIds: new Set(['amplitude', 'experiment']), - }, + 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', diff --git a/packages/node/test/local/cohort/cohortApi.test.ts b/packages/node/test/local/cohort/cohortApi.test.ts index 394288c..e03a0fb 100644 --- a/packages/node/test/local/cohort/cohortApi.test.ts +++ b/packages/node/test/local/cohort/cohortApi.test.ts @@ -119,12 +119,12 @@ test('getCohort success', async () => { body: JSON.stringify({ ...C_A, memberIds: Array.from(C_A.memberIds) }), }; }); - const fetcher = new SdkCohortApi( + const api = new SdkCohortApi( encodedKey, serverUrl, new WrapperClient(httpClient), ); - const cohort = await fetcher.getCohort({ + const cohort = await api.getCohort({ cohortId, maxCohortSize, libraryName: 'experiment-node-server', @@ -142,13 +142,13 @@ test('getCohort 413', async () => { expect(params.headers).toStrictEqual(expectedHeaders); return { status: 413, body: '' }; }); - const fetcher = new SdkCohortApi( + const api = new SdkCohortApi( encodedKey, serverUrl, new WrapperClient(httpClient), ); await expect( - fetcher.getCohort({ + api.getCohort({ cohortId, maxCohortSize, libraryName: 'experiment-node-server', @@ -167,13 +167,13 @@ test('getCohort no modification 204', async () => { expect(params.headers).toStrictEqual(expectedHeaders); return { status: 204, body: '' }; }); - const fetcher = new SdkCohortApi( + const api = new SdkCohortApi( encodedKey, serverUrl, new WrapperClient(httpClient), ); expect( - await fetcher.getCohort({ + await api.getCohort({ cohortId, maxCohortSize, lastModified, @@ -196,13 +196,13 @@ test('getCohort no modification but still return cohort due to cache miss', asyn body: JSON.stringify({ ...C_A, memberIds: Array.from(C_A.memberIds) }), }; }); - const fetcher = new SdkCohortApi( + const api = new SdkCohortApi( encodedKey, serverUrl, new WrapperClient(httpClient), ); expect( - await fetcher.getCohort({ + await api.getCohort({ cohortId, maxCohortSize, lastModified, @@ -225,13 +225,13 @@ test('getCohort other errors', async () => { body: JSON.stringify({ ...C_A, memberIds: Array.from(C_A.memberIds) }), }; }); - const fetcher = new SdkCohortApi( + const api = new SdkCohortApi( encodedKey, serverUrl, new WrapperClient(httpClient), ); await expect( - fetcher.getCohort({ + api.getCohort({ cohortId, maxCohortSize, lastModified, diff --git a/packages/node/test/local/cohort/cohortStorage.test.ts b/packages/node/test/local/cohort/cohortStorage.test.ts index d336f89..e9f91b1 100644 --- a/packages/node/test/local/cohort/cohortStorage.test.ts +++ b/packages/node/test/local/cohort/cohortStorage.test.ts @@ -56,14 +56,50 @@ const C_U3 = { memberIds: new Set(['user1']), }; -test('cohort storage replaceAll and getCohort', async () => { +test('cohort storage put, delete, getAllCohortIds, getCohort', async () => { const storage = new InMemoryCohortStorage(); - storage.replaceAll({ - [C_A.cohortId]: C_A, - }); + + // {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', @@ -86,29 +122,9 @@ test('cohort storage replaceAll and getCohort', async () => { ), ).toStrictEqual(new Set()); - storage.replaceAll({ - c_b: C_B, - }); - expect(storage.getCohort(C_A.cohortId)).toBeUndefined(); - expect(storage.getCohort(C_B.cohortId)).toBe(C_B); - - storage.replaceAll({ - [C_A.cohortId]: C_A, - [C_B.cohortId]: C_B, - [C_B2.cohortId]: C_B2, - }); - 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); -}); - -test('cohort storage getCohortsForGroup', async () => { - const storage = new InMemoryCohortStorage(); - storage.replaceAll({ - [C_A.cohortId]: C_A, - [C_B.cohortId]: C_B, - [C_B2.cohortId]: C_B2, - }); + // {C_A, C_B, C_B2} + storage.put(C_B); + storage.put(C_B2); expect( storage.getCohortsForGroup( @@ -135,11 +151,9 @@ test('cohort storage getCohortsForGroup', async () => { test('cohort storage getCohortsForUser', async () => { const storage = new InMemoryCohortStorage(); - storage.replaceAll({ - [C_U1.cohortId]: C_U1, - [C_U2.cohortId]: C_U2, - [C_U3.cohortId]: C_U3, - }); + storage.put(C_U1); + storage.put(C_U2); + storage.put(C_U3); expect( storage.getCohortsForUser( diff --git a/packages/node/test/util/threading.test.ts b/packages/node/test/util/threading.test.ts new file mode 100644 index 0000000..c95fa34 --- /dev/null +++ b/packages/node/test/util/threading.test.ts @@ -0,0 +1,121 @@ +import { Executor, Mutex, Semaphore } from 'src/util/threading'; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +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); +}); From 14e20049802591a5cb0ef32454d455f269269d38 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Sat, 13 Jul 2024 01:17:21 -0700 Subject: [PATCH 31/48] fixed tests --- packages/node/src/local/cohort/fetcher.ts | 18 +- packages/node/test/local/benchmark.test.ts | 21 +- .../test/local/cohort/cohortFetcher.test.ts | 314 +++++++----- .../test/local/cohort/cohortPoller.test.ts | 450 +++++++++--------- .../node/test/local/flagConfigPoller.test.ts | 137 ++++-- .../test/local/flagConfigStreamer.test.ts | 19 +- 6 files changed, 563 insertions(+), 396 deletions(-) diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts index f4958d0..a9d78d2 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -49,15 +49,20 @@ export class CohortFetcher { 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[cohortId]) { - this.inProgressCohorts[cohortId] = this.executor.run(async () => { + if (!this.inProgressCohorts[key]) { + this.inProgressCohorts[key] = this.executor.run(async () => { this.logger.debug('Start downloading', cohortId); const cohort = await doWithBackoffFailLoudly( async () => @@ -73,22 +78,23 @@ export class CohortFetcher { ) .then(async (cohort) => { const unlock = await this.mutex.lock(); - delete this.inProgressCohorts[cohortId]; + delete this.inProgressCohorts[key]; unlock(); return cohort; }) .catch(async (err) => { const unlock = await this.mutex.lock(); - delete this.inProgressCohorts[cohortId]; + delete this.inProgressCohorts[key]; unlock(); throw err; }); - this.logger.debug('Stop downloading', cohortId, cohort['cohortId']); + this.logger.debug('Stop downloading', cohortId); return cohort; }); } - const cohortPromise = this.inProgressCohorts[cohortId]; + const cohortPromise: Promise = + this.inProgressCohorts[key]; unlock(); return cohortPromise; } diff --git a/packages/node/test/local/benchmark.test.ts b/packages/node/test/local/benchmark.test.ts index 78dfcb5..a2bc881 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 cohortConfig = { + apiKey: process.env['API_KEY'], + secretKey: process.env['SECRET_KEY'], +}; + +const client = Experiment.initializeLocal(apiKey, { + debug: false, + cohortConfig: cohortConfig, +}); beforeAll(async () => { await client.start(); diff --git a/packages/node/test/local/cohort/cohortFetcher.test.ts b/packages/node/test/local/cohort/cohortFetcher.test.ts index 14460cf..695fbe1 100644 --- a/packages/node/test/local/cohort/cohortFetcher.test.ts +++ b/packages/node/test/local/cohort/cohortFetcher.test.ts @@ -1,140 +1,214 @@ -import { CohortFetcher } from 'src/local/cohort/fetcher'; +import { SdkCohortApi } from 'src/local/cohort/cohort-api'; +import { COHORT_CONFIG_TIMEOUT, CohortFetcher } from 'src/local/cohort/fetcher'; +import { CohortConfigDefaults } from 'src/types/config'; import { version as PACKAGE_VERSION } from '../../../gen/version'; -import { MockHttpClient } from '../util/mockHttpClient'; - -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 = `Basic ${Buffer.from(`${apiKey}:${secretKey}`).toString( - 'base64', -)}`; -const expectedHeaders = { - Authorization: encodedKey, - 'X-Amp-Exp-Library': `experiment-node-server/${PACKAGE_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']), + }, }; -test('cohort fetcher 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) }), - }; +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: CohortConfigDefaults.maxCohortSize, + timeoutMillis: COHORT_CONFIG_TIMEOUT, }); - const fetcher = new CohortFetcher( - apiKey, - secretKey, - httpClient, - serverUrl, - false, +}); + +test('cohort fetch success using maxCohortSize and lastModified', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation( + async (options) => COHORTS[options.cohortId], ); - const cohort = await fetcher.fetch(cohortId, maxCohortSize); - expect(cohort).toStrictEqual(C_A); + + 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 fetcher 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: '' }; +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, }); - const fetcher = new CohortFetcher( - apiKey, - secretKey, - httpClient, - serverUrl, - false, - ); - await expect(fetcher.fetch(cohortId, maxCohortSize)).rejects.toThrow(); }); -test('cohort fetcher 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: '' }; +test('cohort fetch failed', async () => { + const cohortApiGetCohortSpy = jest.spyOn(SdkCohortApi.prototype, 'getCohort'); + cohortApiGetCohortSpy.mockImplementation(async () => { + throw Error(); + }); + + const cohortFetcher = new CohortFetcher('', '', null, 'someurl', 10); + + await expect(cohortFetcher.fetch('c1', 10)).rejects.toThrowError(); + + expect(cohortApiGetCohortSpy).toHaveBeenCalledWith({ + cohortId: 'c1', + lastModified: 10, + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + maxCohortSize: 10, + timeoutMillis: COHORT_CONFIG_TIMEOUT, }); - const fetcher = new CohortFetcher( - apiKey, - secretKey, - httpClient, - serverUrl, - false, - ); - expect( - await fetcher.fetch(cohortId, maxCohortSize, lastModified), - ).toBeUndefined(); }); -test('cohort fetcher 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) }), - }; +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, }); - const fetcher = new CohortFetcher( - apiKey, - secretKey, - httpClient, - serverUrl, - false, - ); - expect( - await fetcher.fetch(cohortId, maxCohortSize, lastModified), - ).toStrictEqual(C_A); }); -test('cohort fetcher 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) }), - }; +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, }); - const fetcher = new CohortFetcher( - apiKey, - secretKey, - httpClient, - serverUrl, - false, - ); - await expect( - fetcher.fetch(cohortId, maxCohortSize, lastModified), - ).rejects.toThrow(); }); diff --git a/packages/node/test/local/cohort/cohortPoller.test.ts b/packages/node/test/local/cohort/cohortPoller.test.ts index 7e6c370..5f4fcfb 100644 --- a/packages/node/test/local/cohort/cohortPoller.test.ts +++ b/packages/node/test/local/cohort/cohortPoller.test.ts @@ -1,9 +1,10 @@ +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 { CohortConfigDefaults } from 'src/types/config'; +import { CohortStorage } from 'src/types/cohort'; -const COHORTS = { +const OLD_COHORTS = { c1: { cohortId: 'c1', groupType: 'a', @@ -33,269 +34,288 @@ const COHORTS = { }, }; +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 sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const POLL_MILLIS = 500; +let storage: CohortStorage; +let fetcher: CohortFetcher; +let poller: CohortPoller; +let storageGetAllCohortIdsSpy: jest.SpyInstance; +let storageGetCohortSpy: jest.SpyInstance; +let storagePutSpy: jest.SpyInstance; + +beforeEach(() => { + storage = new InMemoryCohortStorage(); + fetcher = new CohortFetcher('', '', null); + poller = new CohortPoller(fetcher, storage, POLL_MILLIS); + + storageGetAllCohortIdsSpy = jest.spyOn(storage, 'getAllCohortIds'); + storageGetAllCohortIdsSpy.mockImplementation( + () => new Set(['c1', 'c2']), + ); + storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + storageGetCohortSpy.mockImplementation( + (cohortId: string) => OLD_COHORTS[cohortId], + ); + storagePutSpy = jest.spyOn(storage, 'put'); +}); + afterEach(() => { + poller.stop(); jest.clearAllMocks(); }); -test('', async () => { - const fetcher = new CohortFetcher('', '', null); +test('CohortPoller update success', async () => { const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); fetcherFetchSpy.mockImplementation( - async (cohortId: string) => COHORTS[cohortId], + async (cohortId: string) => NEW_COHORTS[cohortId], ); - const storage = new InMemoryCohortStorage(); - const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); - const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); - - const cohortPoller = new CohortPoller(fetcher, storage); - - await cohortPoller.update(new Set(['c1', 'c2'])); - - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c1', - CohortConfigDefaults.maxCohortSize, - undefined, - ); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c2', - CohortConfigDefaults.maxCohortSize, - undefined, - ); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); - expect(storageReplaceAllSpy).toHaveBeenCalledWith({ - c1: COHORTS['c1'], - c2: COHORTS['c2'], - }); + 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('cohort fetch all failed', async () => { - const fetcher = new CohortFetcher('', '', null); +test("CohortPoller update don't update unchanged cohort", async () => { const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); - fetcherFetchSpy.mockImplementation(async () => { - throw Error(); + fetcherFetchSpy.mockImplementation(async (cohortId) => { + if (cohortId === 'c1') { + return NEW_COHORTS['c1']; + } + return undefined; }); - const storage = new InMemoryCohortStorage(); - const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); - const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); - - const cohortPoller = new CohortPoller(fetcher, storage); - - await expect( - cohortPoller.update(new Set(['c1', 'c2', 'c3'])), - ).rejects.toThrow(); - - expect(fetcherFetchSpy).toHaveBeenCalled(); - expect(storageGetCohortSpy).toHaveBeenCalled(); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); + 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('cohort fetch partial failed', async () => { - const fetcher = new CohortFetcher('', '', null); +test("CohortPoller update error don't update cohort", async () => { const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); - fetcherFetchSpy.mockImplementation(async (cohortId: string) => { - if (cohortId === 'c3') { - throw Error(); + fetcherFetchSpy.mockImplementation(async (cohortId) => { + if (cohortId === 'c1') { + return NEW_COHORTS['c1']; } - return COHORTS[cohortId]; + throw Error(); }); - const storage = new InMemoryCohortStorage(); - const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); - const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); - - const cohortPoller = new CohortPoller(fetcher, storage); - - await expect( - cohortPoller.update(new Set(['c1', 'c2', 'c3'])), - ).rejects.toThrow(); - - expect(fetcherFetchSpy).toHaveBeenCalled(); - expect(storageGetCohortSpy).toHaveBeenCalled(); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); + 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('cohort fetch no change', async () => { - const fetcher = new CohortFetcher('', '', null); +test('CohortPoller update no lastModified still fetches cohort', async () => { const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); - fetcherFetchSpy.mockImplementation(async () => undefined); - - const storage = new InMemoryCohortStorage(); - const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); - const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + fetcherFetchSpy.mockImplementation(async (cohortId) => NEW_COHORTS[cohortId]); + storageGetCohortSpy.mockImplementation((cohortId: string) => { + const cohort = OLD_COHORTS[cohortId]; + if (cohortId === 'c2') { + delete cohort['lastModified']; + } + return cohort; + }); - const cohortPoller = new CohortPoller(fetcher, storage); + await poller.update(); - await cohortPoller.update(new Set(['c1', 'c2'])); + expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); expect(fetcherFetchSpy).toHaveBeenCalledWith( 'c1', - CohortConfigDefaults.maxCohortSize, - undefined, + OLD_COHORTS['c1'].lastModified, ); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c2', - CohortConfigDefaults.maxCohortSize, - undefined, - ); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); + expect(fetcherFetchSpy).toHaveBeenCalledWith('c2', undefined); + expect(storagePutSpy).toHaveBeenCalledTimes(2); + expect(storagePutSpy).toHaveBeenCalledWith(NEW_COHORTS['c1']); + expect(storagePutSpy).toHaveBeenCalledWith(NEW_COHORTS['c2']); }); -test('cohort fetch partial changed', async () => { - const fetcher = new CohortFetcher('', '', null); +test('CohortPoller polls every defined ms', async () => { const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); - fetcherFetchSpy.mockImplementation(async (cohortId: string) => { - if (cohortId === 'c1') { - return undefined; - } - return COHORTS[cohortId]; + fetcherFetchSpy.mockImplementation(async (cohortId) => { + return NEW_COHORTS[cohortId]; }); - const storage = new InMemoryCohortStorage(); - const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); - const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); - - const cohortPoller = new CohortPoller(fetcher, storage); - - await cohortPoller.update(new Set(['c1', 'c2'])); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c1', - CohortConfigDefaults.maxCohortSize, - undefined, - ); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c2', - CohortConfigDefaults.maxCohortSize, - undefined, - ); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(1); + 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('cohort fetch using maxCohortSize', async () => { - const fetcher = new CohortFetcher('', '', null); - const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); - fetcherFetchSpy.mockImplementation( - async (cohortId: string) => COHORTS[cohortId], - ); +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 storage = new InMemoryCohortStorage(); - const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); - const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + const pollerUpdateSpy = jest.spyOn(poller, 'update'); - const cohortPoller = new CohortPoller(fetcher, storage, 100); + await poller.start(); - await cohortPoller.update(new Set(['c1', 'c2'])); - expect(fetcherFetchSpy).toHaveBeenCalledWith('c1', 100, undefined); - expect(fetcherFetchSpy).toHaveBeenCalledWith('c2', 100, undefined); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(1); -}); + await sleep(100); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(0); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(0); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(0); -test('cohort fetch using lastModified', async () => { - const fetcher = new CohortFetcher('', '', null); - const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); - fetcherFetchSpy.mockImplementation( - async (cohortId: string, maxCohortSize: number, lastModified?) => { - if (lastModified === COHORTS[cohortId].lastModified) { - return undefined; - } - return COHORTS[cohortId]; - }, - ); + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(1); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(2); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(2); - const storage = new InMemoryCohortStorage(); - const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); - const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(2); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(4); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(2); - const cohortPoller = new CohortPoller(fetcher, storage); + await sleep(POLL_MILLIS); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(3); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(6); + expect(cohortApiGetCohortSpy).toHaveBeenCalledTimes(2); - await cohortPoller.update(new Set(['c1', 'c2'])); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c1', - CohortConfigDefaults.maxCohortSize, - undefined, - ); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c2', - CohortConfigDefaults.maxCohortSize, - undefined, - ); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(1); - jest.clearAllMocks(); + for (const cohortId of storage.getAllCohortIds()) { + expect(storageGetCohortSpy).toHaveBeenCalledWith(cohortId); + } + expect(storagePutSpy).toHaveBeenCalledTimes(0); - await cohortPoller.update(new Set(['c1', 'c2'])); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c1', - CohortConfigDefaults.maxCohortSize, - COHORTS['c1'].lastModified, - ); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c2', - CohortConfigDefaults.maxCohortSize, - COHORTS['c2'].lastModified, - ); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); - jest.clearAllMocks(); + await sleep(POLL_MILLIS / 2); - await cohortPoller.update(new Set(['c1', 'c2', 'c3'])); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c1', - CohortConfigDefaults.maxCohortSize, - COHORTS['c1'].lastModified, - ); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c2', - CohortConfigDefaults.maxCohortSize, - COHORTS['c2'].lastModified, - ); - expect(fetcherFetchSpy).toHaveBeenCalledWith( - 'c3', - CohortConfigDefaults.maxCohortSize, - undefined, - ); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c1'); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c2'); - expect(storageGetCohortSpy).toHaveBeenCalledWith('c3'); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(1); - jest.clearAllMocks(); + expect(storagePutSpy).toHaveBeenCalledTimes(6); }); -test('cohort fetch fails 2 times success 3rd', async () => { - const fetcher = new CohortFetcher('', '', null); +test('CohortPoller polls every defined ms with failures', async () => { const fetcherFetchSpy = jest.spyOn(fetcher, 'fetch'); - let tries = 0; - fetcherFetchSpy.mockImplementation(async (cohortId: string) => { - if (++tries === 3) { - return COHORTS[cohortId]; - } - throw Error(); - }); - const storage = new InMemoryCohortStorage(); - const storageReplaceAllSpy = jest.spyOn(storage, 'replaceAll'); - const storageGetCohortSpy = jest.spyOn(storage, 'getCohort'); - expect(storageReplaceAllSpy).toHaveBeenCalledTimes(0); + const pollerUpdateSpy = jest.spyOn(poller, 'update'); - const cohortPoller = new CohortPoller(fetcher, storage); + await poller.start(); - const start = new Date().getTime(); - await cohortPoller.update(new Set(['c1'])); - expect(new Date().getTime() - start).toBeGreaterThanOrEqual(2000); + await sleep(100); + expect(pollerUpdateSpy).toHaveBeenCalledTimes(0); + expect(storageGetCohortSpy).toHaveBeenCalledTimes(0); + expect(fetcherFetchSpy).toHaveBeenCalledTimes(0); + expect(storagePutSpy).toHaveBeenCalledTimes(0); - expect(fetcherFetchSpy).toHaveBeenCalledTimes(3); - expect(storageGetCohortSpy).toHaveBeenCalledTimes(1); - expect(storageReplaceAllSpy).toHaveBeenCalledWith({ - c1: COHORTS['c1'], + // 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/flagConfigPoller.test.ts b/packages/node/test/local/flagConfigPoller.test.ts index 2eb7ff7..8821c89 100644 --- a/packages/node/test/local/flagConfigPoller.test.ts +++ b/packages/node/test/local/flagConfigPoller.test.ts @@ -3,8 +3,8 @@ import { FlagConfigPoller, 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 { MockHttpClient } from './util/mockHttpClient'; @@ -153,6 +153,27 @@ const FLAG = [ return acc; }, {}); +const NEW_FLAGS = { + ...FLAG, + flag6: { + key: 'flag6', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['anewcohortid'], + }, + ], + ], + }, + ], + variants: {}, + }, +}; + afterEach(() => { // Note that if a test failed, and the poller has not stopped, // the test will hang and this won't be called. @@ -168,29 +189,29 @@ test('flagConfig poller success', async () => { new MockHttpClient(async () => ({ status: 200, body: '' })), ), new InMemoryFlagConfigCache(), - 2000, - new CohortPoller( - new CohortFetcher( - 'apikey', - 'secretkey', - new MockHttpClient(async () => ({ status: 200, body: '' })), - ), - cohortStorage, + 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 () => { - return { ...FLAG, flagPolled: { key: flagPolled++ } }; + ++flagPolled; + if (flagPolled == 1) return { ...FLAG, flagPolled: { key: flagPolled } }; + return { ...NEW_FLAGS, flagPolled: { key: flagPolled } }; }); // Return cohort with their own cohortId. jest - .spyOn(CohortFetcher.prototype, 'fetch') - .mockImplementation(async (cohortId) => { + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async (options) => { return { - cohortId: cohortId, + cohortId: options.cohortId, groupType: '', groupTypeId: 0, lastComputed: 0, @@ -204,36 +225,39 @@ test('flagConfig poller success', async () => { expect(flagPolled).toBe(1); expect(await poller.cache.getAll()).toStrictEqual({ ...FLAG, - flagPolled: { key: 0 }, + flagPolled: { key: flagPolled }, }); expect(cohortStorage.getCohort('hahahaha1').cohortId).toBe('hahahaha1'); expect(cohortStorage.getCohort('hahahaha2').cohortId).toBe('hahahaha2'); expect(cohortStorage.getCohort('hahahaha3').cohortId).toBe('hahahaha3'); expect(cohortStorage.getCohort('hahahaha4').cohortId).toBe('hahahaha4'); expect(cohortStorage.getCohort('hahaorgname1').cohortId).toBe('hahaorgname1'); + expect(cohortStorage.getCohort('newcohortid')).toBeUndefined(); expect(cohortStorage.getCohort('hahahaha1').lastModified).toBe(1); expect(cohortStorage.getCohort('hahahaha2').lastModified).toBe(1); expect(cohortStorage.getCohort('hahahaha3').lastModified).toBe(1); expect(cohortStorage.getCohort('hahahaha4').lastModified).toBe(1); expect(cohortStorage.getCohort('hahaorgname1').lastModified).toBe(1); - // On update, flag and cohort should both be updated. + // On update, flag, existing cohort doesn't update. await new Promise((f) => setTimeout(f, 2000)); expect(flagPolled).toBe(2); expect(await poller.cache.getAll()).toStrictEqual({ - ...FLAG, - flagPolled: { key: 1 }, + ...NEW_FLAGS, + flagPolled: { key: flagPolled }, }); expect(cohortStorage.getCohort('hahahaha1').cohortId).toBe('hahahaha1'); expect(cohortStorage.getCohort('hahahaha2').cohortId).toBe('hahahaha2'); expect(cohortStorage.getCohort('hahahaha3').cohortId).toBe('hahahaha3'); expect(cohortStorage.getCohort('hahahaha4').cohortId).toBe('hahahaha4'); expect(cohortStorage.getCohort('hahaorgname1').cohortId).toBe('hahaorgname1'); - expect(cohortStorage.getCohort('hahahaha1').lastModified).toBe(2); - expect(cohortStorage.getCohort('hahahaha2').lastModified).toBe(2); - expect(cohortStorage.getCohort('hahahaha3').lastModified).toBe(2); - expect(cohortStorage.getCohort('hahahaha4').lastModified).toBe(2); - expect(cohortStorage.getCohort('hahaorgname1').lastModified).toBe(2); + expect(cohortStorage.getCohort('anewcohortid').cohortId).toBe('anewcohortid'); + expect(cohortStorage.getCohort('hahahaha1').lastModified).toBe(1); + expect(cohortStorage.getCohort('hahahaha2').lastModified).toBe(1); + expect(cohortStorage.getCohort('hahahaha3').lastModified).toBe(1); + expect(cohortStorage.getCohort('hahahaha4').lastModified).toBe(1); + expect(cohortStorage.getCohort('hahaorgname1').lastModified).toBe(1); + expect(cohortStorage.getCohort('anewcohortid').lastModified).toBe(2); poller.stop(); }); @@ -244,15 +268,13 @@ test('flagConfig poller initial error', async () => { new MockHttpClient(async () => ({ status: 200, body: '' })), ), new InMemoryFlagConfigCache(), - 10, - new CohortPoller( - new CohortFetcher( - 'apikey', - 'secretkey', - new MockHttpClient(async () => ({ status: 200, body: '' })), - ), - new InMemoryCohortStorage(), + new InMemoryCohortStorage(), + new CohortFetcher( + 'apikey', + 'secretkey', + new MockHttpClient(async () => ({ status: 200, body: '' })), ), + 10, ); // Fetch returns FLAG, but cohort fails. jest @@ -260,9 +282,11 @@ test('flagConfig poller initial error', async () => { .mockImplementation(async () => { return FLAG; }); - jest.spyOn(CohortPoller.prototype, 'update').mockImplementation(async () => { - throw new Error(); - }); + jest + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async () => { + throw new Error(); + }); // FLAG should be empty, as cohort failed. Poller should be stopped immediately and test exists cleanly. await poller.start(); expect(await poller.cache.getAll()).toStrictEqual({}); @@ -275,39 +299,50 @@ test('flagConfig poller initial success, polling error and use old flags', async new MockHttpClient(async () => ({ status: 200, body: '' })), ), new InMemoryFlagConfigCache(), - 2000, - new CohortPoller( - new CohortFetcher( - 'apikey', - 'secretkey', - new MockHttpClient(async () => ({ status: 200, body: '' })), - ), - new InMemoryCohortStorage(), + 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 cohortPolled = 0; + let flagPolled = 0; jest .spyOn(FlagConfigFetcher.prototype, 'fetch') .mockImplementation(async () => { - if (cohortPolled === 0) return FLAG; - return {}; + if (++flagPolled === 1) return FLAG; + return NEW_FLAGS; }); // Only success on first poll and fail on all later ones. - jest.spyOn(CohortPoller.prototype, 'update').mockImplementation(async () => { - if (cohortPolled++ === 0) return; - throw new Error(); - }); + 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 FLAG. await poller.start(); expect(await poller.cache.getAll()).toStrictEqual(FLAG); - expect(cohortPolled).toBe(1); + expect(flagPolled).toBe(1); - // Second poll should fail. The different flag should not be updated. + // Second poll flags with new cohort should fail when new cohort download failed. + // The different flag should not be updated. await new Promise((f) => setTimeout(f, 2000)); - expect(cohortPolled).toBe(2); + expect(flagPolled).toBeGreaterThanOrEqual(2); expect(await poller.cache.getAll()).toStrictEqual(FLAG); poller.stop(); diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 34a2eb7..c008615 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import { FlagConfigPoller, InMemoryFlagConfigCache } from 'src/index'; +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'; @@ -17,7 +19,16 @@ const getTestObjs = ({ serverUrl = 'http://localhostxxxx:00000000', 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: '' })), + ), + }; let dataI = 0; const data = [ '[{"key": "fetcher-a", "variants": {}, "segments": []}]', @@ -40,8 +51,9 @@ const getTestObjs = ({ new FlagConfigPoller( fetchObj.fetcher, cache, + fetchObj.cohortStorage, + fetchObj.cohortFetcher, pollingIntervalMillis, - null, debug, ), cache, @@ -51,7 +63,8 @@ const getTestObjs = ({ streamFlagTryDelayMillis, streamFlagRetryDelayMillis, serverUrl, - null, + fetchObj.cohortStorage, + fetchObj.cohortFetcher, debug, ); return { From 8ea58b9586f766f5bbddcb8c7592a192df3b3e01 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Sat, 13 Jul 2024 01:31:02 -0700 Subject: [PATCH 32/48] fixed typo, env, and err msg --- packages/node/src/local/cohort/cohort-api.ts | 4 +++- packages/node/src/local/updater.ts | 2 +- packages/node/test/local/client.eu.test.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index 5c009f1..95d1f76 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -73,7 +73,9 @@ export class SdkCohortApi implements CohortApi { } else if (response.status == 413) { throw Error(`Cohort error response: size > ${options.maxCohortSize}`); } else { - throw Error(`Cohort error resposne: status=${response.status}`); + throw Error( + `Cohort error response: status ${response.status}, body ${response.body}`, + ); } } } diff --git a/packages/node/src/local/updater.ts b/packages/node/src/local/updater.ts index e50d2ed..1783865 100644 --- a/packages/node/src/local/updater.ts +++ b/packages/node/src/local/updater.ts @@ -116,7 +116,7 @@ export class FlagConfigUpdaterBase { }) .catch((err) => { this.logger.warn( - `[Experiment] Cohort download failed ${cohortId}, using existing cohort`, + `[Experiment] Cohort download failed ${cohortId}, using existing cohort if exist`, err, ); failedCohortIds.add(cohortId); diff --git a/packages/node/test/local/client.eu.test.ts b/packages/node/test/local/client.eu.test.ts index 738912f..12a49ae 100644 --- a/packages/node/test/local/client.eu.test.ts +++ b/packages/node/test/local/client.eu.test.ts @@ -5,7 +5,7 @@ import { Experiment } from 'src/factory'; dotenv.config({ path: path.join(__dirname, '../../', '.env') }); -if (!process.env['API_KEY'] && !process.env['SECRET_KEY']) { +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', ); From 7cd8adbe0256cf3d0474bdac13e0450fe2ac6605 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Sat, 13 Jul 2024 01:42:35 -0700 Subject: [PATCH 33/48] fix gh action --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f29a0fd..813ae0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,3 +41,5 @@ jobs: 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 }} From ffc30a6c242533fc71ad28dc5d838715af87f23f Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Sat, 13 Jul 2024 02:22:47 -0700 Subject: [PATCH 34/48] added streamer test, added streamer onInitUpdate, clearer logic --- packages/node/src/local/client.ts | 2 +- packages/node/src/local/poller.ts | 28 +- packages/node/src/local/stream-flag-api.ts | 30 +- packages/node/src/local/streamer.ts | 24 +- packages/node/src/remote/client.ts | 2 +- packages/node/src/util/config.ts | 15 +- .../node/test/local/flagConfigPoller.test.ts | 7 +- .../test/local/flagConfigStreamer.test.ts | 938 +++++++++--------- 8 files changed, 530 insertions(+), 516 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 7460b82..64de75d 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -68,7 +68,7 @@ export class LocalEvaluationClient { constructor( apiKey: string, - config: LocalEvaluationConfig, + config?: LocalEvaluationConfig, flagConfigCache?: FlagConfigCache, httpClient: HttpClient = new FetchHttpClient(config?.httpAgent), streamEventSourceFactory: StreamEventSourceFactory = (url, params) => diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index 6c98dbd..4ca1332 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -2,7 +2,7 @@ import { CohortStorage } from 'src/types/cohort'; import { LocalEvaluationDefaults } from '../types/config'; import { FlagConfigCache } from '../types/flag'; -import { doWithBackoff, BackoffPolicy } from '../util/backoff'; +import { BackoffPolicy, doWithBackoffFailLoudly } from '../util/backoff'; import { CohortFetcher } from './cohort/fetcher'; import { FlagConfigFetcher } from './fetcher'; @@ -61,18 +61,20 @@ export class FlagConfigPoller }, this.pollingIntervalMillis); // Fetch initial flag configs and await the result. - await doWithBackoff(async () => { - try { - const flagConfigs = await this.fetcher.fetch(); - await super._update(flagConfigs, true, onChange); - } catch (e) { - this.logger.error( - '[Experiment] flag config initial poll failed, stopping', - e, - ); - this.stop(); - } - }, BACKOFF_POLICY); + try { + const flagConfigs = await doWithBackoffFailLoudly( + async () => await this.fetcher.fetch(), + BACKOFF_POLICY, + ); + await super._update(flagConfigs, true, onChange); + } catch (e) { + this.logger.error( + '[Experiment] flag config initial poll failed, stopping', + e, + ); + this.stop(); + throw e; + } } } 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 eca4d77..d9eaecf 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -67,27 +67,19 @@ export class FlagConfigStreamer 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(); }; - let isInitUpdate = true; + this.stream.onInitUpdate = async (flagConfigs) => { + this.logger.debug('[Experiment] streamer - receives updates'); + await super._update(flagConfigs, true, onChange); + }; this.stream.onUpdate = async (flagConfigs) => { this.logger.debug('[Experiment] streamer - receives updates'); - if (isInitUpdate) { - isInitUpdate = false; - try { - super._update(flagConfigs, true, onChange); - } catch { - // Flag update failed on init, stop, fallback to poller. - await this.poller.start(onChange); - this.startRetryStreamInterval(); - } - } else { - super._update(flagConfigs, false, onChange); - } + await super._update(flagConfigs, false, onChange); }; try { @@ -102,11 +94,11 @@ export class FlagConfigStreamer libraryVersion: PACKAGE_VERSION, }); this.poller.stop(); - this.logger.debug('[Experiment] streamer - start stream success'); + this.logger.debug('[Experiment] streamer - start flags 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/remote/client.ts b/packages/node/src/remote/client.ts index 5a3ac3f..9a62141 100644 --- a/packages/node/src/remote/client.ts +++ b/packages/node/src/remote/client.ts @@ -32,7 +32,7 @@ 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 = populateRemoteConfigDefaults(config); this.evaluationApi = new SdkEvaluationApi( diff --git a/packages/node/src/util/config.ts b/packages/node/src/util/config.ts index c86ab9f..11c0c47 100644 --- a/packages/node/src/util/config.ts +++ b/packages/node/src/util/config.ts @@ -11,12 +11,12 @@ import { } from '..'; export const populateRemoteConfigDefaults = ( - customConfig: RemoteEvaluationConfig, + customConfig?: RemoteEvaluationConfig, ): RemoteEvaluationConfig => { const config = { ...RemoteEvaluationDefaults, ...customConfig }; const isEu = config.serverZone.toLowerCase() === EU_SERVER_URLS.name; - if (!customConfig.serverUrl) { + if (!customConfig?.serverUrl) { config.serverUrl = isEu ? EU_SERVER_URLS.remote : RemoteEvaluationDefaults.serverUrl; @@ -25,22 +25,25 @@ export const populateRemoteConfigDefaults = ( }; export const populateLocalConfigDefaults = ( - customConfig: LocalEvaluationConfig, + customConfig?: LocalEvaluationConfig, ): LocalEvaluationConfig => { const config = { ...LocalEvaluationDefaults, ...customConfig }; const isEu = config.serverZone.toLowerCase() === EU_SERVER_URLS.name; - if (!customConfig.serverUrl) { + if (!customConfig?.serverUrl) { config.serverUrl = isEu ? EU_SERVER_URLS.flags : LocalEvaluationDefaults.serverUrl; } - if (!customConfig.streamServerUrl) { + if (!customConfig?.streamServerUrl) { config.streamServerUrl = isEu ? EU_SERVER_URLS.stream : LocalEvaluationDefaults.streamServerUrl; } - if (customConfig.cohortConfig && !customConfig.cohortConfig.cohortServerUrl) { + if ( + customConfig?.cohortConfig && + !customConfig?.cohortConfig.cohortServerUrl + ) { config.cohortConfig.cohortServerUrl = isEu ? EU_SERVER_URLS.cohort : CohortConfigDefaults.cohortServerUrl; diff --git a/packages/node/test/local/flagConfigPoller.test.ts b/packages/node/test/local/flagConfigPoller.test.ts index 8821c89..01bbdc2 100644 --- a/packages/node/test/local/flagConfigPoller.test.ts +++ b/packages/node/test/local/flagConfigPoller.test.ts @@ -288,7 +288,12 @@ test('flagConfig poller initial error', async () => { throw new Error(); }); // FLAG should be empty, as cohort failed. Poller should be stopped immediately and test exists cleanly. - await poller.start(); + try { + // Should throw when init failed. + await poller.start(); + fail(); + // eslint-disable-next-line no-empty + } catch {} expect(await poller.cache.getAll()).toStrictEqual({}); }); diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index c008615..a06176e 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -1,6 +1,7 @@ import assert from 'assert'; 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'; @@ -9,6 +10,16 @@ import { FlagConfigStreamer } from 'src/local/streamer'; import { MockHttpClient } from './util/mockHttpClient'; import { getNewClient } from './util/mockStreamEventSource'; +const FLAG_WITH_COHORT = `[{"key":"flag2","segments":[{ + "conditions":[[{"op":"set contains any","selector":["context","user","cohort_ids"],"values":["hahahaha2"]}]], + "metadata":{"segmentName": "Segment 1"},"variant": "off" + }],"variants": {}}]`; + +let updater; +afterEach(() => { + updater?.stop(); +}); + const getTestObjs = ({ pollingIntervalMillis = 1000, streamFlagConnTimeoutMillis = 1000, @@ -17,6 +28,10 @@ const getTestObjs = ({ streamFlagRetryDelayMillis = 15000, apiKey = 'client-xxxx', serverUrl = 'http://localhostxxxx:00000000', + fetcherData = [ + '[{"key": "fetcher-a", "variants": {}, "segments": []}]', + '[{"key": "fetcher-b", "variants": {}, "segments": []}]', + ], debug = false, }) => { const fetchObj = { @@ -30,15 +45,11 @@ const getTestObjs = ({ ), }; 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 = () => { @@ -46,7 +57,7 @@ const getTestObjs = ({ }; const cache = new InMemoryFlagConfigCache(); const mockClient = getNewClient(); - const updater = new FlagConfigStreamer( + updater = new FlagConfigStreamer( apiKey, new FlagConfigPoller( fetchObj.fetcher, @@ -80,325 +91,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 () => { @@ -408,94 +344,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 () => { @@ -503,31 +429,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 () => { @@ -535,27 +456,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 () => { @@ -563,29 +479,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 () => { @@ -594,39 +505,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 () => { @@ -635,49 +541,143 @@ 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 } = 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: `[{"key":"flag2","segments":[{ + "conditions":[[{"op":"set contains any","selector":["context","user","cohort_ids"],"values":["hahahaha2"]}]], + "metadata":{"segmentName": "Segment 1"},"variant": "off" + }],"variants": {}}]`, + }); + await new Promise((r) => setTimeout(r, 1000)); // Wait for poller to poll. + expect(fetchObj.fetchCalls).toBe(0); + expect(mockClient.numCreated).toBe(1); + updater.stop(); +}); + +test('FlagConfigUpdater.connect, flag success, cohort fail, retry fail, initialization fails, 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, + }); + // Return cohort with their own cohortId. + updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: FLAG_WITH_COHORT, + }); + // Second try + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: FLAG_WITH_COHORT, + }); + + expect(fetchObj.fetchCalls).toBeGreaterThanOrEqual(1); + expect(mockClient.numCreated).toBe(2); + updater.stop(); +}); + +test('FlagConfigUpdater.connect, flag success, cohort fail, initialization fails, fallback to poller, poller fails, streamer start error', async () => { + jest.setTimeout(10000); + jest + .spyOn(SdkCohortApi.prototype, 'getCohort') + .mockImplementation(async () => { + throw Error(); + }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 30000, + streamFlagTryAttempts: 1, + streamFlagTryDelayMillis: 1000, + streamFlagRetryDelayMillis: 100000, + fetcherData: [ + FLAG_WITH_COHORT, + FLAG_WITH_COHORT, + FLAG_WITH_COHORT, + FLAG_WITH_COHORT, + FLAG_WITH_COHORT, + ], + }); + // Return cohort with their own cohortId. + const startPromise = updater.start(); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ + data: FLAG_WITH_COHORT, + }); + // Stream failed, poller should fail as well given the flags and cohort mock. + expect(fetchObj.fetchCalls).toBeGreaterThanOrEqual(1); + expect(mockClient.numCreated).toBe(1); + // Test should exit cleanly as updater.start() failure should stop the streamer. + try { + await startPromise; + fail(); + // eslint-disable-next-line no-empty + } catch {} +}); From b60bb56ba109280a5d680fe7ccbf63930b844afb Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 25 Jul 2024 11:35:24 -0700 Subject: [PATCH 35/48] add test, add return types, move a util func --- packages/node/src/local/updater.ts | 29 +-- packages/node/src/util/cohort.ts | 15 +- .../node/test/local/flagConfigPoller.test.ts | 184 +----------------- .../test/local/flagConfigStreamer.test.ts | 77 ++++++-- .../node/test/local/flagConfigUpdater.test.ts | 115 +++++++++++ packages/node/test/local/util/flags.ts | 164 ++++++++++++++++ 6 files changed, 373 insertions(+), 211 deletions(-) create mode 100644 packages/node/test/local/flagConfigUpdater.test.ts create mode 100644 packages/node/test/local/util/flags.ts diff --git a/packages/node/src/local/updater.ts b/packages/node/src/local/updater.ts index 1783865..f07c903 100644 --- a/packages/node/src/local/updater.ts +++ b/packages/node/src/local/updater.ts @@ -97,14 +97,11 @@ export class FlagConfigUpdaterBase { } } - private async downloadNewCohorts( + protected async downloadNewCohorts( cohortIds: Set, ): Promise> { const oldCohortIds = this.cohortStorage?.getAllCohortIds(); - const newCohortIds = FlagConfigUpdaterBase.setSubtract( - cohortIds, - oldCohortIds, - ); + const newCohortIds = CohortUtils.setSubtract(cohortIds, oldCohortIds); const failedCohortIds = new Set(); const cohortDownloadPromises = [...newCohortIds].map((cohortId) => this.cohortFetcher @@ -126,10 +123,11 @@ export class FlagConfigUpdaterBase { return failedCohortIds; } - private async filterFlagConfigsWithFullCohorts( + protected async filterFlagConfigsWithFullCohorts( flagConfigs: Record, - ) { + ): Promise> { const newFlagConfigs = {}; + const availableCohortIds = this.cohortStorage.getAllCohortIds(); for (const flagKey in flagConfigs) { // Get cohorts for this flag. const cohortIds = CohortUtils.extractCohortIdsFromFlag( @@ -140,9 +138,7 @@ export class FlagConfigUpdaterBase { // If any cohort failed, don't use the new flag. const updateFlag = cohortIds.size === 0 || - [...cohortIds] - .map((id) => this.cohortStorage.getCohort(id)) - .reduce((acc, cur) => acc && cur); + CohortUtils.setSubtract(cohortIds, availableCohortIds).size === 0; if (updateFlag) { newFlagConfigs[flagKey] = flagConfigs[flagKey]; @@ -160,8 +156,10 @@ export class FlagConfigUpdaterBase { return newFlagConfigs; } - private async removeUnusedCohorts(validCohortIds: Set) { - const cohortIdsToBeRemoved = FlagConfigUpdaterBase.setSubtract( + protected async removeUnusedCohorts( + validCohortIds: Set, + ): Promise { + const cohortIdsToBeRemoved = CohortUtils.setSubtract( this.cohortStorage.getAllCohortIds(), validCohortIds, ); @@ -169,11 +167,4 @@ export class FlagConfigUpdaterBase { this.cohortStorage.delete(id); }); } - - private static setSubtract(one: Set, other: Set) { - const result = new Set(one); - other.forEach((v) => result.delete(v)); - - return result; - } } diff --git a/packages/node/src/util/cohort.ts b/packages/node/src/util/cohort.ts index ae452a9..d80fb64 100644 --- a/packages/node/src/util/cohort.ts +++ b/packages/node/src/util/cohort.ts @@ -85,10 +85,10 @@ export class CohortUtils { return cohortIdsByGroup; } - private static mergeBIntoA( + public static mergeBIntoA( a: Record>, b: Record>, - ) { + ): void { for (const groupType in b) { if (!(groupType in a)) { a[groupType] = new Set(); @@ -98,11 +98,20 @@ export class CohortUtils { } } - private static mergeAllValues(a: Record>) { + 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/test/local/flagConfigPoller.test.ts b/packages/node/test/local/flagConfigPoller.test.ts index 01bbdc2..298ae5c 100644 --- a/packages/node/test/local/flagConfigPoller.test.ts +++ b/packages/node/test/local/flagConfigPoller.test.ts @@ -7,173 +7,9 @@ import { SdkCohortApi } from 'src/local/cohort/cohort-api'; import { CohortFetcher } from 'src/local/cohort/fetcher'; import { InMemoryCohortStorage } from 'src/local/cohort/storage'; +import { FLAGS, NEW_FLAGS } from './util/flags'; import { MockHttpClient } from './util/mockHttpClient'; -const FLAG = [ - { - key: 'flag1', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['hahahaha1'], - }, - ], - ], - }, - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'off', - }, - ], - variants: {}, - }, - { - key: 'flag2', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['hahahaha2'], - }, - ], - ], - metadata: { - segmentName: 'Segment 1', - }, - variant: 'off', - }, - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'off', - }, - ], - variants: {}, - }, - { - 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: ['hahahaha3'], - }, - ], - ], - variant: 'off', - }, - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cocoids'], - values: ['nohaha'], - }, - ], - ], - variant: 'off', - }, - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'off', - }, - ], - variants: {}, - }, - { - key: 'flag5', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['hahahaha3', 'hahahaha4'], - }, - ], - ], - }, - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'groups', 'org name', 'cohort_ids'], - values: ['hahaorgname1'], - }, - ], - ], - metadata: { - segmentName: 'Segment 1', - }, - }, - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'gg', 'org name', 'cohort_ids'], - values: ['nohahaorgname'], - }, - ], - ], - metadata: { - segmentName: 'Segment 1', - }, - }, - ], - variants: {}, - }, -].reduce((acc, flag) => { - acc[flag.key] = flag; - return acc; -}, {}); - -const NEW_FLAGS = { - ...FLAG, - flag6: { - key: 'flag6', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['anewcohortid'], - }, - ], - ], - }, - ], - variants: {}, - }, -}; - afterEach(() => { // Note that if a test failed, and the poller has not stopped, // the test will hang and this won't be called. @@ -203,7 +39,7 @@ test('flagConfig poller success', async () => { .spyOn(FlagConfigFetcher.prototype, 'fetch') .mockImplementation(async () => { ++flagPolled; - if (flagPolled == 1) return { ...FLAG, flagPolled: { key: flagPolled } }; + if (flagPolled == 1) return { ...FLAGS, flagPolled: { key: flagPolled } }; return { ...NEW_FLAGS, flagPolled: { key: flagPolled } }; }); // Return cohort with their own cohortId. @@ -224,7 +60,7 @@ test('flagConfig poller success', async () => { await poller.start(); expect(flagPolled).toBe(1); expect(await poller.cache.getAll()).toStrictEqual({ - ...FLAG, + ...FLAGS, flagPolled: { key: flagPolled }, }); expect(cohortStorage.getCohort('hahahaha1').cohortId).toBe('hahahaha1'); @@ -276,18 +112,18 @@ test('flagConfig poller initial error', async () => { ), 10, ); - // Fetch returns FLAG, but cohort fails. + // Fetch returns FLAGS, but cohort fails. jest .spyOn(FlagConfigFetcher.prototype, 'fetch') .mockImplementation(async () => { - return FLAG; + return FLAGS; }); jest .spyOn(SdkCohortApi.prototype, 'getCohort') .mockImplementation(async () => { throw new Error(); }); - // FLAG should be empty, as cohort failed. Poller should be stopped immediately and test exists cleanly. + // 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(); @@ -318,7 +154,7 @@ test('flagConfig poller initial success, polling error and use old flags', async jest .spyOn(FlagConfigFetcher.prototype, 'fetch') .mockImplementation(async () => { - if (++flagPolled === 1) return FLAG; + if (++flagPolled === 1) return FLAGS; return NEW_FLAGS; }); // Only success on first poll and fail on all later ones. @@ -339,16 +175,16 @@ test('flagConfig poller initial success, polling error and use old flags', async throw new Error(); }); - // First poll should return FLAG. + // First poll should return FLAGS. await poller.start(); - expect(await poller.cache.getAll()).toStrictEqual(FLAG); + 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 new Promise((f) => setTimeout(f, 2000)); expect(flagPolled).toBeGreaterThanOrEqual(2); - expect(await poller.cache.getAll()).toStrictEqual(FLAG); + expect(await poller.cache.getAll()).toStrictEqual(FLAGS); poller.stop(); }); diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index a06176e..8e967df 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -10,8 +10,10 @@ import { FlagConfigStreamer } from 'src/local/streamer'; import { MockHttpClient } from './util/mockHttpClient'; import { getNewClient } from './util/mockStreamEventSource'; -const FLAG_WITH_COHORT = `[{"key":"flag2","segments":[{ - "conditions":[[{"op":"set contains any","selector":["context","user","cohort_ids"],"values":["hahahaha2"]}]], +const getFlagWithCohort = ( + cohortId, +) => `[{"key":"flag_${cohortId}","segments":[{ + "conditions":[[{"op":"set contains any","selector":["context","user","cohort_ids"],"values":["${cohortId}"]}]], "metadata":{"segmentName": "Segment 1"},"variant": "off" }],"variants": {}}]`; @@ -584,7 +586,7 @@ test.todo( ); test('FlagConfigUpdater.connect, flag success, cohort success', async () => { - const { fetchObj, mockClient, updater } = getTestObjs({ + const { fetchObj, mockClient, updater, cache } = getTestObjs({ pollingIntervalMillis: 100, }); // Return cohort with their own cohortId. @@ -604,14 +606,59 @@ test('FlagConfigUpdater.connect, flag success, cohort success', async () => { updater.start(); await mockClient.client.doOpen({ type: 'open' }); await mockClient.client.doMsg({ - data: `[{"key":"flag2","segments":[{ - "conditions":[[{"op":"set contains any","selector":["context","user","cohort_ids"],"values":["hahahaha2"]}]], - "metadata":{"segmentName": "Segment 1"},"variant": "off" - }],"variants": {}}]`, + data: getFlagWithCohort('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(); + updater.stop(); +}); + +test('FlagConfigUpdater.connect, flag success, 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: getFlagWithCohort('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: getFlagWithCohort('cohort2'), + }); + await new Promise((r) => setTimeout(r, 1000)); // Wait for poller to poll. + + expect(fetchObj.fetchCalls).toBeGreaterThanOrEqual(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')).toBeUndefined(); // Won't add flag to cache if new cohort fails. updater.stop(); }); @@ -632,12 +679,12 @@ test('FlagConfigUpdater.connect, flag success, cohort fail, retry fail, initiali updater.start(); await mockClient.client.doOpen({ type: 'open' }); await mockClient.client.doMsg({ - data: FLAG_WITH_COHORT, + data: getFlagWithCohort('cohort1'), }); // Second try await mockClient.client.doOpen({ type: 'open' }); await mockClient.client.doMsg({ - data: FLAG_WITH_COHORT, + data: getFlagWithCohort('cohort1'), }); expect(fetchObj.fetchCalls).toBeGreaterThanOrEqual(1); @@ -658,18 +705,18 @@ test('FlagConfigUpdater.connect, flag success, cohort fail, initialization fails streamFlagTryDelayMillis: 1000, streamFlagRetryDelayMillis: 100000, fetcherData: [ - FLAG_WITH_COHORT, - FLAG_WITH_COHORT, - FLAG_WITH_COHORT, - FLAG_WITH_COHORT, - FLAG_WITH_COHORT, + getFlagWithCohort('cohort1'), + getFlagWithCohort('cohort1'), + getFlagWithCohort('cohort1'), + getFlagWithCohort('cohort1'), + getFlagWithCohort('cohort1'), ], }); // Return cohort with their own cohortId. const startPromise = updater.start(); await mockClient.client.doOpen({ type: 'open' }); await mockClient.client.doMsg({ - data: FLAG_WITH_COHORT, + data: getFlagWithCohort('cohort1'), }); // Stream failed, poller should fail as well given the flags and cohort mock. expect(fetchObj.fetchCalls).toBeGreaterThanOrEqual(1); diff --git a/packages/node/test/local/flagConfigUpdater.test.ts b/packages/node/test/local/flagConfigUpdater.test.ts new file mode 100644 index 0000000..0cfde15 --- /dev/null +++ b/packages/node/test/local/flagConfigUpdater.test.ts @@ -0,0 +1,115 @@ +/* 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/flags'; +import { MockHttpClient } from './util/mockHttpClient'; + +class TestFlagConfigUpdaterBase extends FlagConfigUpdaterBase { + public async update(flagConfigs, isInit, onChange) { + await super._update(flagConfigs, isInit, onChange); + } + public async downloadNewCohorts(cohortIds) { + return await super.downloadNewCohorts(cohortIds); + } + public async filterFlagConfigsWithFullCohorts(flagConfigs) { + return await super.filterFlagConfigsWithFullCohorts(flagConfigs); + } + 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, true, () => {}); + expect(await updater.cache.getAll()).toStrictEqual(FLAGS); +}); + +test('FlagConfigUpdaterBase, update no error if not init', async () => { + await updater.update(NEW_FLAGS, false, () => {}); + expect(await updater.cache.getAll()).toStrictEqual(FLAGS); +}); + +test('FlagConfigUpdaterBase, update raise error if not init', async () => { + await expect(updater.update(NEW_FLAGS, true, () => {})).rejects.toThrow(); +}); + +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.filterFlagConfigsWithFullCohorts', async () => { + CohortUtils.extractCohortIds(FLAGS).forEach((cohortId) => { + updater.cohortStorage.put(createCohort(cohortId)); + }); + + const filteredFlags = await updater.filterFlagConfigsWithFullCohorts( + NEW_FLAGS, + ); + expect(filteredFlags).toStrictEqual(FLAGS); +}); + +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/flags.ts b/packages/node/test/local/util/flags.ts new file mode 100644 index 0000000..683400a --- /dev/null +++ b/packages/node/test/local/util/flags.ts @@ -0,0 +1,164 @@ +export const FLAGS = [ + { + key: 'flag1', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['hahahaha1'], + }, + ], + ], + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: {}, + }, + { + key: 'flag2', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['hahahaha2'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + variant: 'off', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: {}, + }, + { + 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: ['hahahaha3'], + }, + ], + ], + variant: 'off', + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cocoids'], + values: ['nohaha'], + }, + ], + ], + variant: 'off', + }, + { + metadata: { + segmentName: 'All Other Users', + }, + variant: 'off', + }, + ], + variants: {}, + }, + { + key: 'flag5', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['hahahaha3', 'hahahaha4'], + }, + ], + ], + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'groups', 'org name', 'cohort_ids'], + values: ['hahaorgname1'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + }, + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'gg', 'org name', 'cohort_ids'], + values: ['nohahaorgname'], + }, + ], + ], + metadata: { + segmentName: 'Segment 1', + }, + }, + ], + variants: {}, + }, +].reduce((acc, flag) => { + acc[flag.key] = flag; + return acc; +}, {}); + +export const NEW_FLAGS = { + ...FLAGS, + flag6: { + key: 'flag6', + segments: [ + { + conditions: [ + [ + { + op: 'set contains any', + selector: ['context', 'user', 'cohort_ids'], + values: ['anewcohortid'], + }, + ], + ], + }, + ], + variants: {}, + }, +}; From 1690d80a0a556259f0349f2d2df645debdfa16c4 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 25 Jul 2024 14:39:54 -0700 Subject: [PATCH 36/48] fix null cohortUpdater when no cohort configs --- packages/node/src/local/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 64de75d..a32148f 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -56,7 +56,7 @@ export class LocalEvaluationClient { private readonly updater: FlagConfigUpdater; private readonly assignmentService: AssignmentService; private readonly evaluation: EvaluationEngine; - private readonly cohortUpdater: CohortUpdater; + private readonly cohortUpdater?: CohortUpdater; /** * Directly access the client's flag config cache. @@ -264,7 +264,7 @@ export class LocalEvaluationClient { */ public async start(): Promise { await this.updater.start(); - await this.cohortUpdater.start(); + await this.cohortUpdater?.start(); } /** @@ -274,6 +274,6 @@ export class LocalEvaluationClient { */ public stop(): void { this.updater.stop(); - this.cohortUpdater.stop(); + this.cohortUpdater?.stop(); } } From d5e4cd78a127482546a43e9bea3d996f7f5da4d1 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 29 Jul 2024 17:42:07 -0700 Subject: [PATCH 37/48] fix poller interval and comments --- packages/node/src/local/client.ts | 2 +- packages/node/src/local/cohort/poller.ts | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index a32148f..50be6ee 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -101,7 +101,7 @@ export class LocalEvaluationClient { this.cohortUpdater = new CohortPoller( cohortFetcher, this.cohortStorage, - 60000, // this.config.cohortConfig?.cohortPollingIntervalMillis, + 60000, this.config.debug, ); } diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index aec0627..e7ab9bd 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -1,5 +1,4 @@ -import { CohortStorage } from 'src/types/cohort'; - +import { CohortStorage } from '../../types/cohort'; import { ConsoleLogger } from '../../util/logger'; import { Logger } from '../../util/logger'; @@ -18,7 +17,7 @@ export class CohortPoller implements CohortUpdater { constructor( fetcher: CohortFetcher, storage: CohortStorage, - pollingIntervalMillis = 60, + pollingIntervalMillis = 60000, debug = false, ) { this.fetcher = fetcher; @@ -28,9 +27,7 @@ export class CohortPoller implements CohortUpdater { } /** - * You must call this function to begin polling for flag config updates. - * The promise returned by this function is resolved when the initial call - * to fetch the flag configuration completes. + * You must call this function to begin polling for cohort updates. * * Calling this function while the poller is already running does nothing. */ @@ -43,14 +40,14 @@ export class CohortPoller implements CohortUpdater { try { await this.update(onChange); } catch (e) { - this.logger.debug('[Experiment] flag config update failed', e); + this.logger.debug('[Experiment] cohort update failed', e); } }, this.pollingIntervalMillis); } } /** - * Stop polling for flag configurations. + * Stop polling for cohorts. * * Calling this function while the poller is not running will do nothing. */ From 1010ec4d3785668742e28799acccb006fba5582a Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 29 Jul 2024 17:44:50 -0700 Subject: [PATCH 38/48] fix relative imports --- packages/node/src/local/client.ts | 6 +++--- packages/node/src/local/cohort/cohort-api.ts | 3 ++- packages/node/src/local/cohort/fetcher.ts | 15 +++++++-------- packages/node/src/local/cohort/storage.ts | 2 +- packages/node/src/local/cohort/updater.ts | 2 +- packages/node/src/local/poller.ts | 3 +-- packages/node/src/local/updater.ts | 7 +++---- 7 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 50be6ee..8dc393c 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -5,15 +5,13 @@ import { topologicalSort, } from '@amplitude/experiment-core'; import EventSource from 'eventsource'; -import { USER_GROUP_TYPE } from 'src/types/cohort'; -import { CohortUtils } from 'src/util/cohort'; -import { populateLocalConfigDefaults } from 'src/util/config'; import { Assignment, AssignmentService } from '../assignment/assignment'; 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, @@ -23,6 +21,8 @@ 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'; diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index 95d1f76..3986499 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -1,5 +1,6 @@ import { HttpClient } from '@amplitude/experiment-core'; -import { Cohort } from 'src/types/cohort'; + +import { Cohort } from '../../types/cohort'; export type GetCohortOptions = { libraryName: string; diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts index a9d78d2..3673995 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -1,12 +1,11 @@ -import { WrapperClient } from 'src/transport/http'; -import { Cohort } from 'src/types/cohort'; -import { CohortConfigDefaults } from 'src/types/config'; -import { HttpClient } from 'src/types/transport'; -import { BackoffPolicy, doWithBackoffFailLoudly } from 'src/util/backoff'; -import { ConsoleLogger, Logger } from 'src/util/logger'; -import { Mutex, Executor } from 'src/util/threading'; - import { version as PACKAGE_VERSION } from '../../../gen/version'; +import { WrapperClient } from '../../transport/http'; +import { Cohort } from '../../types/cohort'; +import { CohortConfigDefaults } from '../../types/config'; +import { HttpClient } from '../../types/transport'; +import { BackoffPolicy, doWithBackoffFailLoudly } from '../../util/backoff'; +import { ConsoleLogger, Logger } from '../../util/logger'; +import { Mutex, Executor } from '../../util/threading'; import { SdkCohortApi } from './cohort-api'; diff --git a/packages/node/src/local/cohort/storage.ts b/packages/node/src/local/cohort/storage.ts index 42e114a..90aecce 100644 --- a/packages/node/src/local/cohort/storage.ts +++ b/packages/node/src/local/cohort/storage.ts @@ -1,4 +1,4 @@ -import { Cohort, CohortStorage, USER_GROUP_TYPE } from 'src/types/cohort'; +import { Cohort, CohortStorage, USER_GROUP_TYPE } from '../../types/cohort'; export class InMemoryCohortStorage implements CohortStorage { store: Record = {}; diff --git a/packages/node/src/local/cohort/updater.ts b/packages/node/src/local/cohort/updater.ts index 7b93c87..b696313 100644 --- a/packages/node/src/local/cohort/updater.ts +++ b/packages/node/src/local/cohort/updater.ts @@ -1,4 +1,4 @@ -import { CohortStorage } from 'src/types/cohort'; +import { CohortStorage } from '../../types/cohort'; export interface CohortUpdater { /** diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index 4ca1332..b523d3e 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -1,5 +1,4 @@ -import { CohortStorage } from 'src/types/cohort'; - +import { CohortStorage } from '../types/cohort'; import { LocalEvaluationDefaults } from '../types/config'; import { FlagConfigCache } from '../types/flag'; import { BackoffPolicy, doWithBackoffFailLoudly } from '../util/backoff'; diff --git a/packages/node/src/local/updater.ts b/packages/node/src/local/updater.ts index f07c903..1068c58 100644 --- a/packages/node/src/local/updater.ts +++ b/packages/node/src/local/updater.ts @@ -1,8 +1,7 @@ -import { CohortStorage } from 'src/types/cohort'; -import { CohortUtils } from 'src/util/cohort'; -import { ConsoleLogger, Logger } from 'src/util/logger'; - 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'; From 13913f1317cfe0fcec717714c63739bb5b8f535e Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 30 Jul 2024 10:40:41 -0700 Subject: [PATCH 39/48] add cohortRequestDelayMillis, use sleep util, skip retry if maxCohortSize error --- packages/node/src/local/client.ts | 1 + packages/node/src/local/cohort/cohort-api.ts | 10 +++- packages/node/src/local/cohort/fetcher.ts | 47 +++++++++---------- packages/node/src/local/streamer.ts | 2 +- packages/node/src/types/config.ts | 3 ++ .../test/local/cohort/cohortFetcher.test.ts | 40 ++++++++++++++-- .../test/local/cohort/cohortPoller.test.ts | 3 +- .../node/test/local/flagConfigPoller.test.ts | 5 +- .../test/local/flagConfigStreamer.test.ts | 7 +++ packages/node/test/util/threading.test.ts | 5 +- 10 files changed, 84 insertions(+), 39 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 8dc393c..7cd4db6 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -96,6 +96,7 @@ export class LocalEvaluationClient { httpClient, this.config.cohortConfig?.cohortServerUrl, this.config.cohortConfig?.maxCohortSize, + this.config.cohortConfig?.cohortRequestDelayMillis, this.config.debug, ); this.cohortUpdater = new CohortPoller( diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index 3986499..a033d6c 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -24,6 +24,10 @@ export interface CohortApi { getCohort(options?: GetCohortOptions): Promise; } +export class CohortMaxSizeExceededError extends Error {} + +export class CohortDownloadError extends Error {} + export class SdkCohortApi implements CohortApi { private readonly cohortApiKey; private readonly serverUrl; @@ -72,9 +76,11 @@ export class SdkCohortApi implements CohortApi { } else if (response.status == 204) { return undefined; } else if (response.status == 413) { - throw Error(`Cohort error response: size > ${options.maxCohortSize}`); + throw new CohortMaxSizeExceededError( + `Cohort error response: size > ${options.maxCohortSize}`, + ); } else { - throw Error( + 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 index 3673995..bff6538 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -3,26 +3,23 @@ import { WrapperClient } from '../../transport/http'; import { Cohort } from '../../types/cohort'; import { CohortConfigDefaults } from '../../types/config'; import { HttpClient } from '../../types/transport'; -import { BackoffPolicy, doWithBackoffFailLoudly } from '../../util/backoff'; +import { BackoffPolicy } from '../../util/backoff'; import { ConsoleLogger, Logger } from '../../util/logger'; import { Mutex, Executor } from '../../util/threading'; +import { sleep } from '../../util/time'; -import { SdkCohortApi } from './cohort-api'; +import { CohortMaxSizeExceededError, SdkCohortApi } from './cohort-api'; export const COHORT_CONFIG_TIMEOUT = 20000; -const BACKOFF_POLICY: BackoffPolicy = { - attempts: 3, - min: 1000, - max: 1000, - scalar: 1, -}; +const ATTEMPTS = 3; export class CohortFetcher { private readonly logger: Logger; readonly cohortApi: SdkCohortApi; readonly maxCohortSize: number; + readonly cohortRequestDelayMillis: number; private readonly inProgressCohorts: Record< string, @@ -37,6 +34,7 @@ export class CohortFetcher { httpClient: HttpClient, serverUrl = CohortConfigDefaults.cohortServerUrl, maxCohortSize = CohortConfigDefaults.maxCohortSize, + cohortRequestDelayMillis = 100, debug = false, ) { this.cohortApi = new SdkCohortApi( @@ -45,6 +43,7 @@ export class CohortFetcher { new WrapperClient(httpClient), ); this.maxCohortSize = maxCohortSize; + this.cohortRequestDelayMillis = cohortRequestDelayMillis; this.logger = new ConsoleLogger(debug); } @@ -63,32 +62,32 @@ export class CohortFetcher { if (!this.inProgressCohorts[key]) { this.inProgressCohorts[key] = this.executor.run(async () => { this.logger.debug('Start downloading', cohortId); - const cohort = await doWithBackoffFailLoudly( - async () => - this.cohortApi.getCohort({ + 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, - }), - BACKOFF_POLICY, - ) - .then(async (cohort) => { + }); + // Do unlock before return. const unlock = await this.mutex.lock(); delete this.inProgressCohorts[key]; unlock(); + this.logger.debug('Stop downloading', cohortId); return cohort; - }) - .catch(async (err) => { - const unlock = await this.mutex.lock(); - delete this.inProgressCohorts[key]; - unlock(); - throw err; - }); - this.logger.debug('Stop downloading', cohortId); - return cohort; + } catch (e) { + if (i === ATTEMPTS - 1 || e instanceof CohortMaxSizeExceededError) { + const unlock = await this.mutex.lock(); + delete this.inProgressCohorts[key]; + unlock(); + throw e; + } + await sleep(this.cohortRequestDelayMillis); + } + } }); } diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index d9eaecf..fc6aa69 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -76,6 +76,7 @@ export class FlagConfigStreamer this.stream.onInitUpdate = async (flagConfigs) => { this.logger.debug('[Experiment] streamer - receives updates'); await super._update(flagConfigs, true, onChange); + this.logger.debug('[Experiment] streamer - start flags stream success'); }; this.stream.onUpdate = async (flagConfigs) => { this.logger.debug('[Experiment] streamer - receives updates'); @@ -94,7 +95,6 @@ export class FlagConfigStreamer libraryVersion: PACKAGE_VERSION, }); this.poller.stop(); - this.logger.debug('[Experiment] streamer - start flags stream success'); } catch (e) { const err = e as StreamErrorEvent; this.logger.debug( diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index f83c218..00f67d0 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -215,6 +215,8 @@ export type CohortConfig = { * size will be skipped. */ maxCohortSize?: number; + + cohortRequestDelayMillis?: number; }; /** @@ -249,6 +251,7 @@ export const CohortConfigDefaults: Omit = { cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', maxCohortSize: 10_000_000, + cohortRequestDelayMillis: 100, }; export const EU_SERVER_URLS = { diff --git a/packages/node/test/local/cohort/cohortFetcher.test.ts b/packages/node/test/local/cohort/cohortFetcher.test.ts index 695fbe1..040134d 100644 --- a/packages/node/test/local/cohort/cohortFetcher.test.ts +++ b/packages/node/test/local/cohort/cohortFetcher.test.ts @@ -1,6 +1,10 @@ -import { SdkCohortApi } from 'src/local/cohort/cohort-api'; +import { + CohortMaxSizeExceededError, + SdkCohortApi, +} from 'src/local/cohort/cohort-api'; import { COHORT_CONFIG_TIMEOUT, CohortFetcher } from 'src/local/cohort/fetcher'; import { CohortConfigDefaults } from 'src/types/config'; +import { sleep } from 'src/util/time'; import { version as PACKAGE_VERSION } from '../../../gen/version'; @@ -34,7 +38,7 @@ const COHORTS = { }, }; -afterEach(() => { +beforeEach(() => { jest.clearAllMocks(); }); @@ -109,10 +113,38 @@ test('cohort fetch failed', async () => { throw Error(); }); - const cohortFetcher = new CohortFetcher('', '', null, 'someurl', 10); + 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(); + }); - await expect(cohortFetcher.fetch('c1', 10)).rejects.toThrowError(); + 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, diff --git a/packages/node/test/local/cohort/cohortPoller.test.ts b/packages/node/test/local/cohort/cohortPoller.test.ts index 5f4fcfb..ddb50cb 100644 --- a/packages/node/test/local/cohort/cohortPoller.test.ts +++ b/packages/node/test/local/cohort/cohortPoller.test.ts @@ -3,6 +3,7 @@ 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'; const OLD_COHORTS = { c1: { @@ -64,8 +65,6 @@ const NEW_COHORTS = { }, }; -const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const POLL_MILLIS = 500; let storage: CohortStorage; let fetcher: CohortFetcher; diff --git a/packages/node/test/local/flagConfigPoller.test.ts b/packages/node/test/local/flagConfigPoller.test.ts index 298ae5c..053a647 100644 --- a/packages/node/test/local/flagConfigPoller.test.ts +++ b/packages/node/test/local/flagConfigPoller.test.ts @@ -6,6 +6,7 @@ import { 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/flags'; import { MockHttpClient } from './util/mockHttpClient'; @@ -76,7 +77,7 @@ test('flagConfig poller success', async () => { expect(cohortStorage.getCohort('hahaorgname1').lastModified).toBe(1); // On update, flag, existing cohort doesn't update. - await new Promise((f) => setTimeout(f, 2000)); + await sleep(2000); expect(flagPolled).toBe(2); expect(await poller.cache.getAll()).toStrictEqual({ ...NEW_FLAGS, @@ -182,7 +183,7 @@ test('flagConfig poller initial success, polling error and use old flags', async // Second poll flags with new cohort should fail when new cohort download failed. // The different flag should not be updated. - await new Promise((f) => setTimeout(f, 2000)); + await sleep(2000); expect(flagPolled).toBeGreaterThanOrEqual(2); expect(await poller.cache.getAll()).toStrictEqual(FLAGS); diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 8e967df..28c93eb 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -30,6 +30,7 @@ const getTestObjs = ({ streamFlagRetryDelayMillis = 15000, apiKey = 'client-xxxx', serverUrl = 'http://localhostxxxx:00000000', + cohortFetcherDelayMillis = 100, fetcherData = [ '[{"key": "fetcher-a", "variants": {}, "segments": []}]', '[{"key": "fetcher-b", "variants": {}, "segments": []}]', @@ -44,6 +45,8 @@ const getTestObjs = ({ 'apikey', 'secretkey', new MockHttpClient(async () => ({ status: 200, body: '' })), + serverUrl, + cohortFetcherDelayMillis, ), }; let dataI = 0; @@ -674,6 +677,7 @@ test('FlagConfigUpdater.connect, flag success, cohort fail, retry fail, initiali streamFlagTryAttempts: 2, streamFlagTryDelayMillis: 1000, streamFlagRetryDelayMillis: 100000, + debug: true, }); // Return cohort with their own cohortId. updater.start(); @@ -681,11 +685,14 @@ test('FlagConfigUpdater.connect, flag success, cohort fail, retry fail, initiali await mockClient.client.doMsg({ data: getFlagWithCohort('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 retry stream. // Second try await mockClient.client.doOpen({ type: 'open' }); await mockClient.client.doMsg({ data: getFlagWithCohort('cohort1'), }); + await new Promise((resolve) => setTimeout(resolve, 250)); // Wait for cohort download done retries and fails. expect(fetchObj.fetchCalls).toBeGreaterThanOrEqual(1); expect(mockClient.numCreated).toBe(2); diff --git a/packages/node/test/util/threading.test.ts b/packages/node/test/util/threading.test.ts index c95fa34..84f5911 100644 --- a/packages/node/test/util/threading.test.ts +++ b/packages/node/test/util/threading.test.ts @@ -1,8 +1,5 @@ import { Executor, Mutex, Semaphore } from 'src/util/threading'; - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +import { sleep } from 'src/util/time'; function mutexSleepFunc(lock, ms, acc) { return async () => { From fae8ecea9b05e3eaa2e310b5034b953696dc144a Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 30 Jul 2024 13:58:23 -0700 Subject: [PATCH 40/48] unused imports --- packages/node/src/local/cohort/fetcher.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/node/src/local/cohort/fetcher.ts b/packages/node/src/local/cohort/fetcher.ts index bff6538..70f418b 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -3,7 +3,6 @@ import { WrapperClient } from '../../transport/http'; import { Cohort } from '../../types/cohort'; import { CohortConfigDefaults } from '../../types/config'; import { HttpClient } from '../../types/transport'; -import { BackoffPolicy } from '../../util/backoff'; import { ConsoleLogger, Logger } from '../../util/logger'; import { Mutex, Executor } from '../../util/threading'; import { sleep } from '../../util/time'; From a1267d9d218a009a749f1c79ee36a7c0c57ed4f5 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 30 Jul 2024 16:32:33 -0700 Subject: [PATCH 41/48] fix relative imports attempt 2 --- packages/node/src/local/streamer.ts | 3 +-- packages/node/src/remote/client.ts | 2 +- packages/node/src/util/cohort.ts | 2 +- packages/node/src/util/config.ts | 11 +++++------ packages/node/src/util/threading.ts | 2 -- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index fc6aa69..7f89ade 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -1,10 +1,9 @@ -import { CohortStorage } from 'src/types/cohort'; - import { version as PACKAGE_VERSION } from '../../gen/version'; import { StreamErrorEvent, StreamEventSourceFactory, } from '../transport/stream'; +import { CohortStorage } from '../types/cohort'; import { LocalEvaluationDefaults } from '../types/config'; import { FlagConfigCache } from '../types/flag'; diff --git a/packages/node/src/remote/client.ts b/packages/node/src/remote/client.ts index 9a62141..f0b601b 100644 --- a/packages/node/src/remote/client.ts +++ b/packages/node/src/remote/client.ts @@ -3,7 +3,6 @@ import { FetchError, SdkEvaluationApi, } from '@amplitude/experiment-core'; -import { populateRemoteConfigDefaults } from 'src/util/config'; import { version as PACKAGE_VERSION } from '../../gen/version'; import { FetchHttpClient, WrapperClient } from '../transport/http'; @@ -11,6 +10,7 @@ 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, diff --git a/packages/node/src/util/cohort.ts b/packages/node/src/util/cohort.ts index d80fb64..516541e 100644 --- a/packages/node/src/util/cohort.ts +++ b/packages/node/src/util/cohort.ts @@ -3,9 +3,9 @@ import { EvaluationOperator, EvaluationSegment, } from '@amplitude/experiment-core'; -import { USER_GROUP_TYPE } from 'src/types/cohort'; import { FlagConfig } from '..'; +import { USER_GROUP_TYPE } from '../types/cohort'; export class CohortUtils { public static isCohortFilter(condition: EvaluationCondition): boolean { diff --git a/packages/node/src/util/config.ts b/packages/node/src/util/config.ts index 11c0c47..97880c9 100644 --- a/packages/node/src/util/config.ts +++ b/packages/node/src/util/config.ts @@ -1,14 +1,13 @@ -import { - EU_SERVER_URLS, - LocalEvaluationDefaults, - CohortConfigDefaults, -} from 'src/types/config'; - import { RemoteEvaluationConfig, RemoteEvaluationDefaults, LocalEvaluationConfig, } from '..'; +import { + EU_SERVER_URLS, + LocalEvaluationDefaults, + CohortConfigDefaults, +} from '../types/config'; export const populateRemoteConfigDefaults = ( customConfig?: RemoteEvaluationConfig, diff --git a/packages/node/src/util/threading.ts b/packages/node/src/util/threading.ts index b277c03..78182ed 100644 --- a/packages/node/src/util/threading.ts +++ b/packages/node/src/util/threading.ts @@ -1,6 +1,4 @@ export class Mutex { - // https://news.ycombinator.com/item?id=11823816 - _locking; constructor() { From d11f76e54d83ee2e907648f61932ea7d46f4260c Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 1 Aug 2024 13:10:32 -0700 Subject: [PATCH 42/48] Log error on eval, dont init fail on if cohort fail, add tests --- packages/node/src/local/client.ts | 41 +- packages/node/src/local/cohort/cohort-api.ts | 4 +- packages/node/src/local/cohort/poller.ts | 10 +- packages/node/src/local/poller.ts | 4 +- packages/node/src/local/streamer.ts | 4 +- packages/node/src/local/updater.ts | 62 +- packages/node/src/util/cohort.ts | 23 +- packages/node/test/local/client.test.ts | 837 ++++++++++++------ .../test/local/cohort/cohortPoller.test.ts | 17 +- .../node/test/local/flagConfigPoller.test.ts | 64 +- .../test/local/flagConfigStreamer.test.ts | 85 +- .../node/test/local/flagConfigUpdater.test.ts | 32 +- .../node/test/local/util/cohortUtils.test.ts | 182 +--- packages/node/test/local/util/flags.ts | 164 ---- packages/node/test/local/util/mockData.ts | 273 ++++++ 15 files changed, 1010 insertions(+), 792 deletions(-) delete mode 100644 packages/node/test/local/util/flags.ts create mode 100644 packages/node/test/local/util/mockData.ts diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 7cd4db6..10c6cf5 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -88,7 +88,7 @@ export class LocalEvaluationClient { this.logger = new ConsoleLogger(this.config.debug); this.cohortStorage = new InMemoryCohortStorage(); - let cohortFetcher = undefined; + let cohortFetcher: CohortFetcher = undefined; if (this.config.cohortConfig) { cohortFetcher = new CohortFetcher( this.config.cohortConfig.apiKey, @@ -102,6 +102,7 @@ export class LocalEvaluationClient { this.cohortUpdater = new CohortPoller( cohortFetcher, this.cohortStorage, + this.cache, 60000, this.config.debug, ); @@ -188,11 +189,47 @@ 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 cohortIdsByGroup = CohortUtils.extractCohortIdsByGroup(flags); + 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]; diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index a033d6c..5d10994 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -77,11 +77,11 @@ export class SdkCohortApi implements CohortApi { return undefined; } else if (response.status == 413) { throw new CohortMaxSizeExceededError( - `Cohort error response: size > ${options.maxCohortSize}`, + `Cohort size > ${options.maxCohortSize}`, ); } else { throw new CohortDownloadError( - `Cohort error response: status ${response.status}, body ${response.body}`, + `Cohort error response status ${response.status}, body ${response.body}`, ); } } diff --git a/packages/node/src/local/cohort/poller.ts b/packages/node/src/local/cohort/poller.ts index e7ab9bd..8326b6c 100644 --- a/packages/node/src/local/cohort/poller.ts +++ b/packages/node/src/local/cohort/poller.ts @@ -1,4 +1,6 @@ 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'; @@ -10,6 +12,7 @@ export class CohortPoller implements CohortUpdater { public readonly fetcher: CohortFetcher; public readonly storage: CohortStorage; + public readonly flagCache: FlagConfigCache; private poller: NodeJS.Timeout; private pollingIntervalMillis: number; @@ -17,11 +20,13 @@ export class CohortPoller implements CohortUpdater { 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); } @@ -64,8 +69,11 @@ export class CohortPoller implements CohortUpdater { ): Promise { let changed = false; const promises = []; + const cohortIds = CohortUtils.extractCohortIds( + await this.flagCache.getAll(), + ); - for (const cohortId of this.storage.getAllCohortIds()) { + for (const cohortId of cohortIds) { this.logger.debug(`[Experiment] updating cohort ${cohortId}`); // Get existing cohort and lastModified. diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index b523d3e..e6ad1eb 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -65,7 +65,7 @@ export class FlagConfigPoller async () => await this.fetcher.fetch(), BACKOFF_POLICY, ); - await super._update(flagConfigs, true, onChange); + await super._update(flagConfigs, onChange); } catch (e) { this.logger.error( '[Experiment] flag config initial poll failed, stopping', @@ -95,6 +95,6 @@ export class FlagConfigPoller ): Promise { this.logger.debug('[Experiment] updating flag configs'); const flagConfigs = await this.fetcher.fetch(); - await super._update(flagConfigs, false, onChange); + await super._update(flagConfigs, onChange); } } diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 7f89ade..84653d2 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -74,12 +74,12 @@ export class FlagConfigStreamer this.stream.onInitUpdate = async (flagConfigs) => { this.logger.debug('[Experiment] streamer - receives updates'); - await super._update(flagConfigs, true, onChange); + 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'); - await super._update(flagConfigs, false, onChange); + await super._update(flagConfigs, onChange); }; try { diff --git a/packages/node/src/local/updater.ts b/packages/node/src/local/updater.ts index 1068c58..5e88bf1 100644 --- a/packages/node/src/local/updater.ts +++ b/packages/node/src/local/updater.ts @@ -52,7 +52,6 @@ export class FlagConfigUpdaterBase { protected async _update( flagConfigs: Record, - isInit: boolean, onChange?: (cache: FlagConfigCache) => Promise, ): Promise { let changed = false; @@ -66,30 +65,20 @@ export class FlagConfigUpdaterBase { // Get all cohort needs update. const cohortIds = CohortUtils.extractCohortIds(flagConfigs); if (cohortIds && cohortIds.size > 0 && !this.cohortFetcher) { - throw Error( - 'cohort found in flag configs but no cohort download configured', + this.logger.error( + 'Cohorts found in flag configs but no cohort download configured', ); + } else { + // Download new cohorts into cohortStorage. + await this.downloadNewCohorts(cohortIds); } - // Download new cohorts into cohortStorage. - const failedCohortIds = await this.downloadNewCohorts(cohortIds); - if (isInit && failedCohortIds.size > 0) { - throw Error('Cohort download failed'); - } - - // Update the flags that has all cohorts successfully updated into flags cache. - const newFlagConfigs = await this.filterFlagConfigsWithFullCohorts( - flagConfigs, - ); - // Update the flags with new flags. await this.cache.clear(); - await this.cache.putAll(newFlagConfigs); + await this.cache.putAll(flagConfigs); // Remove cohorts not used by new flags. - await this.removeUnusedCohorts( - CohortUtils.extractCohortIds(newFlagConfigs), - ); + await this.removeUnusedCohorts(cohortIds); if (changed) { await onChange(this.cache); @@ -111,8 +100,8 @@ export class FlagConfigUpdaterBase { } }) .catch((err) => { - this.logger.warn( - `[Experiment] Cohort download failed ${cohortId}, using existing cohort if exist`, + this.logger.error( + `[Experiment] Cohort download failed ${cohortId}`, err, ); failedCohortIds.add(cohortId); @@ -122,39 +111,6 @@ export class FlagConfigUpdaterBase { return failedCohortIds; } - protected async filterFlagConfigsWithFullCohorts( - flagConfigs: Record, - ): Promise> { - const newFlagConfigs = {}; - const availableCohortIds = this.cohortStorage.getAllCohortIds(); - for (const flagKey in flagConfigs) { - // Get cohorts for this flag. - const cohortIds = CohortUtils.extractCohortIdsFromFlag( - flagConfigs[flagKey], - ); - - // Check if all cohorts for this flag has downloaded. - // If any cohort failed, don't use the new flag. - const updateFlag = - cohortIds.size === 0 || - CohortUtils.setSubtract(cohortIds, availableCohortIds).size === 0; - - if (updateFlag) { - newFlagConfigs[flagKey] = flagConfigs[flagKey]; - } else { - this.logger.warn( - `[Experiment] Flag ${flagKey} failed to update due to cohort update failure`, - ); - const existingFlag = await this.cache.get(flagKey); - if (existingFlag) { - newFlagConfigs[flagKey] = existingFlag; - } - } - } - - return newFlagConfigs; - } - protected async removeUnusedCohorts( validCohortIds: Set, ): Promise { diff --git a/packages/node/src/util/cohort.ts b/packages/node/src/util/cohort.ts index 516541e..f06b015 100644 --- a/packages/node/src/util/cohort.ts +++ b/packages/node/src/util/cohort.ts @@ -20,28 +20,13 @@ export class CohortUtils { public static extractCohortIds( flagConfigs: Record, ): Set { - return CohortUtils.mergeAllValues( - CohortUtils.extractCohortIdsByGroup(flagConfigs), - ); - } - - public static extractCohortIdsByGroup( - flagConfigs: Record, - ): Record> { - const cohortIdsByGroup = {}; + const cohortIdsByFlag = {}; for (const key in flagConfigs) { - CohortUtils.mergeBIntoA( - cohortIdsByGroup, + cohortIdsByFlag[key] = CohortUtils.mergeAllValues( CohortUtils.extractCohortIdsByGroupFromFlag(flagConfigs[key]), ); } - return cohortIdsByGroup; - } - - public static extractCohortIdsFromFlag(flag: FlagConfig): Set { - return CohortUtils.mergeAllValues( - CohortUtils.extractCohortIdsByGroupFromFlag(flag), - ); + return CohortUtils.mergeAllValues(cohortIdsByFlag); } public static extractCohortIdsByGroupFromFlag( @@ -85,7 +70,7 @@ export class CohortUtils { return cohortIdsByGroup; } - public static mergeBIntoA( + public static mergeValuesOfBIntoValuesOfA( a: Record>, b: Record>, ): void { diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index d348baf..b75d93d 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -3,10 +3,23 @@ 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 { + FlagConfigFetcher, + 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 { MockHttpClient } from './util/mockHttpClient'; +import { COHORTS, FLAGS, NEW_FLAGS, getFlagWithCohort } from './util/mockData'; +import { CohortFetcher } from 'src/local/cohort/fetcher'; +import { + CohortDownloadError, + CohortMaxSizeExceededError, + SdkCohortApi, +} from 'src/local/cohort/cohort-api'; +import { sleep } from 'src/util/time'; dotenv.config({ path: path.join(__dirname, '../../', '.env') }); @@ -20,321 +33,597 @@ if (!process.env['API_KEY'] && !process.env['SECRET_KEY']) { ); } -const cohortConfig = { - apiKey: process.env['API_KEY'], - secretKey: process.env['SECRET_KEY'], -}; -const client = Experiment.initializeLocal(apiKey, { - cohortConfig: cohortConfig, -}); - -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(); + }); + + // 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.evaluate with dependencies, with unknown flag keys, no variant', async () => { - const variants = await client.evaluate( - { + 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 dependencies, variant held out', async () => { - const variants = await client.evaluateV2({ - user_id: 'user_id', - device_id: 'device_id', + 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 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 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 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(); }); - 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(); -}); +}; + +describe('ExperimentClient end-to-end tests, normal cases', () => { + describe('Normal cases', () => { + const client = Experiment.initializeLocal(apiKey, { + cohortConfig: { + apiKey: process.env['API_KEY'], + secretKey: process.env['SECRET_KEY'], + }, + }); + + beforeAll(async () => { + await client.start(); + }); -test('ExperimentClient.evaluateV2 with user cohort segment targeted', async () => { - const variants = await client.evaluateV2({ - user_id: '12345', - device_id: 'device_id', + afterAll(async () => { + client.stop(); + }); + + setupEvaluateTestNormalCases(client); + setupEvaluateCohortTestNormalCases(client); }); - 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 user cohort tester targeted', async () => { - const variants = await client.evaluateV2({ - user_id: '1', - device_id: 'device_id', + describe('No cohort config', () => { + const client = Experiment.initializeLocal(apiKey); + + beforeAll(async () => { + await client.start(); + }); + + afterAll(async () => { + client.stop(); + }); + + setupEvaluateTestNormalCases(client); + setupEvaluateCohortTestErrorClientCases(client); }); - 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 group cohort segment targeted', async () => { - const variants = await client.evaluateV2({ - user_id: '12345', - device_id: 'device_id', - groups: { - 'org id': ['1'], - }, + describe('Bad cohort config', () => { + const client = Experiment.initializeLocal(apiKey, { + cohortConfig: { + apiKey: 'bad_api_key', + secretKey: 'bad_secret_key', + }, + }); + + beforeAll(async () => { + await client.start(); + }); + + afterAll(async () => { + client.stop(); + }); + + setupEvaluateTestNormalCases(client); + setupEvaluateCohortTestErrorClientCases(client); }); - 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)'], - }, +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 }; + }); }); - 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(); -}); -// Unit tests -class TestLocalEvaluationClient extends LocalEvaluationClient { - public enrichUserWithCohorts( - user: ExperimentUser, - flags: Record, - ) { - super.enrichUserWithCohorts(user, flags); - } -} + test('ExperimentClient cohort targeting success', async () => { + const client = new LocalEvaluationClient( + 'apikey', + { + cohortConfig: { + apiKey: 'apiKey', + secretKey: 'secretKey', + maxCohortSize: 10, + }, + }, + null, + mockHttpClient, + ); + await client.start(); -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']), + 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(); }); - client.cohortStorage.put({ - cohortId: 'groupcohort1', - groupType: 'groupname', - groupTypeId: 1, - lastComputed: 0, - lastModified: 0, - size: 1, - memberIds: new Set(['amplitude', 'experiment']), + + test('ExperimentClient cohort maxCohortSize download fail', async () => { + const client = new LocalEvaluationClient( + 'apikey', + { + cohortConfig: { + 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(); }); - 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'], - }, - ], - ], + + test('ExperimentClient cohort download initial failures, but poller would success', async () => { + jest.setTimeout(70000); + const client = new LocalEvaluationClient( + 'apikey', + { + flagConfigPollingIntervalMillis: 40000, + cohortConfig: { + apiKey: 'apiKey', + secretKey: 'secretKey', + maxCohortSize: 10, }, - ], - }, - flag2: { - key: 'flag2', - variants: {}, - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'groups', 'groupname', 'cohort_ids'], - values: ['groupcohort1', 'groupcohortnotinstorage'], - }, + }, + 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. + // Cohort poller (pollingIntervalMillis = 60000) will poll all cohorts in the flags, which will all 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(); + }); +}); + +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'], }, - ], - }, - }); - 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/cohortPoller.test.ts b/packages/node/test/local/cohort/cohortPoller.test.ts index ddb50cb..729cd5a 100644 --- a/packages/node/test/local/cohort/cohortPoller.test.ts +++ b/packages/node/test/local/cohort/cohortPoller.test.ts @@ -1,3 +1,4 @@ +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'; @@ -5,6 +6,8 @@ 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', @@ -66,22 +69,24 @@ const NEW_COHORTS = { }; const POLL_MILLIS = 500; +let flagsCache: FlagConfigCache; let storage: CohortStorage; let fetcher: CohortFetcher; let poller: CohortPoller; -let storageGetAllCohortIdsSpy: jest.SpyInstance; let storageGetCohortSpy: jest.SpyInstance; let storagePutSpy: jest.SpyInstance; beforeEach(() => { + flagsCache = new InMemoryFlagConfigCache(); storage = new InMemoryCohortStorage(); fetcher = new CohortFetcher('', '', null); - poller = new CohortPoller(fetcher, storage, POLL_MILLIS); + poller = new CohortPoller(fetcher, storage, flagsCache, POLL_MILLIS); - storageGetAllCohortIdsSpy = jest.spyOn(storage, 'getAllCohortIds'); - storageGetAllCohortIdsSpy.mockImplementation( - () => new Set(['c1', 'c2']), - ); + 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], diff --git a/packages/node/test/local/flagConfigPoller.test.ts b/packages/node/test/local/flagConfigPoller.test.ts index 053a647..157991f 100644 --- a/packages/node/test/local/flagConfigPoller.test.ts +++ b/packages/node/test/local/flagConfigPoller.test.ts @@ -8,7 +8,7 @@ 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/flags'; +import { FLAGS, NEW_FLAGS } from './util/mockData'; import { MockHttpClient } from './util/mockHttpClient'; afterEach(() => { @@ -64,17 +64,19 @@ test('flagConfig poller success', async () => { ...FLAGS, flagPolled: { key: flagPolled }, }); - expect(cohortStorage.getCohort('hahahaha1').cohortId).toBe('hahahaha1'); - expect(cohortStorage.getCohort('hahahaha2').cohortId).toBe('hahahaha2'); - expect(cohortStorage.getCohort('hahahaha3').cohortId).toBe('hahahaha3'); - expect(cohortStorage.getCohort('hahahaha4').cohortId).toBe('hahahaha4'); - expect(cohortStorage.getCohort('hahaorgname1').cohortId).toBe('hahaorgname1'); + 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('hahahaha1').lastModified).toBe(1); - expect(cohortStorage.getCohort('hahahaha2').lastModified).toBe(1); - expect(cohortStorage.getCohort('hahahaha3').lastModified).toBe(1); - expect(cohortStorage.getCohort('hahahaha4').lastModified).toBe(1); - expect(cohortStorage.getCohort('hahaorgname1').lastModified).toBe(1); + 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); @@ -83,22 +85,24 @@ test('flagConfig poller success', async () => { ...NEW_FLAGS, flagPolled: { key: flagPolled }, }); - expect(cohortStorage.getCohort('hahahaha1').cohortId).toBe('hahahaha1'); - expect(cohortStorage.getCohort('hahahaha2').cohortId).toBe('hahahaha2'); - expect(cohortStorage.getCohort('hahahaha3').cohortId).toBe('hahahaha3'); - expect(cohortStorage.getCohort('hahahaha4').cohortId).toBe('hahahaha4'); - expect(cohortStorage.getCohort('hahaorgname1').cohortId).toBe('hahaorgname1'); + 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('hahahaha1').lastModified).toBe(1); - expect(cohortStorage.getCohort('hahahaha2').lastModified).toBe(1); - expect(cohortStorage.getCohort('hahahaha3').lastModified).toBe(1); - expect(cohortStorage.getCohort('hahahaha4').lastModified).toBe(1); - expect(cohortStorage.getCohort('hahaorgname1').lastModified).toBe(1); + 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 error', async () => { +test('flagConfig poller initial cohort error, still init', async () => { const poller = new FlagConfigPoller( new FlagConfigFetcher( 'key', @@ -128,13 +132,18 @@ test('flagConfig poller initial error', async () => { try { // Should throw when init failed. await poller.start(); + } catch { fail(); - // eslint-disable-next-line no-empty - } catch {} - expect(await poller.cache.getAll()).toStrictEqual({}); + } + expect(await poller.cache.getAll()).toStrictEqual(FLAGS); + expect(poller.cohortStorage.getAllCohortIds()).toStrictEqual( + new Set(), + ); + + poller.stop(); }); -test('flagConfig poller initial success, polling error and use old flags', async () => { +test('flagConfig poller initial success, polling flag success, cohort failed, and still updates flags', async () => { const poller = new FlagConfigPoller( new FlagConfigFetcher( 'key', @@ -185,7 +194,8 @@ test('flagConfig poller initial success, polling error and use old flags', async // The different flag should not be updated. await sleep(2000); expect(flagPolled).toBeGreaterThanOrEqual(2); - expect(await poller.cache.getAll()).toStrictEqual(FLAGS); + 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 28c93eb..8f222af 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -7,16 +7,10 @@ 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'; -const getFlagWithCohort = ( - cohortId, -) => `[{"key":"flag_${cohortId}","segments":[{ - "conditions":[[{"op":"set contains any","selector":["context","user","cohort_ids"],"values":["${cohortId}"]}]], - "metadata":{"segmentName": "Segment 1"},"variant": "off" - }],"variants": {}}]`; - let updater; afterEach(() => { updater?.stop(); @@ -609,16 +603,28 @@ test('FlagConfigUpdater.connect, flag success, cohort success', async () => { updater.start(); await mockClient.client.doOpen({ type: 'open' }); await mockClient.client.doMsg({ - data: getFlagWithCohort('cohort1'), + 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 success, success, flag update success, cohort fail, wont fallback to poller as flag stream is ok', async () => { +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') @@ -644,7 +650,7 @@ test('FlagConfigUpdater.connect, flag success, success, flag update success, coh updater.start(); await mockClient.client.doOpen({ type: 'open' }); await mockClient.client.doMsg({ - data: getFlagWithCohort('cohort1'), + data: `[${getFlagStrWithCohort('cohort1')}]`, }); await new Promise((r) => setTimeout(r, 1000)); // Wait for poller to poll. expect(fetchObj.fetchCalls).toBe(0); // No poller poll. @@ -654,18 +660,18 @@ test('FlagConfigUpdater.connect, flag success, success, flag update success, coh // Return cohort with their own cohortId. // Now update the flags with a new cohort that will fail to download. await mockClient.client.doMsg({ - data: getFlagWithCohort('cohort2'), + data: `[${getFlagStrWithCohort('cohort2')}]`, }); await new Promise((r) => setTimeout(r, 1000)); // Wait for poller to poll. - expect(fetchObj.fetchCalls).toBeGreaterThanOrEqual(0); // No poller 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')).toBeUndefined(); // Won't add flag to cache if new cohort fails. + 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 fails, fallback to poller', async () => { +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') @@ -683,55 +689,12 @@ test('FlagConfigUpdater.connect, flag success, cohort fail, retry fail, initiali updater.start(); await mockClient.client.doOpen({ type: 'open' }); await mockClient.client.doMsg({ - data: getFlagWithCohort('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 retry stream. - // Second try - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ - data: getFlagWithCohort('cohort1'), + 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).toBeGreaterThanOrEqual(1); - expect(mockClient.numCreated).toBe(2); - updater.stop(); -}); - -test('FlagConfigUpdater.connect, flag success, cohort fail, initialization fails, fallback to poller, poller fails, streamer start error', async () => { - jest.setTimeout(10000); - jest - .spyOn(SdkCohortApi.prototype, 'getCohort') - .mockImplementation(async () => { - throw Error(); - }); - const { fetchObj, mockClient, updater } = getTestObjs({ - pollingIntervalMillis: 30000, - streamFlagTryAttempts: 1, - streamFlagTryDelayMillis: 1000, - streamFlagRetryDelayMillis: 100000, - fetcherData: [ - getFlagWithCohort('cohort1'), - getFlagWithCohort('cohort1'), - getFlagWithCohort('cohort1'), - getFlagWithCohort('cohort1'), - getFlagWithCohort('cohort1'), - ], - }); - // Return cohort with their own cohortId. - const startPromise = updater.start(); - await mockClient.client.doOpen({ type: 'open' }); - await mockClient.client.doMsg({ - data: getFlagWithCohort('cohort1'), - }); - // Stream failed, poller should fail as well given the flags and cohort mock. - expect(fetchObj.fetchCalls).toBeGreaterThanOrEqual(1); + expect(fetchObj.fetchCalls).toBe(0); expect(mockClient.numCreated).toBe(1); - // Test should exit cleanly as updater.start() failure should stop the streamer. - try { - await startPromise; - fail(); - // eslint-disable-next-line no-empty - } catch {} + updater.stop(); }); diff --git a/packages/node/test/local/flagConfigUpdater.test.ts b/packages/node/test/local/flagConfigUpdater.test.ts index 0cfde15..31b0757 100644 --- a/packages/node/test/local/flagConfigUpdater.test.ts +++ b/packages/node/test/local/flagConfigUpdater.test.ts @@ -6,19 +6,16 @@ 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/flags'; +import { FLAGS, NEW_FLAGS } from './util/mockData'; import { MockHttpClient } from './util/mockHttpClient'; class TestFlagConfigUpdaterBase extends FlagConfigUpdaterBase { - public async update(flagConfigs, isInit, onChange) { - await super._update(flagConfigs, isInit, onChange); + public async update(flagConfigs, onChange) { + await super._update(flagConfigs, onChange); } public async downloadNewCohorts(cohortIds) { return await super.downloadNewCohorts(cohortIds); } - public async filterFlagConfigsWithFullCohorts(flagConfigs) { - return await super.filterFlagConfigsWithFullCohorts(flagConfigs); - } public async removeUnusedCohorts(validCohortIds) { return await super.removeUnusedCohorts(validCohortIds); } @@ -70,17 +67,13 @@ afterEach(() => { }); test('FlagConfigUpdaterBase, update success', async () => { - await updater.update(FLAGS, true, () => {}); + await updater.update(FLAGS, () => {}); expect(await updater.cache.getAll()).toStrictEqual(FLAGS); }); -test('FlagConfigUpdaterBase, update no error if not init', async () => { - await updater.update(NEW_FLAGS, false, () => {}); - expect(await updater.cache.getAll()).toStrictEqual(FLAGS); -}); - -test('FlagConfigUpdaterBase, update raise error if not init', async () => { - await expect(updater.update(NEW_FLAGS, true, () => {})).rejects.toThrow(); +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 () => { @@ -93,17 +86,6 @@ test('FlagConfigUpdaterBase.downloadNewCohorts', async () => { expect(failedCohortIds).toStrictEqual(new Set(['anewcohortid'])); }); -test('FlagConfigUpdaterBase.filterFlagConfigsWithFullCohorts', async () => { - CohortUtils.extractCohortIds(FLAGS).forEach((cohortId) => { - updater.cohortStorage.put(createCohort(cohortId)); - }); - - const filteredFlags = await updater.filterFlagConfigsWithFullCohorts( - NEW_FLAGS, - ); - expect(filteredFlags).toStrictEqual(FLAGS); -}); - test('FlagConfigUpdaterBase.removeUnusedCohorts', async () => { CohortUtils.extractCohortIds(NEW_FLAGS).forEach((cohortId) => { updater.cohortStorage.put(createCohort(cohortId)); diff --git a/packages/node/test/local/util/cohortUtils.test.ts b/packages/node/test/local/util/cohortUtils.test.ts index 1c931b2..2a32325 100644 --- a/packages/node/test/local/util/cohortUtils.test.ts +++ b/packages/node/test/local/util/cohortUtils.test.ts @@ -1,162 +1,36 @@ import { CohortUtils } from 'src/util/cohort'; -test('test extract cohortIds from flags', async () => { - // Flag definition is not complete, only those useful for thest is included. - const flags = [ - { - key: 'flag1', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['hahahaha1'], - }, - ], - ], - }, - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'off', - }, - ], - variants: {}, - }, - { - key: 'flag2', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['hahahaha2'], - }, - ], - ], - metadata: { - segmentName: 'Segment 1', - }, - variant: 'off', - }, - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'off', - }, - ], - variants: {}, - }, - { - 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: ['hahahaha3'], - }, - ], - ], - variant: 'off', - }, - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cocoids'], - values: ['nohaha'], - }, - ], - ], - variant: 'off', - }, - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'off', - }, - ], - variants: {}, - }, - { - key: 'flag5', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['hahahaha3', 'hahahaha4'], - }, - ], - ], - }, - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'groups', 'org name', 'cohort_ids'], - values: ['hahaorgname1'], - }, - ], - ], - metadata: { - segmentName: 'Segment 1', - }, - }, - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'gg', 'org name', 'cohort_ids'], - values: ['nohahaorgname'], - }, - ], - ], - metadata: { - segmentName: 'Segment 1', - }, - }, - ], - variants: {}, - }, - ].reduce((acc, flag) => { - acc[flag.key] = flag; - return acc; - }, {}); +import { FLAGS } from './mockData'; - expect(CohortUtils.extractCohortIdsByGroup(flags)).toStrictEqual({ - User: new Set(['hahahaha1', 'hahahaha2', 'hahahaha3', 'hahahaha4']), - 'org name': new Set(['hahaorgname1']), +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( + expect(CohortUtils.extractCohortIds(FLAGS)).toStrictEqual( new Set([ - 'hahahaha1', - 'hahahaha2', - 'hahahaha3', - 'hahahaha4', - 'hahaorgname1', + 'usercohort1', + 'usercohort2', + 'usercohort3', + 'usercohort4', + 'orgnamecohort1', ]), ); }); diff --git a/packages/node/test/local/util/flags.ts b/packages/node/test/local/util/flags.ts deleted file mode 100644 index 683400a..0000000 --- a/packages/node/test/local/util/flags.ts +++ /dev/null @@ -1,164 +0,0 @@ -export const FLAGS = [ - { - key: 'flag1', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['hahahaha1'], - }, - ], - ], - }, - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'off', - }, - ], - variants: {}, - }, - { - key: 'flag2', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['hahahaha2'], - }, - ], - ], - metadata: { - segmentName: 'Segment 1', - }, - variant: 'off', - }, - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'off', - }, - ], - variants: {}, - }, - { - 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: ['hahahaha3'], - }, - ], - ], - variant: 'off', - }, - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cocoids'], - values: ['nohaha'], - }, - ], - ], - variant: 'off', - }, - { - metadata: { - segmentName: 'All Other Users', - }, - variant: 'off', - }, - ], - variants: {}, - }, - { - key: 'flag5', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['hahahaha3', 'hahahaha4'], - }, - ], - ], - }, - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'groups', 'org name', 'cohort_ids'], - values: ['hahaorgname1'], - }, - ], - ], - metadata: { - segmentName: 'Segment 1', - }, - }, - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'gg', 'org name', 'cohort_ids'], - values: ['nohahaorgname'], - }, - ], - ], - metadata: { - segmentName: 'Segment 1', - }, - }, - ], - variants: {}, - }, -].reduce((acc, flag) => { - acc[flag.key] = flag; - return acc; -}, {}); - -export const NEW_FLAGS = { - ...FLAGS, - flag6: { - key: 'flag6', - segments: [ - { - conditions: [ - [ - { - op: 'set contains any', - selector: ['context', 'user', 'cohort_ids'], - values: ['anewcohortid'], - }, - ], - ], - }, - ], - variants: {}, - }, -}; diff --git a/packages/node/test/local/util/mockData.ts b/packages/node/test/local/util/mockData.ts new file mode 100644 index 0000000..0f76b6f --- /dev/null +++ b/packages/node/test/local/util/mockData.ts @@ -0,0 +1,273 @@ +// Some test flags. +// FLAGS are normal flags with cohortIds. +// NEW_FLAGS adds a flag with cohortId `anewcohortid` on top of FLAGS. + +export const getFlagStrWithCohort = ( + cohortId: 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) => + 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']), + }, +}; From 0e256670449f5834ffdccfe2efc1428c692f8f20 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 1 Aug 2024 14:24:16 -0700 Subject: [PATCH 43/48] fix lint --- packages/node/test/local/client.test.ts | 17 ++++------------- packages/node/test/local/util/mockData.ts | 6 ++++-- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index b75d93d..9021ce3 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -3,24 +3,15 @@ import path from 'path'; import { EvaluationFlag } from '@amplitude/experiment-core'; import * as dotenv from 'dotenv'; import { Experiment } from 'src/factory'; -import { - FlagConfigFetcher, - InMemoryFlagConfigCache, - LocalEvaluationClient, -} from 'src/index'; +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 { MockHttpClient } from './util/mockHttpClient'; -import { COHORTS, FLAGS, NEW_FLAGS, getFlagWithCohort } from './util/mockData'; -import { CohortFetcher } from 'src/local/cohort/fetcher'; -import { - CohortDownloadError, - CohortMaxSizeExceededError, - SdkCohortApi, -} from 'src/local/cohort/cohort-api'; 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'; diff --git a/packages/node/test/local/util/mockData.ts b/packages/node/test/local/util/mockData.ts index 0f76b6f..9ad25bd 100644 --- a/packages/node/test/local/util/mockData.ts +++ b/packages/node/test/local/util/mockData.ts @@ -2,14 +2,16 @@ // 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, -) => `{"key":"flag_${cohortId}","segments":[{ +): 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) => +export const getFlagWithCohort = (cohortId: string): EvaluationFlag => JSON.parse(getFlagStrWithCohort(cohortId)); export const FLAGS = [ From 39841e9a32575c01a9f045299e063252c2e50319 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 1 Aug 2024 14:32:57 -0700 Subject: [PATCH 44/48] add no config integration test --- packages/node/test/local/client.test.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index 9021ce3..d26d4f9 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -420,6 +420,26 @@ describe('ExperimentClient integration tests', () => { 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', @@ -512,7 +532,8 @@ describe('ExperimentClient integration tests', () => { await sleep(62000); // Poller polls after 60s. // Within this time, // Flag poller (flagConfigPollingIntervalMillis = 40000) will poll a new version, NEW_FLAGS which contains flag5. - // Cohort poller (pollingIntervalMillis = 60000) will poll all cohorts in the flags, which will all success. + // 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. From 7b6750ede6ca3786d21e9abd75c3d02e1fdb41e8 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 6 Aug 2024 10:12:46 -0700 Subject: [PATCH 45/48] change default maxCohortSize --- packages/node/src/types/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index 00f67d0..e8b70db 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -250,7 +250,7 @@ export const AssignmentConfigDefaults: Omit = { export const CohortConfigDefaults: Omit = { cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', - maxCohortSize: 10_000_000, + maxCohortSize: 2147483647, cohortRequestDelayMillis: 100, }; From cb05834f84da2fc8ac14bc3e4eb6ba86f6432001 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 6 Aug 2024 13:02:23 -0700 Subject: [PATCH 46/48] changed configs --- packages/node/src/local/client.ts | 19 +++++++++++------- packages/node/src/local/cohort/cohort-api.ts | 11 ++++++++++ packages/node/src/local/cohort/fetcher.ts | 18 ++++++++++++----- packages/node/src/types/config.ts | 20 +++++++++++++------ packages/node/src/util/config.ts | 12 ++++++----- packages/node/test/local/benchmark.test.ts | 4 ++-- packages/node/test/local/client.eu.test.ts | 2 +- packages/node/test/local/client.test.ts | 10 +++++----- .../test/local/cohort/cohortFetcher.test.ts | 4 ++-- packages/node/test/util/config.test.ts | 18 ++++++++--------- 10 files changed, 76 insertions(+), 42 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 10c6cf5..f274e86 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -46,6 +46,8 @@ 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 @@ -89,21 +91,24 @@ export class LocalEvaluationClient { this.cohortStorage = new InMemoryCohortStorage(); let cohortFetcher: CohortFetcher = undefined; - if (this.config.cohortConfig) { + if (this.config.cohortSyncConfig) { cohortFetcher = new CohortFetcher( - this.config.cohortConfig.apiKey, - this.config.cohortConfig.secretKey, + this.config.cohortSyncConfig.apiKey, + this.config.cohortSyncConfig.secretKey, httpClient, - this.config.cohortConfig?.cohortServerUrl, - this.config.cohortConfig?.maxCohortSize, - this.config.cohortConfig?.cohortRequestDelayMillis, + this.config.cohortSyncConfig?.cohortServerUrl, + this.config.cohortSyncConfig?.maxCohortSize, + undefined, this.config.debug, ); this.cohortUpdater = new CohortPoller( cohortFetcher, this.cohortStorage, this.cache, - 60000, + Math.max( + COHORT_POLLING_INTERVAL_MILLIS_MIN, + this.config.cohortSyncConfig?.cohortPollingIntervalMillis, + ), this.config.debug, ); } diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index 5d10994..39eae0b 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -26,6 +26,8 @@ export interface CohortApi { export class CohortMaxSizeExceededError extends Error {} +export class CohortClientRequestError extends Error {} + export class CohortDownloadError extends Error {} export class SdkCohortApi implements CohortApi { @@ -79,6 +81,15 @@ export class SdkCohortApi implements CohortApi { throw new CohortMaxSizeExceededError( `Cohort size > ${options.maxCohortSize}`, ); + } else if ( + 400 <= response.status && + response.status < 500 && + response.status != 429 + ) { + // Any 400 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 index 70f418b..933d1ed 100644 --- a/packages/node/src/local/cohort/fetcher.ts +++ b/packages/node/src/local/cohort/fetcher.ts @@ -1,13 +1,17 @@ import { version as PACKAGE_VERSION } from '../../../gen/version'; import { WrapperClient } from '../../transport/http'; import { Cohort } from '../../types/cohort'; -import { CohortConfigDefaults } from '../../types/config'; +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 { CohortMaxSizeExceededError, SdkCohortApi } from './cohort-api'; +import { + CohortClientRequestError, + CohortMaxSizeExceededError, + SdkCohortApi, +} from './cohort-api'; export const COHORT_CONFIG_TIMEOUT = 20000; @@ -31,8 +35,8 @@ export class CohortFetcher { apiKey: string, secretKey: string, httpClient: HttpClient, - serverUrl = CohortConfigDefaults.cohortServerUrl, - maxCohortSize = CohortConfigDefaults.maxCohortSize, + serverUrl = CohortSyncConfigDefaults.cohortServerUrl, + maxCohortSize = CohortSyncConfigDefaults.maxCohortSize, cohortRequestDelayMillis = 100, debug = false, ) { @@ -78,7 +82,11 @@ export class CohortFetcher { this.logger.debug('Stop downloading', cohortId); return cohort; } catch (e) { - if (i === ATTEMPTS - 1 || e instanceof CohortMaxSizeExceededError) { + if ( + i === ATTEMPTS - 1 || + e instanceof CohortMaxSizeExceededError || + e instanceof CohortClientRequestError + ) { const unlock = await this.mutex.lock(); delete this.inProgressCohorts[key]; unlock(); diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index e8b70db..0b36946 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -128,7 +128,7 @@ export type LocalEvaluationConfig = { /** * Select the Amplitude data center to get flags and variants from, `us` or `eu`. */ - serverZone?: string; + serverZone?: 'us' | 'eu'; /** * The server endpoint from which to request flags. For hitting the EU data center, use serverZone. @@ -184,7 +184,7 @@ export type LocalEvaluationConfig = { */ streamFlagConnTimeoutMillis?: number; - cohortConfig?: CohortConfig; + cohortSyncConfig?: CohortSyncConfig; }; export type AssignmentConfig = { @@ -200,7 +200,7 @@ export type AssignmentConfig = { cacheCapacity?: number; } & NodeOptions; -export type CohortConfig = { +export type CohortSyncConfig = { apiKey: string; secretKey: string; @@ -216,7 +216,15 @@ export type CohortConfig = { */ maxCohortSize?: number; - cohortRequestDelayMillis?: 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; }; /** @@ -247,11 +255,11 @@ export const AssignmentConfigDefaults: Omit = { cacheCapacity: 65536, }; -export const CohortConfigDefaults: Omit = +export const CohortSyncConfigDefaults: Omit = { cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', maxCohortSize: 2147483647, - cohortRequestDelayMillis: 100, + cohortPollingIntervalMillis: 60000, }; export const EU_SERVER_URLS = { diff --git a/packages/node/src/util/config.ts b/packages/node/src/util/config.ts index 97880c9..f735161 100644 --- a/packages/node/src/util/config.ts +++ b/packages/node/src/util/config.ts @@ -6,7 +6,7 @@ import { import { EU_SERVER_URLS, LocalEvaluationDefaults, - CohortConfigDefaults, + CohortSyncConfigDefaults, } from '../types/config'; export const populateRemoteConfigDefaults = ( @@ -14,6 +14,7 @@ export const populateRemoteConfigDefaults = ( ): 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 @@ -28,6 +29,7 @@ export const populateLocalConfigDefaults = ( ): 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 @@ -40,12 +42,12 @@ export const populateLocalConfigDefaults = ( : LocalEvaluationDefaults.streamServerUrl; } if ( - customConfig?.cohortConfig && - !customConfig?.cohortConfig.cohortServerUrl + customConfig?.cohortSyncConfig && + !customConfig?.cohortSyncConfig.cohortServerUrl ) { - config.cohortConfig.cohortServerUrl = isEu + config.cohortSyncConfig.cohortServerUrl = isEu ? EU_SERVER_URLS.cohort - : CohortConfigDefaults.cohortServerUrl; + : CohortSyncConfigDefaults.cohortServerUrl; } return config; }; diff --git a/packages/node/test/local/benchmark.test.ts b/packages/node/test/local/benchmark.test.ts index a2bc881..1343954 100644 --- a/packages/node/test/local/benchmark.test.ts +++ b/packages/node/test/local/benchmark.test.ts @@ -16,14 +16,14 @@ if (!process.env['API_KEY'] && !process.env['SECRET_KEY']) { ); } -const cohortConfig = { +const cohortSyncConfig = { apiKey: process.env['API_KEY'], secretKey: process.env['SECRET_KEY'], }; const client = Experiment.initializeLocal(apiKey, { debug: false, - cohortConfig: cohortConfig, + cohortSyncConfig: cohortSyncConfig, }); beforeAll(async () => { diff --git a/packages/node/test/local/client.eu.test.ts b/packages/node/test/local/client.eu.test.ts index 12a49ae..c5b6ae8 100644 --- a/packages/node/test/local/client.eu.test.ts +++ b/packages/node/test/local/client.eu.test.ts @@ -16,7 +16,7 @@ const apiKey = 'server-Qlp7XiSu6JtP2S3JzA95PnP27duZgQCF'; const client = Experiment.initializeLocal(apiKey, { serverZone: 'eu', - cohortConfig: { + cohortSyncConfig: { apiKey: process.env['EU_API_KEY'], secretKey: process.env['EU_SECRET_KEY'], }, diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index d26d4f9..e1cc8d1 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -267,7 +267,7 @@ const setupEvaluateCohortTestErrorClientCases = ( describe('ExperimentClient end-to-end tests, normal cases', () => { describe('Normal cases', () => { const client = Experiment.initializeLocal(apiKey, { - cohortConfig: { + cohortSyncConfig: { apiKey: process.env['API_KEY'], secretKey: process.env['SECRET_KEY'], }, @@ -302,7 +302,7 @@ describe('ExperimentClient end-to-end tests, normal cases', () => { describe('Bad cohort config', () => { const client = Experiment.initializeLocal(apiKey, { - cohortConfig: { + cohortSyncConfig: { apiKey: 'bad_api_key', secretKey: 'bad_secret_key', }, @@ -368,7 +368,7 @@ describe('ExperimentClient integration tests', () => { const client = new LocalEvaluationClient( 'apikey', { - cohortConfig: { + cohortSyncConfig: { apiKey: 'apiKey', secretKey: 'secretKey', maxCohortSize: 10, @@ -444,7 +444,7 @@ describe('ExperimentClient integration tests', () => { const client = new LocalEvaluationClient( 'apikey', { - cohortConfig: { + cohortSyncConfig: { apiKey: 'apiKey', secretKey: 'secretKey', maxCohortSize: 0, @@ -472,7 +472,7 @@ describe('ExperimentClient integration tests', () => { 'apikey', { flagConfigPollingIntervalMillis: 40000, - cohortConfig: { + cohortSyncConfig: { apiKey: 'apiKey', secretKey: 'secretKey', maxCohortSize: 10, diff --git a/packages/node/test/local/cohort/cohortFetcher.test.ts b/packages/node/test/local/cohort/cohortFetcher.test.ts index 040134d..874b41c 100644 --- a/packages/node/test/local/cohort/cohortFetcher.test.ts +++ b/packages/node/test/local/cohort/cohortFetcher.test.ts @@ -3,7 +3,7 @@ import { SdkCohortApi, } from 'src/local/cohort/cohort-api'; import { COHORT_CONFIG_TIMEOUT, CohortFetcher } from 'src/local/cohort/fetcher'; -import { CohortConfigDefaults } from 'src/types/config'; +import { CohortSyncConfigDefaults } from 'src/types/config'; import { sleep } from 'src/util/time'; import { version as PACKAGE_VERSION } from '../../../gen/version'; @@ -58,7 +58,7 @@ test('cohort fetch success', async () => { lastModified: undefined, libraryName: 'experiment-node-server', libraryVersion: PACKAGE_VERSION, - maxCohortSize: CohortConfigDefaults.maxCohortSize, + maxCohortSize: CohortSyncConfigDefaults.maxCohortSize, timeoutMillis: COHORT_CONFIG_TIMEOUT, }); }); diff --git a/packages/node/test/util/config.test.ts b/packages/node/test/util/config.test.ts index d9ecaed..5843652 100644 --- a/packages/node/test/util/config.test.ts +++ b/packages/node/test/util/config.test.ts @@ -1,7 +1,7 @@ import { LocalEvaluationConfig } from 'src/index'; import { LocalEvaluationDefaults, - CohortConfigDefaults, + CohortSyncConfigDefaults, EU_SERVER_URLS, RemoteEvaluationConfig, } from 'src/types/config'; @@ -17,12 +17,12 @@ test.each([ 'us', LocalEvaluationDefaults.serverUrl, LocalEvaluationDefaults.streamServerUrl, - CohortConfigDefaults.cohortServerUrl, + CohortSyncConfigDefaults.cohortServerUrl, ], ], [ { zone: 'EU' }, - ['EU', EU_SERVER_URLS.flags, EU_SERVER_URLS.stream, EU_SERVER_URLS.cohort], + ['eu', EU_SERVER_URLS.flags, EU_SERVER_URLS.stream, EU_SERVER_URLS.cohort], ], [ { url: 'urlurl', stream: 'streamurl', cohort: 'cohorturl' }, @@ -38,13 +38,13 @@ test.each([ ], ])("'%s'", (testcase, expected) => { const config: LocalEvaluationConfig = { - cohortConfig: { + cohortSyncConfig: { apiKey: '', secretKey: '', }, }; if ('zone' in testcase) { - config.serverZone = testcase.zone; + config.serverZone = testcase.zone as never; } if ('url' in testcase) { config.serverUrl = testcase.url; @@ -53,25 +53,25 @@ test.each([ config.streamServerUrl = testcase.stream; } if ('cohort' in testcase) { - config.cohortConfig.cohortServerUrl = testcase.cohort; + 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.cohortConfig.cohortServerUrl).toBe(expected[3]); + expect(newConfig.cohortSyncConfig.cohortServerUrl).toBe(expected[3]); }); test.each([ [{}, 'us', LocalEvaluationDefaults.serverUrl], - [{ zone: 'EU' }, 'EU', EU_SERVER_URLS.remote], + [{ 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; + config.serverZone = testcase.zone as never; } if ('url' in testcase) { config.serverUrl = testcase.url; From d8740c741fafa9d6fc716fa2d271b42f7caffdf9 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 6 Aug 2024 21:10:10 -0700 Subject: [PATCH 47/48] fix lint --- packages/node/src/types/config.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index 0b36946..7a5e9c6 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -255,12 +255,14 @@ export const AssignmentConfigDefaults: Omit = { cacheCapacity: 65536, }; -export const CohortSyncConfigDefaults: Omit = - { - cohortServerUrl: 'https://cohort-v2.lab.amplitude.com', - maxCohortSize: 2147483647, - cohortPollingIntervalMillis: 60000, - }; +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', From e5c6e533c568a50c886a063c272c47b597723c0a Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 9 Aug 2024 12:11:42 -0700 Subject: [PATCH 48/48] add test, fix comment --- packages/node/src/local/cohort/cohort-api.ts | 10 ++++---- .../test/local/cohort/cohortFetcher.test.ts | 24 ++++++++++++++++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/node/src/local/cohort/cohort-api.ts b/packages/node/src/local/cohort/cohort-api.ts index 39eae0b..9845db2 100644 --- a/packages/node/src/local/cohort/cohort-api.ts +++ b/packages/node/src/local/cohort/cohort-api.ts @@ -24,11 +24,9 @@ export interface CohortApi { getCohort(options?: GetCohortOptions): Promise; } -export class CohortMaxSizeExceededError extends Error {} - -export class CohortClientRequestError extends Error {} - -export class CohortDownloadError extends Error {} +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; @@ -86,7 +84,7 @@ export class SdkCohortApi implements CohortApi { response.status < 500 && response.status != 429 ) { - // Any 400 other than 429. + // Any 4xx other than 429. throw new CohortClientRequestError( `Cohort client error response status ${response.status}, body ${response.body}`, ); diff --git a/packages/node/test/local/cohort/cohortFetcher.test.ts b/packages/node/test/local/cohort/cohortFetcher.test.ts index 874b41c..3f5637a 100644 --- a/packages/node/test/local/cohort/cohortFetcher.test.ts +++ b/packages/node/test/local/cohort/cohortFetcher.test.ts @@ -1,4 +1,5 @@ import { + CohortClientRequestError, CohortMaxSizeExceededError, SdkCohortApi, } from 'src/local/cohort/cohort-api'; @@ -38,7 +39,7 @@ const COHORTS = { }, }; -beforeEach(() => { +afterEach(() => { jest.clearAllMocks(); }); @@ -155,6 +156,27 @@ test('cohort fetch maxSize exceeded, no retry', async () => { }); }); +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) => {