@kama/redis
The Redis and Valkey client for kama. It speaks RESP2 and RESP3 directly, in kama, over
std::net, as redis-rs, go-redis and Lettuce do. There is no hiredis and nothing to install. TLS comes from
@kama/tls.
Status: 0.1.0, a complete client for one server. A connection authenticates, picks its protocol and database, connects over TLS, and sends commands, pipelines, transactions and scripts with deadlines; replies read into kama types. A subscriber listens to channels, patterns and shard channels. A lost connection is made again by its next call, a subscriber's with every subscription, and nothing is sent twice. It is tested live on Redis 6.2–8.10 and Valkey 7.2–9.1. Cluster and Sentinel come next; see the roadmap. Needs kama ≥ 0.9.667.
Connecting
import { core::println, redis::Config, redis::Connection, redis::Command, redis::RedisError, redis::cmd,
redis::reply, redis::replyOpt };
fn int32 main() {
Result<Config, RedisError> parsed = Config.parse(url: "redis://127.0.0.1:6379/0?client_name=example");
Config config = match (give parsed) {
case Ok(value: c): give c;
case Err(error: e): { println(s: e.message()); return 1; }
};
Result<Connection, RedisError> opened = Connection.connect(config: config);
Connection conn = match (give opened) {
case Ok(value: c): give c;
case Err(error: e): { println(s: e.message()); return 1; }
};
// Each hole is one whole argument, spaces and line breaks included: never text spliced into a word.
string key = "greeting";
string value = "hello, world";
int32 ttl = 60;
Command set = cmd"SET ${key} ${value} EX ${ttl}";
Result<string, RedisError> stored = reply::<string>(result: conn.call(command: set));
Command get = cmd"GET ${key}";
Result<Optional<string>, RedisError> got = replyOpt::<string>(result: conn.call(command: get));
match (give got) {
case Ok(value: found): {
match (give found) {
case Some(value: text): { println(s: give text); }
case None: { println(s: "(no such key)"); }
};
}
case Err(error: e): { println(s: e.message()); return 1; }
};
conn.close();
return 0;
}
The handshake is one round trip.
HELLO 3carriesAUTHandSETNAME, andSELECTandCLIENT SETINFOgo in the same write. A proxy that refusesHELLOgets the RESP2 path on the same connection:AUTH,CLIENT SETNAMEandSELECTas needed, or aPINGwhen it needs none, so a connect always knows its link is open. A server with no room left (maxclients) refuses the connection as it accepts it, and the connect fails with that refusal, in the server's words.URL options. Every option is honoured, and any other is refused. docs/ROADMAP.md has what the other clients call each one, and why these defaults.
option value default protocol2or3RESP3, or RESP2 where the server refuses HELLOconnect_timeoutseconds, to the millisecond; 0is no limit5 command_timeoutseconds, to the millisecond; 0is no limit5 client_nameCLIENT SETNAME's namenone keepalivewhole seconds, as hiredis sets it; 0is off15 reconnectonoroffonreconnect_timeoutseconds, to the millisecond; 0is no limit5 Config.tcp,Config.unixand typed setters do the same without a URL.Errors are values. An error reply is
RedisError::Server. Its code (WRONGTYPE,NOAUTH,MOVED, …) is aServerCodeto match, beside the server's message, and the connection goes on. A failure of the connection itself (I/O, bytes that are not RESP, a call past its deadline) loses it, and the next call makes it again.Reconnecting never resends. The call that meets a lost connection fails with what happened, because its command may have run; retrying it is the caller's choice. The next call makes the connection again: every address, the TLS files read again, the handshake in the protocol it spoke, with the database and user
selectandauthenticatelast set, then aPING. While the server is down, restarting, full or still loading, that call keeps trying, with a backoff, for up toreconnect_timeout. If it gives up, its error isReconnect, which carries why, and nothing of the call was sent. A transaction whoseWATCHwas lost with the connection answersWatchConflictwithout being sent, sowatchedruns its body again.reconnect=offleaves a lost connection closed. A connection over your own transport (connectOver) has nothing to redial with, so it stays closed too.The connection belongs to one isolate at a time, and moves between them whole.
TLS
rediss:// (and valkeys://) connect over TLS, from @kama/tls, which
vendors Mbed TLS: still nothing to install.
import { core::println, redis::Config, redis::Connection, redis::RedisError, redis::cmd, redis::reply };
fn int32 main() {
// The server's certificate must chain to this CA and name 127.0.0.1; a client certificate goes with it.
string url = "rediss://127.0.0.1:6380?tls_ca=/etc/redis/ca.crt&tls_cert=/etc/redis/client.crt&tls_key=/etc/redis/client.key";
Result<Config, RedisError> parsed = Config.parse(url: url);
Config config = match (give parsed) {
case Ok(value: c): give c;
case Err(error: e): { println(s: e.message()); return 1; }
};
Result<Connection, RedisError> opened = Connection.connect(config: config);
Connection conn = match (give opened) {
case Ok(value: c): give c;
case Err(error: e): { println(s: e.message()); return 1; }
};
Result<string, RedisError> pong = reply::<string>(result: conn.call(command: cmd"PING"));
match (give pong) {
case Ok(value: p): { println(s: give p); }
case Err(error: e): { println(s: e.message()); return 1; }
};
conn.close();
return 0;
}
Verified by default. The certificate must chain to a trusted root (the system's bundle, unless
tls_canames others) and must name the server: its host name among the certificate's DNS names, or its IP address among its IP addresses. go-redis, redis-py, redis-rs and Lettuce verify the same; redis-cli checks only the chain. The host name is sent as SNI, and an IP address is not (RFC 6066).The TLS options, each refused on
redis://andunix://:option value default tls_verifyfull: the chain and the name;chain: the chain alone;none: nothingfulltls_caa PEM file of roots to trust instead of the system's the system's bundle ( SSL_CERT_FILEoverrides)tls_cert,tls_keya client certificate and its key, in PEM: both, or neither none tls_server_namethe name the certificate must carry, and the SNI sent the host Config.tlsand its setters do the same, and more: roots and an identity as PEM bytes, an encrypted key's password, and the TLS versions (1.2 and 1.3 by default, the servers' own).Files are read at each connect, never when the configuration is made, so a renewed certificate is read by the next connection.
A TLS failure is
RedisError::Tls, in redis-cli's words, carrying@kama/tls'sTlsError, whoseCertificate(flags, reasons)names each check that failed. It is final: the next address is not tried.connect_timeoutbounds the TLS handshake with the rest.Connection.tlsSession()says what was agreed.The wrong port. A server's plaintext port says nothing to a TLS client, so
rediss://there times out; its TLS port closes on a plaintext one, andredis://there is told to userediss://.Not offered: cipher choice (Mbed TLS's defaults), a CA directory, CRLs and session resumption, each refused as an unknown option.
Commands
cmd"…"is aCommand. Its literal text splits on whitespace into arguments, and each hole is a value that writes itself as an argument (RedisArg):type becomes int8toint64,isize,uint16touint64its decimal float32,float64the shortest decimal that reads back the same; inf,-inf; a NaN is refusedstringits bytes DynamicArray<uint8>one binary argument, even when empty DynamicArray<T>of any of theseone argument per element, so an empty list is none ( DEL ${keys})Uuidits text Anything else is refused where it is written: a
bool, acharor anOptionalhas no one meaning on the wire. A literal that touches a hole (user:${id}), puts two holes side by side, or quotes a word is refused when the command is sent, before anything is written.Commandbuilds one by hand:Command.of(name:), thenarg(text:),argBytes(bytes:),argInt(value:),argFloat(value:)andadd(value:)for anyRedisArg.Commands a connection does not carry are refused, by name, before anything is written: the pub/sub commands,
MONITOR,SYNC,PSYNC,CLIENT REPLY,SCRIPT DEBUG,HELLO,RESET,QUIT, andMULTI,EXECandDISCARD, sincetransactsends a transaction whole.What sets what a connection is goes through the connection, because a reconnect would quietly undo a command that set it.
conn.select(db: 3)andconn.authenticate(user: Optional::Some(value: "app"), password: pw)sendSELECTandAUTH, and every reconnect keeps what they set.SELECTandAUTHthemselves are refused, and so areCLIENT SETNAMEandSETINFO(theConfig'sclient_nameand identity, sent by the handshake),CLIENT NO-EVICT,NO-TOUCH,CAPAandIMPORT-SOURCE,READONLYandREADWRITE, each saying what to do instead. The table is checked against every server'sACL CAT connection.Blocking commands wait for their own timeouts.
BLPOP k 5waits 5 s pluscommand_timeout, and a timeout of 0 has no deadline. The table covers every command each server calls blocking, and is checked against theirCOMMAND DOCS:BLPOP,BRPOP,BZPOPMIN,BZPOPMAX,BRPOPLPUSH,BLMOVE,BLMOVEM,BLMPOP,BZMPOP,XREADandXREADGROUP'sBLOCK,WAITandWAITAOF.
Replies, pipelines, transactions, scripts and SCAN
import { core::println, std::collections::DynamicArray, redis::Config, redis::Connection, redis::Command,
redis::Pipeline, redis::Replies, redis::RedisError, redis::TxOutcome, redis::Script, redis::ScriptCall,
redis::Scan, redis::cmd, redis::reply, redis::replyRecordOpt };
// A struct reads by its fields' names: HGETALL's map under RESP3, and its flat list under RESP2.
@generate(Deserializable)
type resource Account {
@field string owner;
@field int64 balance;
public const fn string describe() { int64 b = this.balance; return copy this.owner.concat(other: ": ${b}"); }
}
// BLPOP's [key, value] reads by position, as a record.
@generate(Deserializable)
type resource Popped {
@field string list;
@field string item;
public const fn string describe() { return copy this.list.concat(other: " gave ").concat(other: this.item); }
}
fn int32 main() {
Result<Config, RedisError> parsed = Config.parse(url: "redis://127.0.0.1:6379");
Config config = match (give parsed) {
case Ok(value: c): give c;
case Err(error: e): { println(s: e.message()); return 1; }
};
Result<Connection, RedisError> opened = Connection.connect(config: config);
Connection conn = match (give opened) {
case Ok(value: c): give c;
case Err(error: e): { println(s: e.message()); return 1; }
};
string account = "account:42";
string owner = "ada";
int64 balance = 100;
// A pipeline: every command in one write, every reply in order.
Pipeline setup = Pipeline.make();
Command clear = cmd"DEL ${account} jobs";
Command fill = cmd"HSET ${account} owner ${owner} balance ${balance}";
Command queue = cmd"RPUSH jobs first";
setup.add(command: clear);
setup.add(command: fill);
setup.add(command: queue);
Result<Replies, RedisError> ran = conn.run(pipeline: setup);
match (ran) { case Ok(value: r): { } case Err(error: e): { println(s: e.message()); return 1; } };
// Typed replies: a struct from a hash.
Command all = cmd"HGETALL ${account}";
Result<Account, RedisError> read = reply::<Account>(result: conn.call(command: all));
match (read) {
case Ok(value: a): { println(s: a.describe()); }
case Err(error: e): { println(s: e.message()); return 1; }
};
// A transaction: MULTI, the commands and EXEC in one write. A key changed under WATCH is WatchConflict.
int64 debit = -30;
Pipeline tx = Pipeline.make();
Command take = cmd"HINCRBY ${account} balance ${debit}";
Command log = cmd"RPUSH jobs debited";
tx.add(command: take);
tx.add(command: log);
Result<TxOutcome, RedisError> outcome = conn.transact(pipeline: tx);
match (give outcome) {
case Ok(value: o): {
match (give o) {
case Committed(replies: r): {
Replies replies = give r;
Result<int64, RedisError> left = reply::<int64>(result: replies.take(index: 0));
match (left) { case Ok(value: n): { println(s: "balance ${n}"); } case Err(error: e): { println(s: e.message()); } };
}
case WatchConflict: { println(s: "a watched key changed"); }
};
}
case Err(error: e): { println(s: e.message()); return 1; }
};
// A script: by its SHA-1, and by its source the first time a server lacks it.
Script bump = Script.of(source: "return redis.call('HINCRBY', KEYS[1], 'balance', ARGV[1])");
ScriptCall call = ScriptCall.of(script: bump);
int64 tip = 5;
call.addKey(value: account);
call.addArg(value: tip);
Result<int64, RedisError> bumped = reply::<int64>(result: conn.eval(call: call));
match (bumped) { case Ok(value: n): { println(s: "balance ${n}"); } case Err(error: e): { println(s: e.message()); return 1; } };
// A blocking pop: a record, or None when nothing came within its timeout.
Command pop = cmd"BLPOP jobs 1";
Result<Optional<Popped>, RedisError> popped = replyRecordOpt::<Popped>(result: conn.call(command: pop));
match (popped) {
case Ok(value: o): { match (o) { case Some(value: p): { println(s: p.describe()); } case None: { println(s: "nothing"); } }; }
case Err(error: e): { println(s: e.message()); return 1; }
};
// SCAN, a page at a time: each `next` is one round trip.
Scan scan = Scan.keys();
string pattern = "account:*";
scan.matching(pattern: pattern);
isize found = 0;
while (!scan.isDone()) {
Result<DynamicArray<string>, RedisError> page = reply::<DynamicArray<string>>(result: scan.next(conn: conn));
match (page) { case Ok(value: keys): { found = found + keys.length(); } case Err(error: e): { println(s: e.message()); return 1; } };
}
println(s: "${found} accounts");
conn.close();
return 0;
}
- Typed replies read any reply through
Deserializable, the same way under RESP2 and RESP3:reply::<T>,replyOpt::<T>(None for a null),replyOpts::<T>(MGET: each element may be null),replyRecord::<T>andreplyRecordOpt::<T>(a top-level array read by position);- integers in the server's own grammar, floats (
infandnanincluded), bools, strings (UTF-8, orDynamicArray<uint8>for any bytes), lists, sets, stdMap, and@generatestructs: by name from a map, by position from an array inside another reply (XRANGE's entries); - an error inside a reply (one of EXEC's) is
RedisError::Serverwith its code when it is read; anything that does not read isRedisError::Value, naming where:reply[3].score: a bulk string "x" cannot be read as float64.
- Pipelines (
Connection.run): one write, one deadline, and each reply stands alone (Replies.take). - Transactions (
Connection.transact, andwatchedto run one under WATCH as many times as you say): every reply is read before the outcome is decided. A command refused while queueing aborts it asServer; one that fails when EXEC runs it fails alone. - Scripts:
Connection.evalsends EVALSHA, and EVAL on NOSCRIPT;Script.readOnlyuses the_ROforms (7.0+);Pipeline.addScriptsends EVAL. - SCAN, SSCAN, HSCAN and ZSCAN a page at a time, with
matching,countHint,ofTypeandnoValues.
When the connection is lost
A connection is lost when the server restarts, kills it (CLIENT KILL, its idle timeout, a full output buffer),
or the network fails. The call that meets the loss fails with what happened, and nothing is ever sent again on its
own: a command that may have run is yours to retry or not. The next call makes the connection again, as the first was
made, then sends.
- Before each call writes, it reads whatever came while the connection sat idle. A close found there (the usual case after a kill or an idle timeout) means nothing was sent, so the call makes the connection again and sends, and never sees the loss. A close can still land between that read and the write, so this is usual, not certain.
- Making it again tries every address, reads the TLS files again (a renewed certificate is used), and runs the
handshake in the protocol the connection spoke, as the user and database that
authenticateandselectlast set, then aPING. While the server is down, restarting, full (maxclients) or still loading its data, the call keeps trying, with a backoff (100 ms doubling to 1 s, jittered), for up toreconnect_timeout(5 s). A wrong password, a refused certificate or bytes that are not RESP end it at once. - A transaction whose
WATCHwas lost with the connection answersWatchConflictand is not sent, sowatchedruns its body again on the new connection. reconnect=offleaves a lost connection closed (a pool that replaces its connections wants that), and so doesclose(). A connection over a transport you opened (connectOver) has nothing to make it again with.
Whether a failed call's command could have run:
| error | sent? | what it means |
|---|---|---|
Usage, Value, Config |
no | refused before anything was written; the connection goes on |
Reconnect |
no | the connection was lost earlier and could not be made again; cause() says why, and the next call tries again |
Closed |
no | closed by close(), or lost with nothing to make it again with |
Connect, Tls while connecting |
no | no connection was made |
Server |
yes | the server answered with an error; LOADING, BUSY, TRYAGAIN and MASTERDOWN mean it did not run it |
Io, Timeout, Protocol, Unexpected, Tls during a call |
maybe | the connection was lost with the command sent or half answered: it may have run |
A reliable queue shows one way to live with "maybe": BLMOVE into a processing list, so a job that may have been taken is found again.
Pub/sub
import { core::println, std::collections::DynamicArray, std::time::Duration, redis::Config, redis::Connection,
redis::Subscriber, redis::Delivery, redis::Message, redis::Command, redis::RedisError, redis::cmd,
redis::reply };
fn string describe(const ref Message m) {
Result<string, RedisError> channel = m.channelText();
Result<string, RedisError> payload = m.text();
return match (channel) {
case Ok(value: c): match (payload) { case Ok(value: p): c.concat(other: ": ").concat(other: p); case Err(error: e): e.message(); };
case Err(error: e): e.message();
};
}
fn int32 main() {
Result<Config, RedisError> parsed = Config.parse(url: "redis://127.0.0.1:6379");
Config config = match (give parsed) {
case Ok(value: c): give c;
case Err(error: e): { println(s: e.message()); return 1; }
};
// A subscriber owns a connection of its own, under RESP3 and RESP2 alike.
Result<Subscriber, RedisError> made = Subscriber.connect(config: config);
Subscriber sub = match (give made) {
case Ok(value: s): give s;
case Err(error: e): { println(s: e.message()); return 1; }
};
// Each call returns once the server has confirmed every name.
DynamicArray<string> channels = DynamicArray.empty();
channels.add(item: "news");
channels.add(item: "alerts");
Result<Unit, RedisError> subscribed = sub.subscribe(names: channels);
match (subscribed) { case Ok(value: u): { } case Err(error: e): { println(s: e.message()); return 1; } };
Result<Unit, RedisError> patterned = sub.psubscribe(names: "log:*");
match (patterned) { case Ok(value: u): { } case Err(error: e): { println(s: e.message()); return 1; } };
// Publishing is an ordinary command, on any other connection. It answers how many subscribers got the message.
Result<Connection, RedisError> opened = Connection.connect(config: config);
Connection conn = match (give opened) {
case Ok(value: c): give c;
case Err(error: e): { println(s: e.message()); return 1; }
};
Command publish = cmd"PUBLISH news hello";
Result<int64, RedisError> count = reply::<int64>(result: conn.call(command: publish));
match (count) { case Ok(value: n): { println(s: "${n} subscriber got it"); } case Err(error: e): { println(s: e.message()); return 1; } };
// Wait up to a second for the next message. None means the time passed; the subscriber goes on.
Optional<Duration> second = Optional::Some(value: Duration.fromSecs(s: 1i64));
Result<Optional<Delivery>, RedisError> next = sub.next(timeout: second);
match (give next) {
case Ok(value: o): {
match (give o) {
case Some(value: d): {
match (give d) {
case Message(message: m): { println(s: describe(m: m)); }
case Unsubscribed(kind: k, channel: c): { println(s: "the server ended a subscription"); }
case Reconnected(cause: e): { println(s: "reconnected, and messages may have been missed"); }
};
}
case None: { println(s: "nothing within a second"); }
};
}
case Err(error: e): { println(s: e.message()); return 1; }
};
// Unsubscribe from everything and take the connection back, for commands.
Result<Connection, RedisError> released = sub.release();
Connection again = match (give released) {
case Ok(value: c): give c;
case Err(error: e): { println(s: e.message()); return 1; }
};
Command ping = cmd"PING";
Result<string, RedisError> pong = reply::<string>(result: again.call(command: ping));
match (give pong) { case Ok(value: p): { println(s: give p); } case Err(error: e): { println(s: e.message()); return 1; } };
conn.close();
again.close();
return 0;
}
- A
Subscriberowns its connection, as every peer client gives pub/sub a connection of its own: waiting for messages blocks it, and under RESP2 a subscribed connection takes no other command.Subscriber.overtakes aConnectionyou already have, andrelease()gives it back;Connection.callrefuses the (un)subscribe commands. subscribe,psubscribeandssubscribe, and the three unsubscribes, take one name or a list (strings, orDynamicArray<uint8>for bytes). Each returns once the server has confirmed every name. An unsubscribe given an empty list unsubscribes from every name of its kind. An error in place of the confirmations (NOPERM, or in a cluster CROSSSLOT or MOVED) isRedisError::Server, and the subscriber goes on.next(timeout:)gives the messages: those that arrived while a call waited, then the next to arrive. None waits as long as it takes, and zero takes only what has already arrived. AMessagehas its kind, channel, pattern and payload as bytes, withchannelText(),patternText()andtext()refusing bytes that are not UTF-8. A shard channel the server unsubscribes on its own (its slot moved) comes asDelivery::Unsubscribed.- Delivery is at most once, as the server's own is. Unread messages wait in the socket and the server's buffer,
and the server disconnects a subscriber that falls too far behind (
client-output-buffer-limit pubsub). - A lost subscriber is made again, by
nextitself (nothing of it was sent), or by the next subscribe, unsubscribe or ping. Every channel, pattern and shard channel is subscribed again (shard channels a slot at a time), andReconnected(cause)comes first, after what was already queued: messages published meanwhile were missed, and you are told so. A name the server refuses then (NOPERMafter an ACL change,MOVED) comes asUnsubscribed, and an unsubscribe that failed with the connection is never undone. TCP keepalive (on, 15 s) finds a peer that went away silently; over your own dialer, or withkeepalive=0,ping()is the check. - Sharded pub/sub (
SSUBSCRIBE,SPUBLISH) needs Redis 7.0 or Valkey 7.2. Routing a shard channel to the node that serves its slot comes with cluster support (0.2.0). - Pushes that are not pub/sub (an invalidation another connection's
CLIENT TRACKING … REDIRECTsends) are set aside on the connection:Connection.takePushes()hands them over, at most 1024 and withinmaxReplyBytes, anddroppedPushes()counts any past that.CLIENT TRACKINGandCLIENT CACHINGare refused until client-side caching comes (0.3.0).
Underneath
redis::resp, the protocol as data. A proxy, a fake server or a test harness can use it on its own.Readertakes bytes in any pieces and returns each value when it is whole.Writerwrites every wire form: RESP2's nulls, and RESP3's streamed strings and aggregates.Valueis every RESP2 and RESP3 type, attributes included.The reader matches hiredis, the reader redis-cli uses, on 593 inputs: hiredis's own tests, every example in the RESP specification, and what Redis 6.2–8.10 and Valkey 7.2–9.1 actually send.
Nothing from the server is trusted. These limits are configurable (
resp::Limits):Limit Default One string 512 MiB One aggregate 2³²−1 elements Nesting 1024 deep One reply 1 GiB A malformed reply is an error, never a crash, and memory grows only with bytes that actually arrived.
redis::RedisUrl:redis://[[user]:password@][host][:port][/db][?key=value&…] also valkey:// rediss://… TLS; also valkeys:// unix://[[user]:password@]/path/to/socket[?db=N&key=value&…]redis://URLs mean what they mean toredis-cli -u, andvalkey://URLs what they mean tovalkey-cli. Both CLIs were asked live, URL by URL.- Where a CLI would quietly misread a URL (a port of
6379x, an@in a password that is not written as%40), this package refuses it and says how to write it. unix://is the form redis-py and go-redis document.- A password in a URL can end up in logs and process listings, so prefer giving it separately where you can.
redis::cluster::keySlot, a key's hash slot with hash tags. It is checked against the cluster specification and againstCLUSTER KEYSLOTon every server.redis::Connection,ConfigandRedisError, as above:- TCP, TLS (
redis::secure, internal, over any transport) and Unix-domain sockets, non-blocking, with connect and command deadlines and TCP keepalive; - ACL users and passwords, the password alone of a server's default user, the client's name and the database;
- RESP3 by default, and RESP2 for proxies such as twemproxy and Envoy;
server(), the server's own account of itself fromHELLO.
- TCP, TLS (
Examples
Each is a program of its own under examples/, built by tools/test.sh and run against every
server in the matrix by tools/check-examples.sh. Run one with REDIS_URL=redis://127.0.0.1:6379 kama run examples/quickstart/kama.json.
quickstart: connect, store and read a value, count, close.typed_replies: a struct from a hash, a list, MGET's Optionals, a sorted set as a Map.transactions: a pipeline, and a transfer under WATCH that runs again from fresh reads.pubsub_worker: a subscriber that handles every delivery,Reconnectedincluded, fed from another isolate.job_queue: a reliable queue with BLMOVE and a processing list, so that a call that failed after it was sent loses no job.tls_connection: TLS verified against a CA, and what the handshake settled.scan: keys a page at a time, with MATCH and COUNT.scripts: a rate limiter as a Lua script, by its SHA-1.
What comes next
On Redis 6.2 to 8.x and Valkey 7.2 to 9.x, tested on every one:
- 0.1.0, one server, complete: this release.
- 0.2.0, topologies: Cluster (slots,
MOVED/ASK, replica reads) and Sentinel. - 0.3.0, scale: a connection pool shared across isolates, and client-side caching.
Tests
KAMA=/path/to/kama tools/test.sh # hermetic unit tests, debug and release
KAMA=/path/to/kama tools/test-integration.sh # the live suite on every server, debug and release
tools/test-integration.sh --server valkey-9.1 # … on one
tools/redis.sh list # the servers in the matrix
tools/redis.sh up --server valkey-9.1 # start one (podman or docker); its ports go to out/redistest-valkey-9.1.env
tools/redis.sh smoke --server valkey-9.1 # one check per authentication case and transport, with the server's own redis-cli
tools/redis.sh down --server valkey-9.1
tools/check-readme.sh --server redis-8.10 # build this README's programs and run them against the server
tools/check-examples.sh --server redis-8.10 # build examples/ and run them against the server
tools/mutate.sh tools/mutations/7a-s5.json # each mutation of the source must make the case it targets fail
The unit tests need no server: a scripted server plays real servers' bytes to the connection, a byte at a time.
Their vectors come from upstream by script: tools/gen-resp-vectors.sh, tools/gen-slot-vectors.sh,
tools/gen-url-vectors.sh and tools/gen-server-vectors.sh. Each pins its sources by SHA256 and asks the servers
and CLIs in throwaway containers.
Each test server is one container running two servers, configured from
tests/integration/server/:
- the main server, in plaintext, over TLS and on a Unix socket, with one ACL user per authentication case;
- a locked server, whose default user has a password.
Its test certificates are generated into out/ and never committed.
License
@kama/redis is licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option. It vendors nothing.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
@kama/redis by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any
additional terms or conditions.