diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.overrides.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.overrides.test.ts index 54a0248a59..7e2f838875 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.overrides.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.overrides.test.ts @@ -637,6 +637,48 @@ describe('given override source lifecycle and configuration', () => { expect(await client.boolVariation('overridden-flag', user, false)).toBe(false); }); + it('warns in offline mode that the source is not started and no overrides are served', () => { + const logger = makeMockLogger(); + const factory = jest.fn((_clientContext: LDClientContext) => new TestOverrideSource()); + client = makeFDv2Client(makeFDv2Platform(), { + logger, + offline: true, + dataSystem: { overrides: factory }, + }); + + expect(factory).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + 'Offline mode is enabled; the override source is not started and no overrides are served', + ); + }); + + it('does not warn about overrides in offline mode without an override source', () => { + const logger = makeMockLogger(); + client = makeFDv2Client(makeFDv2Platform(), { logger, offline: true, dataSystem: {} }); + + expect(warningsMatching(logger, /override source/)).toEqual(0); + }); + + it('ignores a snapshot that the source supplies after the client is closed', async () => { + const updates: string[] = []; + const source = new TestOverrideSource(); + const created = makeFDv2Client( + makeFDv2Platform(), + { dataSystem: { overrides: source } }, + makeCallbacks((key) => updates.push(key), true), + ); + client = created; + created.close(); + + // A custom source can keep supplying snapshots after it is closed. + source.sink!.setOverrides([singleValueFlag('late-flag', true)], []); + await settle(); + + expect(updates).toEqual([]); + const detail = await created.boolVariationDetail('late-flag', user, false); + expect(detail.reason).toEqual({ kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' }); + }); + it('accepts a factory function for the source', () => { const source = new TestOverrideSource(); const factory = jest.fn((_clientContext: LDClientContext) => source); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 6f773ce53d..71256c4d33 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -339,6 +339,11 @@ function constructFDv2( dataSystem.overrides !== undefined && !config.offline ? createOverrideSource(dataSystem.overrides, clientContext) : undefined; + if (dataSystem.overrides !== undefined && config.offline) { + logger?.warn( + 'Offline mode is enabled; the override source is not started and no overrides are served', + ); + } const featureStore = dataSystem.featureStoreFactory(clientContext); // The override layer sits at the store read boundary. Evaluation, prerequisite and segment @@ -650,6 +655,8 @@ export default class LDClientImpl implements LDClient { private _overrideLayer?: OverrideLayer; + private _overrideSink?: OverrideSink; + private _overrideSource?: LDOverrideSource; // Settles when the override source's initial load has completed, also when it failed, and is @@ -810,6 +817,7 @@ export default class LDClientImpl implements LDClient { // place before the client evaluates anything. An asynchronous initial load is part of // starting the client: waitForInitialization resolves after it. Evaluation never waits. this._overrideLayer = overrides.layer; + this._overrideSink = overrides.sink; this._overrideSource = overrides.source; this._overridesReady = this._startOverrideSource(overrides.source, overrides.sink); } @@ -1385,6 +1393,9 @@ export default class LDClientImpl implements LDClient { } close(): void { + // The sink stops accepting snapshots first, so a snapshot that the source supplies while or + // after it closes neither changes the layer nor notifies the application. + this._overrideSink?.close(); try { this._overrideSource?.close(); } catch (err) { diff --git a/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts index cb9dfef0fd..76e2d7a026 100644 --- a/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDDataSystemOptions.ts @@ -90,6 +90,9 @@ export interface LDDataSystemOptions { * status or data source status. Configuring it changes nothing until the source supplies an * override. At most one override source can be configured. * + * When the client is offline, the source is not built or started: no overrides are served, and + * a configuration error in the source is not reported. The SDK logs a warning instead. + * * Some implementations provide the source object itself, while others provide a factory * function that creates the source based on the SDK configuration. This property accepts * either. diff --git a/packages/shared/sdk-server/src/overrides/OverrideSink.ts b/packages/shared/sdk-server/src/overrides/OverrideSink.ts index 7f2f59c92a..d8ba5ac939 100644 --- a/packages/shared/sdk-server/src/overrides/OverrideSink.ts +++ b/packages/shared/sdk-server/src/overrides/OverrideSink.ts @@ -101,6 +101,8 @@ export default class OverrideSink implements LDOverrideSink { // Notifications for successive snapshots are computed one at a time, in order. private _chain: Promise = Promise.resolve(); + private _closed = false; + /** * @param _layer The layer to write to. * @param _base The store that holds LaunchDarkly data, without the overlay. The merged views @@ -125,6 +127,12 @@ export default class OverrideSink implements LDOverrideSink { * layer before any of it is applied, and the error reaches the caller. */ setOverrides(flags: LDKeyedFeatureStoreItem[], segments: LDKeyedFeatureStoreItem[]): void { + if (this._closed) { + this._logger?.debug( + 'Ignoring an override snapshot supplied after the override source was closed', + ); + return; + } const { previous, current } = this._layer.setAll(flags, segments); // Computing the affected flags needs the merged view before and after the replacement. Skip // all of that work when nothing is listening. @@ -138,6 +146,14 @@ export default class OverrideSink implements LDOverrideSink { }); } + /** + * Stops accepting snapshots. The client calls this before it closes the source, so a snapshot + * that the source supplies afterward is ignored. The layer keeps its contents. + */ + close(): void { + this._closed = true; + } + /** * Notifies the flags whose override entries were added, removed, or changed, plus every flag * that depends, directly or transitively, on any added, removed, or changed entry of either