kevy-client 1.14.0

Unified client for kevy — switch between in-process embedded and TCP server backends with a single URL.
Documentation

kevy-client

A URL-driven, blocking RESP client for kevy. The same code switches between an in-process backend and a remote TCP server by changing one URL string.

  • Pure Rust, zero crates.io runtime dependencies.
  • Backends: in-process (anonymous, named, or persistent), kevy server, any Redis-protocol server.
  • All five Redis data types, transactions with WATCH, scan iteration, and pub/sub with a borrowing iterator.
use kevy_client::Connection;

let mut conn = Connection::open("tcp://127.0.0.1:6379")?;
conn.set(b"hello", b"world")?;
assert_eq!(conn.get(b"hello")?, Some(b"world".to_vec()));
# Ok::<(), std::io::Error>(())

Install

cargo add kevy-client

URL backends

URL Backend
mem:// Anonymous in-process, per-open fresh; no shared bus.
mem://<name> Shared in-process bus keyed by <name> — two opens of the same name share one Store + pub/sub bus, even across threads.
file:///abs/path Shared in-process with snapshot + AOF persistence in path.
kevy://host:port TCP RESP server, kevy-native URL scheme.
redis://host:port TCP RESP server, standard Redis URL.
tcp://host:port TCP RESP, raw — no SELECT round-trip on connect.

redis://user:pass@host (AUTH) and rediss:// (TLS) are rejected up front — kevy ships without either. Front it with a TLS-terminating sidecar and an authentication proxy if you need them.

Quick start

Same code, two backends

use kevy_client::Connection;

fn cache_smoke(c: &mut Connection) -> std::io::Result<()> {
    c.set(b"hot", b"cached")?;
    assert_eq!(c.get(b"hot")?, Some(b"cached".to_vec()));
    Ok(())
}

let url = std::env::var("KEVY_URL")
    .unwrap_or_else(|_| "mem://app".into());
cache_smoke(&mut Connection::open(&url)?)?;
# Ok::<(), std::io::Error>(())

Set KEVY_URL=mem://app for dev, KEVY_URL=kevy://prod:6379 for production. No code change.

Transactions

use kevy_client::Connection;

# fn run() -> std::io::Result<()> {
let mut conn = Connection::open("tcp://127.0.0.1:6379")?;

let mut txn = conn.multi()?;
txn.set(b"a", b"1")?
    .incr(b"counter")?
    .get(b"a")?;
let mut r = txn.exec_typed()?;
r.next_ok()?;                                 // SET → +OK
let counter: i64 = r.next_int()?;             // INCR → :N
let prior: Option<Vec<u8>> = r.next_bulk()?;  // GET  → $... or nil
r.expect_empty()?;                            // arity check
# Ok(())
# }

For optimistic concurrency, watch a key before the transaction:

use kevy_client::Connection;

# fn run() -> std::io::Result<()> {
let mut conn = Connection::open("tcp://127.0.0.1:6379")?;

conn.watch(&[&b"counter"[..]])?;
let mut txn = conn.multi()?;
txn.incr(b"counter")?;
match txn.exec_watched()? {
    Some(replies) => { /* committed */ }
    None         => { /* watched key changed — retry the whole block */ }
}
# Ok(())
# }

Transactions on the in-process backends return ErrorKind::Unsupported — every Connection method already serialises on the embed mutex, so MULTI's locking guarantee is a no-op in-process.

Pub/sub

use kevy_client::{Connection, Subscriber, PubsubEvent};

# fn run() -> std::io::Result<()> {
let url = std::env::var("KEVY_URL")
    .unwrap_or_else(|_| "mem://news".into());

let mut sub = Subscriber::open(&url, &[&b"updates"[..]])?;
let mut pubconn = Connection::open(&url)?;

let _ack = sub.recv()?;                       // drain the SUBSCRIBE ack
pubconn.publish(b"updates", b"hello")?;

for event in sub.messages().take(1) {
    let (channel, payload) = event?;
    println!("{}: {}",
        String::from_utf8_lossy(&channel),
        String::from_utf8_lossy(&payload));
}
# Ok(())
# }

Pattern subscriptions use psubscribe(&[&b"news.*"[..]]).

Subscriber::messages() and Subscriber::events() are borrowing iterators. The messages() form auto-skips the (p)?(un)?subscribe ack frames and yields (channel, payload) tuples directly. Both iterators terminate on UnexpectedEof; transient errors surface as Some(Err(_)) so callers decide whether to keep going.

Anonymous mem:// (no name) is rejected by Subscriber::open — no other producer can reach it. Use mem://<some-name> for a shared bus.

Cluster-aware routing

ClusterClient discovers the topology of a cluster-mode kevy server and routes each key straight to the owning shard, eliminating the cross-shard forwarding hop:

use kevy_client::ClusterClient;

let mut cc = ClusterClient::connect("127.0.0.1", 6380)?;  // any shard port as seed
cc.set(b"user:42", b"alice")?;                             // routed by CRC16
let v = cc.get(b"user:42")?;
let removed = cc.del(&[&b"a"[..], &b"b"[..], &b"c"[..]])?; // multi-key may span shards
# Ok::<(), std::io::Error>(())

Full cluster-mode guide: docs/cluster.md.

Drop down to the raw backend

use kevy_client::Connection;

# fn handle(conn: &mut Connection) -> std::io::Result<()> {
match conn {
    Connection::Embedded(s) => {
        // call any kevy_embedded::Store method
    }
    Connection::Remote(c)   => {
        // call c.request(&[...]) directly
    }
}
# Ok(())
# }

Wrap coverage matrix (kevy server 3.17 × kevy-client 1.14)

Status legend — wrapped: typed Connection methods; partial: the listed subset is wrapped, the rest is raw-only; raw-only: no typed wrap, reachable through the argv passthroughs (Connection::pipeline, Transaction::queue, idx_*_raw, or Connection::Remote(c) => c.request(...)); n/a: deliberately out of scope for this client.

Server op family (docs/verb-reference.md) Status Notes
connection (PING/ECHO/SELECT/HELLO…) partial ping; SELECT via URL /db; RESP3 HELLO n/a (client speaks RESP2)
server (INFO/CONFIG/DEBUG/COMMAND…) raw-only admin surface — use kevy-cli or the passthroughs
replication (REPLICAOF/WAIT/REPL.*) raw-only topology tooling, not a data-path client concern
scan (SCAN/KEYS/RANDOMKEY) wrapped scan, keys, randomkey
generic (DEL/EXPIRE/TTL/TYPE/RENAME/COPY…) partial del/exists/expire/persist/ttl_ms/type_of/dbsize/flushall; RENAME/COPY/TOUCH/GETEX… raw-only
tx (MULTI/EXEC/DISCARD/WATCH/UNWATCH) wrapped multiTransaction (typed builders + typed reply cursor), watch, unwatch; remote-only
pipeline (client-side batch) wrapped `pipeline(
pubsub (SUBSCRIBE/PUBLISH/PSUBSCRIBE…) wrapped publish + Subscriber (recv, messages(), patterns)
script (EVAL/EVALSHA…) raw-only
string (SET/GET/INCR/MSET/bitmaps…) partial set/set_with_ttl/get/incr/incr_by/mget/mset; APPEND/GETRANGE/SETNX/bitmaps raw-only
hash (HSET/HGET/HGETALL…) partial hset/hget/hdel/hlen/hgetall/hkeys/hvals; HINCRBY/HSETNX/HSCAN… raw-only
hash field-TTL (HEXPIRE/HPEXPIRE/HPERSIST/HTTL) wrapped v1.14: per-field codes in request order; HPEXPIREAT raw-only
list (LPUSH/LRANGE/LREM…) partial lpush/rpush/lpop/rpop/llen/lrange; LINSERT/LSET/LTRIM… raw-only
blocking (BLPOP/BRPOP/BZPOPMIN/BRPOPLPUSH) partial v1.14: blpop/brpop/bzpopmin (both backends really block); BRPOPLPUSH raw-only
set (SADD/SMEMBERS/SINTER…) partial sadd/srem/smembers/scard/sismember/sinter/sunion/sdiff; SPOP/SRANDMEMBER/S*STORE raw-only
zset (ZADD/ZRANGE/ZSCORE…) partial zadd/zrem/zscore/zcard/zrange; ZCOUNT/ZRANK/ZINCRBY/ZSCAN… raw-only
zset algebra (Z*STORE/ZINTERCARD) partial v1.14: zinterstore/zunionstore (+_with WEIGHTS/AGGREGATE)/zintercard; ZDIFFSTORE raw-only
stream (XADD/XREAD/XREADGROUP…) raw-only
geo (GEOADD/GEOSEARCH…) raw-only
index (IDX.CREATE/QUERY/DROP/LIST…) partial v1.14, remote-only: idx_create_range/idx_create_raw, idx_query_range/_eq/_match/_knn/_raw, idx_drop, idx_list; COUNT/VERIFY/EXPLAIN/REBUILD via idx_query_raw-style passthrough
view (VIEW.*) raw-only
feed / CDC (FEED.SHARDS/TAIL/READ) wrapped v1.14: feed_shards/feed_tail/feed_read — the network face of embedded changes_since; same FEEDRESYNC error text on both backends
migration (RESTORE/MIGRATE…) raw-only
cluster (CLUSTER SLOTS routing) wrapped ClusterClient (separate type, CRC16 slot routing)

The embedded backend serves every wrapped family except the remote-only rows (tx, pipeline, index); for those it answers ErrorKind::Unsupported and you drop down to Connection::Embedded's kevy_embedded::Store typed API.

API surface

Connection / generic: ping, dbsize, flush, type_of, exists, del, expire, persist, ttl_ms.

Strings: set, set_with_ttl, get, incr, incr_by.

Hashes: hset, hget, hdel, hlen, hgetall, hkeys, hvals.

Lists: lpush, rpush, lpop, rpop, llen, lrange.

Sets: sadd, srem, smembers, scard, sismember.

Sorted sets: zadd, zrem, zscore, zcard, zrange.

Multi-key: mget, mset, sinter, sunion, sdiff.

Blocking pops (v1.14): blpop, brpop, bzpopmin — real blocking on both backends; timeout: Option<Duration> (None = forever; the remote socket has no read timeout, so a blocking pop never races a client-side deadline).

Hash field-TTL (v1.14): hexpire, hpexpire, hpersist, httl — per-field result codes (HExpireCode) in request order, with the NX|XX|GT|LT condition as HExpireCond.

Zset algebra (v1.14): zinterstore, zunionstore (plus _with variants exposing WEIGHTS + AGGREGATE SUM|MIN|MAX), zintercard with optional LIMIT.

Declarative indexes (v1.14, remote-only): idx_create_range, idx_create_raw, idx_query_range / idx_query_eq (paged IdxPage), idx_query_match / idx_query_knn (ranked (key, score)), idx_query_raw, idx_drop, idx_list (IdxInfo).

Change feed / CDC (v1.14): feed_shards, feed_tail, feed_read (FeedBatch of offset-tagged argv frames, prefix filtering, FEEDRESYNC cursor-rebuild contract shared with the embedded changes_since).

Pipelining (v1.14, remote-only): pipeline(|p| p.cmd(...)) — one write, in-order replies, non-atomic.

Keyspace iteration: keys(pattern), scan(cursor, pattern, count), randomkey. In-process backends finish in one round (any non-zero cursor returns empty); the remote backend honours the server's real cursor.

Transactions (remote only): Connection::multi returns a Transaction. Queue commands via the typed builders (set, get, del, exists, incr, incr_by, mget, mset), commit with exec, exec_typed, exec_watched, or exec_watched_typed. The typed cursor (exec_typed) hands back a TransactionReplies with next_ok / next_int / next_bulk / next_array_of_bulks / expect_empty.

Pub/sub: Connection::publish for the producer side; Subscriber for the consumer side, with recv, recv_message, subscribe, unsubscribe, psubscribe, punsubscribe, and the messages() / events() iterators.

Cluster routing: ClusterClient::connect discovers topology and routes by CRC16 slot.

Async mirror

For applications already running on tokio, smol, or async-std, use kevy-client-async — the API surface mirrors kevy-client exactly, with .await on every call.

License

MIT OR Apache-2.0, at your option.