From 9220241e9d1b56d910b8a49e2208ac2cca462a7d Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Sat, 24 Feb 2024 22:03:06 -0800 Subject: [PATCH 01/14] added stream flag configs updates --- packages/node/package.json | 9 +- packages/node/src/local/client.ts | 38 +- packages/node/src/local/poller.ts | 3 +- packages/node/src/local/streamer.ts | 169 ++++ packages/node/src/local/updater.ts | 26 + packages/node/src/types/config.ts | 45 + .../test/local/flagConfigStreamer.test.ts | 880 ++++++++++++++++++ .../test/local/util/mockStreamEventSource.ts | 93 ++ yarn.lock | 15 + 9 files changed, 1266 insertions(+), 12 deletions(-) create mode 100644 packages/node/src/local/streamer.ts create mode 100644 packages/node/src/local/updater.ts create mode 100644 packages/node/test/local/flagConfigStreamer.test.ts create mode 100644 packages/node/test/local/util/mockStreamEventSource.ts diff --git a/packages/node/package.json b/packages/node/package.json index ba21872..96a8931 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -32,6 +32,11 @@ "dependencies": { "@amplitude/analytics-node": "^1.3.4", "@amplitude/analytics-types": "^1.3.1", - "@amplitude/experiment-core": "^0.7.2" + "@amplitude/experiment-core": "^0.7.2", + "eventsource": "^2.0.2" + }, + "devDependencies": { + "@types/eventsource": "^1.1.15", + "@types/node": "18.7.23" } -} +} \ No newline at end of file diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 363e15d..d674168 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -2,8 +2,10 @@ import * as amplitude from '@amplitude/analytics-node'; import { EvaluationEngine, EvaluationFlag, + StreamEventSourceClass, topologicalSort, } from '@amplitude/experiment-core'; +import EventSource from 'eventsource'; import { Assignment, AssignmentService } from '../assignment/assignment'; import { InMemoryAssignmentFilter } from '../assignment/assignment-filter'; @@ -30,6 +32,8 @@ import { import { InMemoryFlagConfigCache } from './cache'; import { FlagConfigFetcher } from './fetcher'; import { FlagConfigPoller } from './poller'; +import { FlagConfigStreamer } from './streamer'; +import { FlagConfigUpdater } from './updater'; /** * Experiment client for evaluating variants for a user locally. @@ -38,7 +42,7 @@ import { FlagConfigPoller } from './poller'; export class LocalEvaluationClient { private readonly logger: Logger; private readonly config: LocalEvaluationConfig; - private readonly poller: FlagConfigPoller; + private readonly updater: FlagConfigUpdater; private readonly assignmentService: AssignmentService; private readonly evaluation: EvaluationEngine; @@ -54,6 +58,7 @@ export class LocalEvaluationClient { config: LocalEvaluationConfig, flagConfigCache?: FlagConfigCache, httpClient: HttpClient = new FetchHttpClient(config?.httpAgent), + streamEventSourceClass: StreamEventSourceClass = EventSource, ) { this.config = { ...LocalEvaluationDefaults, ...config }; const fetcher = new FlagConfigFetcher( @@ -67,12 +72,27 @@ export class LocalEvaluationClient { this.config.bootstrap, ); this.logger = new ConsoleLogger(this.config.debug); - this.poller = new FlagConfigPoller( - fetcher, - this.cache, - this.config.flagConfigPollingIntervalMillis, - this.config.debug, - ); + this.updater = this.config.getFlagConfigUpdateWithStream + ? new FlagConfigStreamer( + apiKey, + fetcher, + this.cache, + streamEventSourceClass, + this.config.flagConfigPollingIntervalMillis, + this.config.streamConnTimeoutMillis, + this.config.streamFlagConnTimeoutMillis, + this.config.streamFlagTryAttempts, + this.config.streamFlagTryDelayMillis, + this.config.retryStreamFlagDelayMillis, + this.config.streamServerUrl, + this.config.debug, + ) + : new FlagConfigPoller( + fetcher, + this.cache, + this.config.flagConfigPollingIntervalMillis, + this.config.debug, + ); if (this.config.assignmentConfig) { this.config.assignmentConfig = { ...AssignmentConfigDefaults, @@ -158,7 +178,7 @@ export class LocalEvaluationClient { * Calling this function while the poller is already running does nothing. */ public async start(): Promise { - return await this.poller.start(); + return await this.updater.start(); } /** @@ -167,6 +187,6 @@ export class LocalEvaluationClient { * Calling this function while the poller is not running will do nothing. */ public stop(): void { - return this.poller.stop(); + return this.updater.stop(); } } diff --git a/packages/node/src/local/poller.ts b/packages/node/src/local/poller.ts index 80b066d..cb2463c 100644 --- a/packages/node/src/local/poller.ts +++ b/packages/node/src/local/poller.ts @@ -5,6 +5,7 @@ import { ConsoleLogger } from '../util/logger'; import { Logger } from '../util/logger'; import { FlagConfigFetcher } from './fetcher'; +import { FlagConfigUpdater } from './updater'; const BACKOFF_POLICY: BackoffPolicy = { attempts: 5, @@ -13,7 +14,7 @@ const BACKOFF_POLICY: BackoffPolicy = { scalar: 1, }; -export class FlagConfigPoller { +export class FlagConfigPoller implements FlagConfigUpdater { private readonly logger: Logger; private readonly pollingIntervalMillis: number; diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts new file mode 100644 index 0000000..b535fc4 --- /dev/null +++ b/packages/node/src/local/streamer.ts @@ -0,0 +1,169 @@ +import { + SdkStreamFlagApi, + StreamErrorEvent, + StreamEventSourceClass, +} from '@amplitude/experiment-core'; + +import { version as PACKAGE_VERSION } from '../../gen/version'; +import { LocalEvaluationDefaults } from '../types/config'; +import { FlagConfigCache } from '../types/flag'; +import { ConsoleLogger } from '../util/logger'; +import { Logger } from '../util/logger'; + +import { FlagConfigFetcher } from './fetcher'; +import { FlagConfigPoller } from './poller'; + +export class FlagConfigStreamer { + private readonly logger: Logger; + + private readonly poller: FlagConfigPoller; + private readonly stream: SdkStreamFlagApi; + private readonly retryStreamFlagDelayMillis: number; + + private streamRetryTimeout?: NodeJS.Timeout; + + public readonly cache: FlagConfigCache; + + constructor( + apiKey: string, + fetcher: FlagConfigFetcher, + cache: FlagConfigCache, + streamEventSourceClass: StreamEventSourceClass, + pollingIntervalMillis = LocalEvaluationDefaults.flagConfigPollingIntervalMillis, + streamConnTimeoutMillis = LocalEvaluationDefaults.streamConnTimeoutMillis, + streamFlagConnTimeoutMillis = LocalEvaluationDefaults.streamFlagConnTimeoutMillis, + streamFlagTryAttempts = LocalEvaluationDefaults.streamFlagTryAttempts, + streamFlagTryDelayMillis = LocalEvaluationDefaults.streamFlagTryDelayMillis, + retryStreamFlagDelayMillis = LocalEvaluationDefaults.retryStreamFlagDelayMillis, + serverUrl: string = LocalEvaluationDefaults.serverUrl, + debug = false, + ) { + this.logger = new ConsoleLogger(debug); + this.logger.debug('[Experiment] streamer - init'); + this.cache = cache; + this.poller = new FlagConfigPoller( + fetcher, + cache, + pollingIntervalMillis, + debug, + ); + this.stream = new SdkStreamFlagApi( + apiKey, + serverUrl, + streamEventSourceClass, + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + ); + this.retryStreamFlagDelayMillis = retryStreamFlagDelayMillis; + } + + /** + * Fetch initial flag configurations and start polling for updates. + * + * 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?: (cache: FlagConfigCache) => Promise, + ): Promise { + 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}`, + ); + this.poller.start(onChange); + this.startRetryStreamTimeout(); + }; + + this.stream.onUpdate = async (flagConfigs) => { + this.logger.debug('[Experiment] streamer - receives updates'); + let changed = false; + if (onChange) { + const current = await this.cache.getAll(); + if (!Object.is(current, flagConfigs)) { + changed = true; + } + } + await this.cache.clear(); + await this.cache.putAll(flagConfigs); + if (changed) { + await onChange(this.cache); + } + }; + + try { + // Clear retry timeout. If stream isn't connected, we're trying now. + // If stream is connected, timeout will be undefined and connect will do nothing. + if (this.streamRetryTimeout) { + clearTimeout(this.streamRetryTimeout); + } + // stream connect error will be raised, not through calling onError. + // So onError won't be called. + await this.stream.connect({ + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + }); + this.poller.stop(); + this.logger.debug('[Experiment] streamer - start stream success'); + } catch (e) { + const err = e as StreamErrorEvent; + this.logger.debug( + `[Experiment] streamer - start stream failed, fallback to poller, err status: ${err.status}, err message: ${err.message}`, + ); + await this.poller.start(onChange); + this.startRetryStreamTimeout(); + } + } + + /** + * Stop polling for flag configurations. + * + * Calling this function while the poller is not running will do nothing. + */ + public stop(): void { + this.logger.debug('[Experiment] streamer - stop'); + if (this.streamRetryTimeout) { + clearTimeout(this.streamRetryTimeout); + } + this.poller.stop(); + this.stream.close(); + } + + /** + * 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.poller.update(onChange); + } + + // Retry stream after a while. + private startRetryStreamTimeout() { + if (this.streamRetryTimeout) { + clearTimeout(this.streamRetryTimeout); + } + this.streamRetryTimeout = setTimeout(() => { + this.logger.debug('[Experiment] streamer - retry stream'); + this.stream + .connect() + .then(() => { + this.logger.debug('[Experiment] streamer - retry stream success'); + // Stop poller. + this.poller.stop(); + }) + // No need to set timeout here. onError handles calling startRetryStreamInterval(). + // eslint-disable-next-line @typescript-eslint/no-empty-function + .catch(() => {}); + }, this.retryStreamFlagDelayMillis); + } +} diff --git a/packages/node/src/local/updater.ts b/packages/node/src/local/updater.ts new file mode 100644 index 0000000..3674785 --- /dev/null +++ b/packages/node/src/local/updater.ts @@ -0,0 +1,26 @@ +import { FlagConfigCache } from '..'; + +export interface FlagConfigUpdater { + /** + * Fetch initial flag configurations and start watching for updates. + * + * You must call this function to begin watching for flag config updates. + * The promise returned by this function is resolved when the initial call + * to fetch the flag configuration completes. + */ + start(onChange?: (cache: FlagConfigCache) => Promise): Promise; + + /** + * Stop updating flag configurations. + */ + stop(): void; + + /** + * 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. + */ + update(onChange?: (cache: FlagConfigCache) => Promise): Promise; +} diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index 65b20ad..7581041 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -148,6 +148,44 @@ export type LocalEvaluationConfig = { * evaluation. */ assignmentConfig?: AssignmentConfig; + + /** + * To use streaming API or polling. With streaming, flag config updates are + * received immediately, no polling is necessary. If stream fails, it will + * fallback to polling automatically. + */ + getFlagConfigUpdateWithStream?: boolean; + + /** + * The stream server endpoint from which to stream data. + */ + streamServerUrl?: string; + + /** + * To use with streaming. The timeout for connecting an server-side event stream. Aka, the timeout for http connection. + */ + streamConnTimeoutMillis?: number; + + /** + * To use with streaming. The timeout for a single attempt of establishing a valid stream of flag configs. + * This includes streamConnTimeoutMillis and time for receiving initial flag configs. + */ + streamFlagConnTimeoutMillis?: number; + + /** + * To use with streaming. The number attempts to connect before declaring streaming fatal error. + */ + streamFlagTryAttempts?: number; + + /** + * To use with streaming. The delay between attempts to connect. + */ + streamFlagTryDelayMillis?: number; + + /** + * To use with streaming. The delay to retry streaming after stream fatal error and fallbacked to poller. + */ + retryStreamFlagDelayMillis?: number; }; export type AssignmentConfig = { @@ -181,6 +219,13 @@ export const LocalEvaluationDefaults: LocalEvaluationConfig = { bootstrap: {}, flagConfigPollingIntervalMillis: 30000, httpAgent: null, + getFlagConfigUpdateWithStream: false, + streamServerUrl: 'https://stream.lab.amplitude.com', + streamConnTimeoutMillis: 1000, + streamFlagConnTimeoutMillis: 1000, + streamFlagTryAttempts: 2, + streamFlagTryDelayMillis: 1000, + retryStreamFlagDelayMillis: 15000, }; export const AssignmentConfigDefaults: Omit = { diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts new file mode 100644 index 0000000..228552e --- /dev/null +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -0,0 +1,880 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import assert from 'assert'; + +import { InMemoryFlagConfigCache } from 'src/index'; +import { FlagConfigFetcher } from 'src/local/fetcher'; +import { FlagConfigStreamer } from 'src/local/streamer'; + +import { MockHttpClient } from './util/mockHttpClient'; +import { getNewClient } from './util/mockStreamEventSource'; + +const apiKey = 'client-xxxx'; +const serverUrl = 'http://localhostxxxx:799999999'; +const streamConnTimeoutMillis = 1000; +const streamFlagConnTimeoutMillis = 1000; +const streamFlagTryAttempts = 2; +const streamFlagTryDelayMillis = 1000; +const retryStreamFlagDelayMillis = 15000; + +// Following values may not be used in all tests. +const pollingIntervalMillis = 1000; +const fetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + return { status: 500, body: undefined }; + }), +); +const cache = new InMemoryFlagConfigCache(); + +test('FlagConfigUpdater.connect, success', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + try { + updater.start(); + await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client!.doMsg({ data: '[]' }); + assert(fetchCalls == 0); + assert(mockClient.numCreated == 1); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, start success, gets initial flag configs, gets subsequent flag configs', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const cache = new InMemoryFlagConfigCache(); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + try { + updater.start(); + await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client!.doMsg({ + data: '[{"key": "a", "variants": {}, "segments": []}]', + }); + assert(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); + + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, stream start fail, fallback to poller, poller updates flag configs correctly', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + let dataI = 0; + const data = [ + '[{"key": "a", "variants": {}, "segments": []}]', + '[{"key": "b", "variants": {}, "segments": []}]', + ]; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: data[dataI] }; + }), + ); + const cache = new InMemoryFlagConfigCache(); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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(fetchCalls >= 1); + assert(mockClient.numCreated == 1); + assert((await cache.get('a')).key == 'a'); + + dataI++; + await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. + assert((await cache.get('b')).key == 'b'); + assert((await cache.get('a')) == undefined); + + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, start success, gets error initial flag configs, fallback to poller', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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, 1000)); // Need to yield quite some time to start retry. + + 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, 1000)); // Need to yield quite some time to start retry. + + // Should fallbacked to poller. + assert(fetchCalls > 0); + assert(mockClient.numCreated == 2); + + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, start success, gets ok initial flag configs, but gets error flag configs later, fallback to poller', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const cache = new InMemoryFlagConfigCache(); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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(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(fetchCalls > 0); + n = mockClient.numCreated; + assert(n == 3); + + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, open but no initial flag configs', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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(fetchCalls > 0); + assert(mockClient.numCreated == 2); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, success and then fails and then reconnects', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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(fetchCalls == 0); + assert(mockClient.numCreated == 2); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, timeout first try, retry success', async () => { + const mockClient = getNewClient(); + const updater = new FlagConfigStreamer( + apiKey, + fetcher, + cache, + mockClient.clientClass, + pollingIntervalMillis, + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, retry timeout, backoff to poll after 2 tries', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + try { + await updater.start(); // Awaits start(), no data sent. + assert(fetchCalls >= 1); + assert(mockClient.numCreated == 2); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, 501, backoff to poll after 1 try', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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(fetchCalls >= 1); + assert(mockClient.numCreated == 1); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, 404, backoff to poll after 2 tries', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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(fetchCalls >= 1); + assert(mockClient.numCreated == 2); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, two starts, second does nothing', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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(fetchCalls == 0); + assert(mockClient.numCreated == 1); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, start and immediately stop does not retry', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 100, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + try { + updater.start(); + updater.stop(); + await new Promise((r) => setTimeout(r, 1000)); + assert(fetchCalls == 0); + assert(mockClient.numCreated == 1); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, test error after connection, poller starts, stream retry success, poller stops', async () => { + jest.setTimeout(25000); + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 200, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + 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(fetchCalls > 0); + n = mockClient.numCreated; + assert(n == 3); + // Check retry. + await new Promise((r) => setTimeout(r, retryStreamFlagDelayMillis)); // 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 = fetchCalls; + await new Promise((r) => setTimeout(r, 500)); // Wait to see if poller runs while waiting. + assert(fetchCalls == prevFetchCalls); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, test restarts', async () => { + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 200, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + try { + updater.start(); + await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client!.doMsg({ data: '[]' }); + assert(fetchCalls == 0); + let n = mockClient.numCreated; + assert(n == 1); + await updater.stop(); + + // Test start after normal close. + updater.start(); + await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client!.doMsg({ data: '[]' }); + assert(fetchCalls == 0); + n = mockClient.numCreated; + assert(n == 2); + await 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(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 = 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(fetchCalls == prevFetchCalls); + n = mockClient.numCreated; + assert(n == 6); + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, start success, keep alive success, no fallback to poller', async () => { + jest.setTimeout(20000); + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 200, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + try { + updater.start(); + await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client!.doMsg({ data: '[]' }); + assert(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(fetchCalls == 0); + n = mockClient.numCreated; + assert(n == 1); + + await new Promise((r) => setTimeout(r, 3000)); // Wait for original keep alive timeout to reach. + assert(fetchCalls == 0); + n = mockClient.numCreated; + assert(n == 1); + + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigStreamer.connect, start success, keep alive fail, retry success', async () => { + jest.setTimeout(20000); + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 200, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + try { + updater.start(); + await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client!.doMsg({ data: '[]' }); + assert(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(fetchCalls == 0); + n = mockClient.numCreated; + assert(n == 2); + + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, start success, keep alive fail, retry fail twice, fallback to poller', async () => { + jest.setTimeout(20000); + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 200, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + retryStreamFlagDelayMillis, + serverUrl, + false, + ); + try { + updater.start(); + await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client!.doMsg({ data: '[]' }); + assert(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(fetchCalls > 0); + n = mockClient.numCreated; + assert(n == 3); + + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test.todo( + 'FlagConfigUpdater.connect, start and immediately stop and immediately start is an unhandled edge case', +); diff --git a/packages/node/test/local/util/mockStreamEventSource.ts b/packages/node/test/local/util/mockStreamEventSource.ts new file mode 100644 index 0000000..8b3f38f --- /dev/null +++ b/packages/node/test/local/util/mockStreamEventSource.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { + StreamEventSource, + StreamOpenEvent, + StreamMessageEvent, + StreamErrorEvent, + StreamEvent, + StreamEventSourceClass, +} from '@amplitude/experiment-core'; + +export interface MockStreamEventSourceClient extends StreamEventSource { + // Methods for test. + doOpen(evt: StreamOpenEvent): Promise; + doMsg(evt: StreamMessageEvent): Promise; + doErr(evt: StreamErrorEvent): Promise; +} + +export function getNewClient(): { + client: MockStreamEventSourceClient | undefined; + numCreated: number; + clientClass: StreamEventSourceClass; +} { + const clientObj = { + client: undefined, + numCreated: 0, + clientClass: undefined, + }; + class AClientClass implements MockStreamEventSourceClient { + static readonly CLOSED: number = 0; + static readonly CONNECTING: number = 1; + static readonly OPEN: number = 2; + + constructor(url: string, initDict: Record) { + clientObj.client = this; + clientObj.numCreated++; + + this.url = url; + this._readyState = AClientClass.CONNECTING; + // this.withCredentials = params['withCredentials']; + } + CLOSED: number = AClientClass.CLOSED; + CONNECTING: number = AClientClass.CONNECTING; + OPEN: number = AClientClass.OPEN; + + // Variables + readonly url: string; + private _readyState: number; + get readyState(): number { + return this._readyState; + } + readonly withCredentials: boolean; + + // Handlers. + onopen: (evt: StreamOpenEvent) => any; + onmessage: (evt: StreamMessageEvent) => any; + onerror: (evt: StreamErrorEvent) => any; + + // Other unused methods. + addEventListener( + type: string, + listener: (evt: StreamEvent) => void, + ): void {} // No implementation. + dispatchEvent(evt: Event): boolean { + return false; + } + removeEventListener( + type: string, + listener: (evt: StreamEvent) => void, + ): void {} // No implementation. + + // Close. + close(): void { + this._readyState = AClientClass.CLOSED; + } + + // Methods for test. + async doOpen(evt: StreamOpenEvent): Promise { + this._readyState = AClientClass.OPEN; + await this.onopen(evt); + } + async doMsg(evt: StreamMessageEvent): Promise { + await this.onmessage(evt); + } + async doErr(evt: StreamErrorEvent): Promise { + this._readyState = AClientClass.CLOSED; + await this.onerror(evt); + } + } + clientObj.clientClass = AClientClass; + return clientObj; +} diff --git a/yarn.lock b/yarn.lock index a1f030d..b62f6a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1933,6 +1933,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/eventsource@^1.1.15": + version "1.1.15" + resolved "https://registry.yarnpkg.com/@types/eventsource/-/eventsource-1.1.15.tgz#949383d3482e20557cbecbf3b038368d94b6be27" + integrity sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA== + "@types/graceful-fs@^4.1.2": version "4.1.6" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" @@ -1992,6 +1997,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== +"@types/node@18.7.23": + version "18.7.23" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.23.tgz#75c580983846181ebe5f4abc40fe9dfb2d65665f" + integrity sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg== + "@types/node@^14.11.8": version "14.18.42" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.42.tgz#fa39b2dc8e0eba61bdf51c66502f84e23b66e114" @@ -3819,6 +3829,11 @@ events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +eventsource@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" + integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== + exec-sh@^0.3.2: version "0.3.6" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" From cbcba64fc59bbc69b265ea440a4c5a77f840fd71 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 26 Feb 2024 15:17:05 -0800 Subject: [PATCH 02/14] fix retry stopped after first --- packages/node/src/local/streamer.ts | 33 +++-- .../test/local/flagConfigStreamer.test.ts | 130 ++++++++++++++++++ 2 files changed, 148 insertions(+), 15 deletions(-) diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index b535fc4..95a1140 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -20,7 +20,7 @@ export class FlagConfigStreamer { private readonly stream: SdkStreamFlagApi; private readonly retryStreamFlagDelayMillis: number; - private streamRetryTimeout?: NodeJS.Timeout; + private streamRetryInterval?: NodeJS.Timeout; public readonly cache: FlagConfigCache; @@ -77,7 +77,7 @@ export class FlagConfigStreamer { `[Experiment] streamer - onError, fallback to poller, err status: ${err.status}, err message: ${err.message}`, ); this.poller.start(onChange); - this.startRetryStreamTimeout(); + this.startRetryStreamInterval(); }; this.stream.onUpdate = async (flagConfigs) => { @@ -99,9 +99,7 @@ export class FlagConfigStreamer { try { // Clear retry timeout. If stream isn't connected, we're trying now. // If stream is connected, timeout will be undefined and connect will do nothing. - if (this.streamRetryTimeout) { - clearTimeout(this.streamRetryTimeout); - } + this.clearRetryStreamInterval(); // stream connect error will be raised, not through calling onError. // So onError won't be called. await this.stream.connect({ @@ -116,7 +114,7 @@ export class FlagConfigStreamer { `[Experiment] streamer - start stream failed, fallback to poller, err status: ${err.status}, err message: ${err.message}`, ); await this.poller.start(onChange); - this.startRetryStreamTimeout(); + this.startRetryStreamInterval(); } } @@ -127,9 +125,7 @@ export class FlagConfigStreamer { */ public stop(): void { this.logger.debug('[Experiment] streamer - stop'); - if (this.streamRetryTimeout) { - clearTimeout(this.streamRetryTimeout); - } + this.clearRetryStreamInterval(); this.poller.stop(); this.stream.close(); } @@ -148,22 +144,29 @@ export class FlagConfigStreamer { } // Retry stream after a while. - private startRetryStreamTimeout() { - if (this.streamRetryTimeout) { - clearTimeout(this.streamRetryTimeout); - } - this.streamRetryTimeout = setTimeout(() => { + private startRetryStreamInterval() { + this.clearRetryStreamInterval(); + this.streamRetryInterval = setInterval(() => { this.logger.debug('[Experiment] streamer - retry stream'); this.stream .connect() .then(() => { this.logger.debug('[Experiment] streamer - retry stream success'); + // Clear interval. + this.clearRetryStreamInterval(); // Stop poller. this.poller.stop(); }) - // No need to set timeout here. onError handles calling startRetryStreamInterval(). // eslint-disable-next-line @typescript-eslint/no-empty-function .catch(() => {}); }, this.retryStreamFlagDelayMillis); } + + // Clear retry interval. + private clearRetryStreamInterval() { + if (this.streamRetryInterval) { + clearInterval(this.streamRetryInterval); + this.streamRetryInterval = undefined; + } + } } diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 228552e..1ddfe9d 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -875,6 +875,136 @@ test('FlagConfigUpdater.connect, start success, keep alive fail, retry fail twic } }); +test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream success, stop poller, no more retry stream', async () => { + jest.setTimeout(10000); + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 200, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + 2000, + serverUrl, + false, + ); + 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(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 = fetchCalls; + await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client!.doMsg({ data: '[]' }); + assert(fetchCalls == prevFetchCalls); + + // Wait to check poller stopped. + await new Promise((r) => setTimeout(r, 500)); + assert(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); + + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + +test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream fail, continue poller, retry stream success, stop poller', async () => { + jest.setTimeout(10000); + const mockClient = getNewClient(); + let fetchCalls = 0; + const mockFetcher = new FlagConfigFetcher( + apiKey, + new MockHttpClient(async () => { + fetchCalls++; + return { status: 200, body: '[]' }; + }), + ); + const updater = new FlagConfigStreamer( + apiKey, + mockFetcher, + cache, + mockClient.clientClass, + 200, // poller fetch every 100ms. + streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, + streamFlagTryAttempts, + streamFlagTryDelayMillis, + 2000, + serverUrl, + false, + ); + 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(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 = 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(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 = fetchCalls; + await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client!.doMsg({ data: '[]' }); + assert(fetchCalls == prevFetchCalls); + + // Wait to check poller stopped. + await new Promise((r) => setTimeout(r, 500)); + assert(fetchCalls == prevFetchCalls); + + await updater.stop(); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + test.todo( 'FlagConfigUpdater.connect, start and immediately stop and immediately start is an unhandled edge case', ); From 5abedae5989675ff8bd4bb96e74007ad11bd0aca Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 26 Feb 2024 16:44:20 -0800 Subject: [PATCH 03/14] using latest usable types node version --- packages/node/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index 96a8931..d2d2d36 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -37,6 +37,6 @@ }, "devDependencies": { "@types/eventsource": "^1.1.15", - "@types/node": "18.7.23" + "@types/node": "18.8.0" } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index b62f6a3..3040410 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1997,10 +1997,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== -"@types/node@18.7.23": - version "18.7.23" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.23.tgz#75c580983846181ebe5f4abc40fe9dfb2d65665f" - integrity sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg== +"@types/node@18.8.0": + version "18.8.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.0.tgz#b8ee8d83a99470c0661bd899417fcd77060682fe" + integrity sha512-u+h43R6U8xXDt2vzUaVP3VwjjLyOJk6uEciZS8OSyziUQGOwmk+l+4drxcsDboHXwyTaqS1INebghmWMRxq3LA== "@types/node@^14.11.8": version "14.18.42" From 70bad3f95749231868dc725f0e1e67dfcf89eb9d Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 26 Feb 2024 17:18:45 -0800 Subject: [PATCH 04/14] reuse test setup code --- .../test/local/flagConfigStreamer.test.ts | 768 +++++------------- 1 file changed, 195 insertions(+), 573 deletions(-) diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 1ddfe9d..4f0eaf9 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ import assert from 'assert'; import { InMemoryFlagConfigCache } from 'src/index'; @@ -8,40 +7,40 @@ import { FlagConfigStreamer } from 'src/local/streamer'; import { MockHttpClient } from './util/mockHttpClient'; import { getNewClient } from './util/mockStreamEventSource'; -const apiKey = 'client-xxxx'; -const serverUrl = 'http://localhostxxxx:799999999'; -const streamConnTimeoutMillis = 1000; -const streamFlagConnTimeoutMillis = 1000; -const streamFlagTryAttempts = 2; -const streamFlagTryDelayMillis = 1000; -const retryStreamFlagDelayMillis = 15000; - -// Following values may not be used in all tests. -const pollingIntervalMillis = 1000; -const fetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - return { status: 500, body: undefined }; - }), -); -const cache = new InMemoryFlagConfigCache(); - -test('FlagConfigUpdater.connect, success', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( +const getTestObjs = ({ + pollingIntervalMillis = 1000, + streamConnTimeoutMillis = 1000, + streamFlagConnTimeoutMillis = 1000, + streamFlagTryAttempts = 2, + streamFlagTryDelayMillis = 1000, + retryStreamFlagDelayMillis = 15000, + apiKey = 'client-xxxx', + serverUrl = 'http://localhostxxxx:799999999', +}) => { + const fetchObj = { fetchCalls: 0, fetcher: undefined }; + let dataI = 0; + const data = [ + '[{"key": "fetcher-a", "variants": {}, "segments": []}]', + '[{"key": "fetcher-b", "variants": {}, "segments": []}]', + ]; + fetchObj.fetcher = new FlagConfigFetcher( apiKey, new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; + fetchObj.fetchCalls++; + return { status: 200, body: data[dataI] }; }), ); + const fetcherReturnNext = () => { + dataI++; + }; + const cache = new InMemoryFlagConfigCache(); + const mockClient = getNewClient(); const updater = new FlagConfigStreamer( apiKey, - mockFetcher, + fetchObj.fetcher, cache, mockClient.clientClass, - 100, + pollingIntervalMillis, streamConnTimeoutMillis, streamFlagConnTimeoutMillis, streamFlagTryAttempts, @@ -50,11 +49,23 @@ test('FlagConfigUpdater.connect, success', async () => { serverUrl, false, ); + return { + fetchObj, + fetcherReturnNext, + cache, + mockClient, + updater, + }; +}; + +test('FlagConfigUpdater.connect, success', async () => { + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); - assert(fetchCalls == 0); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); assert(mockClient.numCreated == 1); await updater.stop(); // Pass @@ -65,42 +76,20 @@ test('FlagConfigUpdater.connect, success', async () => { }); test('FlagConfigUpdater.connect, start success, gets initial flag configs, gets subsequent flag configs', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const cache = new InMemoryFlagConfigCache(); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[{"key": "a", "variants": {}, "segments": []}]', }); - assert(fetchCalls == 0); + 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({ + await mockClient.client.doMsg({ data: '[{"key": "b", "variants": {}, "segments": []}]', }); await new Promise((r) => setTimeout(r, 200)); @@ -116,47 +105,20 @@ test('FlagConfigUpdater.connect, start success, gets initial flag configs, gets }); test('FlagConfigUpdater.connect, stream start fail, fallback to poller, poller updates flag configs correctly', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - let dataI = 0; - const data = [ - '[{"key": "a", "variants": {}, "segments": []}]', - '[{"key": "b", "variants": {}, "segments": []}]', - ]; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: data[dataI] }; - }), - ); - const cache = new InMemoryFlagConfigCache(); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + 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 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(fetchCalls >= 1); + assert(fetchObj.fetchCalls >= 1); assert(mockClient.numCreated == 1); - assert((await cache.get('a')).key == 'a'); + assert((await cache.get('fetcher-a')).key == 'fetcher-a'); - dataI++; + fetcherReturnNext(); await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. - assert((await cache.get('b')).key == 'b'); - assert((await cache.get('a')) == undefined); + assert((await cache.get('fetcher-b')).key == 'fetcher-b'); + assert((await cache.get('fetcher-a')) == undefined); await updater.stop(); // Pass @@ -167,45 +129,24 @@ test('FlagConfigUpdater.connect, stream start fail, fallback to poller, poller u }); test('FlagConfigUpdater.connect, start success, gets error initial flag configs, fallback to poller', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ + 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, 1000)); // Need to yield quite some time to start retry. - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ + 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, 1000)); // Need to yield quite some time to start retry. // Should fallbacked to poller. - assert(fetchCalls > 0); + assert(fetchObj.fetchCalls > 0); assert(mockClient.numCreated == 2); await updater.stop(); @@ -217,60 +158,38 @@ test('FlagConfigUpdater.connect, start success, gets error initial flag configs, }); test('FlagConfigUpdater.connect, start success, gets ok initial flag configs, but gets error flag configs later, fallback to poller', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const cache = new InMemoryFlagConfigCache(); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ + 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(fetchCalls == 0); + assert(fetchObj.fetchCalls == 0); let n = mockClient.numCreated; assert(n == 1); // Start error ones. - await mockClient.client!.doMsg({ + 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({ + 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({ + 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(fetchCalls > 0); + assert(fetchObj.fetchCalls > 0); n = mockClient.numCreated; assert(n == 3); @@ -283,36 +202,15 @@ test('FlagConfigUpdater.connect, start success, gets ok initial flag configs, bu }); test('FlagConfigUpdater.connect, open but no initial flag configs', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client.doOpen({ type: 'open' }); await new Promise((r) => setTimeout(r, 1100)); - await mockClient.client!.doOpen({ type: 'open' }); + await mockClient.client.doOpen({ type: 'open' }); await new Promise((r) => setTimeout(r, 2000)); - assert(fetchCalls > 0); + assert(fetchObj.fetchCalls > 0); assert(mockClient.numCreated == 2); await updater.stop(); // Pass @@ -323,38 +221,17 @@ test('FlagConfigUpdater.connect, open but no initial flag configs', async () => }); test('FlagConfigUpdater.connect, success and then fails and then reconnects', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, 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 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(fetchCalls == 0); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); assert(mockClient.numCreated == 2); await updater.stop(); // Pass @@ -365,26 +242,13 @@ test('FlagConfigUpdater.connect, success and then fails and then reconnects', as }); test('FlagConfigUpdater.connect, timeout first try, retry success', async () => { - const mockClient = getNewClient(); - const updater = new FlagConfigStreamer( - apiKey, - fetcher, - cache, - mockClient.clientClass, - pollingIntervalMillis, - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, 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: '[]' }); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); assert(mockClient.numCreated == 2); await updater.stop(); // Pass @@ -395,32 +259,11 @@ test('FlagConfigUpdater.connect, timeout first try, retry success', async () => }); test('FlagConfigUpdater.connect, retry timeout, backoff to poll after 2 tries', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { await updater.start(); // Awaits start(), no data sent. - assert(fetchCalls >= 1); + assert(fetchObj.fetchCalls >= 1); assert(mockClient.numCreated == 2); await updater.stop(); // Pass @@ -431,34 +274,13 @@ test('FlagConfigUpdater.connect, retry timeout, backoff to poll after 2 tries', }); test('FlagConfigUpdater.connect, 501, backoff to poll after 1 try', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { updater.start(); - await mockClient.client!.doErr({ status: 501 }); // Send 501 fatal err. + await mockClient.client.doErr({ status: 501 }); // Send 501 fatal err. await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. - assert(fetchCalls >= 1); + assert(fetchObj.fetchCalls >= 1); assert(mockClient.numCreated == 1); await updater.stop(); // Pass @@ -469,36 +291,15 @@ test('FlagConfigUpdater.connect, 501, backoff to poll after 1 try', async () => }); test('FlagConfigUpdater.connect, 404, backoff to poll after 2 tries', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { updater.start(); - await mockClient.client!.doErr({ status: 404 }); // Send error for first try. + 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 mockClient.client.doErr({ status: 404 }); // Send error for second try. await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. - assert(fetchCalls >= 1); + assert(fetchObj.fetchCalls >= 1); assert(mockClient.numCreated == 2); await updater.stop(); // Pass @@ -509,36 +310,15 @@ test('FlagConfigUpdater.connect, 404, backoff to poll after 2 tries', async () = }); test('FlagConfigUpdater.connect, two starts, second does nothing', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { updater.start(); updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); + 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(fetchCalls == 0); + assert(fetchObj.fetchCalls == 0); assert(mockClient.numCreated == 1); await updater.stop(); // Pass @@ -549,34 +329,13 @@ test('FlagConfigUpdater.connect, two starts, second does nothing', async () => { }); test('FlagConfigUpdater.connect, start and immediately stop does not retry', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 100, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 100 }); try { updater.start(); updater.stop(); await new Promise((r) => setTimeout(r, 1000)); - assert(fetchCalls == 0); + assert(fetchObj.fetchCalls == 0); assert(mockClient.numCreated == 1); // Pass } catch (e) { @@ -587,59 +346,42 @@ test('FlagConfigUpdater.connect, start and immediately stop does not retry', asy test('FlagConfigUpdater.connect, test error after connection, poller starts, stream retry success, poller stops', async () => { jest.setTimeout(25000); - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 200, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const retryStreamFlagDelayMillis = 15000; + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ + pollingIntervalMillis: 200, + retryStreamFlagDelayMillis, + }); try { // Test error after normal close. updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); + 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 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. + 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 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(fetchCalls > 0); + assert(fetchObj.fetchCalls > 0); n = mockClient.numCreated; assert(n == 3); // Check retry. await new Promise((r) => setTimeout(r, retryStreamFlagDelayMillis)); // Wait for retry. - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); n = mockClient.numCreated; assert(n == 4); // Check poller stop. - const prevFetchCalls = fetchCalls; + const prevFetchCalls = fetchObj.fetchCalls; await new Promise((r) => setTimeout(r, 500)); // Wait to see if poller runs while waiting. - assert(fetchCalls == prevFetchCalls); + assert(fetchObj.fetchCalls == prevFetchCalls); await updater.stop(); // Pass } catch (e) { @@ -649,69 +391,48 @@ test('FlagConfigUpdater.connect, test error after connection, poller starts, str }); test('FlagConfigUpdater.connect, test restarts', async () => { - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 200, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 200 }); try { updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); - assert(fetchCalls == 0); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); let n = mockClient.numCreated; assert(n == 1); await updater.stop(); // Test start after normal close. updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); - assert(fetchCalls == 0); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); n = mockClient.numCreated; assert(n == 2); await 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 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 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 mockClient.client.doErr({ status: 500 }); // Send error for second retry. await new Promise((r) => setTimeout(r, 500)); // Wait for stream to init. - assert(fetchCalls > 0); + 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 = fetchCalls; + const prevFetchCalls = fetchObj.fetchCalls; updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); await new Promise((r) => setTimeout(r, 500)); // Wait for stream to init. - assert(fetchCalls == prevFetchCalls); + assert(fetchObj.fetchCalls == prevFetchCalls); n = mockClient.numCreated; assert(n == 6); await updater.stop(); @@ -724,46 +445,25 @@ test('FlagConfigUpdater.connect, test restarts', async () => { test('FlagConfigUpdater.connect, start success, keep alive success, no fallback to poller', async () => { jest.setTimeout(20000); - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 200, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 200 }); try { updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); - assert(fetchCalls == 0); + 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(fetchCalls == 0); + 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(fetchCalls == 0); + assert(fetchObj.fetchCalls == 0); n = mockClient.numCreated; assert(n == 1); @@ -777,42 +477,21 @@ test('FlagConfigUpdater.connect, start success, keep alive success, no fallback test('FlagConfigStreamer.connect, start success, keep alive fail, retry success', async () => { jest.setTimeout(20000); - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 200, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 200 }); try { updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); - assert(fetchCalls == 0); + 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(fetchCalls == 0); + await mockClient.client.doOpen({ type: 'open' }); + await mockClient.client.doMsg({ data: '[]' }); + assert(fetchObj.fetchCalls == 0); n = mockClient.numCreated; assert(n == 2); @@ -826,44 +505,23 @@ test('FlagConfigStreamer.connect, start success, keep alive fail, retry success' test('FlagConfigUpdater.connect, start success, keep alive fail, retry fail twice, fallback to poller', async () => { jest.setTimeout(20000); - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 200, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ pollingIntervalMillis: 200 }); try { updater.start(); - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); - assert(fetchCalls == 0); + 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 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 mockClient.client.doErr({ status: 500 }); // Send error for second try. await new Promise((r) => setTimeout(r, 500)); // Wait for poller to init. - assert(fetchCalls > 0); + assert(fetchObj.fetchCalls > 0); n = mockClient.numCreated; assert(n == 3); @@ -877,34 +535,16 @@ test('FlagConfigUpdater.connect, start success, keep alive fail, retry fail twic test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream success, stop poller, no more retry stream', async () => { jest.setTimeout(10000); - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 200, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - 2000, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ + pollingIntervalMillis: 200, + retryStreamFlagDelayMillis: 2000, + }); try { updater.start(); - await mockClient.client!.doErr({ status: 501 }); // Fatal err to fail initial conn. + 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(fetchCalls > 0); + assert(fetchObj.fetchCalls > 0); let n = mockClient.numCreated; assert(n == 1); @@ -914,14 +554,14 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream su assert(n == 2); // Retry stream success. - const prevFetchCalls = fetchCalls; - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); - assert(fetchCalls == prevFetchCalls); + 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(fetchCalls == prevFetchCalls); + assert(fetchObj.fetchCalls == prevFetchCalls); // Check there is no more retry stream. await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. @@ -938,34 +578,16 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream su test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream fail, continue poller, retry stream success, stop poller', async () => { jest.setTimeout(10000); - const mockClient = getNewClient(); - let fetchCalls = 0; - const mockFetcher = new FlagConfigFetcher( - apiKey, - new MockHttpClient(async () => { - fetchCalls++; - return { status: 200, body: '[]' }; - }), - ); - const updater = new FlagConfigStreamer( - apiKey, - mockFetcher, - cache, - mockClient.clientClass, - 200, // poller fetch every 100ms. - streamConnTimeoutMillis, - streamFlagConnTimeoutMillis, - streamFlagTryAttempts, - streamFlagTryDelayMillis, - 2000, - serverUrl, - false, - ); + const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = + getTestObjs({ + pollingIntervalMillis: 200, + retryStreamFlagDelayMillis: 2000, + }); try { updater.start(); - await mockClient.client!.doErr({ status: 501 }); // Fatal err to fail initial conn. + 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(fetchCalls > 0); + assert(fetchObj.fetchCalls > 0); let n = mockClient.numCreated; assert(n == 1); @@ -975,12 +597,12 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream fa assert(n == 2); // Retry stream fail. - let prevFetchCalls = fetchCalls; - await mockClient.client!.doErr({ status: 500 }); // Fatal err to fail stream retry. + 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(fetchCalls > prevFetchCalls); + assert(fetchObj.fetchCalls > prevFetchCalls); // Wait for another retry stream start. await new Promise((r) => setTimeout(r, 2000)); // Wait for retry. @@ -988,14 +610,14 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream fa assert(n == 3); // Retry stream success. - prevFetchCalls = fetchCalls; - await mockClient.client!.doOpen({ type: 'open' }); - await mockClient.client!.doMsg({ data: '[]' }); - assert(fetchCalls == prevFetchCalls); + 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(fetchCalls == prevFetchCalls); + assert(fetchObj.fetchCalls == prevFetchCalls); await updater.stop(); // Pass From 2ea4a09675fc88defa78782097d2e57139784772 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 26 Feb 2024 17:23:35 -0800 Subject: [PATCH 05/14] lint --- packages/node/package.json | 2 +- .../test/local/flagConfigStreamer.test.ts | 105 ++++++++++-------- 2 files changed, 59 insertions(+), 48 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index d2d2d36..ba4a3fd 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -39,4 +39,4 @@ "@types/eventsource": "^1.1.15", "@types/node": "18.8.0" } -} \ No newline at end of file +} diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 4f0eaf9..1368ec5 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -59,8 +59,9 @@ const getTestObjs = ({ }; test('FlagConfigUpdater.connect, success', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -76,8 +77,9 @@ test('FlagConfigUpdater.connect, success', async () => { }); test('FlagConfigUpdater.connect, start success, gets initial flag configs, gets subsequent flag configs', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, cache, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -129,8 +131,9 @@ test('FlagConfigUpdater.connect, stream start fail, fallback to poller, poller u }); test('FlagConfigUpdater.connect, start success, gets error initial flag configs, fallback to poller', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -158,8 +161,9 @@ test('FlagConfigUpdater.connect, start success, gets error initial flag configs, }); test('FlagConfigUpdater.connect, start success, gets ok initial flag configs, but gets error flag configs later, fallback to poller', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -202,8 +206,9 @@ test('FlagConfigUpdater.connect, start success, gets ok initial flag configs, bu }); test('FlagConfigUpdater.connect, open but no initial flag configs', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -221,8 +226,9 @@ test('FlagConfigUpdater.connect, open but no initial flag configs', async () => }); test('FlagConfigUpdater.connect, success and then fails and then reconnects', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -242,8 +248,7 @@ test('FlagConfigUpdater.connect, success and then fails and then reconnects', as }); test('FlagConfigUpdater.connect, timeout first try, retry success', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({}); + 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. @@ -259,8 +264,9 @@ test('FlagConfigUpdater.connect, timeout first try, retry success', async () => }); test('FlagConfigUpdater.connect, retry timeout, backoff to poll after 2 tries', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { await updater.start(); // Awaits start(), no data sent. assert(fetchObj.fetchCalls >= 1); @@ -274,8 +280,9 @@ test('FlagConfigUpdater.connect, retry timeout, backoff to poll after 2 tries', }); test('FlagConfigUpdater.connect, 501, backoff to poll after 1 try', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); await mockClient.client.doErr({ status: 501 }); // Send 501 fatal err. @@ -291,8 +298,9 @@ test('FlagConfigUpdater.connect, 501, backoff to poll after 1 try', async () => }); test('FlagConfigUpdater.connect, 404, backoff to poll after 2 tries', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); await mockClient.client.doErr({ status: 404 }); // Send error for first try. @@ -310,8 +318,9 @@ test('FlagConfigUpdater.connect, 404, backoff to poll after 2 tries', async () = }); test('FlagConfigUpdater.connect, two starts, second does nothing', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); updater.start(); @@ -329,8 +338,9 @@ test('FlagConfigUpdater.connect, two starts, second does nothing', async () => { }); test('FlagConfigUpdater.connect, start and immediately stop does not retry', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 100 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 100, + }); try { updater.start(); updater.stop(); @@ -347,11 +357,10 @@ test('FlagConfigUpdater.connect, start and immediately stop does not retry', asy test('FlagConfigUpdater.connect, test error after connection, poller starts, stream retry success, poller stops', async () => { jest.setTimeout(25000); const retryStreamFlagDelayMillis = 15000; - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ - pollingIntervalMillis: 200, - retryStreamFlagDelayMillis, - }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 200, + retryStreamFlagDelayMillis, + }); try { // Test error after normal close. updater.start(); @@ -391,8 +400,9 @@ test('FlagConfigUpdater.connect, test error after connection, poller starts, str }); test('FlagConfigUpdater.connect, test restarts', async () => { - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 200 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 200, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -445,8 +455,9 @@ test('FlagConfigUpdater.connect, test restarts', async () => { test('FlagConfigUpdater.connect, start success, keep alive success, no fallback to poller', async () => { jest.setTimeout(20000); - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 200 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 200, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -477,8 +488,9 @@ test('FlagConfigUpdater.connect, start success, keep alive success, no fallback test('FlagConfigStreamer.connect, start success, keep alive fail, retry success', async () => { jest.setTimeout(20000); - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 200 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 200, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -505,8 +517,9 @@ test('FlagConfigStreamer.connect, start success, keep alive fail, retry success' test('FlagConfigUpdater.connect, start success, keep alive fail, retry fail twice, fallback to poller', async () => { jest.setTimeout(20000); - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ pollingIntervalMillis: 200 }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 200, + }); try { updater.start(); await mockClient.client.doOpen({ type: 'open' }); @@ -535,11 +548,10 @@ test('FlagConfigUpdater.connect, start success, keep alive fail, retry fail twic test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream success, stop poller, no more retry stream', async () => { jest.setTimeout(10000); - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ - pollingIntervalMillis: 200, - retryStreamFlagDelayMillis: 2000, - }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 200, + retryStreamFlagDelayMillis: 2000, + }); try { updater.start(); await mockClient.client.doErr({ status: 501 }); // Fatal err to fail initial conn. @@ -578,11 +590,10 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream su test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream fail, continue poller, retry stream success, stop poller', async () => { jest.setTimeout(10000); - const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = - getTestObjs({ - pollingIntervalMillis: 200, - retryStreamFlagDelayMillis: 2000, - }); + const { fetchObj, mockClient, updater } = getTestObjs({ + pollingIntervalMillis: 200, + retryStreamFlagDelayMillis: 2000, + }); try { updater.start(); await mockClient.client.doErr({ status: 501 }); // Fatal err to fail initial conn. From caeca48d309aee9da2c6471b41f505ef3c98378a Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 28 Feb 2024 02:11:48 -0800 Subject: [PATCH 06/14] added new test --- packages/node/src/local/streamer.ts | 1 + .../test/local/flagConfigStreamer.test.ts | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 95a1140..6871b5c 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -102,6 +102,7 @@ export class FlagConfigStreamer { this.clearRetryStreamInterval(); // stream connect error will be raised, not through calling onError. // So onError won't be called. + // If close is called during connect, connect will return success. No sideeffects here. await this.stream.connect({ libraryName: 'experiment-node-server', libraryVersion: PACKAGE_VERSION, diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 1368ec5..851db47 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -16,6 +16,7 @@ const getTestObjs = ({ retryStreamFlagDelayMillis = 15000, apiKey = 'client-xxxx', serverUrl = 'http://localhostxxxx:799999999', + debug = false, }) => { const fetchObj = { fetchCalls: 0, fetcher: undefined }; let dataI = 0; @@ -47,7 +48,7 @@ const getTestObjs = ({ streamFlagTryDelayMillis, retryStreamFlagDelayMillis, serverUrl, - false, + debug, ); return { fetchObj, @@ -140,13 +141,13 @@ test('FlagConfigUpdater.connect, start success, gets error initial flag configs, await mockClient.client.doMsg({ data: 'xxx', }); // Initial error flag configs for first try. - await new Promise((r) => setTimeout(r, 1000)); // Need to yield quite some time to start retry. + 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, 1000)); // Need to yield quite some time to start retry. + await new Promise((r) => setTimeout(r, 1100)); // Wait try delay. // Should fallbacked to poller. assert(fetchObj.fetchCalls > 0); @@ -354,6 +355,26 @@ test('FlagConfigUpdater.connect, start and immediately stop does not retry', asy } }); +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); + // Pass + } catch (e) { + updater.stop(); + fail(e); + } +}); + test('FlagConfigUpdater.connect, test error after connection, poller starts, stream retry success, poller stops', async () => { jest.setTimeout(25000); const retryStreamFlagDelayMillis = 15000; From c5a8b3edc088dad9f7ba6cdf6176bcd622160f73 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 28 Feb 2024 02:13:46 -0800 Subject: [PATCH 07/14] clean test --- .../test/local/flagConfigStreamer.test.ts | 65 +++++++------------ 1 file changed, 22 insertions(+), 43 deletions(-) diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 851db47..b9ce8c2 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -15,7 +15,7 @@ const getTestObjs = ({ streamFlagTryDelayMillis = 1000, retryStreamFlagDelayMillis = 15000, apiKey = 'client-xxxx', - serverUrl = 'http://localhostxxxx:799999999', + serverUrl = 'http://localhostxxxx:00000000', debug = false, }) => { const fetchObj = { fetchCalls: 0, fetcher: undefined }; @@ -69,8 +69,7 @@ test('FlagConfigUpdater.connect, success', async () => { await mockClient.client.doMsg({ data: '[]' }); assert(fetchObj.fetchCalls == 0); assert(mockClient.numCreated == 1); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -99,8 +98,7 @@ test('FlagConfigUpdater.connect, start success, gets initial flag configs, gets assert((await cache.get('b')).key == 'b'); assert((await cache.get('a')) == undefined); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -123,8 +121,7 @@ test('FlagConfigUpdater.connect, stream start fail, fallback to poller, poller u assert((await cache.get('fetcher-b')).key == 'fetcher-b'); assert((await cache.get('fetcher-a')) == undefined); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -153,8 +150,7 @@ test('FlagConfigUpdater.connect, start success, gets error initial flag configs, assert(fetchObj.fetchCalls > 0); assert(mockClient.numCreated == 2); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -198,8 +194,7 @@ test('FlagConfigUpdater.connect, start success, gets ok initial flag configs, bu n = mockClient.numCreated; assert(n == 3); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -218,8 +213,7 @@ test('FlagConfigUpdater.connect, open but no initial flag configs', async () => await new Promise((r) => setTimeout(r, 2000)); assert(fetchObj.fetchCalls > 0); assert(mockClient.numCreated == 2); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -240,8 +234,7 @@ test('FlagConfigUpdater.connect, success and then fails and then reconnects', as await mockClient.client.doMsg({ data: '[]' }); assert(fetchObj.fetchCalls == 0); assert(mockClient.numCreated == 2); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -256,8 +249,7 @@ test('FlagConfigUpdater.connect, timeout first try, retry success', async () => await mockClient.client.doOpen({ type: 'open' }); await mockClient.client.doMsg({ data: '[]' }); assert(mockClient.numCreated == 2); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -272,8 +264,7 @@ test('FlagConfigUpdater.connect, retry timeout, backoff to poll after 2 tries', await updater.start(); // Awaits start(), no data sent. assert(fetchObj.fetchCalls >= 1); assert(mockClient.numCreated == 2); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -290,8 +281,7 @@ test('FlagConfigUpdater.connect, 501, backoff to poll after 1 try', async () => await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. assert(fetchObj.fetchCalls >= 1); assert(mockClient.numCreated == 1); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -310,8 +300,7 @@ test('FlagConfigUpdater.connect, 404, backoff to poll after 2 tries', async () = await new Promise((r) => setTimeout(r, 200)); // Wait for poller to poll. assert(fetchObj.fetchCalls >= 1); assert(mockClient.numCreated == 2); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -330,8 +319,7 @@ test('FlagConfigUpdater.connect, two starts, second does nothing', async () => { await new Promise((r) => setTimeout(r, 2500)); // Wait for stream to init success. assert(fetchObj.fetchCalls == 0); assert(mockClient.numCreated == 1); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -348,7 +336,6 @@ test('FlagConfigUpdater.connect, start and immediately stop does not retry', asy await new Promise((r) => setTimeout(r, 1000)); assert(fetchObj.fetchCalls == 0); assert(mockClient.numCreated == 1); - // Pass } catch (e) { updater.stop(); fail(e); @@ -368,7 +355,6 @@ test('FlagConfigUpdater.connect, start fail, retry and immediately stop, no poll await new Promise((r) => setTimeout(r, 200)); // Wait to check poller start. assert(fetchObj.fetchCalls == 0); - // Pass } catch (e) { updater.stop(); fail(e); @@ -412,8 +398,7 @@ test('FlagConfigUpdater.connect, test error after connection, poller starts, str const prevFetchCalls = fetchObj.fetchCalls; await new Promise((r) => setTimeout(r, 500)); // Wait to see if poller runs while waiting. assert(fetchObj.fetchCalls == prevFetchCalls); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -431,7 +416,7 @@ test('FlagConfigUpdater.connect, test restarts', async () => { assert(fetchObj.fetchCalls == 0); let n = mockClient.numCreated; assert(n == 1); - await updater.stop(); + updater.stop(); // Test start after normal close. updater.start(); @@ -440,7 +425,7 @@ test('FlagConfigUpdater.connect, test restarts', async () => { assert(fetchObj.fetchCalls == 0); n = mockClient.numCreated; assert(n == 2); - await updater.stop(); + updater.stop(); // Test error after normal close. updater.start(); @@ -466,8 +451,7 @@ test('FlagConfigUpdater.connect, test restarts', async () => { assert(fetchObj.fetchCalls == prevFetchCalls); n = mockClient.numCreated; assert(n == 6); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -499,8 +483,7 @@ test('FlagConfigUpdater.connect, start success, keep alive success, no fallback n = mockClient.numCreated; assert(n == 1); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -528,8 +511,7 @@ test('FlagConfigStreamer.connect, start success, keep alive fail, retry success' n = mockClient.numCreated; assert(n == 2); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -559,8 +541,7 @@ test('FlagConfigUpdater.connect, start success, keep alive fail, retry fail twic n = mockClient.numCreated; assert(n == 3); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -601,8 +582,7 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream su n = mockClient.numCreated; assert(n == 2); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); @@ -651,8 +631,7 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream fa await new Promise((r) => setTimeout(r, 500)); assert(fetchObj.fetchCalls == prevFetchCalls); - await updater.stop(); - // Pass + updater.stop(); } catch (e) { updater.stop(); fail(e); From 07330f8b52705569e8f2aa6cca048674d572f0ee Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 28 Feb 2024 20:04:22 -0800 Subject: [PATCH 08/14] added eventsource.d.ts and use factory pattern for stream --- packages/node/package.json | 4 -- packages/node/src/local/client.ts | 13 ++-- packages/node/src/local/streamer.ts | 6 +- packages/node/src/types/eventsource.d.ts | 61 +++++++++++++++++++ .../test/local/flagConfigStreamer.test.ts | 2 +- .../test/local/util/mockStreamEventSource.ts | 9 +-- yarn.lock | 10 --- 7 files changed, 78 insertions(+), 27 deletions(-) create mode 100644 packages/node/src/types/eventsource.d.ts diff --git a/packages/node/package.json b/packages/node/package.json index ba4a3fd..b3f6f12 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -34,9 +34,5 @@ "@amplitude/analytics-types": "^1.3.1", "@amplitude/experiment-core": "^0.7.2", "eventsource": "^2.0.2" - }, - "devDependencies": { - "@types/eventsource": "^1.1.15", - "@types/node": "18.8.0" } } diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index d674168..ab7b50b 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -2,7 +2,7 @@ import * as amplitude from '@amplitude/analytics-node'; import { EvaluationEngine, EvaluationFlag, - StreamEventSourceClass, + StreamEventSourceFactory, topologicalSort, } from '@amplitude/experiment-core'; import EventSource from 'eventsource'; @@ -58,7 +58,8 @@ export class LocalEvaluationClient { config: LocalEvaluationConfig, flagConfigCache?: FlagConfigCache, httpClient: HttpClient = new FetchHttpClient(config?.httpAgent), - streamEventSourceClass: StreamEventSourceClass = EventSource, + streamEventSourceFactory: StreamEventSourceFactory = (url, params) => + new EventSource(url, params), ) { this.config = { ...LocalEvaluationDefaults, ...config }; const fetcher = new FlagConfigFetcher( @@ -77,7 +78,7 @@ export class LocalEvaluationClient { apiKey, fetcher, this.cache, - streamEventSourceClass, + streamEventSourceFactory, this.config.flagConfigPollingIntervalMillis, this.config.streamConnTimeoutMillis, this.config.streamFlagConnTimeoutMillis, @@ -177,8 +178,10 @@ export class LocalEvaluationClient { * * Calling this function while the poller is already running does nothing. */ - public async start(): Promise { - return await this.updater.start(); + public async start( + cb: (cache: FlagConfigCache) => Promise, + ): Promise { + return await this.updater.start(cb); } /** diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 6871b5c..2146f29 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -1,7 +1,7 @@ import { SdkStreamFlagApi, StreamErrorEvent, - StreamEventSourceClass, + StreamEventSourceFactory, } from '@amplitude/experiment-core'; import { version as PACKAGE_VERSION } from '../../gen/version'; @@ -28,7 +28,7 @@ export class FlagConfigStreamer { apiKey: string, fetcher: FlagConfigFetcher, cache: FlagConfigCache, - streamEventSourceClass: StreamEventSourceClass, + streamEventSourceFactory: StreamEventSourceFactory, pollingIntervalMillis = LocalEvaluationDefaults.flagConfigPollingIntervalMillis, streamConnTimeoutMillis = LocalEvaluationDefaults.streamConnTimeoutMillis, streamFlagConnTimeoutMillis = LocalEvaluationDefaults.streamFlagConnTimeoutMillis, @@ -50,7 +50,7 @@ export class FlagConfigStreamer { this.stream = new SdkStreamFlagApi( apiKey, serverUrl, - streamEventSourceClass, + streamEventSourceFactory, streamConnTimeoutMillis, streamFlagConnTimeoutMillis, streamFlagTryAttempts, diff --git a/packages/node/src/types/eventsource.d.ts b/packages/node/src/types/eventsource.d.ts new file mode 100644 index 0000000..194b3a7 --- /dev/null +++ b/packages/node/src/types/eventsource.d.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ + +// Adapted from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/eventsource/index.d.ts +// The use of Event class in @types/eventsource was bundled with an older version of Event class from @types/node. +// Thus, using @types/eventsource will incur a conflict if @types/node: ^18.1.0. +// Instead of using @types/eventsource, we directly declare the type here, so Event can be whatever from @types/node. +declare module 'eventsource' { + namespace EventSource { + enum ReadyState { + CONNECTING = 0, + OPEN = 1, + CLOSED = 2, + } + + interface EventSourceInitDict { + withCredentials?: boolean | undefined; + headers?: object | undefined; + proxy?: string | undefined; + https?: object | undefined; + rejectUnauthorized?: boolean | undefined; + } + } + + class MessageEvent { + type: string; + data?: string; + lastEventId?: string; + origin?: string; + } + + class EventSource { + static readonly CLOSED: number; + static readonly CONNECTING: number; + static readonly OPEN: number; + + constructor( + url: string, + eventSourceInitDict?: EventSource.EventSourceInitDict, + ); + + readonly CLOSED: number; + readonly CONNECTING: number; + readonly OPEN: number; + readonly url: string; + readonly readyState: number; + readonly withCredentials: boolean; + onopen: (evt: { type: string }) => any; + onmessage: (evt: MessageEvent) => any; + onerror: (evt: { status?: number; message: string }) => any; + addEventListener(type: string, listener: (evt: MessageEvent) => void): void; + dispatchEvent(evt: Event): boolean; + removeEventListener( + type: string, + listener: (evt: MessageEvent) => void, + ): void; + close(): void; + } + + export default EventSource; +} diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index b9ce8c2..941d35b 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -40,7 +40,7 @@ const getTestObjs = ({ apiKey, fetchObj.fetcher, cache, - mockClient.clientClass, + mockClient.clientFactory, pollingIntervalMillis, streamConnTimeoutMillis, streamFlagConnTimeoutMillis, diff --git a/packages/node/test/local/util/mockStreamEventSource.ts b/packages/node/test/local/util/mockStreamEventSource.ts index 8b3f38f..4f06778 100644 --- a/packages/node/test/local/util/mockStreamEventSource.ts +++ b/packages/node/test/local/util/mockStreamEventSource.ts @@ -7,7 +7,7 @@ import { StreamMessageEvent, StreamErrorEvent, StreamEvent, - StreamEventSourceClass, + StreamEventSourceFactory, } from '@amplitude/experiment-core'; export interface MockStreamEventSourceClient extends StreamEventSource { @@ -20,12 +20,12 @@ export interface MockStreamEventSourceClient extends StreamEventSource { export function getNewClient(): { client: MockStreamEventSourceClient | undefined; numCreated: number; - clientClass: StreamEventSourceClass; + clientFactory: StreamEventSourceFactory; } { const clientObj = { client: undefined, numCreated: 0, - clientClass: undefined, + clientFactory: undefined, }; class AClientClass implements MockStreamEventSourceClient { static readonly CLOSED: number = 0; @@ -88,6 +88,7 @@ export function getNewClient(): { await this.onerror(evt); } } - clientObj.clientClass = AClientClass; + clientObj.clientFactory = (url: string, params: Record) => + new AClientClass(url, params); return clientObj; } diff --git a/yarn.lock b/yarn.lock index 3040410..a5cf615 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1933,11 +1933,6 @@ dependencies: "@babel/types" "^7.3.0" -"@types/eventsource@^1.1.15": - version "1.1.15" - resolved "https://registry.yarnpkg.com/@types/eventsource/-/eventsource-1.1.15.tgz#949383d3482e20557cbecbf3b038368d94b6be27" - integrity sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA== - "@types/graceful-fs@^4.1.2": version "4.1.6" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" @@ -1997,11 +1992,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== -"@types/node@18.8.0": - version "18.8.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.0.tgz#b8ee8d83a99470c0661bd899417fcd77060682fe" - integrity sha512-u+h43R6U8xXDt2vzUaVP3VwjjLyOJk6uEciZS8OSyziUQGOwmk+l+4drxcsDboHXwyTaqS1INebghmWMRxq3LA== - "@types/node@^14.11.8": version "14.18.42" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.42.tgz#fa39b2dc8e0eba61bdf51c66502f84e23b66e114" From f809f3bb61d4317ac3ebf792a8d2583375d8d2d9 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 29 Feb 2024 11:08:50 -0800 Subject: [PATCH 09/14] moved stream api here --- packages/node/src/local/client.ts | 2 +- packages/node/src/local/stream-flag-api.ts | 261 ++++++++++++++++++ packages/node/src/local/streamer.ts | 7 +- packages/node/src/transport/stream.ts | 233 ++++++++++++++++ .../test/local/util/mockStreamEventSource.ts | 5 +- 5 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 packages/node/src/local/stream-flag-api.ts create mode 100644 packages/node/src/transport/stream.ts diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index ab7b50b..ebe7e88 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -2,7 +2,6 @@ import * as amplitude from '@amplitude/analytics-node'; import { EvaluationEngine, EvaluationFlag, - StreamEventSourceFactory, topologicalSort, } from '@amplitude/experiment-core'; import EventSource from 'eventsource'; @@ -11,6 +10,7 @@ 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 { AssignmentConfig, AssignmentConfigDefaults, diff --git a/packages/node/src/local/stream-flag-api.ts b/packages/node/src/local/stream-flag-api.ts new file mode 100644 index 0000000..cb261b8 --- /dev/null +++ b/packages/node/src/local/stream-flag-api.ts @@ -0,0 +1,261 @@ +import { EvaluationFlag } from '@amplitude/experiment-core'; + +import { + StreamErrorEvent, + DEFAULT_STREAM_ERR_EVENTS, + StreamEventSourceFactory, + SdkStream, + StreamOnErrorCallback, + StreamOptions, +} from '../transport/stream'; + +const DEFAULT_INITIAL_CONN_TIMEOUT = 1000; +const DEFAULT_TRY_ATTEMPTS = 2; +const DEFAULT_TRY_WAIT_TIMEOUT = 1000; + +export type StreamFlagOptions = StreamOptions; + +export type StreamFlagOnUpdateCallback = ( + flags: Record, +) => unknown; +export type StreamFlagOnErrorCallback = StreamOnErrorCallback; + +export interface StreamFlagApi { + /** + * To connect to the stream flag endpoint. + * It will connect the stream and makes sure the initial flag configs are received and valid. + * The initial flag configs are delivered through onUpdate. + * It attempts to retry up to the attempts specified. + * If fatal error happens during connect() call, error will be thrown instead of delivered through onError. + * @param options Options for connection. + */ + connect(options?: StreamOptions): Promise; + /** + * To close the stream. + * If application don't call this, the application may not exit as there are underlaying timers. + */ + close(): void; + /** + * Check if the stream is closed and no retry action is happening. + */ + isClosed: boolean; + /** + * Callback for receiving flag configs updates. + * Can set this value directly multiple times and effect immediately. + */ + onUpdate?: StreamFlagOnUpdateCallback; + /** + * Callback for receiving fatal errors. + * Fatal errors are defined as server returning 501 or retry has reached max attempts. + * This callback will not be called when error happens during connect() call. The error will be throwed in connect() instead. + * Can set this value directly multiple times and effect immediately. + */ + onError?: StreamFlagOnErrorCallback; +} + +/** + * This class receives flag config updates from server. + * It also handles errors, retries, flag parsing, and initial flags on connection, in addition to SdkStreamApi. + */ +export class SdkStreamFlagApi implements StreamFlagApi { + // Underlaying SSE api. + private api: SdkStream; + // 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 onUpdate?: StreamFlagOnUpdateCallback; + // Callback for notifying user of fatal errors. Can be set or changed multiple times and effect immediately. + public onError?: StreamFlagOnErrorCallback; + + // Options for streaming. + private options?: StreamFlagOptions; + // Timeout for a single try of connection. Includes streamConnTimeoutMillis and time for receiving initial flag configs. + private streamFlagConnTimeoutMillis: number; + // Number of attempts for trying connection. + private streamFlagTryAttempts: number; + // The delay between attempts. + private streamFlagTryDelayMillis: number; + + constructor( + deploymentKey: string, + serverUrl: string, + eventSourceFactory: StreamEventSourceFactory, + streamConnTimeoutMillis?: number, + streamFlagConnTimeoutMillis: number = DEFAULT_INITIAL_CONN_TIMEOUT, + streamFlagTryAttempts: number = DEFAULT_TRY_ATTEMPTS, + streamFlagTryDelayMillis: number = DEFAULT_TRY_WAIT_TIMEOUT, + ) { + this.api = new SdkStream( + deploymentKey, + serverUrl + '/sdk/stream/v1/flags', + eventSourceFactory, + streamConnTimeoutMillis, + ); + this.streamFlagConnTimeoutMillis = Math.max(0, streamFlagConnTimeoutMillis); + this.streamFlagTryAttempts = Math.max(1, streamFlagTryAttempts); + this.streamFlagTryDelayMillis = Math.max(0, streamFlagTryDelayMillis); + } + + // A try: + // Try connect and receive at least one single flag update. + private connectTry(options?: StreamFlagOptions) { + // Timeout for initial connection. Makes sure the connection do not exceed a certain interval. + let timeout: NodeJS.Timeout | undefined = undefined; + return new Promise((resolve, reject) => { + // On connection and receiving first update, success, set future flag update callback and error handling retries. + const dealWithFlagUpdateInOneTry = async (data: string) => { + if (timeout) { + clearTimeout(timeout); + } + try { + // Make sure valid flag configs. + SdkStreamFlagApi.parseFlagConfigs(data); + } catch (e) { + return reject(DEFAULT_STREAM_ERR_EVENTS.DATA_UNPARSABLE); + } + // Update the callbacks. + this.api.onUpdate = (data: string) => this.handleNewMsg(data); + 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(); + }; + this.api.onUpdate = dealWithFlagUpdateInOneTry; + + // If it fails to connect, fails try. + // If it disconnects before flag update, fails try. + const dealWithErrorInOneTry = async (err: StreamErrorEvent) => { + if (timeout) { + clearTimeout(timeout); + } + reject(err); // Reject promise which will either retry or fatal err. + }; + this.api.onError = dealWithErrorInOneTry; + + // Try connect. + this.api.connect(options); + + // If it fails to return flag update within limit time, fails try. + timeout = setTimeout(() => { + dealWithErrorInOneTry(DEFAULT_STREAM_ERR_EVENTS.TIMEOUT); + }, this.streamFlagConnTimeoutMillis); + }); + } + + // Do try up to 2 times. If any of error is fatal, stop any further tries. + // If trials times reached, fatal error. + public async connect(options?: StreamFlagOptions) { + // Makes sure there is no other connect running. + if (!this.isClosedAndNotTrying) { + return; + } + this.isClosedAndNotTrying = false; + + this.options = options; // Save options for retries in case of errors. + const attempts = this.streamFlagTryAttempts; + const delay = this.streamFlagTryDelayMillis; + for (let i = 0; i < attempts; i++) { + try { + // Try. + return await this.connectTry(options); + } catch (e) { + if (this.isClosedAndNotTrying) { + // There's a call to close while waiting for connection. + return; + } + + // connectTry() does not call close or closeForRetry on error. + const err = e as StreamErrorEvent; + if (this.isFatal(err) || i == attempts - 1) { + // We want to throw exception instead of call onError callback. + this.close(); + throw err; + } + + // Retry. + this.closeForRetry(); + await new Promise((resolve) => setTimeout(resolve, delay)); + + if (this.isClosedAndNotTrying) { + // There's a call to close while waiting for retry. + return; + } + } + } + } + + // Close stream. + public close() { + this.closeForRetry(); + this.isClosedAndNotTrying = true; + } + + // Close stream, but we know there will be another try happening very soon. + public closeForRetry() { + this.api.close(); + } + + get isClosed() { + return this.isClosedAndNotTrying; + } + + // Fatal error if 501 Unimplemented. + private isFatal(err: StreamErrorEvent) { + return err && err?.status == 501; + } + + // If error during normal operation, retry init connection up to 2 times. + private async errorAndRetry(err: StreamErrorEvent) { + if (this.isFatal(err)) { + this.close(); + await this.fatalErr(err); + } else { + this.close(); // Not closeForRetry(), connect checks for isClosedAndNotTrying. + this.connect(this.options).catch((err) => { + this.fatalErr(err); + }); + } + } + + // No more retry, 501 unimplemented. Need fallback. + private async fatalErr(err: StreamErrorEvent) { + if (this.onError) { + try { + await this.onError(err); + // eslint-disable-next-line no-empty + } catch {} // Don't care about application errors after handoff. + } + } + + // Handles new messages, parse them, and handoff to application. Retries if have parsing error. + private async handleNewMsg(data: string) { + let flagConfigs; + try { + flagConfigs = SdkStreamFlagApi.parseFlagConfigs(data); + } catch (e) { + this.errorAndRetry(DEFAULT_STREAM_ERR_EVENTS.DATA_UNPARSABLE); + return; + } + // Put update outside try catch. onUpdate error doesn't mean stream error. + if (this.onUpdate) { + try { + await this.onUpdate(flagConfigs); + // eslint-disable-next-line no-empty + } catch {} // Don't care about application errors after handoff. + } + } + + // Parse message. Throws if unparsable. + private static parseFlagConfigs(data: string) { + const flagsArray: EvaluationFlag[] = JSON.parse(data) as EvaluationFlag[]; + return flagsArray.reduce( + (map: Record, flag: EvaluationFlag) => { + map[flag.key] = flag; + return map; + }, + {}, + ); + } +} diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 2146f29..4218cb7 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -1,10 +1,8 @@ +import { version as PACKAGE_VERSION } from '../../gen/version'; import { - SdkStreamFlagApi, StreamErrorEvent, StreamEventSourceFactory, -} from '@amplitude/experiment-core'; - -import { version as PACKAGE_VERSION } from '../../gen/version'; +} from '../transport/stream'; import { LocalEvaluationDefaults } from '../types/config'; import { FlagConfigCache } from '../types/flag'; import { ConsoleLogger } from '../util/logger'; @@ -12,6 +10,7 @@ import { Logger } from '../util/logger'; import { FlagConfigFetcher } from './fetcher'; import { FlagConfigPoller } from './poller'; +import { SdkStreamFlagApi } from './stream-flag-api'; export class FlagConfigStreamer { private readonly logger: Logger; diff --git a/packages/node/src/transport/stream.ts b/packages/node/src/transport/stream.ts new file mode 100644 index 0000000..dde0394 --- /dev/null +++ b/packages/node/src/transport/stream.ts @@ -0,0 +1,233 @@ +export type StreamErrorEvent = { message?: string; status?: number }; +export type StreamOpenEvent = { type?: string }; +export type StreamMessageEvent = { data?: string }; +export type StreamEvent = + | StreamErrorEvent + | StreamOpenEvent + | StreamMessageEvent; + +/** + * The EventSource client interface. + * https://html.spec.whatwg.org/multipage/server-sent-events.html#eventsource + */ +export interface StreamEventSource { + // These are static members usable without new. + readonly CLOSED: number; + readonly CONNECTING: number; + readonly OPEN: number; + + readonly url: string; + readonly readyState: number; + readonly withCredentials: boolean; + onopen: (evt: StreamOpenEvent) => unknown; + onmessage: (evt: StreamMessageEvent) => unknown; + onerror: (evt: StreamErrorEvent) => unknown; + // The following are in specs but not currently used. Uncomment as we start to use them. + // addEventListener(type: string, listener: (evt: StreamEvent) => void): void; + // dispatchEvent(evt: Event): boolean; + // removeEventListener(type: string, listener: (evt: StreamEvent) => void): void; + close(): void; +} + +// A factory type for StreamEventSource. +export type StreamEventSourceFactory = ( + url: string, + params: unknown, +) => StreamEventSource; + +/** + * Default error events. + */ +export const DEFAULT_STREAM_ERR_EVENTS = { + TIMEOUT: { message: 'timeout' } as StreamErrorEvent, + DATA_UNPARSABLE: { + message: 'stream data parse error', + } as StreamErrorEvent, + KEEP_ALIVE_FAILURE: { + message: 'keep alive fail', + } as StreamErrorEvent, +}; + +export type StreamOptions = { + libraryName: string; + libraryVersion: string; +}; +export type StreamOnUpdateCallback = (data: string) => unknown; +export type StreamOnErrorCallback = (err: StreamErrorEvent) => unknown; + +export interface Stream { + /** + * Initiate a connection. If an existing connection exists, it does nothing. + */ + connect(options?: StreamOptions): void; + /** + * Close a connection. If there is no existing connection, it does nothing. + */ + close(): void; + /** + * Any message will be sent to this callback. + */ + onUpdate?: StreamOnUpdateCallback; + /** + * Any error, including connection errors, will be sent to this callback. + */ + onError?: StreamOnErrorCallback; +} + +const DEFAULT_INITIAL_CONN_TIMEOUT = 1000; // Initial connection timeout. +const MAX_CONN_MS_MIN = 12 * 60 * 1000; // Min of max connection timeout and wants to automatically disconnect and reconnects. +const MAX_CONN_MS_MAX = 18 * 60 * 1000; // Max of the above timeout. +const KEEP_ALIVE_INTERVAL = (15 + 2) * 1000; // 15 seconds plus 2 seconds grace period. // 0 or neg value disables keep alive. +const KEEP_ALIVE_DATA = ' '; + +/** + * This class handles connecting to an server-side event source. + * It handles keep alives from server, automatically disconnect and reconnect after a set random interval. + * It will propagate any error to onError. It will not handle any error or retries. + * (automatic disconnect does not count as error, but if any reconnect errors will propagate). + */ +export class SdkStream implements Stream { + private eventSource: StreamEventSource | undefined; + private reconnectionTimeout?: NodeJS.Timeout; + private initConnTimeout?: NodeJS.Timeout; + private keepAliveTimeout?: NodeJS.Timeout; + + public onUpdate?: StreamOnUpdateCallback; + public onError?: StreamOnErrorCallback; + + private readonly deploymentKey: string; + private readonly serverUrl: string; + private readonly eventSourceFactory: StreamEventSourceFactory; + private streamConnTimeoutMillis: number; // Timeout for connecting the stream. Aka, http connection timeout. + + constructor( + deploymentKey: string, + serverUrl: string, + eventSourceFactory: StreamEventSourceFactory, + streamConnTimeoutMillis: number = DEFAULT_INITIAL_CONN_TIMEOUT, // Timeout for connecting the stream. Aka, http connection timeout. + ) { + this.deploymentKey = deploymentKey; + this.serverUrl = serverUrl; + this.eventSourceFactory = eventSourceFactory; + this.streamConnTimeoutMillis = streamConnTimeoutMillis; + } + + public async connect(options?: StreamOptions) { + if (this.eventSource) { + return; + } + + const headers: Record = { + Authorization: `Api-Key ${this.deploymentKey}`, + }; + if (options?.libraryName && options?.libraryVersion) { + headers[ + 'X-Amp-Exp-Library' + ] = `${options.libraryName}/${options.libraryVersion}`; + } + + // Create connection. It starts connection on new. + const es = this.eventSourceFactory(this.serverUrl, { + headers: headers, + }); + this.eventSource = es; // Set eventSource here so more connect should only creates one eventSource. + + // Handlers. + es.onerror = (err) => err && this.error(err); + es.onmessage = (evt) => this.handleNewMsg(evt); + es.onopen = (evt) => { + if (!evt || !evt?.type || evt.type != 'open') { + return; + } + + // Reconnect connections after certain amount of time. + const randomReconnectionTimeout = Math.floor( + Math.random() * (MAX_CONN_MS_MAX - MAX_CONN_MS_MIN) + MAX_CONN_MS_MIN, + ); + if (this.reconnectionTimeout) { + clearTimeout(this.reconnectionTimeout); + } + this.reconnectionTimeout = setTimeout(async () => { + if (es.readyState == es.OPEN) { + // The es is being checked, not this.eventSource. So it won't affect new connections. + this.close(); + this.connect(); + } + }, randomReconnectionTimeout); + + // Set keep alive checks. + this.setKeepAliveExpiry(); + }; + + // Timeout initial connection, ensures promise returns. + // Force close after timeout only if stream is still connecting. + // Error state should already handled by error handler. + if (this.initConnTimeout) { + clearTimeout(this.initConnTimeout); + } + this.initConnTimeout = setTimeout(() => { + es.readyState == es.CONNECTING && // The es is being checked, not this.eventSource. So it won't affect new connections. + this.error(DEFAULT_STREAM_ERR_EVENTS.TIMEOUT); + }, this.streamConnTimeoutMillis); + } + + public close() { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = undefined; + } + + // Clear timers so program won't hang if want to terminate early. + if (this.initConnTimeout) { + clearTimeout(this.initConnTimeout); + this.initConnTimeout = undefined; + } + if (this.reconnectionTimeout) { + clearTimeout(this.reconnectionTimeout); + this.reconnectionTimeout = undefined; + } + if (this.keepAliveTimeout) { + clearTimeout(this.keepAliveTimeout); + this.keepAliveTimeout = undefined; + } + } + + private async error(err: StreamErrorEvent) { + this.close(); + if (this.onError) { + try { + await this.onError(err); + // eslint-disable-next-line no-empty + } catch {} // Don't care about errors after handoff. + } + } + + private async handleNewMsg(response: StreamMessageEvent) { + if (!response.data) { + return; + } + this.setKeepAliveExpiry(); // Reset keep alive as there is data. + if (response.data == KEEP_ALIVE_DATA) { + // Data solely for keep alive. Don't pass on to client. + return; + } + if (this.onUpdate) { + try { + await this.onUpdate(response.data); + // eslint-disable-next-line no-empty + } catch {} // Don't care about errors after handoff. + } + } + + private setKeepAliveExpiry() { + if (this.keepAliveTimeout) { + clearTimeout(this.keepAliveTimeout); + this.keepAliveTimeout = undefined; + } + if (this.eventSource) { + this.keepAliveTimeout = setTimeout(() => { + this.error(DEFAULT_STREAM_ERR_EVENTS.KEEP_ALIVE_FAILURE); + }, KEEP_ALIVE_INTERVAL); + } + } +} diff --git a/packages/node/test/local/util/mockStreamEventSource.ts b/packages/node/test/local/util/mockStreamEventSource.ts index 4f06778..63f99e6 100644 --- a/packages/node/test/local/util/mockStreamEventSource.ts +++ b/packages/node/test/local/util/mockStreamEventSource.ts @@ -1,14 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-empty-function */ + import { StreamEventSource, StreamOpenEvent, StreamMessageEvent, StreamErrorEvent, - StreamEvent, StreamEventSourceFactory, -} from '@amplitude/experiment-core'; + StreamEvent, +} from 'src/transport/stream'; export interface MockStreamEventSourceClient extends StreamEventSource { // Methods for test. From ad402c937303fff4cb61bbc86cb05a5a386e932d Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 29 Feb 2024 14:43:49 -0800 Subject: [PATCH 10/14] removed accidental addition of start param --- packages/node/src/local/client.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index ebe7e88..2609334 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -178,10 +178,8 @@ export class LocalEvaluationClient { * * Calling this function while the poller is already running does nothing. */ - public async start( - cb: (cache: FlagConfigCache) => Promise, - ): Promise { - return await this.updater.start(cb); + public async start(): Promise { + return await this.updater.start(); } /** From b7565ac105ffd1486c7905ee53f75d67702bedec Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 4 Mar 2024 14:01:17 -0800 Subject: [PATCH 11/14] Rename configs --- packages/node/src/local/client.ts | 4 ++-- packages/node/src/local/streamer.ts | 8 ++++---- packages/node/src/types/config.ts | 13 +++++++------ .../node/test/local/flagConfigStreamer.test.ts | 14 +++++++------- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 2609334..0c882d5 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -73,7 +73,7 @@ export class LocalEvaluationClient { this.config.bootstrap, ); this.logger = new ConsoleLogger(this.config.debug); - this.updater = this.config.getFlagConfigUpdateWithStream + this.updater = this.config.streamUpdates ? new FlagConfigStreamer( apiKey, fetcher, @@ -84,7 +84,7 @@ export class LocalEvaluationClient { this.config.streamFlagConnTimeoutMillis, this.config.streamFlagTryAttempts, this.config.streamFlagTryDelayMillis, - this.config.retryStreamFlagDelayMillis, + this.config.streamFlagRetryDelayMillis, this.config.streamServerUrl, this.config.debug, ) diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 4218cb7..5853759 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -17,7 +17,7 @@ export class FlagConfigStreamer { private readonly poller: FlagConfigPoller; private readonly stream: SdkStreamFlagApi; - private readonly retryStreamFlagDelayMillis: number; + private readonly streamFlagRetryDelayMillis: number; private streamRetryInterval?: NodeJS.Timeout; @@ -33,7 +33,7 @@ export class FlagConfigStreamer { streamFlagConnTimeoutMillis = LocalEvaluationDefaults.streamFlagConnTimeoutMillis, streamFlagTryAttempts = LocalEvaluationDefaults.streamFlagTryAttempts, streamFlagTryDelayMillis = LocalEvaluationDefaults.streamFlagTryDelayMillis, - retryStreamFlagDelayMillis = LocalEvaluationDefaults.retryStreamFlagDelayMillis, + streamFlagRetryDelayMillis = LocalEvaluationDefaults.streamFlagRetryDelayMillis, serverUrl: string = LocalEvaluationDefaults.serverUrl, debug = false, ) { @@ -55,7 +55,7 @@ export class FlagConfigStreamer { streamFlagTryAttempts, streamFlagTryDelayMillis, ); - this.retryStreamFlagDelayMillis = retryStreamFlagDelayMillis; + this.streamFlagRetryDelayMillis = streamFlagRetryDelayMillis; } /** @@ -159,7 +159,7 @@ export class FlagConfigStreamer { }) // eslint-disable-next-line @typescript-eslint/no-empty-function .catch(() => {}); - }, this.retryStreamFlagDelayMillis); + }, this.streamFlagRetryDelayMillis); } // Clear retry interval. diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index 7581041..9fb620c 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -151,10 +151,11 @@ export type LocalEvaluationConfig = { /** * To use streaming API or polling. With streaming, flag config updates are - * received immediately, no polling is necessary. If stream fails, it will - * fallback to polling automatically. + * received immediately, no polling is necessary. If stream fails for any + * reason, it will fallback to polling automatically. + * Default will be false, using poller. */ - getFlagConfigUpdateWithStream?: boolean; + streamUpdates?: boolean; /** * The stream server endpoint from which to stream data. @@ -185,7 +186,7 @@ export type LocalEvaluationConfig = { /** * To use with streaming. The delay to retry streaming after stream fatal error and fallbacked to poller. */ - retryStreamFlagDelayMillis?: number; + streamFlagRetryDelayMillis?: number; }; export type AssignmentConfig = { @@ -219,13 +220,13 @@ export const LocalEvaluationDefaults: LocalEvaluationConfig = { bootstrap: {}, flagConfigPollingIntervalMillis: 30000, httpAgent: null, - getFlagConfigUpdateWithStream: false, + streamUpdates: false, streamServerUrl: 'https://stream.lab.amplitude.com', streamConnTimeoutMillis: 1000, streamFlagConnTimeoutMillis: 1000, streamFlagTryAttempts: 2, streamFlagTryDelayMillis: 1000, - retryStreamFlagDelayMillis: 15000, + streamFlagRetryDelayMillis: 15000, }; export const AssignmentConfigDefaults: Omit = { diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 941d35b..846d3d8 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -13,7 +13,7 @@ const getTestObjs = ({ streamFlagConnTimeoutMillis = 1000, streamFlagTryAttempts = 2, streamFlagTryDelayMillis = 1000, - retryStreamFlagDelayMillis = 15000, + streamFlagRetryDelayMillis = 15000, apiKey = 'client-xxxx', serverUrl = 'http://localhostxxxx:00000000', debug = false, @@ -46,7 +46,7 @@ const getTestObjs = ({ streamFlagConnTimeoutMillis, streamFlagTryAttempts, streamFlagTryDelayMillis, - retryStreamFlagDelayMillis, + streamFlagRetryDelayMillis, serverUrl, debug, ); @@ -363,10 +363,10 @@ test('FlagConfigUpdater.connect, start fail, retry and immediately stop, no poll test('FlagConfigUpdater.connect, test error after connection, poller starts, stream retry success, poller stops', async () => { jest.setTimeout(25000); - const retryStreamFlagDelayMillis = 15000; + const streamFlagRetryDelayMillis = 15000; const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 200, - retryStreamFlagDelayMillis, + streamFlagRetryDelayMillis, }); try { // Test error after normal close. @@ -389,7 +389,7 @@ test('FlagConfigUpdater.connect, test error after connection, poller starts, str n = mockClient.numCreated; assert(n == 3); // Check retry. - await new Promise((r) => setTimeout(r, retryStreamFlagDelayMillis)); // Wait for 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; @@ -552,7 +552,7 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream su jest.setTimeout(10000); const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 200, - retryStreamFlagDelayMillis: 2000, + streamFlagRetryDelayMillis: 2000, }); try { updater.start(); @@ -593,7 +593,7 @@ test('FlagConfigUpdater.connect, start fail, fallback to poller, retry stream fa jest.setTimeout(10000); const { fetchObj, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 200, - retryStreamFlagDelayMillis: 2000, + streamFlagRetryDelayMillis: 2000, }); try { updater.start(); From bdef24bcc91d1c0d827555e472014a49b3aed485 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 7 Mar 2024 02:50:28 -0800 Subject: [PATCH 12/14] fixed implements, added jitter for retry --- packages/node/src/local/client.ts | 3 ++- packages/node/src/local/streamer.ts | 3 ++- packages/node/src/types/config.ts | 24 +++++++++++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index 0c882d5..c9cbbb5 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -84,7 +84,8 @@ export class LocalEvaluationClient { this.config.streamFlagConnTimeoutMillis, this.config.streamFlagTryAttempts, this.config.streamFlagTryDelayMillis, - this.config.streamFlagRetryDelayMillis, + this.config.streamFlagRetryDelayMillis + + Math.floor(Math.random() * this.config.streamFlagRetryJitterMillis), this.config.streamServerUrl, this.config.debug, ) diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 5853759..4e16b50 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -11,8 +11,9 @@ import { Logger } from '../util/logger'; import { FlagConfigFetcher } from './fetcher'; import { FlagConfigPoller } from './poller'; import { SdkStreamFlagApi } from './stream-flag-api'; +import { FlagConfigUpdater } from './updater'; -export class FlagConfigStreamer { +export class FlagConfigStreamer implements FlagConfigUpdater { private readonly logger: Logger; private readonly poller: FlagConfigPoller; diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index 9fb620c..9ff6ea7 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -163,18 +163,22 @@ export type LocalEvaluationConfig = { streamServerUrl?: string; /** - * To use with streaming. The timeout for connecting an server-side event stream. Aka, the timeout for http connection. + * To use with streaming. The timeout for connecting an server-side event + * stream. Aka, the timeout for http connection. */ streamConnTimeoutMillis?: number; /** - * To use with streaming. The timeout for a single attempt of establishing a valid stream of flag configs. - * This includes streamConnTimeoutMillis and time for receiving initial flag configs. + * To use with streaming. The timeout for a single attempt of establishing + * a valid stream of flag configs. + * This includes streamConnTimeoutMillis and time for receiving initial + * flag configs. */ streamFlagConnTimeoutMillis?: number; /** - * To use with streaming. The number attempts to connect before declaring streaming fatal error. + * To use with streaming. The number attempts to connect before declaring + * streaming fatal error. */ streamFlagTryAttempts?: number; @@ -184,9 +188,18 @@ export type LocalEvaluationConfig = { streamFlagTryDelayMillis?: number; /** - * To use with streaming. The delay to retry streaming after stream fatal error and fallbacked to poller. + * To use with streaming. The base delay to retry streaming after stream + * fatal error and fallbacked to poller. */ streamFlagRetryDelayMillis?: number; + + /** + * To use with streaming. The jitter to add to the delay for retry streaming + * after stream fatal error and fallbacked to poller. + * A random number between 0 and streamFlagRetryJitterMillis will be added to + * streamFlagRetryDelayMillis as the delay. + */ + streamFlagRetryJitterMillis?: number; }; export type AssignmentConfig = { @@ -227,6 +240,7 @@ export const LocalEvaluationDefaults: LocalEvaluationConfig = { streamFlagTryAttempts: 2, streamFlagTryDelayMillis: 1000, streamFlagRetryDelayMillis: 15000, + streamFlagRetryJitterMillis: 2000, }; export const AssignmentConfigDefaults: Omit = { From 332a8df836607c33b75a4d4fb86dcc4aa54cc6f7 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 14 Mar 2024 15:18:03 -0700 Subject: [PATCH 13/14] removed extraneous configs --- packages/node/src/local/client.ts | 14 ++++--- packages/node/src/local/stream-flag-api.ts | 8 ++-- packages/node/src/local/streamer.ts | 9 ++--- packages/node/src/types/config.ts | 40 +------------------ .../test/local/flagConfigStreamer.test.ts | 2 - 5 files changed, 19 insertions(+), 54 deletions(-) diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index c9cbbb5..26670e8 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -35,6 +35,11 @@ import { FlagConfigPoller } from './poller'; import { FlagConfigStreamer } from './streamer'; import { FlagConfigUpdater } from './updater'; +const STREAM_RETRY_DELAY_MILLIS = 15000; // The base delay to retry stream after fallback to poller. +const STREAM_RETRY_JITTER_MAX_MILLIS = 2000; // The jitter to add to delay after fallbacked to poller. +const STREAM_ATTEMPTS = 1; // Number of attempts before fallback to poller. +const STREAM_TRY_DELAY_MILLIS = 1000; // The delay between attempts. + /** * Experiment client for evaluating variants for a user locally. * @category Core Usage @@ -80,12 +85,11 @@ export class LocalEvaluationClient { this.cache, streamEventSourceFactory, this.config.flagConfigPollingIntervalMillis, - this.config.streamConnTimeoutMillis, this.config.streamFlagConnTimeoutMillis, - this.config.streamFlagTryAttempts, - this.config.streamFlagTryDelayMillis, - this.config.streamFlagRetryDelayMillis + - Math.floor(Math.random() * this.config.streamFlagRetryJitterMillis), + STREAM_ATTEMPTS, + STREAM_TRY_DELAY_MILLIS, + STREAM_RETRY_DELAY_MILLIS + + Math.floor(Math.random() * STREAM_RETRY_JITTER_MAX_MILLIS), this.config.streamServerUrl, this.config.debug, ) diff --git a/packages/node/src/local/stream-flag-api.ts b/packages/node/src/local/stream-flag-api.ts index cb261b8..d28add3 100644 --- a/packages/node/src/local/stream-flag-api.ts +++ b/packages/node/src/local/stream-flag-api.ts @@ -146,7 +146,7 @@ export class SdkStreamFlagApi implements StreamFlagApi { // Do try up to 2 times. If any of error is fatal, stop any further tries. // If trials times reached, fatal error. - public async connect(options?: StreamFlagOptions) { + public async connect(options?: StreamFlagOptions): Promise { // Makes sure there is no other connect running. if (!this.isClosedAndNotTrying) { return; @@ -187,17 +187,17 @@ export class SdkStreamFlagApi implements StreamFlagApi { } // Close stream. - public close() { + public close(): void { this.closeForRetry(); this.isClosedAndNotTrying = true; } // Close stream, but we know there will be another try happening very soon. - public closeForRetry() { + private closeForRetry(): void { this.api.close(); } - get isClosed() { + get isClosed(): boolean { return this.isClosedAndNotTrying; } diff --git a/packages/node/src/local/streamer.ts b/packages/node/src/local/streamer.ts index 4e16b50..8c73a90 100644 --- a/packages/node/src/local/streamer.ts +++ b/packages/node/src/local/streamer.ts @@ -30,11 +30,10 @@ export class FlagConfigStreamer implements FlagConfigUpdater { cache: FlagConfigCache, streamEventSourceFactory: StreamEventSourceFactory, pollingIntervalMillis = LocalEvaluationDefaults.flagConfigPollingIntervalMillis, - streamConnTimeoutMillis = LocalEvaluationDefaults.streamConnTimeoutMillis, streamFlagConnTimeoutMillis = LocalEvaluationDefaults.streamFlagConnTimeoutMillis, - streamFlagTryAttempts = LocalEvaluationDefaults.streamFlagTryAttempts, - streamFlagTryDelayMillis = LocalEvaluationDefaults.streamFlagTryDelayMillis, - streamFlagRetryDelayMillis = LocalEvaluationDefaults.streamFlagRetryDelayMillis, + streamFlagTryAttempts: number, + streamFlagTryDelayMillis: number, + streamFlagRetryDelayMillis: number, serverUrl: string = LocalEvaluationDefaults.serverUrl, debug = false, ) { @@ -51,7 +50,7 @@ export class FlagConfigStreamer implements FlagConfigUpdater { apiKey, serverUrl, streamEventSourceFactory, - streamConnTimeoutMillis, + streamFlagConnTimeoutMillis, streamFlagConnTimeoutMillis, streamFlagTryAttempts, streamFlagTryDelayMillis, diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index 9ff6ea7..2204fd4 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -162,44 +162,13 @@ export type LocalEvaluationConfig = { */ streamServerUrl?: string; - /** - * To use with streaming. The timeout for connecting an server-side event - * stream. Aka, the timeout for http connection. - */ - streamConnTimeoutMillis?: number; - /** * To use with streaming. The timeout for a single attempt of establishing * a valid stream of flag configs. - * This includes streamConnTimeoutMillis and time for receiving initial + * The time starts at making request and ends when received the initial * flag configs. */ streamFlagConnTimeoutMillis?: number; - - /** - * To use with streaming. The number attempts to connect before declaring - * streaming fatal error. - */ - streamFlagTryAttempts?: number; - - /** - * To use with streaming. The delay between attempts to connect. - */ - streamFlagTryDelayMillis?: number; - - /** - * To use with streaming. The base delay to retry streaming after stream - * fatal error and fallbacked to poller. - */ - streamFlagRetryDelayMillis?: number; - - /** - * To use with streaming. The jitter to add to the delay for retry streaming - * after stream fatal error and fallbacked to poller. - * A random number between 0 and streamFlagRetryJitterMillis will be added to - * streamFlagRetryDelayMillis as the delay. - */ - streamFlagRetryJitterMillis?: number; }; export type AssignmentConfig = { @@ -235,12 +204,7 @@ export const LocalEvaluationDefaults: LocalEvaluationConfig = { httpAgent: null, streamUpdates: false, streamServerUrl: 'https://stream.lab.amplitude.com', - streamConnTimeoutMillis: 1000, - streamFlagConnTimeoutMillis: 1000, - streamFlagTryAttempts: 2, - streamFlagTryDelayMillis: 1000, - streamFlagRetryDelayMillis: 15000, - streamFlagRetryJitterMillis: 2000, + streamFlagConnTimeoutMillis: 1500, }; export const AssignmentConfigDefaults: Omit = { diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index 846d3d8..b6d160a 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -9,7 +9,6 @@ import { getNewClient } from './util/mockStreamEventSource'; const getTestObjs = ({ pollingIntervalMillis = 1000, - streamConnTimeoutMillis = 1000, streamFlagConnTimeoutMillis = 1000, streamFlagTryAttempts = 2, streamFlagTryDelayMillis = 1000, @@ -42,7 +41,6 @@ const getTestObjs = ({ cache, mockClient.clientFactory, pollingIntervalMillis, - streamConnTimeoutMillis, streamFlagConnTimeoutMillis, streamFlagTryAttempts, streamFlagTryDelayMillis, From 8f1d15c606db3afd1ee70d3aa7f89c8ddc0c9589 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 14 Mar 2024 16:30:39 -0700 Subject: [PATCH 14/14] add test --- .../test/local/flagConfigStreamer.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/node/test/local/flagConfigStreamer.test.ts b/packages/node/test/local/flagConfigStreamer.test.ts index b6d160a..26ed392 100644 --- a/packages/node/test/local/flagConfigStreamer.test.ts +++ b/packages/node/test/local/flagConfigStreamer.test.ts @@ -103,6 +103,29 @@ test('FlagConfigUpdater.connect, start success, gets initial flag configs, gets } }); +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); + } +}); + test('FlagConfigUpdater.connect, stream start fail, fallback to poller, poller updates flag configs correctly', async () => { const { fetchObj, fetcherReturnNext, cache, mockClient, updater } = getTestObjs({ pollingIntervalMillis: 100 });