Redis internals
ioxide.redis is a lean RESP2 client on the same ring as everything
else. Its design mirrors the Postgres driver's pipelining and pooling;
the value/parse layer borrows the allocation discipline of StackExchange.Redis. Every class
below.
RedisConnection · RedisConnection.cs (+ .Commands)
One Redis connection over a RingSocket. ConnectAsync connects, then
AUTH (ACL-aware) and SELECT inline before returning a ready connection. The generic surface is
ExecuteAsync(command, args); typed helpers (GET/SET/INCR/HSET/LRANGE/ZADD/…) layer on
top in RedisConnection.Commands.cs.
Pipelining
Identical shape to the pg driver: SubmitCore appends the encoded command into a
fixed 512 KB native send buffer (overflow throws, never realloc - a pipelined send
holds its address), enqueues a Pending (IValueTaskSource<RespValue>),
and kicks the sender/reader loops. SenderLoopAsync drains the staged bytes;
ReaderLoopAsync pulls one reply per in-flight entry and completes the dequeued
Pending FIFO (Redis guarantees reply order on one connection). A documented invariant:
there is no await between the sender loop's final drain check and clearing
_sending, and completions resume inline, so no command can be stranded - it depends on
RingOpSource keeping synchronous continuations. PipelineAsync sends several
commands back to back and awaits all replies - one round trip.
RedisConnection.cs · staging a command into the fixed send buffer (overflow throws, never realloc)
private unsafe void AppendCommand(ReadOnlySpan<byte> nameToken, RedisArg[] args)
{
int size = RespProtocol.CommandSize(nameToken, args);
if (size > _sendCapacity)
{
throw new RedisException($"command exceeds send buffer ({_sendCapacity} bytes)");
}
if (_sendEnd + size > _sendCapacity)
{
throw new RedisException("pipelined send buffer full"); // an in-flight send holds _send's address
}
int written = RespProtocol.WriteCommand(
new Span<byte>((void*)(_send + _sendEnd), _sendCapacity - _sendEnd), nameToken, args);
_sendEnd += written;
}
Command encoding
Command names are a tiny fixed set, so their full pre-framed RESP token
($3\r\nGET\r\n) is computed once and cached in a static
ConcurrentDictionary<string, byte[]> - the hot path memcpy's the whole token in
one shot, no per-call encoding or framing allocation.
RedisConnection.cs · the pre-framed command token is computed once and memoized
private static readonly ConcurrentDictionary<string, byte[]> CommandCache = new();
private static byte[] EncodeCommand(string command) =>
CommandCache.GetOrAdd(command, static c => RespProtocol.FrameName(Encoding.ASCII.GetBytes(c)));
Receiving with an examined cursor
ReceiveReplyAsync reads into a growable native recv buffer (up to 64 MB),
compacting the unparsed tail before growth. TryParse reports, on an incomplete reply,
how many bytes are needed before a re-parse can make progress (the examined cursor), so a large
reply arriving over many recvs isn't re-scanned from the start each time.
RespProtocol.cs · the examined cursor - on an incomplete reply, report how many more bytes are needed
public static bool TryParse(ReadOnlySpan<byte> buffer, out RespValue value, out int consumed, out int needed)
{
int pos = 0;
needed = 0;
if (TryParseAt(buffer, ref pos, out value, ref needed))
{
consumed = pos;
return true;
}
consumed = 0;
return false; // 'needed' lets the caller wait for that many bytes before re-parsing
}
Timeouts and breakage
Each Pending stamps EnqueuedAtMs; the pool's ticker calls
CheckTimeout to tear down a connection whose oldest in-flight command has aged past
CommandTimeoutMs (diagnostic RedisException; closing the fd cancels the
stuck recv). A transport failure marks IsBroken and the pool evicts.
RespProtocol · RespProtocol.cs
The RESP2 codec, a pure static. CommandSize/WriteCommand write a
command as a RESP array of bulk strings (the pre-framed name token + each arg). FrameName
builds the cached name token. TryParse recursively parses one reply -
+ simple string, - error, : integer, $ bulk
(with $-1 null), * array (with *-1 null), and _
(RESP3 null, tolerated) - returning false with a needed-bytes count when the reply
isn't fully buffered. ParseLong is strict: optional sign then ASCII digits, with
overflow-checked accumulation, so a malformed length breaks the connection cleanly instead of
computing a garbage size and wedging. Bulk/array lengths are range-guarded.
RespProtocol.cs · WriteCommand - a RESP array of bulk strings, name token memcpy'd in
public static int WriteCommand(Span<byte> dst, ReadOnlySpan<byte> nameToken, ReadOnlySpan<RedisArg> args)
{
int p = 0;
dst[p++] = (byte)'*';
p += WriteInt(dst[p..], args.Length + 1);
dst[p++] = (byte)'\r';
dst[p++] = (byte)'\n';
nameToken.CopyTo(dst[p..]); // pre-framed $len\r\nNAME\r\n - one memcpy, no per-call framing
p += nameToken.Length;
foreach (RedisArg arg in args)
{
p += WriteBulk(dst[p..], arg.Bytes);
}
return p;
}
RespProtocol.cs · ParseLong - strict ASCII digits, overflow-checked, malformed input breaks the connection
private static long ParseLong(ReadOnlySpan<byte> s)
{
bool neg = s.Length > 0 && s[0] == (byte)'-';
if (neg) s = s[1..];
if (s.IsEmpty)
{
throw new RedisException("malformed RESP integer");
}
long n = 0;
foreach (byte c in s)
{
if (c < (byte)'0' || c > (byte)'9')
{
throw new RedisException($"malformed RESP integer (byte 0x{c:x2})");
}
n = checked(n * 10 + (c - '0'));
}
return neg ? -n : n;
}
RespValue · RespValue.cs
A parsed reply, as a value type (struct), so parsing allocates no per-node object -
integers, nulls, and booleans are entirely allocation-free; bulk/simple strings and arrays carry
the bytes (or element array) they materialized during parse, which outlive the recv buffer.
Accessors convert on demand: AsString, Bytes, AsInteger
(accepts integer replies and numeric strings, length-checked), AsDouble (accepts
integers, numeric strings, and the non-finite tokens inf/-inf/nan
that Utf8Parser won't parse - e.g. a ZSCORE of +inf), AsBool,
Items, IsNull, IsError.
RespValue.cs · a readonly struct - integers/nulls/bools cost zero allocation
public readonly struct RespValue
{
public RespKind Kind { get; }
public long Integer { get; }
private readonly byte[]? _bytes;
private readonly RespValue[]? _items;
private RespValue(RespKind kind, long integer, byte[]? bytes, RespValue[]? items)
{
Kind = kind;
Integer = integer;
_bytes = bytes; // bulk/simple strings materialize here at parse, outliving the recv buffer
_items = items; // arrays only
}
// ...accessors below convert on demand
}
RespValue.cs · AsDouble - falls back to the non-finite tokens Utf8Parser won't take
public double AsDouble()
{
if (Kind == RespKind.Integer)
{
return Integer;
}
if (_bytes is not null && (Kind == RespKind.BulkString || Kind == RespKind.SimpleString))
{
if (Utf8Parser.TryParse(_bytes, out double d, out int n) && n == _bytes.Length)
{
return d;
}
ReadOnlySpan<byte> b = _bytes;
if (b.SequenceEqual("inf"u8) || b.SequenceEqual("+inf"u8)) return double.PositiveInfinity;
if (b.SequenceEqual("-inf"u8)) return double.NegativeInfinity;
if (b.SequenceEqual("nan"u8)) return double.NaN; // e.g. a ZSCORE of +inf
}
throw new RedisException($"reply ({Kind}) is not a double");
}
RedisPool · RedisPool.cs
N connections per reactor with round-robin and pipelining, mirroring PgPool:
bounded fail-fast opens (WaitForConnectionAsync throws after
MaxConnectAttempts instead of hanging), a reactor ticker that sweeps command timeouts
and replenishes with jittered backoff, and the cache-aside surface (GetAsync,
SetExAsync, DelAsync) plus ExecuteAsync and
PipelineAsync (the latter picks one connection so the batch is a real single round
trip). One pool per reactor, reactor-thread-only.
The value types
- RedisArg (
RedisArg.cs) - a value-type command argument with implicit conversions from string/byte[]/int/long/double/bool, rendered to bytes for the wire (double via round-trip "R", bool as "1"/"0"). - RedisCommand (in
RedisConnection.cs) - a (name, args) pair forPipelineAsync; the name is pre-framed to its RESP token at construction. - RedisOptions (
RedisOptions.cs) - host/port, password + optional ACL user, database index,PoolSize,CommandTimeoutMs. - RedisException (
RedisException.cs) - a-ERRreply or a transport failure.
Note: RESP2 only (no HELLO), and the FIFO
one-reply-per-request model has no out-of-band/push path - pub/sub on a RESP2 connection would
need a dedicated connection, as StackExchange.Redis does.