Skip to content

Commit 61a1e59

Browse files
authored
feat: Add the FDv2 data system and expose it through configuration (#310)
## What this adds The piece that ties the FDv2 building blocks together and turns them on: `FDv2DataSystem`, the manager routing that applies FDv2 payloads, and the configuration that opts in. This completes the FDv2 work in `common_client` — everything below the public Flutter API. - **`FDv2DataSystem`** composes the data source factories the `DataSourceManager` consumes. It owns the state that must outlive any single orchestrator: the current selector and the context it belongs to. A fresh orchestrator is built per connection-mode switch and per identify; the selector survives mode switches (initializers are skipped when a selector is already held) but resets when the context changes, since a selector is specific to one context. `buildFactories()` returns factories for streaming, polling, and background — offline has no data source and is handled by the manager directly. Custom connection modes from config replace the built-in definitions by name. - **`DataSourceManager` payload routing.** The `PayloadEvent` case — a no-op placeholder in the orchestrator PR — now applies the change set through `handlePayload` and completes the pending identify, the same way `DataEvent` does for FDv1. - **Configuration & exposure.** `DataSystemConfig` (providing it, even empty, opts into FDv2); `LDCommonConfig.dataSystem`; `LDCommonClient` constructs an `FDv2DataSystem` and installs its factories when `dataSystem` is configured, otherwise the FDv1 sources run unchanged; and the new public types are exported. When `dataSystem` is absent the FDv1 path is byte-for-byte unchanged, so existing behavior is unaffected. ## Testing - `DataSourceManager` test: a `PayloadEvent` is applied through `handlePayload`, marks the source valid, and completes identify (a dropped/no-op payload would leave identify hanging — this distinguishes the real routing from the placeholder). - `FDv2DataSystem` tests: `buildFactories` exposes streaming/polling/background and not offline; each factory builds a fresh data source per call; a custom connection mode replaces the built-in definition. - `DataSystemConfig` default (no custom modes). - Full `common_client` suite passes; `flutter_client_sdk` (the dependent package) analyzes and tests clean against these changes. - End-to-end behavior of the wired data system is exercised by the FDv2 contract tests, which land with the contract-service changes in a later PR (and pass on the integration branch today). SDK-2186 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Touches core flag acquisition, identify completion, connection-mode/offline behavior, and persistence read paths; incorrect timing could leave identifies hanging or serve stale flags, though FDv1 remains unchanged when `dataSystem` is unset. > > **Overview** > Opts the SDK into **FDv2** when `LDCommonConfig.dataSystem` is set (even empty), exports `DataSystemConfig` / `ConnectionModeId` and mode-definition types, and keeps the FDv1 path when `dataSystem` is absent. > > **`FDv2DataSystem`** builds per-mode orchestrator factories (streaming, polling, background, **offline** as a real cache-only pipeline), holds the **selector** across mode switches, and applies `connectionModes` overrides. **`FDv2DataManager`** clears the selector on each identify and maps `waitForNetworkResults` to cached vs fresh availability; FDv1 still loads cache at identify via **`FDv1DataManager`**. > > **`DataSourceManager`** applies **`PayloadEvent`** through `handlePayload`, sets **valid** on applied online payloads (including no-change restores), does not promote valid in offline mode, and completes identify on first applied data (cached) or **`InitializedEvent`** (fresh). **`InitializedEvent`** and orchestrator init semantics separate cache hits from network-ready state. Cache loads use **`readCached`** without touching the store until the pipeline applies payloads. > > Adds an **FDv1 fallback** synchronizer (FDv1 poll → FDv2 change sets) with loop guards when already on fallback. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 194eae7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent eb9a35f commit 61a1e59

26 files changed

Lines changed: 1316 additions & 76 deletions

packages/common_client/lib/launchdarkly_common_client.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ export 'src/ld_common_config.dart'
66
AutoEnvAttributes,
77
PollingConfig;
88

9+
export 'src/config/data_system_config.dart'
10+
show DataSystemConfig, ConnectionModeId;
11+
export 'src/data_sources/fdv2/mode_definition.dart'
12+
show
13+
ModeDefinition,
14+
EndpointConfig,
15+
InitializerEntry,
16+
SynchronizerEntry,
17+
CacheInitializer,
18+
PollingInitializer,
19+
StreamingInitializer,
20+
PollingSynchronizer,
21+
StreamingSynchronizer,
22+
Fdv1FallbackConfig;
23+
924
export 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'
1025
show
1126
LDContext,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import '../data_sources/fdv2/mode_definition.dart';
2+
3+
// Maintainer note (not public API): ConnectionModeId is a sealed
4+
// hierarchy rather than an enum so a custom-mode variant can be added
5+
// later without changing this surface. The planned extension is a custom
6+
// variant constructed as `ConnectionModeId.custom('my-mode')`:
7+
//
8+
// factory ConnectionModeId.custom(String name) = _CustomConnectionMode;
9+
// final class _CustomConnectionMode extends ConnectionModeId {
10+
// final String name;
11+
// const _CustomConnectionMode(this.name);
12+
// // value equality on name so it works as an override-map key
13+
// }
14+
//
15+
// A custom mode is a distinct type from a built-in, so the two share no
16+
// namespace: a custom id never equals a built-in id (even with the same
17+
// name), and so cannot collide with a current or future built-in. The
18+
// type is the namespace -- no name prefix is needed. This holds only
19+
// while custom modes stay typed; if one is ever reduced to a bare string
20+
// (logs, persistence) that reintroduces a shared string space where a
21+
// prefix would matter again.
22+
//
23+
// Equality split: the built-in values are const singletons relying on
24+
// canonical-instance identity, which lets a connectionModes map of only
25+
// built-in keys be a const map. A runtime-constructed custom variant must
26+
// carry value equality, so an override map holding a custom key would be
27+
// non-const. The built-in variant therefore must not override
28+
// `==`/`hashCode`.
29+
30+
/// Identifies a built-in connection mode whose data-source pipeline can be
31+
/// overridden through [DataSystemConfig.connectionModes]: [streaming],
32+
/// [polling], [background], or [offline].
33+
sealed class ConnectionModeId {
34+
const ConnectionModeId();
35+
36+
/// The built-in streaming mode.
37+
static const ConnectionModeId streaming = _BuiltInConnectionMode('streaming');
38+
39+
/// The built-in polling mode.
40+
static const ConnectionModeId polling = _BuiltInConnectionMode('polling');
41+
42+
/// The built-in background mode.
43+
static const ConnectionModeId background =
44+
_BuiltInConnectionMode('background');
45+
46+
/// The built-in offline mode. Its pipeline loads cached flags and runs
47+
/// no synchronizer, so overriding it customizes how the SDK behaves
48+
/// while offline (for example, the cache initializer it uses).
49+
static const ConnectionModeId offline = _BuiltInConnectionMode('offline');
50+
}
51+
52+
final class _BuiltInConnectionMode extends ConnectionModeId {
53+
final String name;
54+
55+
const _BuiltInConnectionMode(this.name);
56+
57+
@override
58+
String toString() => 'ConnectionModeId.$name';
59+
}
60+
61+
/// Configuration for the FDv2 data system.
62+
///
63+
/// Providing a [DataSystemConfig] (even an empty one) opts the SDK into
64+
/// the FDv2 data acquisition protocol. When absent the SDK uses the
65+
/// FDv1 data sources.
66+
///
67+
/// This feature is not stable, and not subject to any backwards
68+
/// compatibility guarantees or semantic versioning. It is in early
69+
/// access. If you want access to this feature please join the EAP.
70+
final class DataSystemConfig {
71+
/// Overrides for built-in connection modes. A definition given here
72+
/// replaces the built-in pipeline for that mode; modes not present keep
73+
/// their built-in definition.
74+
final Map<ConnectionModeId, ModeDefinition> connectionModes;
75+
76+
const DataSystemConfig({
77+
this.connectionModes = const {},
78+
});
79+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import 'dart:async';
2+
3+
import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'
4+
show LDContext;
5+
6+
import '../flag_manager/flag_manager.dart';
7+
import 'data_source_manager.dart';
8+
9+
/// Owns the data-acquisition strategy for an identify: how the cache is
10+
/// loaded and when the identify resolves. The FDv1 and FDv2 protocols
11+
/// diverge here, so each has its own implementation; everything else
12+
/// (connection lifecycle, mode switching, event routing) is shared in the
13+
/// [DataSourceManager] that both delegate to.
14+
abstract interface class DataManager {
15+
/// Brings the SDK to a usable state for [context], resolving when the
16+
/// manager's data-availability strategy is satisfied.
17+
///
18+
/// When [waitForNetworkResults] is true the returned future resolves
19+
/// only once network (or otherwise fresh) data has arrived; otherwise it
20+
/// may resolve as soon as cached data is available.
21+
Future<void> identify(LDContext context,
22+
{required bool waitForNetworkResults});
23+
}
24+
25+
final class FDv1DataManager implements DataManager {
26+
final DataSourceManager _dataSourceManager;
27+
final FlagManager _flagManager;
28+
29+
FDv1DataManager(this._dataSourceManager, this._flagManager);
30+
31+
@override
32+
Future<void> identify(LDContext context,
33+
{required bool waitForNetworkResults}) async {
34+
final completer = Completer<void>();
35+
final loadedFromCache = await _flagManager.loadCached(context);
36+
_dataSourceManager.identify(context, completer);
37+
if (loadedFromCache && !waitForNetworkResults) {
38+
return;
39+
}
40+
return completer.future;
41+
}
42+
}
43+
44+
final class FDv2DataManager implements DataManager {
45+
final DataSourceManager _dataSourceManager;
46+
final void Function() _clearSelector;
47+
48+
FDv2DataManager(this._dataSourceManager, this._clearSelector);
49+
50+
@override
51+
Future<void> identify(LDContext context,
52+
{required bool waitForNetworkResults}) {
53+
_clearSelector();
54+
final completer = Completer<void>();
55+
_dataSourceManager.identify(context, completer,
56+
minimumDataAvailability: waitForNetworkResults
57+
? DataAvailability.fresh
58+
: DataAvailability.cached);
59+
return completer.future;
60+
}
61+
}

packages/common_client/lib/src/data_sources/data_source.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ final class StatusEvent implements DataSourceEvent {
3131
{this.shutdown = false});
3232
}
3333

34+
/// Emitted once by the FDv2 orchestrator when initialization is complete:
35+
/// a selector-bearing payload arrived, the initializer chain was exhausted
36+
/// (with cached data or in a cache-only system), or the first synchronizer
37+
/// delivered a change set. The manager resolves a wait-for-network identify
38+
/// on this; a cached identify resolves earlier, on the first applied payload.
39+
final class InitializedEvent implements DataSourceEvent {}
40+
3441
abstract interface class DataSource {
3542
Stream<DataSourceEvent> get events;
3643

packages/common_client/lib/src/data_sources/data_source_event_handler.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,13 @@ final class DataSourceEventHandler {
101101
///
102102
/// Full change sets replace the stored flags, partial change sets apply
103103
/// each update, and a change set of type none confirms the SDK is up to
104-
/// date without changing data. All three mark the data source valid.
104+
/// date without changing data.
105105
Future<MessageStatus> handlePayload(LDContext context, ChangeSet changeSet,
106106
{String? environmentId}) async {
107107
try {
108108
await _flagManager.applyChanges(
109109
context, changeSet.updates, changeSet.type,
110110
environmentId: environmentId);
111-
_statusManager.setValid();
112111
return MessageStatus.messageHandled;
113112
} catch (err) {
114113
_logger.error('Failed to apply an FDv2 change set: ${err.runtimeType}');

packages/common_client/lib/src/data_sources/data_source_manager.dart

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ import 'data_source_status_manager.dart';
1313

1414
typedef DataSourceFactory = DataSource Function(LDContext context);
1515

16+
/// The minimum data availability an identify must reach before it
17+
/// completes, mapped from the caller's wait-for-network-results
18+
/// preference (`false` -> [cached], `true` -> [fresh]).
19+
enum DataAvailability {
20+
/// Resolve as soon as any data is applied, including a cache load.
21+
cached,
22+
23+
/// Wait for fresh network data (the orchestrator's InitializedEvent);
24+
/// a cache load alone does not satisfy it.
25+
fresh,
26+
}
27+
1628
/// The data source manager controls which data source is connected to
1729
/// the data source status as well as the data source event handler.
1830
final class DataSourceManager {
@@ -38,6 +50,11 @@ final class DataSourceManager {
3850

3951
Completer<void>? _identifyCompleter;
4052

53+
/// The minimum data availability the active identify must reach before
54+
/// it resolves. Set per identify from the caller's
55+
/// wait-for-network-results preference.
56+
DataAvailability _minimumDataAvailability = DataAvailability.cached;
57+
4158
DataSourceManager({
4259
ConnectionMode startingMode = ConnectionMode.streaming,
4360
required DataSourceStatusManager statusManager,
@@ -61,8 +78,10 @@ final class DataSourceManager {
6178
_dataSourceFactories.addAll(factories);
6279
}
6380

64-
void identify(LDContext context, Completer<void> completer) {
81+
void identify(LDContext context, Completer<void> completer,
82+
{DataAvailability minimumDataAvailability = DataAvailability.cached}) {
6583
_identifyCompleter = completer;
84+
_minimumDataAvailability = minimumDataAvailability;
6685
_activeContext = context;
6786

6887
_setupConnection();
@@ -92,6 +111,19 @@ final class DataSourceManager {
92111
_activeDataSource = null;
93112
}
94113

114+
/// Resolves the pending identify, if any. Idempotent: only the first call
115+
/// completes it.
116+
void _maybeCompleteIdentify() {
117+
final completer = _identifyCompleter;
118+
if (completer == null) {
119+
return;
120+
}
121+
if (!completer.isCompleted) {
122+
completer.complete();
123+
}
124+
_identifyCompleter = null;
125+
}
126+
95127
DataSource? _createDataSource(FDv2ConnectionMode mode) {
96128
if (_activeContext != null) {
97129
if (_dataSourceFactories[mode] == null) {
@@ -126,7 +158,6 @@ final class DataSourceManager {
126158
case OfflineBackgroundDisabled():
127159
_statusManager.setBackgroundDisabled();
128160
}
129-
return;
130161
case FDv2Streaming():
131162
case FDv2Polling():
132163
case FDv2Background():
@@ -146,22 +177,35 @@ final class DataSourceManager {
146177
var handled = await _dataSourceEventHandler.handleMessage(
147178
_activeContext!, event.type, event.data,
148179
environmentId: event.environmentId);
149-
if (handled == MessageStatus.messageHandled &&
150-
_identifyCompleter != null) {
151-
if (_identifyCompleter!.isCompleted) {
152-
_logger.error('Identify was already complete before receiving '
153-
'data. This could represent an issue with SDK logic. Please'
154-
'make a bug report if you encounter this situation.');
155-
} else {
156-
_identifyCompleter!.complete();
157-
}
180+
if (handled == MessageStatus.messageHandled) {
181+
_maybeCompleteIdentify();
158182
}
159-
// Only need to complete this the first time.
160-
_identifyCompleter = null;
161183
return handled;
162184
case PayloadEvent():
163-
// The FDv1 data sources this manager runs never produce FDv2
164-
// payload events.
185+
var handled = await _dataSourceEventHandler.handlePayload(
186+
_activeContext!, event.changeSet,
187+
environmentId: event.environmentId);
188+
if (handled == MessageStatus.messageHandled) {
189+
// Applying any change set from a live source marks it valid --
190+
// including a no-change response, which restores valid after an
191+
// interruption.
192+
if (_activeConnectionMode is! FDv2Offline) {
193+
_statusManager.setValid();
194+
}
195+
// A 'cached' identify resolves on any applied data; a 'fresh'
196+
// identify waits for the orchestrator's InitializedEvent
197+
// instead.
198+
if (_minimumDataAvailability == DataAvailability.cached) {
199+
_maybeCompleteIdentify();
200+
}
201+
}
202+
return handled;
203+
case InitializedEvent():
204+
// Initialization is complete (network basis, initializer
205+
// exhaustion, or the first synchronizer change set). Resolves a
206+
// wait-for-network identify; a cached identify has usually resolved
207+
// already on earlier data.
208+
_maybeCompleteIdentify();
165209
return MessageStatus.messageHandled;
166210
case StatusEvent():
167211
if (_identifyCompleter != null && !_identifyCompleter!.isCompleted) {

0 commit comments

Comments
 (0)