Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion 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. It has no timeout of its own; `StartWaitTime` applies to the client constructor. |
| ✅ | 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. |
| ✅ | 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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public sealed partial class Provider
private sealed class StatusProvider
{
private ProviderStatus _providerStatus = ProviderStatus.NotReady;
private string _statusMessage;
private bool _firstEvent = true;
private readonly object _statusLock = new object();
private readonly Channel<object> _eventChannel;
Expand Down Expand Up @@ -53,6 +54,43 @@ private async Task SafeWrite(ProviderEventPayload payload)
}
}

/// <summary>
/// Handles a startup wait which was canceled instead of completing. The OpenFeature SDK reports the
/// cancellation as the initialization outcome, so the current status is emitted as an event and later
/// status changes are no longer suppressed as duplicates of the startup event.
/// </summary>
public void StartupWaitCanceled()
{
lock (_statusLock)
{
_firstEvent = false;
if (_providerStatus != ProviderStatus.NotReady)
{
EmitStatusEvent(_providerStatus, _statusMessage);
}
}
}

private void EmitStatusEvent(ProviderStatus status, string message)
{
switch (status)
{
case ProviderStatus.NotReady:
break;
case ProviderStatus.Ready:
EmitProviderEvent(ProviderEventTypes.ProviderReady, message);
break;
case ProviderStatus.Stale:
EmitProviderEvent(ProviderEventTypes.ProviderStale, message);
break;
case ProviderStatus.Error:
case ProviderStatus.Fatal:
default:
EmitProviderEvent(ProviderEventTypes.ProviderError, message);
break;
}
}

public void SetStatus(ProviderStatus status, string message = null)
{
lock (_statusLock)
Expand All @@ -63,29 +101,16 @@ 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)
{
_firstEvent = false;
return;
}
switch (status)
{
case ProviderStatus.NotReady:
break;
case ProviderStatus.Ready:
EmitProviderEvent(ProviderEventTypes.ProviderReady, message);
break;
case ProviderStatus.Stale:
EmitProviderEvent(ProviderEventTypes.ProviderStale, message);
break;
case ProviderStatus.Error:
case ProviderStatus.Fatal:
default:
EmitProviderEvent(ProviderEventTypes.ProviderError, message);
break;
}

EmitStatusEvent(status, message);
}
}
}
Expand Down
25 changes: 23 additions & 2 deletions src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke
{
if (_initializeCalled)
{
return _initCompletion.Task;
return WaitForInitializationAsync(cancellationToken);
}
_initializeCalled = true;
}
Expand All @@ -159,7 +159,28 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke
_initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage));
}

return _initCompletion.Task;
return WaitForInitializationAsync(cancellationToken);
}

private async Task WaitForInitializationAsync(CancellationToken cancellationToken)
{
if (!cancellationToken.CanBeCanceled)
{
await _initCompletion.Task.ConfigureAwait(false);
return;
}

var cancellation = new TaskCompletionSource<bool>();
using (cancellationToken.Register(() => cancellation.TrySetCanceled(cancellationToken)))
{
var completed = await Task.WhenAny(_initCompletion.Task, cancellation.Task).ConfigureAwait(false);
if (completed != _initCompletion.Task)
{
_statusProvider.StartupWaitCanceled();
}
Comment thread
cursor[bot] marked this conversation as resolved.

await completed.ConfigureAwait(false);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Timers;
Expand All @@ -6,6 +7,7 @@
using LaunchDarkly.Sdk.Server;
using LaunchDarkly.Sdk.Server.Interfaces;
using Moq;
using OpenFeature.Constant;
using OpenFeature.Model;
using Xunit;
using LaunchDarkly.Sdk.Server.Integrations;
Expand Down Expand Up @@ -83,6 +85,95 @@ public async Task ItHandlesValidInitializationWhenClientIsReadyAfterADelay()
await provider.InitializeAsync(EvaluationContext.Empty);
}

[Fact(Timeout = 5000)]
public async Task ItStopsWaitingForInitializationWhenTheTokenIsCanceled()
{
var mockClient = new Mock<ILdClient>();
mockClient.Setup(l => l.GetLogger())
.Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null));

var mockDataSourceStatus = new Mock<IDataSourceStatusProvider>();
mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus
{
State = DataSourceState.Initializing
});
mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object);

var mockFlagTracker = new Mock<IFlagTracker>();
mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object);

var provider = new Provider(mockClient.Object);

using (var cancellation = new System.Threading.CancellationTokenSource(100))
{
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => provider.InitializeAsync(EvaluationContext.Empty, cancellation.Token));
}
}

[Fact(Timeout = 5000)]
public async Task ItStopsWaitingForAnInProgressInitializationWhenTheTokenIsCanceled()
{
var mockClient = new Mock<ILdClient>();
mockClient.Setup(l => l.GetLogger())
.Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null));

var mockDataSourceStatus = new Mock<IDataSourceStatusProvider>();
mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus
{
State = DataSourceState.Initializing
});
mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object);

var mockFlagTracker = new Mock<IFlagTracker>();
mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object);

var provider = new Provider(mockClient.Object);

var firstInitialization = provider.InitializeAsync(EvaluationContext.Empty);

using (var cancellation = new System.Threading.CancellationTokenSource(100))
{
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => provider.InitializeAsync(EvaluationContext.Empty, cancellation.Token));
}

Assert.False(firstInitialization.IsCompleted);
}

[Fact(Timeout = 5000)]
public async Task ItEmitsAReadyEventAfterACanceledInitializationWait()
{
var mockClient = new Mock<ILdClient>();
mockClient.Setup(l => l.GetLogger())
.Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null));

var mockDataSourceStatus = new Mock<IDataSourceStatusProvider>();
mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus
{
State = DataSourceState.Initializing
});
mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object);

var mockFlagTracker = new Mock<IFlagTracker>();
mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object);

var provider = new Provider(mockClient.Object);

using (var cancellation = new System.Threading.CancellationTokenSource(100))
{
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => provider.InitializeAsync(EvaluationContext.Empty, cancellation.Token));
}

mockDataSourceStatus.Raise(e => e.StatusChanged += null,
mockDataSourceStatus.Object,
new DataSourceStatus {State = DataSourceState.Valid});

var readyEvent = await provider.GetEventChannel().Reader.ReadAsync() as ProviderEventPayload;
Assert.Equal(ProviderEventTypes.ProviderReady, readyEvent?.Type);
}

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