From a8ceda2ad37ef06b1d2268f3f7db19b3d2fb922c Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 14 May 2025 14:09:39 -0500 Subject: [PATCH] chore: experimental FDv2 configuration hooked up --- .../DataSystem/CompositeDataSource.test.ts | 69 ++++- .../api/subsystem/DataSystem/DataSource.ts | 15 ++ .../src/datasource/CompositeDataSource.ts | 6 + .../__tests__/options/Configuration.test.ts | 68 ++++- .../shared/sdk-server/src/LDClientImpl.ts | 243 ++++++++++++++++-- .../src/api/options/LDDataSystemOptions.ts | 144 +++++++++++ .../sdk-server/src/api/options/LDOptions.ts | 50 +++- .../sdk-server/src/api/options/index.ts | 1 + .../LDTransactionalDataSourceUpdates.ts | 4 + .../subsystems/LDTransactionalFeatureStore.ts | 4 + .../createDiagnosticsInitConfig.ts | 97 +++++-- .../sdk-server/src/options/Configuration.ts | 164 +++++++++++- .../src/options/ValidatedOptions.ts | 2 + 13 files changed, 807 insertions(+), 60 deletions(-) create mode 100644 packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts diff --git a/packages/shared/common/__tests__/subsystem/DataSystem/CompositeDataSource.test.ts b/packages/shared/common/__tests__/subsystem/DataSystem/CompositeDataSource.test.ts index e7da96118f..87d2c77105 100644 --- a/packages/shared/common/__tests__/subsystem/DataSystem/CompositeDataSource.test.ts +++ b/packages/shared/common/__tests__/subsystem/DataSystem/CompositeDataSource.test.ts @@ -9,7 +9,7 @@ import { TransitionConditions, } from '../../../src/datasource/CompositeDataSource'; import { DataSourceErrorKind } from '../../../src/datasource/DataSourceErrorKinds'; -import { LDFlagDeliveryFallbackError } from '../../../src/datasource/errors'; +import { LDFlagDeliveryFallbackError, LDPollingError } from '../../../src/datasource/errors'; function makeDataSourceFactory(internal: DataSource): LDDataSourceFactory { return () => internal; @@ -107,6 +107,73 @@ it('handles initializer getting basis, switching to synchronizer', async () => { expect(statusCallback).toHaveBeenNthCalledWith(4, DataSourceState.Valid, undefined); }); +it('handles initializer getting error and switches to synchronizer 1', async () => { + const mockInitializer1: DataSource = { + start: jest + .fn() + .mockImplementation( + ( + _dataCallback: (basis: boolean, data: any) => void, + _statusCallback: (status: DataSourceState, err?: any) => void, + ) => { + _statusCallback(DataSourceState.Initializing); + _statusCallback( + DataSourceState.Closed, + new LDPollingError(DataSourceErrorKind.ErrorResponse, 'polling error'), + ); + }, + ), + stop: jest.fn(), + }; + + const mockSynchronizer1Data = { key: 'sync1' }; + const mockSynchronizer1 = { + start: jest + .fn() + .mockImplementation( + ( + _dataCallback: (basis: boolean, data: any) => void, + _statusCallback: (status: DataSourceState, err?: any) => void, + ) => { + _statusCallback(DataSourceState.Initializing); + _statusCallback(DataSourceState.Valid, null); // this should lead to recovery + _dataCallback(true, mockSynchronizer1Data); + }, + ), + stop: jest.fn(), + }; + + const underTest = new CompositeDataSource( + [makeDataSourceFactory(mockInitializer1)], + [makeDataSourceFactory(mockSynchronizer1)], + [], + undefined, + makeTestTransitionConditions(), + makeZeroBackoff(), + ); + + let callback; + const statusCallback = jest.fn(); + await new Promise((resolve) => { + callback = jest.fn((_: boolean, data: any) => { + if (data === mockSynchronizer1Data) { + resolve(); + } + }); + + underTest.start(callback, statusCallback); + }); + + expect(mockInitializer1.start).toHaveBeenCalledTimes(1); + expect(mockSynchronizer1.start).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenNthCalledWith(1, true, { key: 'sync1' }); + expect(statusCallback).toHaveBeenCalledTimes(3); + expect(statusCallback).toHaveBeenNthCalledWith(1, DataSourceState.Initializing, undefined); + expect(statusCallback).toHaveBeenNthCalledWith(2, DataSourceState.Interrupted, expect.anything()); // sync1 error + expect(statusCallback).toHaveBeenNthCalledWith(3, DataSourceState.Valid, undefined); // sync1 got data +}); + it('handles initializer getting basis, switches to synchronizer 1, falls back to synchronizer 2, recovers to synchronizer 1', async () => { const mockInitializer1: DataSource = { start: jest diff --git a/packages/shared/common/src/api/subsystem/DataSystem/DataSource.ts b/packages/shared/common/src/api/subsystem/DataSystem/DataSource.ts index 38eb3da1cf..0f4e9680d8 100644 --- a/packages/shared/common/src/api/subsystem/DataSystem/DataSource.ts +++ b/packages/shared/common/src/api/subsystem/DataSystem/DataSource.ts @@ -1,4 +1,9 @@ // TODO: refactor client-sdk to use this enum +/** + * @experimental + * This feature is not stable and not subject to any backwards compatibility guarantees or semantic + * versioning. It is not suitable for production usage. + */ export enum DataSourceState { // Positive confirmation of connection/data receipt Valid, @@ -10,6 +15,11 @@ export enum DataSourceState { Closed, } +/** + * @experimental + * This feature is not stable and not subject to any backwards compatibility guarantees or semantic + * versioning. It is not suitable for production usage. + */ export interface DataSource { /** * May be called any number of times, if already started, has no effect @@ -30,4 +40,9 @@ export interface DataSource { stop(): void; } +/** + * @experimental + * This feature is not stable and not subject to any backwards compatibility guarantees or semantic + * versioning. It is not suitable for production usage. + */ export type LDDataSourceFactory = () => DataSource; diff --git a/packages/shared/common/src/datasource/CompositeDataSource.ts b/packages/shared/common/src/datasource/CompositeDataSource.ts index 4c46e81e40..63aae519f6 100644 --- a/packages/shared/common/src/datasource/CompositeDataSource.ts +++ b/packages/shared/common/src/datasource/CompositeDataSource.ts @@ -288,6 +288,12 @@ export class CompositeDataSource implements DataSource { break; case 'fallback': default: + // if asked to fallback after using all init factories, switch to sync factories + if (this._initPhaseActive && this._initFactories.pos() >= this._initFactories.length()) { + this._initPhaseActive = false; + this._syncFactories.reset(); + } + if (this._initPhaseActive) { isPrimary = this._initFactories.pos() === 0; factory = this._initFactories.next(); diff --git a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts index 399439be73..add75b37ce 100644 --- a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts +++ b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts @@ -1,5 +1,6 @@ -import { LDOptions } from '../../src'; +import { DataSourceOptions, isStandardOptions, LDFeatureStore, LDOptions } from '../../src'; import Configuration from '../../src/options/Configuration'; +import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import TestLogger, { LogLevel } from '../Logger'; function withLogger(options: LDOptions): LDOptions { @@ -13,7 +14,7 @@ function logger(options: LDOptions): TestLogger { describe.each([undefined, null, 'potat0', 17, [], {}])('constructed without options', (input) => { it('should have default options', () => { // JavaScript is not going to stop you from calling this with whatever - // you want. So we need to tell TS to ingore our bad behavior. + // you want. So we need to tell TS to ignore our bad behavior. // @ts-ignore const config = new Configuration(input); @@ -42,6 +43,7 @@ describe.each([undefined, null, 'potat0', 17, [], {}])('constructed without opti expect(config.wrapperVersion).toBeUndefined(); expect(config.hooks).toBeUndefined(); expect(config.payloadFilterKey).toBeUndefined(); + expect(config.dataSystem).toBeUndefined(); }); }); @@ -408,4 +410,66 @@ describe('when setting different options', () => { }, ]); }); + + it('drops invalid datasystem data source options and replaces with defaults', () => { + const config = new Configuration( + withLogger({ + dataSystem: { dataSource: { bogus: 'myBogusOptions' } as unknown as DataSourceOptions }, + }), + ); + expect(isStandardOptions(config.dataSystem!.dataSource)).toEqual(true); + logger(config).expectMessages([ + { + level: LogLevel.Warn, + matches: /Config option "dataSource" should be of type DataSourceOptions/, + }, + ]); + }); + + it('validates the datasystem persistent store is a factory or object', () => { + const config1 = new Configuration( + withLogger({ + dataSystem: { + persistentStore: () => new InMemoryFeatureStore(), + }, + }), + ); + expect(isStandardOptions(config1.dataSystem!.dataSource)).toEqual(true); + expect(logger(config1).getCount()).toEqual(0); + + const config2 = new Configuration( + withLogger({ + dataSystem: { + persistentStore: 'bogus type' as unknown as LDFeatureStore, + }, + }), + ); + expect(isStandardOptions(config2.dataSystem!.dataSource)).toEqual(true); + logger(config2).expectMessages([ + { + level: LogLevel.Warn, + matches: /Config option "persistentStore" should be of type LDFeatureStore/, + }, + ]); + }); + + it('provides reasonable defaults when datasystem is provided, but some options are missing', () => { + const config = new Configuration( + withLogger({ + dataSystem: {}, + }), + ); + expect(isStandardOptions(config.dataSystem!.dataSource)).toEqual(true); + expect(logger(config).getCount()).toEqual(0); + }); + + it('provides reasonable defaults within the dataSystem.dataSource options when they are missing', () => { + const config = new Configuration( + withLogger({ + dataSystem: { dataSource: { dataSourceOptionsType: 'standard' } }, + }), + ); + expect(isStandardOptions(config.dataSystem!.dataSource)).toEqual(true); + expect(logger(config).getCount()).toEqual(0); + }); }); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 6d9096b54c..2501b09203 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -2,6 +2,7 @@ import { cancelableTimedPromise, ClientContext, + CompositeDataSource, Context, defaultHeaders, internal, @@ -26,17 +27,28 @@ import { LDMigrationStage, LDMigrationVariation, LDOptions, + LDTransactionalFeatureStore, } from './api'; import { Hook } from './api/integrations/Hook'; import { BigSegmentStoreMembership } from './api/interfaces'; import { LDWaitForInitializationOptions } from './api/LDWaitForInitializationOptions'; +import { + isPollingOnlyOptions, + isStandardOptions, + isStreamingOnlyOptions, +} from './api/options/LDDataSystemOptions'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; +import { createPayloadListener } from './data_sources/createPayloadListenerFDv2'; import { createStreamListeners } from './data_sources/createStreamListeners'; import DataSourceUpdates from './data_sources/DataSourceUpdates'; +import OneShotInitializerFDv2 from './data_sources/OneShotInitializerFDv2'; import PollingProcessor from './data_sources/PollingProcessor'; +import PollingProcessorFDv2 from './data_sources/PollingProcessorFDv2'; import Requestor from './data_sources/Requestor'; import StreamingProcessor from './data_sources/StreamingProcessor'; +import StreamingProcessorFDv2 from './data_sources/StreamingProcessorFDv2'; +import TransactionalDataSourceUpdates from './data_sources/TransactionalDataSourceUpdates'; import createDiagnosticsInitConfig from './diagnostics/createDiagnosticsInitConfig'; import { allAsync } from './evaluation/collection'; import { Flag } from './evaluation/data/Flag'; @@ -51,7 +63,7 @@ import FlagsStateBuilder from './FlagsStateBuilder'; import HookRunner from './hooks/HookRunner'; import MigrationOpEventToInputEvent from './MigrationOpEventConversion'; import MigrationOpTracker from './MigrationOpTracker'; -import Configuration from './options/Configuration'; +import Configuration, { DEFAULT_POLL_INTERVAL } from './options/Configuration'; import VersionedDataKinds from './store/VersionedDataKinds'; const { ClientMessages, ErrorKinds, NullEventProcessor } = internal; @@ -93,17 +105,19 @@ const VARIATION_METHOD_DETAIL_NAME = 'LDClient.variationDetail'; export default class LDClientImpl implements LDClient { private _initState: InitState = InitState.Initializing; - private _featureStore: LDFeatureStore; + private _featureStore!: LDFeatureStore | LDTransactionalFeatureStore; private _updateProcessor?: subsystem.LDStreamProcessor; + private _dataSource?: subsystem.DataSource; + private _eventFactoryDefault = new EventFactory(false); private _eventFactoryWithReasons = new EventFactory(true); - private _eventProcessor: subsystem.LDEventProcessor; + private _eventProcessor!: subsystem.LDEventProcessor; - private _evaluator: Evaluator; + private _evaluator!: Evaluator; private _initResolve?: (value: LDClient | PromiseLike) => void; @@ -115,19 +129,19 @@ export default class LDClientImpl implements LDClient { private _logger?: LDLogger; - private _config: Configuration; + private _config!: Configuration; - private _bigSegmentsManager: BigSegmentsManager; + private _bigSegmentsManager!: BigSegmentsManager; - private _onError: (err: Error) => void; + private _onError!: (err: Error) => void; - private _onFailed: (err: Error) => void; + private _onFailed!: (err: Error) => void; - private _onReady: () => void; + private _onReady!: () => void; private _diagnosticsManager?: internal.DiagnosticsManager; - private _hookRunner: HookRunner; + private _hookRunner!: HookRunner; public get logger(): LDLogger | undefined { return this._logger; @@ -140,7 +154,7 @@ export default class LDClientImpl implements LDClient { * a platform event system. For node this would be an EventEmitter, for other * platforms it would likely be an EventTarget. */ - protected bigSegmentStatusProviderInternal: BigSegmentStoreStatusProvider; + protected bigSegmentStatusProviderInternal!: BigSegmentStoreStatusProvider; constructor( private _sdkKey: string, @@ -148,33 +162,49 @@ export default class LDClientImpl implements LDClient { options: LDOptions, callbacks: LDClientCallbacks, internalOptions?: internal.LDInternalOptions, + ) { + const config = new Configuration(options, internalOptions); + + if (!config.dataSystem) { + // setup for FDv1 + this._constructorFDv1(_sdkKey, _platform, config, callbacks); + } else { + // setup for FDv2 + this._constructorFDv2(_sdkKey, _platform, config, callbacks); + } + } + + private _constructorFDv1( + sdkKey: string, + platform: Platform, + config: Configuration, + callbacks: LDClientCallbacks, ) { this._onError = callbacks.onError; this._onFailed = callbacks.onFailed; this._onReady = callbacks.onReady; const { onUpdate, hasEventListeners } = callbacks; - const config = new Configuration(options, internalOptions); this._hookRunner = new HookRunner(config.logger, config.hooks || []); - if (!_sdkKey && !config.offline) { + if (!sdkKey && !config.offline) { throw new Error('You must configure the client with an SDK key'); } this._config = config; this._logger = config.logger; - const baseHeaders = defaultHeaders(_sdkKey, _platform.info, config.tags); + const baseHeaders = defaultHeaders(sdkKey, platform.info, config.tags); - const clientContext = new ClientContext(_sdkKey, config, _platform); + const clientContext = new ClientContext(sdkKey, config, platform); const featureStore = config.featureStoreFactory(clientContext); const dataSourceUpdates = new DataSourceUpdates(featureStore, hasEventListeners, onUpdate); if (config.sendEvents && !config.offline && !config.diagnosticOptOut) { this._diagnosticsManager = new internal.DiagnosticsManager( - _sdkKey, - _platform, - createDiagnosticsInitConfig(config, _platform, featureStore), + sdkKey, + platform, + createDiagnosticsInitConfig(config, platform, featureStore), ); } @@ -259,6 +289,175 @@ export default class LDClientImpl implements LDClient { } } + private _constructorFDv2( + sdkKey: string, + platform: Platform, + config: Configuration, + callbacks: LDClientCallbacks, + ) { + this._onError = callbacks.onError; + this._onFailed = callbacks.onFailed; + this._onReady = callbacks.onReady; + + const { onUpdate, hasEventListeners } = callbacks; + + this._hookRunner = new HookRunner(config.logger, config.hooks || []); + + if (!sdkKey && !config.offline) { + throw new Error('You must configure the client with an SDK key'); + } + this._config = config; + this._logger = config.logger; + const baseHeaders = defaultHeaders(sdkKey, platform.info, config.tags); + + const clientContext = new ClientContext(sdkKey, config, platform); + const dataSystem = config.dataSystem!; // dataSystem must be defined to get into this helper function + const featureStore = dataSystem.featureStoreFactory(clientContext); + + const dataSourceUpdates = new TransactionalDataSourceUpdates( + featureStore, + hasEventListeners, + onUpdate, + ); + + if (config.sendEvents && !config.offline && !config.diagnosticOptOut) { + this._diagnosticsManager = new internal.DiagnosticsManager( + sdkKey, + platform, + createDiagnosticsInitConfig(config, platform, featureStore), + ); + } + + if (!config.sendEvents || config.offline) { + this._eventProcessor = new NullEventProcessor(); + } else { + this._eventProcessor = new internal.EventProcessor( + config, + clientContext, + baseHeaders, + new ContextDeduplicator(config), + this._diagnosticsManager, + ); + } + + this._featureStore = featureStore; + + const manager = new BigSegmentsManager( + config.bigSegments?.store?.(clientContext), + config.bigSegments ?? {}, + config.logger, + this._platform.crypto, + ); + this._bigSegmentsManager = manager; + this.bigSegmentStatusProviderInternal = manager.statusProvider as BigSegmentStoreStatusProvider; + + const queries: Queries = { + getFlag(key: string, cb: (flag: Flag | undefined) => void): void { + featureStore.get(VersionedDataKinds.Features, key, (item) => cb(item as Flag)); + }, + getSegment(key: string, cb: (segment: Segment | undefined) => void): void { + featureStore.get(VersionedDataKinds.Segments, key, (item) => cb(item as Segment)); + }, + getBigSegmentsMembership( + userKey: string, + ): Promise<[BigSegmentStoreMembership | null, string] | undefined> { + return manager.getUserMembership(userKey); + }, + }; + this._evaluator = new Evaluator(this._platform, queries); + + if (!(config.offline || config.dataSystem!.useLdd)) { + // make the FDv2 composite datasource with initializers/synchronizers + const initializers: subsystem.LDDataSourceFactory[] = []; + + // use one shot initializer for performance and cost + initializers.push( + () => + new OneShotInitializerFDv2( + new Requestor(config, this._platform.requests, baseHeaders, '/sdk/poll', config.logger), + config.logger, + ), + ); + + const synchronizers: subsystem.LDDataSourceFactory[] = []; + // if streaming is configured, add streaming synchronizer + if ( + isStandardOptions(dataSystem.dataSource) || + isStreamingOnlyOptions(dataSystem.dataSource) + ) { + const reconnectDelay = dataSystem.dataSource.streamInitialReconnectDelay; + synchronizers.push( + () => + new StreamingProcessorFDv2( + clientContext, + '/sdk/stream', + [], + baseHeaders, + this._diagnosticsManager, + reconnectDelay, + ), + ); + } + + let pollingInterval = DEFAULT_POLL_INTERVAL; + // if polling is configured, add polling synchronizer + if (isStandardOptions(dataSystem.dataSource) || isPollingOnlyOptions(dataSystem.dataSource)) { + pollingInterval = dataSystem.dataSource.pollInterval ?? DEFAULT_POLL_INTERVAL; + synchronizers.push( + () => + new PollingProcessorFDv2( + new Requestor( + config, + this._platform.requests, + baseHeaders, + '/sdk/poll', + config.logger, + ), + pollingInterval, + config.logger, + ), + ); + } + + // This is short term handling and will be removed once FDv2 adoption is sufficient. + const fdv1FallbackSynchronizers = [ + () => + new PollingProcessorFDv2( + new Requestor(config, this._platform.requests, baseHeaders, '/sdk/poll', config.logger), + pollingInterval, + config.logger, + true, + ), + ]; + + this._dataSource = new CompositeDataSource( + initializers, + synchronizers, + fdv1FallbackSynchronizers, + this.logger, + ); + const payloadListener = createPayloadListener(dataSourceUpdates, this.logger, () => { + this._initSuccess(); + }); + + this._dataSource.start( + (_, payload) => { + payloadListener(payload); + }, + (state, err) => { + if (state === subsystem.DataSourceState.Closed && err) { + this._dataSourceErrorHandler(err); + } + }, + () => featureStore.getSelector?.(), + ); + } else { + // Deferring the start callback should allow client construction to complete before we start + // emitting events. Allowing the client an opportunity to register events. + setTimeout(() => this._initSuccess(), 0); + } + } + initialized(): boolean { return this._initState === InitState.Initialized; } @@ -272,7 +471,10 @@ export default class LDClientImpl implements LDClient { // If there is no update processor, then there is functionally no initialization // so it is fine not to wait. - if (options?.timeout === undefined && this._updateProcessor !== undefined) { + if ( + options?.timeout === undefined && + (this._updateProcessor !== undefined || this._dataSource !== undefined) + ) { this._logger?.warn( 'The waitForInitialization function was called without a timeout specified.' + ' In a future version a default timeout will be applied.', @@ -281,7 +483,7 @@ export default class LDClientImpl implements LDClient { if ( options?.timeout !== undefined && options?.timeout > HIGH_TIMEOUT_THRESHOLD && - this._updateProcessor !== undefined + (this._updateProcessor !== undefined || this._dataSource !== undefined) ) { this._logger?.warn( 'The waitForInitialization function was called with a timeout greater than ' + @@ -731,6 +933,7 @@ export default class LDClientImpl implements LDClient { close(): void { this._eventProcessor.close(); this._updateProcessor?.close(); + this._dataSource?.stop(); this._featureStore.close(); this._bigSegmentsManager.close(); } diff --git a/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts new file mode 100644 index 0000000000..cdb80f8ea5 --- /dev/null +++ b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts @@ -0,0 +1,144 @@ +import { LDClientContext } from '@launchdarkly/js-sdk-common'; + +import { LDFeatureStore } from '../subsystems'; + +/** + * @experimental + * This feature is not stable and not subject to any backwards compatibility guarantees or semantic + * versioning. It is not suitable for production usage. + * + * Configuration options for the Data System that the SDK uses to get and maintain flags and other + * data from LaunchDarkly and other sources. + * + * Example (Recommended): + * ```typescript + * let dataSystemOptions = { + * dataSource: { + * dataSourceOptionsType: 'standard'; + * }, + * } + * + * Example (Polling with DynamoDB Persistent Store): + * ```typescript + * import { DynamoDBFeatureStore } from '@launchdarkly/node-server-sdk-dynamodb'; + * + * let dataSystemOptions = { + * dataSource: { + * dataSourceOptionsType: 'pollingOnly'; + * pollInterval: 300; + * }, + * persistentStore: DynamoDBFeatureStore('your-table', { cacheTTL: 30 }); + * } + * const client = init('my-sdk-key', { hooks: [new TracingHook()] }); + * ``` + */ +export interface LDDataSystemOptions { + /** + * Configuration options for the Data Source that the SDK uses to get flags and other + * data from the LaunchDarkly servers. Choose one of {@link StandardDataSourceOptions}, + * {@link StreamingDataSourceOptions}, or {@link PollingDataSourceOptions}; setting the + * type and the optional fields you want to customize. + * + * If not specified, this defaults to using the {@link StandardDataSourceOptions} which + * performs a combination of streaming and polling. + * + * See {@link LDDataSystemOptions} documentation for examples. + */ + dataSource?: DataSourceOptions; + + /** + * Before data has arrived from LaunchDarkly, the SDK is able to evaluate flags using + * data from the persistent store. Once fresh data has arrived from LaunchDarkly, the + * SDK will no longer read from the persistent store, although it will keep it up-to-date + * for future startups. + * + * Some implementations provide the store implementation object itself, while others + * provide a factory function that creates the store implementation based on the SDK + * configuration; this property accepts either. + * + * @param clientContext whose properties may be used to influence creation of the persistent store. + */ + persistentStore?: LDFeatureStore | ((clientContext: LDClientContext) => LDFeatureStore); + + /** + * Whether you are using the LaunchDarkly relay proxy in daemon mode. + * + * In this configuration, the client will not connect to LaunchDarkly to get feature flags, + * but will instead get feature state from a database (Redis or another supported feature + * store integration) that is populated by the relay. By default, this is false. + */ + useLdd?: boolean; +} + +export type DataSourceOptions = + | StandardDataSourceOptions + | StreamingDataSourceOptions + | PollingDataSourceOptions; + +/** + * This standard data source is the recommended datasource for most customers. It will use + * a combination of streaming and polling to initialize the SDK, provide real time updates, + * and can switch between streaming and polling automatically to provide redundancy. + */ +export interface StandardDataSourceOptions { + dataSourceOptionsType: 'standard'; + + /** + * Sets the initial reconnect delay for the streaming connection, in seconds. Default if omitted. + * + * The streaming service uses a backoff algorithm (with jitter) every time the connection needs + * to be reestablished. The delay for the first reconnection will start near this value, and then + * increase exponentially for any subsequent connection failures. + * + * The default value is 1. + */ + streamInitialReconnectDelay?: number; + + /** + * The time between polling requests, in seconds. Default if omitted. + */ + pollInterval?: number; +} + +/** + * This data source will make best effort to maintain a streaming connection to LaunchDarkly services + * to provide real time data updates. + */ +export interface StreamingDataSourceOptions { + dataSourceOptionsType: 'streamingOnly'; + + /** + * Sets the initial reconnect delay for the streaming connection, in seconds. Default if omitted. + * + * The streaming service uses a backoff algorithm (with jitter) every time the connection needs + * to be reestablished. The delay for the first reconnection will start near this value, and then + * increase exponentially up to a maximum for any subsequent connection failures. + * + * The default value is 1. + */ + streamInitialReconnectDelay?: number; +} + +/** + * This data source will periodically make a request to LaunchDarkly services to retrieve updated data. + */ +export interface PollingDataSourceOptions { + dataSourceOptionsType: 'pollingOnly'; + + /** + * The time between polling requests, in seconds. Default if omitted. + */ + pollInterval?: number; +} + +export function isStandardOptions(u: any): u is StandardDataSourceOptions { + return u.dataSourceOptionsType === 'standard'; +} + +export function isStreamingOnlyOptions(u: any): u is StreamingDataSourceOptions { + return u.dataSourceOptionsType === 'streamingOnly'; +} + +export function isPollingOnlyOptions(u: any): u is PollingDataSourceOptions { + return u.dataSourceOptionsType === 'pollingOnly'; +} diff --git a/packages/shared/sdk-server/src/api/options/LDOptions.ts b/packages/shared/sdk-server/src/api/options/LDOptions.ts index f31f644aa2..ed8b3257ec 100644 --- a/packages/shared/sdk-server/src/api/options/LDOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDOptions.ts @@ -3,6 +3,7 @@ import { LDClientContext, LDLogger, subsystem, VoidFunction } from '@launchdarkl import { Hook } from '../integrations/Hook'; import { LDDataSourceUpdates, LDFeatureStore } from '../subsystems'; import { LDBigSegmentsOptions } from './LDBigSegmentsOptions'; +import { LDDataSystemOptions } from './LDDataSystemOptions'; import { LDProxyOptions } from './LDProxyOptions'; import { LDTLSOptions } from './LDTLSOptions'; @@ -60,6 +61,8 @@ export interface LDOptions { /** * A component that stores feature flags and related data received from LaunchDarkly. * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. + * * By default, this is an in-memory data structure. Database integrations are also * available, as described in the * [SDK features guide](https://docs.launchdarkly.com/sdk/concepts/data-stores). @@ -70,6 +73,41 @@ export interface LDOptions { */ featureStore?: LDFeatureStore | ((clientContext: LDClientContext) => LDFeatureStore); + /** + * @experimental + * This feature is not stable and not subject to any backwards compatibility guarantees or semantic + * versioning. It is not suitable for production usage. + * + * Configuration options for the Data System that the SDK uses to get and maintain flags and other + * data from LaunchDarkly and other sources. + * + * Setting this option supersedes + * + * Example (Recommended): + * ```typescript + * let dataSystemOptions = { + * dataSource: { + * type: 'standard'; + * // options can be customized here, though defaults are recommended + * }, + * } + * + * Example (Polling with DynamoDB Persistent Store): + * ```typescript + * import { DynamoDBFeatureStore } from '@launchdarkly/node-server-sdk-dynamodb'; + * + * let dataSystemOptions = { + * dataSource: { + * type: 'pollingOnly'; + * pollInterval: 300; + * }, + * persistentStore: DynamoDBFeatureStore('your-table', { cacheTTL: 30 }); + * } + * const client = init('my-sdk-key', { hooks: [new TracingHook()] }); + * ``` + */ + dataSystem?: LDDataSystemOptions; + /** * Additional parameters for configuring the SDK's Big Segments behavior. * @@ -86,7 +124,7 @@ export interface LDOptions { /** * A component that obtains feature flag data and puts it in the feature store. * - * By default, this is the client's default streaming or polling component. + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. */ updateProcessor?: | object @@ -104,6 +142,8 @@ export interface LDOptions { /** * The time between polling requests, in seconds. Ignored in streaming mode. + * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. */ pollInterval?: number; @@ -120,6 +160,8 @@ export interface LDOptions { /** * Whether streaming mode should be used to receive flag updates. * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. + * * This is true by default. If you set it to false, the client will use polling. * Streaming should only be disabled on the advice of LaunchDarkly support. */ @@ -128,6 +170,8 @@ export interface LDOptions { /** * Sets the initial reconnect delay for the streaming connection, in seconds. * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. + * * The streaming service uses a backoff algorithm (with jitter) every time the connection needs * to be reestablished. The delay for the first reconnection will start near this value, and then * increase exponentially for any subsequent connection failures. @@ -139,6 +183,8 @@ export interface LDOptions { /** * Whether you are using the LaunchDarkly relay proxy in daemon mode. * + * If you specify the {@link LDOptions#dataSystem}, this setting will be ignored. + * * In this configuration, the client will not connect to LaunchDarkly to get feature flags, * but will instead get feature state from a database (Redis or another supported feature * store integration) that is populated by the relay. By default, this is false. @@ -151,7 +197,7 @@ export interface LDOptions { sendEvents?: boolean; /** - * Whether all context attributes (except the contexy key) should be marked as private, and + * Whether all context attributes (except the context key) should be marked as private, and * not sent to LaunchDarkly. * * By default, this is false. diff --git a/packages/shared/sdk-server/src/api/options/index.ts b/packages/shared/sdk-server/src/api/options/index.ts index 1e7b63de7b..44b81cc99c 100644 --- a/packages/shared/sdk-server/src/api/options/index.ts +++ b/packages/shared/sdk-server/src/api/options/index.ts @@ -3,3 +3,4 @@ export * from './LDOptions'; export * from './LDProxyOptions'; export * from './LDTLSOptions'; export * from './LDMigrationOptions'; +export * from './LDDataSystemOptions'; diff --git a/packages/shared/sdk-server/src/api/subsystems/LDTransactionalDataSourceUpdates.ts b/packages/shared/sdk-server/src/api/subsystems/LDTransactionalDataSourceUpdates.ts index ef6d04099b..f9f994599b 100644 --- a/packages/shared/sdk-server/src/api/subsystems/LDTransactionalDataSourceUpdates.ts +++ b/packages/shared/sdk-server/src/api/subsystems/LDTransactionalDataSourceUpdates.ts @@ -6,6 +6,10 @@ import { LDFeatureStoreDataStorage } from './LDFeatureStore'; type InitMetadata = internal.InitMetadata; /** + * @experimental + * This feature is not stable and not subject to any backwards compatibility guarantees or semantic + * versioning. It is not suitable for production usage. + * * Transactional version of {@link LDDataSourceUpdates} with support for {@link applyChanges} */ export interface LDTransactionalDataSourceUpdates extends LDDataSourceUpdates { diff --git a/packages/shared/sdk-server/src/api/subsystems/LDTransactionalFeatureStore.ts b/packages/shared/sdk-server/src/api/subsystems/LDTransactionalFeatureStore.ts index dd904bee6b..d3bed2bc14 100644 --- a/packages/shared/sdk-server/src/api/subsystems/LDTransactionalFeatureStore.ts +++ b/packages/shared/sdk-server/src/api/subsystems/LDTransactionalFeatureStore.ts @@ -5,6 +5,10 @@ import { LDFeatureStore, LDFeatureStoreDataStorage } from './LDFeatureStore'; type InitMetadata = internal.InitMetadata; /** + * @experimental + * This feature is not stable and not subject to any backwards compatibility guarantees or semantic + * versioning. It is not suitable for production usage. + * * Transactional version of {@link LDFeatureStore} with support for {@link applyChanges} */ export interface LDTransactionalFeatureStore extends LDFeatureStore { diff --git a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts index 764efef6ea..03c8bb8d03 100644 --- a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts +++ b/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.ts @@ -1,37 +1,80 @@ import { Platform, secondsToMillis } from '@launchdarkly/js-sdk-common'; -import { LDFeatureStore } from '../api'; +import { + isPollingOnlyOptions, + isStandardOptions, + isStreamingOnlyOptions, + LDFeatureStore, +} from '../api'; import Configuration, { defaultValues } from '../options/Configuration'; const createDiagnosticsInitConfig = ( config: Configuration, platform: Platform, featureStore: LDFeatureStore, -) => ({ - customBaseURI: config.serviceEndpoints.polling !== defaultValues.baseUri, - customStreamURI: config.serviceEndpoints.streaming !== defaultValues.streamUri, - customEventsURI: config.serviceEndpoints.events !== defaultValues.eventsUri, - eventsCapacity: config.eventsCapacity, - - // Node doesn't distinguish between these two kinds of timeouts. It is unlikely other web - // based implementations would be able to either. - connectTimeoutMillis: secondsToMillis(config.timeout), - socketTimeoutMillis: secondsToMillis(config.timeout), - eventsFlushIntervalMillis: secondsToMillis(config.flushInterval), - pollingIntervalMillis: secondsToMillis(config.pollInterval), - reconnectTimeMillis: secondsToMillis(config.streamInitialReconnectDelay), - contextKeysFlushIntervalMillis: secondsToMillis(config.contextKeysFlushInterval), - diagnosticRecordingIntervalMillis: secondsToMillis(config.diagnosticRecordingInterval), - - streamingDisabled: !config.stream, - usingRelayDaemon: config.useLdd, - offline: config.offline, - allAttributesPrivate: config.allAttributesPrivate, - contextKeysCapacity: config.contextKeysCapacity, - - usingProxy: !!platform.requests.usingProxy?.(), - usingProxyAuthenticator: !!platform.requests.usingProxyAuth?.(), - dataStoreType: featureStore.getDescription?.() ?? 'memory', -}); +) => { + let pollingIntervalMillis: number | undefined; + if (config.dataSystem?.dataSource) { + if ( + (isStandardOptions(config.dataSystem.dataSource) || + isPollingOnlyOptions(config.dataSystem.dataSource)) && + config.dataSystem.dataSource.pollInterval + ) { + pollingIntervalMillis = secondsToMillis(config.dataSystem.dataSource.pollInterval); + } + } else { + pollingIntervalMillis = secondsToMillis(config.pollInterval); + } + + let reconnectTimeMillis: number | undefined; + if (config.dataSystem?.dataSource) { + if ( + (isStandardOptions(config.dataSystem.dataSource) || + isStreamingOnlyOptions(config.dataSystem.dataSource)) && + config.dataSystem.dataSource.streamInitialReconnectDelay + ) { + reconnectTimeMillis = secondsToMillis( + config.dataSystem.dataSource.streamInitialReconnectDelay, + ); + } + } else { + reconnectTimeMillis = secondsToMillis(config.streamInitialReconnectDelay); + } + + let streamDisabled: boolean; + if (config.dataSystem?.dataSource) { + streamDisabled = isPollingOnlyOptions(config.dataSystem?.dataSource); + } else { + streamDisabled = !config.stream; + } + + return { + customBaseURI: config.serviceEndpoints.polling !== defaultValues.baseUri, + customStreamURI: config.serviceEndpoints.streaming !== defaultValues.streamUri, + customEventsURI: config.serviceEndpoints.events !== defaultValues.eventsUri, + eventsCapacity: config.eventsCapacity, + + // Node doesn't distinguish between these two kinds of timeouts. It is unlikely other web + // based implementations would be able to either. + connectTimeoutMillis: secondsToMillis(config.timeout), + socketTimeoutMillis: secondsToMillis(config.timeout), + eventsFlushIntervalMillis: secondsToMillis(config.flushInterval), + // include polling interval if data source config has it + ...(pollingIntervalMillis ? { pollingIntervalMillis } : null), + // include reconnect delay if data source config has it + ...(reconnectTimeMillis ? { reconnectTimeMillis } : null), + contextKeysFlushIntervalMillis: secondsToMillis(config.contextKeysFlushInterval), + diagnosticRecordingIntervalMillis: secondsToMillis(config.diagnosticRecordingInterval), + streamingDisabled: streamDisabled, + usingRelayDaemon: config.dataSystem?.useLdd ?? config.useLdd, + offline: config.offline, + allAttributesPrivate: config.allAttributesPrivate, + contextKeysCapacity: config.contextKeysCapacity, + + usingProxy: !!platform.requests.usingProxy?.(), + usingProxyAuthenticator: !!platform.requests.usingProxyAuth?.(), + dataStoreType: featureStore.getDescription?.() ?? 'memory', + }; +}; export default createDiagnosticsInitConfig; diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index 96d7494143..1b12ff6d10 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -14,7 +14,21 @@ import { import { LDBigSegmentsOptions, LDOptions, LDProxyOptions, LDTLSOptions } from '../api'; import { Hook } from '../api/integrations'; -import { LDDataSourceUpdates, LDFeatureStore } from '../api/subsystems'; +import { + DataSourceOptions, + isPollingOnlyOptions, + isStandardOptions, + isStreamingOnlyOptions, + LDDataSystemOptions, + PollingDataSourceOptions, + StandardDataSourceOptions, + StreamingDataSourceOptions, +} from '../api/options/LDDataSystemOptions'; +import { + LDDataSourceUpdates, + LDFeatureStore, + LDTransactionalFeatureStore, +} from '../api/subsystems'; import InMemoryFeatureStore from '../store/InMemoryFeatureStore'; import { ValidatedOptions } from './ValidatedOptions'; @@ -35,6 +49,7 @@ const validations: Record = { capacity: TypeValidators.Number, logger: TypeValidators.Object, featureStore: TypeValidators.ObjectOrFactory, + dataSystem: TypeValidators.Object, bigSegments: TypeValidators.Object, updateProcessor: TypeValidators.ObjectOrFactory, flushInterval: TypeValidators.Number, @@ -58,6 +73,30 @@ const validations: Record = { payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/), hooks: TypeValidators.createTypeArray('Hook[]', {}), enableEventCompression: TypeValidators.Boolean, + dataSourceOptionsType: TypeValidators.String, +}; + +export const DEFAULT_POLL_INTERVAL = 30; +const DEFAULT_STREAM_RECONNECT_DELAY = 1; + +const defaultStandardDataSourceOptions: StandardDataSourceOptions = { + dataSourceOptionsType: 'standard', + streamInitialReconnectDelay: DEFAULT_STREAM_RECONNECT_DELAY, + pollInterval: DEFAULT_POLL_INTERVAL, +}; + +const defaultStreamingDataSourceOptions: StreamingDataSourceOptions = { + dataSourceOptionsType: 'streamingOnly', + streamInitialReconnectDelay: DEFAULT_STREAM_RECONNECT_DELAY, +}; + +const defaultPollingDataSourceOptions: PollingDataSourceOptions = { + dataSourceOptionsType: 'pollingOnly', + pollInterval: DEFAULT_POLL_INTERVAL, +}; + +const defaultDataSystemOptions = { + dataSource: defaultStandardDataSourceOptions, }; /** @@ -68,12 +107,12 @@ export const defaultValues: ValidatedOptions = { streamUri: 'https://stream.launchdarkly.com', eventsUri: ServiceEndpoints.DEFAULT_EVENTS, stream: true, - streamInitialReconnectDelay: 1, + streamInitialReconnectDelay: DEFAULT_STREAM_RECONNECT_DELAY, sendEvents: true, timeout: 5, capacity: 10000, flushInterval: 5, - pollInterval: 30, + pollInterval: DEFAULT_POLL_INTERVAL, offline: false, useLdd: false, allAttributesPrivate: false, @@ -84,14 +123,23 @@ export const defaultValues: ValidatedOptions = { diagnosticRecordingInterval: 900, featureStore: () => new InMemoryFeatureStore(), enableEventCompression: false, + dataSystem: defaultDataSystemOptions, +}; + +// General options type needed by validation algorithm. Specific types can be asserted after use. +type Options = { + [k: string]: any; }; -function validateTypesAndNames(options: LDOptions): { +function validateTypesAndNames( + options: Options, + defaults: Options, +): { errors: string[]; - validatedOptions: ValidatedOptions; + validatedOptions: Options; } { const errors: string[] = []; - const validatedOptions: ValidatedOptions = { ...defaultValues }; + const validatedOptions: Options = { ...defaults }; Object.keys(options).forEach((optionName) => { // We need to tell typescript it doesn't actually know what options are. // If we don't then it complains we are doing crazy things with it. @@ -125,7 +173,7 @@ function validateTypesAndNames(options: LDOptions): { return { errors, validatedOptions }; } -function validateEndpoints(options: LDOptions, validatedOptions: ValidatedOptions) { +function validateEndpoints(options: LDOptions, validatedOptions: Options) { const { baseUri, streamUri, eventsUri } = options; const streamingEndpointSpecified = streamUri !== undefined && streamUri !== null; const pollingEndpointSpecified = baseUri !== undefined && baseUri !== null; @@ -152,6 +200,74 @@ function validateEndpoints(options: LDOptions, validatedOptions: ValidatedOption } } +function validateDataSystemOptions(options: Options): { + errors: string[]; + validatedOptions: Options; +} { + const allErrors: string[] = []; + const validatedOptions: Options = { ...options }; + + if (options.persistentStore && !TypeValidators.ObjectOrFactory.is(options.persistentStore)) { + validatedOptions.persistentStore = undefined; // default is to not use this + allErrors.push( + OptionMessages.wrongOptionType( + 'persistentStore', + 'LDFeatureStore', + typeof options.persistentStore, + ), + ); + } + + if (options.dataSource) { + let errors: string[]; + let validatedDataSourceOptions: Options; + if (isStandardOptions(options.dataSource)) { + ({ errors, validatedOptions: validatedDataSourceOptions } = validateTypesAndNames( + options.dataSource, + defaultStandardDataSourceOptions, + )); + } else if (isStreamingOnlyOptions(options.dataSource)) { + ({ errors, validatedOptions: validatedDataSourceOptions } = validateTypesAndNames( + options.dataSource, + defaultStreamingDataSourceOptions, + )); + } else if (isPollingOnlyOptions(options.dataSource)) { + ({ errors, validatedOptions: validatedDataSourceOptions } = validateTypesAndNames( + options.dataSource, + defaultPollingDataSourceOptions, + )); + } else { + // provided datasource options don't fit any expected form, drop them and use defaults + validatedDataSourceOptions = defaultStandardDataSourceOptions; + errors = [ + OptionMessages.wrongOptionType( + 'dataSource', + 'DataSourceOptions', + typeof options.dataSource, + ), + ]; + } + validatedOptions.dataSource = validatedDataSourceOptions; + allErrors.push(...errors); + } else { + // use default datasource options if no datasource was specified + validatedOptions.dataSource = defaultStandardDataSourceOptions; + } + + return { errors: allErrors, validatedOptions }; +} + +/** + * Configuration for the Data System + * + * @internal + */ +export interface DataSystemConfiguration { + dataSource?: DataSourceOptions; + featureStoreFactory: (clientContext: LDClientContext) => LDTransactionalFeatureStore; + useLdd?: boolean; +} + /** * Configuration options for the LDClient. * @@ -206,6 +322,8 @@ export default class Configuration { public readonly featureStoreFactory: (clientContext: LDClientContext) => LDFeatureStore; + public readonly dataSystem?: DataSystemConfiguration; + public readonly updateProcessorFactory?: ( clientContext: LDClientContext, dataSourceUpdates: LDDataSourceUpdates, @@ -227,13 +345,43 @@ export default class Configuration { // If there isn't a valid logger from the platform, then logs would go nowhere. this.logger = options.logger; - const { errors, validatedOptions } = validateTypesAndNames(options); + const { errors, validatedOptions: topLevelResult } = validateTypesAndNames( + options, + defaultValues, + ); + const validatedOptions = topLevelResult as ValidatedOptions; errors.forEach((error) => { this.logger?.warn(error); }); validateEndpoints(options, validatedOptions); + if (options.dataSystem) { + // validate the data system options, this will also apply reasonable defaults + const { errors: dsErrors, validatedOptions: dsResult } = validateDataSystemOptions( + options.dataSystem, + ); + const validatedDSOptions = dsResult as LDDataSystemOptions; + this.dataSystem = { + dataSource: validatedDSOptions.dataSource, + useLdd: validatedDSOptions.useLdd, + // @ts-ignore + featureStoreFactory: (clientContext) => { + if (validatedDSOptions.persistentStore === undefined) { + // the persistent store provided was either undefined or invalid, default to memory store + return new InMemoryFeatureStore(); + } + if (TypeValidators.Function.is(validatedDSOptions.persistentStore)) { + return validatedDSOptions.persistentStore(clientContext); + } + return validatedDSOptions.persistentStore; + }, + }; + dsErrors.forEach((error) => { + this.logger?.warn(error); + }); + } + this.serviceEndpoints = new ServiceEndpoints( validatedOptions.streamUri, validatedOptions.baseUri, diff --git a/packages/shared/sdk-server/src/options/ValidatedOptions.ts b/packages/shared/sdk-server/src/options/ValidatedOptions.ts index 1ec31c6c4a..7389cf949b 100644 --- a/packages/shared/sdk-server/src/options/ValidatedOptions.ts +++ b/packages/shared/sdk-server/src/options/ValidatedOptions.ts @@ -2,6 +2,7 @@ import { LDLogger, subsystem } from '@launchdarkly/js-sdk-common'; import { LDBigSegmentsOptions, LDOptions, LDProxyOptions, LDTLSOptions } from '../api'; import { Hook } from '../api/integrations'; +import { LDDataSystemOptions } from '../api/options/LDDataSystemOptions'; import { LDFeatureStore } from '../api/subsystems'; /** @@ -30,6 +31,7 @@ export interface ValidatedOptions { diagnosticOptOut: boolean; diagnosticRecordingInterval: number; featureStore: LDFeatureStore | ((options: LDOptions) => LDFeatureStore); + dataSystem: LDDataSystemOptions; tlsParams?: LDTLSOptions; updateProcessor?: subsystem.LDStreamProcessor; wrapperName?: string;