diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 971d3c7..6de1263 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -41,8 +41,11 @@ 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 readonly TimeSpan? _initTimeout; + + internal Provider(ILdClient client, TimeSpan? initTimeout = null) { + _initTimeout = initTimeout; _client = client; _logger = _client.GetLogger().SubLogger(NameSpace); _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger); @@ -53,7 +56,7 @@ 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)), InitTimeout(config)) { } @@ -61,7 +64,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(Configuration.Builder(sdkKey).Build()) { } @@ -159,6 +162,11 @@ public override Task InitializeAsync(EvaluationContext context, CancellationToke _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); } + if (_initTimeout.HasValue && !_initCompletion.Task.IsCompleted) + { + ScheduleInitTimeout(_initTimeout.Value); + } + return _initCompletion.Task; } @@ -174,6 +182,33 @@ public override Task ShutdownAsync(CancellationToken cancellationToken = default #endregion + /// + /// A start wait time of zero means the caller does not want to block on initialization at all, so the provider + /// waits indefinitely and leaves it to the caller to decide how long to wait. + /// + private static TimeSpan? InitTimeout(Configuration config) => + config.StartWaitTime > TimeSpan.Zero ? config.StartWaitTime : (TimeSpan?)null; + + private void ScheduleInitTimeout(TimeSpan timeout) + { + var message = $"the provider did not become ready within {timeout.TotalMilliseconds}ms"; + Task.Delay(timeout).ContinueWith(_ => + { + lock (_initLock) + { + if (_initCompletion.Task.IsCompleted) + { + return; + } + + _logger.Warn(message); + // The client keeps trying to connect, so a later successful connection will emit a ready event. + _statusProvider.SetStatus(ProviderStatus.Error, message); + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message)); + } + }).ConfigureAwait(false); + } + private void FlagChangeHandler(object sender, FlagChangeEvent changeEvent) { Task.Run(() => SafeWriteChangeEvent(changeEvent)).ConfigureAwait(false); @@ -203,8 +238,11 @@ private void StatusChangeHandler(object sender, DataSourceStatus status) case DataSourceState.Initializing: break; case DataSourceState.Valid: - _statusProvider.SetStatus(ProviderStatus.Ready); - _initCompletion.TrySetResult(true); + lock (_initLock) + { + _statusProvider.SetStatus(ProviderStatus.Ready); + _initCompletion.TrySetResult(true); + } break; case DataSourceState.Interrupted: // The "ProviderStatus.Error" state says it is unable to evaluate flags. We can always evaluate diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs index fd1ab08..337b4f5 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ClientIntegrationTests.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; using LaunchDarkly.Logging; @@ -152,6 +153,51 @@ public async Task ItCanEvaluateFlagsAfterTheDataSourceHasBeenShutdown() Assert.True(await client.GetBooleanValueAsync("the-flag", false, EvaluationContext.Builder().Set("targetingKey", "the-key").Build())); } + + [Fact(Timeout = 5000)] + public async Task ItBecomesReadyAfterInitializationTimesOut() + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + mockClient.Setup(l => l.BoolVariationDetail("the-flag", It.IsAny(), false)) + .Returns(new Sdk.EvaluationDetail(true, 10, Sdk.EvaluationReason.FallthroughReason)); + + var mockDataSourceStatus = new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + + var mockFlagTracker = new Mock(); + mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object); + + var provider = new Provider(mockClient.Object, TimeSpan.FromMilliseconds(50)); + + await Api.Instance.SetProviderAsync(provider); + + // The handler is added after the failed initialization, otherwise it would be immediately invoked for + // the state of any previously registered provider. + var readyCount = 0; + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, + details => { Interlocked.Increment(ref readyCount); }); + + var context = EvaluationContext.Builder().Set("targetingKey", "the-key").Build(); + + // A timed out initialization does not short-circuit evaluations. + Assert.True(await Api.Instance.GetClient().GetBooleanValueAsync("the-flag", false, context)); + + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus { State = DataSourceState.Valid }); + + // The initialization timeout does not stop the client from connecting, so a later connection makes the + // provider ready. + Thread.Sleep(100); + Assert.Equal(1, readyCount); + Assert.True(await Api.Instance.GetClient().GetBooleanValueAsync("the-flag", false, context)); + } #endif } } diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index ec642dd..ec6ca10 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; using System.Timers; @@ -129,6 +130,78 @@ public async Task ItHandlesFailedInitialization() Assert.Equal("the provider has encountered a permanent error or been shutdown", exception.Message); } + [Fact(Timeout = 5000)] + public async Task ItStopsWaitingForInitializationAfterTheStartWaitTime() + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + + var mockDataSourceStatus = new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + + var mockFlagTracker = new Mock(); + mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object); + + var provider = new Provider(mockClient.Object, TimeSpan.FromMilliseconds(50)); + + var exception = + await Record.ExceptionAsync(async () => await provider.InitializeAsync(EvaluationContext.Empty)); + Assert.NotNull(exception); + Assert.Equal("the provider did not become ready within 50ms", exception.Message); + } + + [Fact(Timeout = 5000)] + public async Task ItDoesNotTimeOutInitializationWhenTheStartWaitTimeIsZero() + { + var provider = new Provider(Configuration.Builder("") + .DataSource(Components.ExternalUpdatesOnly) + .Events(Components.NoEvents) + .StartWaitTime(TimeSpan.Zero) + .Build()); + + var initialization = provider.InitializeAsync(EvaluationContext.Empty); + await Task.Delay(100); + + Assert.False(initialization.IsFaulted); + } + + [Fact(Timeout = 5000)] + public async Task ItDoesNotTimeOutInitializationWhenTheClientBecomesReady() + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + + var mockDataSourceStatus = new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + + var mockFlagTracker = new Mock(); + mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object); + + var provider = new Provider(mockClient.Object, TimeSpan.FromMilliseconds(2000)); + + var completionTimer = new Timer(50); + completionTimer.AutoReset = false; + completionTimer.Elapsed += (sender, args) => + { + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus {State = DataSourceState.Valid}); + }; + completionTimer.Start(); + + await provider.InitializeAsync(EvaluationContext.Empty); + } + [Fact(Timeout = 5000)] public void ItCanBeConstructedWithLoggingConfiguration() {