From 62254621f920d2dd4e26e0459979d4b84b013cb0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:52:58 -0700 Subject: [PATCH 01/20] feat: Add the FDv2 data system and expose it through configuration --- .../lib/launchdarkly_common_client.dart | 15 ++ .../lib/src/config/data_system_config.dart | 74 +++++++++ .../src/data_sources/data_source_manager.dart | 35 +++-- .../src/data_sources/fdv2/data_system.dart | 140 ++++++++++++++++++ .../lib/src/ld_common_client.dart | 25 +++- .../lib/src/ld_common_config.dart | 22 ++- .../data_source_manager_test.dart | 47 +++++- .../data_sources/fdv2/data_system_test.dart | 85 +++++++++++ 8 files changed, 418 insertions(+), 25 deletions(-) create mode 100644 packages/common_client/lib/src/config/data_system_config.dart create mode 100644 packages/common_client/lib/src/data_sources/fdv2/data_system.dart create mode 100644 packages/common_client/test/data_sources/fdv2/data_system_test.dart diff --git a/packages/common_client/lib/launchdarkly_common_client.dart b/packages/common_client/lib/launchdarkly_common_client.dart index 475948d7..50a01e3e 100644 --- a/packages/common_client/lib/launchdarkly_common_client.dart +++ b/packages/common_client/lib/launchdarkly_common_client.dart @@ -6,6 +6,21 @@ export 'src/ld_common_config.dart' AutoEnvAttributes, PollingConfig; +export 'src/config/data_system_config.dart' + show DataSystemConfig, ConnectionModeId; +export 'src/data_sources/fdv2/mode_definition.dart' + show + ModeDefinition, + EndpointConfig, + InitializerEntry, + SynchronizerEntry, + CacheInitializer, + PollingInitializer, + StreamingInitializer, + PollingSynchronizer, + StreamingSynchronizer, + Fdv1FallbackConfig; + export 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' show LDContext, diff --git a/packages/common_client/lib/src/config/data_system_config.dart b/packages/common_client/lib/src/config/data_system_config.dart new file mode 100644 index 00000000..334362e1 --- /dev/null +++ b/packages/common_client/lib/src/config/data_system_config.dart @@ -0,0 +1,74 @@ +import '../data_sources/fdv2/mode_definition.dart'; + +// Maintainer note (not public API): ConnectionModeId is a sealed +// hierarchy rather than an enum so a custom-mode variant can be added +// later without changing this surface. The planned extension is a custom +// variant constructed as `ConnectionModeId.custom('my-mode')`: +// +// factory ConnectionModeId.custom(String name) = _CustomConnectionMode; +// final class _CustomConnectionMode extends ConnectionModeId { +// final String name; +// const _CustomConnectionMode(this.name); +// // value equality on name so it works as an override-map key +// } +// +// A custom mode is a distinct type from a built-in, so the two share no +// namespace: a custom id never equals a built-in id (even with the same +// name), and so cannot collide with a current or future built-in. The +// type is the namespace -- no name prefix is needed. This holds only +// while custom modes stay typed; if one is ever reduced to a bare string +// (logs, persistence) that reintroduces a shared string space where a +// prefix would matter again. +// +// Equality split: the built-in values are const singletons relying on +// canonical-instance identity, which lets a connectionModes map of only +// built-in keys be a const map. A runtime-constructed custom variant must +// carry value equality, so an override map holding a custom key would be +// non-const. The built-in variant therefore must not override +// `==`/`hashCode`. + +/// Identifies a built-in connection mode whose data-source pipeline can be +/// overridden through [DataSystemConfig.connectionModes]: [streaming], +/// [polling], or [background]. +sealed class ConnectionModeId { + const ConnectionModeId(); + + /// The built-in streaming mode. + static const ConnectionModeId streaming = _BuiltInConnectionMode('streaming'); + + /// The built-in polling mode. + static const ConnectionModeId polling = _BuiltInConnectionMode('polling'); + + /// The built-in background mode. + static const ConnectionModeId background = + _BuiltInConnectionMode('background'); +} + +final class _BuiltInConnectionMode extends ConnectionModeId { + final String name; + + const _BuiltInConnectionMode(this.name); + + @override + String toString() => 'ConnectionModeId.$name'; +} + +/// Configuration for the FDv2 data system. +/// +/// Providing a [DataSystemConfig] (even an empty one) opts the SDK into +/// the FDv2 data acquisition protocol. When absent the SDK uses the +/// FDv1 data sources. +/// +/// This feature is not stable, and not subject to any backwards +/// compatibility guarantees or semantic versioning. It is in early +/// access. If you want access to this feature please join the EAP. +final class DataSystemConfig { + /// Overrides for built-in connection modes. A definition given here + /// replaces the built-in pipeline for that mode; modes not present keep + /// their built-in definition. + final Map connectionModes; + + const DataSystemConfig({ + this.connectionModes = const {}, + }); +} diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index ceab262e..49a20c11 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -92,6 +92,20 @@ final class DataSourceManager { _activeDataSource = null; } + void _completeIdentify(MessageStatus handled) { + if (handled == MessageStatus.messageHandled && _identifyCompleter != null) { + if (_identifyCompleter!.isCompleted) { + _logger.error('Identify was already complete before receiving ' + 'data. This could represent an issue with SDK logic. Please' + 'make a bug report if you encounter this situation.'); + } else { + _identifyCompleter!.complete(); + } + } + // Only need to complete this the first time. + _identifyCompleter = null; + } + DataSource? _createDataSource(FDv2ConnectionMode mode) { if (_activeContext != null) { if (_dataSourceFactories[mode] == null) { @@ -146,23 +160,14 @@ final class DataSourceManager { var handled = await _dataSourceEventHandler.handleMessage( _activeContext!, event.type, event.data, environmentId: event.environmentId); - if (handled == MessageStatus.messageHandled && - _identifyCompleter != null) { - if (_identifyCompleter!.isCompleted) { - _logger.error('Identify was already complete before receiving ' - 'data. This could represent an issue with SDK logic. Please' - 'make a bug report if you encounter this situation.'); - } else { - _identifyCompleter!.complete(); - } - } - // Only need to complete this the first time. - _identifyCompleter = null; + _completeIdentify(handled); return handled; case PayloadEvent(): - // The FDv1 data sources this manager runs never produce FDv2 - // payload events. - return MessageStatus.messageHandled; + var handled = await _dataSourceEventHandler.handlePayload( + _activeContext!, event.changeSet, + environmentId: event.environmentId); + _completeIdentify(handled); + return handled; case StatusEvent(): if (_identifyCompleter != null && !_identifyCompleter!.isCompleted) { _identifyCompleter!.completeError(Exception(event.message)); diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart new file mode 100644 index 00000000..1d6a9d02 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -0,0 +1,140 @@ +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; + +import '../../config/data_system_config.dart'; +import '../../config/service_endpoints.dart'; +import '../../fdv2_connection_mode.dart'; +import '../data_source_manager.dart'; +import '../data_source_status_manager.dart'; +import 'built_in_modes.dart'; +import 'entry_factories.dart'; +import 'mode_definition.dart'; +import 'orchestrator.dart'; +import 'requestor.dart'; +import 'selector.dart'; +import 'source_factory_context.dart'; +import 'source_manager.dart'; + +/// Composes the FDv2 data source factories consumed by the +/// DataSourceManager and owns the state that must outlive any single +/// orchestrator instance: the current selector and the context it +/// belongs to. +/// +/// A fresh orchestrator is created per connection-mode switch and per +/// identify. The selector survives mode switches (initializers are +/// skipped when a selector is held) but is reset whenever the context +/// changes, since a selector is specific to a single context. +final class FDv2DataSystem { + final String _credential; + final LDLogger _logger; + final HttpProperties _httpProperties; + final ServiceEndpoints _serviceEndpoints; + final bool _withReasons; + final Duration _defaultPollingInterval; + final DataSourceStatusManager _statusManager; + final Map _connectionModeOverrides; + final FDv2SseClientFactory _sseClientFactory; + final HttpClientFactory? _httpClientFactory; + + Selector _selector = Selector.empty; + LDContext? _lastContext; + + FDv2DataSystem({ + required DataSystemConfig config, + required String credential, + required LDLogger logger, + required HttpProperties httpProperties, + required ServiceEndpoints serviceEndpoints, + required bool withReasons, + required Duration defaultPollingInterval, + required DataSourceStatusManager statusManager, + FDv2SseClientFactory sseClientFactory = defaultSseClientFactory, + HttpClientFactory? httpClientFactory, + }) : _credential = credential, + _logger = logger, + _httpProperties = httpProperties, + _serviceEndpoints = serviceEndpoints, + _withReasons = withReasons, + _defaultPollingInterval = defaultPollingInterval, + _statusManager = statusManager, + _sseClientFactory = sseClientFactory, + _httpClientFactory = httpClientFactory, + _connectionModeOverrides = config.connectionModes; + + /// The definition for a built-in mode: the user's override if one was + /// given for it, otherwise the built-in default. + ModeDefinition _resolve(ConnectionModeId mode, ModeDefinition builtIn) => + _connectionModeOverrides[mode] ?? builtIn; + + /// Produces the factory map for the DataSourceManager. Offline carries + /// no factory; the manager handles offline without a data source. + Map buildFactories() { + return { + const FDv2Streaming(): _factoryForMode( + _resolve(ConnectionModeId.streaming, BuiltInModes.streaming)), + const FDv2Polling(): _factoryForMode( + _resolve(ConnectionModeId.polling, BuiltInModes.polling)), + const FDv2Background(): _factoryForMode( + _resolve(ConnectionModeId.background, BuiltInModes.background)), + }; + } + + DataSourceFactory _factoryForMode(ModeDefinition modeDefinition) { + return (LDContext context) { + if (!identical(context, _lastContext)) { + // A new identify produces a new decorated context instance; a + // mode switch re-uses the active one. The selector belongs to a + // single context and must not be reused across identifies. + _lastContext = context; + _selector = Selector.empty; + } + + final factoryContext = SourceFactoryContext.fromClientConfig( + context: context, + credential: _credential, + logger: _logger, + httpProperties: _httpProperties, + serviceEndpoints: _serviceEndpoints, + withReasons: _withReasons, + defaultPollingInterval: _defaultPollingInterval, + // The common client loads cached flags into the flag store before + // the data source starts (FlagManager.loadCached during identify), + // so the cache is already applied by the time this chain runs. + // Reporting a miss advances the chain without re-applying it. + cachedFlagsReader: (_) async => null, + httpClientFactory: _httpClientFactory, + ); + + // When a selector is held the SDK already has basis data for this + // context; mode switches go straight to synchronizers. + final includeInitializers = _selector.isEmpty; + final initializerFactories = includeInitializers + ? buildInitializerFactories( + modeDefinition.initializers, factoryContext) + : []; + + // The FDv1 fallback tier (modeDefinition.fdv1Fallback) is not built + // into a slot yet. When it is, mark that slot isFdv1Fallback and keep + // its source incapable of emitting a result with fdv1Fallback set: + // it is the terminal tier, so re-asserting the directive from there + // would drive the orchestrator to re-engage FDv1 fallback on every + // result, undelayed and blocking no slot. A source that cannot emit + // the directive is simpler than guarding the orchestrator against + // re-engaging while already on FDv1. + final synchronizerSlots = buildSynchronizerFactories( + modeDefinition.synchronizers, factoryContext, + sseClientFactory: _sseClientFactory) + .map((factory) => SynchronizerSlot(factory: factory)) + .toList(); + + return FDv2DataSourceOrchestrator( + initializerFactories: initializerFactories, + synchronizerSlots: synchronizerSlots, + selectorGetter: () => _selector, + selectorUpdater: (selector) => _selector = selector, + statusManager: _statusManager, + logger: _logger, + ); + }; + } +} diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index f13bf3c2..b1d4f47b 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -19,6 +19,7 @@ import 'hooks/hook_runner.dart'; import 'data_sources/data_source.dart'; import 'data_sources/data_source_event_handler.dart'; import 'data_sources/fdv2/built_in_modes.dart'; +import 'data_sources/fdv2/data_system.dart'; import 'data_sources/data_source_manager.dart'; import 'data_sources/data_source_status.dart'; import 'data_sources/data_source_status_manager.dart'; @@ -421,10 +422,26 @@ final class LDCommonClient { _updateEventSendingState(); if (!_config.offline) { - _dataSourceManager.setFactories(_composeFactoriesForManager( - fdv1Factories: _dataSourceFactories(_config, _logger, httpProperties), - backgroundFactory: _backgroundFactory(_config, _logger, httpProperties), - )); + if (_config.dataSystem case final dataSystemConfig?) { + final dataSystem = FDv2DataSystem( + config: dataSystemConfig, + credential: _config.sdkCredential, + logger: _logger, + httpProperties: httpProperties, + serviceEndpoints: _config.serviceEndpoints, + withReasons: _config.dataSourceConfig.evaluationReasons, + defaultPollingInterval: + _config.dataSourceConfig.polling.pollingInterval, + statusManager: _dataSourceStatusManager, + ); + _dataSourceManager.setFactories(dataSystem.buildFactories()); + } else { + _dataSourceManager.setFactories(_composeFactoriesForManager( + fdv1Factories: _dataSourceFactories(_config, _logger, httpProperties), + backgroundFactory: + _backgroundFactory(_config, _logger, httpProperties), + )); + } } else { DataSource nullSource(LDContext _) => NullDataSource(); _dataSourceManager.setFactories({ diff --git a/packages/common_client/lib/src/ld_common_config.dart b/packages/common_client/lib/src/ld_common_config.dart index e62aa295..e175c0c0 100644 --- a/packages/common_client/lib/src/ld_common_config.dart +++ b/packages/common_client/lib/src/ld_common_config.dart @@ -1,13 +1,15 @@ import 'dart:collection'; import 'dart:math'; -import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; import 'hooks/hook.dart'; +import 'config/data_system_config.dart'; import 'config/defaults/default_config.dart'; import 'config/events_config.dart'; import 'connection_mode.dart'; -import 'config/service_endpoints.dart' as client_endpoints; +import 'config/service_endpoints.dart'; /// Configuration which affects how the SDK uses persistence. final class PersistenceConfig { @@ -132,6 +134,16 @@ abstract class LDCommonConfig { /// An initial list of hooks. final UnmodifiableListView? hooks; + /// Configuration for the FDv2 data system. Providing this (even an + /// empty configuration) opts the SDK into the FDv2 data acquisition + /// protocol. + /// + /// This feature is not stable, and not subject to any backwards + /// compatibility guarantees or semantic versioning. It is in early + /// access. If you want access to this feature please join the EAP. + /// https://launchdarkly.com/docs/sdk/features/data-saving-mode + final DataSystemConfig? dataSystem; + LDCommonConfig(this.sdkCredential, this.autoEnvAttributes, {this.applicationInfo, HttpProperties? httpProperties, @@ -143,10 +155,10 @@ abstract class LDCommonConfig { DataSourceConfig? dataSourceConfig, bool? allAttributesPrivate, List? globalPrivateAttributes, - List? hooks}) + List? hooks, + this.dataSystem}) : httpProperties = httpProperties ?? HttpProperties(), - serviceEndpoints = - serviceEndpoints ?? client_endpoints.ServiceEndpoints(), + serviceEndpoints = serviceEndpoints ?? ServiceEndpoints(), events = events ?? EventsConfig(), persistence = persistence ?? PersistenceConfig(), offline = offline ?? DefaultConfig.defaultOffline, diff --git a/packages/common_client/test/data_sources/data_source_manager_test.dart b/packages/common_client/test/data_sources/data_source_manager_test.dart index e2d518da..47230d5e 100644 --- a/packages/common_client/test/data_sources/data_source_manager_test.dart +++ b/packages/common_client/test/data_sources/data_source_manager_test.dart @@ -6,23 +6,32 @@ import 'package:launchdarkly_common_client/src/data_sources/data_source_event_ha import 'package:launchdarkly_common_client/src/data_sources/data_source_manager.dart'; import 'package:launchdarkly_common_client/src/data_sources/data_source_status.dart'; import 'package:launchdarkly_common_client/src/data_sources/data_source_status_manager.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/payload.dart'; import 'package:launchdarkly_common_client/src/flag_manager/flag_manager.dart'; +import 'package:launchdarkly_common_client/src/item_descriptor.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; import 'package:test/test.dart'; final class MockDataSource implements DataSource { final StreamController controller = StreamController(); + final List _startEvents; bool startCalled = false; bool stopCalled = false; bool restartCalled = false; + MockDataSource({List? startEvents}) + : _startEvents = startEvents ?? [DataEvent('put', '{}')]; + @override Stream get events => controller.stream; @override void start() { startCalled = true; - controller.sink.add(DataEvent('put', '{}')); + for (final event in _startEvents) { + controller.sink.add(event); + } } @override @@ -112,6 +121,42 @@ void main() { )); }); + test('it applies an FDv2 payload event and completes identify', () async { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final context = LDContextBuilder().kind('user', 'bob').build(); + final changeSet = ChangeSet(type: PayloadType.full, updates: { + 'flag-a': ItemDescriptor( + version: 3, + flag: LDEvaluationResult( + version: 3, + detail: LDEvaluationDetail( + LDValue.ofBool(true), 0, LDEvaluationReason.off()), + ), + ), + }); + final factories = { + const FDv2Streaming(): (_) => + MockDataSource(startEvents: [PayloadEvent(changeSet)]), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + }; + final manager = + makeManager(context, factories, inStatusManager: statusManager); + + expectLater( + statusManager.changes, + emits(DataSourceStatus( + state: DataSourceState.valid, stateSince: DateTime(1)))); + + final completer = Completer(); + manager.identify(context, completer); + + // The payload reaches handlePayload, which applies the change set, + // marks the source valid, and completes the pending identify. (A + // dropped/no-op payload would leave the identify hanging.) + await completer.future; + }); + test('it can transition to offline and tear-down the previous connection', () { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); diff --git a/packages/common_client/test/data_sources/fdv2/data_system_test.dart b/packages/common_client/test/data_sources/fdv2/data_system_test.dart new file mode 100644 index 00000000..b8428f32 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/data_system_test.dart @@ -0,0 +1,85 @@ +import 'package:launchdarkly_common_client/src/config/data_system_config.dart'; +import 'package:launchdarkly_common_client/src/config/service_endpoints.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source_status_manager.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/built_in_modes.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/data_system.dart'; +import 'package:launchdarkly_common_client/src/fdv2_connection_mode.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; +import 'package:test/test.dart'; + +FDv2DataSystem makeDataSystem( + {DataSystemConfig config = const DataSystemConfig()}) => + FDv2DataSystem( + config: config, + credential: 'the-credential', + logger: LDLogger(level: LDLogLevel.none), + httpProperties: HttpProperties(), + serviceEndpoints: ServiceEndpoints(), + withReasons: false, + defaultPollingInterval: const Duration(seconds: 300), + statusManager: DataSourceStatusManager(), + ); + +LDContext _context() => LDContextBuilder().kind('user', 'bob').build(); + +void main() { + test('an empty data system config overrides no modes', () { + expect(const DataSystemConfig().connectionModes, isEmpty); + }); + + test('buildFactories exposes streaming, polling, and background', () { + final factories = makeDataSystem().buildFactories(); + + expect( + factories.keys, + containsAll([ + const FDv2Streaming(), + const FDv2Polling(), + const FDv2Background(), + ])); + expect(factories.containsKey(const FDv2Offline()), isFalse, + reason: 'offline has no data source; the manager handles it directly'); + }); + + test('a factory builds a data source, fresh on each call', () { + final factory = makeDataSystem().buildFactories()[const FDv2Streaming()]!; + final context = _context(); + + final first = factory(context); + final second = factory(context); + + expect(first, isA()); + expect(identical(first, second), isFalse, + reason: 'a fresh orchestrator is created per connection'); + + first.stop(); + second.stop(); + }); + + test('an override replaces a built-in mode definition', () { + // Override streaming with the polling definition; the streaming + // factory should still build a usable data source from it. + final factory = makeDataSystem( + config: const DataSystemConfig(connectionModes: { + ConnectionModeId.streaming: BuiltInModes.polling, + })).buildFactories()[const FDv2Streaming()]!; + + final source = factory(_context()); + expect(source, isA()); + source.stop(); + }); + + test('the override map is keyed only by built-in modes', () { + // ConnectionModeId is a sealed type whose only nameable values are the + // built-in modes, so a custom/arbitrary mode name cannot be expressed + // as a key. Providing an override for a built-in resolves; the others + // keep their built-in definitions. + const config = DataSystemConfig(connectionModes: { + ConnectionModeId.polling: BuiltInModes.streaming, + }); + final factories = makeDataSystem(config: config).buildFactories(); + expect(factories.keys, hasLength(3)); + }); +} From 6c00880e5533a116c50dc7e9d6eb097d32517852 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:18:35 -0700 Subject: [PATCH 02/20] feat: load the FDv2 cache through the pipeline and run offline as a data source Split the identify strategy into FDv1/FDv2 data managers so cache handling lives with each protocol. FDv2 no longer loads the cache at identify; the data system reads it through a real cachedFlagsReader and feeds it into the pipeline. A PayloadEvent basis flag, set by the orchestrator, gates both the valid status (moved out of handlePayload) and wait-for-network resolution, so cached flags apply without reporting a live connection. Offline is now a real pipeline mode (cache initializer, no synchronizer) that loads cache while the manager keeps the offline status. Adds ConnectionModeId.offline and FlagManager.readCached. --- .../lib/src/config/data_system_config.dart | 7 +- .../lib/src/data_sources/data_manager.dart | 70 +++++++++++ .../lib/src/data_sources/data_source.dart | 15 ++- .../data_source_event_handler.dart | 9 +- .../src/data_sources/data_source_manager.dart | 50 ++++++-- .../src/data_sources/fdv2/data_system.dart | 22 ++-- .../src/data_sources/fdv2/orchestrator.dart | 29 +++-- .../lib/src/flag_manager/flag_manager.dart | 8 ++ .../src/flag_manager/flag_persistence.dart | 36 ++++-- .../lib/src/ld_common_client.dart | 25 ++-- .../data_source_event_handler_test.dart | 12 +- .../data_source_manager_test.dart | 111 ++++++++++++++++++ .../data_sources/fdv2/data_system_test.dart | 9 +- .../data_sources/fdv2/orchestrator_test.dart | 45 +++++++ .../test/flag_persistence_test.dart | 57 +++++++++ 15 files changed, 444 insertions(+), 61 deletions(-) create mode 100644 packages/common_client/lib/src/data_sources/data_manager.dart diff --git a/packages/common_client/lib/src/config/data_system_config.dart b/packages/common_client/lib/src/config/data_system_config.dart index 334362e1..4a7a9e53 100644 --- a/packages/common_client/lib/src/config/data_system_config.dart +++ b/packages/common_client/lib/src/config/data_system_config.dart @@ -29,7 +29,7 @@ import '../data_sources/fdv2/mode_definition.dart'; /// Identifies a built-in connection mode whose data-source pipeline can be /// overridden through [DataSystemConfig.connectionModes]: [streaming], -/// [polling], or [background]. +/// [polling], [background], or [offline]. sealed class ConnectionModeId { const ConnectionModeId(); @@ -42,6 +42,11 @@ sealed class ConnectionModeId { /// The built-in background mode. static const ConnectionModeId background = _BuiltInConnectionMode('background'); + + /// The built-in offline mode. Its pipeline loads cached flags and runs + /// no synchronizer, so overriding it customizes how the SDK behaves + /// while offline (for example, the cache initializer it uses). + static const ConnectionModeId offline = _BuiltInConnectionMode('offline'); } final class _BuiltInConnectionMode extends ConnectionModeId { diff --git a/packages/common_client/lib/src/data_sources/data_manager.dart b/packages/common_client/lib/src/data_sources/data_manager.dart new file mode 100644 index 00000000..56df0635 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/data_manager.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + show LDContext; + +import '../flag_manager/flag_manager.dart'; +import 'data_source_manager.dart'; + +/// Owns the data-acquisition strategy for an identify: how the cache is +/// loaded and when the identify resolves. The FDv1 and FDv2 protocols +/// diverge here, so each has its own implementation; everything else +/// (connection lifecycle, mode switching, event routing) is shared in the +/// [DataSourceManager] that both delegate to. +abstract interface class DataManager { + /// Brings the SDK to a usable state for [context], resolving when the + /// manager's data-availability strategy is satisfied. + /// + /// When [waitForNetworkResults] is true the returned future resolves + /// only once network (or otherwise fresh) data has arrived; otherwise it + /// may resolve as soon as cached data is available. + Future identify(LDContext context, + {required bool waitForNetworkResults}); +} + +/// FDv1 data manager. +/// +/// The cache is loaded imperatively at identify time via +/// [FlagManager.loadCached]. A cache hit resolves identify immediately +/// unless the caller is waiting for network results; either way the +/// network connection is started so live data follows. +final class FDv1DataManager implements DataManager { + final DataSourceManager _dataSourceManager; + final FlagManager _flagManager; + + FDv1DataManager(this._dataSourceManager, this._flagManager); + + @override + Future identify(LDContext context, + {required bool waitForNetworkResults}) async { + final completer = Completer(); + final loadedFromCache = await _flagManager.loadCached(context); + _dataSourceManager.identify(context, completer); + if (loadedFromCache && !waitForNetworkResults) { + return; + } + return completer.future; + } +} + +/// FDv2 data manager. +/// +/// The cache is not loaded at identify time; the data source pipeline's +/// cache initializer loads it as the first tier. Identify resolves on the +/// first delivered payload, or -- when waiting for network results -- only +/// on basis (network or terminal) data, so a cache load alone does not +/// satisfy a wait-for-network identify. +final class FDv2DataManager implements DataManager { + final DataSourceManager _dataSourceManager; + + FDv2DataManager(this._dataSourceManager); + + @override + Future identify(LDContext context, + {required bool waitForNetworkResults}) { + final completer = Completer(); + _dataSourceManager.identify(context, completer, + requireFreshData: waitForNetworkResults); + return completer.future; + } +} diff --git a/packages/common_client/lib/src/data_sources/data_source.dart b/packages/common_client/lib/src/data_sources/data_source.dart index af0f4f0b..3c728708 100644 --- a/packages/common_client/lib/src/data_sources/data_source.dart +++ b/packages/common_client/lib/src/data_sources/data_source.dart @@ -18,7 +18,20 @@ final class PayloadEvent implements DataSourceEvent { final ChangeSet changeSet; final String? environmentId; - PayloadEvent(this.changeSet, {this.environmentId}); + /// Whether this payload represents the freshest data the active source + /// can produce for this initialization -- network basis data, an FDv1 + /// fallback transfer, or the terminal payload of a cache-only system. + /// + /// False only for preliminary cache data delivered while a fresher + /// source is still expected (e.g. the cache initializer ahead of a + /// streaming synchronizer). The manager uses this to decide whether to + /// mark the source valid and whether to resolve an identify that is + /// waiting for network results: cached flags are applied either way, + /// but a non-basis payload neither drives the status to valid nor + /// satisfies a wait-for-network identify. + final bool basis; + + PayloadEvent(this.changeSet, {this.environmentId, this.basis = true}); } final class StatusEvent implements DataSourceEvent { diff --git a/packages/common_client/lib/src/data_sources/data_source_event_handler.dart b/packages/common_client/lib/src/data_sources/data_source_event_handler.dart index c28daa77..634d9a96 100644 --- a/packages/common_client/lib/src/data_sources/data_source_event_handler.dart +++ b/packages/common_client/lib/src/data_sources/data_source_event_handler.dart @@ -101,14 +101,19 @@ final class DataSourceEventHandler { /// /// Full change sets replace the stored flags, partial change sets apply /// each update, and a change set of type none confirms the SDK is up to - /// date without changing data. All three mark the data source valid. + /// date without changing data. + /// + /// This does not touch the data source status. Unlike the FDv1 verbs, + /// an FDv2 payload only marks the source valid when it is network basis + /// data, and never while offline; the DataSourceManager makes that call + /// from the payload's basis flag and the active mode, so applying cached + /// flags here does not prematurely report valid. Future handlePayload(LDContext context, ChangeSet changeSet, {String? environmentId}) async { try { await _flagManager.applyChanges( context, changeSet.updates, changeSet.type, environmentId: environmentId); - _statusManager.setValid(); return MessageStatus.messageHandled; } catch (err) { _logger.error('Failed to apply an FDv2 change set: ${err.runtimeType}'); diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index 49a20c11..05e7f520 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -38,6 +38,11 @@ final class DataSourceManager { Completer? _identifyCompleter; + /// When true, the active identify resolves only on basis (network or + /// terminal) data, not on preliminary cache data. Set per identify from + /// the caller's wait-for-network-results preference. + bool _requireFreshData = false; + DataSourceManager({ ConnectionMode startingMode = ConnectionMode.streaming, required DataSourceStatusManager statusManager, @@ -61,8 +66,10 @@ final class DataSourceManager { _dataSourceFactories.addAll(factories); } - void identify(LDContext context, Completer completer) { + void identify(LDContext context, Completer completer, + {bool requireFreshData = false}) { _identifyCompleter = completer; + _requireFreshData = requireFreshData; _activeContext = context; _setupConnection(); @@ -92,15 +99,22 @@ final class DataSourceManager { _activeDataSource = null; } - void _completeIdentify(MessageStatus handled) { - if (handled == MessageStatus.messageHandled && _identifyCompleter != null) { - if (_identifyCompleter!.isCompleted) { - _logger.error('Identify was already complete before receiving ' - 'data. This could represent an issue with SDK logic. Please' - 'make a bug report if you encounter this situation.'); - } else { - _identifyCompleter!.complete(); - } + void _completeIdentify(MessageStatus handled, {bool basis = true}) { + if (handled != MessageStatus.messageHandled || _identifyCompleter == null) { + return; + } + // An identify waiting for network results resolves only on basis + // (network or terminal) data; preliminary cache data is applied but + // leaves the identify pending so a later basis payload resolves it. + if (_requireFreshData && !basis) { + return; + } + if (_identifyCompleter!.isCompleted) { + _logger.error('Identify was already complete before receiving ' + 'data. This could represent an issue with SDK logic. Please' + 'make a bug report if you encounter this situation.'); + } else { + _identifyCompleter!.complete(); } // Only need to complete this the first time. _identifyCompleter = null; @@ -132,6 +146,11 @@ final class DataSourceManager { switch (_activeConnectionMode) { case FDv2Offline(): + // Report why the SDK is offline. When an offline data source is + // configured (the FDv2 data system supplies one) it then loads + // cached flags through the pipeline below; its payload does not + // drive the status to valid while offline, so this status stands. + // FDv1 has no offline factory, so offline stays status-only. switch (_offlineDetail) { case OfflineSetOffline(): _statusManager.setOffline(); @@ -140,7 +159,6 @@ final class DataSourceManager { case OfflineBackgroundDisabled(): _statusManager.setBackgroundDisabled(); } - return; case FDv2Streaming(): case FDv2Polling(): case FDv2Background(): @@ -166,7 +184,15 @@ final class DataSourceManager { var handled = await _dataSourceEventHandler.handlePayload( _activeContext!, event.changeSet, environmentId: event.environmentId); - _completeIdentify(handled); + if (handled == MessageStatus.messageHandled && event.basis) { + // Basis data marks the source valid, except while offline: + // there the offline status set in _setupConnection stands and + // a cache load must not be reported as a live connection. + if (_activeConnectionMode is! FDv2Offline) { + _statusManager.setValid(); + } + } + _completeIdentify(handled, basis: event.basis); return handled; case StatusEvent(): if (_identifyCompleter != null && !_identifyCompleter!.isCompleted) { diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index 1d6a9d02..ca599c9a 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -7,6 +7,7 @@ import '../../fdv2_connection_mode.dart'; import '../data_source_manager.dart'; import '../data_source_status_manager.dart'; import 'built_in_modes.dart'; +import 'cache_initializer.dart'; import 'entry_factories.dart'; import 'mode_definition.dart'; import 'orchestrator.dart'; @@ -33,6 +34,7 @@ final class FDv2DataSystem { final Duration _defaultPollingInterval; final DataSourceStatusManager _statusManager; final Map _connectionModeOverrides; + final CachedFlagsReader _cachedFlagsReader; final FDv2SseClientFactory _sseClientFactory; final HttpClientFactory? _httpClientFactory; @@ -48,6 +50,7 @@ final class FDv2DataSystem { required bool withReasons, required Duration defaultPollingInterval, required DataSourceStatusManager statusManager, + required CachedFlagsReader cachedFlagsReader, FDv2SseClientFactory sseClientFactory = defaultSseClientFactory, HttpClientFactory? httpClientFactory, }) : _credential = credential, @@ -57,6 +60,7 @@ final class FDv2DataSystem { _withReasons = withReasons, _defaultPollingInterval = defaultPollingInterval, _statusManager = statusManager, + _cachedFlagsReader = cachedFlagsReader, _sseClientFactory = sseClientFactory, _httpClientFactory = httpClientFactory, _connectionModeOverrides = config.connectionModes; @@ -66,8 +70,11 @@ final class FDv2DataSystem { ModeDefinition _resolve(ConnectionModeId mode, ModeDefinition builtIn) => _connectionModeOverrides[mode] ?? builtIn; - /// Produces the factory map for the DataSourceManager. Offline carries - /// no factory; the manager handles offline without a data source. + /// Produces the factory map for the DataSourceManager. Offline is a + /// real pipeline mode: its data source runs the cache initializer with + /// no synchronizer, so the SDK serves cached flags while offline. The + /// manager reports the offline status itself; the offline source's + /// payload does not drive the status to valid. Map buildFactories() { return { const FDv2Streaming(): _factoryForMode( @@ -76,6 +83,8 @@ final class FDv2DataSystem { _resolve(ConnectionModeId.polling, BuiltInModes.polling)), const FDv2Background(): _factoryForMode( _resolve(ConnectionModeId.background, BuiltInModes.background)), + const FDv2Offline(): _factoryForMode( + _resolve(ConnectionModeId.offline, BuiltInModes.offline)), }; } @@ -97,11 +106,10 @@ final class FDv2DataSystem { serviceEndpoints: _serviceEndpoints, withReasons: _withReasons, defaultPollingInterval: _defaultPollingInterval, - // The common client loads cached flags into the flag store before - // the data source starts (FlagManager.loadCached during identify), - // so the cache is already applied by the time this chain runs. - // Reporting a miss advances the chain without re-applying it. - cachedFlagsReader: (_) async => null, + // The FDv2 data system owns cache loading: the cache initializer + // reads persistence through this reader and feeds the result into + // the pipeline, rather than the client applying it at identify. + cachedFlagsReader: _cachedFlagsReader, httpClientFactory: _httpClientFactory, ); diff --git a/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart b/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart index 2c3caf4b..c8611d2e 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart @@ -63,6 +63,11 @@ final class FDv2DataSourceOrchestrator implements DataSource { bool _closed = false; bool _emittedPayload = false; + /// True when the only sources are cache initializers (no synchronizers). + /// In such a system the cache load is the freshest data that will ever + /// arrive, so its payload is treated as basis rather than preliminary. + final bool _cacheOnlyDataSystem; + /// Resolves the outcome of the active synchronizer run. Set while a /// synchronizer is running; [restart] and [stop] use it to interrupt /// the run. @@ -86,6 +91,9 @@ final class FDv2DataSourceOrchestrator implements DataSource { _recoveryTimeout = recoveryTimeout, _recycleDelay = recycleDelay, _logger = logger.subLogger('FDv2Orchestrator'), + _cacheOnlyDataSystem = initializerFactories.isNotEmpty && + initializerFactories.every((f) => f.isCache) && + synchronizerSlots.isEmpty, _sourceManager = SourceManager( initializerFactories: initializerFactories, synchronizerSlots: synchronizerSlots, @@ -137,7 +145,7 @@ final class FDv2DataSourceOrchestrator implements DataSource { } } - void _emitPayload(ChangeSetResult result) { + void _emitPayload(ChangeSetResult result, {bool basis = true}) { if (_closed || _controller.isClosed) return; // An intent of "none" means the SDK is already up to date; it carries // no selector and must not regress the one we hold. For any other @@ -150,8 +158,8 @@ final class FDv2DataSourceOrchestrator implements DataSource { _selectorUpdater(result.changeSet.selector); } _emittedPayload = true; - _controller.add( - PayloadEvent(result.changeSet, environmentId: result.environmentId)); + _controller.add(PayloadEvent(result.changeSet, + environmentId: result.environmentId, basis: basis)); } void _reportTransientError(StatusResult result) { @@ -200,7 +208,15 @@ final class FDv2DataSourceOrchestrator implements DataSource { switch (result) { case ChangeSetResult(): if (result.changeSet.type != PayloadType.none) { - _emitPayload(result); + // Selector-bearing data is network basis; an FDv1 fallback + // transfer is fresh network data too; and in a cache-only + // system the cache load is the freshest data there will be. + // Otherwise this is preliminary cache data ahead of a + // synchronizer: applied, but not basis. + final isBasis = result.changeSet.selector.isNotEmpty || + result.fdv1Fallback || + _cacheOnlyDataSystem; + _emitPayload(result, basis: isBasis); if (_handleFdv1Fallback(result)) { // Data was received but the server directed FDv1 fallback; @@ -240,10 +256,7 @@ final class FDv2DataSourceOrchestrator implements DataSource { // miss -- there is nowhere else for data to come from. Emit an empty // payload so the pipeline reaches a valid state, unless an error has // already been reported. - final cacheOnlyDataSystem = _initializerFactories.isNotEmpty && - _initializerFactories.every((f) => f.isCache) && - _synchronizerSlots.isEmpty; - if (cacheOnlyDataSystem && !_emittedPayload && !errorDuringInit) { + if (_cacheOnlyDataSystem && !_emittedPayload && !errorDuringInit) { _emitPayload(const ChangeSetResult( changeSet: ChangeSet(type: PayloadType.none, updates: {}), persist: false, diff --git a/packages/common_client/lib/src/flag_manager/flag_manager.dart b/packages/common_client/lib/src/flag_manager/flag_manager.dart index 1e9c7973..381eb479 100644 --- a/packages/common_client/lib/src/flag_manager/flag_manager.dart +++ b/packages/common_client/lib/src/flag_manager/flag_manager.dart @@ -72,6 +72,14 @@ final class FlagManager { return _flagPersistence.loadCached(context); } + /// Reads cached values from persistence without applying them to the + /// store. Used by the FDv2 cache initializer, which loads the cache + /// through the data source pipeline rather than at identify time. + Future<({Map flags, String? environmentId})?> + readCached(LDContext context) async { + return _flagPersistence.readCached(context); + } + /// A broadcast stream which emits events as flag changes occur based either /// on loading cached values or updates from the data source. Stream get changes => _flagUpdater.changes; diff --git a/packages/common_client/lib/src/flag_manager/flag_persistence.dart b/packages/common_client/lib/src/flag_manager/flag_persistence.dart index 7ce0477c..e0ed7f68 100644 --- a/packages/common_client/lib/src/flag_manager/flag_persistence.dart +++ b/packages/common_client/lib/src/flag_manager/flag_persistence.dart @@ -81,12 +81,20 @@ final class FlagPersistence { return false; } - Future loadCached(LDContext context) async { + /// Reads the cached flag state for [context] from persistence without + /// applying it to the store. Returns null on a cache miss, an + /// unreadable entry, or a parse failure. + /// + /// The FDv2 data system loads the cache through its cache initializer + /// rather than the [loadCached] apply-at-identify path, so it needs the + /// parsed flags back rather than a side effect on the store. + Future<({Map flags, String? environmentId})?> + readCached(LDContext context) async { final json = await _persistence?.read( _environmentKey, encodePersistenceKey(context.canonicalKey)); if (json == null) { - return false; + return null; } final environmentId = await _persistence?.read(_environmentKey, _envIdKey); @@ -94,18 +102,26 @@ final class FlagPersistence { try { final flagConfig = LDEvaluationResultsSerialization.fromJson(jsonDecode(json)); - - _updater.initCached( - context, - flagConfig.map((key, value) => MapEntry( - key, ItemDescriptor(version: value.version, flag: value))), - environmentId: environmentId); - _logger.debug('Loaded a cached flag config from persistence.'); - return true; + return (flags: flagConfig, environmentId: environmentId); } catch (e) { _logger.warn('Could not load cached flag values for context: $e'); + return null; + } + } + + Future loadCached(LDContext context) async { + final cached = await readCached(context); + if (cached == null) { return false; } + + _updater.initCached( + context, + cached.flags.map((key, value) => + MapEntry(key, ItemDescriptor(version: value.version, flag: value))), + environmentId: cached.environmentId); + _logger.debug('Loaded a cached flag config from persistence.'); + return true; } Future _loadIndex() async { diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index b1d4f47b..21032d97 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -16,6 +16,7 @@ import 'context_modifiers/context_modifier.dart'; import 'context_modifiers/env_context_modifier.dart'; import 'hooks/hook.dart'; import 'hooks/hook_runner.dart'; +import 'data_sources/data_manager.dart'; import 'data_sources/data_source.dart'; import 'data_sources/data_source_event_handler.dart'; import 'data_sources/fdv2/built_in_modes.dart'; @@ -205,6 +206,11 @@ final class LDCommonClient { final CommonPlatform _platform; late final DataSourceManager _dataSourceManager; + + /// Owns the per-protocol identify strategy (cache load + resolution). + /// Selected from [_config.dataSystem]: FDv2 when a data system is + /// configured, otherwise FDv1. + late final DataManager _dataManager; late final EnvironmentReport _envReport; late final AsyncSingleQueue _identifyQueue = AsyncSingleQueue(); late final DataSourceFactoriesFn _dataSourceFactories; @@ -283,6 +289,11 @@ final class LDCommonClient { dataSourceEventHandler: dataSourceEventHandler, logger: _logger); + // FDv2 loads the cache through its pipeline; FDv1 loads it at identify. + _dataManager = _config.dataSystem != null + ? FDv2DataManager(_dataSourceManager) + : FDv1DataManager(_dataSourceManager, _flagManager); + if (_config.offline) { _dataSourceStatusManager.setOffline(); } @@ -433,6 +444,7 @@ final class LDCommonClient { defaultPollingInterval: _config.dataSourceConfig.polling.pollingInterval, statusManager: _dataSourceStatusManager, + cachedFlagsReader: _flagManager.readCached, ); _dataSourceManager.setFactories(dataSystem.buildFactories()); } else { @@ -550,19 +562,18 @@ final class LDCommonClient { final afterIdentify = _hookRunner.identify(_context); hookCallback(afterIdentify); - final completer = Completer(); _eventProcessor?.processIdentifyEvent(IdentifyEvent(context: _context)); - final loadedFromCache = await _flagManager.loadCached(_context); if (_config.offline) { + // Fully offline: there is no data source to run, so load the cache + // directly to serve flags. (Distinct from the offline connection + // mode, whose pipeline loads the cache for the FDv2 data system.) + await _flagManager.loadCached(_context); return; } - _dataSourceManager.identify(_context, completer); - if (loadedFromCache && !waitForNetworkResults) { - return; - } - return completer.future; + return _dataManager.identify(_context, + waitForNetworkResults: waitForNetworkResults); } /// Returns the value of flag [flagKey] for the current context as a bool. diff --git a/packages/common_client/test/data_sources/data_source_event_handler_test.dart b/packages/common_client/test/data_sources/data_source_event_handler_test.dart index 488a4f0c..e20a8ced 100644 --- a/packages/common_client/test/data_sources/data_source_event_handler_test.dart +++ b/packages/common_client/test/data_sources/data_source_event_handler_test.dart @@ -303,13 +303,7 @@ void main() { ), ); - test('a full change set replaces the stored flags and sets valid', - () async { - expectLater( - statusManager!.changes, - emits(DataSourceStatus( - state: DataSourceState.valid, stateSince: DateTime(2)))); - + test('a full change set replaces the stored flags', () async { await eventHandler!.handlePayload(context, ChangeSet(type: PayloadType.full, updates: {'flagA': flagEval(1)})); @@ -329,7 +323,7 @@ void main() { test( 'a partial change set applies updates without per-item version ' - 'comparison and sets valid', () async { + 'comparison', () async { await eventHandler!.handlePayload(context, ChangeSet(type: PayloadType.full, updates: {'flagA': flagEval(7)})); @@ -348,7 +342,7 @@ void main() { expect(updated.detail.value, LDValue.ofBool(false)); }); - test('a change set of none changes no data and sets valid', () async { + test('a change set of none changes no data', () async { await eventHandler!.handlePayload(context, ChangeSet(type: PayloadType.full, updates: {'flagA': flagEval(1)})); diff --git a/packages/common_client/test/data_sources/data_source_manager_test.dart b/packages/common_client/test/data_sources/data_source_manager_test.dart index 47230d5e..e91d7cd1 100644 --- a/packages/common_client/test/data_sources/data_source_manager_test.dart +++ b/packages/common_client/test/data_sources/data_source_manager_test.dart @@ -295,4 +295,115 @@ void main() { expect(createdDataSource.controller.hasListener, isTrue); expect(createdDataSource.restartCalled, isTrue); }); + + ChangeSet aChangeSet() => ChangeSet(type: PayloadType.full, updates: { + 'flag-a': ItemDescriptor( + version: 3, + flag: LDEvaluationResult( + version: 3, + detail: LDEvaluationDetail( + LDValue.ofBool(true), 0, LDEvaluationReason.off()), + ), + ), + }); + + test( + 'a non-basis payload applies data and resolves a cached identify ' + 'without marking the source valid', () async { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final context = LDContextBuilder().kind('user', 'bob').build(); + final factories = { + const FDv2Streaming(): (_) => MockDataSource( + startEvents: [PayloadEvent(aChangeSet(), basis: false)]), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + }; + final manager = + makeManager(context, factories, inStatusManager: statusManager); + + final completer = Completer(); + // requireFreshData defaults false: a cached identify resolves on the + // preliminary cache payload. + manager.identify(context, completer); + await completer.future; + + expect(statusManager.status.state, isNot(DataSourceState.valid), + reason: 'preliminary cache data must not report a live connection'); + }); + + test( + 'an identify requiring fresh data ignores a non-basis payload and ' + 'resolves on a basis payload, then marks valid', () async { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final context = LDContextBuilder().kind('user', 'bob').build(); + final factories = { + const FDv2Streaming(): (_) => MockDataSource(startEvents: [ + PayloadEvent(aChangeSet(), basis: false), + PayloadEvent(aChangeSet(), basis: true), + ]), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + }; + final manager = + makeManager(context, factories, inStatusManager: statusManager); + + final completer = Completer(); + manager.identify(context, completer, requireFreshData: true); + await completer.future; + + expect(statusManager.status.state, DataSourceState.valid); + }); + + test('an identify requiring fresh data does not resolve on cache alone', + () async { + final context = LDContextBuilder().kind('user', 'bob').build(); + final factories = { + const FDv2Streaming(): (_) => MockDataSource( + startEvents: [PayloadEvent(aChangeSet(), basis: false)]), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + }; + final manager = makeManager(context, factories); + + final completer = Completer(); + manager.identify(context, completer, requireFreshData: true); + await pumpEventQueue(); + + expect(completer.isCompleted, isFalse, + reason: + 'cache data alone must not satisfy a wait-for-network identify'); + }); + + test( + 'offline runs its data source to load cache but keeps the offline status', + () async { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final context = LDContextBuilder().kind('user', 'bob').build(); + var offlineStarted = false; + final factories = { + const FDv2Streaming(): (_) => MockDataSource(), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + const FDv2Offline(): (_) { + offlineStarted = true; + // A cache-only system: the cache load is the terminal (basis) + // payload, so the identify resolves -- but the manager must keep + // the offline status rather than report valid. + return MockDataSource( + startEvents: [PayloadEvent(aChangeSet(), basis: true)]); + }, + }; + final manager = + makeManager(context, factories, inStatusManager: statusManager); + + manager.setMode(const ResolvedOffline(OfflineSetOffline())); + final completer = Completer(); + manager.identify(context, completer); + await completer.future; + + expect(offlineStarted, isTrue, + reason: 'offline is a pipeline mode that runs its data source'); + expect(statusManager.status.state, DataSourceState.setOffline, + reason: 'a cache load while offline must not report valid'); + }); } diff --git a/packages/common_client/test/data_sources/fdv2/data_system_test.dart b/packages/common_client/test/data_sources/fdv2/data_system_test.dart index b8428f32..0348a2e7 100644 --- a/packages/common_client/test/data_sources/fdv2/data_system_test.dart +++ b/packages/common_client/test/data_sources/fdv2/data_system_test.dart @@ -20,6 +20,7 @@ FDv2DataSystem makeDataSystem( withReasons: false, defaultPollingInterval: const Duration(seconds: 300), statusManager: DataSourceStatusManager(), + cachedFlagsReader: (_) async => null, ); LDContext _context() => LDContextBuilder().kind('user', 'bob').build(); @@ -29,7 +30,8 @@ void main() { expect(const DataSystemConfig().connectionModes, isEmpty); }); - test('buildFactories exposes streaming, polling, and background', () { + test('buildFactories exposes streaming, polling, background, and offline', + () { final factories = makeDataSystem().buildFactories(); expect( @@ -38,9 +40,8 @@ void main() { const FDv2Streaming(), const FDv2Polling(), const FDv2Background(), + const FDv2Offline(), ])); - expect(factories.containsKey(const FDv2Offline()), isFalse, - reason: 'offline has no data source; the manager handles it directly'); }); test('a factory builds a data source, fresh on each call', () { @@ -80,6 +81,6 @@ void main() { ConnectionModeId.polling: BuiltInModes.streaming, }); final factories = makeDataSystem(config: config).buildFactories(); - expect(factories.keys, hasLength(3)); + expect(factories.keys, hasLength(4)); }); } diff --git a/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart b/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart index 59a5589d..88a1ca23 100644 --- a/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart +++ b/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart @@ -194,6 +194,51 @@ void main() { harness.orchestrator.stop(); }); + test('tags preliminary cache data as non-basis and network data as basis', + () async { + final synchronizers = []; + final harness = Harness(initializerFactories: [ + // A cache hit (full data, no selector) ahead of a synchronizer. + initializerFactory(changeSet(type: PayloadType.full), isCache: true), + ], synchronizerSlots: [ + synchronizerSlot(synchronizers), + ]); + + harness.orchestrator.start(); + await harness.pump(); + + final afterCache = harness.events.whereType().toList(); + expect(afterCache, hasLength(1)); + expect(afterCache.single.basis, isFalse, + reason: 'cache data ahead of a synchronizer is preliminary'); + + synchronizers.single.controller + .add(changeSet(selector: const Selector(state: 'state-1', version: 1))); + await harness.pump(); + + final all = harness.events.whereType().toList(); + expect(all, hasLength(2)); + expect(all.last.basis, isTrue, reason: 'network data is basis'); + + harness.orchestrator.stop(); + }); + + test('a cache-only system tags the cache load as basis', () async { + final harness = Harness(initializerFactories: [ + initializerFactory(changeSet(type: PayloadType.full), isCache: true), + ], synchronizerSlots: []); + + harness.orchestrator.start(); + await harness.pump(); + + final payloads = harness.events.whereType().toList(); + expect(payloads, hasLength(1)); + expect(payloads.single.basis, isTrue, + reason: 'with no fresher source, the cache load is the basis'); + + harness.orchestrator.stop(); + }); + test('synchronizer change sets are emitted and update the selector', () async { final synchronizers = []; diff --git a/packages/common_client/test/flag_persistence_test.dart b/packages/common_client/test/flag_persistence_test.dart index 12c8e528..470e7e8c 100644 --- a/packages/common_client/test/flag_persistence_test.dart +++ b/packages/common_client/test/flag_persistence_test.dart @@ -372,6 +372,63 @@ void main() { expect(flagStore.get('flagB'), basicData['flagB']); }); + test('readCached returns parsed flags without applying them', () async { + final context = LDContextBuilder().kind('user', 'user-key').build(); + final contextPersistenceKey = + sha256.convert(utf8.encode(context.canonicalKey)).toString(); + + final flagStore = FlagStore(); + final mockPersistence = MockPersistence(); + + mockPersistence.storage[sdkKeyPersistence] = { + contextPersistenceKey: '{"flagA":{' + '"version":1,' + '"value":"test",' + '"variation":0,' + '"reason":{"kind":"OFF"}' + '},' + '"flagB":{' + '"version":2,' + '"value":"test2",' + '"variation":1,' + '"reason":{"kind":"TARGET_MATCH"}' + '}}', + }; + + final flagPersistence = FlagPersistence( + persistence: mockPersistence, + updater: FlagUpdater(flagStore: flagStore, logger: logger), + store: flagStore, + sdkKey: sdkKey, + maxCachedContexts: 5, + logger: logger, + stamper: () => DateTime.fromMillisecondsSinceEpoch(0)); + + final cached = await flagPersistence.readCached(context); + + expect(cached, isNotNull); + expect(cached!.flags.keys, containsAll(['flagA', 'flagB'])); + expect(flagStore.getAll(), isEmpty, + reason: 'readCached must not apply to the store'); + }); + + test('readCached returns null on a cache miss', () async { + final context = LDContextBuilder().kind('user', 'user-key').build(); + final flagStore = FlagStore(); + final mockPersistence = MockPersistence(); + + final flagPersistence = FlagPersistence( + persistence: mockPersistence, + updater: FlagUpdater(flagStore: flagStore, logger: logger), + store: flagStore, + sdkKey: sdkKey, + maxCachedContexts: 5, + logger: logger, + stamper: () => DateTime.fromMillisecondsSinceEpoch(0)); + + expect(await flagPersistence.readCached(context), isNull); + }); + test('it can handle a corrupt cached flag payload', () async { final context = LDContextBuilder().kind('user', 'user-key').build(); final contextPersistenceKey = From 7f96be53dedaad4842d2f46ccb0e24f1f2a7d9c3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:00:37 -0700 Subject: [PATCH 03/20] refactor: reset the FDv2 basis at identify, not in the data source factory The held selector was discarded inside the data source factory by comparing context instance identity, which only held while the factory was invoked for every context change. Move the reset to the data manager's identify path, keyed on the context canonical key, so a context change clears the basis regardless of the active connection mode (including offline) and independent of factory invocation timing. The factory no longer tracks the context. --- .../lib/src/data_sources/data_manager.dart | 17 ++++- .../src/data_sources/fdv2/data_system.dart | 29 ++++---- .../lib/src/ld_common_client.dart | 14 ++-- .../test/data_sources/data_manager_test.dart | 71 +++++++++++++++++++ 4 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 packages/common_client/test/data_sources/data_manager_test.dart diff --git a/packages/common_client/lib/src/data_sources/data_manager.dart b/packages/common_client/lib/src/data_sources/data_manager.dart index 56df0635..8ebcdee8 100644 --- a/packages/common_client/lib/src/data_sources/data_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_manager.dart @@ -54,14 +54,29 @@ final class FDv1DataManager implements DataManager { /// first delivered payload, or -- when waiting for network results -- only /// on basis (network or terminal) data, so a cache load alone does not /// satisfy a wait-for-network identify. +/// +/// Identify is also where a held basis is discarded: a selector points at +/// one context's data, so on a context change [resetBasis] is invoked +/// before connecting. This is keyed on the context's canonical key and +/// driven here rather than inferred from the context instance inside the +/// data source factory, so it holds regardless of which connection mode is +/// active when the context changes (including offline). final class FDv2DataManager implements DataManager { final DataSourceManager _dataSourceManager; + final void Function() _resetBasis; + + String? _lastContextKey; - FDv2DataManager(this._dataSourceManager); + FDv2DataManager(this._dataSourceManager, this._resetBasis); @override Future identify(LDContext context, {required bool waitForNetworkResults}) { + final key = context.canonicalKey; + if (key != _lastContextKey) { + _lastContextKey = key; + _resetBasis(); + } final completer = Completer(); _dataSourceManager.identify(context, completer, requireFreshData: waitForNetworkResults); diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index ca599c9a..85ec2a2f 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -17,14 +17,16 @@ import 'source_factory_context.dart'; import 'source_manager.dart'; /// Composes the FDv2 data source factories consumed by the -/// DataSourceManager and owns the state that must outlive any single -/// orchestrator instance: the current selector and the context it -/// belongs to. +/// DataSourceManager and owns the selector, which must outlive any single +/// orchestrator instance. /// /// A fresh orchestrator is created per connection-mode switch and per /// identify. The selector survives mode switches (initializers are -/// skipped when a selector is held) but is reset whenever the context -/// changes, since a selector is specific to a single context. +/// skipped when a selector is held). It is specific to a single context, +/// so it must be reset on a context change; that is driven explicitly by +/// the data manager via [clearSelector] at identify time rather than +/// inferred here from the context instance, which depends on the factory +/// being invoked for every change. final class FDv2DataSystem { final String _credential; final LDLogger _logger; @@ -39,7 +41,6 @@ final class FDv2DataSystem { final HttpClientFactory? _httpClientFactory; Selector _selector = Selector.empty; - LDContext? _lastContext; FDv2DataSystem({ required DataSystemConfig config, @@ -70,6 +71,14 @@ final class FDv2DataSystem { ModeDefinition _resolve(ConnectionModeId mode, ModeDefinition builtIn) => _connectionModeOverrides[mode] ?? builtIn; + /// Discards the held selector so the next source rebuilds a basis from + /// its initializers. Called when identifying a new context, since a + /// selector points at one context's data and cannot seed a delta for + /// another. Mode switches keep the selector and so do not call this. + void clearSelector() { + _selector = Selector.empty; + } + /// Produces the factory map for the DataSourceManager. Offline is a /// real pipeline mode: its data source runs the cache initializer with /// no synchronizer, so the SDK serves cached flags while offline. The @@ -90,14 +99,6 @@ final class FDv2DataSystem { DataSourceFactory _factoryForMode(ModeDefinition modeDefinition) { return (LDContext context) { - if (!identical(context, _lastContext)) { - // A new identify produces a new decorated context instance; a - // mode switch re-uses the active one. The selector belongs to a - // single context and must not be reused across identifies. - _lastContext = context; - _selector = Selector.empty; - } - final factoryContext = SourceFactoryContext.fromClientConfig( context: context, credential: _credential, diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index 21032d97..cbd549b1 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -289,11 +289,6 @@ final class LDCommonClient { dataSourceEventHandler: dataSourceEventHandler, logger: _logger); - // FDv2 loads the cache through its pipeline; FDv1 loads it at identify. - _dataManager = _config.dataSystem != null - ? FDv2DataManager(_dataSourceManager) - : FDv1DataManager(_dataSourceManager, _flagManager); - if (_config.offline) { _dataSourceStatusManager.setOffline(); } @@ -447,12 +442,18 @@ final class LDCommonClient { cachedFlagsReader: _flagManager.readCached, ); _dataSourceManager.setFactories(dataSystem.buildFactories()); + // FDv2 loads the cache through its pipeline and resets the held + // basis on a context change (clearSelector). + _dataManager = + FDv2DataManager(_dataSourceManager, dataSystem.clearSelector); } else { _dataSourceManager.setFactories(_composeFactoriesForManager( fdv1Factories: _dataSourceFactories(_config, _logger, httpProperties), backgroundFactory: _backgroundFactory(_config, _logger, httpProperties), )); + // FDv1 loads the cache imperatively at identify. + _dataManager = FDv1DataManager(_dataSourceManager, _flagManager); } } else { DataSource nullSource(LDContext _) => NullDataSource(); @@ -461,6 +462,9 @@ final class LDCommonClient { const FDv2Polling(): nullSource, const FDv2Background(): nullSource, }); + // Fully offline serves cached flags directly at identify; the data + // manager is not exercised, but assign one so the field is set. + _dataManager = FDv1DataManager(_dataSourceManager, _flagManager); } } diff --git a/packages/common_client/test/data_sources/data_manager_test.dart b/packages/common_client/test/data_sources/data_manager_test.dart new file mode 100644 index 00000000..4703752f --- /dev/null +++ b/packages/common_client/test/data_sources/data_manager_test.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:launchdarkly_common_client/src/data_sources/data_manager.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source_event_handler.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source_manager.dart'; +import 'package:launchdarkly_common_client/src/data_sources/data_source_status_manager.dart'; +import 'package:launchdarkly_common_client/src/flag_manager/flag_manager.dart'; +import 'package:launchdarkly_common_client/src/offline_detail.dart'; +import 'package:launchdarkly_common_client/src/resolved_connection_mode.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; +import 'package:test/test.dart'; + +/// A data source manager with no factories. Its identify is a no-op +/// connection-wise (no factory builds a source), which is all these tests +/// need: they exercise the data manager's own logic, not the connection. +DataSourceManager _managerWithoutFactories() { + final logger = LDLogger(level: LDLogLevel.none); + final statusManager = DataSourceStatusManager(); + return DataSourceManager( + statusManager: statusManager, + dataSourceEventHandler: DataSourceEventHandler( + flagManager: FlagManager( + sdkKey: 'sdk-key', maxCachedContexts: 5, logger: logger), + statusManager: statusManager, + logger: logger), + logger: logger, + ); +} + +LDContext _ctx(String key) => LDContextBuilder().kind('user', key).build(); + +void main() { + group('FDv2DataManager', () { + test('resets the basis on a context change but not when it repeats', () { + var resets = 0; + final manager = + FDv2DataManager(_managerWithoutFactories(), () => resets++); + + // The returned futures never complete (no factory delivers data); we + // only care that the basis-reset decision fires correctly. + unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); + unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); + unawaited(manager.identify(_ctx('b'), waitForNetworkResults: false)); + unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); + + // a (first), b, a-again -> 3 resets. The repeated 'a' keeps its basis. + expect(resets, 3); + }); + + test( + 'resets the basis on a context change regardless of intervening mode ' + 'switches', () { + // The reset is driven at identify time, not by the data source factory, + // so a mode switch between identifies (e.g. going offline) cannot leave + // a stale basis behind for the next context. Mode switches go through + // DataSourceManager.setMode and never reach this manager, so they do not + // reset the basis themselves. + var resets = 0; + final dataSourceManager = _managerWithoutFactories(); + final manager = FDv2DataManager(dataSourceManager, () => resets++); + + unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); + dataSourceManager.setMode(const ResolvedOffline(OfflineSetOffline())); + unawaited(manager.identify(_ctx('b'), waitForNetworkResults: false)); + dataSourceManager.setMode(const ResolvedStreaming()); + + // Reset fired for 'a' and 'b'; the offline/online switches did not. + expect(resets, 2); + }); + }); +} From 03fa4eb547026716ed71bf0b802d1cec8633eea4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:40:12 -0700 Subject: [PATCH 04/20] test: verify the FDv2 mode override is selected at the resolution layer The override test asserted only that a DataSource was produced, which holds whether or not the override is honored. Assert that resolvedDefinition picks the override for the overridden mode and the built-in otherwise. Translating a definition's entries into concrete sources stays covered by the entry-factory tests. Adds a visibleForTesting accessor and the meta dependency it needs. --- .../src/data_sources/fdv2/data_system.dart | 43 +++++++++++++------ packages/common_client/pubspec.yaml | 1 + .../data_sources/fdv2/data_system_test.dart | 23 ++++++---- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index 85ec2a2f..f05917b6 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -1,5 +1,6 @@ import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' hide ServiceEndpoints; +import 'package:meta/meta.dart'; import '../../config/data_system_config.dart'; import '../../config/service_endpoints.dart'; @@ -66,10 +67,30 @@ final class FDv2DataSystem { _httpClientFactory = httpClientFactory, _connectionModeOverrides = config.connectionModes; - /// The definition for a built-in mode: the user's override if one was - /// given for it, otherwise the built-in default. - ModeDefinition _resolve(ConnectionModeId mode, ModeDefinition builtIn) => - _connectionModeOverrides[mode] ?? builtIn; + /// The built-in definition for each connection mode, before any override. + static const Map _builtInDefinitions = { + ConnectionModeId.streaming: BuiltInModes.streaming, + ConnectionModeId.polling: BuiltInModes.polling, + ConnectionModeId.background: BuiltInModes.background, + ConnectionModeId.offline: BuiltInModes.offline, + }; + + /// The definition for [mode]: the user's override if one was given for + /// it, otherwise the built-in default. + ModeDefinition _resolve(ConnectionModeId mode) { + if (_builtInDefinitions[mode] case final builtIn?) { + return _connectionModeOverrides[mode] ?? builtIn; + } + // Unreachable: ConnectionModeId is sealed over the built-in modes, each + // of which has an entry above. + throw StateError('No built-in definition for connection mode: $mode'); + } + + /// The resolved definition for [mode], exposed so tests can confirm that + /// an override is selected over the built-in. How a definition's entries + /// become concrete data sources is covered by the entry-factory tests. + @visibleForTesting + ModeDefinition resolvedDefinition(ConnectionModeId mode) => _resolve(mode); /// Discards the held selector so the next source rebuilds a basis from /// its initializers. Called when identifying a new context, since a @@ -86,14 +107,12 @@ final class FDv2DataSystem { /// payload does not drive the status to valid. Map buildFactories() { return { - const FDv2Streaming(): _factoryForMode( - _resolve(ConnectionModeId.streaming, BuiltInModes.streaming)), - const FDv2Polling(): _factoryForMode( - _resolve(ConnectionModeId.polling, BuiltInModes.polling)), - const FDv2Background(): _factoryForMode( - _resolve(ConnectionModeId.background, BuiltInModes.background)), - const FDv2Offline(): _factoryForMode( - _resolve(ConnectionModeId.offline, BuiltInModes.offline)), + const FDv2Streaming(): + _factoryForMode(_resolve(ConnectionModeId.streaming)), + const FDv2Polling(): _factoryForMode(_resolve(ConnectionModeId.polling)), + const FDv2Background(): + _factoryForMode(_resolve(ConnectionModeId.background)), + const FDv2Offline(): _factoryForMode(_resolve(ConnectionModeId.offline)), }; } diff --git a/packages/common_client/pubspec.yaml b/packages/common_client/pubspec.yaml index 1242bf51..a332af05 100644 --- a/packages/common_client/pubspec.yaml +++ b/packages/common_client/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: launchdarkly_dart_common: 1.8.1 launchdarkly_event_source_client: 2.2.0 crypto: ^3.0.3 + meta: ^1.16.0 uuid: ">= 3.0.7 <5.0.0" dev_dependencies: diff --git a/packages/common_client/test/data_sources/fdv2/data_system_test.dart b/packages/common_client/test/data_sources/fdv2/data_system_test.dart index 0348a2e7..3f3981c4 100644 --- a/packages/common_client/test/data_sources/fdv2/data_system_test.dart +++ b/packages/common_client/test/data_sources/fdv2/data_system_test.dart @@ -59,17 +59,24 @@ void main() { second.stop(); }); - test('an override replaces a built-in mode definition', () { - // Override streaming with the polling definition; the streaming - // factory should still build a usable data source from it. - final factory = makeDataSystem( + test('an override is selected over the built-in for that mode', () { + // The data system's job here is resolution: the overridden mode uses + // the override definition, others keep their built-in. Translating a + // definition's entries into concrete sources (e.g. that the polling + // definition yields a polling source) is covered by entry_factories. + final dataSystem = makeDataSystem( config: const DataSystemConfig(connectionModes: { ConnectionModeId.streaming: BuiltInModes.polling, - })).buildFactories()[const FDv2Streaming()]!; + })); - final source = factory(_context()); - expect(source, isA()); - source.stop(); + expect(dataSystem.resolvedDefinition(ConnectionModeId.streaming), + same(BuiltInModes.polling), + reason: 'the override replaces the built-in streaming definition'); + expect(dataSystem.resolvedDefinition(ConnectionModeId.polling), + same(BuiltInModes.polling), + reason: 'an un-overridden mode keeps its built-in'); + expect(dataSystem.resolvedDefinition(ConnectionModeId.offline), + same(BuiltInModes.offline)); }); test('the override map is keyed only by built-in modes', () { From beda26363215fa3dd8aaf9b378671b8d5e366eb7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:12:49 -0700 Subject: [PATCH 05/20] fix: lower the meta constraint to 1.12.0 for the older Flutter toolchain Flutter 3.22.3 pins meta to 1.12.0 through its SDK dependency (via connectivity_plus), so a ^1.16.0 floor broke workspace version solving on that toolchain while passing on the newer one. visibleForTesting has been in meta since well before 1.12.0. --- packages/common_client/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common_client/pubspec.yaml b/packages/common_client/pubspec.yaml index a332af05..7efc6d8b 100644 --- a/packages/common_client/pubspec.yaml +++ b/packages/common_client/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: launchdarkly_dart_common: 1.8.1 launchdarkly_event_source_client: 2.2.0 crypto: ^3.0.3 - meta: ^1.16.0 + meta: ^1.12.0 uuid: ">= 3.0.7 <5.0.0" dev_dependencies: From 99aa536eb3a29e835dcb442f430c797ebda3c666 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:47:21 -0700 Subject: [PATCH 06/20] refactor: drive identify completion and validity from changeset type and selector Replaces the derived PayloadEvent.basis flag with the explicit signals it stood for. A cache load -- a full changeset with no selector -- is applied and resolves a cached identify, but does not mark the source valid or satisfy a wait-for-network identify. Network data carries a server selector and does both. An intent-none, or being offline, resolves a wait-for-network identify since no selector will arrive. This mirrors js-core's FDv2DataManagerBase minimum-data-availability handling. The wire-level basis query parameter is unaffected. --- .../lib/src/data_sources/data_manager.dart | 14 ++-- .../lib/src/data_sources/data_source.dart | 15 +--- .../data_source_event_handler.dart | 10 +-- .../src/data_sources/data_source_manager.dart | 47 +++++++----- .../src/data_sources/fdv2/data_system.dart | 10 +-- .../src/data_sources/fdv2/orchestrator.dart | 33 ++++----- .../lib/src/ld_common_client.dart | 4 +- .../test/data_sources/data_manager_test.dart | 30 ++++---- .../data_source_manager_test.dart | 71 +++++++++++-------- .../data_sources/fdv2/orchestrator_test.dart | 42 ++++++----- 10 files changed, 143 insertions(+), 133 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_manager.dart b/packages/common_client/lib/src/data_sources/data_manager.dart index 8ebcdee8..5fee4ef1 100644 --- a/packages/common_client/lib/src/data_sources/data_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_manager.dart @@ -52,22 +52,22 @@ final class FDv1DataManager implements DataManager { /// The cache is not loaded at identify time; the data source pipeline's /// cache initializer loads it as the first tier. Identify resolves on the /// first delivered payload, or -- when waiting for network results -- only -/// on basis (network or terminal) data, so a cache load alone does not -/// satisfy a wait-for-network identify. +/// on fresh data, so a cache load alone does not satisfy a wait-for-network +/// identify. /// -/// Identify is also where a held basis is discarded: a selector points at -/// one context's data, so on a context change [resetBasis] is invoked +/// Identify is also where a held selector is discarded: a selector points +/// at one context's data, so on a context change [clearSelector] is invoked /// before connecting. This is keyed on the context's canonical key and /// driven here rather than inferred from the context instance inside the /// data source factory, so it holds regardless of which connection mode is /// active when the context changes (including offline). final class FDv2DataManager implements DataManager { final DataSourceManager _dataSourceManager; - final void Function() _resetBasis; + final void Function() _clearSelector; String? _lastContextKey; - FDv2DataManager(this._dataSourceManager, this._resetBasis); + FDv2DataManager(this._dataSourceManager, this._clearSelector); @override Future identify(LDContext context, @@ -75,7 +75,7 @@ final class FDv2DataManager implements DataManager { final key = context.canonicalKey; if (key != _lastContextKey) { _lastContextKey = key; - _resetBasis(); + _clearSelector(); } final completer = Completer(); _dataSourceManager.identify(context, completer, diff --git a/packages/common_client/lib/src/data_sources/data_source.dart b/packages/common_client/lib/src/data_sources/data_source.dart index 3c728708..af0f4f0b 100644 --- a/packages/common_client/lib/src/data_sources/data_source.dart +++ b/packages/common_client/lib/src/data_sources/data_source.dart @@ -18,20 +18,7 @@ final class PayloadEvent implements DataSourceEvent { final ChangeSet changeSet; final String? environmentId; - /// Whether this payload represents the freshest data the active source - /// can produce for this initialization -- network basis data, an FDv1 - /// fallback transfer, or the terminal payload of a cache-only system. - /// - /// False only for preliminary cache data delivered while a fresher - /// source is still expected (e.g. the cache initializer ahead of a - /// streaming synchronizer). The manager uses this to decide whether to - /// mark the source valid and whether to resolve an identify that is - /// waiting for network results: cached flags are applied either way, - /// but a non-basis payload neither drives the status to valid nor - /// satisfies a wait-for-network identify. - final bool basis; - - PayloadEvent(this.changeSet, {this.environmentId, this.basis = true}); + PayloadEvent(this.changeSet, {this.environmentId}); } final class StatusEvent implements DataSourceEvent { diff --git a/packages/common_client/lib/src/data_sources/data_source_event_handler.dart b/packages/common_client/lib/src/data_sources/data_source_event_handler.dart index 634d9a96..a012a05e 100644 --- a/packages/common_client/lib/src/data_sources/data_source_event_handler.dart +++ b/packages/common_client/lib/src/data_sources/data_source_event_handler.dart @@ -103,11 +103,11 @@ final class DataSourceEventHandler { /// each update, and a change set of type none confirms the SDK is up to /// date without changing data. /// - /// This does not touch the data source status. Unlike the FDv1 verbs, - /// an FDv2 payload only marks the source valid when it is network basis - /// data, and never while offline; the DataSourceManager makes that call - /// from the payload's basis flag and the active mode, so applying cached - /// flags here does not prematurely report valid. + /// This does not touch the data source status. Unlike the FDv1 verbs, an + /// FDv2 payload only marks the source valid when it carries a server + /// selector (network data), and never while offline; the DataSourceManager + /// makes that call from the change set's selector and the active mode, so + /// applying cached flags here does not prematurely report valid. Future handlePayload(LDContext context, ChangeSet changeSet, {String? environmentId}) async { try { diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index 05e7f520..e4767cbc 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -10,6 +10,7 @@ import '../resolved_connection_mode.dart'; import 'data_source.dart'; import 'data_source_event_handler.dart'; import 'data_source_status_manager.dart'; +import 'fdv2/payload.dart'; typedef DataSourceFactory = DataSource Function(LDContext context); @@ -38,9 +39,9 @@ final class DataSourceManager { Completer? _identifyCompleter; - /// When true, the active identify resolves only on basis (network or - /// terminal) data, not on preliminary cache data. Set per identify from - /// the caller's wait-for-network-results preference. + /// When true, the active identify resolves only on fresh data, not on a + /// cache load. Set per identify from the caller's wait-for-network-results + /// preference. bool _requireFreshData = false; DataSourceManager({ @@ -99,15 +100,23 @@ final class DataSourceManager { _activeDataSource = null; } - void _completeIdentify(MessageStatus handled, {bool basis = true}) { + void _completeIdentify(MessageStatus handled, ChangeSet? changeSet, + {bool offline = false}) { if (handled != MessageStatus.messageHandled || _identifyCompleter == null) { return; } - // An identify waiting for network results resolves only on basis - // (network or terminal) data; preliminary cache data is applied but - // leaves the identify pending so a later basis payload resolves it. - if (_requireFreshData && !basis) { - return; + // An identify waiting for network results resolves only on fresh data: a + // payload carrying a server selector, or an intent-none (the server + // confirming the SDK is up to date). A cache load has neither, so it is + // applied but leaves the identify pending until network data arrives. + // Offline can produce neither, so it resolves on whatever data is + // available; FDv1 passes no change set and never waits for fresh data. + if (_requireFreshData && !offline) { + final fresh = changeSet != null && + (changeSet.selector.isNotEmpty || changeSet.type == PayloadType.none); + if (!fresh) { + return; + } } if (_identifyCompleter!.isCompleted) { _logger.error('Identify was already complete before receiving ' @@ -178,21 +187,23 @@ final class DataSourceManager { var handled = await _dataSourceEventHandler.handleMessage( _activeContext!, event.type, event.data, environmentId: event.environmentId); - _completeIdentify(handled); + _completeIdentify(handled, null); return handled; case PayloadEvent(): var handled = await _dataSourceEventHandler.handlePayload( _activeContext!, event.changeSet, environmentId: event.environmentId); - if (handled == MessageStatus.messageHandled && event.basis) { - // Basis data marks the source valid, except while offline: - // there the offline status set in _setupConnection stands and - // a cache load must not be reported as a live connection. - if (_activeConnectionMode is! FDv2Offline) { - _statusManager.setValid(); - } + final offline = _activeConnectionMode is FDv2Offline; + if (handled == MessageStatus.messageHandled && + event.changeSet.selector.isNotEmpty && + !offline) { + // A server selector means this is network data, which marks the + // source valid. Cached data has no selector, and while offline + // the status set in _setupConnection stands, so neither reports a + // live connection. + _statusManager.setValid(); } - _completeIdentify(handled, basis: event.basis); + _completeIdentify(handled, event.changeSet, offline: offline); return handled; case StatusEvent(): if (_identifyCompleter != null && !_identifyCompleter!.isCompleted) { diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index f05917b6..d19bab9a 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -92,10 +92,10 @@ final class FDv2DataSystem { @visibleForTesting ModeDefinition resolvedDefinition(ConnectionModeId mode) => _resolve(mode); - /// Discards the held selector so the next source rebuilds a basis from - /// its initializers. Called when identifying a new context, since a - /// selector points at one context's data and cannot seed a delta for - /// another. Mode switches keep the selector and so do not call this. + /// Discards the held selector so the next source re-fetches a full + /// payload from its initializers. Called when identifying a new context, + /// since a selector points at one context's data and cannot seed a delta + /// for another. Mode switches keep the selector and so do not call this. void clearSelector() { _selector = Selector.empty; } @@ -133,7 +133,7 @@ final class FDv2DataSystem { httpClientFactory: _httpClientFactory, ); - // When a selector is held the SDK already has basis data for this + // When a selector is held the SDK already has current data for this // context; mode switches go straight to synchronizers. final includeInitializers = _selector.isEmpty; final initializerFactories = includeInitializers diff --git a/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart b/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart index c8611d2e..ed654a31 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart @@ -64,8 +64,8 @@ final class FDv2DataSourceOrchestrator implements DataSource { bool _emittedPayload = false; /// True when the only sources are cache initializers (no synchronizers). - /// In such a system the cache load is the freshest data that will ever - /// arrive, so its payload is treated as basis rather than preliminary. + /// Such a system must still reach a usable state on a cache miss, so an + /// empty payload is emitted when no data was produced. final bool _cacheOnlyDataSystem; /// Resolves the outcome of the active synchronizer run. Set while a @@ -145,21 +145,21 @@ final class FDv2DataSourceOrchestrator implements DataSource { } } - void _emitPayload(ChangeSetResult result, {bool basis = true}) { + void _emitPayload(ChangeSetResult result) { if (_closed || _controller.isClosed) return; // An intent of "none" means the SDK is already up to date; it carries // no selector and must not regress the one we hold. For any other // type the payload's selector is adopted verbatim, including an empty - // one -- a selector-less full transfer (an FDv1 fallback payload, - // whose state cannot serve as an FDv2 basis) must clear the held - // selector so the next request sends no stale basis. Do not gate this - // on a non-empty selector. + // one -- a selector-less full transfer (e.g. an FDv1 fallback payload, + // whose state cannot drive FDv2 deltas) clears the held selector so the + // next request asks for a full payload rather than a stale delta. Do + // not gate this on a non-empty selector. if (result.changeSet.type != PayloadType.none) { _selectorUpdater(result.changeSet.selector); } _emittedPayload = true; - _controller.add(PayloadEvent(result.changeSet, - environmentId: result.environmentId, basis: basis)); + _controller.add( + PayloadEvent(result.changeSet, environmentId: result.environmentId)); } void _reportTransientError(StatusResult result) { @@ -208,15 +208,7 @@ final class FDv2DataSourceOrchestrator implements DataSource { switch (result) { case ChangeSetResult(): if (result.changeSet.type != PayloadType.none) { - // Selector-bearing data is network basis; an FDv1 fallback - // transfer is fresh network data too; and in a cache-only - // system the cache load is the freshest data there will be. - // Otherwise this is preliminary cache data ahead of a - // synchronizer: applied, but not basis. - final isBasis = result.changeSet.selector.isNotEmpty || - result.fdv1Fallback || - _cacheOnlyDataSystem; - _emitPayload(result, basis: isBasis); + _emitPayload(result); if (_handleFdv1Fallback(result)) { // Data was received but the server directed FDv1 fallback; @@ -225,10 +217,11 @@ final class FDv2DataSourceOrchestrator implements DataSource { } if (result.changeSet.selector.isNotEmpty) { - // Basis data with a selector: initialization is complete. + // A selector means a complete, server-versioned payload: + // initialization is done. A selector-less payload (e.g. cache) + // is applied, but we keep initializing toward network data. return; } - // Data without a selector (e.g. cache); keep initializing. } case StatusResult(): switch (result.state) { diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index cbd549b1..ff28ab99 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -442,8 +442,8 @@ final class LDCommonClient { cachedFlagsReader: _flagManager.readCached, ); _dataSourceManager.setFactories(dataSystem.buildFactories()); - // FDv2 loads the cache through its pipeline and resets the held - // basis on a context change (clearSelector). + // FDv2 loads the cache through its pipeline and clears the held + // selector on a context change. _dataManager = FDv2DataManager(_dataSourceManager, dataSystem.clearSelector); } else { diff --git a/packages/common_client/test/data_sources/data_manager_test.dart b/packages/common_client/test/data_sources/data_manager_test.dart index 4703752f..a118d838 100644 --- a/packages/common_client/test/data_sources/data_manager_test.dart +++ b/packages/common_client/test/data_sources/data_manager_test.dart @@ -31,41 +31,41 @@ LDContext _ctx(String key) => LDContextBuilder().kind('user', key).build(); void main() { group('FDv2DataManager', () { - test('resets the basis on a context change but not when it repeats', () { - var resets = 0; + test('clears the selector on a context change but not when it repeats', () { + var clears = 0; final manager = - FDv2DataManager(_managerWithoutFactories(), () => resets++); + FDv2DataManager(_managerWithoutFactories(), () => clears++); // The returned futures never complete (no factory delivers data); we - // only care that the basis-reset decision fires correctly. + // only care that the clear-selector decision fires correctly. unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); unawaited(manager.identify(_ctx('b'), waitForNetworkResults: false)); unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); - // a (first), b, a-again -> 3 resets. The repeated 'a' keeps its basis. - expect(resets, 3); + // a (first), b, a-again -> 3 clears. The repeated 'a' keeps its selector. + expect(clears, 3); }); test( - 'resets the basis on a context change regardless of intervening mode ' - 'switches', () { - // The reset is driven at identify time, not by the data source factory, + 'clears the selector on a context change regardless of intervening ' + 'mode switches', () { + // The clear is driven at identify time, not by the data source factory, // so a mode switch between identifies (e.g. going offline) cannot leave - // a stale basis behind for the next context. Mode switches go through + // a stale selector behind for the next context. Mode switches go through // DataSourceManager.setMode and never reach this manager, so they do not - // reset the basis themselves. - var resets = 0; + // clear the selector themselves. + var clears = 0; final dataSourceManager = _managerWithoutFactories(); - final manager = FDv2DataManager(dataSourceManager, () => resets++); + final manager = FDv2DataManager(dataSourceManager, () => clears++); unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); dataSourceManager.setMode(const ResolvedOffline(OfflineSetOffline())); unawaited(manager.identify(_ctx('b'), waitForNetworkResults: false)); dataSourceManager.setMode(const ResolvedStreaming()); - // Reset fired for 'a' and 'b'; the offline/online switches did not. - expect(resets, 2); + // Clear fired for 'a' and 'b'; the offline/online switches did not. + expect(clears, 2); }); }); } diff --git a/packages/common_client/test/data_sources/data_source_manager_test.dart b/packages/common_client/test/data_sources/data_source_manager_test.dart index e91d7cd1..75dac34f 100644 --- a/packages/common_client/test/data_sources/data_source_manager_test.dart +++ b/packages/common_client/test/data_sources/data_source_manager_test.dart @@ -7,6 +7,7 @@ import 'package:launchdarkly_common_client/src/data_sources/data_source_manager. import 'package:launchdarkly_common_client/src/data_sources/data_source_status.dart'; import 'package:launchdarkly_common_client/src/data_sources/data_source_status_manager.dart'; import 'package:launchdarkly_common_client/src/data_sources/fdv2/payload.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/selector.dart'; import 'package:launchdarkly_common_client/src/flag_manager/flag_manager.dart'; import 'package:launchdarkly_common_client/src/item_descriptor.dart'; import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'; @@ -124,16 +125,19 @@ void main() { test('it applies an FDv2 payload event and completes identify', () async { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); final context = LDContextBuilder().kind('user', 'bob').build(); - final changeSet = ChangeSet(type: PayloadType.full, updates: { - 'flag-a': ItemDescriptor( - version: 3, - flag: LDEvaluationResult( - version: 3, - detail: LDEvaluationDetail( - LDValue.ofBool(true), 0, LDEvaluationReason.off()), - ), - ), - }); + final changeSet = ChangeSet( + selector: const Selector(state: 'state-1', version: 1), + type: PayloadType.full, + updates: { + 'flag-a': ItemDescriptor( + version: 3, + flag: LDEvaluationResult( + version: 3, + detail: LDEvaluationDetail( + LDValue.ofBool(true), 0, LDEvaluationReason.off()), + ), + ), + }); final factories = { const FDv2Streaming(): (_) => MockDataSource(startEvents: [PayloadEvent(changeSet)]), @@ -151,9 +155,10 @@ void main() { final completer = Completer(); manager.identify(context, completer); - // The payload reaches handlePayload, which applies the change set, - // marks the source valid, and completes the pending identify. (A - // dropped/no-op payload would leave the identify hanging.) + // The network payload (carrying a selector) reaches handlePayload, which + // applies the change set; the manager marks the source valid and + // completes the pending identify. (A dropped/no-op payload would leave + // the identify hanging.) await completer.future; }); @@ -296,7 +301,8 @@ void main() { expect(createdDataSource.restartCalled, isTrue); }); - ChangeSet aChangeSet() => ChangeSet(type: PayloadType.full, updates: { + ChangeSet aChangeSet({Selector selector = Selector.empty}) => + ChangeSet(selector: selector, type: PayloadType.full, updates: { 'flag-a': ItemDescriptor( version: 3, flag: LDEvaluationResult( @@ -307,14 +313,17 @@ void main() { ), }); + ChangeSet aNetworkChangeSet() => + aChangeSet(selector: const Selector(state: 'state-1', version: 1)); + test( - 'a non-basis payload applies data and resolves a cached identify ' - 'without marking the source valid', () async { + 'a selector-less payload resolves a cached identify without marking the ' + 'source valid', () async { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); final context = LDContextBuilder().kind('user', 'bob').build(); final factories = { - const FDv2Streaming(): (_) => MockDataSource( - startEvents: [PayloadEvent(aChangeSet(), basis: false)]), + const FDv2Streaming(): (_) => + MockDataSource(startEvents: [PayloadEvent(aChangeSet())]), const FDv2Polling(): (_) => MockDataSource(), const FDv2Background(): (_) => MockDataSource(), }; @@ -323,23 +332,24 @@ void main() { final completer = Completer(); // requireFreshData defaults false: a cached identify resolves on the - // preliminary cache payload. + // cache payload, which has no selector. manager.identify(context, completer); await completer.future; expect(statusManager.status.state, isNot(DataSourceState.valid), - reason: 'preliminary cache data must not report a live connection'); + reason: 'cache data has no selector and must not report a live ' + 'connection'); }); test( - 'an identify requiring fresh data ignores a non-basis payload and ' - 'resolves on a basis payload, then marks valid', () async { + 'an identify requiring fresh data ignores a selector-less payload and ' + 'resolves on one with a selector, then marks valid', () async { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); final context = LDContextBuilder().kind('user', 'bob').build(); final factories = { const FDv2Streaming(): (_) => MockDataSource(startEvents: [ - PayloadEvent(aChangeSet(), basis: false), - PayloadEvent(aChangeSet(), basis: true), + PayloadEvent(aChangeSet()), + PayloadEvent(aNetworkChangeSet()), ]), const FDv2Polling(): (_) => MockDataSource(), const FDv2Background(): (_) => MockDataSource(), @@ -358,8 +368,8 @@ void main() { () async { final context = LDContextBuilder().kind('user', 'bob').build(); final factories = { - const FDv2Streaming(): (_) => MockDataSource( - startEvents: [PayloadEvent(aChangeSet(), basis: false)]), + const FDv2Streaming(): (_) => + MockDataSource(startEvents: [PayloadEvent(aChangeSet())]), const FDv2Polling(): (_) => MockDataSource(), const FDv2Background(): (_) => MockDataSource(), }; @@ -386,11 +396,10 @@ void main() { const FDv2Background(): (_) => MockDataSource(), const FDv2Offline(): (_) { offlineStarted = true; - // A cache-only system: the cache load is the terminal (basis) - // payload, so the identify resolves -- but the manager must keep - // the offline status rather than report valid. - return MockDataSource( - startEvents: [PayloadEvent(aChangeSet(), basis: true)]); + // Offline cannot reach the network, so the identify resolves on the + // selector-less cache payload -- but the manager must keep the + // offline status rather than report valid. + return MockDataSource(startEvents: [PayloadEvent(aChangeSet())]); }, }; final manager = diff --git a/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart b/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart index 88a1ca23..192a7319 100644 --- a/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart +++ b/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart @@ -124,7 +124,8 @@ final class Harness { } void main() { - test('runs initializers in order until one returns basis data', () async { + test('runs initializers in order until one returns data with a selector', + () async { final firstCreated = []; final secondCreated = []; final thirdCreated = []; @@ -194,11 +195,11 @@ void main() { harness.orchestrator.stop(); }); - test('tags preliminary cache data as non-basis and network data as basis', + test('a cache hit is applied but initialization continues to network data', () async { final synchronizers = []; final harness = Harness(initializerFactories: [ - // A cache hit (full data, no selector) ahead of a synchronizer. + // A cache hit: full data with no selector. initializerFactory(changeSet(type: PayloadType.full), isCache: true), ], synchronizerSlots: [ synchronizerSlot(synchronizers), @@ -209,32 +210,41 @@ void main() { final afterCache = harness.events.whereType().toList(); expect(afterCache, hasLength(1)); - expect(afterCache.single.basis, isFalse, - reason: 'cache data ahead of a synchronizer is preliminary'); + expect(afterCache.single.changeSet.selector.isEmpty, isTrue, + reason: 'cache data carries no selector'); + expect(synchronizers, hasLength(1), + reason: 'a selector-less payload does not complete initialization, ' + 'so the synchronizer tier still starts'); synchronizers.single.controller .add(changeSet(selector: const Selector(state: 'state-1', version: 1))); await harness.pump(); - final all = harness.events.whereType().toList(); - expect(all, hasLength(2)); - expect(all.last.basis, isTrue, reason: 'network data is basis'); + expect(harness.selector.state, 'state-1', + reason: 'network data carries the selector forward'); harness.orchestrator.stop(); }); - test('a cache-only system tags the cache load as basis', () async { - final harness = Harness(initializerFactories: [ - initializerFactory(changeSet(type: PayloadType.full), isCache: true), - ], synchronizerSlots: []); + test('a selector-less full payload clears the held selector', () async { + final synchronizers = []; + final harness = Harness( + initializerFactories: [], + synchronizerSlots: [synchronizerSlot(synchronizers)]); harness.orchestrator.start(); await harness.pump(); - final payloads = harness.events.whereType().toList(); - expect(payloads, hasLength(1)); - expect(payloads.single.basis, isTrue, - reason: 'with no fresher source, the cache load is the basis'); + synchronizers.single.controller + .add(changeSet(selector: const Selector(state: 'state-1', version: 1))); + await harness.pump(); + expect(harness.selector.state, 'state-1'); + + // A full transfer with no selector (e.g. an FDv1 fallback) clears it, so + // the next reconnect asks for a full payload rather than a stale delta. + synchronizers.single.controller.add(changeSet(type: PayloadType.full)); + await harness.pump(); + expect(harness.selector.isEmpty, isTrue); harness.orchestrator.stop(); }); From dc91b1c664f14f54d3d414f65d2cae632d92c610 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:14:21 -0700 Subject: [PATCH 07/20] fix: clear the FDv2 selector on every identify instead of tracking context keys Identify now unconditionally clears the held selector, matching js-core's FDv2DataManagerBase (which resets the selector on every identify). The previous context-key comparison split the "which context is the selector for" invariant between the data manager (holding the key) and the data system (holding the selector), with nothing keeping the two consistent. Mode switches still keep the selector, since they reach the data source manager directly rather than through the data manager. --- .../lib/src/data_sources/data_manager.dart | 19 ++++++----------- .../test/data_sources/data_manager_test.dart | 21 +++++++------------ 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_manager.dart b/packages/common_client/lib/src/data_sources/data_manager.dart index 5fee4ef1..a8825d65 100644 --- a/packages/common_client/lib/src/data_sources/data_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_manager.dart @@ -55,28 +55,21 @@ final class FDv1DataManager implements DataManager { /// on fresh data, so a cache load alone does not satisfy a wait-for-network /// identify. /// -/// Identify is also where a held selector is discarded: a selector points -/// at one context's data, so on a context change [clearSelector] is invoked -/// before connecting. This is keyed on the context's canonical key and -/// driven here rather than inferred from the context instance inside the -/// data source factory, so it holds regardless of which connection mode is -/// active when the context changes (including offline). +/// Each identify starts data acquisition fresh: any held selector is +/// discarded via [clearSelector] before connecting, so the new connection +/// re-fetches a full payload rather than resuming a previous context's +/// basis. Mode switches keep the selector and reach the data source manager +/// directly rather than through here, so they are unaffected. final class FDv2DataManager implements DataManager { final DataSourceManager _dataSourceManager; final void Function() _clearSelector; - String? _lastContextKey; - FDv2DataManager(this._dataSourceManager, this._clearSelector); @override Future identify(LDContext context, {required bool waitForNetworkResults}) { - final key = context.canonicalKey; - if (key != _lastContextKey) { - _lastContextKey = key; - _clearSelector(); - } + _clearSelector(); final completer = Completer(); _dataSourceManager.identify(context, completer, requireFreshData: waitForNetworkResults); diff --git a/packages/common_client/test/data_sources/data_manager_test.dart b/packages/common_client/test/data_sources/data_manager_test.dart index a118d838..918e967d 100644 --- a/packages/common_client/test/data_sources/data_manager_test.dart +++ b/packages/common_client/test/data_sources/data_manager_test.dart @@ -31,30 +31,25 @@ LDContext _ctx(String key) => LDContextBuilder().kind('user', key).build(); void main() { group('FDv2DataManager', () { - test('clears the selector on a context change but not when it repeats', () { + test('clears the selector on every identify', () { var clears = 0; final manager = FDv2DataManager(_managerWithoutFactories(), () => clears++); // The returned futures never complete (no factory delivers data); we - // only care that the clear-selector decision fires correctly. + // only care that each identify starts fresh. unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); unawaited(manager.identify(_ctx('b'), waitForNetworkResults: false)); - unawaited(manager.identify(_ctx('a'), waitForNetworkResults: false)); - // a (first), b, a-again -> 3 clears. The repeated 'a' keeps its selector. + // Every identify clears, including re-identifying the same context. expect(clears, 3); }); - test( - 'clears the selector on a context change regardless of intervening ' - 'mode switches', () { - // The clear is driven at identify time, not by the data source factory, - // so a mode switch between identifies (e.g. going offline) cannot leave - // a stale selector behind for the next context. Mode switches go through - // DataSourceManager.setMode and never reach this manager, so they do not - // clear the selector themselves. + test('mode switches do not clear the selector, only identifies do', () { + // The clear is driven at identify time. Mode switches reach the data + // source manager directly (not this manager), so they keep the held + // selector and resume rather than re-initializing. var clears = 0; final dataSourceManager = _managerWithoutFactories(); final manager = FDv2DataManager(dataSourceManager, () => clears++); @@ -64,7 +59,7 @@ void main() { unawaited(manager.identify(_ctx('b'), waitForNetworkResults: false)); dataSourceManager.setMode(const ResolvedStreaming()); - // Clear fired for 'a' and 'b'; the offline/online switches did not. + // Two identifies cleared; the offline/streaming switches did not. expect(clears, 2); }); }); From 4995258a8c94f2df8211bc48774c7208fbc86ee7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:19:38 -0700 Subject: [PATCH 08/20] docs: drop the handlePayload status note that explained removed behavior The note described the method by contrast to the FDv1 verbs and to a prematurely-valid bug that no longer exists, rather than the code as it is. --- .../lib/src/data_sources/data_source_event_handler.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_source_event_handler.dart b/packages/common_client/lib/src/data_sources/data_source_event_handler.dart index a012a05e..01b15256 100644 --- a/packages/common_client/lib/src/data_sources/data_source_event_handler.dart +++ b/packages/common_client/lib/src/data_sources/data_source_event_handler.dart @@ -102,12 +102,6 @@ final class DataSourceEventHandler { /// Full change sets replace the stored flags, partial change sets apply /// each update, and a change set of type none confirms the SDK is up to /// date without changing data. - /// - /// This does not touch the data source status. Unlike the FDv1 verbs, an - /// FDv2 payload only marks the source valid when it carries a server - /// selector (network data), and never while offline; the DataSourceManager - /// makes that call from the change set's selector and the active mode, so - /// applying cached flags here does not prematurely report valid. Future handlePayload(LDContext context, ChangeSet changeSet, {String? environmentId}) async { try { From fd9cc70c72fee20d2a9f714af7cf8aa6b93ab049 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:22:36 -0700 Subject: [PATCH 09/20] docs: describe selector clearing as it works now, not the old design Drops the contrast with the removed context-instance inference and the stale "reset on a context change" wording; the data manager clears the selector on every identify. --- .../lib/src/data_sources/fdv2/data_system.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index d19bab9a..665fa91d 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -23,11 +23,9 @@ import 'source_manager.dart'; /// /// A fresh orchestrator is created per connection-mode switch and per /// identify. The selector survives mode switches (initializers are -/// skipped when a selector is held). It is specific to a single context, -/// so it must be reset on a context change; that is driven explicitly by -/// the data manager via [clearSelector] at identify time rather than -/// inferred here from the context instance, which depends on the factory -/// being invoked for every change. +/// skipped when a selector is held). The data manager clears it via +/// [clearSelector] on each identify, so a new identify starts from a full +/// payload rather than resuming a prior state. final class FDv2DataSystem { final String _credential; final LDLogger _logger; From f601d15b5dd48dd9026cdf42e5256cc660d9e799 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:26:07 -0700 Subject: [PATCH 10/20] docs: describe the cache reader by what it does, not vs the FDv1 path --- .../common_client/lib/src/data_sources/fdv2/data_system.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index 665fa91d..a3ba41c4 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -124,9 +124,8 @@ final class FDv2DataSystem { serviceEndpoints: _serviceEndpoints, withReasons: _withReasons, defaultPollingInterval: _defaultPollingInterval, - // The FDv2 data system owns cache loading: the cache initializer - // reads persistence through this reader and feeds the result into - // the pipeline, rather than the client applying it at identify. + // The cache initializer reads persistence through this reader and + // feeds the result into the pipeline. cachedFlagsReader: _cachedFlagsReader, httpClientFactory: _httpClientFactory, ); From 9083654d40d65428ee4bded07d8db79e617b8f06 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:42:28 -0700 Subject: [PATCH 11/20] refactor: rename _completeIdentify to _maybeCompleteIdentify It returns without completing when a wait-for-network identify has not yet seen fresh data, so the name should reflect that it is conditional. --- .../lib/src/data_sources/data_source_manager.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index e4767cbc..f6267267 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -100,7 +100,7 @@ final class DataSourceManager { _activeDataSource = null; } - void _completeIdentify(MessageStatus handled, ChangeSet? changeSet, + void _maybeCompleteIdentify(MessageStatus handled, ChangeSet? changeSet, {bool offline = false}) { if (handled != MessageStatus.messageHandled || _identifyCompleter == null) { return; @@ -187,7 +187,7 @@ final class DataSourceManager { var handled = await _dataSourceEventHandler.handleMessage( _activeContext!, event.type, event.data, environmentId: event.environmentId); - _completeIdentify(handled, null); + _maybeCompleteIdentify(handled, null); return handled; case PayloadEvent(): var handled = await _dataSourceEventHandler.handlePayload( @@ -203,7 +203,7 @@ final class DataSourceManager { // live connection. _statusManager.setValid(); } - _completeIdentify(handled, event.changeSet, offline: offline); + _maybeCompleteIdentify(handled, event.changeSet, offline: offline); return handled; case StatusEvent(): if (_identifyCompleter != null && !_identifyCompleter!.isCompleted) { From 89b6bac892b2cbf5af8447636cf83a5df6ee773f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:15:09 -0700 Subject: [PATCH 12/20] fix: restore valid on a no-change server response after an interruption setValid was gated on the change set carrying a selector, but an intent-none (the server confirming no changes) carries none -- so a healthy reconnect that reported no changes left the status stuck at interrupted. Gate status (and the wait-for-network identify) on whether the change set is server data -- a selector-bearing payload or an intent-none -- rather than a cache load (a selector-less full). Matches js-core and android, where any synchronizer response, including no-change, restores valid. Adds a regression test. --- .../src/data_sources/data_source_manager.dart | 33 +++++++++++-------- .../data_source_manager_test.dart | 32 ++++++++++++++++++ 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index f6267267..1c3ad086 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -100,21 +100,25 @@ final class DataSourceManager { _activeDataSource = null; } + /// Whether [changeSet] is server-provided current data rather than a cache + /// load. It is server data when it carries a selector (a basis or delta the + /// server versioned) or is an intent-none (the server confirming the SDK is + /// already up to date). A cache load is a full transfer with no selector, + /// so it is not server data. + bool _isServerData(ChangeSet changeSet) => + changeSet.selector.isNotEmpty || changeSet.type == PayloadType.none; + void _maybeCompleteIdentify(MessageStatus handled, ChangeSet? changeSet, {bool offline = false}) { if (handled != MessageStatus.messageHandled || _identifyCompleter == null) { return; } - // An identify waiting for network results resolves only on fresh data: a - // payload carrying a server selector, or an intent-none (the server - // confirming the SDK is up to date). A cache load has neither, so it is - // applied but leaves the identify pending until network data arrives. - // Offline can produce neither, so it resolves on whatever data is - // available; FDv1 passes no change set and never waits for fresh data. + // An identify waiting for network results resolves only on server data, so + // a cache load is applied but leaves the identify pending until the server + // responds. Offline cannot reach the server, so it resolves on whatever + // data is available; FDv1 passes no change set and never waits. if (_requireFreshData && !offline) { - final fresh = changeSet != null && - (changeSet.selector.isNotEmpty || changeSet.type == PayloadType.none); - if (!fresh) { + if (changeSet == null || !_isServerData(changeSet)) { return; } } @@ -195,12 +199,13 @@ final class DataSourceManager { environmentId: event.environmentId); final offline = _activeConnectionMode is FDv2Offline; if (handled == MessageStatus.messageHandled && - event.changeSet.selector.isNotEmpty && + _isServerData(event.changeSet) && !offline) { - // A server selector means this is network data, which marks the - // source valid. Cached data has no selector, and while offline - // the status set in _setupConnection stands, so neither reports a - // live connection. + // Server data means the connection is live, so the source is + // valid -- including a no-change response, which is how a healthy + // reconnect restores valid after an interruption. A cache load is + // not server data, and while offline the status set in + // _setupConnection stands, so neither reports a live connection. _statusManager.setValid(); } _maybeCompleteIdentify(handled, event.changeSet, offline: offline); diff --git a/packages/common_client/test/data_sources/data_source_manager_test.dart b/packages/common_client/test/data_sources/data_source_manager_test.dart index 75dac34f..c77dda4e 100644 --- a/packages/common_client/test/data_sources/data_source_manager_test.dart +++ b/packages/common_client/test/data_sources/data_source_manager_test.dart @@ -162,6 +162,38 @@ void main() { await completer.future; }); + test('a no-change payload after an interruption restores valid', () async { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final context = LDContextBuilder().kind('user', 'bob').build(); + const networkBasis = ChangeSet( + selector: Selector(state: 'state-1', version: 1), + type: PayloadType.full, + updates: {}); + const noChange = ChangeSet(type: PayloadType.none, updates: {}); + final factories = { + const FDv2Streaming(): (_) => MockDataSource(startEvents: [ + // Healthy connection delivers basis data, then drops, then + // reconnects and reports no changes. + PayloadEvent(networkBasis), + StatusEvent(ErrorKind.networkError, null, 'connection dropped'), + PayloadEvent(noChange), + ]), + const FDv2Polling(): (_) => MockDataSource(), + const FDv2Background(): (_) => MockDataSource(), + }; + final manager = + makeManager(context, factories, inStatusManager: statusManager); + + final completer = Completer(); + manager.identify(context, completer); + await completer.future; + await pumpEventQueue(); + + expect(statusManager.status.state, DataSourceState.valid, + reason: 'a healthy reconnect reporting no changes carries no selector, ' + 'but it is still a server response and must restore valid'); + }); + test('it can transition to offline and tear-down the previous connection', () { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); From a980153d8b3b1160a97401ec8f5f4f92d8951423 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:49:49 -0700 Subject: [PATCH 13/20] feat: connect FDv1 fallback; decide valid/initialized structurally Three linked changes that finish the FDv2 data system in common_client: - Status: the manager marks the source valid on any applied change set while online (offline keeps its offline status). No selector inference -- a cache load and a no-change response both report valid, matching js-core. - Initialization: the orchestrator, which knows the source tier, emits a single InitializedEvent (selector-bearing initializer, initializer exhaustion with data or in a cache-only system, or the first synchronizer change set). The manager resolves a wait-for-network identify on it; a cached identify resolves earlier on the first applied payload. Replaces the fragile type+selector inference, and an FDv1-fallback first response now completes a fresh identify. - FDv1 fallback: a new FDv1 fallback synchronizer (an FDv1 poller whose responses translate to full, selector-less change sets tagged fdv1Fallback:false) is appended as the terminal, initially-blocked slot when a mode configures it. The orchestrator ignores a fallback directive once already on FDv1. The x-ld-fd-fallback detection was already present. --- .../lib/src/data_sources/data_source.dart | 7 + .../src/data_sources/data_source_manager.dart | 73 +++++------ .../src/data_sources/fdv2/data_system.dart | 20 +-- .../fdv2/fdv1_fallback_synchronizer.dart | 121 ++++++++++++++++++ .../src/data_sources/fdv2/orchestrator.dart | 47 +++++-- .../src/data_sources/fdv2/source_manager.dart | 9 ++ .../src/data_sources/polling_data_source.dart | 3 +- .../data_sources/streaming_data_source.dart | 4 +- .../data_source_manager_test.dart | 23 ++-- .../fdv2/fdv1_fallback_synchronizer_test.dart | 76 +++++++++++ .../data_sources/fdv2/orchestrator_test.dart | 58 +++++++++ .../polling_data_source_test.dart | 3 + .../streaming_data_source_test.dart | 1 + 13 files changed, 372 insertions(+), 73 deletions(-) create mode 100644 packages/common_client/lib/src/data_sources/fdv2/fdv1_fallback_synchronizer.dart create mode 100644 packages/common_client/test/data_sources/fdv2/fdv1_fallback_synchronizer_test.dart diff --git a/packages/common_client/lib/src/data_sources/data_source.dart b/packages/common_client/lib/src/data_sources/data_source.dart index af0f4f0b..6d9e8d0c 100644 --- a/packages/common_client/lib/src/data_sources/data_source.dart +++ b/packages/common_client/lib/src/data_sources/data_source.dart @@ -31,6 +31,13 @@ final class StatusEvent implements DataSourceEvent { {this.shutdown = false}); } +/// Emitted once by the FDv2 orchestrator when initialization is complete: +/// a selector-bearing payload arrived, the initializer chain was exhausted +/// (with cached data or in a cache-only system), or the first synchronizer +/// delivered a change set. The manager resolves a wait-for-network identify +/// on this; a cached identify resolves earlier, on the first applied payload. +final class InitializedEvent implements DataSourceEvent {} + abstract interface class DataSource { Stream get events; diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index 1c3ad086..ec8cd0bc 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -10,7 +10,6 @@ import '../resolved_connection_mode.dart'; import 'data_source.dart'; import 'data_source_event_handler.dart'; import 'data_source_status_manager.dart'; -import 'fdv2/payload.dart'; typedef DataSourceFactory = DataSource Function(LDContext context); @@ -100,36 +99,18 @@ final class DataSourceManager { _activeDataSource = null; } - /// Whether [changeSet] is server-provided current data rather than a cache - /// load. It is server data when it carries a selector (a basis or delta the - /// server versioned) or is an intent-none (the server confirming the SDK is - /// already up to date). A cache load is a full transfer with no selector, - /// so it is not server data. - bool _isServerData(ChangeSet changeSet) => - changeSet.selector.isNotEmpty || changeSet.type == PayloadType.none; - - void _maybeCompleteIdentify(MessageStatus handled, ChangeSet? changeSet, - {bool offline = false}) { - if (handled != MessageStatus.messageHandled || _identifyCompleter == null) { + /// Resolves the pending identify, if any. Idempotent: only the first call + /// completes it. Callers decide *when* to call it -- a cached identify on + /// the first applied payload, a wait-for-network identify on the + /// orchestrator's [InitializedEvent]. + void _maybeCompleteIdentify() { + final completer = _identifyCompleter; + if (completer == null) { return; } - // An identify waiting for network results resolves only on server data, so - // a cache load is applied but leaves the identify pending until the server - // responds. Offline cannot reach the server, so it resolves on whatever - // data is available; FDv1 passes no change set and never waits. - if (_requireFreshData && !offline) { - if (changeSet == null || !_isServerData(changeSet)) { - return; - } - } - if (_identifyCompleter!.isCompleted) { - _logger.error('Identify was already complete before receiving ' - 'data. This could represent an issue with SDK logic. Please' - 'make a bug report if you encounter this situation.'); - } else { - _identifyCompleter!.complete(); + if (!completer.isCompleted) { + completer.complete(); } - // Only need to complete this the first time. _identifyCompleter = null; } @@ -191,25 +172,37 @@ final class DataSourceManager { var handled = await _dataSourceEventHandler.handleMessage( _activeContext!, event.type, event.data, environmentId: event.environmentId); - _maybeCompleteIdentify(handled, null); + if (handled == MessageStatus.messageHandled) { + _maybeCompleteIdentify(); + } return handled; case PayloadEvent(): var handled = await _dataSourceEventHandler.handlePayload( _activeContext!, event.changeSet, environmentId: event.environmentId); - final offline = _activeConnectionMode is FDv2Offline; - if (handled == MessageStatus.messageHandled && - _isServerData(event.changeSet) && - !offline) { - // Server data means the connection is live, so the source is - // valid -- including a no-change response, which is how a healthy - // reconnect restores valid after an interruption. A cache load is - // not server data, and while offline the status set in - // _setupConnection stands, so neither reports a live connection. - _statusManager.setValid(); + if (handled == MessageStatus.messageHandled) { + // Applying any change set from a live source marks it valid -- + // including a no-change response, which restores valid after an + // interruption. While offline the status set in _setupConnection + // stands, so cached data does not report a live connection. + if (_activeConnectionMode is! FDv2Offline) { + _statusManager.setValid(); + } + // A cached identify resolves on any applied data; a + // wait-for-network identify waits for the orchestrator's + // InitializedEvent instead. + if (!_requireFreshData) { + _maybeCompleteIdentify(); + } } - _maybeCompleteIdentify(handled, event.changeSet, offline: offline); return handled; + case InitializedEvent(): + // Initialization is complete (network basis, initializer + // exhaustion, or the first synchronizer change set). Resolves a + // wait-for-network identify; a cached identify has usually resolved + // already on earlier data. + _maybeCompleteIdentify(); + return MessageStatus.messageHandled; case StatusEvent(): if (_identifyCompleter != null && !_identifyCompleter!.isCompleted) { _identifyCompleter!.completeError(Exception(event.message)); diff --git a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart index a3ba41c4..5f0d30ef 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/data_system.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/data_system.dart @@ -10,6 +10,7 @@ import '../data_source_status_manager.dart'; import 'built_in_modes.dart'; import 'cache_initializer.dart'; import 'entry_factories.dart'; +import 'fdv1_fallback_synchronizer.dart'; import 'mode_definition.dart'; import 'orchestrator.dart'; import 'requestor.dart'; @@ -138,20 +139,23 @@ final class FDv2DataSystem { modeDefinition.initializers, factoryContext) : []; - // The FDv1 fallback tier (modeDefinition.fdv1Fallback) is not built - // into a slot yet. When it is, mark that slot isFdv1Fallback and keep - // its source incapable of emitting a result with fdv1Fallback set: - // it is the terminal tier, so re-asserting the directive from there - // would drive the orchestrator to re-engage FDv1 fallback on every - // result, undelayed and blocking no slot. A source that cannot emit - // the directive is simpler than guarding the orchestrator against - // re-engaging while already on FDv1. final synchronizerSlots = buildSynchronizerFactories( modeDefinition.synchronizers, factoryContext, sseClientFactory: _sseClientFactory) .map((factory) => SynchronizerSlot(factory: factory)) .toList(); + // The FDv1 fallback is the terminal tier: appended last, blocked until + // the server directs fallback. Its source never re-asserts the + // directive, so engaging it cannot loop. + if (modeDefinition.fdv1Fallback case final fallbackConfig?) { + synchronizerSlots.add(SynchronizerSlot( + factory: createFdv1FallbackSynchronizerFactory( + fallbackConfig, factoryContext), + isFdv1Fallback: true, + )); + } + return FDv2DataSourceOrchestrator( initializerFactories: initializerFactories, synchronizerSlots: synchronizerSlots, diff --git a/packages/common_client/lib/src/data_sources/fdv2/fdv1_fallback_synchronizer.dart b/packages/common_client/lib/src/data_sources/fdv2/fdv1_fallback_synchronizer.dart new file mode 100644 index 00000000..f1d1fee8 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/fdv1_fallback_synchronizer.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; + +import '../../config/data_source_config.dart'; +import '../../item_descriptor.dart'; +import '../data_source.dart' + show + DataEvent, + DataSourceEvent, + InitializedEvent, + PayloadEvent, + StatusEvent; +import '../requestor.dart' as fdv1; +import 'entry_factories.dart' show SynchronizerFactory, mergeServiceEndpoints; +import 'mode_definition.dart'; +import 'payload.dart'; +import 'polling_synchronizer.dart'; +import 'selector.dart'; +import 'source.dart'; +import 'source_factory_context.dart'; +import 'source_result.dart'; + +/// Builds the FDv1 fallback synchronizer: an FDv1 poller whose responses are +/// translated into FDv2 change sets. +/// +/// Engaged only when the server directs fallback (the `x-ld-fd-fallback` +/// response header, detected by the primary FDv2 sources). It is the terminal +/// tier and never re-asserts the fallback directive -- every result it emits +/// carries `fdv1Fallback: false`. +/// +/// FDv1 has no delta protocol, so it polls with the context in the path and +/// each response is a complete flag set, translated to a `full` change set +/// with no selector. +SynchronizerFactory createFdv1FallbackSynchronizerFactory( + Fdv1FallbackConfig config, + SourceFactoryContext ctx, +) { + final endpoints = + mergeServiceEndpoints(ctx.serviceEndpoints, config.endpoints); + final interval = config.pollInterval ?? ctx.defaultPollingInterval; + final pollingConfig = PollingDataSourceConfig( + useReport: false, + withReasons: ctx.withReasons, + pollingInterval: interval, + ); + + return SynchronizerFactory( + create: (SelectorGetter selectorGetter) { + final requestor = fdv1.Requestor( + logger: ctx.logger, + contextString: base64UrlEncode(utf8.encode(ctx.contextJson)), + method: RequestMethod.get, + httpProperties: ctx.httpProperties, + credential: ctx.credential, + endpoints: endpoints, + dataSourceConfig: pollingConfig, + httpClientFactory: ctx.httpClientFactory ?? _defaultHttpClientFactory, + ); + return FDv2PollingSynchronizer( + // FDv1 has no basis/selector, so the selector argument is ignored. + poll: ({Selector basis = Selector.empty}) async => + _translate(await requestor.requestAllFlags()), + selectorGetter: selectorGetter, + interval: interval, + logger: ctx.logger, + ); + }, + ); +} + +HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) => + HttpClient(httpProperties: httpProperties); + +/// Translates an FDv1 polling result into an FDv2 source result. Results +/// always carry `fdv1Fallback: false` (the default), so the fallback tier can +/// never re-trigger fallback. +FDv2SourceResult _translate(DataSourceEvent? event) { + switch (event) { + case null: + // 304 Not Modified: the SDK's data is confirmed current. + return const ChangeSetResult( + changeSet: ChangeSet(type: PayloadType.none, updates: {}), + persist: false, + ); + case DataEvent(): + try { + final results = + LDEvaluationResultsSerialization.fromJson(jsonDecode(event.data)); + final updates = results.map((key, value) => + MapEntry(key, ItemDescriptor(version: value.version, flag: value))); + // FDv1 carries no selector; every poll is a complete snapshot. + return ChangeSetResult( + changeSet: ChangeSet(type: PayloadType.full, updates: updates), + environmentId: event.environmentId, + persist: true, + ); + } catch (_) { + return const StatusResult( + state: SourceState.interrupted, + message: 'Could not parse FDv1 fallback payload', + ); + } + case StatusEvent(): + return StatusResult( + state: event.shutdown + ? SourceState.terminalError + : SourceState.interrupted, + message: event.message, + statusCode: event.statusCode?.toInt(), + ); + case PayloadEvent(): + case InitializedEvent(): + // The FDv1 requestor only produces DataEvent / StatusEvent / null. + return const StatusResult( + state: SourceState.interrupted, + message: 'Unexpected event from the FDv1 fallback poller', + ); + } +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart b/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart index ed654a31..82e427f1 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart @@ -62,6 +62,7 @@ final class FDv2DataSourceOrchestrator implements DataSource { bool _started = false; bool _closed = false; bool _emittedPayload = false; + bool _initialized = false; /// True when the only sources are cache initializers (no synchronizers). /// Such a system must still reach a usable state on a cache miss, so an @@ -181,10 +182,22 @@ final class FDv2DataSourceOrchestrator implements DataSource { .add(StatusEvent(ErrorKind.unknown, null, message, shutdown: true)); } + /// Signals that initialization is complete. Emitted at most once -- the + /// manager resolves a wait-for-network identify on it. + void _emitInitialized() { + if (_initialized || _closed || _controller.isClosed) return; + _initialized = true; + _controller.add(InitializedEvent()); + } + /// True when the source indicated an FDv1 fallback directive and a - /// fallback tier exists to engage. + /// fallback tier exists to engage. The directive is ignored when the + /// FDv1 fallback synchronizer is already the active source, so it cannot + /// loop (the fallback source also never re-asserts the directive). bool _handleFdv1Fallback(FDv2SourceResult result) { - if (result.fdv1Fallback && _sourceManager.hasFdv1FallbackConfigured) { + if (result.fdv1Fallback && + _sourceManager.hasFdv1FallbackConfigured && + !_sourceManager.isCurrentSynchronizerFdv1Fallback) { _logger.warn('Server directed fallback to FDv1; engaging the FDv1 ' 'fallback synchronizer.'); _sourceManager.engageFdv1Fallback(); @@ -195,6 +208,7 @@ final class FDv2DataSourceOrchestrator implements DataSource { Future _runInitializers() async { var errorDuringInit = false; + var dataReceived = false; while (!_closed) { final initializer = _sourceManager.nextInitializer(); @@ -209,10 +223,12 @@ final class FDv2DataSourceOrchestrator implements DataSource { case ChangeSetResult(): if (result.changeSet.type != PayloadType.none) { _emitPayload(result); + dataReceived = true; if (_handleFdv1Fallback(result)) { // Data was received but the server directed FDv1 fallback; - // move on to synchronizers where the fallback tier runs. + // move on to synchronizers where the fallback tier runs and + // its first change set completes initialization. return; } @@ -220,6 +236,7 @@ final class FDv2DataSourceOrchestrator implements DataSource { // A selector means a complete, server-versioned payload: // initialization is done. A selector-less payload (e.g. cache) // is applied, but we keep initializing toward network data. + _emitInitialized(); return; } } @@ -244,27 +261,34 @@ final class FDv2DataSourceOrchestrator implements DataSource { if (_closed) return; - // All initializers exhausted. A data system whose only sources are - // cache initializers must still complete initialization on a cache - // miss -- there is nowhere else for data to come from. Emit an empty - // payload so the pipeline reaches a valid state, unless an error has - // already been reported. + // All initializers exhausted. A cache-only system (no synchronizer to + // produce data on its own) must still surface something on a cache + // miss, so a cached identify has a payload to resolve on; emit an empty + // payload unless an error was already reported. if (_cacheOnlyDataSystem && !_emittedPayload && !errorDuringInit) { _emitPayload(const ChangeSetResult( changeSet: ChangeSet(type: PayloadType.none, updates: {}), persist: false, )); } + + // Initialization completes at exhaustion when there is no synchronizer + // to wait on (cache-only), or when an initializer already delivered data + // without a selector. Otherwise the first synchronizer completes it. + if (_cacheOnlyDataSystem || dataReceived) { + _emitInitialized(); + } } Future _runSynchronizers() async { - // A data system with no sources at all has nothing to do; an empty - // payload marks it valid so a pending identify completes. + // A data system with no sources at all has nothing to do; complete + // initialization so a pending identify resolves. if (_initializerFactories.isEmpty && _synchronizerSlots.isEmpty) { _emitPayload(const ChangeSetResult( changeSet: ChangeSet(type: PayloadType.none, updates: {}), persist: false, )); + _emitInitialized(); return; } @@ -356,6 +380,9 @@ final class FDv2DataSourceOrchestrator implements DataSource { switch (result) { case ChangeSetResult(): _emitPayload(result); + // The first synchronizer change set -- of any type, with or + // without a selector -- completes initialization. + _emitInitialized(); case StatusResult(): switch (result.state) { case SourceState.interrupted: diff --git a/packages/common_client/lib/src/data_sources/fdv2/source_manager.dart b/packages/common_client/lib/src/data_sources/fdv2/source_manager.dart index 0d0f631f..41dbfa03 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/source_manager.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/source_manager.dart @@ -203,6 +203,15 @@ final class SourceManager { bool get hasFdv1FallbackConfigured => _synchronizerSlots.any((slot) => slot.isFdv1Fallback); + /// True when the active synchronizer is the FDv1 fallback. Reads the scan + /// cursor, so it is only meaningful while the cursor identifies the active + /// slot (see the class contract). Used to ignore a repeat fallback + /// directive once the SDK is already on FDv1. + bool get isCurrentSynchronizerFdv1Fallback => + _synchronizerIndex >= 0 && + _synchronizerIndex < _synchronizerSlots.length && + _synchronizerSlots[_synchronizerIndex].isFdv1Fallback; + /// Close the active source and mark the manager as shut down. void close() { _shutdown = true; diff --git a/packages/common_client/lib/src/data_sources/polling_data_source.dart b/packages/common_client/lib/src/data_sources/polling_data_source.dart index 555da33c..f900a325 100644 --- a/packages/common_client/lib/src/data_sources/polling_data_source.dart +++ b/packages/common_client/lib/src/data_sources/polling_data_source.dart @@ -117,7 +117,8 @@ final class PollingDataSource implements DataSource { stop(); } case PayloadEvent(): - // The FDv1 requestor never produces FDv2 payload events. + case InitializedEvent(): + // The FDv1 requestor never produces FDv2 payload or lifecycle events. break; } diff --git a/packages/common_client/lib/src/data_sources/streaming_data_source.dart b/packages/common_client/lib/src/data_sources/streaming_data_source.dart index 6796b152..fec2d4d3 100644 --- a/packages/common_client/lib/src/data_sources/streaming_data_source.dart +++ b/packages/common_client/lib/src/data_sources/streaming_data_source.dart @@ -174,7 +174,9 @@ final class StreamingDataSource implements DataSource { _logger.error('$message: $argument'); _dataController.sink.add(res); case PayloadEvent(): - // The FDv1 requestor never produces FDv2 payload events. + case InitializedEvent(): + // The FDv1 requestor never produces FDv2 payload or lifecycle + // events. break; } } else { diff --git a/packages/common_client/test/data_sources/data_source_manager_test.dart b/packages/common_client/test/data_sources/data_source_manager_test.dart index c77dda4e..4b418035 100644 --- a/packages/common_client/test/data_sources/data_source_manager_test.dart +++ b/packages/common_client/test/data_sources/data_source_manager_test.dart @@ -345,15 +345,13 @@ void main() { ), }); - ChangeSet aNetworkChangeSet() => - aChangeSet(selector: const Selector(state: 'state-1', version: 1)); - test( - 'a selector-less payload resolves a cached identify without marking the ' - 'source valid', () async { + 'a cached identify resolves on the first applied payload, which marks ' + 'the source valid', () async { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); final context = LDContextBuilder().kind('user', 'bob').build(); final factories = { + // A cache load (selector-less full) is enough for a cached identify. const FDv2Streaming(): (_) => MockDataSource(startEvents: [PayloadEvent(aChangeSet())]), const FDv2Polling(): (_) => MockDataSource(), @@ -363,25 +361,24 @@ void main() { makeManager(context, factories, inStatusManager: statusManager); final completer = Completer(); - // requireFreshData defaults false: a cached identify resolves on the - // cache payload, which has no selector. + // requireFreshData defaults false (cached): resolves on any applied data. manager.identify(context, completer); await completer.future; - expect(statusManager.status.state, isNot(DataSourceState.valid), - reason: 'cache data has no selector and must not report a live ' - 'connection'); + expect(statusManager.status.state, DataSourceState.valid, + reason: 'applying any data while online marks the source valid'); }); test( - 'an identify requiring fresh data ignores a selector-less payload and ' - 'resolves on one with a selector, then marks valid', () async { + 'a wait-for-network identify resolves on the initialized event, not ' + 'earlier data', () async { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); final context = LDContextBuilder().kind('user', 'bob').build(); final factories = { const FDv2Streaming(): (_) => MockDataSource(startEvents: [ + // Cache data, then the orchestrator's initialized signal. PayloadEvent(aChangeSet()), - PayloadEvent(aNetworkChangeSet()), + InitializedEvent(), ]), const FDv2Polling(): (_) => MockDataSource(), const FDv2Background(): (_) => MockDataSource(), diff --git a/packages/common_client/test/data_sources/fdv2/fdv1_fallback_synchronizer_test.dart b/packages/common_client/test/data_sources/fdv2/fdv1_fallback_synchronizer_test.dart new file mode 100644 index 00000000..7aa4d752 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/fdv1_fallback_synchronizer_test.dart @@ -0,0 +1,76 @@ +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:launchdarkly_common_client/src/config/service_endpoints.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/fdv1_fallback_synchronizer.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/mode_definition.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/payload.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/selector.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source_factory_context.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source_result.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; +import 'package:test/test.dart'; + +SourceFactoryContext _ctx(MockClient client) => + SourceFactoryContext.fromClientConfig( + context: LDContextBuilder().kind('user', 'bob').build(), + logger: LDLogger(level: LDLogLevel.none), + httpProperties: HttpProperties(), + serviceEndpoints: ServiceEndpoints(), + withReasons: false, + defaultPollingInterval: const Duration(seconds: 300), + cachedFlagsReader: (_) async => null, + credential: 'the-credential', + httpClientFactory: (props) => + HttpClient(client: client, httpProperties: props), + ); + +Future _firstResult(MockClient client) { + final synchronizer = createFdv1FallbackSynchronizerFactory( + const Fdv1FallbackConfig(), _ctx(client)) + .create(() => Selector.empty); + final result = synchronizer.results.first; + return result.whenComplete(synchronizer.close); +} + +void main() { + test('translates an FDv1 flag map into a full change set with no selector', + () async { + final mock = MockClient((_) async => http.Response( + '{"flagA":{"version":3,"value":true,"variation":0,' + '"reason":{"kind":"OFF"}}}', + 200)); + + final result = await _firstResult(mock); + + expect(result, isA()); + final changeSetResult = result as ChangeSetResult; + expect(changeSetResult.changeSet.type, PayloadType.full); + expect(changeSetResult.changeSet.selector.isEmpty, isTrue, + reason: 'FDv1 carries no selector'); + expect(changeSetResult.changeSet.updates.keys, contains('flagA')); + expect(changeSetResult.fdv1Fallback, isFalse, + reason: 'the fallback tier must never re-assert the directive'); + }); + + test('a 304 response becomes a no-op none change set', () async { + final mock = MockClient((_) async => http.Response('', 304)); + + final result = await _firstResult(mock); + + expect(result, isA()); + expect((result as ChangeSetResult).changeSet.type, PayloadType.none); + expect(result.fdv1Fallback, isFalse); + }); + + test('a server error surfaces as interrupted without re-triggering fallback', + () async { + final mock = MockClient((_) async => http.Response('', 503)); + + final result = await _firstResult(mock); + + expect(result, isA()); + expect((result as StatusResult).state, SourceState.interrupted); + expect(result.fdv1Fallback, isFalse); + }); +} diff --git a/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart b/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart index 192a7319..327b2c53 100644 --- a/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart +++ b/packages/common_client/test/data_sources/fdv2/orchestrator_test.dart @@ -249,6 +249,64 @@ void main() { harness.orchestrator.stop(); }); + test('emits InitializedEvent when an initializer returns a selector', + () async { + final synchronizers = []; + final harness = Harness(initializerFactories: [ + initializerFactory( + changeSet(selector: const Selector(state: 'state-1', version: 1))), + ], synchronizerSlots: [ + synchronizerSlot(synchronizers), + ]); + + harness.orchestrator.start(); + await harness.pump(); + + expect(harness.events.whereType(), hasLength(1)); + + harness.orchestrator.stop(); + }); + + test( + 'emits InitializedEvent on the first synchronizer change set, even ' + 'a no-change one with no selector', () async { + final synchronizers = []; + final harness = Harness( + initializerFactories: [], + synchronizerSlots: [synchronizerSlot(synchronizers)]); + + harness.orchestrator.start(); + await harness.pump(); + expect(harness.events.whereType(), isEmpty, + reason: 'no data has arrived yet'); + + synchronizers.single.controller.add(changeSet(type: PayloadType.none)); + await harness.pump(); + expect(harness.events.whereType(), hasLength(1), + reason: 'the first synchronizer result completes initialization'); + + synchronizers.single.controller + .add(changeSet(selector: const Selector(state: 's', version: 1))); + await harness.pump(); + expect(harness.events.whereType(), hasLength(1), + reason: 'it is emitted at most once'); + + harness.orchestrator.stop(); + }); + + test('a cache-only system emits InitializedEvent at exhaustion', () async { + final harness = Harness(initializerFactories: [ + initializerFactory(changeSet(type: PayloadType.none), isCache: true), + ], synchronizerSlots: []); + + harness.orchestrator.start(); + await harness.pump(); + + expect(harness.events.whereType(), hasLength(1)); + + harness.orchestrator.stop(); + }); + test('synchronizer change sets are emitted and update the selector', () async { final synchronizers = []; diff --git a/packages/common_client/test/data_sources/polling_data_source_test.dart b/packages/common_client/test/data_sources/polling_data_source_test.dart index 754c9844..a2ec0d56 100644 --- a/packages/common_client/test/data_sources/polling_data_source_test.dart +++ b/packages/common_client/test/data_sources/polling_data_source_test.dart @@ -66,6 +66,7 @@ class MockLogAdapter extends Mock implements LDLogAdapter {} shutdown: event.shutdown); } case PayloadEvent(): + case InitializedEvent(): break; } }).listen((_) {}); @@ -443,6 +444,7 @@ void main() { shutdown: event.shutdown); } case PayloadEvent(): + case InitializedEvent(): break; } }).listen((_) {}); @@ -506,6 +508,7 @@ void main() { shutdown: event.shutdown); } case PayloadEvent(): + case InitializedEvent(): break; } }).listen((_) {}); diff --git a/packages/common_client/test/data_sources/streaming_data_source_test.dart b/packages/common_client/test/data_sources/streaming_data_source_test.dart index 6296ddd2..8289457b 100644 --- a/packages/common_client/test/data_sources/streaming_data_source_test.dart +++ b/packages/common_client/test/data_sources/streaming_data_source_test.dart @@ -97,6 +97,7 @@ class MockSseClient implements SSEClient { shutdown: event.shutdown); } case PayloadEvent(): + case InitializedEvent(): break; } }).listen((_) {}); From b3b7c09481b3cf7d82db032552cd4c9e6e3aa306 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:03:37 -0700 Subject: [PATCH 14/20] test: lock FDv1 fallback ETag scoping to one requestor instance A fresh fallback synchronizer instance must not inherit a prior instance's ETag, which would let it receive a 304 for a connection it never made and keep stale data. The if-none-match header is per-requestor instance state. --- .../fdv2/fdv1_fallback_synchronizer_test.dart | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/common_client/test/data_sources/fdv2/fdv1_fallback_synchronizer_test.dart b/packages/common_client/test/data_sources/fdv2/fdv1_fallback_synchronizer_test.dart index 7aa4d752..8f6b9221 100644 --- a/packages/common_client/test/data_sources/fdv2/fdv1_fallback_synchronizer_test.dart +++ b/packages/common_client/test/data_sources/fdv2/fdv1_fallback_synchronizer_test.dart @@ -73,4 +73,36 @@ void main() { expect((result as StatusResult).state, SourceState.interrupted); expect(result.fdv1Fallback, isFalse); }); + + test('a fresh synchronizer instance does not inherit a prior ETag', () async { + final requests = []; + final mock = MockClient((req) async { + requests.add(req); + return http.Response( + '{"flagA":{"version":1,"value":true,"variation":0,' + '"reason":{"kind":"OFF"}}}', + 200, + headers: {'etag': 'etag-from-first-connection'}, + ); + }); + final factory = createFdv1FallbackSynchronizerFactory( + const Fdv1FallbackConfig(), _ctx(mock)); + + // First instance polls and receives an ETag. + final first = factory.create(() => Selector.empty); + await first.results.first; + first.close(); + + // A second instance (a new connection) must poll without if-none-match; + // were the ETag shared, it could receive a 304 for a request it never + // made and silently keep stale data. + final second = factory.create(() => Selector.empty); + await second.results.first; + second.close(); + + expect(requests, hasLength(2)); + expect(requests[0].headers.containsKey('if-none-match'), isFalse); + expect(requests[1].headers.containsKey('if-none-match'), isFalse, + reason: 'the ETag is scoped to a single requestor instance'); + }); } From 78bcd0cf3df2e2404a25aabcaad8e0da20d8ef01 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:48:15 -0700 Subject: [PATCH 15/20] refactor: model identify completion as a DataAvailability concept Replace the requireFreshData bool with minimumDataAvailability, of type DataAvailability { cached, fresh } -- matching js-core's cached/fresh data-availability concept and identifier. FDv2DataManager maps waitForNetworkResults -> fresh, otherwise cached. cached resolves on any applied data; fresh waits for the orchestrator's InitializedEvent. --- .../lib/src/data_sources/data_manager.dart | 4 ++- .../src/data_sources/data_source_manager.dart | 32 +++++++++++++------ .../data_source_manager_test.dart | 8 +++-- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_manager.dart b/packages/common_client/lib/src/data_sources/data_manager.dart index a8825d65..f5222df2 100644 --- a/packages/common_client/lib/src/data_sources/data_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_manager.dart @@ -72,7 +72,9 @@ final class FDv2DataManager implements DataManager { _clearSelector(); final completer = Completer(); _dataSourceManager.identify(context, completer, - requireFreshData: waitForNetworkResults); + minimumDataAvailability: waitForNetworkResults + ? DataAvailability.fresh + : DataAvailability.cached); return completer.future; } } diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index ec8cd0bc..f9e3032b 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -13,6 +13,18 @@ import 'data_source_status_manager.dart'; typedef DataSourceFactory = DataSource Function(LDContext context); +/// The minimum data availability an identify must reach before it +/// completes, mapped from the caller's wait-for-network-results +/// preference (`false` -> [cached], `true` -> [fresh]). +enum DataAvailability { + /// Resolve as soon as any data is applied, including a cache load. + cached, + + /// Wait for fresh network data (the orchestrator's InitializedEvent); + /// a cache load alone does not satisfy it. + fresh, +} + /// The data source manager controls which data source is connected to /// the data source status as well as the data source event handler. final class DataSourceManager { @@ -38,10 +50,10 @@ final class DataSourceManager { Completer? _identifyCompleter; - /// When true, the active identify resolves only on fresh data, not on a - /// cache load. Set per identify from the caller's wait-for-network-results - /// preference. - bool _requireFreshData = false; + /// The minimum data availability the active identify must reach before + /// it resolves. Set per identify from the caller's + /// wait-for-network-results preference. + DataAvailability _minimumDataAvailability = DataAvailability.cached; DataSourceManager({ ConnectionMode startingMode = ConnectionMode.streaming, @@ -67,9 +79,9 @@ final class DataSourceManager { } void identify(LDContext context, Completer completer, - {bool requireFreshData = false}) { + {DataAvailability minimumDataAvailability = DataAvailability.cached}) { _identifyCompleter = completer; - _requireFreshData = requireFreshData; + _minimumDataAvailability = minimumDataAvailability; _activeContext = context; _setupConnection(); @@ -188,10 +200,10 @@ final class DataSourceManager { if (_activeConnectionMode is! FDv2Offline) { _statusManager.setValid(); } - // A cached identify resolves on any applied data; a - // wait-for-network identify waits for the orchestrator's - // InitializedEvent instead. - if (!_requireFreshData) { + // A 'cached' identify resolves on any applied data; a 'fresh' + // identify waits for the orchestrator's InitializedEvent + // instead. + if (_minimumDataAvailability == DataAvailability.cached) { _maybeCompleteIdentify(); } } diff --git a/packages/common_client/test/data_sources/data_source_manager_test.dart b/packages/common_client/test/data_sources/data_source_manager_test.dart index 4b418035..c9b99c74 100644 --- a/packages/common_client/test/data_sources/data_source_manager_test.dart +++ b/packages/common_client/test/data_sources/data_source_manager_test.dart @@ -361,7 +361,7 @@ void main() { makeManager(context, factories, inStatusManager: statusManager); final completer = Completer(); - // requireFreshData defaults false (cached): resolves on any applied data. + // minimumDataAvailability defaults to cached: resolves on any applied data. manager.identify(context, completer); await completer.future; @@ -387,7 +387,8 @@ void main() { makeManager(context, factories, inStatusManager: statusManager); final completer = Completer(); - manager.identify(context, completer, requireFreshData: true); + manager.identify(context, completer, + minimumDataAvailability: DataAvailability.fresh); await completer.future; expect(statusManager.status.state, DataSourceState.valid); @@ -405,7 +406,8 @@ void main() { final manager = makeManager(context, factories); final completer = Completer(); - manager.identify(context, completer, requireFreshData: true); + manager.identify(context, completer, + minimumDataAvailability: DataAvailability.fresh); await pumpEventQueue(); expect(completer.isCompleted, isFalse, From aa2d079579a64f111c7aa105869ccfa382e01936 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:01:28 -0700 Subject: [PATCH 16/20] Update packages/common_client/lib/src/data_sources/data_source_manager.dart --- .../lib/src/data_sources/data_source_manager.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index f9e3032b..bf1aed73 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -112,9 +112,7 @@ final class DataSourceManager { } /// Resolves the pending identify, if any. Idempotent: only the first call - /// completes it. Callers decide *when* to call it -- a cached identify on - /// the first applied payload, a wait-for-network identify on the - /// orchestrator's [InitializedEvent]. + /// completes it. void _maybeCompleteIdentify() { final completer = _identifyCompleter; if (completer == null) { From ee36588bf434b8b964da7106804b6a343324a9c7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:01:40 -0700 Subject: [PATCH 17/20] Update packages/common_client/lib/src/data_sources/data_source_manager.dart --- .../lib/src/data_sources/data_source_manager.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index bf1aed73..54626324 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -150,11 +150,6 @@ final class DataSourceManager { switch (_activeConnectionMode) { case FDv2Offline(): - // Report why the SDK is offline. When an offline data source is - // configured (the FDv2 data system supplies one) it then loads - // cached flags through the pipeline below; its payload does not - // drive the status to valid while offline, so this status stands. - // FDv1 has no offline factory, so offline stays status-only. switch (_offlineDetail) { case OfflineSetOffline(): _statusManager.setOffline(); From 525fc3c60a68a205a758ec3f4591c9ee2b500ab9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:01:51 -0700 Subject: [PATCH 18/20] Update packages/common_client/lib/src/data_sources/data_source_manager.dart --- .../lib/src/data_sources/data_source_manager.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index 54626324..755fc55c 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -188,8 +188,7 @@ final class DataSourceManager { if (handled == MessageStatus.messageHandled) { // Applying any change set from a live source marks it valid -- // including a no-change response, which restores valid after an - // interruption. While offline the status set in _setupConnection - // stands, so cached data does not report a live connection. + // interruption. if (_activeConnectionMode is! FDv2Offline) { _statusManager.setValid(); } From 26e76667d776d808a6dd3135d7e7a8361e50a61f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:01:59 -0700 Subject: [PATCH 19/20] Update packages/common_client/lib/src/data_sources/data_manager.dart --- .../lib/src/data_sources/data_manager.dart | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_manager.dart b/packages/common_client/lib/src/data_sources/data_manager.dart index f5222df2..bad37d25 100644 --- a/packages/common_client/lib/src/data_sources/data_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_manager.dart @@ -47,19 +47,6 @@ final class FDv1DataManager implements DataManager { } } -/// FDv2 data manager. -/// -/// The cache is not loaded at identify time; the data source pipeline's -/// cache initializer loads it as the first tier. Identify resolves on the -/// first delivered payload, or -- when waiting for network results -- only -/// on fresh data, so a cache load alone does not satisfy a wait-for-network -/// identify. -/// -/// Each identify starts data acquisition fresh: any held selector is -/// discarded via [clearSelector] before connecting, so the new connection -/// re-fetches a full payload rather than resuming a previous context's -/// basis. Mode switches keep the selector and reach the data source manager -/// directly rather than through here, so they are unaffected. final class FDv2DataManager implements DataManager { final DataSourceManager _dataSourceManager; final void Function() _clearSelector; From 194eae78bb44946888b09b7fc9ab5b5dc6d95d44 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:02:09 -0700 Subject: [PATCH 20/20] Update packages/common_client/lib/src/data_sources/data_manager.dart --- .../common_client/lib/src/data_sources/data_manager.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_manager.dart b/packages/common_client/lib/src/data_sources/data_manager.dart index bad37d25..e31d9d73 100644 --- a/packages/common_client/lib/src/data_sources/data_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_manager.dart @@ -22,12 +22,6 @@ abstract interface class DataManager { {required bool waitForNetworkResults}); } -/// FDv1 data manager. -/// -/// The cache is loaded imperatively at identify time via -/// [FlagManager.loadCached]. A cache hit resolves identify immediately -/// unless the caller is waiting for network results; either way the -/// network connection is started so live data follows. final class FDv1DataManager implements DataManager { final DataSourceManager _dataSourceManager; final FlagManager _flagManager;