Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions docs/ClientSideCaching.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ With `Prefixes` set, the prefixes decide what is cached: a read inside them is c

Requirements:

- **RESP3.** Invalidations arrive as out-of-band pushes, which only RESP3 can deliver on the same connection. With a cache configured and RESP2, connecting **fails** with a message saying so, rather than quietly running without invalidation. See [Using RESP3](Resp3).
- **RESP3.** Invalidations arrive as out-of-band pushes, which only RESP3 can deliver on the same connection. With a tracking mode **named** and RESP2, connecting **fails** with a message saying so, rather than quietly running without invalidation. See [Using RESP3](Resp3). With the mode left unset, the cache instead falls back to keyspace notifications - see "Falling back", below. The two modes that do not use `CLIENT TRACKING` - `KeyspaceNotifications` and `None` - do not need RESP3.
- **A server that supports `CLIENT TRACKING`** (Redis 6.0 or later, or a compatible server). If the server refuses it - including a managed service that disables the command - the connection fails, for the same reason: a cache that is never invalidated is worse than no cache.

The cache is configured in code only - it is not part of the connection string, because its settings are correctness choices that deserve to be read before they are set.
Expand Down Expand Up @@ -196,18 +196,32 @@ Under `MaxBytes`, replies are stored in large slabs in the order they arrive and
- **`TrackingByPrefix`** (the default when `Prefixes` are set): `Prefixes` decide for the calls that pass no flag. A read whose keys all fall under a prefix is treated as if it had said `CommandFlags.CacheOptIn`: it goes out behind `CLIENT CACHING YES`, the server tracks it, and it is stored on its first miss. Any other read goes to the server as it would without a cache. The flags still win: `CacheOptIn` on a read outside the prefixes caches it, `CacheOptOut` on a read inside them does not. Negotiated as `CLIENT TRACKING ON OPTIN`; the list is **not** sent to the server (the server only accepts `PREFIX` with `BCAST`), so the server tracks exactly the keys this client holds. The price is an extra frame and reply per miss inside the prefixes, which is the traffic the cache exists to shrink. Prefixes are `RedisKey` values, so a byte prefix is as good as a string one, and a key made with `WithKeyPrefix` can be given as it is. They must not overlap one another (`"app:"` and `"app:user:"` together are rejected), and they are compared with the key as sent - so if you build keys with `WithKeyPrefix`, the prefix must include that.
- **`TrackingOptIn`**: opt-in by flag alone. Negotiated as `CLIENT TRACKING ON OPTIN`: the server tracks nothing, and nothing is cached, unless a call says so with `CommandFlags.CacheOptIn` - that read goes out behind `CLIENT CACHING YES`, is tracked, and is stored on its first miss. Every other read goes to the server as it would without a cache. For "cache these few hot keys and leave everything else alone" when the keys are not a prefix; the price is an extra frame and an extra reply per opted-in miss. `Prefixes` cannot be combined with it - that is `TrackingByPrefix`.
- **`Broadcast`**: the server announces every change to every key matching your `Prefixes`, whoever made it, and keeps no record of what you read. Cheap for the server; the cost is on the client, which hears about keys it never cached, and that cost is set by how busy the server is rather than by anything this client does - which is why prefixes no longer select this mode by default. **Set `Prefixes`** if you choose it: with none, this connection hears about every write to every key in the instance. Keys outside the prefixes are simply not cached, and here the list is a hard boundary: the server was never asked about those keys, so no flag can opt one in. The per-call flags keep only their local meaning: the server has no per-client table to opt in to or out of.
- **`KeyspaceNotifications`**: no `CLIENT TRACKING` at all; the cache subscribes to [keyspace notifications](KeyspaceNotifications) instead - `__keyspace@*__:{prefix}*` once per entry in `Prefixes`, or `__keyspace@*__:*` without them - and invalidates the key named by any event. For servers or proxies that do not offer `CLIENT TRACKING` but do offer pub/sub, and it works over RESP2. **The server must have notifications enabled** (`notify-keyspace-events` including `K` and the event classes that can change what you cache; `KA` covers everything, expiry and eviction included). This is not checked: with notifications off the subscription is silent and the cache behaves as `None`. Like `Broadcast`, it hears about every matching change by anyone, so set `Prefixes` on a busy server; they are a hard boundary on what is cached, and must not contain glob characters (`*`, `?`, `[`). In a cluster each node raises notifications only for its own keys, so the subscriptions are made on every node. Weaker than tracking in two further ways: a `FLUSHDB`/`FLUSHALL` by another client raises no per-key event, and pub/sub does not replay what was missed while the subscription was down (losing a connection flushes the cache, as in every mode). `TimeToLive` bounds both.
- **`None`**: no server-assisted invalidation. Entries are retired by `TimeToLive` and by writes made through this multiplexer, and nothing else: a write by another client is served stale until the entry ages out, so choose the lifetime as the staleness you are prepared to accept. Nothing is negotiated, so it works over RESP2 and against any server. `Prefixes`, if set, only bound what is cached.

`CacheTrackingMode.Default` (the value you get by not setting it) means "the library's choice", resolved as above today; name a mode explicitly if you depend on it.

#### Falling back

Under `Default` only, when server-assisted tracking turns out to be unavailable, the cache switches to `KeyspaceNotifications` instead of failing the connection. That happens when:

- the connection is not RESP3;
- `CLIENT` is disabled in the `CommandMap` (known before connecting);
- the server answers `CLIENT TRACKING` with any error - an older server, a proxy, an ACL.

The switch is for the whole cache, happens at most once, and never reverts. It flushes the cache, starts the keyspace subscription, and logs a warning. If `CONFIG GET notify-keyspace-events` is permitted and shows that notifications are off, a second warning says so: the cache then behaves as `None`, retired by `TimeToLive` alone. That is a diagnostic, not a failure. `ClientCache.EffectiveTrackingMode` (from `GetClientCache()`) reports the mode actually in force. A mode named explicitly never falls back: it fails the connection as before.

Keyspace notifications match prefixes as patterns, so a prefix containing `*`, `?` or `[` cannot be subscribed to; after a fallback such a cache logs the failed subscription and is retired by `TimeToLive` alone.

What the two flags do, by mode:

| | `Tracking` | `TrackingByPrefix` | `TrackingOptIn` | `Broadcast` |
| | `Tracking` | `TrackingByPrefix` | `TrackingOptIn` | `Broadcast`, `KeyspaceNotifications`, `None` |
|---|---|---|---|---|
| no flag | cached; tracked | inside `Prefixes`: cached, stored on first miss, `CLIENT CACHING YES` sent; outside: not cached, not tracked | not cached; not tracked | cached if inside `Prefixes` |
| `CacheOptIn` | cached, stored on first miss | cached, stored on first miss; `CLIENT CACHING YES` sent - inside or outside `Prefixes` | cached, stored on first miss; `CLIENT CACHING YES` sent | cached if inside `Prefixes`, stored on first miss |
| `CacheOptOut` | not cached; `CLIENT CACHING NO` sent | not cached | not cached | not cached |

Passing both flags is rejected. Neither flag can make an uncacheable command cacheable (a write, a keyless read, a random or time-based read). In `Broadcast` mode `CacheOptIn` does not bypass `Prefixes`; in `TrackingByPrefix` mode it does, because there the list is only the policy for calls that passed no flag.
Passing both flags is rejected. Neither flag can make an uncacheable command cacheable (a write, a keyless read, a random or time-based read). In `Broadcast`, `KeyspaceNotifications` and `None` modes `CacheOptIn` does not bypass `Prefixes`; in `TrackingByPrefix` mode it does, because there the list is only the policy for calls that passed no flag.

### Invalidating by hand

Expand All @@ -225,7 +239,7 @@ The same object carries the cache's counters - `Count`, `Bytes`, `Stored`, `Evic

### Connection groups

With a [connection group](Failover) (geo-failover, Active-Active), the cache is configured once, on `MultiGroupOptions.ClientCache`, and every member inherits it; a member whose own `ConfigurationOptions.ClientCache` is set is refused when the group connects. Each member still needs `Protocol = RedisProtocol.Resp3`.
With a [connection group](Failover) (geo-failover, Active-Active), the cache is configured once, on `MultiGroupOptions.ClientCache`, and every member inherits it; a member whose own `ConfigurationOptions.ClientCache` is set is refused when the group connects. Each member still needs `Protocol = RedisProtocol.Resp3`, unless the mode is `KeyspaceNotifications` or `None`.

```csharp
MultiGroupOptions options = new MultiGroupOptions.Builder
Expand Down
2 changes: 1 addition & 1 deletion src/StackExchange.Redis/Caching/CacheAdmission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public enum CacheAdmission
/// Let the library choose; today that is <see cref="OnRepeatedMiss"/>.
/// </summary>
/// <remarks>
/// Zero means "no opinion", as it does for <see cref="CacheTrackingMode.Default"/>: an unset value is not a
/// Zero means "no opinion", as <see cref="CacheTrackingMode.Default"/> does for the tracking mode: an unset value is not a
/// considered choice, and keeping the two apart lets the library change what it resolves to without
/// overriding anybody who named a mode. Name one explicitly if you depend on its behaviour.
/// </remarks>
Expand Down
59 changes: 51 additions & 8 deletions src/StackExchange.Redis/Caching/CacheOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public sealed class CacheOptions
/// instead: the prefixes say what to cache, and the server still hears only about that. Read <see cref="ResolvedTrackingMode"/>
/// rather than this one when you need the mode that will actually be negotiated.
/// </remarks>
public CacheTrackingMode TrackingMode { get; init; }
public CacheTrackingMode TrackingMode { get; init; } = CacheTrackingMode.Default;

/// <summary>
/// <see cref="TrackingMode"/> with <see cref="CacheTrackingMode.Default"/> resolved to the mode it
Expand All @@ -67,6 +67,25 @@ public sealed class CacheOptions
var explicitMode => explicitMode,
};

/// <summary>
/// Whether a <c>notify-keyspace-events</c> value raises keyspace events that can invalidate anything.
/// </summary>
/// <remarks>
/// <c>K</c> selects the keyspace channel, and on its own raises nothing: it needs at least one event class
/// with it. <c>A</c> is the alias for the classes that change a key. Not counted: <c>n</c> (a new key, which
/// has nothing cached) and <c>m</c> (a key miss, which is a read).
/// </remarks>
internal static bool KeyspaceEventsInvalidate(string events)
=> events.IndexOf('K') >= 0 && events.IndexOfAny(['A', 'g', '$', 'l', 's', 'h', 'z', 'x', 'e', 't', 'd']) >= 0;

/// <summary>Whether the resolved mode is negotiated with <c>CLIENT TRACKING</c> at the handshake.</summary>
/// <remarks>
/// False for <see cref="CacheTrackingMode.None"/>, which negotiates nothing, and for
/// <see cref="CacheTrackingMode.KeyspaceNotifications"/>, which subscribes instead - and so for both, RESP3 is
/// not required.
/// </remarks>
internal bool UsesClientTracking => ResolvedTrackingMode is not (CacheTrackingMode.None or CacheTrackingMode.KeyspaceNotifications);

/// <summary>How entries behave, unless a caller says otherwise.</summary>
public CachePolicy DefaultPolicy { get; init; } = CachePolicy.Default;

Expand Down Expand Up @@ -269,7 +288,7 @@ public int EvictionSampleSize
/// total one.
/// </para>
/// <para>
/// <b>Two modes read it.</b> Under <see cref="CacheTrackingMode.Broadcast"/> it goes to the server as
/// <b>Four modes read it.</b> Under <see cref="CacheTrackingMode.Broadcast"/> it goes to the server as
/// <c>PREFIX</c> arguments and filters what is announced, as well as what is cached. Under
/// <see cref="CacheTrackingMode.TrackingByPrefix"/> it stays here and decides which unflagged reads
/// are opted in (sent behind <c>CLIENT CACHING YES</c>, tracked, stored); the per-call flags override
Expand All @@ -281,6 +300,12 @@ public int EvictionSampleSize
/// <see cref="CacheTrackingMode.Default"/> selects <see cref="CacheTrackingMode.TrackingByPrefix"/>.
/// Either mistake is raised when the cache is built rather than at the handshake.
/// </para>
/// <para>
/// Under <see cref="CacheTrackingMode.KeyspaceNotifications"/> it is the set of keyspace channels subscribed
/// to, one pattern per prefix, and the same hard boundary on what is cached as under
/// <see cref="CacheTrackingMode.Broadcast"/>. Under <see cref="CacheTrackingMode.None"/> it only bounds what
/// is cached; nothing is sent.
/// </para>
/// </remarks>
public IReadOnlyList<RedisKey> Prefixes
{
Expand Down Expand Up @@ -316,8 +341,8 @@ public IReadOnlyList<RedisKey> Prefixes

/// <summary>Whether <see cref="Prefixes"/> restricts what may be cached.</summary>
/// <remarks>
/// Only ever true in <see cref="CacheTrackingMode.Broadcast"/> and
/// <see cref="CacheTrackingMode.TrackingByPrefix"/>, the two modes that accept a list: under
/// Never true in <see cref="CacheTrackingMode.Tracking"/> or <see cref="CacheTrackingMode.TrackingOptIn"/>,
/// the two modes that refuse a list: under
/// <see cref="CacheTrackingMode.Tracking"/> the server announces exactly what we read, so there is no
/// such thing as an untracked key and nothing for the gate to refuse.
/// </remarks>
Expand All @@ -335,15 +360,33 @@ public IReadOnlyList<RedisKey> Prefixes
internal void Validate()
{
var mode = ResolvedTrackingMode;
if (HasPrefixes && mode is not (CacheTrackingMode.Broadcast or CacheTrackingMode.TrackingByPrefix))
if (HasPrefixes && mode is (CacheTrackingMode.Tracking or CacheTrackingMode.TrackingOptIn))
{
throw new ArgumentException(
$"Cache prefixes require {nameof(CacheTrackingMode)}.{nameof(CacheTrackingMode.Broadcast)}"
+ $" or {nameof(CacheTrackingMode)}.{nameof(CacheTrackingMode.TrackingByPrefix)};"
+ $" {TrackingMode} announces the keys that were read, so there is nothing to filter.",
$"Cache prefixes require {nameof(CacheTrackingMode)}.{nameof(CacheTrackingMode.Broadcast)},"
+ $" {nameof(CacheTrackingMode.TrackingByPrefix)}, {nameof(CacheTrackingMode.KeyspaceNotifications)}"
+ $" or {nameof(CacheTrackingMode.None)}; {TrackingMode} announces the keys that were read, so there"
+ " is nothing to filter.",
nameof(Prefixes));
}

if (mode == CacheTrackingMode.KeyspaceNotifications)
{
// each prefix becomes the pattern __keyspace@*__:{prefix}*, and a glob character in it would match
// something other than the prefix - a wider set would be harmless, but a narrower one ("[ab]" is one
// byte, not four) leaves cached keys nobody announces
foreach (var prefix in _prefixBytes)
{
if (prefix.AsSpan().IndexOfAny((byte)'*', (byte)'?', (byte)'[') >= 0)
{
throw new ArgumentException(
$"{nameof(CacheTrackingMode)}.{nameof(CacheTrackingMode.KeyspaceNotifications)} subscribes to"
+ " a pattern per prefix, so a prefix must not contain '*', '?' or '['.",
nameof(Prefixes));
}
}
}

if (mode == CacheTrackingMode.TrackingByPrefix && !HasPrefixes)
{
throw new ArgumentException(
Expand Down
Loading
Loading