Skip to content
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
using LaunchDarkly.Logging;
Expand All @@ -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<object> _eventChannel;
private readonly string _providerName;
private readonly Logger _logger;
private readonly Func<bool> _initializationCompleted;

public StatusProvider(Channel<object> eventChannel, string providerName, Logger logger)
public StatusProvider(Channel<object> eventChannel, string providerName, Logger logger,
Func<bool> initializationCompleted)
{
_eventChannel = eventChannel;
_providerName = providerName;
_logger = logger;
_initializationCompleted = initializationCompleted;
}

private void EmitProviderEvent(ProviderEventTypes type, string message)
Expand Down Expand Up @@ -63,7 +67,7 @@ public void StartupWaitCanceled()
{
lock (_statusLock)
{
_firstEvent = false;
_startupWaitCanceled = true;
if (_providerStatus != ProviderStatus.NotReady)
{
EmitStatusEvent(_providerStatus, _statusMessage);
Expand Down Expand Up @@ -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;
}

Expand Down
87 changes: 75 additions & 12 deletions src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> _initCompletion = new TaskCompletionSource<bool>();
Expand All @@ -41,27 +45,62 @@ 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);
}

/// <summary>
/// Construct a new instance of the provider with the given configuration.
/// </summary>
/// <param name="config">A client configuration object</param>
public Provider(Configuration config) : this(new LdClient(WrapConfig(config)))
public Provider(Configuration config) : this(new LdClient(WrapConfig(config)), false)
{
}

/// <summary>
/// Construct a new instance of the provider with the given configuration and start wait time.
/// </summary>
/// <remarks>
/// <para>
/// The start wait time replaces the one configured with <c>StartWaitTime</c> and bounds the whole of
/// initialization: the LaunchDarkly client waits for up to that long while it is constructed, and
/// <see cref="InitializeAsync"/> then reports whether it became ready.
/// </para>
/// <para>
/// <see cref="TimeSpan.Zero"/> waits nowhere, so initialization fails unless the client is already ready and
/// the application learns when it becomes usable from provider events.
/// <see cref="Timeout.InfiniteTimeSpan"/> waits indefinitely: nothing is waited for during construction, and
/// initialization does not complete until the data source becomes valid or fails permanently.
/// </para>
/// </remarks>
/// <param name="config">A client configuration object</param>
/// <param name="startWait">How long to wait for the client to become ready, or
/// <see cref="Timeout.InfiniteTimeSpan"/> to wait indefinitely</param>
public Provider(Configuration config, TimeSpan startWait)
: this(new LdClient(WrapConfig(config,
startWait == Timeout.InfiniteTimeSpan ? TimeSpan.Zero : startWait)),
startWait == Timeout.InfiniteTimeSpan)
{
}

/// <summary>
/// Construct a new instance of the provider with the given SDK key.
/// </summary>
/// <param name="sdkKey">The SDK key</param>
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)
{
}

Expand Down Expand Up @@ -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

/// <inheritdoc />
Expand Down Expand Up @@ -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));
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

return WaitForInitializationAsync(cancellationToken);
Expand Down Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDataSourceStatusProvider>();
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<IDataSourceStatusProvider>();
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<ILdClient> MakeNotReadyClient(Mock<IDataSourceStatusProvider> mockDataSourceStatus = null)
{
var mockClient = new Mock<ILdClient>();
mockClient.Setup(l => l.GetLogger())
.Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null));

mockDataSourceStatus = mockDataSourceStatus ?? new Mock<IDataSourceStatusProvider>();
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<IFlagTracker>().Object);

return mockClient;
}

[Fact(Timeout = 5000)]
public async Task ItStopsWaitingForInitializationWhenTheTokenIsCanceled()
{
Expand Down
Loading