Skip to content

Commit 7802950

Browse files
feat: Add isolated API instance functionality (#740)
* feat: add tracking for bound API instance in FeatureProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: clear ownership of bound API instances during provider shutdown Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add CreateIsolated method and provider ownership validation to Api class Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add unit tests for isolated API instances and provider behavior Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add demonstration of isolated API instance creation and usage Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add isolated API instance testing functionality Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Fix formatting Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: enhance exception message assertions for provider binding in isolated API tests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: improve provider instance binding logic Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Move to a separate namespace. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add experimental identifiers for Isolated API features and update related code Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: reorder using directives for consistency in IsolatedApiTests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
1 parent 34bf89a commit 7802950

10 files changed

Lines changed: 589 additions & 4 deletions

File tree

samples/Console/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,14 @@ The sample defines the following flags using the `InMemoryProvider`:
4343
## NativeAOT
4444

4545
This sample is published with [NativeAOT](https://learn.microsoft.com/dotnet/core/deploying/native-aot/) enabled (`PublishAot=true`), demonstrating that the OpenFeature .NET SDK is fully compatible with NativeAOT compilation. See the [AOT Compatibility Guide](../../../docs/AOT_COMPATIBILITY.md) for more details.
46+
47+
## Isolated API Instance
48+
49+
The sample also demonstrates how to create an **isolated API instance** using `Api.CreateIsolated()`. The isolated instance has its own provider with different flag values, proving that it operates independently from the global singleton.
50+
51+
| Flag Key | Type | Variants | Default Variant |
52+
| -------------- | -------- | ---------------------------------------------------------------------------------- | --------------- |
53+
| `bool-flag` | `bool` | `on``true`, `off``false` | `off` |
54+
| `string-flag` | `string` | `greeting``"Howdy, Isolated World!"`, `farewell``"See ya!"` | `greeting` |
55+
56+
The isolated instance evaluates flags from its own provider, then shuts down without affecting the global singleton. This is useful for testing, multi-tenant applications, and dependency injection scenarios. See the [specification](https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances) for more details.

samples/Console/app.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,32 @@
4444
// Evaluate the `object-flag` flag and print the result to the console
4545
var objectResult = await client.GetObjectValueAsync("object-flag", new Value("Ben"));
4646
Console.WriteLine("The `object-flag` flag returned {0}", objectResult.AsString);
47+
48+
// --- Isolated API Instance ---
49+
// Create an independent API instance with its own provider and state.
50+
// This is useful for testing, multi-tenant apps, and DI scenarios.
51+
52+
var isolatedFlags = new Dictionary<string, Flag>
53+
{
54+
{ "bool-flag", new Flag<bool>(new Dictionary<string, bool> { { "on", true }, { "off", false } }, defaultVariant: "off") },
55+
{ "string-flag", new Flag<string>(new Dictionary<string, string> { { "greeting", "Howdy, Isolated World!" }, { "farewell", "See ya!" } }, defaultVariant: "greeting") },
56+
};
57+
58+
var isolated = Api.CreateIsolated();
59+
await isolated.SetProviderAsync(new InMemoryProvider(isolatedFlags));
60+
61+
var isolatedClient = isolated.GetClient();
62+
63+
Console.WriteLine();
64+
Console.WriteLine("--- Isolated API Instance ---");
65+
66+
// The isolated instance has its own flag values
67+
var isolatedBool = await isolatedClient.GetBooleanValueAsync("bool-flag", true);
68+
Console.WriteLine("Isolated `bool-flag`: {0} (singleton was: {1})", isolatedBool, helloWorldResult);
69+
70+
var isolatedString = await isolatedClient.GetStringValueAsync("string-flag", "default");
71+
Console.WriteLine("Isolated `string-flag`: {0} (singleton was: {1})", isolatedString, stringResult);
72+
73+
// Shut down the isolated instance — does not affect the global singleton
74+
await isolated.ShutdownAsync();
75+
Console.WriteLine("Isolated instance shut down. Singleton is unaffected.");

src/OpenFeature/Api.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public Task SetProviderAsync(FeatureProvider featureProvider)
5656
/// <returns>A <see cref="Task"/> that completes once Provider initialization is complete.</returns>
5757
public async Task SetProviderAsync(FeatureProvider featureProvider, CancellationToken cancellationToken)
5858
{
59+
this.ValidateProviderOwnership(featureProvider);
5960
this._eventExecutor.RegisterDefaultFeatureProvider(featureProvider);
6061
await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitializationAsync, this.AfterErrorAsync, cancellationToken)
6162
.ConfigureAwait(false);
@@ -91,6 +92,7 @@ public async Task SetProviderAsync(string domain, FeatureProvider featureProvide
9192
{
9293
throw new ArgumentNullException(nameof(domain));
9394
}
95+
this.ValidateProviderOwnership(featureProvider);
9496
this._eventExecutor.RegisterClientFeatureProvider(domain, featureProvider);
9597
await this._repository.SetProviderAsync(domain, featureProvider, this.GetContext(), this.AfterInitializationAsync, this.AfterErrorAsync, cancellationToken)
9698
.ConfigureAwait(false);
@@ -401,4 +403,19 @@ internal static void SetInstance(Api api)
401403
{
402404
Instance = api;
403405
}
406+
407+
/// <summary>
408+
/// Validates that the given provider is not already bound to a different API instance.
409+
/// </summary>
410+
/// <param name="featureProvider">The provider to validate ownership for.</param>
411+
/// <exception cref="InvalidOperationException">Thrown if the provider is already bound to a different API instance.</exception>
412+
private void ValidateProviderOwnership(FeatureProvider featureProvider)
413+
{
414+
if (!featureProvider.TryBindApiInstance(this))
415+
{
416+
throw new InvalidOperationException(
417+
"This provider instance is already bound to a different API instance. " +
418+
"A provider should not be registered with more than one API instance simultaneously.");
419+
}
420+
}
404421
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace OpenFeature.Constant;
2+
3+
/// <summary>
4+
/// Contains identifiers for experimental features and diagnostics in the OpenFeature framework.
5+
/// </summary>
6+
/// <remarks>
7+
/// <c>Experimental</c> - This class includes identifiers that allow developers to track and conditionally enable
8+
/// experimental features. Each identifier follows a structured code format to indicate the feature domain,
9+
/// maturity level, and unique identifier. Note that experimental features are subject to change or removal
10+
/// in future releases.
11+
/// <para>
12+
/// <strong>Basic Information</strong><br/>
13+
/// These identifiers conform to OpenFeature’s Diagnostics Specifications, allowing developers to recognize
14+
/// and manage experimental features effectively.
15+
/// </para>
16+
/// </remarks>
17+
/// <example>
18+
/// <code>
19+
/// Code Structure:
20+
/// - "OF" - Represents the OpenFeature library.
21+
/// - "ISO" - Indicates the Isolated API domain.
22+
/// - "001" - Unique identifier for a specific feature.
23+
/// </code>
24+
/// </example>
25+
internal static class FeatureDiagnosticCodes
26+
{
27+
/// <summary>
28+
/// Identifier for the experimental Isolated API features within the OpenFeature framework.
29+
/// </summary>
30+
/// <remarks>
31+
/// <c>OFISO001</c> identifier marks experimental features in the Isolated (ISO) domain.
32+
///
33+
/// Usage:
34+
/// Developers can use this identifier to conditionally enable or test experimental Isolated API features.
35+
/// It is part of the OpenFeature diagnostics system to help track experimental functionality.
36+
/// </remarks>
37+
public const string IsolatedApi = "OFISO001";
38+
}

src/OpenFeature/FeatureProvider.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,33 @@ public abstract Task<ResolutionDetails<Value>> ResolveStructureValueAsync(string
9898
/// </summary>
9999
internal virtual ProviderStatus Status { get; set; } = ProviderStatus.NotReady;
100100

101+
/// <summary>
102+
/// Tracks which Api instance this provider is currently bound to.
103+
/// A provider should not be registered with more than one API instance simultaneously (spec 1.8.4).
104+
/// </summary>
105+
private Api? _boundApiInstance;
106+
107+
/// <summary>
108+
/// Attempts to bind this provider to the given API instance.
109+
/// Uses <see cref="Interlocked.CompareExchange{T}"/> for thread-safe check-and-set.
110+
/// </summary>
111+
/// <param name="api">The API instance to bind to.</param>
112+
/// <returns><c>true</c> if the provider was successfully bound (or was already bound to the same instance);
113+
/// <c>false</c> if the provider is already bound to a different API instance.</returns>
114+
internal bool TryBindApiInstance(Api api)
115+
{
116+
var previous = Interlocked.CompareExchange(ref this._boundApiInstance, api, null);
117+
return previous is null || ReferenceEquals(previous, api);
118+
}
119+
120+
/// <summary>
121+
/// Clears the API instance binding, allowing this provider to be registered with another API instance.
122+
/// </summary>
123+
internal void UnbindApiInstance()
124+
{
125+
this._boundApiInstance = null;
126+
}
127+
101128
/// <summary>
102129
/// <para>
103130
/// This method is called before a provider is used to evaluate flags. Providers can overwrite this method,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#if NET8_0_OR_GREATER
2+
using System.Diagnostics.CodeAnalysis;
3+
#endif
4+
5+
namespace OpenFeature.Isolated;
6+
7+
/// <summary>
8+
/// Factory for creating isolated instances of the OpenFeature API.
9+
/// </summary>
10+
/// <remarks>
11+
/// This factory provides a method to create new, independent instances of the OpenFeature API with fully isolated state. Each isolated instance maintains its own providers, evaluation context, hooks, event handlers,
12+
/// and transaction context propagators. It does not share state with the global <see cref="Api.Instance"/> singleton or with any other isolated instance.
13+
/// </remarks>
14+
public static class OpenFeatureFactory
15+
{
16+
/// <summary>
17+
/// Creates a new, independent instance of the OpenFeature API with fully isolated state.
18+
/// <para>
19+
/// Each isolated instance maintains its own providers, evaluation context, hooks, event handlers,
20+
/// and transaction context propagators. It does not share state with the global <see cref="Api.Instance"/>
21+
/// singleton or with any other isolated instance.
22+
/// </para>
23+
/// </summary>
24+
/// <remarks>
25+
/// <para>
26+
/// A single provider instance should not be bound to more than one API instance at a time.
27+
/// Attempting to do so will result in an <see cref="InvalidOperationException"/>.
28+
/// </para>
29+
/// </remarks>
30+
/// <returns>A new, independent <see cref="Api"/> instance.</returns>
31+
/// <seealso href="https://openfeature.dev/specification/sections/flag-evaluation#18-isolated-api-instances">Specification 1.8 - Isolated API Instances</seealso>
32+
#if NET8_0_OR_GREATER
33+
[Experimental(Constant.FeatureDiagnosticCodes.IsolatedApi)]
34+
#endif
35+
public static Api CreateIsolated() => new Api();
36+
}

src/OpenFeature/ProviderRepository.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,13 @@ private async Task ShutdownIfUnusedAsync(
193193
return;
194194
}
195195

196+
// Clear ownership while still under the write lock — the provider is confirmed unused.
197+
// This prevents a race where async shutdown clears ownership after a re-registration.
198+
if (targetProvider != null)
199+
{
200+
targetProvider.UnbindApiInstance();
201+
}
202+
196203
await this.SafeShutdownProviderAsync(targetProvider, cancellationToken).ConfigureAwait(false);
197204
}
198205

@@ -270,6 +277,12 @@ internal async Task ShutdownAsync(Action<FeatureProvider, Exception>? afterError
270277
// Set a default provider so the Api is ready to be used again.
271278
this._defaultProvider = new NoOpFeatureProvider();
272279
this._featureProviders.Clear();
280+
281+
// Clear ownership under the write lock for all providers being shut down.
282+
foreach (var provider in providers)
283+
{
284+
provider.UnbindApiInstance();
285+
}
273286
}
274287
finally
275288
{

test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
1515
<WarningsNotAsErrors>NU1903</WarningsNotAsErrors>
1616
<RootNamespace>OpenFeature.AotCompatibility</RootNamespace>
17+
<NoWarn>$(NoWarn);OFISO001</NoWarn>
1718
</PropertyGroup>
1819

1920
<ItemGroup>
@@ -27,8 +28,4 @@
2728
<PackageReference Include="Microsoft.Extensions.Hosting" />
2829
</ItemGroup>
2930

30-
<ItemGroup>
31-
<PackageReference Include="System.Text.Json" />
32-
</ItemGroup>
33-
3431
</Project>

test/OpenFeature.AotCompatibility/Program.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.Extensions.Hosting;
44
using Microsoft.Extensions.Logging;
55
using OpenFeature.Constant;
6+
using OpenFeature.Isolated;
67
using OpenFeature.Model;
78
using OpenFeature.Providers.MultiProvider;
89
using OpenFeature.Providers.MultiProvider.Models;
@@ -27,6 +28,9 @@ private static async Task Main(string[] args)
2728
// Test basic API functionality
2829
await TestBasicApiAsync();
2930

31+
// Test isolated API instances
32+
await TestIsolatedApiAsync();
33+
3034
// Test MultiProvider AOT compatibility
3135
await TestMultiProviderAotCompatibilityAsync();
3236

@@ -49,6 +53,41 @@ private static async Task Main(string[] args)
4953
}
5054
}
5155

56+
private static async Task TestIsolatedApiAsync()
57+
{
58+
Console.WriteLine("\nTesting isolated API instances...");
59+
60+
// Create an isolated instance
61+
var isolated = OpenFeatureFactory.CreateIsolated();
62+
Console.WriteLine($"✓- Isolated API instance created: {isolated.GetType().Name}");
63+
64+
// Verify it is distinct from the singleton
65+
if (ReferenceEquals(isolated, Api.Instance))
66+
{
67+
throw new InvalidOperationException("Isolated instance should not be the same as the singleton.");
68+
}
69+
Console.WriteLine("✓- Isolated instance is distinct from singleton");
70+
71+
// Set a provider on the isolated instance
72+
var provider = new TestProvider();
73+
await isolated.SetProviderAsync(provider);
74+
Console.WriteLine($"✓- Provider set on isolated instance: {isolated.GetProviderMetadata()?.Name}");
75+
76+
// Create a client and evaluate a flag
77+
var client = isolated.GetClient("isolated-client");
78+
var result = await client.GetBooleanValueAsync("test-flag", false);
79+
Console.WriteLine($"✓- Isolated client flag evaluation: {result}");
80+
81+
// Set context on isolated instance and verify independence
82+
var context = EvaluationContext.Builder().Set("scope", "isolated").Build();
83+
isolated.SetContext(context);
84+
Console.WriteLine($"✓- Isolated context set with {context.Count} attributes");
85+
86+
// Shutdown the isolated instance
87+
await isolated.ShutdownAsync();
88+
Console.WriteLine("✓- Isolated instance shut down successfully");
89+
}
90+
5291
private static async Task TestBasicApiAsync()
5392
{
5493
Console.WriteLine("\nTesting basic API functionality...");

0 commit comments

Comments
 (0)