Ruvio.

C# client

The client examples on this page were run against a live Ruvio server (standalone and a 3-shard cluster) before they were pasted here. Exceptions: the Username/Password options (the test server has no password) and the integration snippets, which come from each package README and are marked as such.

Overview

Ruvio.Client is the async .NET client for Ruvio. One connection is shared by many concurrent callers: commands are pipelined on the socket and replies are matched in order. It has a typed method for every command in the compatibility matrix, plus helpers for locks, rate limits, cache-aside and idempotency.

PackageRuvio.Client and Ruvio.Client.AspNetCore (DI, health check)
Installdotnet add package Ruvio.Client
Versionlatest. The repository can be ahead of the published package (the "Unreleased" part of the README), so some APIs on this page may need a newer build than the one on NuGet.
LicenseMIT
Runtimenet8.0. Marked AOT-compatible and trimmable.
ModelAsync only (ValueTask). One multiplexed socket per database and per shard.
DependenciesNone at runtime (no PackageReference in the project).
ProtocolRESP2. No HELLO or CLIENT SETINFO on connect. No TLS option exists.
ClusterYes. Slot routing from one seed address, falls back to standalone by itself. See Cluster.
Coverage287 of 287 commands in the compatibility matrix have a typed method. MatrixCoverageTests fails when one is missing, and the live test Every_matrix_command_runs_against_a_live_server sends all of them.

Install

Both packages are published on NuGet.

dotnet add package Ruvio.Client
dotnet add package Ruvio.Client.AspNetCore   # only for ASP.NET Core DI and health checks

To use the repository version (it can be ahead of the published package), reference the project: dotnet add reference clients/csharp/Ruvio.Client/Ruvio.Client.csproj. The .NET 8 SDK is enough to build it.

Quick start

The connection owns the sockets. A database client is fixed to one logical database. Dispose only the connection.

using Ruvio.Client;

await using var connection = await RuvioConnection.ConnectAsync("127.0.0.1", 6379);
IRuvioClient db = await connection.GetDatabaseAsync(0);

await db.SetAsync("greeting", "hello", TimeSpan.FromMinutes(5));
string? value = await db.GetStringAsync("greeting");   // "hello"
string? none  = await db.GetStringAsync("nope");        // null (nil reply)
long visits   = await db.IncrementAsync("visits");      // 1

Options (timeouts, auth, database, reconnect) go in RuvioClientOptions:

var options = new RuvioClientOptions
{
    Host = "127.0.0.1",
    Port = 6379,
    Username = "app",            // sent as AUTH user password, only when Password is set
    Password = "secret",
    Database = 4,                // standalone only; cluster has database 0 only
    ConnectTimeout = TimeSpan.FromSeconds(2),
    MaxInFlightRequests = 256,   // per socket
};
await using var connection = await RuvioConnection.ConnectAsync(options);
IRuvioClient db = await connection.GetDefaultDatabaseAsync();

Other databases come from connection.GetDatabaseAsync(n); each gets its own socket, so SELECT never races (and SELECT is rejected on the typed and raw API). There is no per-command timeout option: pass a CancellationToken. TLS is not supported.

ASP.NET Core:

builder.Services.AddRuvioClient(builder.Configuration.GetSection("Ruvio"));
// then inject IRuvioClient

Performance

How the client stays light, from the README and the code:

  • One connection is shared. A writer sends complete RESP frames without waiting for replies and a reader matches replies to callers in FIFO order. Do not open a connection per request.
  • Write coalescing: frames already queued are sent in one write (at most 16 request groups, stops collecting at 64 KiB). There is no timer and no wait to fill a batch, so a serial call is sent at once.
  • Command names such as GET and SET are pre-encoded RESP fragments generated at compile time from RespVerbs.txt (43 verbs). Integers are written straight into the pooled buffer, with no string allocation.
  • Typed commands with up to five arguments keep them inline in a struct. Larger calls build one exact-size array. Replies are parsed in place on a reusable buffer; null, +OK, +PONG and small integers are shared objects.
  • Every command returns ValueTask backed by a pooled object. Await each result once and do not keep it. Call .AsTask() once if you need Task.WhenAll.

Measured in the README (one run, shared noisy 4-core Linux VM, loopback, memory-only server, repeated runs varied by up to 2x; orders of magnitude, not a comparison, not release evidence):

CaseResult
build + encode GET / SET / DELEX / LPUSHCAP0 B/op, 150 to 560 ns/op
parse :1, +OK0 B/op, 300 to 420 ns/op
parse a bulk string / an integer array88 / 96 B/op (the payload and the list)
GET / SET / DELEX / LPUSHCAP, 1 callerabout 9k to 18k ops/s
same, 16 callers on one connectionabout 54k to 93k ops/s

Faster still: use an explicit pipeline (ExecuteManyAsync). The README's instrumented M4 Pro run (256-byte values, diagnostic only) measured about 486k GET ops/s in pipelines of 16 against about 173k for 16 concurrent single calls. Reuse one connection, keep payloads small, and use the binary API (ExecuteBinaryAsync) for non-text values.

Open issue: the README describes a System.Threading.Channels failure seen under load on .NET 8 ARM64 in a few benchmark passes. It is unresolved and not reproduced in later runs.

Commands

Method names say what they do: Get, Set, Add, Remove, Delete, Increment. All of these run on a standalone server (these calls were run).

FamilyTypical methods
Strings, expirySetAsync (PX/NX/XX/PXAT), GetStringAsync, GetManyAsync, SetManyAsync, IncrementAsync, AppendAsync, ExpireAsync, GetTimeToLiveAsync
HashesSetHashFieldsAsync, GetHashFieldAsync, GetHashAllAsync, ExpireHashFieldsAsync
ListsPushListRightAsync, PopListLeftAsync, GetListRangeAsync, PopListLeftBlockingAsync
SetsAddSetMembersAsync, SetContainsAsync, GetSetIntersectionAsync
Sorted setsAddSortedSetAsync, GetSortedSetRangeByRankWithScoresAsync, PopSortedSetMinAsync
StreamsAddStreamEntryAsync, GetStreamRangeAsync, CreateStreamGroupAsync, GetStreamGroupUpdatesAsync, AcknowledgeStreamAsync
JSONJsonSetAsync, JsonGetAsync, JsonSetValueAsync, JsonGetValueAsync<T>, JsonArrayAppendAsync
GeoAddGeoAsync, GeoDistanceAsync, GeoSearchAsync
Bitmaps, HyperLogLogSetBitAsync, BitCountAsync, BitOperationAsync, HyperLogLogAddAsync, HyperLogLogCountAsync
BloomReserveBloomFilterAsync, AddBloomFilterManyAsync, BloomFilterContainsManyAsync
Scripting, ACL, serverEvaluateAsync, LoadScriptAsync, SetAclUserAsync, GetInfoAsync, SaveAsync
Pub/SubSubscribeAsync, PublishAsync, ListenAsync
await db.SetHashFieldsAsync("user:1", ("name", "Ada"), ("age", "36"));
string? name = await db.GetHashFieldAsync("user:1", "name");                  // "Ada"

await db.PushListRightAsync("queue", "a", "b", "c");
IReadOnlyList<string> items = await db.GetListRangeAsync("queue", 0, -1);      // a,b,c

await db.AddSetMembersAsync("tags", "x", "y");
bool has = await db.SetContainsAsync("tags", "x");                            // true

await db.AddSortedSetAsync("board", ("ada", 100d), ("bob", 50d));
IReadOnlyList<SortedSetEntry> top =
    await db.GetSortedSetRangeByRankWithScoresAsync("board", 0, -1, descending: true);   // ada 100, bob 50

string id = await db.AddStreamEntryAsync("events", "*", ("type", "signup"));
var entries = await db.GetStreamRangeAsync("events", "-", "+");               // 1 entry

await db.JsonSetAsync("doc", "$", "{\"city\":\"Izmir\"}");
await db.JsonSetValueAsync("doc", "$.age", 31);                               // the client serializes the value
string? json = await db.JsonGetAsync("doc");                                  // {"city":"Izmir","age":31}

await db.AddGeoAsync("places", (13.361389, 38.115556, "Palermo"), (15.087269, 37.502669, "Catania"));
double? km = await db.GeoDistanceAsync("places", "Palermo", "Catania", "km");  // 166.27...

await db.SetBitAsync("flags", 7, true);
long bits = await db.BitCountAsync("flags");                                  // 1
await db.HyperLogLogAddAsync("uniq", "a", "b", "a");
long unique = await db.HyperLogLogCountAsync("uniq");                         // 2

Anything without a typed method goes through ExecuteAsync with the same words as redis-cli. It returns a RespValue (Kind, Integer, AsString(), Bytes). For binary arguments use ExecuteBinaryAsync.

RespValue r = await db.ExecuteAsync("STRLEN", "greeting");   // Integer 5

Two forms have no typed method: FLUSHDB ASYNC and HELLO AUTH/SETNAME. Typed JSON helpers accept your own JsonSerializerOptions or a source-generated JsonTypeInfo<T> (AOT-safe).

Ruvio commands

These replace Lua scripts or multi-step Redis patterns with one atomic command.

CommandMethodReplaces
SET ... IFEQSetIfEqualAsynccompare-and-set script
DELEX key IFEQ vDeleteIfEqualAsync"delete if value matches" script (safe lock release)
INCRBY ... MAXIncrementWithLimitAsyncbounded counter script
LIMITLimitAsyncINCR + EXPIRE rate limiter
FILLGET / FILLSETFillGetAsync, FillSetAsynccache stampede lock + SETNX
IDEM BEGIN/COMPLETETryBeginIdempotencyAsync, CompleteIdempotencyAsyncidempotency-key script
LEASE / RELEASELeaseAsync, ReleaseAsynclock with fencing counter
SEMAPHOREAcquireSemaphoreAsynccounting semaphore script
ONCE, TAKE, SETV, GETEX, CHANGESOnceAsync, TakeAsync, SetVersionAsync, GetExpireAsync, StartChangesAsync/ReadChangesAsyncone-shot flag, stock decrement, versioned write, read-and-set-TTL, change feed
LPUSHCAP / RPUSHCAPPushListLeftCappedAsyncLPUSH + LTRIM
BF.*ReserveBloomFilterAsync, AddBloomFilterManyAsync, ...RedisBloom module
JSON.INDEX / JSON.FINDJsonCreateIndexAsync, JsonFindAsyncsecondary index scan
await db.SetAsync("cfg", "v1");
await db.SetIfEqualAsync("cfg", "v2", "v1");        // true
await db.SetIfEqualAsync("cfg", "v3", "v1");        // false, the value is now v2
await db.DeleteIfEqualAsync("cfg", "v2");           // true

long? a = await db.IncrementWithLimitAsync("seats", 3, 4);   // 3
long? b = await db.IncrementWithLimitAsync("seats", 2, 4);   // null: would pass the limit

long? left1 = await db.LimitAsync("login", 2, 60_000);       // 1 (remaining)
long? left2 = await db.LimitAsync("login", 2, 60_000);       // 0
long? left3 = await db.LimitAsync("login", 2, 60_000);       // null: refused

FillResult f = await db.FillGetAsync("page", TimeSpan.FromSeconds(10));   // State: Fill (you build it)
if (f.State == FillState.Fill)
    await db.FillSetAsync("page", "<html/>", TimeSpan.FromMinutes(1), f.Token!.Value);   // true
// the next FillGetAsync returns State: Hit

long? lease = await db.LeaseAsync("job", 30_000);            // fencing token
bool free   = await db.LeaseAsync("job", 30_000) is null;    // true while held
await db.ReleaseAsync("job", lease!.Value);

long once1 = await db.OnceAsync("pay", 60_000);              // 1
long once2 = await db.OnceAsync("pay", 60_000);              // 0

await db.SetAsync("tickets", "5");
long? t1 = await db.TakeAsync("tickets", 2);                 // 3 left
long? t2 = await db.TakeAsync("tickets", 9);                 // null: not enough

long v1 = await db.SetVersionAsync("d", "a");                // 1
long v2 = await db.SetVersionAsync("d", "b", v1);            // 2 (a wrong version throws)

await db.PushListLeftCappedAsync("recent", 3, "1", "2", "3", "4", "5");   // list keeps 3

await db.ReserveBloomFilterAsync("bf", 0.01, 1000);
await db.AddBloomFilterManyAsync("bf", "a", "b");
var seen = await db.BloomFilterContainsManyAsync("bf", "a", "z");          // true, false

IRuvioClient db0 = await connection.GetDatabaseAsync(0);     // JSON.INDEX CREATE only runs in database 0
await db0.JsonCreateIndexAsync("byCity", "user:", "$.city");
await db0.JsonSetAsync("user:1", "$", "{\"city\":\"Izmir\"}");
// the index builds in the background: JSON.FIND answers "ERR index is building" until it is ready
IReadOnlyList<string> keys = await db0.JsonFindAsync("byCity", "Izmir");    // user:1

long cursor = await db.StartChangesAsync();
await db.SetAsync("note", "x");
IReadOnlyList<ChangeNote> notes = await db.ReadChangesAsync(cursor);       // 1 note

Helpers

Extension methods on IRuvioClient in RuvioClientHelpers. They only send single-key commands, so they work unchanged in cluster mode (checked, see below).

// Lock: SET NX PX to take, SET IFEQ to renew, DELEX IFEQ to release.
await using (RuvioLock? held = await db.TryAcquireLockAsync("lock:invoice", TimeSpan.FromSeconds(10)))
{
    if (held is null) return;          // someone else has it
    bool still = await held.RenewAsync();   // false when the lock was lost
}                                       // dispose releases only if the token still matches
RuvioLock? waited = await db.AcquireLockAsync("lock:invoice", TimeSpan.FromSeconds(10), wait: TimeSpan.FromSeconds(2));

// Fenced lease and semaphore slot
await using RuvioLease? lease = await db.TryAcquireLeaseAsync("lease:job", TimeSpan.FromSeconds(30));
await using RuvioLease? slot  = await db.TryAcquireSemaphoreAsync("sem:export", max: 4, TimeSpan.FromSeconds(30));

// Rate limit: refused calls are not counted
RateLimitResult d = await db.RateLimitAsync("rl:ada", max: 2, TimeSpan.FromMinutes(1));   // Allowed true, Remaining 1

// Cache-aside with stampede protection (FILLGET/FILLSET)
string page = await db.GetOrCreateAsync("cache:home", TimeSpan.FromMinutes(5),
    async ct => { await Task.Delay(50, ct); return "<home/>"; });   // factory ran once

// Idempotency
IdempotencyBegin begin = await db.TryBeginIdempotencyAsync("idem:pay:1", "fingerprint", "me", TimeSpan.FromMinutes(10));
// begin.State == Acquired: do the work, then
await db.CompleteIdempotencyAsync("idem:pay:1", "fingerprint", "me", "ok");
// a later caller gets State Completed and the stored Result bytes

// Lazy scans: ScanKeysAsync, ScanHashEntriesAsync, ScanSetMembersAsync, ScanSortedSetEntriesAsync
await foreach (string key in db.ScanKeysAsync("user:*", count: 500)) { }

// Typed JSON
await db.JsonSetValueAsync("u:1", "$", new Person("Ada", 36));
Person? p = await db.JsonGetValueAsync<Person>("u:1", "$");

// Recent items and "seen before"
await db.PushRecentAsync("recent:ada", "item-42", cap: 50);
IReadOnlyList<string> newest = await db.GetRecentAsync("recent:ada", 10);
IReadOnlyList<bool> seen = await db.SeenBeforeAsync("seen:emails", "a@x", "b@x");   // false,false then records them
IReadOnlyList<bool> maybe = await db.MaybeSeenAsync("seen:emails", "c@x");          // read-only

Failure behavior:

  • Lock. TryAcquireLockAsync returns null when the lock is held. AcquireLockAsync retries with a 2 to 25 ms backoff and returns null after wait. There is no automatic renewal and no "lock lost" token in this helper: call RenewAsync yourself (false means you no longer own it). Release and renew compare the token, so they never touch another owner's lock. A failed release is not retried; the key expires by its TTL. For automatic renewal see Ruvio.AspNetCore.DistributedLock below.
  • GetOrCreateAsync. Waiters poll plain FILLGET, never FILLGET ... WAIT, because a server-side wait parks the whole shared connection. A waiter that outlasts GetOrCreateOptions.WaitTimeout (5 s) builds and stores the value itself. The fill marker lives 10 s. If your factory throws, the exception reaches you and the marker lapses.
  • Rate limit and idempotency. Server errors and timeouts throw. Nothing is retried for you. An unknown outcome after a timeout stays unknown.

Pipelines, transactions, pub/sub

Concurrent calls on one client are already pipelined. For an explicit group, use ExecuteManyAsync: one pipeline is one in-flight group and replies come back in order.

var replies = await db.ExecuteManyAsync([["SET", "p1", "1"], ["INCR", "p1"], ["GET", "p1"]]);
// OK, 2, "2"

// A complete transaction in one group
var tx = await db.ExecuteManyAsync([["MULTI"], ["SET", "t", "1"], ["INCR", "t"], ["EXEC"]]);
// tx[3] is the EXEC array

// Stepwise form (WATCH / MULTI / EXEC on this client)
await db.WatchAsync("tw");
await db.BeginTransactionAsync();
await db.ExecuteAsync("SET", "tw", "1");
var results = await db.ExecuteTransactionAsync();

// 1000 concurrent calls share the socket
await Task.WhenAll(Enumerable.Range(0, 1000).Select(i => db.SetAsync($"c{i}", "v").AsTask()));

// Pub/Sub uses its own second socket
var got = new TaskCompletionSource<string>();
await db.SubscribeAsync("news", m => got.TrySetResult(m.Payload));
await db.PublishAsync("news", "hello");

// Blocking read: returns null on timeout
var item = await db.PopListLeftBlockingAsync("nolist", TimeSpan.FromMilliseconds(100));

Do not mix MULTI or blocking commands with unrelated concurrent calls on the same client; use a separate connection for a blocking read. RESP3 and client-side caching are not supported (HELLO 3 is not spoken; CLIENT TRACKING only with OFF).

Cluster

Give the client one seed address. On connect it sends CLUSTER SLOTS. A standalone server answers with an error and the client keeps one socket (this fallback was checked on port 17370). A sharded server returns the slot map; the client then opens one socket per shard and sends each single-key command to the owner of its slot (CRC16, {hash tags} honoured). RuvioClientOptions.DiscoverCluster = false keeps the seed socket only.

  • Split by shard and merged in the caller's order: MGET, MSET, DEL, UNLINK, EXISTS, TOUCH. FLUSHDB, FLUSHALL, DBSIZE go to every shard. BITOP, SINTERCARD and JSON.MGET route by their real keys.
  • Other cross-slot commands fail with CROSSSLOT. Only database 0 exists.
  • MOVED refreshes the map and the command is retried once.
  • Pipelines that touch several shards are grouped per shard.
await using var connection = await RuvioConnection.ConnectAsync("127.0.0.1", 17380);   // any shard port
IRuvioClient db = await connection.GetDefaultDatabaseAsync();
// connection.IsCluster == true, connection.ShardCount == 3
await db.SetAsync("session:ada", "hello");
await db.SetAsync("somekey", "on-another-shard");
IReadOnlyList<string?> values = await db.GetManyAsync("session:ada", "somekey");
int slot = RuvioClient.GetHashSlot("somekey");   // 11058, same as CLUSTER KEYSLOT

Real cluster smoke test

The README says routing had only unit tests. The repository does have two live cluster tests (ClusterLiveTests, run with RUVIO_CLUSTER_ADDR), and they pass against a real 3-shard server. In addition, a small console script outside the repo ran this against a 3-shard server (port_mode = "per-shard", ports 17380 to 17382).

Result: 22 of 22 checks passed (the stepwise cross-slot transaction counts as passed because it was rejected, but see the note below the table).

CheckResult
Discovery from one seedpass: 3 shards
GetHashSlot equals CLUSTER KEYSLOT (5 keys, one non-ASCII)pass
300 keys spread over shards (counted on each shard with direct connections)pass: 101 / 92 / 107
DBSIZE through the router, GET of each keypass
Cross-slot MGET (60 keys reversed, plus a missing key), MSET, EXISTS, DELpass: caller order kept, nil kept
Pipeline of 50 GET and 30 INCR over 10 keyspass: replies in caller order
MOVED: non-discovering client on the wrong shard, 40 SET+GETpass
Same-slot MULTI/EXEC in one pipelinepass
Cross-slot transactionrejected, but the error is a raw server MOVED, not a clear client error (see below)
SUNION on two keys in different slotsrejected: CROSSSLOT Keys in request do not hash to the same slot
Helpers: lock, rate limit, GetOrCreateAsync (10 callers, factory ran once), idempotency, LEASE, ONCEpass
ScanKeysAsync over all shardspass: 299 of 299 present keys (one key had been removed by a cross-slot RENAME in the same script)
Pub/Sub on the cluster clientpass

Not as good as hoped: the stepwise BeginTransactionAsync / ExecuteTransactionAsync form with keys in different slots does not fail fast with a CROSSSLOT-style message. It surfaced a server MOVED error after commands were already sent.

Keep a transaction to one slot (use a {hash tag}) and send it in one ExecuteManyAsync group. A RENAME across slots was accepted and worked.

Throughput, 16 callers, 50/50 GET/SET, 3 seconds after a 1 second warmup, Release build, loopback, 4-vCPU shared VM with the servers on the same machine: the two runs gave 50.7k (cluster) vs 41.1k (standalone) and 40.6k vs 99.8k ops/s, and a Debug-build run gave 10k vs 56k. The ordering flipped between runs, so no claim is made about cluster versus standalone speed. Not release evidence.

Errors, nil, reconnect, cancellation

  • A server -ERR becomes RuvioException with the server text. IsServerReply is true and the connection stays usable. A framing failure closes the connection. ClosedBeforeReply is true when no byte of the reply arrived.
  • Nil maps to null (string?, long?, nullable results). Commands that "refuse" (LIMIT, TAKE, LEASE, IncrementWithLimitAsync) return null.
  • Reconnect is on by default: a dropped socket is reopened before the next command (up to MaxReconnectAttempts, exponential delay). The command that finds the drop fails, so a write is never applied twice. One exception: a single GET, MGET, EXISTS, HGET, TTL or PTTL that got no reply byte is sent once more. Pipelines are not retried. Pub/Sub channels are subscribed again. Events: ConnectionLost, ConnectionRestored, ConnectionFailed.
  • Cancellation: pass a CancellationToken. Before dispatch it skips the command. After dispatch it only stops your wait; the server may still have run it.
try { await db.IncrementAsync("greeting"); }
catch (RuvioException ex) { /* ex.IsServerReply == true; ERR value is not an integer or out of range */ }

try { await db.ExecuteAsync("NOSUCH"); }
catch (RuvioException ex) { /* ERR unknown command */ }

using var cts = new CancellationTokenSource(); cts.Cancel();
try { await db.GetStringAsync("greeting", cts.Token); }
catch (OperationCanceledException) { }   // and the connection still answers PING

Not done / not verified

  • No TLS, no RESP3, no client-side caching.
  • No per-command timeout option; use cancellation tokens.
  • Cluster: cross-slot transaction in the stepwise API gives a raw MOVED (above). Cluster routing was smoke-tested here once, on one machine; no failover, resharding or node-loss run was made.
  • ACL LOAD/SAVE were only checked to reach the server, SCRIPT KILL only as NOTBUSY, OBJECT IDLETIME/FREQ only as the maxmemory-policy error.
  • A BF.MADD or BF.INSERT reply with an error element (full non-scaling filter) throws for the whole call.
  • Performance numbers are single noisy runs, not BenchmarkDotNet, and were not compared with another client. a .NET channel failure under load is unresolved.
  • The repository can be ahead of the published package; anything the README lists as "Unreleased" is not on NuGet yet.

Testing

dotnet test clients/csharp/Ruvio.Client.Tests
RUVIO_TEST_ADDR=127.0.0.1:6379 RUVIO_CLUSTER_ADDR=127.0.0.1:6379 dotnet test clients/csharp/Ruvio.Client.Tests

Without a server: 198 tests, 193 pass and 5 live tests are skipped. With RUVIO_TEST_ADDR (standalone) and RUVIO_CLUSTER_ADDR (3 shards): 198 of 198 passed on this run. The suite covers the RESP codec and parser, the generated verbs, multiplexing and reconnect with scripted fake servers, cluster routing with scripted servers, the 287-command matrix mapping, and a live run of every command family and helper.

The live tests use databases 12 to 14, flush them and end with FLUSHALL: point them at a disposable server only. Ruvio.Client.AspNetCore.Tests: 11 tests passed.

Integrations (.NET)

Separate NuGet packages built on Ruvio.Client for ASP.NET Core and Microsoft.Extensions. They borrow the shared IRuvioClient; none of them owns or disposes it. All target .NET 8. Register the client first (AddRuvioClient from Ruvio.Client.AspNetCore).

The snippets below are taken from each package README (not re-run here); each test suite was run against a live server, results in the table.

PackageDoesOn NuGet todayTests (live run)
Ruvio.Extensions.CachingIDistributedCache, plus a stampede-safe GetOrCreateAsynclatest21 passed
Ruvio.AspNetCore.RateLimitingOpt-in per-endpoint fixed-window budgets on LIMITlatest15 passed
Ruvio.AspNetCore.IdempotencyOpt-in HTTP request dedup and response replay on IDEMlatest71 passed
Ruvio.AspNetCore.OutputCachingIOutputCacheStore with tag invalidation by one DELlatest14 passed
Ruvio.AspNetCore.DataProtectionEncrypted key ring in Ruvio, shared cookies across instanceslatest10 passed
Ruvio.AspNetCore.DistributedLockOwner-checked lock with auto-renew, and [RuvioLock] endpoint attributelatest12 passed

NuGet versions were read from the NuGet index when this page was written. Publishing is a manual workflow, and each integration needs the matching Ruvio.Client version to be published first. Install uses the usual form, for example dotnet add package Ruvio.Extensions.Caching. Because the repo is ahead of NuGet, a feature in a README may need a version newer than the one on NuGet.

Ruvio.Extensions.Caching

Binary-safe IDistributedCache (also usable for ASP.NET Core Session and the distributed tier of HybridCache). Absolute and sliding expiry are supported; a set is one SET with PX or PXAT. GetOrCreateAsync uses FILLGET ... WAIT so one caller across all processes builds the value. Defaults: 1 MiB value, 1024-byte key. Do not mix with Redis-provider entries (different format).

dotnet add package Ruvio.Extensions.Caching

services.AddRuvioDistributedCache(options => options.InstanceName = "orders");

await cache.SetAsync("invoice:42", bytes, new DistributedCacheEntryOptions
{
    SlidingExpiration = TimeSpan.FromMinutes(5),
    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
}, cancellationToken);

byte[] page = await cache.GetOrCreateAsync("invoice:42",
    async token => await RenderAsync(token),
    new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });

Ruvio.AspNetCore.RateLimiting

Distributed first-request-anchored fixed window, one atomic LIMIT per request on opted-in endpoints. Rejections are HTTP 429 with a full-window Retry-After. Backend failures propagate to your exception handler; it never fails open. A missing partition is a configuration error. Not a billing ledger: eviction or failover can reset a budget.

builder.Services.AddRuvioClient(builder.Configuration.GetSection("Ruvio"));
builder.Services.AddRuvioRateLimiting(options =>
{
    options.KeyPrefix = "orders:rate:v1:";
    options.PartitionKey = context =>
        context.User.FindFirst("tenant_id")?.Value
        ?? throw new InvalidOperationException("An authenticated tenant is required.");
});

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseRuvioRateLimiting();
app.MapPost("/orders", HandleOrder)
    .RequireAuthorization()
    .WithMetadata(new RuvioRateLimitAttribute("create-order", 100, 60));

Ruvio.AspNetCore.Idempotency

Requires an Idempotency-Key header on POST, PUT, PATCH and DELETE endpoints that carry the attribute. A repeat with the same body replays the stored status and body; the same key with a different body returns 409; a request still pending returns 409.

Default limits 64 KiB request and response. It is not downstream exactly-once: expiry, eviction or failover can allow a duplicate, so irreversible side effects still need their own deduplication. Authentication and authorization must run before it.

builder.Services.AddRuvioClient(builder.Configuration.GetSection("Ruvio"));
builder.Services.AddRuvioIdempotency(options =>
{
    options.Namespace = "orders-production-v1";   // stable across replicas
    options.ScopeSelector = context => context.User.FindFirst("tenant_id")?.Value;
    options.Retention = TimeSpan.FromHours(24);
});

app.UseAuthentication();
app.UseAuthorization();          // must run before idempotency
app.UseRuvioIdempotency();
app.MapPost("/orders", HandleOrder)
    .RequireAuthorization()
    .WithMetadata(new RuvioIdempotencyAttribute());

Ruvio.AspNetCore.OutputCaching

Replaces the framework's output-cache store; ASP.NET Core still owns policies and variation. Evicting a tag is one DEL of the tag generation, with no scan. Cluster limit: one InstanceName lives in one hash slot, so one namespace does not spread over shards.

builder.Services.AddRuvioClient("127.0.0.1", 6379);
builder.Services.AddRuvioOutputCache(o => o.InstanceName = "catalog-production-v1");

app.UseOutputCache();
app.MapGet("/catalog", () => new { version = 1 })
    .CacheOutput(policy => policy.Expire(TimeSpan.FromSeconds(30)).Tag("catalog"));

// invalidate: inject IOutputCacheStore
await store.EvictByTagAsync("catalog", cancellationToken);

Ruvio.AspNetCore.DataProtection

Append-only key-ring repository, so all instances can decrypt each other's cookies and antiforgery tokens. Encryption of the ring is required by default. The ring has no TTL: use persistent, backed-up, non-evicting storage, ideally a separate durable connection.

builder.Services.AddDataProtection()
    .SetApplicationName("orders-production")
    .PersistKeysToRuvio(o => o.Key = "orders:production:data-protection:v1")
    .ProtectKeysWithCertificate(certificate);

Ruvio.AspNetCore.DistributedLock

Lock as one key with an owner token: acquire SET NX PX, renew SET IFEQ PX, release DELEX IFEQ. Unlike the plain client helper it renews by itself (every third of the lease) and exposes a LockLost token. The [RuvioLock] attribute guards an endpoint: 409 with Retry-After when taken, 503 when the server is unreachable (fails closed), and the request is cancelled if the lock is lost.

Limits: one server holds the lock, so a failover can let two owners overlap for up to a lease; not reentrant; no fencing tokens. Not on NuGet yet.

services.AddRuvioDistributedLock(options => options.KeyPrefix = "myapp:lock:");

await using IRuvioLock? held = await locks.TryAcquireAsync(
    "nightly-report", lease: TimeSpan.FromSeconds(30), wait: TimeSpan.FromSeconds(5));
if (held is null) return;
await RunReportAsync(held.LockLost);

// or per endpoint
app.UseRuvioLock();
app.MapPost("/orders/{id}/pay", PayAsync).WithMetadata(new RuvioLockAttribute("order:{id}"));

Middleware order matters when you combine them: rate limiting before idempotency also counts retries and replays; the other order does not. Put your exception handler outside both. Details and limits are in each package README under integrations/dotnet.

See also: Clients overview, Rust, Go, Java, Python, Node.js, Examples, Commands, Packages.