Skip to content

v4: CountMinSketch on the SE.Redis v4 command-group surface (spike) - #577

Draft
mgravell wants to merge 9 commits into
masterfrom
marc/v4
Draft

mgravell wants to merge 9 commits into
masterfrom
marc/v4

Conversation

@mgravell

@mgravell mgravell commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, for review of the shape rather than for merge: this is the first module (the smallest, Count-Min Sketch) migrated to the StackExchange.Redis v4 command-group surface. Built against 4.0.86-alpha.

What changes for callers

Nothing existing breaks: db.CMS(), ICmsCommands, ICmsCommandsAsync, Pipeline.Cms and Transaction.Cms keep their signatures and behaviour. The new surface sits beside them. This section is what a caller sees when they move.

Reaching the commands

// before: a method on IDatabase, returning a class
var cms = db.CMS();

// after: a property on anything with a keyspace context, returning a struct
var cms = db.CountMinSketch;           // IDatabase
var cms = batch.CountMinSketch;        // IBatch
var cms = tran.CountMinSketch;         // ITransaction
var cms = db.Context.AppendKeyPrefix("tenant:42:").CountMinSketch;   // a prefixed context
  • The group is a readonly struct holding only the context. Getting it allocates nothing; take it at the call site rather than storing it.
  • One accessor covers IDatabase, IBatch, ITransaction and RespDatabaseContext, so a prefixed, blocking or cache-adjusted context reaches the same commands.
  • db.CountMinSketch is a C# 14 extension property. On an older language version (the .NET 8 SDK defaults to C# 12), add using NRedisStack.Downlevel; and write db.CountMinSketch() instead. Do not import that namespace on C# 14, where both in scope is CS9339.

Method shape

Every command is asynchronous and follows one pattern:

ValueTask<TResult> XxxAsync(/* command arguments */,
    CommandFlags flags = CommandFlags.None,
    CancellationToken cancellationToken = default)
  • ValueTask, not Task. A result already known (a client-side cache hit) completes without allocating. Await it once; do not store it to await twice. db.AsTask(...) gives a Task that carries the database's AsyncState and marks faults observed; prefer it over .AsTask() if you need a Task.
  • No synchronous twins. To block, make the context blocking once and read the result: db.Context.Blocking().CountMinSketch.InfoAsync(key).GetAwaiter().GetResult(). Every send through that context completes on the calling thread; no thread-pool hop. A batch or transaction cannot block (nothing is sent until it executes).
  • CommandFlags on every call. Fire-and-forget, replica preference, NoClientCache, and retry categories all work as on the core API. Each command already declares its own retry category; a category you pass wins.
  • CancellationToken on every call. It cancels the wait: a command not yet written is never sent; one already written still runs on the server and its reply is discarded.

Return types

command before (db.CMS()) after (db.CountMinSketch)
InitByDim, InitByProb, Merge bool (always true; errors threw) ValueTask - completes or throws
IncrBy (one item) long ValueTask<long>
IncrBy (many items), Query long[] ValueTask<ReadOnlyLease<long>> - dispose it
Info CmsInformation ValueTask<CmsInformation>
using var counts = await db.CountMinSketch.QueryAsync(key, ["foo", "bar"]);
long foo = counts.Span[0];          // or counts.ToArray() if you need to keep it

A ReadOnlyLease<T> is a pooled buffer, valid until disposed. From a client-side cache hit it is a read-only view over the shared entry; disposing it does not affect other readers.

Arguments

  • Keys are RedisKey. MergeAsync(RedisKey destination, ReadOnlySpan<RedisKey> sources, ReadOnlySpan<long> weights = default, ...). The legacy Merge took these as RedisValue, so cluster routing and key prefixing never saw them; that is fixed in both surfaces (the legacy proxy converts). The legacy numKeys parameter is gone from the new surface and validated against the source count on the old one.
  • Spans, not arrays. QueryAsync(key, ReadOnlySpan<RedisValue>) and IncrByAsync(key, ReadOnlySpan<(RedisValue Item, long Increment)>). Arrays and collection expressions convert implicitly: QueryAsync(key, ["foo", "bar"]), IncrByAsync(key, [("foo", 5), ("bar", 15)]). The legacy Tuple<RedisValue, long>[] form stays on db.CMS().
  • Empty input short-circuits. QueryAsync(key, []) and IncrByAsync(key, []) return an empty lease with no round trip (the key need not exist). MergeAsync with no sources, or with weights that do not match the sources one for one, throws ArgumentException before sending.

Client-side caching

QUERY and INFO are declared read-only, so with ConfigurationOptions.ClientCache set (RESP3) they are served from the local cache and invalidated by any INCRBY, MERGE or DEL on the key, including from other clients. Opt a call out with CommandFlags.NoClientCache, or a context with WithoutCache() / WithMaxCacheAge(...). See docs/ClientSideCaching.md in StackExchange.Redis.

Deprecated

CmsCommandBuilder and its SerializedCommand output are [Obsolete]. They still work, but the new surface has no builder layer, and every module will follow.

What the migration looks like

before after
request CmsCommandBuilder → SerializedCommand(object[]) → db.Execute(...) cms.Context.SendAsync<T>($"{Query}{key}{items}", flags.WithRetryCategory(...))
reply RedisResult → .ToLong() / .ToCmsInfo() inbuilt handler for long/long[]; IRespHandler<CmsInformation> for INFO
keys inferred by position (i.e. not at all: Merge sent keys as values) RedisKey holes: routed, prefixed, cache-tracked
surface db.CMS() → class with sync + async twins db.CountMinSketch → readonly struct, ValueTask extension methods only
legacy kept, served from the group: sync via db.Context.Blocking(), Task via db.AsTask(...)

Files worth reading in order:

  1. src/NRedisStack/CountMinSketch/CountMinSketch.cs - the struct and the one generic accessor.
  2. src/NRedisStack/CountMinSketch/CountMinSketch.Methods.cs - the six commands. Four are one interpolated string; INCRBY (multi) and MERGE use Compose; WEIGHTS is a [Resp] generated fragment (the generator ships in the SE.Redis package and runs here unchanged).
  3. CmsCommands.cs / CmsCommandsAsync.cs - the legacy proxies. One line each; the group is resolved once in the constructor.
  4. tests/.../CountMinSketchGroupTests.cs - new surface, RESP2+RESP3, standalone+cluster, prefixed context, batch.
  5. tests/.../CountMinSketchCachingTests.cs - client-side caching over a module command: read-only opt-in is cached, cache key covers value args, local and remote invalidation both reach a module key (RedisBloom does signal key modification).

Decisions taken

  • Accessor spelled out (CountMinSketch, not Cms).
  • New surface is ValueTask-only, no sync twins (results may complete synchronously from the client-side cache). Legacy API keeps Task<T> / sync shapes untouched.
  • InitByDim/InitByProb/Merge on the new surface return ValueTask, not a bool that could only be true.
  • CmsCommandBuilder is [Obsolete]. The builder surface has no v4 equivalent; expect this across all modules.
  • Library-name announcement is unchanged (still via the legacy proxies). SE.Redis's intended mechanism is db.Context.Multiplexer?.AddLibraryNameSuffix(...), but it is a suffix with no override or opt-out, where today's logic replaces the name and offers both; deciding that is separate.

Behaviour changes in the legacy surface

  • Merge: destination/sources were RedisValue, so cluster routing never saw a key. Now converted to RedisKey; numKeys is validated against the source count rather than passed through.

Found along the way (fed back to SE.Redis; all address

  • Needed a public sync path → Blocking().
  • Needed a public ValueTask→Task bridge that carries AsyncState and marks faults observed → db.AsTask(...). - Needed RespDatabaseContext to be an `IRespKeyspaceTar prefixed context → done.
  • Still internal, not needed yet: RespHandlers.Inbuilt<T>. No non-nullable numeric AppendFormatted overloads (deliberate; only visible in Compose loops).
  • Test-side: xunit v3's span Assert.Equal overloads bind to collection expressions, so Assert.Equal([1,2], await ...) is CS4007. Hoist into
    a typed local.

Test run

dotnet test -f net8.0 with REDIS_VERSION=8.10.0 againuster: CMS legacy, group, caching, pipeline, transactionand WithRetry tests all pass (109 passed, 6 skipped - the skips are pre-existing ExampleTests/TestModulesPipeline). Library builds with 0 warnings on all five TFMs.

Commits

  • cb8cb46 Move to StackExchange.Redis 4.0.86-alpha
  • 3bae19c CountMinSketch: first module on the v4 command-group surface
  • af86fed CountMinSketch proxies: resolve the group onc

Not in scope

Any other module. Next candidate by the same recipe: TopK

Pre-release look at SE.Redis v4. The existing Execute-based code compiles
against it unchanged (0 warnings on all TFMs), so old and new styles can
coexist while modules are migrated one group at a time.
A spike to see what migrating a module to SE.Redis v4 looks like, using the
smallest one. The shape follows docs/Extending.md and SE.Redis's own groups:

- RespCountMinSketch: a readonly struct over a RespContext, reached via one
  generic extension property (db.CountMinSketch, batch.CountMinSketch,
  db.Context.AppendKeyPrefix(...).CountMinSketch).
- CountMinSketchCommands: the six commands as ValueTask extension methods.
  Four are a single interpolated string; INCRBY (multi) and MERGE use
  Compose for the variable tail, with WEIGHTS as a [Resp] generated fragment.
  Two custom IRespHandler<T>s: CMS.INFO (pairs by label, so RESP2 array and
  RESP3 map read identically) and single-item INCRBY.
- Every command declares its retry category via WithRetryCategory, which is
  also what makes QUERY and INFO eligible for client-side caching.

The original surface is kept and served from the group, as SE.Redis does
for IDatabase: CmsCommands (sync) sends through db.Context.Blocking();
CmsCommandsAsync (Task) bridges with db.AsTask(...). Both are the public
v4 APIs for exactly this. CmsCommandBuilder is [Obsolete]; the builder
surface has no equivalent in v4 and nothing uses it any more.

Behaviour changes in the legacy surface:
- Merge: destination and sources were typed as RedisValue, so no key ever
  reached cluster routing. The group types them as RedisKey; the proxy
  converts, and now validates numKeys against the source count.
- InitByDim/InitByProb/Merge on the new surface return ValueTask rather
  than a bool that could only ever be true.

Tests: CountMinSketchGroupTests (new surface; RESP2+RESP3, standalone and
cluster, prefixed context, batch) and CountMinSketchCachingTests (RESP3;
proves the read-only opt-in is cached, the cache key covers value args,
and both local and remote invalidation reach a module key). The existing
CmsTests exercise the proxied legacy surface unchanged.
Blocking() allocates a new context each time; a database's context is
immutable and does not change, so hold the blocking group in a field,
as RedisDatabase does internally. The async side's group is a free struct
but is cached too, for symmetry.
mgravell added a commit to StackExchange/StackExchange.Redis that referenced this pull request Oct 9, 2026
…s, and read-only means cacheable

Gaps found reviewing the NRedisStack port (redis/NRedisStack#577), which followed the page faithfully
and still ended up with:
- an extension-property accessor only, invisible below C# 14 (the .NET 8 SDK's default, .NET Framework):
  the page now says to ship the property and a method in a separate Downlevel namespace, as this client
  does, and why apart (CS9339);
- naked long[] replies: a new "Shaping the signature" section states the conventions - ValueTask, flags and
  token last, RedisKey for keys, ReadOnlySpan<T> in, ReadOnlyLease<T> out, empty input answered without a
  round trip - with a sample compiled against the library;
- no warning that CommandRetryReadOnly opts a command into client-side caching, and that a read whose answer
  drifts without a write should add NoClientCache.
Groups.md's lease bullet presented leases as an occasional extra form; it now describes them as the shape
of every variable-length reply.
- QueryAsync and the multi-item IncrByAsync return ReadOnlyLease<long>
  rather than long[], as the SE.Redis groups do for multi-value replies
  (Lists.PositionsAsync, Hashes.GetTimeToLiveAsync). The inbuilt handler
  covers it; the legacy proxies copy out with lease.ToArray().
- An empty item span short-circuits to ReadOnlyLease<long>.Empty with no
  round trip, matching MGET. MergeAsync with no sources still throws, now
  ArgumentException: it is malformed, not out of range.
- NRedisStack.Downlevel.RedisStackGroups: the accessor as a method, for
  consumers below C# 14 who cannot see extension properties. Separate
  namespace because both in scope on C# 14 is CS9339. Mirrors
  StackExchange.Redis.Downlevel.RespGroups.
- cancellationToken docs use the standard sentence (what happens to a
  command already written).
- MergeAsync remarks say why it stays write-accumulating.
… not arrive

CI failed AWriteFromAnotherClientIsAnnouncedForAModuleKey on the Redis 8.10
jobs only (net9.0 and net10.0, not net8.0; nothing on 7.2-8.8), and it does
not reproduce locally against the same 8.10.0 image, filtered or as the
full suite. On failure the test now reports whether the polled reads were
cache hits or reached the server, the remote INCRBY's returned count, CLIENT
TRACKINGINFO on the cached connection, and the server's tracking_* INFO
fields, so the next run can tell a dropped push from a stale server answer.
…e test, document the caveat

The CI failure was the Windows 6.2.6 job, not the 8.10 jobs (those failed
on the pre-existing Search cluster flake). Redis Stack 6.2.6's RedisBloom
does not signal CMS.INCRBY as a key modification, so a tracking client is
never told and a client-side cached CMS.QUERY/INFO stays stale until
TimeToLive. 7.2 and later announce correctly, as the Windows 7.2/7.4 and
all Linux jobs show.

The remote-invalidation test is gated to >= 7.2.0; the group's docs carry
the caveat, since it is user-visible for anyone enabling ClientCache
against a 6.2 server.
…ith [Resp]

The six command names become [Resp("CMS.X")] partial RespCommand
properties, generated by the package's analyzer alongside the WEIGHTS
fragment: one declaration style for commands and tokens, and the names
are checked at build time. Unknown names (all of these) are framed once at
startup; a known name would stay deferred so the command map could still
rename it. No behaviour change.
…k load

PopulateTimeoutIndex writes 100k documents fire-and-forget, then pinged
the database once to order itself after them. A ping is ordered after
the writes on its own connection only; on a cluster the writes are spread
over every primary, so the other nodes could still be draining while
FT.INFO was read. Under StackExchange.Redis 3.x fire-and-forget writes
went to the sockets inline, which hid the race; v4 queues them, and the
8.10 cluster jobs started failing with num_docs a few thousand short
(94081, 96017, 98730 of 100000; a different on-timeout test each run).

Ping each connected primary instead, and give AssertIndexSize a 50ms
pause between its ten FT.INFO re-reads - indexing is asynchronous on the
server, so an immediate re-read sees nothing new.

This branch has not been deployed

No deployments
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.

1 participant