diff --git a/Flagsmith.Client.Test/ClientTest.csproj b/Flagsmith.Client.Test/ClientTest.csproj index d4306c8..bc8b87e 100644 --- a/Flagsmith.Client.Test/ClientTest.csproj +++ b/Flagsmith.Client.Test/ClientTest.csproj @@ -7,6 +7,8 @@ + + diff --git a/Flagsmith.Client.Test/HybridCacheTest.cs b/Flagsmith.Client.Test/HybridCacheTest.cs new file mode 100644 index 0000000..8d491b0 --- /dev/null +++ b/Flagsmith.Client.Test/HybridCacheTest.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Flagsmith.Cache.Hybrid; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Flagsmith.FlagsmithClientTest +{ + public class HybridCacheTest + { + private const string Identifier = "identifier"; + + private static HybridCache CreateHybridCache() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + return services.BuildServiceProvider().GetRequiredService(); + } + + private static Mock MockIdentityResponse() + { + return HttpMocker.MockHttpResponse(HttpStatusCode.OK, Fixtures.ApiIdentityResponse, false); + } + + private static FlagsmithClient CreateClient( + Mock mockHttpClient, + Action configure = null) + { + var hybridCacheConfig = new HybridCacheConfig(CreateHybridCache()); + configure?.Invoke(hybridCacheConfig); + + return new FlagsmithClient(new FlagsmithConfiguration + { + EnvironmentKey = Fixtures.ApiKey, + HttpClient = mockHttpClient.Object, + HybridCacheConfig = hybridCacheConfig + }); + } + + private static FlagsmithClient CreateClientRefreshingOnTraitChanges(Mock mockHttpClient) + { + return CreateClient(mockHttpClient, config => config.RefreshOnTraitChanges = true); + } + + private static List Traits(params (string Key, string Value)[] traits) + { + var result = new List(); + foreach (var trait in traits) + { + result.Add(new Trait(trait.Key, trait.Value)); + } + + return result; + } + + /// + /// Cache entries are written by System.Text.Json on .NET 9 and later and by Newtonsoft.Json + /// below that, and a shared L2 cache can hold entries written by either. Pinning the wire + /// format here keeps the two readable by one another. + /// + [Fact] + public void TestCacheEntryWireFormatIsPinned() + { + // Given + var entry = new CachedFlagList + { + Flags = + { + new CachedFlag + { + FeatureName = "some_feature", + FeatureId = 1, + Enabled = true, + Value = "some-value" + } + }, + Traits = { ["foo"] = "bar" } + }; + + // When + var json = CachedFlagListSerializer.Serialize(entry); + + // Then + Assert.Equal( + "{\"flags\":[{\"feature_name\":\"some_feature\",\"feature_id\":1,\"enabled\":true," + + "\"value\":\"some-value\"}],\"traits\":{\"foo\":\"bar\"}}", + json); + } + + [Fact] + public void TestCacheEntryRoundTripsThroughTheSerializer() + { + // Given + var json = + "{\"flags\":[{\"feature_name\":\"some_feature\",\"feature_id\":1,\"enabled\":true," + + "\"value\":\"some-value\"}],\"traits\":{\"foo\":\"bar\"}}"; + + // When + var entry = CachedFlagListSerializer.Deserialize(json); + + // Then + var flag = Assert.Single(entry.Flags); + Assert.Equal("some_feature", flag.FeatureName); + Assert.Equal(1, flag.FeatureId); + Assert.True(flag.Enabled); + Assert.Equal("some-value", flag.Value); + Assert.Equal("bar", Assert.Single(entry.Traits).Value); + } + + [Fact] + public void TestCannotEnableBothCacheConfigAndHybridCacheConfig() + { + // Given + var config = new FlagsmithConfiguration + { + EnvironmentKey = Fixtures.ApiKey, + CacheConfig = new CacheConfig(true), + HybridCacheConfig = new HybridCacheConfig(CreateHybridCache()) + }; + + // Then + var exception = Assert.Throws(() => new FlagsmithClient(config)); + Assert.Equal("ValueError: Cannot use both cacheConfig and hybridCacheConfig.", exception.Message); + } + + [Fact] + public void TestCannotEnableHybridCacheWithoutACacheInstance() + { + // Given + var config = new FlagsmithConfiguration + { + EnvironmentKey = Fixtures.ApiKey, + HybridCacheConfig = new HybridCacheConfig { Enabled = true } + }; + + // Then + var exception = Assert.Throws(() => new FlagsmithClient(config)); + Assert.Equal("ValueError: hybridCacheConfig.Cache must be provided to use HybridCache.", exception.Message); + } + + [Fact] + public async Task TestEnvironmentFlagsAreServedFromTheCache() + { + // Given + var mockHttpClient = HttpMocker.MockHttpResponse(HttpStatusCode.OK, Fixtures.ApiFlagResponse, false); + var client = CreateClient(mockHttpClient); + + // When + await client.GetEnvironmentFlags(); + var flags = (await client.GetEnvironmentFlags()).AllFlags(); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Get, "/api/v1/flags/", Times.Once); + Assert.True(flags[0].Enabled); + Assert.Equal("some-value", flags[0].Value); + Assert.Equal("some_feature", flags[0].GetFeatureName()); + } + + [Fact] + public async Task TestIdentityFlagsAreServedFromTheCacheWhenTraitsAreUnchanged() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClient(mockHttpClient); + var traits = Traits(("foo", "bar")); + + // When + await client.GetIdentityFlags(Identifier, traits); + var flags = (await client.GetIdentityFlags(Identifier, Traits(("foo", "bar")))).AllFlags(); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", Times.Once); + Assert.True(flags[0].Enabled); + Assert.Equal("some-value", flags[0].Value); + Assert.Equal("some_feature", flags[0].GetFeatureName()); + } + + [Fact] + public async Task TestIdentityFlagsAreFetchedAgainWhenATraitIsAdded() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClientRefreshingOnTraitChanges(mockHttpClient); + + // When + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"), ("baz", "qux"))); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", () => Times.Exactly(2)); + } + + [Fact] + public async Task TestIdentityFlagsAreFetchedAgainWhenATraitValueChanges() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClientRefreshingOnTraitChanges(mockHttpClient); + + // When + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); + await client.GetIdentityFlags(Identifier, Traits(("foo", "changed"))); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", () => Times.Exactly(2)); + } + + [Fact] + public async Task TestIdentityFlagsAreFetchedAgainWhenATraitBecomesTransient() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClientRefreshingOnTraitChanges(mockHttpClient); + + // When + await client.GetIdentityFlags(Identifier, new List { new Trait("foo", "bar") }); + await client.GetIdentityFlags(Identifier, new List { new Trait("foo", "bar", true) }); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", () => Times.Exactly(2)); + } + + [Fact] + public async Task TestIdentityFlagsStayCachedWhenATraitIsOmitted() + { + // Given: Flagsmith keeps traits it has already been told about, so dropping one from the + // request cannot change the flags it returns. + var mockHttpClient = MockIdentityResponse(); + var client = CreateClientRefreshingOnTraitChanges(mockHttpClient); + + // When + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"), ("baz", "qux"))); + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); + await client.GetIdentityFlags(Identifier, new List()); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", Times.Once); + } + + [Fact] + public async Task TestTraitsKnownBeforeARefreshAreStillKnownAfterIt() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClientRefreshingOnTraitChanges(mockHttpClient); + + // When: the second call adds a trait, so it refreshes. The third call carries only the + // trait from the first one, which the refreshed entry must still remember. + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); + await client.GetIdentityFlags(Identifier, Traits(("baz", "qux"))); + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", () => Times.Exactly(2)); + } + + /// + /// HybridCache rejects a non-positive expiration when it writes an entry, which would + /// otherwise surface as an exception from every flag read rather than at construction. + /// + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void TestCannotUseANonPositiveDuration(int minutes) + { + // Given + var config = new FlagsmithConfiguration + { + EnvironmentKey = Fixtures.ApiKey, + HybridCacheConfig = new HybridCacheConfig(CreateHybridCache()) + { + Duration = TimeSpan.FromMinutes(minutes) + } + }; + + // Then + var exception = Assert.Throws(() => new FlagsmithClient(config)); + Assert.Equal("ValueError: hybridCacheConfig.Duration must be positive.", exception.Message); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void TestCannotUseANonPositiveLocalCacheDuration(int minutes) + { + // Given + var config = new FlagsmithConfiguration + { + EnvironmentKey = Fixtures.ApiKey, + HybridCacheConfig = new HybridCacheConfig(CreateHybridCache()) + { + LocalCacheDuration = TimeSpan.FromMinutes(minutes) + } + }; + + // Then + var exception = Assert.Throws(() => new FlagsmithClient(config)); + Assert.Equal("ValueError: hybridCacheConfig.LocalCacheDuration must be positive.", exception.Message); + } + + [Fact] + public void TestAnUnsetLocalCacheDurationIsAccepted() + { + var mockHttpClient = MockIdentityResponse(); + + // Then: no throw + _ = CreateClient(mockHttpClient, config => config.LocalCacheDuration = null); + } + + [Fact] + public void TestRefreshOnTraitChangesIsOffByDefault() + { + Assert.False(new HybridCacheConfig().RefreshOnTraitChanges); + Assert.False(new HybridCacheConfig(CreateHybridCache()).RefreshOnTraitChanges); + } + + /// + /// The default keeps evaluation stateless, matching the other server-side SDKs: a trait change + /// waits out the cache duration rather than invalidating the entry. + /// + [Fact] + public async Task TestIdentityFlagsStayCachedOnTraitChangesByDefault() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClient(mockHttpClient); + + // When + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); + await client.GetIdentityFlags(Identifier, Traits(("foo", "changed"), ("baz", "qux"))); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", Times.Once); + } + + [Fact] + public async Task TestIdentityFlagsAreFetchedAgainOnceTheCacheEntryExpires() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClient(mockHttpClient, config => + { + config.Duration = TimeSpan.FromMilliseconds(100); + config.RefreshOnTraitChanges = false; + }); + + // When + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); + await Task.Delay(TimeSpan.FromMilliseconds(300)); + await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", () => Times.Exactly(2)); + } + + [Fact] + public async Task TestIdentitiesAreCachedSeparately() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClient(mockHttpClient); + + // When + await client.GetIdentityFlags("first"); + await client.GetIdentityFlags("second"); + await client.GetIdentityFlags("first"); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", () => Times.Exactly(2)); + } + + [Fact] + public async Task TestTransientIdentitiesAreCachedSeparatelyFromPersistedOnes() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClient(mockHttpClient); + + // When + await client.GetIdentityFlags(Identifier, null); + await client.GetIdentityFlags(Identifier, null, true); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", () => Times.Exactly(2)); + } + + [Fact] + public async Task TestClientsWithDifferentEnvironmentKeysDoNotShareCacheEntries() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var sharedCache = CreateHybridCache(); + + FlagsmithClient ClientFor(string environmentKey) => new FlagsmithClient(new FlagsmithConfiguration + { + EnvironmentKey = environmentKey, + HttpClient = mockHttpClient.Object, + HybridCacheConfig = new HybridCacheConfig(sharedCache) + }); + + // When + await ClientFor("first-environment-key").GetIdentityFlags(Identifier); + await ClientFor("second-environment-key").GetIdentityFlags(Identifier); + + // Then + mockHttpClient.VerifyHttpRequest(HttpMethod.Post, "/api/v1/identities/", () => Times.Exactly(2)); + } + } +} diff --git a/Flagsmith.FlagsmithClient/Cache/Hybrid/CacheJsonContext.cs b/Flagsmith.FlagsmithClient/Cache/Hybrid/CacheJsonContext.cs new file mode 100644 index 0000000..353913e --- /dev/null +++ b/Flagsmith.FlagsmithClient/Cache/Hybrid/CacheJsonContext.cs @@ -0,0 +1,15 @@ +#if NET9_0_OR_GREATER +using System.Text.Json.Serialization; + +namespace Flagsmith.Cache.Hybrid +{ + /// + /// Source-generated serialization metadata for cache entries, so that reading and writing them + /// needs no runtime reflection and stays trimming and AOT friendly. + /// + [JsonSerializable(typeof(CachedFlagList))] + internal partial class CacheJsonContext : JsonSerializerContext + { + } +} +#endif diff --git a/Flagsmith.FlagsmithClient/Cache/Hybrid/CachedFlagList.cs b/Flagsmith.FlagsmithClient/Cache/Hybrid/CachedFlagList.cs new file mode 100644 index 0000000..b486593 --- /dev/null +++ b/Flagsmith.FlagsmithClient/Cache/Hybrid/CachedFlagList.cs @@ -0,0 +1,64 @@ +#nullable enable + +using System.Collections.Generic; +using Newtonsoft.Json; +#if NET9_0_OR_GREATER +using System.Text.Json.Serialization; +#endif + +namespace Flagsmith.Cache.Hybrid +{ + /// + /// What the SDK stores for one cache entry: the flag list, plus the identity traits Flagsmith was + /// known to hold when that list was fetched. + /// Property names are spelled out rather than left to a naming policy, because the same entry may + /// be written by one target framework and read back by another through a shared L2 cache. + /// + internal class CachedFlagList + { + [JsonProperty("flags")] +#if NET9_0_OR_GREATER + [JsonPropertyName("flags")] +#endif + public List Flags { get; set; } = new List(); + + /// + /// Trait key to a fingerprint of the trait as it was last sent to Flagsmith. + /// + [JsonProperty("traits")] +#if NET9_0_OR_GREATER + [JsonPropertyName("traits")] +#endif + public Dictionary Traits { get; set; } = new Dictionary(); + } + + /// + /// A single flag, reduced to the state that survives a round trip through the cache. + /// + internal class CachedFlag + { + [JsonProperty("feature_name")] +#if NET9_0_OR_GREATER + [JsonPropertyName("feature_name")] +#endif + public string FeatureName { get; set; } = string.Empty; + + [JsonProperty("feature_id")] +#if NET9_0_OR_GREATER + [JsonPropertyName("feature_id")] +#endif + public int FeatureId { get; set; } + + [JsonProperty("enabled")] +#if NET9_0_OR_GREATER + [JsonPropertyName("enabled")] +#endif + public bool Enabled { get; set; } + + [JsonProperty("value")] +#if NET9_0_OR_GREATER + [JsonPropertyName("value")] +#endif + public string? Value { get; set; } + } +} diff --git a/Flagsmith.FlagsmithClient/Cache/Hybrid/CachedFlagListSerializer.cs b/Flagsmith.FlagsmithClient/Cache/Hybrid/CachedFlagListSerializer.cs new file mode 100644 index 0000000..d73b83a --- /dev/null +++ b/Flagsmith.FlagsmithClient/Cache/Hybrid/CachedFlagListSerializer.cs @@ -0,0 +1,37 @@ +#nullable enable + +#if NET9_0_OR_GREATER +using System.Text.Json; +#else +using Newtonsoft.Json; +#endif + +namespace Flagsmith.Cache.Hybrid +{ + /// + /// Reads and writes cache entries as JSON, so that any HybridCache backing store can hold + /// them without the host having to register a serializer for SDK types. + /// On .NET 9 and later this goes through the System.Text.Json source generator; older targets fall + /// back to Newtonsoft.Json. Both produce the same JSON. + /// + internal static class CachedFlagListSerializer + { + internal static string Serialize(CachedFlagList entry) + { +#if NET9_0_OR_GREATER + return JsonSerializer.Serialize(entry, CacheJsonContext.Default.CachedFlagList); +#else + return JsonConvert.SerializeObject(entry); +#endif + } + + internal static CachedFlagList Deserialize(string json) + { +#if NET9_0_OR_GREATER + return JsonSerializer.Deserialize(json, CacheJsonContext.Default.CachedFlagList) ?? new CachedFlagList(); +#else + return JsonConvert.DeserializeObject(json) ?? new CachedFlagList(); +#endif + } + } +} diff --git a/Flagsmith.FlagsmithClient/Cache/Hybrid/HybridFlagListCache.cs b/Flagsmith.FlagsmithClient/Cache/Hybrid/HybridFlagListCache.cs new file mode 100644 index 0000000..885cf53 --- /dev/null +++ b/Flagsmith.FlagsmithClient/Cache/Hybrid/HybridFlagListCache.cs @@ -0,0 +1,130 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; + +namespace Flagsmith.Cache.Hybrid +{ + /// + /// Stores flag lists in a supplied by the host application. + /// + internal class HybridFlagListCache + { + private readonly HybridCache _cache; + private readonly HybridCacheConfig _config; + private readonly HybridCacheEntryOptions _entryOptions; + private readonly Func, IFlags> _flagsFactory; + private readonly string _keyPrefix; + + internal HybridFlagListCache( + HybridCacheConfig config, + string? environmentKey, + Func, IFlags> flagsFactory) + { + _config = config; + _cache = config.Cache!; + _flagsFactory = flagsFactory; + _entryOptions = new HybridCacheEntryOptions + { + Expiration = config.Duration, + LocalCacheExpiration = config.LocalCacheDuration ?? config.Duration + }; + + // Scope keys by environment so several clients can share one HybridCache instance. + _keyPrefix = $"{config.KeyPrefix}:{Utils.GetHashString(environmentKey ?? string.Empty).Substring(0, 16)}"; + } + + internal async Task GetEnvironmentFlags(Func> getFlags) + { + var json = await _cache.GetOrCreateAsync( + $"{_keyPrefix}:environment", + getFlags, + async (factory, _) => Serialize( + await factory().ConfigureAwait(false), + new Dictionary()), + _entryOptions).ConfigureAwait(false); + + return ToFlags(Deserialize(json)); + } + + internal async Task GetIdentityFlags( + IdentityWrapper identityWrapper, + Func> getFlags) + { + var key = IdentityKey(identityWrapper); + var currentTraits = TraitFingerprint.Create(identityWrapper.Traits); + + var json = await _cache.GetOrCreateAsync( + key, + (identityWrapper, currentTraits, getFlags), + async (state, _) => Serialize( + await state.getFlags(state.identityWrapper).ConfigureAwait(false), + state.currentTraits), + _entryOptions).ConfigureAwait(false); + + var entry = Deserialize(json); + + if (!_config.RefreshOnTraitChanges || + !TraitFingerprint.HasAddedOrChangedTraits(entry.Traits, currentTraits)) + { + return ToFlags(entry); + } + + // Flagsmith only learns of a trait once we send it, so an added or changed trait has to + // reach the API before the flags derived from it can be trusted. Callers racing here cost + // an extra request, never a stale result. + var flags = await getFlags(identityWrapper).ConfigureAwait(false); + await _cache.SetAsync( + key, + Serialize(flags, TraitFingerprint.Merge(entry.Traits, currentTraits)), + _entryOptions).ConfigureAwait(false); + + return flags; + } + + private string IdentityKey(IdentityWrapper identityWrapper) + { + // A transient identity is not persisted by Flagsmith and so evaluates differently. + var scope = identityWrapper.Transient ? "transient-identity" : "identity"; + return $"{_keyPrefix}:{scope}:{Utils.GetHashString(identityWrapper.Identifier ?? string.Empty)}"; + } + + private static string Serialize(IFlags flags, Dictionary traits) + { + return CachedFlagListSerializer.Serialize(new CachedFlagList + { + Flags = flags.AllFlags()?.Select(ToCachedFlag).ToList() ?? new List(), + Traits = traits + }); + } + + private static CachedFlagList Deserialize(string json) + { + return CachedFlagListSerializer.Deserialize(json); + } + + private static CachedFlag ToCachedFlag(IFlag flag) + { + return new CachedFlag + { + FeatureName = flag.GetFeatureName(), + FeatureId = (flag as Flag)?.GetFeatureId() ?? default, + Enabled = flag.Enabled, + Value = flag.Value + }; + } + + private static IFlag FromCachedFlag(CachedFlag flag) + { + return new Flag(new Feature(flag.FeatureName, flag.FeatureId), flag.Enabled, flag.Value); + } + + private IFlags ToFlags(CachedFlagList entry) + { + return _flagsFactory(entry.Flags.Select(FromCachedFlag).ToList()); + } + } +} diff --git a/Flagsmith.FlagsmithClient/Cache/Hybrid/TraitFingerprint.cs b/Flagsmith.FlagsmithClient/Cache/Hybrid/TraitFingerprint.cs new file mode 100644 index 0000000..6d664af --- /dev/null +++ b/Flagsmith.FlagsmithClient/Cache/Hybrid/TraitFingerprint.cs @@ -0,0 +1,80 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Flagsmith.Cache.Hybrid +{ + /// + /// Compares the traits of an incoming request against the traits Flagsmith is known to hold, so + /// that a cached flag list can be discarded when it can no longer be trusted. + /// + internal static class TraitFingerprint + { + /// + /// Reduces traits to a trait key to value fingerprint map. + /// + internal static Dictionary Create(List? traits) + { + var fingerprints = new Dictionary(StringComparer.Ordinal); + if (traits == null) + { + return fingerprints; + } + + foreach (var trait in traits) + { + var key = trait?.GetTraitKey(); + if (key == null) + { + continue; + } + + // ITrait.ToString() renders the key, the value and the transient flag, all of which + // change what Flagsmith evaluates. + fingerprints[key] = trait!.ToString(); + } + + return fingerprints; + } + + /// + /// Returns true when carries a trait that + /// does not, or a different value for one it does. + /// Traits missing from are ignored on purpose: Flagsmith keeps the + /// traits it has already been told about, so omitting one cannot change the flags it returns. + /// + internal static bool HasAddedOrChangedTraits( + IDictionary known, + IDictionary current) + { + foreach (var trait in current) + { + if (!known.TryGetValue(trait.Key, out var knownFingerprint) || + !string.Equals(knownFingerprint, trait.Value, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// + /// Folds into . Traits are never dropped, so + /// that a later request carrying only a subset of them is still recognised as unchanged. + /// + internal static Dictionary Merge( + IDictionary known, + IDictionary current) + { + var merged = new Dictionary(known, StringComparer.Ordinal); + foreach (var trait in current) + { + merged[trait.Key] = trait.Value; + } + + return merged; + } + } +} diff --git a/Flagsmith.FlagsmithClient/Flag.cs b/Flagsmith.FlagsmithClient/Flag.cs index 7f1ff01..5f19939 100644 --- a/Flagsmith.FlagsmithClient/Flag.cs +++ b/Flagsmith.FlagsmithClient/Flag.cs @@ -28,6 +28,15 @@ public string GetFeatureName() { return this.Feature.Name; } + + /// + /// The feature id, which does not expose. Used to round-trip a flag + /// through the cache without losing it. + /// + internal int GetFeatureId() + { + return this.Feature != null ? this.Feature.Id : default; + } public override string ToString() { return JsonConvert.SerializeObject(this); diff --git a/Flagsmith.FlagsmithClient/Flagsmith.FlagsmithClient.csproj b/Flagsmith.FlagsmithClient/Flagsmith.FlagsmithClient.csproj index 1ec00cf..48bc8c8 100644 --- a/Flagsmith.FlagsmithClient/Flagsmith.FlagsmithClient.csproj +++ b/Flagsmith.FlagsmithClient/Flagsmith.FlagsmithClient.csproj @@ -1,8 +1,11 @@ - netstandard2.0 - 8 + + netstandard2.0;net9.0 + + 9 $(DefaultItemExcludes);example/**; true Flagsmith @@ -22,6 +25,12 @@ + + + + + + diff --git a/Flagsmith.FlagsmithClient/FlagsmithClient.cs b/Flagsmith.FlagsmithClient/FlagsmithClient.cs index 9ad6bc5..9f53523 100644 --- a/Flagsmith.FlagsmithClient/FlagsmithClient.cs +++ b/Flagsmith.FlagsmithClient/FlagsmithClient.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Flagsmith.Cache; +using Flagsmith.Cache.Hybrid; using Flagsmith.Extensions; using Flagsmith.Providers; using FlagsmithEngine; @@ -45,6 +46,7 @@ public class FlagsmithClient : IFlagsmithClient private AnalyticsProcessor? _analyticsProcessor; private RegularFlagListCache? _regularFlagListCache; private ConcurrentDictionary? _flagListCacheDictionary; + private HybridFlagListCache? _hybridFlagListCache; private void Initialise() { @@ -56,6 +58,24 @@ private void Initialise() { throw new Exception("ValueError: Cannot use both defaultFlagHandler and offlineHandler."); } + else if (_config.CacheConfig.Enabled && _config.HybridCacheConfig.Enabled) + { + throw new Exception("ValueError: Cannot use both cacheConfig and hybridCacheConfig."); + } + else if (_config.HybridCacheConfig.Enabled && _config.HybridCacheConfig.Cache is null) + { + throw new Exception("ValueError: hybridCacheConfig.Cache must be provided to use HybridCache."); + } + // HybridCache rejects a non-positive expiration when it writes, and that throw would + // otherwise surface from every flag read rather than from here. + else if (_config.HybridCacheConfig.Enabled && _config.HybridCacheConfig.Duration <= TimeSpan.Zero) + { + throw new Exception("ValueError: hybridCacheConfig.Duration must be positive."); + } + else if (_config.HybridCacheConfig.Enabled && _config.HybridCacheConfig.LocalCacheDuration <= TimeSpan.Zero) + { + throw new Exception("ValueError: hybridCacheConfig.LocalCacheDuration must be positive."); + } if (_config.OfflineHandler != null) { @@ -92,6 +112,12 @@ private void Initialise() _config.CacheConfig.DurationInMinutes); _flagListCacheDictionary = new ConcurrentDictionary(); } + else if (_config.HybridCacheConfig.Enabled) + { + _hybridFlagListCache = new HybridFlagListCache(_config.HybridCacheConfig, + _config.EnvironmentKey, + flags => Flags.FromApiFlag(_analyticsProcessor, _config.DefaultFlagHandler, flags)); + } } public FlagsmithClient(FlagsmithConfiguration configuration) @@ -110,6 +136,11 @@ public async Task GetEnvironmentFlags() return _regularFlagListCache!.GetLatestFlags(GetFeatureFlagsFromCorrectSource); } + if (_hybridFlagListCache != null) + { + return await _hybridFlagListCache.GetEnvironmentFlags(GetFeatureFlagsFromCorrectSource).ConfigureAwait(false); + } + return await GetFeatureFlagsFromCorrectSource().ConfigureAwait(false); } @@ -140,6 +171,11 @@ public async Task GetIdentityFlags(string identifier, List? trai return flagListCache.GetLatestFlags(GetIdentityFlagsFromCorrectSource); } + if (_hybridFlagListCache != null) + { + return await _hybridFlagListCache.GetIdentityFlags(identityWrapper, GetIdentityFlagsFromCorrectSource).ConfigureAwait(false); + } + if (_config.OfflineMode) return this.GetIdentityFlagsFromLocalEvaluationContext(identifier, traits ?? null); @@ -257,13 +293,17 @@ private async Task GetFeatureFlagsFromApi() var flags = JsonConvert.DeserializeObject>(json)?.ToList(); return Flags.FromApiFlag(_analyticsProcessor, _config.DefaultFlagHandler, flags); } - catch (FlagsmithAPIError e) + catch (FlagsmithAPIError) { if (Environment != null) { return this.GetEnvironmentFlagsFromLocalEvaluationContext(); } - return _config.DefaultFlagHandler != null ? Flags.FromApiFlag(_analyticsProcessor, _config.DefaultFlagHandler, null) : throw e; + if (_config.DefaultFlagHandler == null) + { + throw; + } + return Flags.FromApiFlag(_analyticsProcessor, _config.DefaultFlagHandler, null); } } @@ -279,13 +319,17 @@ private async Task GetIdentityFlagsFromApi(string identity, List return Flags.FromApiFlag(_analyticsProcessor, _config.DefaultFlagHandler, flags); } - catch (FlagsmithAPIError e) + catch (FlagsmithAPIError) { if (Environment != null) { return this.GetIdentityFlagsFromLocalEvaluationContext(identity, traits); } - return _config.DefaultFlagHandler != null ? Flags.FromApiFlag(_analyticsProcessor, _config.DefaultFlagHandler, null) : throw e; + if (_config.DefaultFlagHandler == null) + { + throw; + } + return Flags.FromApiFlag(_analyticsProcessor, _config.DefaultFlagHandler, null); } } diff --git a/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs b/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs index 29ad442..7fe79d3 100644 --- a/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs +++ b/Flagsmith.FlagsmithClient/FlagsmithConfiguration.cs @@ -73,6 +73,13 @@ public Double? RequestTimeout /// public CacheConfig CacheConfig { get; set; } = new CacheConfig(false); + /// + /// If enabled, the SDK will cache the flags in the HybridCache instance provided by the host + /// application, for the duration specified in the HybridCacheConfig. Cannot be combined with + /// the CacheConfig. + /// + public HybridCacheConfig HybridCacheConfig { get; set; } = new HybridCacheConfig(); + /// /// Indicates whether the client is in offline mode. /// diff --git a/Flagsmith.FlagsmithClient/HybridCacheConfig.cs b/Flagsmith.FlagsmithClient/HybridCacheConfig.cs new file mode 100644 index 0000000..654702e --- /dev/null +++ b/Flagsmith.FlagsmithClient/HybridCacheConfig.cs @@ -0,0 +1,75 @@ +#nullable enable + +using System; +using Microsoft.Extensions.Caching.Hybrid; + +namespace Flagsmith +{ + /// + /// Caches flags in a owned by the host application, so that flags can + /// be shared across instances through the distributed (L2) cache the host has configured. + /// This is an alternative to ; only one of the two may be enabled. + /// + public class HybridCacheConfig + { + /// + /// Creates a disabled configuration. + /// + public HybridCacheConfig() + { + } + + /// + /// Creates a configuration backed by the given cache. + /// + /// + /// The cache to store flags in, typically resolved from the service provider after calling + /// AddHybridCache(). + /// + /// Whether caching is enabled. + public HybridCacheConfig(HybridCache cache, bool enabled = true) + { + Cache = cache ?? throw new ArgumentNullException(nameof(cache)); + Enabled = enabled; + } + + /// + /// Whether flags are cached in . + /// + public bool Enabled { get; set; } + + /// + /// The cache to store flags in. Required when is set. + /// + public HybridCache? Cache { get; set; } + + /// + /// How long a cached flag list is served before it is fetched again. Defaults to 5 minutes. + /// + public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// How long a flag list is kept in the in-process (L1) cache. Defaults to . + /// Set this lower than to let instances pick up each other's writes sooner. + /// + public TimeSpan? LocalCacheDuration { get; set; } + + /// + /// Prefix for every cache key written by the SDK. Keys are further scoped by environment key, + /// so several clients may share one . + /// + public string KeyPrefix { get; set; } = "flagsmith"; + + /// + /// When enabled, cached identity flags are discarded and fetched again as soon as a request + /// carries a trait Flagsmith has not been told about, or a new value for one it has. This + /// guarantees such trait changes reach Flagsmith instead of waiting out . + /// Traits that are merely absent from a request do not trigger a refresh: Flagsmith keeps the + /// traits it already holds, so omitting one cannot change the flags it returns. + /// Disabled by default, which keeps evaluation stateless and consistent with the other + /// server-side SDKs: cached flags are then only ever refreshed once + /// has elapsed. + /// + public bool RefreshOnTraitChanges { get; set; } + } +} diff --git a/README.md b/README.md index 02e1a6a..3a0f827 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,60 @@ This Project contains all the Test Cases to evaluate the Engine functionality. For full documentation visit [https://docs.flagsmith.com/clients/server-side](https://docs.flagsmith.com/clients/server-side). +## Caching + +Flags can be cached either in process, via `CacheConfig`, or in a +[`HybridCache`](https://learn.microsoft.com/aspnet/core/performance/caching/hybrid) owned by your +application, via `HybridCacheConfig`. The two are mutually exclusive. `HybridCacheConfig` lets flags +be shared across instances through whatever distributed (L2) cache you have configured, and only +requires the `Microsoft.Extensions.Caching.Hybrid` package on your side — the SDK itself depends on +the abstraction alone. + +```csharp +builder.Services.AddHybridCache(); + +var flagsmith = new FlagsmithClient(new FlagsmithConfiguration +{ + EnvironmentKey = "", + HybridCacheConfig = new HybridCacheConfig(serviceProvider.GetRequiredService()) + { + Duration = TimeSpan.FromMinutes(5), + }, +}); +``` + +### Keeping identity flags in step with traits + +Flagsmith evaluates an identity against the traits it holds for it, and it only learns of a trait +when the SDK sends it. A cached flag list would therefore hide trait changes until it expired. + +`RefreshOnTraitChanges` opts in to preventing that: a call to `GetIdentityFlags` that carries a trait +Flagsmith has not been told about, or a new value for one it has, discards the cached entry and +fetches again, so the change reaches Flagsmith immediately. Traits that are merely absent from a call +do not trigger a refresh — Flagsmith keeps the traits it already holds, so omitting one cannot change +the flags it returns, and neither can sending one that is unchanged. + +```csharp +HybridCacheConfig = new HybridCacheConfig(hybridCache) +{ + RefreshOnTraitChanges = true, +} +``` + +It is off by default, which keeps evaluation stateless and consistent with the other server-side +SDKs: cached flags are then only ever refreshed once `Duration` has elapsed, and a trait change waits +that duration out. + +### Serialization + +Cache entries are written as JSON. On .NET 9 and later this goes through a source-generated +`System.Text.Json` context, so no runtime reflection is involved and the path stays trimming and AOT +friendly; `netstandard2.0` falls back to `Newtonsoft.Json`. Both emit the same JSON, so instances on +different target frameworks can share one distributed cache. + +Nothing needs registering on your side either way: entries are stored as strings, so your +`HybridCache` does not need a serializer for SDK types. + ## Contributing Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code of conduct, and the process for submitting pull requests