Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public sealed partial class HostedFeatureLifecycleService : IHostedLifecycleServ
private readonly IFeatureLifecycleManager _featureLifecycleManager;
private readonly IOptions<FeatureLifecycleStateOptions> _featureLifecycleStateOptions;

// Hosts that support IHostedLifecycleService (e.g. the .NET 8+ generic host) always invoke
// StartingAsync before StartAsync. If StartingAsync was never invoked, the host only supports
// IHostedService (e.g. the legacy ASP.NET Core WebHost), and we fall back to StartAsync/StopAsync.
private bool _lifecycleCallbacksSupported;

/// <summary>
/// Initializes a new instance of the <see cref="HostedFeatureLifecycleService"/> class.
/// </summary>
Expand All @@ -35,13 +40,25 @@ public HostedFeatureLifecycleService(
/// Ensures that the feature is properly initialized when the service starts.
/// </summary>
public async Task StartingAsync(CancellationToken cancellationToken)
=> await InitializeIfStateMatchesAsync(FeatureStartState.Starting, cancellationToken).ConfigureAwait(false);
{
_lifecycleCallbacksSupported = true;
await InitializeIfStateMatchesAsync(FeatureStartState.Starting, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Ensures that the feature is in the "Start" state.
/// </summary>
public async Task StartAsync(CancellationToken cancellationToken)
=> await InitializeIfStateMatchesAsync(FeatureStartState.Start, cancellationToken).ConfigureAwait(false);
{
if (!_lifecycleCallbacksSupported && _featureLifecycleStateOptions.Value.StartState != FeatureStartState.Start)
{
this.LogLifecycleCallbacksNotSupportedFallback(nameof(StartAsync));
await InitializeIfStateMatchesAsync(_featureLifecycleStateOptions.Value.StartState, cancellationToken).ConfigureAwait(false);
return;
}

await InitializeIfStateMatchesAsync(FeatureStartState.Start, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Ensures that the feature is fully started and operational.
Expand All @@ -59,7 +76,16 @@ public async Task StoppingAsync(CancellationToken cancellationToken)
/// Ensures that the feature is in the "Stop" state.
/// </summary>
public async Task StopAsync(CancellationToken cancellationToken)
=> await ShutdownIfStateMatchesAsync(FeatureStopState.Stop, cancellationToken).ConfigureAwait(false);
{
if (!_lifecycleCallbacksSupported && _featureLifecycleStateOptions.Value.StopState != FeatureStopState.Stop)
{
this.LogLifecycleCallbacksNotSupportedFallback(nameof(StopAsync));
await ShutdownIfStateMatchesAsync(_featureLifecycleStateOptions.Value.StopState, cancellationToken).ConfigureAwait(false);
return;
}

await ShutdownIfStateMatchesAsync(FeatureStopState.Stop, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Ensures that the feature is fully stopped and no longer operational.
Expand Down Expand Up @@ -96,4 +122,7 @@ private async Task ShutdownIfStateMatchesAsync(FeatureStopState expectedState, C

[LoggerMessage(200, LogLevel.Information, "Shutting down the Feature Lifecycle Manager for state {State}")]
partial void LogShuttingDownFeatureLifecycleManager(FeatureStopState state);

[LoggerMessage(201, LogLevel.Information, "The host does not support IHostedLifecycleService callbacks. Falling back to {Method} to manage the feature lifecycle.")]
partial void LogLifecycleCallbacksNotSupportedFallback(string method);
}
15 changes: 15 additions & 0 deletions src/OpenFeature.Hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ app.MapGet("/", async ([FromKeyedServices("beta")] IFeatureClient client) => {
app.Run();
```

## 🔄 Lifecycle Management

`AddOpenFeature` registers a hosted service that initializes the configured providers on application startup and shuts them down on application exit. By default it hooks into the `IHostedLifecycleService` callbacks (`StartingAsync`/`StoppedAsync`), which are supported by the .NET generic host (`WebApplication.CreateBuilder`, `Host.CreateDefaultBuilder`).

Hosts that only support `IHostedService` — such as the legacy ASP.NET Core `WebHost`/`WebHostBuilder` — never invoke those callbacks. In that case the hosted service automatically falls back to `StartAsync`/`StopAsync`, so providers are still initialized and shut down without any extra configuration. Note that on such hosts the fallback preserves lifecycle execution, not the exact timing of the configured `Starting`/`Started`/`Stopping`/`Stopped` callbacks: initialization and shutdown always run within `StartAsync` and `StopAsync`.

You can also control which lifecycle callbacks are used explicitly:

```csharp
builder.Services.Configure<FeatureLifecycleStateOptions>(options => {
options.StartState = FeatureStartState.Start;
options.StopState = FeatureStopState.Stop;
});
```

## 📚 Further Reading

- [OpenFeature .NET SDK Documentation](https://github.com/open-feature/dotnet-sdk)
Expand Down
168 changes: 168 additions & 0 deletions test/OpenFeature.Hosting.Tests/HostedFeatureLifecycleServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;

namespace OpenFeature.Hosting.Tests;

public class HostedFeatureLifecycleServiceTests
{
private readonly IFeatureLifecycleManager _featureLifecycleManager;

public HostedFeatureLifecycleServiceTests()
{
_featureLifecycleManager = Substitute.For<IFeatureLifecycleManager>();
}

private HostedFeatureLifecycleService CreateSystemUnderTest(FeatureLifecycleStateOptions? options = null)
=> new(
NullLogger<HostedFeatureLifecycleService>.Instance,
_featureLifecycleManager,
Options.Create(options ?? new FeatureLifecycleStateOptions()));

[Theory]
[InlineData(FeatureStartState.Starting)]
[InlineData(FeatureStartState.Start)]
[InlineData(FeatureStartState.Started)]
public async Task GenericHostLifecycle_ShouldInitializeExactlyOnce_ForAnyStartState(FeatureStartState startState)
{
// Arrange
var sut = CreateSystemUnderTest(new FeatureLifecycleStateOptions { StartState = startState });

// Act - the generic host invokes all IHostedLifecycleService start callbacks in order.
await sut.StartingAsync(CancellationToken.None);
await sut.StartAsync(CancellationToken.None);
await sut.StartedAsync(CancellationToken.None);

// Assert
await _featureLifecycleManager.Received(1).EnsureInitializedAsync(Arg.Any<CancellationToken>());
}

[Theory]
[InlineData(FeatureStopState.Stopping)]
[InlineData(FeatureStopState.Stop)]
[InlineData(FeatureStopState.Stopped)]
public async Task GenericHostLifecycle_ShouldShutdownExactlyOnce_ForAnyStopState(FeatureStopState stopState)
{
// Arrange
var sut = CreateSystemUnderTest(new FeatureLifecycleStateOptions { StopState = stopState });

// Act - the generic host invokes all IHostedLifecycleService callbacks in order.
await sut.StartingAsync(CancellationToken.None);
await sut.StartAsync(CancellationToken.None);
await sut.StartedAsync(CancellationToken.None);
await sut.StoppingAsync(CancellationToken.None);
await sut.StopAsync(CancellationToken.None);
await sut.StoppedAsync(CancellationToken.None);

// Assert
await _featureLifecycleManager.Received(1).ShutdownAsync(Arg.Any<CancellationToken>());
}

[Fact]
public async Task WebHostLifecycle_ShouldInitializeAndShutdown_WithDefaultOptions()
{
// Arrange
var sut = CreateSystemUnderTest();

// Act - the legacy ASP.NET Core WebHost only invokes the IHostedService callbacks.
await sut.StartAsync(CancellationToken.None);
await sut.StopAsync(CancellationToken.None);

// Assert
await _featureLifecycleManager.Received(1).EnsureInitializedAsync(Arg.Any<CancellationToken>());
await _featureLifecycleManager.Received(1).ShutdownAsync(Arg.Any<CancellationToken>());
}

[Theory]
[InlineData(FeatureStartState.Starting)]
[InlineData(FeatureStartState.Start)]
[InlineData(FeatureStartState.Started)]
public async Task WebHostLifecycle_ShouldInitializeExactlyOnce_ForAnyStartState(FeatureStartState startState)
{
// Arrange
var sut = CreateSystemUnderTest(new FeatureLifecycleStateOptions { StartState = startState });

// Act - the legacy ASP.NET Core WebHost only invokes the IHostedService callbacks.
await sut.StartAsync(CancellationToken.None);

// Assert
await _featureLifecycleManager.Received(1).EnsureInitializedAsync(Arg.Any<CancellationToken>());
}

[Theory]
[InlineData(FeatureStopState.Stopping)]
[InlineData(FeatureStopState.Stop)]
[InlineData(FeatureStopState.Stopped)]
public async Task WebHostLifecycle_ShouldShutdownExactlyOnce_ForAnyStopState(FeatureStopState stopState)
{
// Arrange
var sut = CreateSystemUnderTest(new FeatureLifecycleStateOptions { StopState = stopState });

// Act - the legacy ASP.NET Core WebHost only invokes the IHostedService callbacks.
await sut.StartAsync(CancellationToken.None);
await sut.StopAsync(CancellationToken.None);

// Assert
await _featureLifecycleManager.Received(1).ShutdownAsync(Arg.Any<CancellationToken>());
}

[Theory]
[InlineData(FeatureStartState.Starting)]
[InlineData(FeatureStartState.Start)]
[InlineData(FeatureStartState.Started)]
public async Task GenericHostLifecycle_ShouldInitializeOnlyInConfiguredCallback(FeatureStartState startState)
{
// Arrange
var sut = CreateSystemUnderTest(new FeatureLifecycleStateOptions { StartState = startState });

// Act & Assert - initialization only happens once the configured callback is reached.
await sut.StartingAsync(CancellationToken.None);
await _featureLifecycleManager.Received(startState == FeatureStartState.Starting ? 1 : 0)
.EnsureInitializedAsync(Arg.Any<CancellationToken>());

await sut.StartAsync(CancellationToken.None);
await _featureLifecycleManager.Received(startState == FeatureStartState.Started ? 0 : 1)
.EnsureInitializedAsync(Arg.Any<CancellationToken>());

await sut.StartedAsync(CancellationToken.None);
await _featureLifecycleManager.Received(1).EnsureInitializedAsync(Arg.Any<CancellationToken>());
}

[Theory]
[InlineData(FeatureStopState.Stopping)]
[InlineData(FeatureStopState.Stop)]
[InlineData(FeatureStopState.Stopped)]
public async Task GenericHostLifecycle_ShouldShutdownOnlyInConfiguredCallback(FeatureStopState stopState)
{
// Arrange
var sut = CreateSystemUnderTest(new FeatureLifecycleStateOptions { StopState = stopState });
await sut.StartingAsync(CancellationToken.None);
await sut.StartAsync(CancellationToken.None);
await sut.StartedAsync(CancellationToken.None);

// Act & Assert - shutdown only happens once the configured callback is reached.
await sut.StoppingAsync(CancellationToken.None);
await _featureLifecycleManager.Received(stopState == FeatureStopState.Stopping ? 1 : 0)
.ShutdownAsync(Arg.Any<CancellationToken>());

await sut.StopAsync(CancellationToken.None);
await _featureLifecycleManager.Received(stopState == FeatureStopState.Stopped ? 0 : 1)
.ShutdownAsync(Arg.Any<CancellationToken>());

await sut.StoppedAsync(CancellationToken.None);
await _featureLifecycleManager.Received(1).ShutdownAsync(Arg.Any<CancellationToken>());
}

[Fact]
public async Task WebHostLifecycle_ShouldShutdownExactlyOnce_WhenStartWasNeverInvoked()
{
// Arrange
var sut = CreateSystemUnderTest();

// Act - hosts may stop services even if startup failed before StartAsync was invoked.
await sut.StopAsync(CancellationToken.None);

// Assert
await _featureLifecycleManager.Received(1).ShutdownAsync(Arg.Any<CancellationToken>());
}
}