feat: add HybridCache support with trait-driven refresh - #199
vpetrusevici wants to merge 4 commits into
Conversation
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
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
|
Hi @vpetrusevici , thanks for the contribution here. As far as I can tell, the PR description here doesn't really reference any use cases, or give any indication as to why we should add this. It also doesn't link to any open issues in the repository. Can you please provide some more context here? |
|
Thanks — fair, the description covers what but not why. Here's the context. We self-host Flagsmith on Kubernetes and call Local evaluation would remove that load, but it stops sending traits to Flagsmith entirely, and we're not willing to give up trait persistence. So caching is the only lever we have. The current
This PR backs the cache with a Happy to open an issue for this first if you'd prefer, and to split the PR — the |
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
|
@matthewelwell i tried to use claude to make a PR faster. Checked on ai slop, looks good from my side, but you knows details better anyway :) |
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
|
Some measurements from running this pattern in production, with the caveats that bound them. What this is and isn't. We measured a decorator over the SDK backed by Why we need it. We evaluate a flag on essentially every request to a high-traffic route, and we depend on trait storage, so local evaluation isn't open to us. Under remote evaluation every evaluation is a Result. Measured in a production deployment of a mobile banking application, with the same feature-flag payload on both sides and identical request volume on the route: calls to the flag provider fell from 0.98 to 0.32 per application request, removing about two thirds of the network calls, at the library's default one-minute entry lifetime. Nobody chose one minute — the configured value never bound because the key sat at the wrong configuration path — so that is two thirds removed at the shortest lifetime we could have had. A pod started cold reached 0.348 within two minutes against 0.327 for one that had been running thirteen, which shows the shared distributed level being read rather than each instance warming its own copy. That holds only where a distributed cache is registered behind I'm not quoting absolute request rates or server-side CPU figures: other changes landed in the same window, so they don't isolate the cache. Why in the SDK rather than in our decorator — from reading
Those are reasons from the source rather than from measurement. The practical point is that anyone wanting a shared cache today has to build the same decorator we did, above the SDK — we'd rather that lived in one place. |
What
Adds an alternative flag cache backed by a
HybridCacheowned by the host application, configuredthrough a new
HybridCacheConfig. Flags can then be shared across instances via whatever distributed(L2) cache the host has set up. It is mutually exclusive with the existing in-process
CacheConfig;enabling both throws at construction.
The SDK references only
Microsoft.Extensions.Caching.Abstractions, which is where theHybridCacheabstraction lives, so hosts pick their own implementation and backing store.
The README gains a Caching section covering both options.
Keeping identity flags in step with traits
Flagsmith evaluates an identity against the traits it holds, and it only learns of a trait once the
SDK sends it. A cached flag list would therefore hide trait changes until it expired.
Cache entries carry a fingerprint of the traits Flagsmith was known to hold. A call to
GetIdentityFlagscarrying a trait Flagsmith has not been told about, or a new value for one it has,discards the entry and fetches again, so the change reaches Flagsmith immediately. Traits merely
omitted from a call do not trigger a refresh: Flagsmith keeps the traits it already holds, so leaving
one out cannot change the flags it returns.
Fingerprints are merged rather than replaced on refresh, so a later call carrying only a subset of
the traits is still recognised as unchanged.
This is toggled by
HybridCacheConfig.RefreshOnTraitChanges, which is off by default so thatevaluation stays stateless and consistent with the other server-side SDKs. With it off, a trait change
simply waits out
Durationlike any other entry.Serialization
The client project now multi-targets
netstandard2.0andnet9.0. Onnet9.0cache entries gothrough a source-generated
System.Text.Jsoncontext, so no runtime reflection is involved and thepath stays trimming and AOT friendly.
netstandard2.0falls back toNewtonsoft.Json, which therest of the SDK still uses on every target. .NET 10 resolves the
net9.0assets.Both serializers emit identical JSON, so instances on different target frameworks can share one L2
cache. Property names are spelled out for both rather than left to a naming policy, and pinned by a
test that runs on every framework in the CI matrix — a silent name mismatch would not fail, it would
deserialize into an empty flag list and serve the wrong flags.
Entries are stored as strings, so the host's
HybridCacheneeds no serializer registered for SDKtypes.
Notes for review
LangVersionmoves from 8 to 9, required for theinit-only setters onHybridCacheEntryOptions.Flag, whose[JsonProperty]members are privateand therefore invisible to System.Text.Json.
Flaggained an internal feature id accessor so theid survives the round trip.
Cache/Hybrid. It is nested underCacherather than atthe top level so the namespace can be
Flagsmith.Cache.Hybrid; aFlagsmith.HybridCachenamespacewould collide with the
HybridCachetype.HybridCacheConfigstays at the root next toCacheConfig, in theFlagsmithnamespace, so callers need no extrausing.net9.0target switched on the .NET SDK analyzers, which flagged two pre-existingthrow erethrows that discard stack information. They are rewritten as plain rethrows, withoutwhich
dotnet format --verify-no-changes— and so CI — fails. Happy to split this out if you wouldrather keep the PR to the feature.
Testing
fingerprints surviving a refresh; the disabled toggle; TTL expiry; separation by identity,
transience and environment key; both configuration errors; and the pinned wire format.
marked
Skip).dotnet format --verify-no-changesclean in every project CI checks.dotnet packproduceslib/net9.0andlib/netstandard2.0, both still embeddingFlagsmith.Engine.dll.