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.
| Package | Ruvio.Client and Ruvio.Client.AspNetCore (DI, health check) |
|---|---|
| Install | dotnet add package Ruvio.Client |
| Version | latest. 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. |
| License | MIT |
| Runtime | net8.0. Marked AOT-compatible and trimmable. |
| Model | Async only (ValueTask). One multiplexed socket per database and per shard. |
| Dependencies | None at runtime (no PackageReference in the project). |
| Protocol | RESP2. No HELLO or CLIENT SETINFO on connect. No TLS option exists. |
| Cluster | Yes. Slot routing from one seed address, falls back to standalone by itself. See Cluster. |
| Coverage | 287 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
GETandSETare pre-encoded RESP fragments generated at compile time fromRespVerbs.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,+PONGand small integers are shared objects. - Every command returns
ValueTaskbacked by a pooled object. Await each result once and do not keep it. Call.AsTask()once if you needTask.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):
| Case | Result |
|---|---|
| build + encode GET / SET / DELEX / LPUSHCAP | 0 B/op, 150 to 560 ns/op |
parse :1, +OK | 0 B/op, 300 to 420 ns/op |
| parse a bulk string / an integer array | 88 / 96 B/op (the payload and the list) |
| GET / SET / DELEX / LPUSHCAP, 1 caller | about 9k to 18k ops/s |
| same, 16 callers on one connection | about 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).
| Family | Typical methods |
|---|---|
| Strings, expiry | SetAsync (PX/NX/XX/PXAT), GetStringAsync, GetManyAsync, SetManyAsync, IncrementAsync, AppendAsync, ExpireAsync, GetTimeToLiveAsync |
| Hashes | SetHashFieldsAsync, GetHashFieldAsync, GetHashAllAsync, ExpireHashFieldsAsync |
| Lists | PushListRightAsync, PopListLeftAsync, GetListRangeAsync, PopListLeftBlockingAsync |
| Sets | AddSetMembersAsync, SetContainsAsync, GetSetIntersectionAsync |
| Sorted sets | AddSortedSetAsync, GetSortedSetRangeByRankWithScoresAsync, PopSortedSetMinAsync |
| Streams | AddStreamEntryAsync, GetStreamRangeAsync, CreateStreamGroupAsync, GetStreamGroupUpdatesAsync, AcknowledgeStreamAsync |
| JSON | JsonSetAsync, JsonGetAsync, JsonSetValueAsync, JsonGetValueAsync<T>, JsonArrayAppendAsync |
| Geo | AddGeoAsync, GeoDistanceAsync, GeoSearchAsync |
| Bitmaps, HyperLogLog | SetBitAsync, BitCountAsync, BitOperationAsync, HyperLogLogAddAsync, HyperLogLogCountAsync |
| Bloom | ReserveBloomFilterAsync, AddBloomFilterManyAsync, BloomFilterContainsManyAsync |
| Scripting, ACL, server | EvaluateAsync, LoadScriptAsync, SetAclUserAsync, GetInfoAsync, SaveAsync |
| Pub/Sub | SubscribeAsync, 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.
| Command | Method | Replaces |
|---|---|---|
SET ... IFEQ | SetIfEqualAsync | compare-and-set script |
DELEX key IFEQ v | DeleteIfEqualAsync | "delete if value matches" script (safe lock release) |
INCRBY ... MAX | IncrementWithLimitAsync | bounded counter script |
LIMIT | LimitAsync | INCR + EXPIRE rate limiter |
FILLGET / FILLSET | FillGetAsync, FillSetAsync | cache stampede lock + SETNX |
IDEM BEGIN/COMPLETE | TryBeginIdempotencyAsync, CompleteIdempotencyAsync | idempotency-key script |
LEASE / RELEASE | LeaseAsync, ReleaseAsync | lock with fencing counter |
SEMAPHORE | AcquireSemaphoreAsync | counting semaphore script |
ONCE, TAKE, SETV, GETEX, CHANGES | OnceAsync, TakeAsync, SetVersionAsync, GetExpireAsync, StartChangesAsync/ReadChangesAsync | one-shot flag, stock decrement, versioned write, read-and-set-TTL, change feed |
LPUSHCAP / RPUSHCAP | PushListLeftCappedAsync | LPUSH + LTRIM |
BF.* | ReserveBloomFilterAsync, AddBloomFilterManyAsync, ... | RedisBloom module |
JSON.INDEX / JSON.FIND | JsonCreateIndexAsync, JsonFindAsync | secondary 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.
TryAcquireLockAsyncreturnsnullwhen the lock is held.AcquireLockAsyncretries with a 2 to 25 ms backoff and returnsnullafterwait. There is no automatic renewal and no "lock lost" token in this helper: callRenewAsyncyourself (falsemeans 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 seeRuvio.AspNetCore.DistributedLockbelow. - GetOrCreateAsync. Waiters poll plain
FILLGET, neverFILLGET ... WAIT, because a server-side wait parks the whole shared connection. A waiter that outlastsGetOrCreateOptions.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,DBSIZEgo to every shard.BITOP,SINTERCARDandJSON.MGETroute by their real keys. - Other cross-slot commands fail with
CROSSSLOT. Only database 0 exists. MOVEDrefreshes 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).
| Check | Result |
|---|---|
| Discovery from one seed | pass: 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 key | pass |
Cross-slot MGET (60 keys reversed, plus a missing key), MSET, EXISTS, DEL | pass: caller order kept, nil kept |
| Pipeline of 50 GET and 30 INCR over 10 keys | pass: replies in caller order |
MOVED: non-discovering client on the wrong shard, 40 SET+GET | pass |
Same-slot MULTI/EXEC in one pipeline | pass |
| Cross-slot transaction | rejected, but the error is a raw server MOVED, not a clear client error (see below) |
SUNION on two keys in different slots | rejected: CROSSSLOT Keys in request do not hash to the same slot |
Helpers: lock, rate limit, GetOrCreateAsync (10 callers, factory ran once), idempotency, LEASE, ONCE | pass |
ScanKeysAsync over all shards | pass: 299 of 299 present keys (one key had been removed by a cross-slot RENAME in the same script) |
| Pub/Sub on the cluster client | pass |
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
-ERRbecomesRuvioExceptionwith the server text.IsServerReplyis true and the connection stays usable. A framing failure closes the connection.ClosedBeforeReplyis true when no byte of the reply arrived. - Nil maps to
null(string?,long?, nullable results). Commands that "refuse" (LIMIT,TAKE,LEASE,IncrementWithLimitAsync) returnnull. - 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 singleGET,MGET,EXISTS,HGET,TTLorPTTLthat 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/SAVEwere only checked to reach the server,SCRIPT KILLonly asNOTBUSY,OBJECT IDLETIME/FREQonly as the maxmemory-policy error.- A
BF.MADDorBF.INSERTreply 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.
| Package | Does | On NuGet today | Tests (live run) |
|---|---|---|---|
Ruvio.Extensions.Caching | IDistributedCache, plus a stampede-safe GetOrCreateAsync | latest | 21 passed |
Ruvio.AspNetCore.RateLimiting | Opt-in per-endpoint fixed-window budgets on LIMIT | latest | 15 passed |
Ruvio.AspNetCore.Idempotency | Opt-in HTTP request dedup and response replay on IDEM | latest | 71 passed |
Ruvio.AspNetCore.OutputCaching | IOutputCacheStore with tag invalidation by one DEL | latest | 14 passed |
Ruvio.AspNetCore.DataProtection | Encrypted key ring in Ruvio, shared cookies across instances | latest | 10 passed |
Ruvio.AspNetCore.DistributedLock | Owner-checked lock with auto-renew, and [RuvioLock] endpoint attribute | latest | 12 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.