Files
git.stella-ops.org/src/__Libraries/StellaOps.FeatureFlags/CompositeFeatureFlagService.cs
2026-02-01 21:37:40 +02:00

304 lines
9.6 KiB
C#

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Collections.Concurrent;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Text.Json;
namespace StellaOps.FeatureFlags;
/// <summary>
/// Composite feature flag service that aggregates flags from multiple providers.
/// Providers are checked in priority order; first match wins.
/// </summary>
public sealed class CompositeFeatureFlagService : IFeatureFlagService, IDisposable
{
private readonly IReadOnlyList<IFeatureFlagProvider> _providers;
private readonly FeatureFlagOptions _options;
private readonly ILogger<CompositeFeatureFlagService> _logger;
private readonly MemoryCache _cache;
private readonly Subject<FeatureFlagChangedEvent> _changeSubject;
private readonly CancellationTokenSource _watchCts;
private readonly List<Task> _watchTasks;
private bool _disposed;
/// <summary>
/// Creates a new composite feature flag service.
/// </summary>
public CompositeFeatureFlagService(
IEnumerable<IFeatureFlagProvider> providers,
IOptions<FeatureFlagOptions> options,
ILogger<CompositeFeatureFlagService> logger)
{
_providers = providers.OrderBy(p => p.Priority).ToList();
_options = options.Value;
_logger = logger;
_cache = new MemoryCache(new MemoryCacheOptions());
_changeSubject = new Subject<FeatureFlagChangedEvent>();
_watchCts = new CancellationTokenSource();
_watchTasks = [];
// Start watching providers that support it
StartWatching();
_logger.LogInformation(
"CompositeFeatureFlagService initialized with {ProviderCount} providers: {Providers}",
_providers.Count,
string.Join(", ", _providers.Select(p => $"{p.Name}(priority={p.Priority})")));
}
/// <inheritdoc />
public IObservable<FeatureFlagChangedEvent> OnFlagChanged => _changeSubject.AsObservable();
/// <inheritdoc />
public Task<bool> IsEnabledAsync(string flagKey, CancellationToken ct = default)
{
return IsEnabledAsync(flagKey, FeatureFlagEvaluationContext.Empty, ct);
}
/// <inheritdoc />
public async Task<bool> IsEnabledAsync(
string flagKey,
FeatureFlagEvaluationContext context,
CancellationToken ct = default)
{
var result = await EvaluateAsync(flagKey, context, ct);
return result.Enabled;
}
/// <inheritdoc />
public async Task<FeatureFlagResult> EvaluateAsync(
string flagKey,
FeatureFlagEvaluationContext? context = null,
CancellationToken ct = default)
{
context ??= FeatureFlagEvaluationContext.Empty;
// Check cache first
var cacheKey = GetCacheKey(flagKey, context);
if (_options.EnableCaching && _cache.TryGetValue(cacheKey, out FeatureFlagResult? cached) && cached is not null)
{
if (_options.EnableLogging)
{
_logger.LogDebug("Flag '{FlagKey}' returned from cache: {Enabled}", flagKey, cached.Enabled);
}
return cached;
}
// Try each provider in priority order
foreach (var provider in _providers)
{
try
{
var result = await provider.TryGetFlagAsync(flagKey, context, ct);
if (result is not null)
{
// Cache the result
if (_options.EnableCaching)
{
_cache.Set(cacheKey, result, _options.CacheDuration);
}
if (_options.EnableLogging)
{
_logger.LogDebug(
"Flag '{FlagKey}' evaluated by {Provider}: Enabled={Enabled}, Reason={Reason}",
flagKey, provider.Name, result.Enabled, result.Reason);
}
return result;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Provider {Provider} failed to evaluate flag '{FlagKey}'",
provider.Name, flagKey);
}
}
// No provider had the flag, return default
var defaultResult = new FeatureFlagResult(
flagKey,
_options.DefaultValue,
null,
"Flag not found in any provider",
"default");
if (_options.EnableLogging)
{
_logger.LogDebug(
"Flag '{FlagKey}' not found, using default: {Default}",
flagKey, _options.DefaultValue);
}
return defaultResult;
}
/// <inheritdoc />
public async Task<T> GetVariantAsync<T>(
string flagKey,
T defaultValue,
FeatureFlagEvaluationContext? context = null,
CancellationToken ct = default)
{
var result = await EvaluateAsync(flagKey, context, ct);
if (result.Variant is null)
{
return defaultValue;
}
try
{
// Handle direct type match
if (result.Variant is T typedValue)
{
return typedValue;
}
// Handle JSON string variant
if (result.Variant is string jsonString)
{
return JsonSerializer.Deserialize<T>(jsonString) ?? defaultValue;
}
// Handle JsonElement variant
if (result.Variant is JsonElement jsonElement)
{
return JsonSerializer.Deserialize<T>(jsonElement.GetRawText()) ?? defaultValue;
}
// Try conversion
return (T)Convert.ChangeType(result.Variant, typeof(T));
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to convert variant for flag '{FlagKey}' to type {Type}",
flagKey, typeof(T).Name);
return defaultValue;
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<FeatureFlagDefinition>> ListFlagsAsync(CancellationToken ct = default)
{
var allFlags = new Dictionary<string, FeatureFlagDefinition>();
foreach (var provider in _providers)
{
try
{
var flags = await provider.ListFlagsAsync(ct);
foreach (var flag in flags)
{
// First provider to define a flag wins (priority order)
if (!allFlags.ContainsKey(flag.Key))
{
allFlags[flag.Key] = flag;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Provider {Provider} failed to list flags", provider.Name);
}
}
return allFlags.Values.OrderBy(f => f.Key).ToList();
}
/// <inheritdoc />
public void InvalidateCache(string? flagKey = null)
{
if (flagKey is null)
{
// Clear all cached values
_cache.Compact(1.0);
_logger.LogDebug("All feature flag cache entries invalidated");
}
else
{
// We can't easily invalidate a single key with all contexts,
// so we compact the entire cache
_cache.Compact(1.0);
_logger.LogDebug("Feature flag cache invalidated for key '{FlagKey}'", flagKey);
}
}
private void StartWatching()
{
foreach (var provider in _providers.Where(p => p.SupportsWatch))
{
var task = Task.Run(async () =>
{
try
{
await foreach (var change in provider.WatchAsync(_watchCts.Token))
{
// Invalidate cache for changed flag
InvalidateCache(change.Key);
// Publish change event
_changeSubject.OnNext(change);
if (_options.EnableLogging)
{
_logger.LogInformation(
"Flag '{FlagKey}' changed from {OldValue} to {NewValue} (source: {Source})",
change.Key, change.OldValue, change.NewValue, change.Source);
}
}
}
catch (OperationCanceledException) when (_watchCts.Token.IsCancellationRequested)
{
// Expected during shutdown
}
catch (Exception ex)
{
_logger.LogError(ex, "Error watching provider {Provider}", provider.Name);
}
});
_watchTasks.Add(task);
}
}
private static string GetCacheKey(string flagKey, FeatureFlagEvaluationContext context)
{
// Include relevant context in cache key
var contextHash = HashCode.Combine(
context.UserId,
context.TenantId,
context.Environment);
return $"ff:{flagKey}:{contextHash}";
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
return;
_watchCts.Cancel();
_watchCts.Dispose();
try
{
Task.WaitAll([.. _watchTasks], TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
// Ignore cancellation exceptions
}
_changeSubject.Dispose();
_cache.Dispose();
_disposed = true;
}
}