From 6fbc8cff0ae38735628d5ff8388187ee3340d18f Mon Sep 17 00:00:00 2001 From: Vladimir Petrusevici Date: Fri, 18 Sep 2026 15:36:26 +0300 Subject: [PATCH 1/4] feat: add HybridCache support with trait-driven refresh Flags can now be cached in a HybridCache owned by the host application, configured through the new HybridCacheConfig. This is an alternative to the existing in-process CacheConfig; enabling both throws. Only the HybridCache abstraction is referenced, so hosts pick their own implementation and L2 store. Because Flagsmith only learns of an identity's trait once the SDK sends it, a cached flag list would otherwise hide trait changes until it expired. Cache entries therefore carry a fingerprint of the traits Flagsmith was known to hold, and a call carrying an added or changed trait discards the entry and fetches again. Traits merely omitted from a call do not: Flagsmith keeps the traits it already has, so leaving one out cannot change the flags it returns. This is toggled by HybridCacheConfig.RefreshOnTraitChanges, on by default; with it off, entries are only refreshed once their duration has elapsed. LangVersion moves from 8 to 9, needed for the init-only setters on HybridCacheEntryOptions. AIGenerated --- Flagsmith.Client.Test/ClientTest.csproj | 2 + Flagsmith.Client.Test/HybridCacheTest.cs | 290 ++++++++++++++++++ .../Cache/CachedFlagList.cs | 24 ++ .../Cache/HybridFlagListCache.cs | 120 ++++++++ .../Cache/TraitFingerprint.cs | 80 +++++ .../Flagsmith.FlagsmithClient.csproj | 5 +- Flagsmith.FlagsmithClient/FlagsmithClient.cs | 25 ++ .../FlagsmithConfiguration.cs | 7 + .../HybridCacheConfig.cs | 73 +++++ README.md | 36 +++ 10 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 Flagsmith.Client.Test/HybridCacheTest.cs create mode 100644 Flagsmith.FlagsmithClient/Cache/CachedFlagList.cs create mode 100644 Flagsmith.FlagsmithClient/Cache/HybridFlagListCache.cs create mode 100644 Flagsmith.FlagsmithClient/Cache/TraitFingerprint.cs create mode 100644 Flagsmith.FlagsmithClient/HybridCacheConfig.cs 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..ad101a5 --- /dev/null +++ b/Flagsmith.Client.Test/HybridCacheTest.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +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 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; + } + + [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 = CreateClient(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 = CreateClient(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 = CreateClient(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 = CreateClient(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 = CreateClient(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)); + } + + [Fact] + public async Task TestIdentityFlagsStayCachedOnTraitChangesWhenRefreshOnTraitChangesIsDisabled() + { + // Given + var mockHttpClient = MockIdentityResponse(); + var client = CreateClient(mockHttpClient, config => config.RefreshOnTraitChanges = false); + + // 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/CachedFlagList.cs b/Flagsmith.FlagsmithClient/Cache/CachedFlagList.cs new file mode 100644 index 0000000..72865dc --- /dev/null +++ b/Flagsmith.FlagsmithClient/Cache/CachedFlagList.cs @@ -0,0 +1,24 @@ +#nullable enable + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Flagsmith.Cache +{ + /// + /// 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. Written as JSON so that any HybridCache backing + /// store can hold it without the host having to register a serializer for SDK types. + /// + internal class CachedFlagList + { + [JsonProperty("flags")] + public List Flags { get; set; } = new List(); + + /// + /// Trait key to a fingerprint of the trait as it was last sent to Flagsmith. + /// + [JsonProperty("traits")] + public Dictionary Traits { get; set; } = new Dictionary(); + } +} diff --git a/Flagsmith.FlagsmithClient/Cache/HybridFlagListCache.cs b/Flagsmith.FlagsmithClient/Cache/HybridFlagListCache.cs new file mode 100644 index 0000000..efcca57 --- /dev/null +++ b/Flagsmith.FlagsmithClient/Cache/HybridFlagListCache.cs @@ -0,0 +1,120 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid; +using Newtonsoft.Json; + +namespace Flagsmith.Cache +{ + /// + /// 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 JsonConvert.SerializeObject(new CachedFlagList + { + Flags = flags.AllFlags()?.Select(AsFlag).ToList() ?? new List(), + Traits = traits + }); + } + + private static CachedFlagList Deserialize(string json) + { + return JsonConvert.DeserializeObject(json) ?? new CachedFlagList(); + } + + private static Flag AsFlag(IFlag flag) + { + return flag as Flag ?? new Flag(new Feature(flag.GetFeatureName()), flag.Enabled, flag.Value); + } + + private IFlags ToFlags(CachedFlagList entry) + { + return _flagsFactory(entry.Flags.ToList()); + } + } +} diff --git a/Flagsmith.FlagsmithClient/Cache/TraitFingerprint.cs b/Flagsmith.FlagsmithClient/Cache/TraitFingerprint.cs new file mode 100644 index 0000000..cf0254b --- /dev/null +++ b/Flagsmith.FlagsmithClient/Cache/TraitFingerprint.cs @@ -0,0 +1,80 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Flagsmith.Cache +{ + /// + /// 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/Flagsmith.FlagsmithClient.csproj b/Flagsmith.FlagsmithClient/Flagsmith.FlagsmithClient.csproj index 1ec00cf..33a2061 100644 --- a/Flagsmith.FlagsmithClient/Flagsmith.FlagsmithClient.csproj +++ b/Flagsmith.FlagsmithClient/Flagsmith.FlagsmithClient.csproj @@ -2,7 +2,8 @@ netstandard2.0 - 8 + + 9 $(DefaultItemExcludes);example/**; true Flagsmith @@ -22,6 +23,8 @@ + + diff --git a/Flagsmith.FlagsmithClient/FlagsmithClient.cs b/Flagsmith.FlagsmithClient/FlagsmithClient.cs index 9ad6bc5..13ee1b0 100644 --- a/Flagsmith.FlagsmithClient/FlagsmithClient.cs +++ b/Flagsmith.FlagsmithClient/FlagsmithClient.cs @@ -45,6 +45,7 @@ public class FlagsmithClient : IFlagsmithClient private AnalyticsProcessor? _analyticsProcessor; private RegularFlagListCache? _regularFlagListCache; private ConcurrentDictionary? _flagListCacheDictionary; + private HybridFlagListCache? _hybridFlagListCache; private void Initialise() { @@ -56,6 +57,14 @@ 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."); + } if (_config.OfflineHandler != null) { @@ -92,6 +101,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 +125,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 +160,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); 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..cc5aa82 --- /dev/null +++ b/Flagsmith.FlagsmithClient/HybridCacheConfig.cs @@ -0,0 +1,73 @@ +#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 (the default), 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. + /// When disabled, cached flags are only ever refreshed once has elapsed. + /// + public bool RefreshOnTraitChanges { get; set; } = true; + } +} diff --git a/README.md b/README.md index 02e1a6a..0a562e8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,42 @@ 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`, enabled by default, prevents 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. + +Set `RefreshOnTraitChanges = false` to opt out, in which case cached flags are only ever refreshed +once `Duration` has elapsed. + ## 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 From 79bed9f00c2608f022443dce94049d167b9ce4bb Mon Sep 17 00:00:00 2001 From: Vladimir Petrusevici Date: Fri, 18 Sep 2026 15:45:28 +0300 Subject: [PATCH 2/4] refactor: serialize HybridCache entries with System.Text.Json on net9.0 The client project now multi-targets netstandard2.0 and net9.0. On net9.0 cache entries go through a source-generated System.Text.Json context instead of Newtonsoft.Json, so the path needs no runtime reflection and stays trimming and AOT friendly. .NET 10 resolves the net9.0 assets. The rest of the SDK still uses Newtonsoft.Json on every target, so it remains a package dependency. Entries are now a dedicated DTO rather than the Flag type, whose private [JsonProperty] members System.Text.Json cannot see. Property names are spelled out for both serializers and pinned by a test, because the two targets can share one L2 cache and a silent name mismatch would deserialize into an empty flag list. Flag gained an internal feature id accessor so the id survives the trip. The HybridCache implementation moves to its own Cache/Hybrid folder. The folder is nested under Cache rather than top level so the namespace can be Flagsmith.Cache.Hybrid: a Flagsmith.HybridCache namespace would collide with the HybridCache type. HybridCacheConfig stays at the root next to CacheConfig, in the Flagsmith namespace, so callers need no extra using. Adding the net9.0 target also switched on the .NET SDK analyzers, which flagged two pre-existing `throw e` rethrows that discard stack information. They are rewritten as plain rethrows to keep dotnet format, and so CI, green. AIGenerated --- Flagsmith.Client.Test/HybridCacheTest.cs | 55 ++++++++++++++++ .../Cache/CachedFlagList.cs | 24 ------- .../Cache/Hybrid/CacheJsonContext.cs | 15 +++++ .../Cache/Hybrid/CachedFlagList.cs | 64 +++++++++++++++++++ .../Cache/Hybrid/CachedFlagListSerializer.cs | 37 +++++++++++ .../Cache/{ => Hybrid}/HybridFlagListCache.cs | 26 +++++--- .../Cache/{ => Hybrid}/TraitFingerprint.cs | 2 +- Flagsmith.FlagsmithClient/Flag.cs | 9 +++ .../Flagsmith.FlagsmithClient.csproj | 8 ++- Flagsmith.FlagsmithClient/FlagsmithClient.cs | 17 +++-- README.md | 10 +++ 11 files changed, 229 insertions(+), 38 deletions(-) delete mode 100644 Flagsmith.FlagsmithClient/Cache/CachedFlagList.cs create mode 100644 Flagsmith.FlagsmithClient/Cache/Hybrid/CacheJsonContext.cs create mode 100644 Flagsmith.FlagsmithClient/Cache/Hybrid/CachedFlagList.cs create mode 100644 Flagsmith.FlagsmithClient/Cache/Hybrid/CachedFlagListSerializer.cs rename Flagsmith.FlagsmithClient/Cache/{ => Hybrid}/HybridFlagListCache.cs (82%) rename Flagsmith.FlagsmithClient/Cache/{ => Hybrid}/TraitFingerprint.cs (98%) diff --git a/Flagsmith.Client.Test/HybridCacheTest.cs b/Flagsmith.Client.Test/HybridCacheTest.cs index ad101a5..24aad5b 100644 --- a/Flagsmith.Client.Test/HybridCacheTest.cs +++ b/Flagsmith.Client.Test/HybridCacheTest.cs @@ -3,6 +3,7 @@ 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; @@ -52,6 +53,60 @@ private static List Traits(params (string Key, string Value)[] traits) 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() { diff --git a/Flagsmith.FlagsmithClient/Cache/CachedFlagList.cs b/Flagsmith.FlagsmithClient/Cache/CachedFlagList.cs deleted file mode 100644 index 72865dc..0000000 --- a/Flagsmith.FlagsmithClient/Cache/CachedFlagList.cs +++ /dev/null @@ -1,24 +0,0 @@ -#nullable enable - -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace Flagsmith.Cache -{ - /// - /// 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. Written as JSON so that any HybridCache backing - /// store can hold it without the host having to register a serializer for SDK types. - /// - internal class CachedFlagList - { - [JsonProperty("flags")] - public List Flags { get; set; } = new List(); - - /// - /// Trait key to a fingerprint of the trait as it was last sent to Flagsmith. - /// - [JsonProperty("traits")] - public Dictionary Traits { get; set; } = new Dictionary(); - } -} 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/HybridFlagListCache.cs b/Flagsmith.FlagsmithClient/Cache/Hybrid/HybridFlagListCache.cs similarity index 82% rename from Flagsmith.FlagsmithClient/Cache/HybridFlagListCache.cs rename to Flagsmith.FlagsmithClient/Cache/Hybrid/HybridFlagListCache.cs index efcca57..885cf53 100644 --- a/Flagsmith.FlagsmithClient/Cache/HybridFlagListCache.cs +++ b/Flagsmith.FlagsmithClient/Cache/Hybrid/HybridFlagListCache.cs @@ -5,9 +5,8 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Hybrid; -using Newtonsoft.Json; -namespace Flagsmith.Cache +namespace Flagsmith.Cache.Hybrid { /// /// Stores flag lists in a supplied by the host application. @@ -95,26 +94,37 @@ private string IdentityKey(IdentityWrapper identityWrapper) private static string Serialize(IFlags flags, Dictionary traits) { - return JsonConvert.SerializeObject(new CachedFlagList + return CachedFlagListSerializer.Serialize(new CachedFlagList { - Flags = flags.AllFlags()?.Select(AsFlag).ToList() ?? new List(), + Flags = flags.AllFlags()?.Select(ToCachedFlag).ToList() ?? new List(), Traits = traits }); } private static CachedFlagList Deserialize(string json) { - return JsonConvert.DeserializeObject(json) ?? new CachedFlagList(); + return CachedFlagListSerializer.Deserialize(json); } - private static Flag AsFlag(IFlag flag) + private static CachedFlag ToCachedFlag(IFlag flag) { - return flag as Flag ?? new Flag(new Feature(flag.GetFeatureName()), flag.Enabled, flag.Value); + 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.ToList()); + return _flagsFactory(entry.Flags.Select(FromCachedFlag).ToList()); } } } diff --git a/Flagsmith.FlagsmithClient/Cache/TraitFingerprint.cs b/Flagsmith.FlagsmithClient/Cache/Hybrid/TraitFingerprint.cs similarity index 98% rename from Flagsmith.FlagsmithClient/Cache/TraitFingerprint.cs rename to Flagsmith.FlagsmithClient/Cache/Hybrid/TraitFingerprint.cs index cf0254b..6d664af 100644 --- a/Flagsmith.FlagsmithClient/Cache/TraitFingerprint.cs +++ b/Flagsmith.FlagsmithClient/Cache/Hybrid/TraitFingerprint.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; -namespace Flagsmith.Cache +namespace Flagsmith.Cache.Hybrid { /// /// Compares the traits of an incoming request against the traits Flagsmith is known to hold, so 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 33a2061..48bc8c8 100644 --- a/Flagsmith.FlagsmithClient/Flagsmith.FlagsmithClient.csproj +++ b/Flagsmith.FlagsmithClient/Flagsmith.FlagsmithClient.csproj @@ -1,7 +1,9 @@ - netstandard2.0 + + netstandard2.0;net9.0 9 $(DefaultItemExcludes);example/**; @@ -22,6 +24,10 @@ BSD-3-Clause + + + + diff --git a/Flagsmith.FlagsmithClient/FlagsmithClient.cs b/Flagsmith.FlagsmithClient/FlagsmithClient.cs index 13ee1b0..4ac3219 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; @@ -282,13 +283,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); } } @@ -304,13 +309,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/README.md b/README.md index 0a562e8..60151f2 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,16 @@ change the flags it returns, and neither can sending one that is unchanged. Set `RefreshOnTraitChanges = false` to opt out, in which case cached flags are only ever refreshed once `Duration` has elapsed. +### 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 From e9554c4de6f3a7285b8ae5912fddd0effa7732f7 Mon Sep 17 00:00:00 2001 From: Vladimir Petrusevici Date: Fri, 18 Sep 2026 16:16:22 +0300 Subject: [PATCH 3/4] feat: default RefreshOnTraitChanges to off Keeps evaluation stateless out of the box, consistent with the other server-side SDKs, so trait-aware invalidation is strictly opt-in. Callers that need a trait change to reach Flagsmith before the cache duration elapses set the flag explicitly. AIGenerated --- Flagsmith.Client.Test/HybridCacheTest.cs | 30 ++++++++++++++----- .../HybridCacheConfig.cs | 12 ++++---- README.md | 24 ++++++++++----- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/Flagsmith.Client.Test/HybridCacheTest.cs b/Flagsmith.Client.Test/HybridCacheTest.cs index 24aad5b..040b70d 100644 --- a/Flagsmith.Client.Test/HybridCacheTest.cs +++ b/Flagsmith.Client.Test/HybridCacheTest.cs @@ -42,6 +42,11 @@ private static FlagsmithClient CreateClient( }); } + 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(); @@ -180,7 +185,7 @@ public async Task TestIdentityFlagsAreFetchedAgainWhenATraitIsAdded() { // Given var mockHttpClient = MockIdentityResponse(); - var client = CreateClient(mockHttpClient); + var client = CreateClientRefreshingOnTraitChanges(mockHttpClient); // When await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); @@ -195,7 +200,7 @@ public async Task TestIdentityFlagsAreFetchedAgainWhenATraitValueChanges() { // Given var mockHttpClient = MockIdentityResponse(); - var client = CreateClient(mockHttpClient); + var client = CreateClientRefreshingOnTraitChanges(mockHttpClient); // When await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); @@ -210,7 +215,7 @@ public async Task TestIdentityFlagsAreFetchedAgainWhenATraitBecomesTransient() { // Given var mockHttpClient = MockIdentityResponse(); - var client = CreateClient(mockHttpClient); + var client = CreateClientRefreshingOnTraitChanges(mockHttpClient); // When await client.GetIdentityFlags(Identifier, new List { new Trait("foo", "bar") }); @@ -226,7 +231,7 @@ 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 = CreateClient(mockHttpClient); + var client = CreateClientRefreshingOnTraitChanges(mockHttpClient); // When await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"), ("baz", "qux"))); @@ -242,7 +247,7 @@ public async Task TestTraitsKnownBeforeARefreshAreStillKnownAfterIt() { // Given var mockHttpClient = MockIdentityResponse(); - var client = CreateClient(mockHttpClient); + 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. @@ -255,11 +260,22 @@ public async Task TestTraitsKnownBeforeARefreshAreStillKnownAfterIt() } [Fact] - public async Task TestIdentityFlagsStayCachedOnTraitChangesWhenRefreshOnTraitChangesIsDisabled() + 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, config => config.RefreshOnTraitChanges = false); + var client = CreateClient(mockHttpClient); // When await client.GetIdentityFlags(Identifier, Traits(("foo", "bar"))); diff --git a/Flagsmith.FlagsmithClient/HybridCacheConfig.cs b/Flagsmith.FlagsmithClient/HybridCacheConfig.cs index cc5aa82..654702e 100644 --- a/Flagsmith.FlagsmithClient/HybridCacheConfig.cs +++ b/Flagsmith.FlagsmithClient/HybridCacheConfig.cs @@ -61,13 +61,15 @@ public HybridCacheConfig(HybridCache cache, bool enabled = true) public string KeyPrefix { get; set; } = "flagsmith"; /// - /// When enabled (the default), 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 . + /// 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. - /// When disabled, cached flags are only ever refreshed once has elapsed. + /// 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; } = true; + public bool RefreshOnTraitChanges { get; set; } } } diff --git a/README.md b/README.md index 60151f2..3a0f827 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,22 @@ var flagsmith = new FlagsmithClient(new FlagsmithConfiguration 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`, enabled by default, prevents 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. - -Set `RefreshOnTraitChanges = false` to opt out, in which case cached flags are only ever refreshed -once `Duration` has elapsed. +`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 From 9634d90c9301ac7ffee6e69dc6cc3d2eb05e2a00 Mon Sep 17 00:00:00 2001 From: Vladimir Petrusevici Date: Fri, 18 Sep 2026 19:28:13 +0300 Subject: [PATCH 4/4] fix: reject a non-positive HybridCache duration at construction HybridCache rejects a non-positive relative expiration when it writes an entry, and that ArgumentOutOfRangeException is not caught anywhere: it surfaced from every flag read instead of from configuration, turning a typo into a per-request failure. Duration and LocalCacheDuration are now validated alongside the other configuration errors. An unset LocalCacheDuration still falls back to Duration. AIGenerated --- Flagsmith.Client.Test/HybridCacheTest.cs | 53 ++++++++++++++++++++ Flagsmith.FlagsmithClient/FlagsmithClient.cs | 10 ++++ 2 files changed, 63 insertions(+) diff --git a/Flagsmith.Client.Test/HybridCacheTest.cs b/Flagsmith.Client.Test/HybridCacheTest.cs index 040b70d..8d491b0 100644 --- a/Flagsmith.Client.Test/HybridCacheTest.cs +++ b/Flagsmith.Client.Test/HybridCacheTest.cs @@ -259,6 +259,59 @@ public async Task TestTraitsKnownBeforeARefreshAreStillKnownAfterIt() 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() { diff --git a/Flagsmith.FlagsmithClient/FlagsmithClient.cs b/Flagsmith.FlagsmithClient/FlagsmithClient.cs index 4ac3219..9f53523 100644 --- a/Flagsmith.FlagsmithClient/FlagsmithClient.cs +++ b/Flagsmith.FlagsmithClient/FlagsmithClient.cs @@ -66,6 +66,16 @@ private void Initialise() { 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) {