diff --git a/README.md b/README.md index a8eeb29..e3af02b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for .NET](https:/ | ✅ | Logging | The provider logs through the logging configuration of the `Configuration` it is given. | | ✅ | Domains | Domains bind clients to providers in the OpenFeature SDK; a separate provider instance may be registered per domain. | | ✅ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE`, and `PROVIDER_ERROR`. Flag changes are emitted as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. | -| ✅ | Initialization | `InitializeAsync` waits for the LaunchDarkly client to become ready or to fail permanently, and stops waiting if the cancellation token given to `SetProviderAsync` is canceled. It has no timeout of its own; `StartWaitTime` applies to the client constructor. | +| ✅ | Initialization | `InitializeAsync` reports whether the LaunchDarkly client became ready within the start wait time, which is `StartWaitTime` or the start wait time given to the provider constructor. `Timeout.InfiniteTimeSpan` waits indefinitely, until the cancellation token given to `SetProviderAsync` is canceled. | | ✅ | Shutdown | `ShutdownAsync` closes the LaunchDarkly client. A closed client cannot be restarted, so a new provider instance is required afterward. | | ✅ | Transaction Context Propagation | Provided by the OpenFeature SDK, which merges the transaction context into the evaluation context before the provider is called; no provider support is required. | | ✅ | Extending | The underlying LaunchDarkly client is available through `GetClient()` for functionality with no OpenFeature equivalent. | @@ -192,12 +192,18 @@ var inExperiment = details.FlagMetadata.GetBool("inExperiment") ?? false; #### Asynchronous Initialization -The LaunchDarkly SDK by default blocks on construction for up to 5 seconds for initialization. If you require construction to be non-blocking, then you can adjust the `startWaitTime` to `TimeSpan.Zero`. Initialization will be completed asynchronously and OpenFeature will emit a ready event when the provider has initialized. The `SetProviderAsync` method can be awaited to wait for the SDK to finish initialization. +The LaunchDarkly SDK by default blocks on construction for up to 5 seconds for initialization, and `SetProviderAsync` then reports whether the client became ready in that time. + +If you require construction to be non-blocking, then pass `TimeSpan.Zero` as the start wait time. Nothing is waited for, so `SetProviderAsync` reports a failed initialization, and OpenFeature emits a ready event once the provider becomes usable. ```csharp -var config = Configuration.Builder("my-sdk-key") - .StartWaitTime(TimeSpan.Zero) - .Build(); +var provider = new Provider(config, TimeSpan.Zero); +``` + +To wait for the provider to become ready without a deadline, pass `Timeout.InfiniteTimeSpan`. Construction does not block, and `SetProviderAsync` completes once the data source becomes valid or fails permanently. + +```csharp +var provider = new Provider(config, Timeout.InfiniteTimeSpan); ``` #### Provider Shutdown diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs index f0b1023..d364a65 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Channels; using System.Threading.Tasks; using LaunchDarkly.Logging; @@ -12,17 +13,20 @@ private sealed class StatusProvider { private ProviderStatus _providerStatus = ProviderStatus.NotReady; private string _statusMessage; - private bool _firstEvent = true; + private bool _startupWaitCanceled; private readonly object _statusLock = new object(); private readonly Channel _eventChannel; private readonly string _providerName; private readonly Logger _logger; + private readonly Func _initializationCompleted; - public StatusProvider(Channel eventChannel, string providerName, Logger logger) + public StatusProvider(Channel eventChannel, string providerName, Logger logger, + Func initializationCompleted) { _eventChannel = eventChannel; _providerName = providerName; _logger = logger; + _initializationCompleted = initializationCompleted; } private void EmitProviderEvent(ProviderEventTypes type, string message) @@ -63,7 +67,7 @@ public void StartupWaitCanceled() { lock (_statusLock) { - _firstEvent = false; + _startupWaitCanceled = true; if (_providerStatus != ProviderStatus.NotReady) { EmitStatusEvent(_providerStatus, _statusMessage); @@ -103,10 +107,11 @@ public void SetStatus(ProviderStatus status, string message = null) _providerStatus = status; _statusMessage = message; // The OpenFeature client will emit a ready or error event when initialization completes. - // We want to avoid duplicating that event. - if (_firstEvent) + // The status change which completes initialization is therefore not emitted here, to avoid + // duplicating that event. Status changes after initialization has completed, including after it + // has failed, or after the startup wait was canceled, are emitted. + if (!_startupWaitCanceled && !_initializationCompleted()) { - _firstEvent = false; return; } diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 4268a5c..eb089b5 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -32,7 +32,11 @@ public sealed partial class Provider : FeatureProvider private readonly StatusProvider _statusProvider; private readonly object _initLock = new object(); + // Guards recording a status together with the matching initialization outcome, so a concurrent data source + // status change cannot be interleaved between the two. + private readonly object _completionLock = new object(); private bool _initializeCalled = false; + private readonly bool _waitIndefinitely; // There is no support for void task completion, so we use bool as a dummy result type. private readonly TaskCompletionSource _initCompletion = new TaskCompletionSource(); @@ -41,11 +45,20 @@ public sealed partial class Provider : FeatureProvider private const string ProviderShutdownMessage = "the provider has encountered a permanent error or been shutdown"; - internal Provider(ILdClient client) + private const string ProviderNotReadyMessage = + "the LaunchDarkly client did not become ready within the start wait time"; + + internal Provider(ILdClient client) : this(client, true) + { + } + + internal Provider(ILdClient client, bool waitIndefinitely) { _client = client; + _waitIndefinitely = waitIndefinitely; _logger = _client.GetLogger().SubLogger(NameSpace); - _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger); + _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger, + () => _initCompletion.Task.IsCompleted); _contextConverter = new EvalContextConverter(_logger); } @@ -53,7 +66,33 @@ internal Provider(ILdClient client) /// Construct a new instance of the provider with the given configuration. /// /// A client configuration object - public Provider(Configuration config) : this(new LdClient(WrapConfig(config))) + public Provider(Configuration config) : this(new LdClient(WrapConfig(config)), false) + { + } + + /// + /// Construct a new instance of the provider with the given configuration and start wait time. + /// + /// + /// + /// The start wait time replaces the one configured with StartWaitTime and bounds the whole of + /// initialization: the LaunchDarkly client waits for up to that long while it is constructed, and + /// then reports whether it became ready. + /// + /// + /// waits nowhere, so initialization fails unless the client is already ready and + /// the application learns when it becomes usable from provider events. + /// waits indefinitely: nothing is waited for during construction, and + /// initialization does not complete until the data source becomes valid or fails permanently. + /// + /// + /// A client configuration object + /// How long to wait for the client to become ready, or + /// to wait indefinitely + public Provider(Configuration config, TimeSpan startWait) + : this(new LdClient(WrapConfig(config, + startWait == Timeout.InfiniteTimeSpan ? TimeSpan.Zero : startWait)), + startWait == Timeout.InfiniteTimeSpan) { } @@ -61,7 +100,7 @@ public Provider(Configuration config) : this(new LdClient(WrapConfig(config))) /// Construct a new instance of the provider with the given SDK key. /// /// The SDK key - public Provider(string sdkKey) : this(new LdClient(WrapConfig(Configuration.Builder(sdkKey).Build()))) + public Provider(string sdkKey) : this(new LdClient(WrapConfig(Configuration.Builder(sdkKey).Build())), false) { } @@ -92,6 +131,11 @@ private static Configuration WrapConfig(Configuration config) .Build(); } + private static Configuration WrapConfig(Configuration config, TimeSpan startWait) + { + return Configuration.Builder(WrapConfig(config)).StartWaitTime(startWait).Build(); + } + #region FeatureProvider Implementation /// @@ -147,16 +191,27 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke // then we could have missed a status change. If we check before registering a listener, then we could // miss a change between checking and listening. Doing it this way we can get duplicates, but we filter // when the status does not actually change, so we won't emit duplicate events. - if (_client.Initialized) + lock (_completionLock) { - _statusProvider.SetStatus(ProviderStatus.Ready); - _initCompletion.TrySetResult(true); - } + if (_client.Initialized) + { + _statusProvider.SetStatus(ProviderStatus.Ready); + _initCompletion.TrySetResult(true); + } - if (_client.DataSourceStatusProvider.Status.State == DataSourceState.Off) - { - _statusProvider.SetStatus(ProviderStatus.Error, ProviderShutdownMessage); - _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); + if (_client.DataSourceStatusProvider.Status.State == DataSourceState.Off) + { + _statusProvider.SetStatus(ProviderStatus.Error, ProviderShutdownMessage); + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); + } + + if (!_waitIndefinitely && !_initCompletion.Task.IsCompleted) + { + // The OpenFeature client emits an error event when initialization fails, so the status is + // recorded here, before initialization completes, without emitting an event of our own. + _statusProvider.SetStatus(ProviderStatus.Error, ProviderNotReadyMessage); + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderNotReadyMessage)); + } } return WaitForInitializationAsync(cancellationToken); @@ -218,6 +273,14 @@ await EventChannel.Writer.WriteAsync(new ProviderEventPayload } private void StatusChangeHandler(object sender, DataSourceStatus status) + { + lock (_completionLock) + { + HandleStatusChange(status); + } + } + + private void HandleStatusChange(DataSourceStatus status) { switch (status.State) { diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index 492243d..4eedbb4 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -85,6 +85,78 @@ public async Task ItHandlesValidInitializationWhenClientIsReadyAfterADelay() await provider.InitializeAsync(EvaluationContext.Empty); } + [Fact(Timeout = 5000)] + public async Task ItDoesNotWaitAgainWithAStartWaitTime() + { + var provider = new Provider(MakeNotReadyClient().Object, false); + + var exception = + await Record.ExceptionAsync(async () => await provider.InitializeAsync(EvaluationContext.Empty)); + Assert.NotNull(exception); + Assert.Equal("the LaunchDarkly client did not become ready within the start wait time", + exception.Message); + } + + [Fact(Timeout = 5000)] + public async Task ItWaitsIndefinitelyWithAnInfiniteStartWaitTime() + { + var mockDataSourceStatus = new Mock(); + var provider = new Provider(MakeNotReadyClient(mockDataSourceStatus).Object, true); + var initialization = provider.InitializeAsync(EvaluationContext.Empty); + + Assert.False(initialization.IsCompleted); + + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus {State = DataSourceState.Valid}); + + await initialization; + } + + [Fact(Timeout = 5000)] + public async Task ItReportsBecomingReadyAfterInitializationFailed() + { + var mockDataSourceStatus = new Mock(); + var provider = new Provider(MakeNotReadyClient(mockDataSourceStatus).Object, false); + + await Record.ExceptionAsync(async () => await provider.InitializeAsync(EvaluationContext.Empty)); + + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus {State = DataSourceState.Valid}); + + var payload = await provider.GetEventChannel().Reader.ReadAsync() as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderReady, payload?.Type); + } + + [Fact(Timeout = 5000)] + public void ItAppliesAStartWaitTimeToTheClientConfiguration() + { + var provider = new Provider(Configuration.Builder("") + .DataSource(Components.ExternalUpdatesOnly) + .Events(Components.NoEvents) + .Build(), TimeSpan.FromMilliseconds(50)); + + Assert.NotNull(provider.GetClient()); + } + + private static Mock MakeNotReadyClient(Mock mockDataSourceStatus = null) + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + + mockDataSourceStatus = mockDataSourceStatus ?? new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + mockClient.Setup(l => l.FlagTracker).Returns(new Mock().Object); + + return mockClient; + } + [Fact(Timeout = 5000)] public async Task ItStopsWaitingForInitializationWhenTheTokenIsCanceled() {