Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6225462
feat: Add the FDv2 data system and expose it through configuration
kinyoklion Jun 17, 2026
6c00880
feat: load the FDv2 cache through the pipeline and run offline as a d…
kinyoklion Jun 18, 2026
7f96be5
refactor: reset the FDv2 basis at identify, not in the data source fa…
kinyoklion Jun 18, 2026
03fa4eb
test: verify the FDv2 mode override is selected at the resolution layer
kinyoklion Jun 18, 2026
beda263
fix: lower the meta constraint to 1.12.0 for the older Flutter toolchain
kinyoklion Jun 22, 2026
99aa536
refactor: drive identify completion and validity from changeset type …
kinyoklion Jun 22, 2026
dc91b1c
fix: clear the FDv2 selector on every identify instead of tracking co…
kinyoklion Jun 22, 2026
4995258
docs: drop the handlePayload status note that explained removed behavior
kinyoklion Jun 22, 2026
fd9cc70
docs: describe selector clearing as it works now, not the old design
kinyoklion Jun 22, 2026
f601d15
docs: describe the cache reader by what it does, not vs the FDv1 path
kinyoklion Jun 22, 2026
9083654
refactor: rename _completeIdentify to _maybeCompleteIdentify
kinyoklion Jun 22, 2026
89b6bac
fix: restore valid on a no-change server response after an interruption
kinyoklion Jun 22, 2026
a980153
feat: connect FDv1 fallback; decide valid/initialized structurally
kinyoklion Jun 22, 2026
b3b7c09
test: lock FDv1 fallback ETag scoping to one requestor instance
kinyoklion Jun 23, 2026
78bcd0c
refactor: model identify completion as a DataAvailability concept
kinyoklion Jun 25, 2026
aa2d079
Update packages/common_client/lib/src/data_sources/data_source_manage…
kinyoklion Jun 25, 2026
ee36588
Update packages/common_client/lib/src/data_sources/data_source_manage…
kinyoklion Jun 25, 2026
525fc3c
Update packages/common_client/lib/src/data_sources/data_source_manage…
kinyoklion Jun 25, 2026
26e7666
Update packages/common_client/lib/src/data_sources/data_manager.dart
kinyoklion Jun 25, 2026
194eae7
Update packages/common_client/lib/src/data_sources/data_manager.dart
kinyoklion Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/common_client/lib/launchdarkly_common_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions packages/common_client/lib/src/config/data_system_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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], [background], or [offline].
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');

/// 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');
}
Comment thread
kinyoklion marked this conversation as resolved.

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<ConnectionModeId, ModeDefinition> connectionModes;

const DataSystemConfig({
this.connectionModes = const {},
});
}
61 changes: 61 additions & 0 deletions packages/common_client/lib/src/data_sources/data_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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<void> identify(LDContext context,
{required bool waitForNetworkResults});
}

final class FDv1DataManager implements DataManager {
final DataSourceManager _dataSourceManager;
final FlagManager _flagManager;

FDv1DataManager(this._dataSourceManager, this._flagManager);

@override
Future<void> identify(LDContext context,
{required bool waitForNetworkResults}) async {
final completer = Completer<void>();
final loadedFromCache = await _flagManager.loadCached(context);
_dataSourceManager.identify(context, completer);
if (loadedFromCache && !waitForNetworkResults) {
return;
}
return completer.future;
}
}

final class FDv2DataManager implements DataManager {
final DataSourceManager _dataSourceManager;
final void Function() _clearSelector;

FDv2DataManager(this._dataSourceManager, this._clearSelector);

@override
Future<void> identify(LDContext context,
{required bool waitForNetworkResults}) {
_clearSelector();
final completer = Completer<void>();
_dataSourceManager.identify(context, completer,
minimumDataAvailability: waitForNetworkResults
? DataAvailability.fresh
: DataAvailability.cached);
return completer.future;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<DataSourceEvent> get events;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,13 @@ 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.
Future<MessageStatus> 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}');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -38,6 +50,11 @@ final class DataSourceManager {

Completer<void>? _identifyCompleter;

/// 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,
required DataSourceStatusManager statusManager,
Expand All @@ -61,8 +78,10 @@ final class DataSourceManager {
_dataSourceFactories.addAll(factories);
}

void identify(LDContext context, Completer<void> completer) {
void identify(LDContext context, Completer<void> completer,
{DataAvailability minimumDataAvailability = DataAvailability.cached}) {
_identifyCompleter = completer;
_minimumDataAvailability = minimumDataAvailability;
_activeContext = context;

_setupConnection();
Expand Down Expand Up @@ -92,6 +111,19 @@ final class DataSourceManager {
_activeDataSource = null;
}

/// Resolves the pending identify, if any. Idempotent: only the first call
/// completes it.
void _maybeCompleteIdentify() {
final completer = _identifyCompleter;
if (completer == null) {
return;
}
if (!completer.isCompleted) {
completer.complete();
}
_identifyCompleter = null;
}

DataSource? _createDataSource(FDv2ConnectionMode mode) {
if (_activeContext != null) {
if (_dataSourceFactories[mode] == null) {
Expand Down Expand Up @@ -126,7 +158,6 @@ final class DataSourceManager {
case OfflineBackgroundDisabled():
_statusManager.setBackgroundDisabled();
}
return;
case FDv2Streaming():
case FDv2Polling():
case FDv2Background():
Expand All @@ -146,22 +177,35 @@ 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();
}
if (handled == MessageStatus.messageHandled) {
_maybeCompleteIdentify();
}
// Only need to complete this the first time.
_identifyCompleter = null;
return handled;
case PayloadEvent():
// The FDv1 data sources this manager runs never produce FDv2
// payload events.
var handled = await _dataSourceEventHandler.handlePayload(
_activeContext!, event.changeSet,
environmentId: event.environmentId);
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.
if (_activeConnectionMode is! FDv2Offline) {
_statusManager.setValid();
}
// A 'cached' identify resolves on any applied data; a 'fresh'
// identify waits for the orchestrator's InitializedEvent
// instead.
if (_minimumDataAvailability == DataAvailability.cached) {
_maybeCompleteIdentify();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect a comparison here like payload.dataAvailable >= _minimumDataAvailability. Then all paths through which the payload itself completes identify would happen here (and not only cached here, while fresh or exhaustion happens in InitializedEvent).

Having the payload include the data availability could also eliminate the need for the _cacheOnlyDataSystem boolean and associated special logic in the orchestrator.

It would also eliminate these situations of back to back signals.

          _emitPayload(result);
          _emitInitialized();

I think then the InitializedEvent indicates the orchestrator is in steady state, which is a way identify completes as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We aren't directly driving the behavior 1 to 1 with a payload. But what types of signals we have over time.

Sometimes they are coupled, but other times they are independent. If payloads were always the direct transition, then that would work.

We get a payload, and we are waiting for "fresh" data.
We handle several more data sources and don't get any better.
The orchestrator then decides we are initialized. Independent of the payloads.

It would be directly handled in the orchestrator, but we actually have some situations which are outside its control, which is why it is handled at this level.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically the cases for whenever they aren't paired, the cases where they are paired is technically redundant. Otherwise this state is already represented in the selector, which the payload already has.

}
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) {
Expand Down
Loading
Loading