A Redis client for asyncio that lives on the event loop. Written in Rust, built for the request path of Poltergeist.
Every command costs one write(2), one read(2) and one Future. There is no
connection pool to lock, no background runtime and no cross-thread handoff:
commands are written to the socket as they are issued and replies are matched
to their waiters in order, which is how Redis itself works.
- The socket is registered with the asyncio loop. The client calls
loop.add_readeronce per connection and parses RESP straight out of the socket buffer when the loop calls back. - One Future per command, created with
loop.create_future()and resolved from inside the read callback with the converted value. - Concurrent commands share one connection. Redis is single-threaded and answers in order, so a single connection with in-flight commands is both the simplest and the fastest arrangement. More connections are available for very large values.
- Pipelines are one write.
pipeline()buffers the encoded commands andexecute()sends them, wrapped in MULTI/EXEC by default, as a single write with a single Future.
Measured against redis-py 8.1 on the same machine, 32 concurrent tasks, GET of a 64-byte value: 4.7 µs of CPU per command instead of 31 µs, and 150k instead of 30k commands per second from a single process.
import beerstream2
redis = beerstream2.Client(host="127.0.0.1", port=6379, database=0)
await redis.connect()
await redis.set("session:1", "abc", ex=3600)
value = await redis.get("session:1") # "abc" or None
await redis.set("lock", "1", nx=True, ex=30) # True or None
ranks = await redis.zrevrange("board", 0, 9) # list[str]
async with redis.pipeline(transaction=True) as pipeline:
pipeline.incr("rate:ip")
pipeline.expire("rate:ip", 60, nx=True)
count, _ = await pipeline.execute()
await redis.close()Every method returns an asyncio Future, so the calls are awaited like any coroutine. The client must be created and used from one event loop; uvloop and the default loop both work.
Replies are decoded to str, matching redis-py with decode_responses=True:
| Method | Returns |
|---|---|
get, getex(key, ex=) |
`str |
set(key, value, ex=, nx=) |
True, or None when nx did not set |
mget(keys) |
`list[str |
delete(*keys), exists(*keys), incr, sadd, srem, zadd(key, mapping), zrem, zcard, publish, dbsize |
int |
expire(key, seconds, nx=) |
bool |
smembers |
set[str] |
zrevrange(key, start, stop) |
list[str] |
zrevrank(key, member) |
`int |
ping |
True |
info |
dict with numbers parsed and keyspace lines as nested dicts |
execute_command(*args) |
the reply converted by type |
Values sent to the server may be str, int, float or bytes.
pipeline(transaction=True) returns a Pipeline with the same command
methods, which buffer instead of sending. execute() sends the buffer and
resolves with one result per command, in order. With transaction=True the
commands run inside MULTI/EXEC. An error reply to any command rejects the
whole execute() with ResponseError. The pipeline works as an async
context manager and empties itself on exit.
connect() opens the configured connections, sends AUTH and SELECT as
needed and waits for a PING. A connection the server drops fails every
command in flight with ConnectionLost and is reopened on the next command.
beerstream2.Error is the base. ResponseError is an error reply from the
server, ProtocolError an unparseable reply, ConnectionLost a dropped
socket and ClientClosed a call after close().
- RESP2 only; no pub/sub subscriptions, no blocking commands, no cluster.
- No TLS; the client is meant for a private network.
- Host names are resolved synchronously when a connection is opened.
uv venv && uvx maturin develop --release
cargo testWheels for Python 3.14 on manylinux x86_64 and aarch64 are built by the
GitHub workflow and attached to each v* release.