-
Notifications
You must be signed in to change notification settings - Fork 19
feat: Add the FDv2 data system and expose it through configuration #310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6225462
6c00880
7f96be5
03fa4eb
beda263
99aa536
dc91b1c
4995258
fd9cc70
f601d15
9083654
89b6bac
a980153
b3b7c09
78bcd0c
aa2d079
ee36588
525fc3c
26e7666
194eae7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||
| } | ||
|
|
||
| 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 {}, | ||
| }); | ||
| } | ||
| 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 |
|---|---|---|
|
|
@@ -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,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, | ||
|
|
@@ -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(); | ||
|
|
@@ -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) { | ||
|
|
@@ -126,7 +158,6 @@ final class DataSourceManager { | |
| case OfflineBackgroundDisabled(): | ||
| _statusManager.setBackgroundDisabled(); | ||
| } | ||
| return; | ||
| case FDv2Streaming(): | ||
| case FDv2Polling(): | ||
| case FDv2Background(): | ||
|
|
@@ -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(); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 It would also eliminate these situations of back to back signals. I think then the InitializedEvent indicates the orchestrator is in steady state, which is a way identify completes as well.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.