Skip to content

feat: add HybridCache support with trait-driven refresh - #199

Open
vpetrusevici wants to merge 4 commits into
Flagsmith:mainfrom
vpetrusevici:feat/hybrid-cache
Open

vpetrusevici wants to merge 4 commits into
Flagsmith:mainfrom
vpetrusevici:feat/hybrid-cache

Conversation

@vpetrusevici

@vpetrusevici vpetrusevici commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

What

Adds an alternative flag cache backed by a HybridCache owned by the host application, configured
through 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 the HybridCache
abstraction 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
GetIdentityFlags carrying 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 that
evaluation stays stateless and consistent with the other server-side SDKs. With it off, a trait change
simply waits out Duration like any other entry.

Serialization

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, so no runtime reflection is involved and the
path stays trimming and AOT friendly. netstandard2.0 falls back to Newtonsoft.Json, which the
rest of the SDK still uses on every target. .NET 10 resolves the net9.0 assets.

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 HybridCache needs no serializer registered for SDK
types.

Notes for review

  • LangVersion moves from 8 to 9, required for the init-only setters on HybridCacheEntryOptions.
  • Cache entries use a dedicated DTO rather than Flag, whose [JsonProperty] members are private
    and therefore invisible to System.Text.Json. Flag gained an internal feature id accessor so the
    id survives the round trip.
  • The HybridCache implementation lives in Cache/Hybrid. It is nested under Cache rather than at
    the 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 switched on the .NET SDK analyzers, which flagged two pre-existing
    throw e rethrows that discard stack information. They are rewritten as plain rethrows, without
    which dotnet format --verify-no-changes — and so CI — fails. Happy to split this out if you would
    rather keep the PR to the feature.

Testing

  • 16 new tests covering trait addition, change, removal and transition to transient; merged
    fingerprints surviving a refresh; the disabled toggle; TTL expiry; separation by identity,
    transience and environment key; both configuration errors; and the pinned wire format.
  • Full client suite green on net10.0: 64 passed, 1 skipped (the pre-existing flaky test already
    marked Skip).
  • dotnet format --verify-no-changes clean in every project CI checks.
  • dotnet pack produces lib/net9.0 and lib/netstandard2.0, both still embedding
    Flagsmith.Engine.dll.

Vladimir Petrusevici added 2 commits September 18, 2026 15:36
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
@vpetrusevici
vpetrusevici requested a review from a team as a code owner September 18, 2026 13:04
@vpetrusevici
vpetrusevici requested review from emyller and removed request for a team September 18, 2026 13:04
@matthewelwell
matthewelwell requested review from matthewelwell and removed request for emyller September 18, 2026 13:06
@matthewelwell

Copy link
Copy Markdown
Contributor

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?

@vpetrusevici

Copy link
Copy Markdown
Contributor Author

Thanks — fair, the description covers what but not why. Here's the context.

We self-host Flagsmith on Kubernetes and call GetIdentityFlags(identifier, traits) on essentially every request. We depend on trait storage, so each call is a POST /api/v1/identities/ — a write that upserts traits before returning flags. Those writes are the largest CPU consumer on our instance.

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 CacheConfig doesn't get us there:

  1. It's per-process. Each pod keeps its own dictionary, so the hit rate is effectively divided by the replica count, and every rollout starts cold. We need entries shared across the fleet.
  2. It grows without bound. Nothing is ever evicted from _flagListCacheDictionary — staleness only controls whether the flags are refetched, not whether the entry goes away. That's one entry per identity for the lifetime of the process.
  3. The trait-keyed cache key compounds that. Since IdentityWrapper.CacheKey hashes the serialised traits, a trait change doesn't refresh the entry — it allocates a new one and leaks the old. For identities whose traits change, the cache mostly misses and keeps growing.

This PR backs the cache with a HybridCache the host supplies, which addresses all three: entries are shared through whatever L2 the host has configured, eviction becomes HybridCache's job, and the key is the identity rather than identity+traits. The SDK takes no Redis dependency and makes no assumptions about the host's concurrency model.

Happy to open an issue for this first if you'd prefer, and to split the PR — the net9.0 multi-targeting and source-generated serialization are separable from the caching itself.

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
@vpetrusevici

vpetrusevici commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

@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
@vpetrusevici

Copy link
Copy Markdown
Contributor Author

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 Microsoft.Extensions.Caching.Hybrid with a distributed second level — the same idea as this PR, one layer up, not this PR's code. And the SDK's built-in cache was never enabled in the environments measured, so this is caching versus no caching at any layer, not a comparison against CacheConfig. I'm making no claim about how CacheConfig performs.

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 POST /api/v1/identities/ — a write that persists the identity and recalculates segments, and the dominant cost we impose on Flagsmith.

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 HybridCache; with an L1-only configuration there is nothing to share and the comparison means nothing.

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 CacheConfig in 9.0.0:

  • it's per-process, so the saving above is exactly the part that can't be reproduced without shared state;
  • Duration is a freshness boundary, not an eviction policy — entries stay in the ConcurrentDictionary for the life of the process, and nothing bounds how many there are;
  • the key is identity + serialized traits, so changing a trait adds an entry rather than replacing one;
  • FlagListCache.GetLatestFlags blocks on .Result, so every miss is sync-over-async on a pool thread.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants