From 73c15d927f59ae1a3e58f5fd001c05dc44b864f5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 31 Oct 2023 10:46:12 -0700 Subject: [PATCH] ffeat: Add support for provider shutdown and status. --- README.md | 17 +- src/OpenFeature/Api.cs | 66 +- src/OpenFeature/Constant/ProviderStatus.cs | 31 + src/OpenFeature/FeatureProvider.cs | 49 ++ src/OpenFeature/ProviderRepository.cs | 291 +++++++++ .../OpenFeatureClientBenchmarks.cs | 1 - .../Steps/EvaluationStepDefinitions.cs | 2 +- .../ClearOpenFeatureInstanceFixture.cs | 2 +- .../OpenFeatureClientTests.cs | 22 +- .../OpenFeature.Tests/OpenFeatureHookTests.cs | 18 +- test/OpenFeature.Tests/OpenFeatureTests.cs | 71 ++- .../ProviderRepositoryTests.cs | 598 ++++++++++++++++++ 12 files changed, 1101 insertions(+), 67 deletions(-) create mode 100644 src/OpenFeature/Constant/ProviderStatus.cs create mode 100644 src/OpenFeature/ProviderRepository.cs create mode 100644 test/OpenFeature.Tests/ProviderRepositoryTests.cs diff --git a/README.md b/README.md index 65007001..8888b25d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ dotnet add package OpenFeature public async Task Example() { // Register your feature flag provider - Api.Instance.SetProvider(new InMemoryProvider()); + await Api.Instance.SetProvider(new InMemoryProvider()); // Create a new client FeatureClient client = Api.Instance.GetClient(); @@ -97,7 +97,7 @@ public async Task Example() | ✅ | [Logging](#logging) | Integrate with popular logging packages. | | ✅ | [Named clients](#named-clients) | Utilize multiple providers in a single application. | | ❌ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | -| ❌ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | +| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | | ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ @@ -112,7 +112,7 @@ If the provider you're looking for hasn't been created yet, see the [develop a p Once you've added a provider as a dependency, it can be registered with OpenFeature like this: ```csharp -Api.Instance.SetProvider(new MyProvider()); +await Api.Instance.SetProvider(new MyProvider()); ``` In some situations, it may be beneficial to register multiple providers in the same application. @@ -179,9 +179,9 @@ If a name has no associated provider, the global provider is used. ```csharp // registering the default provider -Api.Instance.SetProvider(new LocalProvider()); +await Api.Instance.SetProvider(new LocalProvider()); // registering a named provider -Api.Instance.SetProvider("clientForCache", new CachedProvider()); +await Api.Instance.SetProvider("clientForCache", new CachedProvider()); // a client backed by default provider FeatureClient clientDefault = Api.Instance.GetClient(); @@ -196,7 +196,12 @@ Events are currently not supported by the .NET SDK. Progress on this feature can ### Shutdown -A shutdown handler is not yet available in the .NET SDK. Progress on this feature can be tracked [here](https://github.com/open-feature/dotnet-sdk/issues/126). +The OpenFeature API provides a close function to perform a cleanup of all registered providers. This should only be called when your application is in the process of shutting down. + +```csharp +// Shut down all providers +await Api.Instance.Shutdown(); +``` ## Extending diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index e679bf75..3583572e 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using OpenFeature.Model; @@ -15,14 +16,12 @@ namespace OpenFeature public sealed class Api { private EvaluationContext _evaluationContext = EvaluationContext.Empty; - private FeatureProvider _defaultProvider = new NoOpFeatureProvider(); - private readonly ConcurrentDictionary _featureProviders = - new ConcurrentDictionary(); + private readonly ProviderRepository _repository = new ProviderRepository(); private readonly ConcurrentStack _hooks = new ConcurrentStack(); /// The reader/writer locks are not disposed because the singleton instance should never be disposed. private readonly ReaderWriterLockSlim _evaluationContextLock = new ReaderWriterLockSlim(); - private readonly ReaderWriterLockSlim _featureProviderLock = new ReaderWriterLockSlim(); + /// /// Singleton instance of Api @@ -36,31 +35,26 @@ static Api() { } private Api() { } /// - /// Sets the feature provider + /// Sets the feature provider. In order to wait for the provider to be set, and initialization to complete, + /// await the returned task. /// + /// The provider cannot be set to null. Attempting to set the provider to null has no effect. /// Implementation of - public void SetProvider(FeatureProvider featureProvider) + public async Task SetProvider(FeatureProvider featureProvider) { - this._featureProviderLock.EnterWriteLock(); - try - { - this._defaultProvider = featureProvider ?? this._defaultProvider; - } - finally - { - this._featureProviderLock.ExitWriteLock(); - } + await this._repository.SetProvider(featureProvider, this.GetContext()).ConfigureAwait(false); } + /// - /// Sets the feature provider to given clientName + /// Sets the feature provider to given clientName. In order to wait for the provider to be set, and + /// initialization to complete, await the returned task. /// /// Name of client /// Implementation of - public void SetProvider(string clientName, FeatureProvider featureProvider) + public async Task SetProvider(string clientName, FeatureProvider featureProvider) { - this._featureProviders.AddOrUpdate(clientName, featureProvider, - (key, current) => featureProvider); + await this._repository.SetProvider(clientName, featureProvider, this.GetContext()).ConfigureAwait(false); } /// @@ -76,15 +70,7 @@ public void SetProvider(string clientName, FeatureProvider featureProvider) /// public FeatureProvider GetProvider() { - this._featureProviderLock.EnterReadLock(); - try - { - return this._defaultProvider; - } - finally - { - this._featureProviderLock.ExitReadLock(); - } + return this._repository.GetProvider(); } /// @@ -95,17 +81,9 @@ public FeatureProvider GetProvider() /// have a corresponding provider the default provider will be returned public FeatureProvider GetProvider(string clientName) { - if (string.IsNullOrEmpty(clientName)) - { - return this.GetProvider(); - } - - return this._featureProviders.TryGetValue(clientName, out var featureProvider) - ? featureProvider - : this.GetProvider(); + return this._repository.GetProvider(clientName); } - /// /// Gets providers metadata /// @@ -210,5 +188,19 @@ public EvaluationContext GetContext() this._evaluationContextLock.ExitReadLock(); } } + + /// + /// + /// Shut down and reset the current status of OpenFeature API. + /// + /// + /// This call cleans up all active providers and attempts to shut down internal event handling mechanisms. + /// Once shut down is complete, API is reset and ready to use again. + /// + /// + public async Task Shutdown() + { + await this._repository.Shutdown().ConfigureAwait(false); + } } } diff --git a/src/OpenFeature/Constant/ProviderStatus.cs b/src/OpenFeature/Constant/ProviderStatus.cs new file mode 100644 index 00000000..e56c6c95 --- /dev/null +++ b/src/OpenFeature/Constant/ProviderStatus.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; + +namespace OpenFeature.Constant +{ + /// + /// The state of the provider. + /// + /// + public enum ProviderStatus + { + /// + /// The provider has not been initialized and cannot yet evaluate flags. + /// + [Description("NOT_READY")] NotReady, + + /// + /// The provider is ready to resolve flags. + /// + [Description("READY")] Ready, + + /// + /// The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + /// + [Description("STALE")] Stale, + + /// + /// The provider is in an error state and unable to evaluate flags. + /// + [Description("ERROR")] Error + } +} diff --git a/src/OpenFeature/FeatureProvider.cs b/src/OpenFeature/FeatureProvider.cs index fe8f664d..c3cc1406 100644 --- a/src/OpenFeature/FeatureProvider.cs +++ b/src/OpenFeature/FeatureProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Threading.Tasks; +using OpenFeature.Constant; using OpenFeature.Model; namespace OpenFeature @@ -79,5 +80,53 @@ public abstract Task> ResolveDoubleValue(string flagKe /// public abstract Task> ResolveStructureValue(string flagKey, Value defaultValue, EvaluationContext context = null); + + /// + /// Get the status of the provider. + /// + /// The current + /// + /// If a provider does not override this method, then its status will be assumed to be + /// . If a provider implements this method, and supports initialization, + /// then it should start in the status . If the status is + /// , then the Api will call the when the + /// provider is set. + /// + public virtual ProviderStatus GetStatus() => ProviderStatus.Ready; + + /// + /// + /// This method is called before a provider is used to evaluate flags. Providers can overwrite this method, + /// if they have special initialization needed prior being called for flag evaluation. + /// + /// + /// + /// A task that completes when the initialization process is complete. + /// + /// + /// A provider which supports initialization should override this method as well as + /// . + /// + /// + /// The provider should return or from + /// the method after initialization is complete. + /// + /// + public virtual Task Initialize(EvaluationContext context) + { + // Intentionally left blank. + return Task.CompletedTask; + } + + /// + /// This method is called when a new provider is about to be used to evaluate flags, or the SDK is shut down. + /// Providers can overwrite this method, if they have special shutdown actions needed. + /// + /// A task that completes when the shutdown process is complete. + public virtual Task Shutdown() + { + // Intentionally left blank. + return Task.CompletedTask; + } } } diff --git a/src/OpenFeature/ProviderRepository.cs b/src/OpenFeature/ProviderRepository.cs new file mode 100644 index 00000000..dbd0794c --- /dev/null +++ b/src/OpenFeature/ProviderRepository.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Constant; +using OpenFeature.Model; + + +namespace OpenFeature +{ + /// + /// This class manages the collection of providers, both default and named, contained by the API. + /// + internal class ProviderRepository + { + private FeatureProvider _defaultProvider = new NoOpFeatureProvider(); + + private readonly ConcurrentDictionary _featureProviders = + new ConcurrentDictionary(); + + /// The reader/writer locks is not disposed because the singleton instance should never be disposed. + /// + /// Mutations of the _defaultProvider or _featureProviders are done within this lock even though + /// _featureProvider is a concurrent collection. This is for a couple reasons, the first is that + /// a provider should only be shutdown if it is not in use, and it could be in use as either a named or + /// default provider. + /// + /// The second is that a concurrent collection doesn't provide any ordering so we could check a provider + /// as it was being added or removed such as two concurrent calls to SetProvider replacing multiple instances + /// of that provider under different names.. + private readonly ReaderWriterLockSlim _providersLock = new ReaderWriterLockSlim(); + + /// + /// Set the default provider + /// + /// the provider to set as the default, passing null has no effect + /// the context to initialize the provider with + /// + /// + /// Called after the provider is set, but before any actions are taken on it. + /// + /// This can be used for tasks such as registering event handlers. It should be noted that this can be called + /// several times for a single provider. For instance registering a provider with multiple names or as the + /// default and named provider. + /// + /// + /// + /// + /// called after the provider has initialized successfully, only called if the provider needed initialization + /// + /// + /// called if an error happens during the initialization of the provider, only called if the provider needed + /// initialization + /// + /// called after a provider is shutdown, can be used to remove event handlers + public async Task SetProvider( + FeatureProvider featureProvider, + EvaluationContext context, + Action afterSet = null, + Action afterInitialization = null, + Action afterError = null, + Action afterShutdown = null) + { + // Cannot unset the feature provider. + if (featureProvider == null) + { + return; + } + + this._providersLock.EnterWriteLock(); + // Default provider is swapped synchronously, initialization and shutdown may happen asynchronously. + try + { + // Setting the provider to the same provider should not have an effect. + if (ReferenceEquals(featureProvider, this._defaultProvider)) + { + return; + } + + var oldProvider = this._defaultProvider; + this._defaultProvider = featureProvider; + afterSet?.Invoke(featureProvider); + // We want to allow shutdown to happen concurrently with initialization, and the caller to not + // wait for it. +#pragma warning disable CS4014 + this.ShutdownIfUnused(oldProvider, afterShutdown, afterError); +#pragma warning restore CS4014 + } + finally + { + this._providersLock.ExitWriteLock(); + } + + await InitProvider(this._defaultProvider, context, afterInitialization, afterError) + .ConfigureAwait(false); + } + + private static async Task InitProvider( + FeatureProvider newProvider, + EvaluationContext context, + Action afterInitialization, + Action afterError) + { + if (newProvider == null) + { + return; + } + if (newProvider.GetStatus() == ProviderStatus.NotReady) + { + try + { + await newProvider.Initialize(context).ConfigureAwait(false); + afterInitialization?.Invoke(newProvider); + } + catch (Exception ex) + { + afterError?.Invoke(newProvider, ex); + } + } + } + + /// + /// Set a named provider + /// + /// the name to associate with the provider + /// the provider to set as the default, passing null has no effect + /// the context to initialize the provider with + /// + /// + /// Called after the provider is set, but before any actions are taken on it. + /// + /// This can be used for tasks such as registering event handlers. It should be noted that this can be called + /// several times for a single provider. For instance registering a provider with multiple names or as the + /// default and named provider. + /// + /// + /// + /// + /// called after the provider has initialized successfully, only called if the provider needed initialization + /// + /// + /// called if an error happens during the initialization of the provider, only called if the provider needed + /// initialization + /// + /// called after a provider is shutdown, can be used to remove event handlers + public async Task SetProvider(string clientName, + FeatureProvider featureProvider, + EvaluationContext context, + Action afterSet = null, + Action afterInitialization = null, + Action afterError = null, + Action afterShutdown = null) + { + // Cannot set a provider for a null clientName. + if (clientName == null) + { + return; + } + + this._providersLock.EnterWriteLock(); + + try + { + this._featureProviders.TryGetValue(clientName, out var oldProvider); + if (featureProvider != null) + { + this._featureProviders.AddOrUpdate(clientName, featureProvider, + (key, current) => featureProvider); + afterSet?.Invoke(featureProvider); + } + else + { + // If names of clients are programmatic, then setting the provider to null could result + // in unbounded growth of the collection. + this._featureProviders.TryRemove(clientName, out _); + } + + // We want to allow shutdown to happen concurrently with initialization, and the caller to not + // wait for it. +#pragma warning disable CS4014 + this.ShutdownIfUnused(oldProvider, afterShutdown, afterError); +#pragma warning restore CS4014 + } + finally + { + this._providersLock.ExitWriteLock(); + } + + await InitProvider(featureProvider, context, afterInitialization, afterError).ConfigureAwait(false); + } + + /// + /// Shutdown the feature provider if it is unused. This must be called within a write lock of the _providersLock. + /// + private async Task ShutdownIfUnused( + FeatureProvider targetProvider, + Action afterShutdown, + Action afterError) + { + if (ReferenceEquals(this._defaultProvider, targetProvider)) + { + return; + } + + if (this._featureProviders.Values.Contains(targetProvider)) + { + return; + } + + await SafeShutdownProvider(targetProvider, afterShutdown, afterError).ConfigureAwait(false); + } + + /// + /// + /// Shut down the provider and capture any exceptions thrown. + /// + /// + /// The provider is set either to a name or default before the old provider it shutdown, so + /// it would not be meaningful to emit an error. + /// + /// + private static async Task SafeShutdownProvider(FeatureProvider targetProvider, + Action afterShutdown, + Action afterError) + { + try + { + await targetProvider.Shutdown().ConfigureAwait(false); + afterShutdown?.Invoke(targetProvider); + } + catch (Exception ex) + { + afterError?.Invoke(targetProvider, ex); + } + } + + public FeatureProvider GetProvider() + { + this._providersLock.EnterReadLock(); + try + { + return this._defaultProvider; + } + finally + { + this._providersLock.ExitReadLock(); + } + } + + public FeatureProvider GetProvider(string clientName) + { + if (string.IsNullOrEmpty(clientName)) + { + return this.GetProvider(); + } + + return this._featureProviders.TryGetValue(clientName, out var featureProvider) + ? featureProvider + : this.GetProvider(); + } + + public async Task Shutdown(Action afterError = null) + { + var providers = new HashSet(); + this._providersLock.EnterWriteLock(); + try + { + providers.Add(this._defaultProvider); + foreach (var featureProvidersValue in this._featureProviders.Values) + { + providers.Add(featureProvidersValue); + } + + // Set a default provider so the Api is ready to be used again. + this._defaultProvider = new NoOpFeatureProvider(); + this._featureProviders.Clear(); + } + finally + { + this._providersLock.ExitWriteLock(); + } + + foreach (var targetProvider in providers) + { + // We don't need to take any actions after shutdown. + await SafeShutdownProvider(targetProvider, null, afterError).ConfigureAwait(false); + } + } + } +} diff --git a/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs index 3adcb132..03f6082a 100644 --- a/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs +++ b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs @@ -40,7 +40,6 @@ public OpenFeatureClientBenchmarks() _defaultStructureValue = fixture.Create(); _emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); - Api.Instance.SetProvider(new NoOpFeatureProvider()); _client = Api.Instance.GetClient(_clientName, _clientVersion); } diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs index bb177468..4847bfb2 100644 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -42,7 +42,7 @@ public EvaluationStepDefinitions(ScenarioContext scenarioContext) { _scenarioContext = scenarioContext; var flagdProvider = new FlagdProvider(); - Api.Instance.SetProvider(flagdProvider); + Api.Instance.SetProvider(flagdProvider).Wait(); client = Api.Instance.GetClient(); } diff --git a/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs b/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs index 3a0ab349..a70921f7 100644 --- a/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs +++ b/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs @@ -7,7 +7,7 @@ public ClearOpenFeatureInstanceFixture() { Api.Instance.SetContext(null); Api.Instance.ClearHooks(); - Api.Instance.SetProvider(new NoOpFeatureProvider()); + Api.Instance.SetProvider(new NoOpFeatureProvider()).Wait(); } } } diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index 4995423e..30bee168 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -75,7 +75,7 @@ public async Task OpenFeatureClient_Should_Allow_Flag_Evaluation() var defaultStructureValue = fixture.Create(); var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); - Api.Instance.SetProvider(new NoOpFeatureProvider()); + await Api.Instance.SetProvider(new NoOpFeatureProvider()); var client = Api.Instance.GetClient(clientName, clientVersion); (await client.GetBooleanValue(flagName, defaultBoolValue)).Should().Be(defaultBoolValue); @@ -121,7 +121,7 @@ public async Task OpenFeatureClient_Should_Allow_Details_Flag_Evaluation() var defaultStructureValue = fixture.Create(); var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); - Api.Instance.SetProvider(new NoOpFeatureProvider()); + await Api.Instance.SetProvider(new NoOpFeatureProvider()); var client = Api.Instance.GetClient(clientName, clientVersion); var boolFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultBoolValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); @@ -172,7 +172,7 @@ public async Task OpenFeatureClient_Should_Return_DefaultValue_When_Type_Mismatc mockedFeatureProvider.GetMetadata().Returns(new Metadata(fixture.Create())); mockedFeatureProvider.GetProviderHooks().Returns(ImmutableList.Empty); - Api.Instance.SetProvider(mockedFeatureProvider); + await Api.Instance.SetProvider(mockedFeatureProvider); var client = Api.Instance.GetClient(clientName, clientVersion, mockedLogger); var evaluationDetails = await client.GetObjectDetails(flagName, defaultValue); @@ -202,7 +202,7 @@ public async Task Should_Resolve_BooleanValue() featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - Api.Instance.SetProvider(featureProviderMock); + await Api.Instance.SetProvider(featureProviderMock); var client = Api.Instance.GetClient(clientName, clientVersion); (await client.GetBooleanValue(flagName, defaultValue)).Should().Be(defaultValue); @@ -224,7 +224,7 @@ public async Task Should_Resolve_StringValue() featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - Api.Instance.SetProvider(featureProviderMock); + await Api.Instance.SetProvider(featureProviderMock); var client = Api.Instance.GetClient(clientName, clientVersion); (await client.GetStringValue(flagName, defaultValue)).Should().Be(defaultValue); @@ -246,7 +246,7 @@ public async Task Should_Resolve_IntegerValue() featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - Api.Instance.SetProvider(featureProviderMock); + await Api.Instance.SetProvider(featureProviderMock); var client = Api.Instance.GetClient(clientName, clientVersion); (await client.GetIntegerValue(flagName, defaultValue)).Should().Be(defaultValue); @@ -268,7 +268,7 @@ public async Task Should_Resolve_DoubleValue() featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - Api.Instance.SetProvider(featureProviderMock); + await Api.Instance.SetProvider(featureProviderMock); var client = Api.Instance.GetClient(clientName, clientVersion); (await client.GetDoubleValue(flagName, defaultValue)).Should().Be(defaultValue); @@ -290,7 +290,7 @@ public async Task Should_Resolve_StructureValue() featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - Api.Instance.SetProvider(featureProviderMock); + await Api.Instance.SetProvider(featureProviderMock); var client = Api.Instance.GetClient(clientName, clientVersion); (await client.GetObjectValue(flagName, defaultValue)).Should().Be(defaultValue); @@ -313,7 +313,7 @@ public async Task When_Error_Is_Returned_From_Provider_Should_Return_Error() featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - Api.Instance.SetProvider(featureProviderMock); + await Api.Instance.SetProvider(featureProviderMock); var client = Api.Instance.GetClient(clientName, clientVersion); var response = await client.GetObjectDetails(flagName, defaultValue); @@ -338,7 +338,7 @@ public async Task When_Exception_Occurs_During_Evaluation_Should_Return_Error() featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - Api.Instance.SetProvider(featureProviderMock); + await Api.Instance.SetProvider(featureProviderMock); var client = Api.Instance.GetClient(clientName, clientVersion); var response = await client.GetObjectDetails(flagName, defaultValue); @@ -351,7 +351,7 @@ public async Task When_Exception_Occurs_During_Evaluation_Should_Return_Error() [Fact] public async Task Should_Use_No_Op_When_Provider_Is_Null() { - Api.Instance.SetProvider(null); + await Api.Instance.SetProvider(null); var client = new FeatureClient("test", "test"); (await client.GetIntegerValue("some-key", 12)).Should().Be(12); } diff --git a/test/OpenFeature.Tests/OpenFeatureHookTests.cs b/test/OpenFeature.Tests/OpenFeatureHookTests.cs index 72e4a1d0..a4810c04 100644 --- a/test/OpenFeature.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureHookTests.cs @@ -51,7 +51,7 @@ public async Task Hooks_Should_Be_Called_In_Order() var testProvider = new TestProvider(); testProvider.AddHook(providerHook); Api.Instance.AddHooks(apiHook); - Api.Instance.SetProvider(testProvider); + await Api.Instance.SetProvider(testProvider); var client = Api.Instance.GetClient(clientName, clientVersion); client.AddHooks(clientHook); @@ -197,7 +197,7 @@ public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order() provider.ResolveBooleanValue(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", true)); - Api.Instance.SetProvider(provider); + await Api.Instance.SetProvider(provider); var hook = Substitute.For(); hook.Before(Arg.Any>(), Arg.Any>()).Returns(hookContext); @@ -269,7 +269,7 @@ public async Task Hook_Should_Execute_In_Correct_Order() _ = hook.After(Arg.Any>(), Arg.Any>(), Arg.Any>()); _ = hook.Finally(Arg.Any>(), Arg.Any>()); - Api.Instance.SetProvider(featureProvider); + await Api.Instance.SetProvider(featureProvider); var client = Api.Instance.GetClient(); client.AddHooks(hook); @@ -301,7 +301,7 @@ public async Task Register_Hooks_Should_Be_Available_At_All_Levels() var testProvider = new TestProvider(); testProvider.AddHook(hook4); Api.Instance.AddHooks(hook1); - Api.Instance.SetProvider(testProvider); + await Api.Instance.SetProvider(testProvider); var client = Api.Instance.GetClient(); client.AddHooks(hook2); await client.GetBooleanValue("test", false, null, @@ -332,7 +332,7 @@ public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() hook2.Finally(Arg.Any>(), null).Returns(Task.CompletedTask); hook1.Finally(Arg.Any>(), null).Throws(new Exception()); - Api.Instance.SetProvider(featureProvider); + await Api.Instance.SetProvider(featureProvider); var client = Api.Instance.GetClient(); client.AddHooks(new[] { hook1, hook2 }); client.GetHooks().Count().Should().Be(2); @@ -377,7 +377,7 @@ public async Task Error_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() hook2.Error(Arg.Any>(), Arg.Any(), null).Returns(Task.CompletedTask); hook1.Error(Arg.Any>(), Arg.Any(), null).Returns(Task.CompletedTask); - Api.Instance.SetProvider(featureProvider1); + await Api.Instance.SetProvider(featureProvider1); var client = Api.Instance.GetClient(); client.AddHooks(new[] { hook1, hook2 }); @@ -414,7 +414,7 @@ public async Task Error_Occurs_During_Before_After_Evaluation_Should_Not_Invoke_ _ = hook1.Error(Arg.Any>(), Arg.Any(), null); _ = hook2.Error(Arg.Any>(), Arg.Any(), null); - Api.Instance.SetProvider(featureProvider); + await Api.Instance.SetProvider(featureProvider); var client = Api.Instance.GetClient(); client.AddHooks(new[] { hook1, hook2 }); @@ -459,7 +459,7 @@ public async Task Hook_Hints_May_Be_Optional() hook.Finally(Arg.Any>(), Arg.Any>()) .Returns(Task.CompletedTask); - Api.Instance.SetProvider(featureProvider); + await Api.Instance.SetProvider(featureProvider); var client = Api.Instance.GetClient(); await client.GetBooleanValue("test", false, EvaluationContext.Empty, flagOptions); @@ -537,7 +537,7 @@ public async Task When_Error_Occurs_In_After_Hook_Should_Invoke_Error_Hook() hook.Finally(Arg.Any>(), Arg.Any>()) .Returns(Task.CompletedTask); - Api.Instance.SetProvider(featureProvider); + await Api.Instance.SetProvider(featureProvider); var client = Api.Instance.GetClient(); var resolvedFlag = await client.GetBooleanValue("test", true, config: flagOptions); diff --git a/test/OpenFeature.Tests/OpenFeatureTests.cs b/test/OpenFeature.Tests/OpenFeatureTests.cs index 9776d552..afcfcd18 100644 --- a/test/OpenFeature.Tests/OpenFeatureTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureTests.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Threading.Tasks; using FluentAssertions; using NSubstitute; using OpenFeature.Constant; @@ -20,6 +21,74 @@ public void OpenFeature_Should_Be_Singleton() openFeature.Should().BeSameAs(openFeature2); } + [Fact] + [Specification("1.1.2.2", "The provider mutator function MUST invoke the initialize function on the newly registered provider before using it to resolve flag values.")] + public async Task OpenFeature_Should_Initialize_Provider() + { + var providerMockDefault = Substitute.For(); + providerMockDefault.GetStatus().Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProvider(providerMockDefault).ConfigureAwait(false); + await providerMockDefault.Received(1).Initialize(Api.Instance.GetContext()).ConfigureAwait(false); + + var providerMockNamed = Substitute.For(); + providerMockNamed.GetStatus().Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProvider("the-name", providerMockNamed).ConfigureAwait(false); + await providerMockNamed.Received(1).Initialize(Api.Instance.GetContext()).ConfigureAwait(false); + } + + [Fact] + [Specification("1.1.2.3", + "The provider mutator function MUST invoke the shutdown function on the previously registered provider once it's no longer being used to resolve flag values.")] + public async Task OpenFeature_Should_Shutdown_Unused_Provider() + { + var providerA = Substitute.For(); + providerA.GetStatus().Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProvider(providerA).ConfigureAwait(false); + await providerA.Received(1).Initialize(Api.Instance.GetContext()).ConfigureAwait(false); + + var providerB = Substitute.For(); + providerB.GetStatus().Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProvider(providerB).ConfigureAwait(false); + await providerB.Received(1).Initialize(Api.Instance.GetContext()).ConfigureAwait(false); + await providerA.Received(1).Shutdown().ConfigureAwait(false); + + var providerC = Substitute.For(); + providerC.GetStatus().Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProvider("named", providerC).ConfigureAwait(false); + await providerC.Received(1).Initialize(Api.Instance.GetContext()).ConfigureAwait(false); + + var providerD = Substitute.For(); + providerD.GetStatus().Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProvider("named", providerD).ConfigureAwait(false); + await providerD.Received(1).Initialize(Api.Instance.GetContext()).ConfigureAwait(false); + await providerC.Received(1).Shutdown().ConfigureAwait(false); + } + + [Fact] + [Specification("1.6.1", "The API MUST define a mechanism to propagate a shutdown request to active providers.")] + public async Task OpenFeature_Should_Support_Shutdown() + { + var providerA = Substitute.For(); + providerA.GetStatus().Returns(ProviderStatus.NotReady); + + var providerB = Substitute.For(); + providerB.GetStatus().Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProvider(providerA).ConfigureAwait(false); + await Api.Instance.SetProvider("named", providerB).ConfigureAwait(false); + + await Api.Instance.Shutdown().ConfigureAwait(false); + + await providerA.Received(1).Shutdown().ConfigureAwait(false); + await providerB.Received(1).Shutdown().ConfigureAwait(false); + } + [Fact] [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] public void OpenFeature_Should_Not_Change_Named_Providers_When_Setting_Default_Provider() @@ -111,7 +180,7 @@ public void OpenFeature_Should_Add_Hooks() [Specification("1.1.5", "The API MUST provide a function for retrieving the metadata field of the configured `provider`.")] public void OpenFeature_Should_Get_Metadata() { - Api.Instance.SetProvider(new NoOpFeatureProvider()); + Api.Instance.SetProvider(new NoOpFeatureProvider()).Wait(); var openFeature = Api.Instance; var metadata = openFeature.GetProviderMetadata(); diff --git a/test/OpenFeature.Tests/ProviderRepositoryTests.cs b/test/OpenFeature.Tests/ProviderRepositoryTests.cs new file mode 100644 index 00000000..c010c09d --- /dev/null +++ b/test/OpenFeature.Tests/ProviderRepositoryTests.cs @@ -0,0 +1,598 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenFeature.Constant; +using OpenFeature.Model; +using Xunit; + +// We intentionally do not await for purposes of validating behavior. +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + +namespace OpenFeature.Tests +{ + [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] + public class ProviderRepositoryTests + { + [Fact] + public void Default_Provider_Is_Set_Without_Await() + { + var repository = new ProviderRepository(); + var provider = new NoOpFeatureProvider(); + var context = new EvaluationContextBuilder().Build(); + repository.SetProvider(provider, context); + Assert.Equal(provider, repository.GetProvider()); + } + + [Fact] + public void AfterSet_Is_Invoked_For_Setting_Default_Provider() + { + var repository = new ProviderRepository(); + var provider = new NoOpFeatureProvider(); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + // The setting of the provider is synchronous, so the afterSet should be as well. + repository.SetProvider(provider, context, afterSet: (theProvider) => + { + callCount++; + Assert.Equal(provider, theProvider); + }); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task Initialization_Provider_Method_Is_Invoked_For_Setting_Default_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider(providerMock, context); + providerMock.Received(1).Initialize(context); + providerMock.DidNotReceive().Shutdown(); + } + + [Fact] + public async Task AfterInitialization_Is_Invoked_For_Setting_Default_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProvider(providerMock, context, afterInitialization: (theProvider) => + { + Assert.Equal(providerMock, theProvider); + callCount++; + }); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AfterError_Is_Invoked_If_Initialization_Errors_Default_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + providerMock.When(x => x.Initialize(context)).Throw(new Exception("BAD THINGS")); + var callCount = 0; + Exception receivedError = null; + await repository.SetProvider(providerMock, context, afterError: (theProvider, error) => + { + Assert.Equal(providerMock, theProvider); + callCount++; + receivedError = error; + }); + Assert.Equal("BAD THINGS", receivedError.Message); + Assert.Equal(1, callCount); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + public async Task Initialize_Is_Not_Called_For_Ready_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(status); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider(providerMock, context); + providerMock.DidNotReceive().Initialize(context); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + public async Task AfterInitialize_Is_Not_Called_For_Ready_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(status); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProvider(providerMock, context, afterInitialization: provider => { callCount++; }); + Assert.Equal(0, callCount); + } + + [Fact] + public async Task Replaced_Default_Provider_Is_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider(provider1, context); + await repository.SetProvider(provider2, context); + provider1.Received(1).Shutdown(); + provider2.DidNotReceive().Shutdown(); + } + + [Fact] + public async Task AfterShutdown_Is_Called_For_Shutdown_Provider() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider(provider1, context); + var callCount = 0; + await repository.SetProvider(provider2, context, afterShutdown: provider => + { + Assert.Equal(provider, provider1); + callCount++; + }); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AfterError_Is_Called_For_Shutdown_That_Throws() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Shutdown().Throws(new Exception("SHUTDOWN ERROR")); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider(provider1, context); + var callCount = 0; + Exception errorThrown = null; + await repository.SetProvider(provider2, context, afterError: (provider, ex) => + { + Assert.Equal(provider, provider1); + errorThrown = ex; + callCount++; + }); + Assert.Equal(1, callCount); + Assert.Equal("SHUTDOWN ERROR", errorThrown.Message); + } + + [Fact] + public void Named_Provider_Provider_Is_Set_Without_Await() + { + var repository = new ProviderRepository(); + var provider = new NoOpFeatureProvider(); + var context = new EvaluationContextBuilder().Build(); + repository.SetProvider("the-name", provider, context); + Assert.Equal(provider, repository.GetProvider("the-name")); + } + + [Fact] + public void AfterSet_Is_Invoked_For_Setting_Named_Provider() + { + var repository = new ProviderRepository(); + var provider = new NoOpFeatureProvider(); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + // The setting of the provider is synchronous, so the afterSet should be as well. + repository.SetProvider("the-name", provider, context, afterSet: (theProvider) => + { + callCount++; + Assert.Equal(provider, theProvider); + }); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task Initialization_Provider_Method_Is_Invoked_For_Setting_Named_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider("the-name", providerMock, context); + providerMock.Received(1).Initialize(context); + providerMock.DidNotReceive().Shutdown(); + } + + [Fact] + public async Task AfterInitialization_Is_Invoked_For_Setting_Named_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProvider("the-name", providerMock, context, afterInitialization: (theProvider) => + { + Assert.Equal(providerMock, theProvider); + callCount++; + }); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AfterError_Is_Invoked_If_Initialization_Errors_Named_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + providerMock.When(x => x.Initialize(context)).Throw(new Exception("BAD THINGS")); + var callCount = 0; + Exception receivedError = null; + await repository.SetProvider("the-provider", providerMock, context, afterError: (theProvider, error) => + { + Assert.Equal(providerMock, theProvider); + callCount++; + receivedError = error; + }); + Assert.Equal("BAD THINGS", receivedError.Message); + Assert.Equal(1, callCount); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + public async Task Initialize_Is_Not_Called_For_Ready_Named_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(status); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider("the-name", providerMock, context); + providerMock.DidNotReceive().Initialize(context); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + public async Task AfterInitialize_Is_Not_Called_For_Ready_Named_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.GetStatus().Returns(status); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProvider("the-name", providerMock, context, + afterInitialization: provider => { callCount++; }); + Assert.Equal(0, callCount); + } + + [Fact] + public async Task Replaced_Named_Provider_Is_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider("the-name", provider1, context); + await repository.SetProvider("the-name", provider2, context); + provider1.Received(1).Shutdown(); + provider2.DidNotReceive().Shutdown(); + } + + [Fact] + public async Task AfterShutdown_Is_Called_For_Shutdown_Named_Provider() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider("the-provider", provider1, context); + var callCount = 0; + await repository.SetProvider("the-provider", provider2, context, afterShutdown: provider => + { + Assert.Equal(provider, provider1); + callCount++; + }); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AfterError_Is_Called_For_Shutdown_Named_Provider_That_Throws() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Shutdown().Throws(new Exception("SHUTDOWN ERROR")); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider("the-name", provider1, context); + var callCount = 0; + Exception errorThrown = null; + await repository.SetProvider("the-name", provider2, context, afterError: (provider, ex) => + { + Assert.Equal(provider, provider1); + errorThrown = ex; + callCount++; + }); + Assert.Equal(1, callCount); + Assert.Equal("SHUTDOWN ERROR", errorThrown.Message); + } + + [Fact] + public async Task In_Use_Provider_Named_And_Default_Is_Not_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProvider(provider1, context); + await repository.SetProvider("A", provider1, context); + // Provider one is replaced for "A", but not default. + await repository.SetProvider("A", provider2, context); + + provider1.DidNotReceive().Shutdown(); + } + + [Fact] + public async Task In_Use_Provider_Two_Named_Is_Not_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProvider("B", provider1, context); + await repository.SetProvider("A", provider1, context); + // Provider one is replaced for "A", but not "B. + await repository.SetProvider("A", provider2, context); + + provider1.DidNotReceive().Shutdown(); + } + + [Fact] + public async Task When_All_Instances_Are_Removed_Shutdown_Is_Called() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProvider("B", provider1, context); + await repository.SetProvider("A", provider1, context); + + await repository.SetProvider("A", provider2, context); + await repository.SetProvider("B", provider2, context); + + provider1.Received(1).Shutdown(); + } + + [Fact] + public async Task Can_Get_Providers_By_Name() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProvider("A", provider1, context); + await repository.SetProvider("B", provider2, context); + + Assert.Equal(provider1, repository.GetProvider("A")); + Assert.Equal(provider2, repository.GetProvider("B")); + } + + [Fact] + public async Task Replaced_Named_Provider_Gets_Latest_Set() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProvider("A", provider1, context); + await repository.SetProvider("A", provider2, context); + + Assert.Equal(provider2, repository.GetProvider("A")); + } + + [Fact] + public async Task Can_Shutdown_All_Providers() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + + var provider3 = Substitute.For(); + provider3.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProvider(provider1, context); + await repository.SetProvider("provider1", provider1, context); + await repository.SetProvider("provider2", provider2, context); + await repository.SetProvider("provider2a", provider2, context); + await repository.SetProvider("provider3", provider3, context); + + await repository.Shutdown(); + + provider1.Received(1).Shutdown(); + provider2.Received(1).Shutdown(); + provider3.Received(1).Shutdown(); + } + + [Fact] + public async Task Errors_During_Shutdown_Propagate() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.GetStatus().Returns(ProviderStatus.NotReady); + provider1.Shutdown().Throws(new Exception("SHUTDOWN ERROR 1")); + + var provider2 = Substitute.For(); + provider2.GetStatus().Returns(ProviderStatus.NotReady); + provider2.Shutdown().Throws(new Exception("SHUTDOWN ERROR 2")); + + var provider3 = Substitute.For(); + provider3.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProvider(provider1, context); + await repository.SetProvider("provider1", provider1, context); + await repository.SetProvider("provider2", provider2, context); + await repository.SetProvider("provider2a", provider2, context); + await repository.SetProvider("provider3", provider3, context); + + var callCountShutdown1 = 0; + var callCountShutdown2 = 0; + var totalCallCount = 0; + await repository.Shutdown(afterError: (provider, exception) => + { + totalCallCount++; + if (provider == provider1) + { + callCountShutdown1++; + Assert.Equal("SHUTDOWN ERROR 1", exception.Message); + } + + if (provider == provider2) + { + callCountShutdown2++; + Assert.Equal("SHUTDOWN ERROR 2", exception.Message); + } + }); + Assert.Equal(2, totalCallCount); + Assert.Equal(1, callCountShutdown1); + Assert.Equal(1, callCountShutdown2); + + provider1.Received(1).Shutdown(); + provider2.Received(1).Shutdown(); + provider3.Received(1).Shutdown(); + } + + [Fact] + public async Task Setting_Same_Default_Provider_Has_No_Effect() + { + var repository = new ProviderRepository(); + var provider = Substitute.For(); + provider.GetStatus().Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider(provider, context); + await repository.SetProvider(provider, context); + + Assert.Equal(provider, repository.GetProvider()); + provider.Received(1).Initialize(context); + provider.DidNotReceive().Shutdown(); + } + + [Fact] + public async Task Setting_Null_Default_Provider_Has_No_Effect() + { + var repository = new ProviderRepository(); + var provider = Substitute.For(); + provider.GetStatus().Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider(provider, context); + await repository.SetProvider(null, context); + + Assert.Equal(provider, repository.GetProvider()); + provider.Received(1).Initialize(context); + provider.DidNotReceive().Shutdown(); + } + + [Fact] + public async Task Setting_Null_Named_Provider_Removes_It() + { + var repository = new ProviderRepository(); + + var namedProvider = Substitute.For(); + namedProvider.GetStatus().Returns(ProviderStatus.NotReady); + + var defaultProvider = Substitute.For(); + defaultProvider.GetStatus().Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProvider(defaultProvider, context); + + await repository.SetProvider("named-provider", namedProvider, context); + await repository.SetProvider("named-provider", null, context); + + Assert.Equal(defaultProvider, repository.GetProvider("named-provider")); + } + + [Fact] + public async Task Setting_Named_Provider_With_Null_Name_Has_No_Effect() + { + var repository = new ProviderRepository(); + var context = new EvaluationContextBuilder().Build(); + + var defaultProvider = Substitute.For(); + defaultProvider.GetStatus().Returns(ProviderStatus.NotReady); + await repository.SetProvider(defaultProvider, context); + + var namedProvider = Substitute.For(); + namedProvider.GetStatus().Returns(ProviderStatus.NotReady); + + await repository.SetProvider(null, namedProvider, context); + + namedProvider.DidNotReceive().Initialize(context); + namedProvider.DidNotReceive().Shutdown(); + + Assert.Equal(defaultProvider, repository.GetProvider(null)); + } + } +}