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

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.