Skip to main content

core.redis — Multi-purpose Redis subsystem

Production-grade Redis client speaking RESP2 / RESP3. Used as a multi-purpose data store: cache, pub/sub, streams (event queue), transactions, sorted-set leaderboards, distributed locks.

Architectural composition​

Lower layers (protocol.vr, client.vr) handle RESP framing + connection pool + cluster redirects. Upper layers (commands.vr, pubsub.vr, stream.vr, transaction.vr, script.vr) provide typed surfaces. Every command goes through client.exec (or exec_bytes for binary-safe, exec_pipeline for a batch), which acquires a pool slot, writes the framed RESP, reads one reply, releases the slot.

Composes against lower stdlib subsystems:

DependencyWhat it provides
core.net.tcpTransport
core.asyncConcurrency
core.sync.{rwlock, semaphore}Pool primitives

Zero new low-level primitives — every protocol concern delegates to the canonical subsystem.

Layout​

FileWhat's in it
protocol.vrRESP2 / RESP3 framing (RespValue, RespError, encode, decode)
client.vrRedisClient, RedisConfig, RedisError, connect, connect_url, exec, exec_bytes, exec_pipeline
commands.vrTyped wrappers over the common command set
pubsub.vrPubSubMessage, publish + subscriber loops
transaction.vrTxResult, MULTI/EXEC scripting
stream.vrStreamEntry, xadd / xrange / xread / xreadgroup / xack / xgroup_create
script.vrscript_load, eval, evalsha, script_exists, script_flush

RESP protocol model​

// RESP3, not RESP2: sixteen arms, and nullity is its own variant
// rather than a `Maybe` inside the payload.
public type RespValue is
| SimpleString(Text) // "+OK"
| Error_(Text) // "-ERR msg" (trailing _)
| Integer(Int) // ":42"
| BulkString(List<Byte>) // "$N\r\n..."
| NullBulk // "$-1"
| NullArray // "*-1"
| Array(List<RespValue>) // "*N\r\n..."
| Boolean(Bool) // "#t" / "#f"
| Double(Float) // ","
| BigNumber(Text) // "("
| BulkError { code: Text, message: Text }
| VerbatimString { format: Text, content: Text }
| MapValue(List<(RespValue, RespValue)>)
| SetValue(List<RespValue>)
| Push(List<RespValue>) // out-of-band, pub/sub
| Nil_;

public type RespError is
| UnexpectedEof
| InvalidPrefix(Byte) // carries the offending byte
| InvalidUtf8(Text)
| InvalidInteger(Text)
| InvalidBulkLength(Int)
| InvalidArrayLength(Int)
| TooDeep { depth: Int, limit: Int }
| TooLarge { size: Int, limit: Int };

Two names carry a trailing underscore — Error_ and Nil_ — because the bare forms collide with the prelude.

Nullity is a variant, not a Maybe in the payload: NullBulk and NullArray are distinct from an empty BulkString or Array, which is a distinction Redis makes and Maybe<List<Byte>> would flatten.

Every parse error names what it saw. InvalidPrefix carries the byte, TooDeep and TooLarge carry both the value and the limit — so a caller reports the failure without re-reading the frame. There is no InvalidFrame catch-all and no IntegerOverflow.

The decoder is binary-safe: BulkString holds List<Byte> and preserves non-UTF8 payloads byte-for-byte. UTF-8 conversion happens at the upper-layer command boundary.

RedisClient​

public type RedisConfig is {
address: Text, // "host:port", one field
username: Maybe<Text>,
password: Maybe<Text>,
db: Int { >= 0, <= 15 },
pool_size: Int { >= 1, <= 1024 },
command_timeout_ms: Int { >= 1 }, // one timeout, not read+write
connect_timeout_ms: Int { >= 1 },
tls: Bool,
cluster_mode: Bool,
max_redirects: Int { >= 1, <= 32 },
client_name: Maybe<Text>,
};

public fn connect(config: &RedisConfig) -> Result<RedisClient, RedisError>;
public fn connect_url(url: &Text) -> Result<RedisClient, RedisError>;
public fn redis_config_default() -> RedisConfig;

host + port is one address field, and the read/write timeout pair is one command_timeout_ms — a Redis command is a round trip, so splitting the two invites a configuration that cannot be honoured. Authentication (username / password / db / client_name) and tls are config, not connection-string trivia.

Cluster awareness is opt-in via cluster_mode. When enabled the client transparently follows MOVED and ASK redirects; there are no separate follow_moved / follow_asking switches, and the loop guard is max_redirects, refined to 1..=32 so a misconfiguration cannot ask for an unbounded chase.

Defaults from redis_config_default(): 127.0.0.1:6379, db 0, pool 16, both timeouts 5000 ms, no TLS, no cluster, 6 redirects.

Command execution surface​

FunctionPurpose
exec(client, args)UTF-8 args + UTF-8 reply path
exec_bytes(client, args)Binary-safe — args/replies as List<Byte>
exec_pipeline(client, batches)Batched send + batched receive in pool slot

The typed wrappers in commands.vr are the recommended entry point — they encapsulate argument formatting and reply parsing for the common command set (GET, SET, INCR, LPUSH, ZADD, etc.). Drop down to raw exec only when you need a command not yet wrapped or a non-standard reply shape.

Streams (event queue)​

public type StreamEntry is { id: Text, fields: List<(Text, List<Byte>)> };
functionparametersreturns
xaddclient, key, entryResult<Text, RedisError>
xrangeclient, key, start, endResult<List<StreamEntry>, RedisError>
xreadclient, keys, last_ids, count, block_msResult<Map<Text, List<StreamEntry>>, RedisError>
xreadgroupclient, group, consumer, keys, ids, count, block_msResult<Map<Text, List<StreamEntry>>, RedisError>
xackclient, key, group, idsResult<Int, RedisError>
xgroup_createclient, key, group, start_id, mkstreamResult<(), RedisError>

Stream semantics match Redis 5+: append-only log keyed per stream, consumer-group acknowledgement, optional MKSTREAM on first group create. The typed xread / xreadgroup block on the underlying socket via core.async so the call site can await without blocking the executor.

Pub/Sub​

// A sum, not a record: the stream carries subscribe/unsubscribe
// acknowledgements alongside the payloads, and a consumer must match
// on which it got.
public type PubSubMessage is
| Message { channel: Text, payload: List<Byte> }
| PMessage { pattern: Text, channel: Text, payload: List<Byte> }
| Subscribed { channel: Text, total_subscriptions: Int }
| Unsubscribed { channel: Text, total_subscriptions: Int }
| PSubscribed { pattern: Text, total_subscriptions: Int }
| PUnsubscribed { pattern: Text, total_subscriptions: Int };

pub async fn publish(
client: &RedisClient,
channel: &Text,
payload: &[Byte],
) -> Result<Int, RedisError>;

Subscription consumes from RedisClient via a dedicated connection (separate from the command pool — Redis pub/sub semantics require the connection stay in subscribe-mode until UNSUBSCRIBE).

Transactions​

MULTI / EXEC via transaction.run_simple, re-exported from core.redis under the name transaction_run — that alias is what a mount core.redis.{...} sees. It answers a TxResult. For optimistic concurrency (WATCH-based CAS), drop to raw exec and orchestrate manually — no typed wrapper exists.

Scripting (Lua)​

// All five are `async`.
pub async fn script_load(client, source) -> Result<Text, RedisError>;
pub async fn eval(client, source, keys, args) -> Result<RespValue, RedisError>;
pub async fn evalsha(client, sha1, keys, args) -> Result<RespValue, RedisError>;
pub async fn script_exists(client, sha: &Text) -> Result<Bool, RedisError>;
pub async fn script_flush(client) -> Result<(), RedisError>;

Standard SCRIPT LOAD / EVALSHA round-trip; the script SHA is returned by script_load and consumed by evalsha for the optimised fast path.

Status​

FileStatus
protocol.vrregression-only — RESP2 + RESP3 framing complete — core-tests/redis/protocol
client.vrundocumented — connection pool + cluster redirects — no conformance suite yet
commands.vrundocumented — common commands wrapped; long tail TBD — no conformance suite yet
pubsub.vrunverified — publish + subscriber loops — core-tests/redis/pubsub
transaction.vrunverified — MULTI/EXEC wrapped; WATCH-based CAS TBD — core-tests/redis/transaction
stream.vrunverified — full XADD/XREAD/XREADGROUP/XACK surface — core-tests/redis/stream
script.vrundocumented — full SCRIPT LOAD/EVAL/EVALSHA surface — no conformance suite yet

Integration with core.cache​

core.cache.adapters.redis is the abstract-cache adapter that re-exposes a subset of core.redis via the CacheBackend protocol. Same composition discipline as every other adapter — delegates to core.redis for protocol-level concerns, exposes only CacheBackend-shaped methods upward.