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));
+ }
+ }
+}