From c07a82bac1d5e88e421eb0fdc1106168d3a1ac27 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:04:46 +0000 Subject: [PATCH] fix: Warn when offline mode skips the override source, and ignore snapshots after close When the client is offline and an override source is configured, the source is still not built or started, and the SDK now logs a warning that no overrides are served. The overrides option documents that a configuration error in the source is not reported offline. Closing the client closes the override sink before the source, so a snapshot that a custom source supplies after the client is closed no longer changes the layer or notifies the application. --- .../__tests__/LDClientImpl.overrides.test.ts | 42 +++++++++++++++++++ .../shared/sdk-server/src/LDClientImpl.ts | 11 +++++ .../src/api/options/LDDataSystemOptions.ts | 3 ++ .../sdk-server/src/overrides/OverrideSink.ts | 16 +++++++ 4 files changed, 72 insertions(+) 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