Redis over the ring
ioxide.redis is a Redis client where connect, every command, and
pipelines are io_uring ops on the host reactor's ring, resumed inline. Full RESP2, a generic
command API that runs anything, typed helpers across every data type, and pipelining - pooled
per reactor, shared-nothing.
The pool
Open connections per reactor from OnStart and register the pool as a service;
handlers fetch it and rent per operation. One connection carries one in-flight command (or
pipeline) at a time, so the pool size is the reactor's Redis concurrency.
reactor.OnStart = r => RedisPool.Start(r, new RedisOptions
{
Host = "127.0.0.1", Port = 6379, PoolSize = 8,
// Password, User (ACL), Database (SELECT on connect) are all optional
});
Two API levels
Generic - ExecuteAsync runs any command and returns a
RespValue, the full RESP2 reply taxonomy. Use it for anything the typed helpers do
not cover:
var redis = reactor.GetService<RedisPool>();
RespValue pong = await redis.ExecuteAsync("PING");
RespValue n = await redis.ExecuteAsync("INCRBY", "counter", 5);
RespValue info = await redis.ExecuteAsync("COMMAND", "COUNT");
Typed - convenience methods over the generic call, covering strings, keys, hashes, lists, sets, sorted sets, pub/sub, and scripting. Rent a connection for a sequence, or use the pool's one-shot shortcuts:
var c = await redis.RentAsync();
try
{
await c.SetExAsync("session:42", token, seconds: 300);
string? v = await c.GetAsync("session:42");
long count = await c.IncrAsync("hits");
var fields = await c.HGetAllAsync("user:42");
var range = await c.LRangeAsync("queue", 0, -1);
double? sc = await c.ZScoreAsync("board", "alice");
}
finally { redis.Return(c); }
The reply type
RespValue represents any RESP2 reply - null, simple string, error, integer, bulk
string, or a (possibly nested) array - and converts on demand. Errors are surfaced two ways:
ExecuteAsync throws a RedisException on a top-level error reply, while
array elements that are errors stay inspectable (for MULTI/EXEC).
RespValue r = await redis.ExecuteAsync("HGETALL", "user:42");
foreach (RespValue field in r.Items) // arrays
Console.WriteLine(field.AsString());
long size = (await redis.ExecuteAsync("DBSIZE")).AsInteger(); // integers
bool gone = (await redis.ExecuteAsync("GET", "missing")).IsNull;
Pipelining
Send several commands back to back and read all replies in one round trip. Errors are returned per-command rather than thrown, so you can inspect each outcome.
RespValue[] replies = await c.PipelineAsync(
new RedisCommand("SET", "k", "10"),
new RedisCommand("INCR", "k"),
new RedisCommand("GET", "k"));
long current = replies[2].AsInteger(); // 11
Cache-aside
The everyday pattern - check the cache, fall back to the source, populate with a TTL, and
invalidate on write. The pool exposes GetAsync / SetExAsync /
DelAsync directly so a read is one rented round trip:
string key = $"item:{id}";
string? cached = await redis.GetAsync(key);
if (cached is null)
{
cached = await LoadFromDatabase(id); // ioxide.pg, also on the ring
await redis.SetExAsync(key, cached, seconds: 1);
}
// ... on update:
await redis.DelAsync($"item:{id}");
Shared-nothing, but consistent. Each reactor owns its own Redis connections, so there is no shared managed state and no lock on the hot path. Because Redis itself is shared across reactors, a write that invalidates a key is seen by every reactor's next read - cache-aside stays correct even though connections land on different reactors. This is the reason a shared cache (Redis) fits the model better than a per-reactor in-process cache, which would serve stale reads across reactors.
Connection
RedisOptions: Host (IPv4 literal - resolve names up front, DNS
would block the reactor), Port, Password with optional
User for ACL AUTH, Database to SELECT on
connect, and PoolSize per reactor. Total server-side connections are
PoolSize × ReactorCount. Broken connections are discarded on return and
replaced in the background, exactly like the Postgres pool.