v4 roadmap: client-side caching, cancellation, API overhaul, and finishing the IO core rework #3221
mgravell
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
This is a working proposal / discussion document; nothing here is a promise.
The v3 proof-of-concept proposed, originally described multiple staged features:
With: on the roadmap but not tied to any specific versions:
As plans evolved, reality happened, and we have shipped:
Execute,BITFIELD,CLUSTER SLOTS, etcThe plan: v4 mops up the rest!
So, in my R&D spike, it turns out that most of the rest collapses to the same feature.
When talking about client-side-caching of arbitrary commands, what you need is a reliable testable key. And do you know what is reliable? Using the actual request RESP (plus database) as the key! This also has a secondary win: if you want to handle stampede-prone scenarios like "background refresh while stale but not expired" or "treat invalidate as a short grace period where it will be treated as stale but not expired", if the key is the request (plus database): then we already know how to refresh it. To quote Whoopi Goldberg:
So what we need is a better way of expressing requests. Our 3.2 work used
ReadOnlyMemory<RedisKeyOrValue>which is ... very awkward. But with some voodoo, we have a new working API; consider:Looks like a string, right? So... allocation heavy, that we then need to parse, and hope that there's no quotes in
query, etc.That isn't a string. That is pure RESP, and is allocation free. That actually constructs, in a leased/amortized buffer, something like (using
|to mean CR/LF):using the magic of C# custom string interpolation handlers. This is now super easy to express, looking just like the documentation, and it is super efficient!
And: it all happens at the caller; this reduces work inside the critical section and makes the value usable for a cache key.
Stale-While-Revalidate
It is easy to use local time-based expiration, but Redis client-side-caching provides server assisted key invalidation notifications. Both of these problems have a "stampede" problem: when the data becomes expired/invalidated, suddenly 200 requests can see the "miss" and hammer the backend. To avoid this, the cache in SE.Redis is being designed with "stale-while-revalidate" support (optional) for both expiration and invalidation, to avoid this.
API rework
Now that we have a key, we can implement caching - we'll wave a wand over the "how" and just agree "caching happens", but now we need to be able to read the results, ideally efficiently. Client-side-caching in Redis supports most data types, but let's focus just on strings for now; historically, we might have:
There are a few problems here:
Lease<byte>, which currently exposes a mutable view over the data (Memory<byte>,Span<byte>,ArraySegment<byte>) - for safety, we are forced to create a secondary lease and copy the data each readStringLeaseAsyncmethod is aTask<Lease<byte>>, which demands allocation even for synchronous (available from cache) usageSeparately, I have wanted to refactor the API for a very long time, so as part of this work, I propose a new API (addition; this does not break existing usage, but will be the primary focus for new additions moving forward):
The
.Stringshere switches us into our new context-bound API; however, on this new API:Asyncsuffix stays anyway: there is nothing to disambiguate from, but analyzers,ConfigureAwaitguidance and human readers all key off the name, and aGetthat you mustawaitreads as a bug at a glanceValueTask<T>-based rather thanTask<T>, making use of both allocation-free synchronous results and IVTSReadOnlyLease<byte>, allowing us to efficiently reuse buffers with lifetime tracking, without risking data corruption via the mutable APIdb.Json: you can do thatdb.Strings) are extension properties, which need C# 14; everything hanging off them is an ordinary extension method and binds anywhere. For older compilers there is an opt-in namespace where the same groups appear as methods -db.Strings().GetAsync(key)- and that has been checked against real toolchains (Mono/net472 at C# 7.3, the .NET 6 and .NET 8 SDKs), not just reasoned about. Nothing else moves, and nobody on an old compiler pays anything for the property existingIDatabase[Async]*; the new APIs are all *extension* methods, which makes it possible to add a new overload and *demote the old* without breaking binary compatibility, by simply removing thethis`; new users choose the new overload next time they build, but existing built code keeps workingAdditionally, the new context-bound API will support true cancellation. I originally proposed hanging the token off the context (
db = db.WithCancellation(token)), on the grounds that cancellation tends to have wider scope than one command. On reflection that is wrong, and the spike has dropped it: a token's lifetime is the operation's, not the connection's, and a context is a cheap value that callers are expected to build once and reuse - so a stashed context would quietly carry a token belonging to some long-finished request. So the token is a per-call argument instead.Being blunt about where that currently stands, since "supports cancellation" is the sort of claim that is easy to half-mean: the token is plumbed through the send path, but the underlying pipeline cannot yet cancel a request that is already in flight. Rather than accept a token and ignore it, passing a live token today throws
NotImplementedException. An already-cancelled token is honoured properly, because that one genuinely can be. Making it real is part of the remaining IO-core work, and the API shape is already in place for when it is.This is a large set of changes, that fundamentally interact. Delivering any one by itself would give an incomplete idea of what the wider scenario demands, hence I propose that all of these combine to become: v4
Update (Sep 2026): the spike (#3219) has moved on a fair way since this was written; the PR description tracks the current state. Beyond the corrections inline above, the notable changes are that client-side caching now negotiates
CLIENT TRACKINGitself and receives real invalidations end to end, multi-value replies became windows into the reply buffer rather than arrays of self-owning values (MGETof a thousand 32-byte values: 56,656 bytes allocated before, 736 after), and errors stay as exceptions on the new surface - theredis.callvsredis.pcallquestion - with an errors-as-values opt-in kept possible but not built.One naming note, since it is the sort of thing that is much cheaper to change now than later: on the new context the key-prefix method is
AppendKeyPrefix, notWithKeyPrefix.Withreads as "the result differs in this respect", which invites the guess that a second call replaces the first — and it does not, it accumulates, exactly as nestingWithKeyPrefixonIDatabasealways has.Appendrather thanPrependbecause the newest prefix ends up nearest the key:AppendKeyPrefix("a").AppendKeyPrefix("b")sends keykasabk. The existingdb.WithKeyPrefix(...)onIDatabaseis unaffected and keeps its name; it behaves the same way, and I am not going to break a widely-used API over a name.Still open, and the honest blocker: transactions.
WATCH/MULTIneeds the write loop to stop mid-transaction to choose betweenUNWATCH,EXECandDISCARD, and today that costs the calling thread - with a 250ms round trip injected,ExecuteAsyncblocks for 508ms with one condition against 2ms with none, i.e. two round trips done synchronously before you are handed a task to await. That is thread-pool starvation shaped. The fix is not a redesign of that pause; it is theMessage/write-loop rework that removes the mechanism it waits on, so it waits for that.All reactions