Redis API
Redis and Valkey compatible API
kvs speaks RESP2, the protocol Redis and Valkey clients use, so existing tooling connects
without a shim: redis-cli, redis-benchmark, go-redis, and any other RESP2 client.
All three protocols share one keyspace. A key written over RESP is readable over the HTTP and gRPC APIs and the other way round.
$ redis-cli -p 6379 set greeting hello
OK
$ curl http://localhost:3456/v1/keys/greeting
{"key":"greeting","value":"hello"}
Listening
The RESP listener defaults to 127.0.0.1:6379, unlike the HTTP and gRPC listeners which
bind every interface. Port 6379 is scanned continuously across the public internet and kvs
has no authentication unless you configure one, so exposing it is a deliberate step.
$ kvs serve # RESP on 127.0.0.1:6379
$ kvs serve --resp-addr :6379 # every interface
$ kvs serve --resp-addr none # RESP disabled
If something already holds the default port — a local Redis, say — kvs logs a warning, leaves RESP off, and serves HTTP and gRPC as usual. An address you name yourself is different: kvs refuses to start rather than quietly ignore it.
The server holds at most 10000 connections at once, the Redis maxclients default, and
answers ERR max number of clients reached past that. A client that connects and sends
nothing within 30 seconds is dropped; once it has sent a command it may idle indefinitely,
which is what a subscriber does.
Authentication
Set a password to require AUTH. There is deliberately no command line flag: an
argument is visible to anything that can list processes. Use the config file or the
environment instead.
$ KVS_RESP_PASSWORD=s3cret kvs serve --resp-addr :6379
$ redis-cli -p 6379 -a s3cret ping
PONG
# config.yaml
resp_addr: ":6379"
resp_password: "s3cret"
Containers
The image publishes 6379, but the loopback default still applies inside the container, so set the address when you publish the port:
docker run -p 6379:6379 -e KVS_RESP_ADDR=:6379 -e KVS_RESP_PASSWORD=s3cret \
ghcr.io/skyoo2003/kvs:latest-alpine
Supported commands
Connection and server
AUTH CLIENT (ID, GETNAME, SETNAME, SETINFO, INFO) COMMAND CONFIG GET
ECHO HELLO INFO PING QUIT RESET SELECT
Strings
APPEND DECR DECRBY GET GETDEL GETEX GETRANGE GETSET INCR INCRBY
INCRBYFLOAT MGET MSET MSETNX PSETEX SET SETEX SETNX SETRANGE STRLEN
SET accepts NX, XX, GET, KEEPTTL, EX, PX, EXAT, and PXAT.
Keys and expiry
COPY DBSIZE DEL EXISTS EXPIRE EXPIREAT FLUSHALL FLUSHDB KEYS PERSIST
PEXPIRE PEXPIREAT PTTL RANDOMKEY RENAME RENAMENX SCAN TTL TYPE UNLINK
Hashes
HDEL HEXISTS HGET HGETALL HINCRBY HINCRBYFLOAT HKEYS HLEN HMGET HSCAN
HSET HSETNX HVALS
Lists
LINDEX LLEN LPOP LPUSH LPUSHX LRANGE LREM LSET LTRIM RPOP RPUSH
RPUSHX
Sets
SADD SCARD SDIFF SINTER SISMEMBER SMEMBERS SPOP SRANDMEMBER SREM SSCAN
SUNION
Sorted sets
ZADD ZCARD ZCOUNT ZINCRBY ZMSCORE ZRANGE ZRANGEBYSCORE ZRANK ZREM
ZREVRANGE ZREVRANGEBYSCORE ZREVRANK ZSCAN ZSCORE
ZADD accepts NX, XX, CH, GT, and LT.
Transactions
DISCARD EXEC MULTI UNWATCH WATCH
WATCH tracks the keys you name, so a write elsewhere in the keyspace does not abort the
transaction. EXEC runs the whole batch under one lock and encodes its replies before
releasing it, so a slow client cannot stall other writers.
One transaction may queue up to 64 MiB of commands. A queue holds every command until EXEC
runs it, so the budget is what keeps one connection from queueing until the process runs out of
memory. Each argument is charged its bytes plus a fixed overhead, because a command carrying a
million empty arguments costs the server far more than its bytes say. A command over the budget
is refused and marks the transaction, so EXEC answers EXECABORT rather than applying part of
a batch and dropping the rest.
Publish and subscribe
PSUBSCRIBE PUBLISH PUNSUBSCRIBE SUBSCRIBE UNSUBSCRIBE
Scripting
EVAL EVALSHA SCRIPT LOAD SCRIPT EXISTS SCRIPT FLUSH
Scripts are Lua 5.1, and a script reaches the keyspace through redis.call and redis.pcall
with KEYS and ARGV bound the way Redis binds them. redis.error_reply,
redis.status_reply, and redis.sha1hex are there too, and the table answers to server as
well, the name Valkey gives it. redis.log is accepted and discarded, since kvs has no script
log to write to and a script that calls it wants to keep running rather than fail.
$ redis-cli eval "return redis.call('INCRBY', KEYS[1], ARGV[1])" 1 counter 5
(integer) 5
EVALSHA answers NOSCRIPT for a digest the cache does not hold, which is the reply every
client library keys its fallback on: it sends the digest first and resends the script only
after seeing that code. EVAL caches on the way through, so that fallback needs no
SCRIPT LOAD of its own.
Cluster
KVS.JOIN <node-id> <raft-addr> asks the node it is sent to, which has to be the leader, to
admit another node as a voting member. kvs serve --join sends it for you; it is on the RESP
listener rather than a port of its own so that joining authenticates the same way everything
else does.
Behaviour worth knowing
These are the places kvs answers correctly but not identically to Redis.
One keyspace. SELECT accepts only index 0, and FLUSHDB and FLUSHALL do the same
thing.
RESP2 only. HELLO 3 answers NOPROTO, which every client reads as a signal to fall
back. go-redis asks for RESP3 by default and downgrades on its own.
SCAN cursors are opaque handles, not offsets. The keyspace walk is paged and honours
COUNT, and a cursor identifies the last key a call reached rather than a position, so
deleting keys the walk has already passed cannot make it skip one. The handle lives on the
server rather than on the connection, because client libraries pool connections: the SCAN that
opens an iteration and the one that continues it routinely land on different sockets.
The server remembers 1024 unfinished iterations. Past that, opening a new one forgets the
handle that has sat still longest, so an abandoned walk is dropped before a live one, and a
forgotten cursor answers ERR invalid cursor. That is
deliberately an error rather than an empty final page: 0 with no keys is the protocol’s signal
that a walk is complete, so answering it there would tell a client it had enumerated a keyspace
it had barely started. A client that sees it should start the iteration again.
HSCAN, SSCAN, and ZSCAN page the same way over their own elements. ZSCAN walks a
sorted set by member name rather than by score, because a cursor has to resume in an order
that does not move when a score changes; the SCAN family promises no ordering, so nothing a
client is entitled to is lost.
Expiry is reclaimed by sampling. An expired key stops being visible immediately, and its memory is released either when a write touches that key or when a write’s sampling sweep reaches it. Each write inspects a bounded sample of the keys that carry an expiry, so a keyspace with no expiries pays nothing and an abandoned key does not linger.
A slow subscriber is dropped. Messages queue per connection under both a count and a memory budget, and a subscriber that exceeds either is disconnected rather than slowing the publisher down, which is how Redis bounds a client output buffer.
Publish/subscribe never leaves the node it happened on. Channels are not keyspace, so a
cluster does not replicate them: a PUBLISH on one node returns 0 while a subscriber sits on
another. Point publishers and subscribers at the same node.
In a cluster, only the leader takes writes. A write sent anywhere else is answered
MOVED 0 <leader> — Redis Cluster’s wording, with slot 0 always, because kvs does not shard —
so a client that already follows redirections needs no changes. During an election nobody is the
leader yet and the reply is CLUSTERDOWN instead. Reads are answered by any node and may be
behind the leader.
INFO says which node you reached. In a cluster the leader reports role:master and counts
the others as connected_slaves; a follower reports role:slave and names the leader in
master_host and master_port, so a client can find the node that takes writes without sending
one first. Outside a cluster it is role:master with no followers, as before. HELLO carries the
same role.
master_link_status is not reported: kvs does not track how far behind a follower is, and a field
invented to look complete would be worse than its absence.
redis_mode stays standalone, and cluster_enabled is 0, on a clustered node too. Those
fields name the protocol a client should speak, and kvs speaks the standalone one — Redis
Cluster’s mode promises hash slots and a CLUSTER command family that kvs does not have. A node
still answers MOVED, which a standalone Redis never does; the redirect is borrowed wording, not
a claim to be Redis Cluster.
CONFIG SET is refused rather than accepted and ignored. CONFIG GET answers for the
parameters clients probe on connect.
A script gives up after 5 seconds. A script holds the store’s write lock from its first
instruction to its last, which is what makes it atomic, so a loop that never ends would stop
every other client for the life of the process. Redis instead lets a script run forever and
offers SCRIPT KILL, which works only because it can still serve a second connection while
the first sits in the interpreter. A stopped script keeps whatever it had already written, the
way Redis keeps the writes a script made before it failed.
A script cannot reach outside the process. Only the base, table, string, math, and
cjson libraries are open, and dofile, loadfile, print, and require are removed along
with them; os, io, debug, and package are never opened. cmsgpack, bit, and struct
are absent, so a script needing one of those has to be rewritten or the work moved to the
client. redis.call also refuses the commands that make no sense inside a script: the
transaction and subscribe families, the scripting commands themselves, and the session
commands.
cjson.encode and cjson.decode follow cjson’s rules: a table whose keys are exactly 1 to n
encodes as an array and anything else, an empty table included, as an object; a JSON null
decodes to cjson.null rather than to nil, so it neither ends the array it sits in nor drops
the key it is under. A value with no JSON spelling, a function or a table holding itself, is an
error rather than a wrong answer. A string that is not valid UTF-8 is encoded with the
replacement character, where Redis passes the bytes through unchanged.
The script cache holds up to 16 MiB. Past that EVAL still runs the script and only skips
caching it, which costs the client the EVALSHA shortcut rather than the command, while
SCRIPT LOAD reports an error because caching is all it was asked to do. SCRIPT FLUSH
releases the budget.
Not implemented
Functions (FCALL), keyspace notifications, streams, blocking commands (BLPOP and friends),
ACLs beyond a single password, RESP3 push and attribute types, MONITOR, OBJECT, bit
operations, GEO, and HyperLogLog.
Redis’s replication and cluster commands are absent — REPLICAOF, SLAVEOF, WAIT, and
the CLUSTER family. kvs does replicate and does cluster, through Raft and its own KVS.JOIN,
but none of it is driven by Redis’s commands, and it does not shard, so there are no slots to
report. See Durability and Clustering.
RDB and AOF files are absent as formats. --data-dir keeps an append log of its own that
serves the same purpose, and there is no SAVE, BGSAVE, or BGREWRITEAOF to drive it: the log
is written as part of each write and compacted at startup.
SCRIPT KILL and the _RO script variants are absent too, as is the cmsgpack library inside
a script.
ZADD with INCR and the NX, XX, GT, and LT options on EXPIRE are also absent, as is
LIMIT on ZRANGEBYSCORE and ZREVRANGEBYSCORE. ZRANGE takes positions only, so its
BYSCORE, BYLEX, and REV forms are absent too; ZRANGEBYSCORE and ZREVRANGE cover them.
Anything unimplemented answers with an error, so a client discovers it rather than getting a
wrong result.